import { ListItem } from "./list"; import { parseDateString } from "./common"; declare var window: GlobalWindow; export enum QueryOperator { Greater = ">", Lower = "<", Equal = "=", } export function QueryOperatorToDateText(op: QueryOperator): string { switch (op) { case QueryOperator.Greater: return window.lang.strings("after"); case QueryOperator.Lower: return window.lang.strings("before"); default: return ""; } } export interface QueryType { name: string; description?: string; getter: string; bool: boolean; string: boolean; date: boolean; dependsOnElement?: string; // Format for querySelector show?: boolean; localOnly?: boolean; // Indicates can't be performed server-side. } export interface SearchConfiguration { filterArea?: HTMLElement; sortingByButton?: HTMLButtonElement; searchOptionsHeader?: HTMLElement; notFoundPanel?: HTMLElement; notFoundLocallyText?: HTMLElement; notFoundCallback?: (notFound: boolean) => void; filterList?: HTMLElement; clearSearchButtonSelector?: string; serverSearchButtonSelector?: string; search?: HTMLInputElement; queries: { [field: string]: QueryType }; setVisibility: (items: string[], visible: boolean, appendedItems: boolean) => void; onSearchCallback: (newItems: boolean, loadAll: boolean, callback?: (resp: PaginatedDTO) => void) => void; searchServer: (params: PaginatedReqDTO, newSearch: boolean, then?: () => void) => void; clearServerSearch: () => void; loadMore?: () => void; } export interface ServerSearchReqDTO extends PaginatedReqDTO { searchTerms: string[]; queries: QueryDTO[]; } export interface QueryDTO { class: "bool" | "string" | "date"; // QueryType.getter field: string; operator: QueryOperator; value: boolean | string | DateAttempt; } export abstract class Query { protected _subject: QueryType; protected _operator: QueryOperator; protected _card: HTMLElement; protected _id: string; public type: string; constructor(id: string, subject: QueryType | null, operator: QueryOperator) { this._id = id; this._subject = subject; this._operator = operator; if (subject != null) { this._card = document.createElement("span"); this._card.ariaLabel = window.lang.strings("clickToRemoveFilter"); } } set onclick(v: () => void) { this._card.addEventListener("click", v); } asElement(): HTMLElement { return this._card; } public abstract compare(subjectValue: any): boolean; asDTO(): QueryDTO | null { if (this.localOnly) return null; let out = {} as QueryDTO; out.field = this._subject.getter; out.operator = this._operator; return out; } get subject(): QueryType { return this._subject; } getValueFromItem(item: SearchableItem): any { return Object.getOwnPropertyDescriptor(Object.getPrototypeOf(item), this.subject.getter).get.call(item); } compareItem(item: SearchableItem): boolean { return this.compare(this.getValueFromItem(item)); } get localOnly(): boolean { return this._subject.localOnly ? true : false; } } export class BoolQuery extends Query { protected _value: boolean; constructor(id: string, subject: QueryType, value: boolean) { super(id, subject, QueryOperator.Equal); this.type = "bool"; this._value = value; this._card.classList.add("button", "@high", "center", "flex", "flex-row", "gap-2"); if (this._value) { this._card.classList.add("~positive"); } else { this._card.classList.add("~critical"); } this._card.innerHTML = ` ${subject.name} `; } public static paramsFromString(valueString: string): [boolean, boolean] { let isBool = false; let boolState = false; if (valueString == "true" || valueString == "yes" || valueString == "t" || valueString == "y") { isBool = true; boolState = true; } else if (valueString == "false" || valueString == "no" || valueString == "f" || valueString == "n") { isBool = true; boolState = false; } return [boolState, isBool]; } get value(): boolean { return this._value; } // Ripped from old code. Why it's like this, I don't know public compare(subjectBool: boolean): boolean { return (subjectBool && this._value) || (!subjectBool && !this._value); } asDTO(): QueryDTO | null { let out = super.asDTO(); if (out === null) return null; out.class = "bool"; out.value = this._value; return out; } } export class StringQuery extends Query { protected _value: string; constructor(id: string, subject: QueryType, value: string) { super(id, subject, QueryOperator.Equal); this.type = "string"; this._value = value.toLowerCase(); this._card.classList.add("button", "~neutral", "@low", "center", "flex", "flex-row", "gap-2"); this._card.innerHTML = ` ${subject.name}: "${this._value}" `; } get value(): string { return this._value; } public compare(subjectString: string): boolean { return subjectString.toLowerCase().includes(this._value); } asDTO(): QueryDTO | null { let out = super.asDTO(); if (out === null) return null; out.class = "string"; out.value = this._value; return out; } } const dateGetters: Map number> = (() => { let m = new Map number>(); m.set("year", Date.prototype.getFullYear); m.set("month", Date.prototype.getMonth); m.set("day", Date.prototype.getDate); m.set("hour", Date.prototype.getHours); m.set("minute", Date.prototype.getMinutes); return m; })(); const dateSetters: Map void> = (() => { let m = new Map void>(); m.set("year", Date.prototype.setFullYear); m.set("month", Date.prototype.setMonth); m.set("day", Date.prototype.setDate); m.set("hour", Date.prototype.setHours); m.set("minute", Date.prototype.setMinutes); return m; })(); export class DateQuery extends Query { protected _value: ParsedDate; constructor(id: string, subject: QueryType, operator: QueryOperator, value: ParsedDate) { super(id, subject, operator); this.type = "date"; this._value = value; this._card.classList.add("button", "~neutral", "@low", "center", "flex", "flex-row", "gap-2"); let dateText = QueryOperatorToDateText(operator); this._card.innerHTML = ` ${subject.name}: ${dateText != "" ? dateText + " " : ""}${value.text} `; } public static paramsFromString(valueString: string): [ParsedDate, QueryOperator, boolean] { let op = QueryOperator.Equal; if ((Object.values(QueryOperator) as string[]).includes(valueString.charAt(0))) { op = valueString.charAt(0) as QueryOperator; // Trim the operator from the string valueString = valueString.substring(1); } let out = parseDateString(valueString); let isValid = true; if (out.invalid) isValid = false; return [out, op, isValid]; } get value(): ParsedDate { return this._value; } public compare(subjectDate: Date): boolean { // We want to compare only the fields given in this._value, // so we copy subjectDate and apply on those fields from this._value. const temp = new Date(subjectDate.valueOf()); for (let [field] of dateGetters) { if (field in this._value.attempt) { dateSetters.get(field).call(temp, dateGetters.get(field).call(this._value.date)); } } if (this._operator == QueryOperator.Equal) { return subjectDate.getTime() == temp.getTime(); } else if (this._operator == QueryOperator.Lower) { return subjectDate < temp; } return subjectDate > temp; } asDTO(): QueryDTO | null { let out = super.asDTO(); if (out === null) return null; out.class = "date"; out.value = this._value.attempt; return out; } } export interface SearchableItem extends ListItem { matchesSearch: (query: string) => boolean; } export const SearchableItemDataAttribute = "data-search-item"; export type SearchableItems = Map; export class Search implements Navigatable { private _c: SearchConfiguration; private _sortField: string = ""; private _ascending: boolean = true; private _ordering: string[] = []; private _items: SearchableItems = new Map(); // Search queries (filters) private _queries: Query[] = []; // Plain-text search terms private _searchTerms: string[] = []; inSearch: boolean = false; private _inServerSearch: boolean = false; get inServerSearch(): boolean { return this._inServerSearch; } set inServerSearch(v: boolean) { const previous = this._inServerSearch; this._inServerSearch = v; if (!v && previous != v) { this._c.clearServerSearch(); } } // Intended to be set from the JS console, if true searches are timed. timeSearches: boolean = false; private _serverSearchButtons: HTMLElement[]; static tokenizeSearch = (query: string): string[] => { query = query.toLowerCase(); let words: string[] = []; let quoteSymbol = ``; let queryStart = -1; let lastQuote = -1; for (let i = 0; i < query.length; i++) { if (queryStart == -1 && query[i] != " " && query[i] != `"` && query[i] != `'`) { queryStart = i; } if ((query[i] == `"` || query[i] == `'`) && (quoteSymbol == `` || query[i] == quoteSymbol)) { if (lastQuote != -1) { lastQuote = -1; quoteSymbol = ``; } else { lastQuote = i; quoteSymbol = query[i]; } } if (query[i] == " " || i == query.length - 1) { if (lastQuote != -1) { continue; } else { let end = i + 1; if (query[i] == " ") { end = i; while (i + 1 < query.length && query[i + 1] == " ") { i += 1; } } words.push(query.substring(queryStart, end).replace(/['"]/g, "")); queryStart = -1; } } } return words; }; parseTokens = (tokens: string[]): [string[], Query[]] => { let queries: Query[] = []; let searchTerms: string[] = []; for (let word of tokens) { // 1. Normal search text, no filters or anything if (!word.includes(":")) { searchTerms.push(word); continue; } // 2. A filter query of some sort. const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":") + 1)]; if (!(split[0] in this._c.queries)) continue; const queryFormat = this._c.queries[split[0]]; let q: Query | null = null; if (queryFormat.bool) { let [boolState, isBool] = BoolQuery.paramsFromString(split[1]); if (isBool) { q = new BoolQuery(split[0], queryFormat, boolState); q.onclick = () => { for (let quote of [`"`, `'`, ``]) { this._c.search.value = this._c.search.value.replace( split[0] + ":" + quote + split[1] + quote, "", ); } this._c.search.oninput(null as Event); }; queries.push(q); continue; } } if (queryFormat.string) { q = new StringQuery(split[0], queryFormat, split[1]); q.onclick = () => { for (let quote of [`"`, `'`, ``]) { let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig"); this._c.search.value = this._c.search.value.replace(regex, ""); } this._c.search.oninput(null as Event); }; queries.push(q); continue; } if (queryFormat.date) { let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]); if (!isDate) continue; q = new DateQuery(split[0], queryFormat, op, parsedDate); q.onclick = () => { for (let quote of [`"`, `'`, ``]) { let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig"); this._c.search.value = this._c.search.value.replace(regex, ""); } this._c.search.oninput(null as Event); }; queries.push(q); continue; } // if (q != null) queries.push(q); } return [searchTerms, queries]; }; // Returns a list of identifiers (used as keys in items, values in ordering). searchParsed = (searchTerms: string[], queries: Query[]): string[] => { let result: string[] = [...this._ordering]; // If we didn't care about rendering the query cards, we could run this to (maybe) return early. // if (this.inServerSearch) { // let hasLocalOnlyQueries = false; // for (const q of queries) { // if (q.localOnly) { // hasLocalOnlyQueries = true; // break; // } // } // } // Normal searches can be evaluated by the server, so skip this if we've already ran one. if (!this.inServerSearch) { for (let term of searchTerms) { let cachedResult = [...result]; for (let id of cachedResult) { const u = this.items.get(id); if (!u.matchesSearch(term)) { result.splice(result.indexOf(id), 1); } } } } for (let q of queries) { this._c.filterArea?.appendChild(q.asElement()); // Skip if this query has already been performed by the server. if (this.inServerSearch && !q.localOnly) continue; let cachedResult = [...result]; if (q.type == "bool") { for (let id of cachedResult) { const u = this.items.get(id); // Remove from result if not matching query if (!q.compareItem(u)) { // console.log("not matching, result is", result); result.splice(result.indexOf(id), 1); } } } else if (q.type == "string") { for (let id of cachedResult) { const u = this.items.get(id); // We want to compare case-insensitively, so we get value, lower-case it then compare, // rather than doing both with compareItem. const value = q.getValueFromItem(u).toLowerCase(); if (!q.compare(value)) { result.splice(result.indexOf(id), 1); } } } else if (q.type == "date") { for (let id of cachedResult) { const u = this.items.get(id); // Getter here returns a unix timestamp rather than a date, so we can't use compareItem. const unixValue = q.getValueFromItem(u); if (unixValue == 0) { result.splice(result.indexOf(id), 1); continue; } let value = new Date(unixValue * 1000); if (!q.compare(value)) { result.splice(result.indexOf(id), 1); } } } } return result; }; // Returns a list of identifiers (used as keys in items, values in ordering). search = (query: string): string[] => { let timer = this.timeSearches ? performance.now() : null; if (this._c.filterArea) this._c.filterArea.textContent = ""; const [searchTerms, queries] = this.parseTokens(Search.tokenizeSearch(query)); let result = this.searchParsed(searchTerms, queries); this._queries = queries; this._searchTerms = searchTerms; if (this.timeSearches) { const totalTime = performance.now() - timer; console.debug(`Search took ${totalTime}ms`); } return result; }; // postServerSearch performs local-only queries after a server search if necessary. postServerSearch = () => { this.searchParsed(this._searchTerms, this._queries); }; showHideSearchOptionsHeader = () => { let sortingBy = false; if (this._c.sortingByButton) sortingBy = !this._c.sortingByButton.classList.contains("hidden"); const hasFilters = this._c.filterArea ? this._c.filterArea.textContent != "" : false; if (sortingBy || hasFilters) { this._c.searchOptionsHeader?.classList.remove("hidden"); } else { this._c.searchOptionsHeader?.classList.add("hidden"); } }; // -all- elements. get items(): Map { return this._items; } // set items(v: { [id: string]: SearchableItem }) { // this._items = v; // } // The order of -all- elements (even those hidden), by their identifier. get ordering(): string[] { return this._ordering; } // Specifically dis-allow setting ordering itself, so that setOrdering is used instead (for the field and ascending params). // set ordering(v: string[]) { this._ordering = v; } setOrdering = (v: string[], field: string, ascending: boolean) => { this._ordering = v; this._sortField = field; this._ascending = ascending; }; get sortField(): string { return this._sortField; } get ascending(): boolean { return this._ascending; } // FIXME: This is being called by navigate, and triggering a "load more" when we haven't loaded at all, and loading without a searchc when we have one! onSearchBoxChange = ( newItems: boolean = false, appendedItems: boolean = false, loadAll: boolean = false, callback?: (resp: PaginatedDTO) => void, ) => { const query = this._c.search.value; if (!query) { this.inSearch = false; } else { this.inSearch = true; } const results = this.search(query); this._c.setVisibility(results, true, appendedItems); this._c.onSearchCallback(newItems, loadAll, callback); if (this.inSearch) { if (this.inServerSearch) { this._serverSearchButtons.forEach((v: HTMLElement) => { v.classList.add("@low"); v.classList.remove("@high"); }); } else { this._serverSearchButtons.forEach((v: HTMLElement) => { v.classList.add("@high"); v.classList.remove("@low"); }); } } this.showHideSearchOptionsHeader(); this.setNotFoundPanelVisibility(results.length == 0); if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0); }; setNotFoundPanelVisibility = (visible: boolean) => { if (this._inServerSearch || !this.inSearch) { this._c.notFoundLocallyText?.classList.add("unfocused"); } else if (this.inSearch) { this._c.notFoundLocallyText?.classList.remove("unfocused"); } if (visible) { this._c.notFoundPanel?.classList.remove("unfocused"); } else { this._c.notFoundPanel?.classList.add("unfocused"); } }; fillInFilter = (name: string, value: string, offset?: number) => { this._c.search.value = name + ":" + value + " " + this._c.search.value; this._c.search.focus(); let newPos = name.length + 1 + value.length; if (typeof offset !== "undefined") newPos += offset; this._c.search.setSelectionRange(newPos, newPos); this._c.search.oninput(null as any); }; generateFilterList = () => { if (!this._c.filterList) return; const filterListContainer = document.createElement("div"); filterListContainer.classList.add("flex", "flex-row", "flex-wrap", "gap-2"); // Generate filter buttons for (let queryName of Object.keys(this._c.queries)) { const query = this._c.queries[queryName]; if ("show" in query && !query.show) continue; if ("dependsOnElement" in query && query.dependsOnElement) { const el = document.querySelector(query.dependsOnElement); if (el === null) continue; } const container = document.createElement("span") as HTMLSpanElement; container.classList.add( "button", "button-xl", "~neutral", "@low", "align-bottom", "flex", "flex-row", "gap-2", ); container.innerHTML = `
${query.name} ${query.description || ""}
`; if (query.bool) { const pos = document.createElement("button") as HTMLButtonElement; pos.type = "button"; pos.ariaLabel = `Filter by "${query.name}": True`; pos.classList.add("button", "~positive"); pos.innerHTML = ``; pos.addEventListener("click", () => this.fillInFilter(queryName, "true")); const neg = document.createElement("button") as HTMLButtonElement; neg.type = "button"; neg.ariaLabel = `Filter by "${query.name}": False`; neg.classList.add("button", "~critical"); neg.innerHTML = ``; neg.addEventListener("click", () => this.fillInFilter(queryName, "false")); container.appendChild(pos); container.appendChild(neg); } if (query.string) { const button = document.createElement("button") as HTMLButtonElement; button.type = "button"; button.classList.add("button", "~urge", "flex", "flex-row", "gap-2"); button.innerHTML = `${window.lang.strings("matchText")}`; // Position cursor between quotes button.addEventListener("click", () => this.fillInFilter(queryName, `""`, -1)); container.appendChild(button); } if (query.date) { const onDate = document.createElement("button") as HTMLButtonElement; onDate.type = "button"; onDate.classList.add("button", "~urge", "flex", "flex-row", "gap-2"); onDate.innerHTML = `On Date`; onDate.addEventListener("click", () => this.fillInFilter(queryName, `"="`, -1)); const beforeDate = document.createElement("button") as HTMLButtonElement; beforeDate.type = "button"; beforeDate.classList.add("button", "~urge", "flex", "flex-row", "gap-2"); beforeDate.innerHTML = `Before Date`; beforeDate.addEventListener("click", () => this.fillInFilter(queryName, `"<"`, -1)); const afterDate = document.createElement("button") as HTMLButtonElement; afterDate.type = "button"; afterDate.classList.add("button", "~urge", "flex", "flex-row", "gap-2"); afterDate.innerHTML = `After Date`; afterDate.addEventListener("click", () => this.fillInFilter(queryName, `">"`, -1)); container.appendChild(onDate); container.appendChild(beforeDate); container.appendChild(afterDate); } filterListContainer.appendChild(container); } this._c.filterList.appendChild(filterListContainer); }; onServerSearch = (then?: () => void) => { const newServerSearch = !this.inServerSearch; this.inServerSearch = true; // this.setQueryParam(); this.searchServer(newServerSearch, then); }; searchServer = (newServerSearch: boolean, then?: () => void) => { this._c.searchServer(this.serverSearchParams(this._searchTerms, this._queries), newServerSearch, then); }; serverSearchParams = (searchTerms: string[], queries: Query[]): PaginatedReqDTO => { let req: ServerSearchReqDTO = { searchTerms: searchTerms, queries: [], // queries.map((q: Query) => q.asDTO()) won't work as localOnly queries return null limit: -1, page: 0, sortByField: this.sortField, ascending: this.ascending, }; for (const q of queries) { const dto = q.asDTO(); if (dto !== null) req.queries.push(dto); } return req; }; private _qps: URLSearchParams = new URLSearchParams(); private _clearWithoutNavigate = false; // clearQueryParam removes the "search" query parameter --without-- triggering a navigate call. clearQueryParam = () => { if (!this._qps.has("search")) return; this._clearWithoutNavigate = true; this.setQueryParam(""); }; // setQueryParam sets the ?search query param to the current searchbox content, // or value if given. If everything is set up correctly, this should trigger a search when it is // set to a new value. setQueryParam = (value?: string) => { let triggerManually = false; if (value === undefined || value == null) value = this._c.search.value; const url = new URL(window.location.href); // FIXME: do better and make someone else clear this if (value.trim()) { url.searchParams.delete("user"); url.searchParams.set("search", value); } else { // If the query param is already blank, no change will mean no call to navigate() triggerManually = !url.searchParams.has("search"); url.searchParams.delete("search"); } console.log("pushing", url.toString()); window.history.pushState(null, "", url.toString()); if (triggerManually) this.navigate(); }; setServerSearchButtonsDisabled = (disabled: boolean) => { this._serverSearchButtons.forEach((v: HTMLButtonElement) => (v.disabled = disabled)); }; isURL = (url?: string) => { const urlParams = new URLSearchParams(url || window.location.search); const searchContent = urlParams.get("search"); return Boolean(searchContent); }; // navigate pulls the current "search" query param, puts it in the search box and searches it. navigate = (url?: string, then?: () => void) => { this._qps = new URLSearchParams(url || window.location.search); if (this._clearWithoutNavigate) { this._clearWithoutNavigate = false; return; } const searchContent = this._qps.get("search") || ""; this._c.search.value = searchContent; this.onSearchBoxChange(); this.onServerSearch(then); }; constructor(c: SearchConfiguration) { this._c = c; if (!this._c.search) { // Make a dummy one this._c.search = document.createElement("input") as HTMLInputElement; } this._c.search.oninput = () => { this.inServerSearch = false; this.clearQueryParam(); this.onSearchBoxChange(); }; this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => { if (ev.key == "Enter") { this.setQueryParam(); } }); if (this._c.clearSearchButtonSelector) { const clearSearchButtons = Array.from( document.querySelectorAll(this._c.clearSearchButtonSelector), ) as Array; for (let b of clearSearchButtons) { b.addEventListener("click", () => { this.inServerSearch = false; this.setQueryParam(""); }); } } this._serverSearchButtons = this._c.serverSearchButtonSelector ? (Array.from(document.querySelectorAll(this._c.serverSearchButtonSelector)) as Array) : []; for (let b of this._serverSearchButtons) { b.addEventListener("click", () => { this.setQueryParam(); }); } } }