const dateParser = require("any-date-parser"); export interface QueryType { name: string; description?: string; getter: string; bool: boolean; string: boolean; date: boolean; dependsOnElement?: string; // Format for querySelector show?: boolean; } export interface SearchConfiguration { filterArea: HTMLElement; sortingByButton: HTMLButtonElement; searchOptionsHeader: HTMLElement; notFoundPanel: HTMLElement; notFoundCallback?: (notFound: boolean) => void; filterList: HTMLElement; clearSearchButtonSelector: string; search: HTMLInputElement; queries: { [field: string]: QueryType }; setVisibility: (items: string[], visible: boolean) => void; onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => void; loadMore?: () => void; } export interface SearchableItem { matchesSearch: (query: string) => boolean; } export class Search { private _c: SearchConfiguration; private _ordering: string[] = []; private _items: { [id: string]: SearchableItem }; inSearch: boolean; search = (query: String): string[] => { this._c.filterArea.textContent = ""; query = query.toLowerCase(); let result: string[] = [...this._ordering]; 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, "")); console.log("pushed", words); queryStart = -1; } } } query = ""; for (let word of words) { if (!word.includes(":")) { let cachedResult = [...result]; for (let id of cachedResult) { const u = this._items[id]; if (!u.matchesSearch(word)) { result.splice(result.indexOf(id), 1); } } continue; } 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]]; if (queryFormat.bool) { let isBool = false; let boolState = false; if (split[1] == "true" || split[1] == "yes" || split[1] == "t" || split[1] == "y") { isBool = true; boolState = true; } else if (split[1] == "false" || split[1] == "no" || split[1] == "f" || split[1] == "n") { isBool = true; boolState = false; } if (isBool) { const filterCard = document.createElement("span"); filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter"); filterCard.classList.add("button", "~" + (boolState ? "positive" : "critical"), "@high", "center", "mx-2", "h-full"); filterCard.innerHTML = ` ${queryFormat.name} `; filterCard.addEventListener("click", () => { 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)); }) this._c.filterArea.appendChild(filterCard); // console.log("is bool, state", boolState); // So removing elements doesn't affect us let cachedResult = [...result]; for (let id of cachedResult) { const u = this._items[id]; const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u); // console.log("got", queryFormat.getter + ":", value); // Remove from result if not matching query if (!((value && boolState) || (!value && !boolState))) { // console.log("not matching, result is", result); result.splice(result.indexOf(id), 1); } } continue } } if (queryFormat.string) { const filterCard = document.createElement("span"); filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter"); filterCard.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full"); filterCard.innerHTML = ` ${queryFormat.name}: "${split[1]}" `; filterCard.addEventListener("click", () => { 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)); }) this._c.filterArea.appendChild(filterCard); let cachedResult = [...result]; for (let id of cachedResult) { const u = this._items[id]; const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u).toLowerCase(); if (!(value.includes(split[1]))) { result.splice(result.indexOf(id), 1); } } continue; } if (queryFormat.date) { // -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0 let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2)); let unmodifiedValue = split[1]; if (compareType != 2) { split[1] = split[1].substring(1); } if (compareType == 2) compareType = 0; let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]); // Month in Date objects is 0-based, so make our parsed date that way too if ("month" in attempt) attempt.month -= 1; let date: Date = (Date as any).fromString(split[1]) as Date; console.log("Read", attempt, "and", date); if ("invalid" in (date as any)) continue; const filterCard = document.createElement("span"); filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter"); filterCard.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full"); filterCard.innerHTML = ` ${queryFormat.name}: ${(compareType == 1) ? window.lang.strings("after")+" " : ((compareType == -1) ? window.lang.strings("before")+" " : "")}${split[1]} `; filterCard.addEventListener("click", () => { for (let quote of [`"`, `'`, ``]) { let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig"); this._c.search.value = this._c.search.value.replace(regex, ""); } this._c.search.oninput((null as Event)); }) this._c.filterArea.appendChild(filterCard); let cachedResult = [...result]; for (let id of cachedResult) { const u = this._items[id]; const unixValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u); if (unixValue == 0) { result.splice(result.indexOf(id), 1); continue; } let value = new Date(unixValue*1000); const getterPairs: [string, () => number][] = [["year", Date.prototype.getFullYear], ["month", Date.prototype.getMonth], ["day", Date.prototype.getDate], ["hour", Date.prototype.getHours], ["minute", Date.prototype.getMinutes]]; // When doing > or < with no date, we need to ignore the rest of the Date object if (compareType != 0 && Object.keys(attempt).length == 2 && "hour" in attempt && "minute" in attempt) { const temp = new Date(date.valueOf()); temp.setHours(value.getHours(), value.getMinutes()); value = temp; console.log("just hours/minutes workaround, value set to", value); } let match = true; if (compareType == 0) { for (let pair of getterPairs) { if (pair[0] in attempt) { if (compareType == 0 && attempt[pair[0]] != pair[1].call(value)) { match = false; break; } } } } else if (compareType == -1) { match = (value < date); } else if (compareType == 1) { match = (value > date); } if (!match) { result.splice(result.indexOf(id), 1); } } } } return result; } showHideSearchOptionsHeader = () => { const sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden")); const hasFilters = this._c.filterArea.textContent != ""; console.log("sortingBy", sortingBy, "hasFilters", hasFilters); if (sortingBy || hasFilters) { this._c.searchOptionsHeader.classList.remove("hidden"); } else { this._c.searchOptionsHeader.classList.add("hidden"); } } get items(): { [id: string]: SearchableItem } { return this._items; } set items(v: { [id: string]: SearchableItem }) { this._items = v; } get ordering(): string[] { return this._ordering; } set ordering(v: string[]) { this._ordering = v; } onSearchBoxChange = (newItems: boolean = false, loadAll: boolean = false) => { 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); this._c.onSearchCallback(results.length, newItems, loadAll); this.showHideSearchOptionsHeader(); if (results.length == 0) { this._c.notFoundPanel.classList.remove("unfocused"); } else { this._c.notFoundPanel.classList.add("unfocused"); } if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0); } 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 = () => { // 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", "mb-1", "mr-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", "ml-2"); 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", "ml-2"); 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", "ml-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", "ml-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", "ml-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", "ml-2"); afterDate.innerHTML = `After Date`; afterDate.addEventListener("click", () => this.fillInFilter(queryName, `">"`, -1)); container.appendChild(onDate); container.appendChild(beforeDate); container.appendChild(afterDate); } this._c.filterList.appendChild(container); } } constructor(c: SearchConfiguration) { this._c = c; this._c.search.oninput = () => this.onSearchBoxChange(); const clearSearchButtons = Array.from(document.querySelectorAll(this._c.clearSearchButtonSelector)) as Array; for (let b of clearSearchButtons) { b.addEventListener("click", () => { this._c.search.value = ""; this.onSearchBoxChange(); }); } } }