|
|
|
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 = `
|
|
|
|
<span class="font-bold mr-2">${queryFormat.name}</span>
|
|
|
|
<i class="text-2xl ri-${boolState? "checkbox" : "close"}-circle-fill"></i>
|
|
|
|
`;
|
|
|
|
|
|
|
|
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 = `
|
|
|
|
<span class="font-bold mr-2">${queryFormat.name}:</span> "${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 = `
|
|
|
|
<span class="font-bold mr-2">${queryFormat.name}:</span> ${(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 < <time> 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", "align-bottom");
|
|
|
|
container.innerHTML = `
|
|
|
|
<div class="flex flex-col mr-2">
|
|
|
|
<span>${query.name}</span>
|
|
|
|
<span class="support">${query.description || ""}</span>
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
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 = `<i class="ri-checkbox-circle-fill"></i>`;
|
|
|
|
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 = `<i class="ri-close-circle-fill"></i>`;
|
|
|
|
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 = `<i class="ri-equal-line mr-2"></i>${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 = `<i class="ri-calendar-check-line mr-2"></i>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 = `<i class="ri-calendar-check-line mr-2"></i>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 = `<i class="ri-calendar-check-line mr-2"></i>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<HTMLSpanElement>;
|
|
|
|
for (let b of clearSearchButtons) {
|
|
|
|
b.addEventListener("click", () => {
|
|
|
|
this._c.search.value = "";
|
|
|
|
this.onSearchBoxChange();
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|