summaryrefslogblamecommitdiffstatshomepage
path: root/frontend/src/lib/Filter.svelte.ts
blob: 8c0fa82c6a7f5869af4fc4239f97450a39ed108a (plain) (tree)
1
2
3
4
5
6
7
8
9








                                
                                        
                                            









                                                   
                                               
 
                                          
 
                                                             























                                            
                                       
               

                                              

















                                                                                      
                                                 
























                                                                                                
                                      


















                                                            
                                            

















                                                                 
                              










































































































                                                                                          



                              
 
                                              

                         
                                                                                                                


                              
          


                                                                        

                                       




                                                 



                                                                                     



                                                                    

                                     




                                                                                  



                                                                                   



                                                                    

                                     



                                                       

                                                                       



                                                                

                                   




                                             



                                                                                 


         
















                                                                                 
import {
	type ArchiveFilter,
	type ArchiveFilterInput,
	type ComicFilter,
	type ComicFilterInput,
	type StringFilter,
	type TagFilter,
	type TagFilterInput
} from '$gql/graphql';
import { navigate } from './Navigation';
import { numKeys, type Key } from './Utils';

interface FilterInput<T> {
	include?: T | null;
	exclude?: T | null;
}

interface BasicFilter {
	name?: { contains?: string | null } | null;
}

export type FilterType = 'include' | 'exclude';

type FilterMode = 'any' | 'all' | 'exact';

type Filter<T, K extends Key> = Partial<Record<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[] = $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<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)[] = $state([]);

	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[] = $state([]);

	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 = $state(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 = $state('');

	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<F>;
	exclude!: Controls<F>;
	includes = 0;
	excludes = 0;

	apply = (params: URLSearchParams) => {
		navigate(
			{
				filter: buildFilterInput(this.include.buildFilter(), this.exclude.buildFilter())
			},
			params
		);
	};
}

export class ArchiveFilterContext extends FilterContext<ArchiveFilter> {
	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<ComicFilter> {
	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<BasicFilter> {
	include: BasicFilterControls;
	exclude: BasicFilterControls;

	constructor(filter: FilterInput<BasicFilter>) {
		super();

		this.include = new BasicFilterControls(filter.include);
		this.exclude = new BasicFilterControls();
	}
}

export class TagFilterContext extends FilterContext<TagFilter> {
	include: TagFilterControls;
	exclude: TagFilterControls;
	private static ignore = ['name'];

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