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(subject: QueryType, operator: QueryOperator, value: ParsedDate) {
super(subject, operator);
this._value = value;
this._card.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
let dateText = QueryOperatorToDateText(operator);
this._card.innerHTML = `
${subject.name}: ${dateText != "" ? dateText+" " : ""}${value.text}
`;
}
public static paramsFromString(valueString: string): [ParsedDate, QueryOperator, boolean] {
// FIXME: Validate this!
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: ParsedDate = {
text: valueString,
// Used just to tell use what fields the user passed.
attempt: dateParser.attempt(valueString),
// note Date.fromString is also provided by dateParser.
date: (Date as any).fromString(valueString) as Date
};
// Month in Date objects is 0-based, so make our parsed date that way too
if ("month" in out.attempt) out.attempt.month -= 1;
let isValid = true;
if ("invalid" in (out.date as any)) { 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 {
let out = super.asDTO();
out.class = "date";
out.value = this._value.attempt;
return out;
}
}
export interface SearchableItem {
matchesSearch: (query: string) => boolean;
// FIXME: SearchableItem should really be ListItem or something, this isn't for search!
asElement: () => HTMLElement;
}
export const SearchableItemDataAttribute = "data-search-item";
export type SearchableItems = { [id: string]: SearchableItem };
export class Search {
private _c: SearchConfiguration;
private _sortField: string = "";
private _ascending: boolean = true;
private _ordering: string[] = [];
private _items: SearchableItems = {};
// 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(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));
};
}
} else if (queryFormat.string) {
q = new StringQuery(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));
}
} else if (queryFormat.date) {
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
if (!isDate) continue;
q = new DateQuery(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));
}
}
if (q != null) queries.push(q);
}
return [searchTerms, queries];
}
// Returns a list of identifiers (used as keys in items, values in ordering).
search = (query: string): string[] => {
let timer = this.timeSearches ? performance.now() : null;
this._c.filterArea.textContent = "";
let result: string[] = [...this._ordering];
// If we're in a server search already, the results are already correct.
if (this.inServerSearch) return result;
const [searchTerms, queries] = this.parseTokens(Search.tokenizeSearch(query));
query = "";
for (let term of searchTerms) {
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this.items[id];
if (!u.matchesSearch(term)) {
result.splice(result.indexOf(id), 1);
}
}
}
for (let q of queries) {
this._c.filterArea.appendChild(q.asElement());
let cachedResult = [...result];
if (q.subject.bool) {
for (let id of cachedResult) {
const u = this.items[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.subject.string) {
for (let id of cachedResult) {
const u = this.items[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.subject.date) {
for (let id of cachedResult) {
const u = this.items[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);
}
}
}
}
this._queries = queries;
this._searchTerms = searchTerms;
if (this.timeSearches) {
const totalTime = performance.now() - timer;
console.log(`Search took ${totalTime}ms`);
}
return result;
}
showHideSearchOptionsHeader = () => {
let sortingBy = false;
if (this._c.sortingByButton) sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
const hasFilters = this._c.filterArea.textContent != "";
if (sortingBy || hasFilters) {
this._c.searchOptionsHeader.classList.remove("hidden");
} else {
this._c.searchOptionsHeader.classList.add("hidden");
}
}
// -all- elements.
get items(): { [id: string]: SearchableItem } { 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; }
onSearchBoxChange = (newItems: boolean = false, appendedItems: 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, appendedItems);
this._c.onSearchCallback(newItems, loadAll);
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);
};
// FIXME: Make XQuery classes less specifically for in-progress searches, and include this code for making info button things.
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 = `
${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);
}
}
onServerSearch = () => {
const newServerSearch = !this.inServerSearch;
this.inServerSearch = true;
this._c.searchServer(this.serverSearchParams(this._searchTerms, this._queries), newServerSearch);
}
serverSearchParams = (searchTerms: string[], queries: Query[]): PaginatedReqDTO => {
let req: ServerSearchReqDTO = {
searchTerms: searchTerms,
queries: queries.map((q: Query) => q.asDTO()),
limit: -1,
page: 0,
sortByField: this.sortField,
ascending: this.ascending
};
return req;
}
setServerSearchButtonsDisabled = (disabled: boolean) => {
this._serverSearchButtons.forEach((v: HTMLButtonElement) => v.disabled = disabled);
}
constructor(c: SearchConfiguration) {
this._c = c;
this._c.search.oninput = () => {
this.inServerSearch = false;
this.onSearchBoxChange();
}
this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => {
if (ev.key == "Enter") {
this.onServerSearch();
}
});
const clearSearchButtons = Array.from(document.querySelectorAll(this._c.clearSearchButtonSelector)) as Array;
for (let b of clearSearchButtons) {
b.addEventListener("click", () => {
this._c.search.value = "";
this.inServerSearch = false;
this.onSearchBoxChange();
});
}
this._serverSearchButtons = Array.from(document.querySelectorAll(this._c.serverSearchButtonSelector)) as Array;
for (let b of this._serverSearchButtons) {
b.addEventListener("click", () => {
this.onServerSearch();
});
}
}
}