gristlabs_grist-core/app/client/models/SearchModel.ts

442 lines
17 KiB
TypeScript
Raw Normal View History

// tslint:disable:no-console
// TODO: Add documentation and clean up log statements.
import {CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors';
import {delay} from 'app/common/delay';
import {waitObs} from 'app/common/gutil';
import {TableData} from 'app/common/TableData';
import {BaseFormatter} from 'app/common/ValueFormatter';
import {Disposable, Observable} from 'grainjs';
import debounce = require('lodash/debounce');
/**
* SearchModel used to maintain the state of the search UI.
*/
export interface SearchModel {
value: Observable<string>; // string in the search input
isOpen: Observable<boolean>; // indicates whether the search bar is expanded to show the input
noMatch: Observable<boolean>; // indicates if there are no search matches
isEmpty: Observable<boolean>; // indicates whether the value is empty
isRunning: Observable<boolean>; // indicates that matching is in progress
multiPage: Observable<boolean>; // if true will search across all pages
findNext(): Promise<void>; // find next match
findPrev(): Promise<void>; // find previous match
}
interface SearchPosition {
pageIndex: number;
sectionIndex: number;
rowIndex: number;
fieldIndex: number;
}
/**
* Stepper is an helper class that is used to implement stepping through all the cells of a
* document. Fields belongs to rows, rows belongs to section and sections to pages. So this is four
* steppers that must be used together, one for each level (field, rows, section and pages). When a
* stepper reaches the end of its array, this is the `nextArrayFunc` callback, passed to the
* `next()`, that is responsible for both taking a step at the higher level and updating the
* stepper's array.
*/
class Stepper<T> {
public array: ReadonlyArray<T> = [];
public index: number = 0;
public inRange() {
return this.index >= 0 && this.index < this.array.length;
}
// Doing await at every step adds a ton of overhead; we can optimize by returning and waiting on
// Promises only when needed.
public next(step: number, nextArrayFunc: () => Promise<void>|void): Promise<void>|void {
this.index += step;
if (!this.inRange()) {
// If index reached the end of the array, take a step at a higher level to get a new array.
// For efficiency, only wait asynchronously if the callback returned a promise.
const p = nextArrayFunc();
if (p) {
return p.then(() => this.setStart(step));
} else {
this.setStart(step);
}
}
}
public setStart(step: number) {
this.index = step > 0 ? 0 : this.array.length - 1;
}
public get value(): T { return this.array[this.index]; }
}
/**
* Interface that represents an ongoing search job which stops on the first match found.
*/
interface IFinder {
matchFound: boolean; // true if a match was found
startPosition: SearchPosition; // position at which to stop searching for a new match
abort(): void; // abort current search
matchNext(step: number): Promise<void>; // next match
nextField(step: number): Promise<void>|void; // move the current position
getCurrentPosition(): SearchPosition; // get the current position
}
// A callback to opening a page: useful to switch to next page during an ongoing search.
type DocPageOpener = (viewId: number) => Promise<void>;
/**
* An implementation of an IFinder.
*/
class FinderImpl implements IFinder {
public matchFound = false;
public startPosition: SearchPosition;
private _searchRegexp: RegExp;
private _pageStepper = new Stepper<any>();
private _sectionStepper = new Stepper<ViewSectionRec>();
private _sectionTableData: TableData;
private _rowStepper = new Stepper<number>();
private _fieldStepper = new Stepper<ViewFieldRec>();
private _fieldFormatters: BaseFormatter[];
private _pagesSwitched: number = 0;
private _aborted = false;
private _clearCursorHighlight: (() => void)|undefined;
constructor(private _gristDoc: GristDoc, value: string, private _openDocPageCB: DocPageOpener,
public multiPage: Observable<boolean>) {
this._searchRegexp = makeRegexp(value);
}
public abort() {
this._aborted = true;
if (this._clearCursorHighlight) { this._clearCursorHighlight(); }
}
public getCurrentPosition(): SearchPosition {
return {
pageIndex: this._pageStepper.index,
sectionIndex: this._sectionStepper.index,
rowIndex: this._rowStepper.index,
fieldIndex: this._fieldStepper.index,
};
}
// Initialize the steppers. Returns false if anything goes wrong.
public init(): boolean {
const pages: any[] = this._gristDoc.docModel.visibleDocPages.peek();
this._pageStepper.array = pages;
this._pageStepper.index = pages.findIndex(t => t.viewRef() === this._gristDoc.activeViewId.get());
if (this._pageStepper.index < 0) { return false; }
const view = this._pageStepper.value.view.peek();
const sections: any[] = view.viewSections().peek();
this._sectionStepper.array = sections;
this._sectionStepper.index = sections.findIndex(s => s.getRowId() === view.activeSectionId());
if (this._sectionStepper.index < 0) { return false; }
this._initNewSectionShown();
// Find the current cursor position in the current section.
const viewInstance = this._sectionStepper.value.viewInstance.peek()!;
const pos = viewInstance.cursor.getCursorPos();
this._rowStepper.index = pos.rowIndex!;
this._fieldStepper.index = pos.fieldIndex!;
return true;
}
public async matchNext(step: number): Promise<void> {
let count = 0;
let lastBreak = Date.now();
this._pagesSwitched = 0;
while (!this._matches() || ((await this._loadSection(step)) && !this._matches())) {
// If search was aborted, simply returns.
if (this._aborted) { return; }
// To avoid hogging the CPU for too long, check time periodically, and if we've been running
// for long enough, take a brief break. We choose a 5ms break every 20ms; and only check
// time every 100 iterations, to avoid excessive overhead purely due to time checks.
if ((++count) % 100 === 0 && Date.now() >= lastBreak + 20) {
await delay(5);
lastBreak = Date.now();
}
const p = this.nextField(step);
if (p) { await p; }
// Detect when we get back to the start position; this is where we break on no match.
if (this._isCurrentPosition(this.startPosition) && !this._matches()) {
console.log("SearchBar: reached start position without finding anything");
this.matchFound = false;
return;
}
// A fail-safe to prevent certain bugs from causing infinite loops; break also if we scan
// through pages too many times.
// TODO: test it by disabling the check above.
if (this._pagesSwitched > this._pageStepper.array.length) {
console.log("SearchBar: aborting search due to too many page switches");
this.matchFound = false;
return;
}
}
console.log("SearchBar: found a match at %s", JSON.stringify(this.getCurrentPosition()));
this.matchFound = true;
await this._highlight();
}
public nextField(step: number): Promise<void>|void {
return this._fieldStepper.next(step, () => this._nextRow(step));
}
private _nextRow(step: number) {
return this._rowStepper.next(step, () => this._nextSection(step));
}
private async _nextSection(step: number) {
// Switching sections is rare enough that we don't worry about optimizing away `await` calls.
await this._sectionStepper.next(step, () => this._nextPage(step));
await this._initNewSectionAny();
}
// TODO There are issues with filtering. A section may have filters applied, and it may be
// auto-filtered (linked sections). If a tab is shown, we have the filtered list of rowIds; if
// the tab is not shown, it takes work to apply explicit filters. For linked sections, the
// sensible behavior seems to scan through ALL values, then once a match is found, set the
// cursor that determines the linking to include the matched row. And even that may not always
// be possible. So this is an open question.
private _initNewSectionCommon() {
const section = this._sectionStepper.value;
const tableModel = this._gristDoc.getTableModel(section.table.peek().tableId.peek());
this._sectionTableData = tableModel.tableData;
this._fieldStepper.array = section.viewFields().peek();
this._fieldFormatters = this._fieldStepper.array.map(f => f.visibleColFormatter.peek());
return tableModel;
}
private _initNewSectionShown() {
this._initNewSectionCommon();
const viewInstance = this._sectionStepper.value.viewInstance.peek()!;
this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek() as number[];
}
private async _initNewSectionAny() {
const tableModel = this._initNewSectionCommon();
const viewInstance = this._sectionStepper.value.viewInstance.peek();
if (viewInstance) {
this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek() as number[];
} else {
// If we are searching through another page (not currently loaded), we will NOT have a
// viewInstance, but we use the unsorted unfiltered row list, and if we find a match, the
// _loadSection() method will load the page and we'll repeat the search with a viewInstance.
await tableModel.fetch();
this._rowStepper.array = this._sectionTableData.getRowIds();
}
}
private async _nextPage(step: number) {
if (!this.multiPage.get()) { return; }
await this._pageStepper.next(step, () => undefined);
this._pagesSwitched++;
const view = this._pageStepper.value.view.peek();
this._sectionStepper.array = view.viewSections().peek();
}
private _matches(): boolean {
if (this._pageStepper.index < 0 || this._sectionStepper.index < 0 ||
this._rowStepper.index < 0 || this._fieldStepper.index < 0) {
console.warn("match outside");
return false;
}
const field = this._fieldStepper.value;
const formatter = this._fieldFormatters[this._fieldStepper.index];
const rowId = this._rowStepper.value;
const displayCol = field.displayColModel.peek();
const value = this._sectionTableData.getValue(rowId, displayCol.colId.peek());
// TODO: Note that formatting dates is now the bulk of the performance cost.
const text = formatter.formatAny(value);
return this._searchRegexp.test(text);
}
private async _loadSection(step: number): Promise<boolean> {
// If we found a match in a section for which we don't have a valid BaseView instance, we need
// to load the BaseView and start searching the section again, since the match we found does
// not take into account sort or filters. So we switch to the right page, wait for the
// viewInstance to be created, reset the section info, and return true to continue searching.
const section = this._sectionStepper.value;
if (!section.viewInstance.peek()) {
const view = this._pageStepper.value.view.peek();
await this._openDocPage(view.getRowId());
console.log("SearchBar: loading view %s section %s", view.getRowId(), section.getRowId());
const viewInstance: any = await waitObs(section.viewInstance);
await viewInstance.getLoadingDonePromise();
this._initNewSectionShown();
this._rowStepper.setStart(step);
this._fieldStepper.setStart(step);
console.log("SearchBar: loaded view %s section %s", view.getRowId(), section.getRowId());
return true;
}
return false;
}
// Highlights the cell at the current position.
private async _highlight() {
if (this._aborted) { return; }
const section = this._sectionStepper.value;
const sectionId = section.getRowId();
const cursorPos: CursorPos = {
sectionId,
rowId: this._rowStepper.value,
fieldIndex: this._fieldStepper.index,
};
await this._gristDoc.recursiveMoveToCursorPos(cursorPos, true).catch(reportError);
if (this._aborted) { return; }
// Highlight the selected cursor, after giving it a chance to update. We find the cursor in
// this ad-hoc way rather than use observables, to avoid the overhead of *every* cell
// depending on an additional observable.
await delay(0);
const viewInstance = (await waitObs(section.viewInstance))!;
await viewInstance.getLoadingDonePromise();
if (this._aborted) { return; }
// Make sure we are at good place. This is important when the cursor
// was already in a matched record, but the record was scrolled away.
await viewInstance.revealActiveRecord();
const cursor = viewInstance.viewPane.querySelector('.selected_cursor');
if (cursor) {
cursor.classList.add('search-match');
this._clearCursorHighlight = () => {
cursor.classList.remove('search-match');
clearTimeout(timeout);
this._clearCursorHighlight = undefined;
};
const timeout = setTimeout(this._clearCursorHighlight, 20);
}
}
private _isCurrentPosition(pos: SearchPosition): boolean {
return (
this._pageStepper.index === pos.pageIndex &&
this._sectionStepper.index === pos.sectionIndex &&
this._rowStepper.index === pos.rowIndex &&
this._fieldStepper.index === pos.fieldIndex
);
}
private _openDocPage(viewId: number) {
if (this._aborted) { return; }
return this._openDocPageCB(viewId);
}
}
/**
* Implementation of SearchModel used to construct the search UI.
*/
export class SearchModelImpl extends Disposable implements SearchModel {
public readonly value = Observable.create(this, '');
public readonly isOpen = Observable.create(this, false);
public readonly isRunning = Observable.create(this, false);
public readonly noMatch = Observable.create(this, true);
public readonly isEmpty = Observable.create(this, true);
public readonly multiPage = Observable.create(this, false);
private _isRestartNeeded = false;
private _finder: IFinder|null = null;
constructor(private _gristDoc: GristDoc) {
super();
// Listen to input value changes (debounced) to activate searching.
const findFirst = debounce((_value: string) => this._findFirst(_value), 100);
this.autoDispose(this.value.addListener(v => { this.isRunning.set(true); void findFirst(v); }));
// Set this.noMatch to false when multiPage gets turned ON.
this.autoDispose(this.multiPage.addListener(v => { if (v) { this.noMatch.set(false); } }));
// Schedule a search restart when user changes pages (otherwise search would resume from the
// previous page that is not shown anymore). Also revert noMatch flag when in single page mode.
this.autoDispose(this._gristDoc.activeViewId.addListener(() => {
if (!this.multiPage.get()) { this.noMatch.set(false); }
this._isRestartNeeded = true;
}));
}
public async findNext() {
if (this.isRunning.get() || this.noMatch.get()) { return; }
if (this._isRestartNeeded) { return this._findFirst(this.value.get()); }
await this._run(async (finder) => {
await finder.nextField(1);
await finder.matchNext(1);
});
}
public async findPrev() {
if (this.isRunning.get() || this.noMatch.get()) { return; }
if (this._isRestartNeeded) { return this._findFirst(this.value.get()); }
await this._run(async (finder) => {
await finder.nextField(-1);
await finder.matchNext(-1);
});
}
private async _findFirst(value: string) {
this._isRestartNeeded = false;
this.isEmpty.set(!value);
this._updateFinder(value);
if (!value || !this._finder) { this.noMatch.set(true); return; }
await this._run(async (finder) => {
await finder.matchNext(1);
});
}
private _updateFinder(value: string) {
if (this._finder) { this._finder.abort(); }
const impl = new FinderImpl(this._gristDoc, value, this._openDocPage.bind(this), this.multiPage);
const isValid = impl.init();
this._finder = isValid ? impl : null;
}
// Internal helper that runs cb, passing it the current `this._finder` as first argument and sets
// this.isRunning to true until the call resolves. It also takes care of updating this.noMatch.
private async _run(cb: (finder: IFinder) => Promise<void>) {
const finder = this._finder;
if (!finder) { throw new Error("SearchModel: finder is not defined"); }
try {
this.isRunning.set(true);
finder.startPosition = finder.getCurrentPosition();
await cb(finder);
} finally {
this.isRunning.set(false);
this.noMatch.set(!finder.matchFound);
}
}
// Opens doc page without triggering a restart.
private async _openDocPage(viewId: number) {
await this._gristDoc.openDocPage(viewId);
this._isRestartNeeded = false;
}
}
function makeRegexp(value: string) {
// From https://stackoverflow.com/a/3561711/328565
const escaped = value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
return new RegExp(escaped, 'i');
}