From f50d7dca147e2d40d0174737852adda175be047f Mon Sep 17 00:00:00 2001 From: Gregg Tavares Date: Fri, 17 Nov 2023 12:09:23 +0900 Subject: [PATCH] add fuzzy search --- package-lock.json | 11 +++++ package.json | 1 + src/components/LoadGist.tsx | 80 ++++++++++++++++++++++++++----------- 3 files changed, 68 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index f6474a7..f43097d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "base64-arraybuffer": "^1.0.2", "codemirror": "^5.65.1", "mime-types": "^2.1.34", + "minisearch": "^6.2.0", "react": "^17.0.2", "react-codemirror2": "^7.2.1", "react-dom": "^17.0.2", @@ -11087,6 +11088,11 @@ "node": ">=8" } }, + "node_modules/minisearch": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-6.2.0.tgz", + "integrity": "sha512-BECkorDF1TY2rGKt9XHdSeP9TP29yUbrAaCh/C03wpyf1vx3uYcP/+8XlMcpTkgoU0rBVnHMAOaP83Rc9Tm+TQ==" + }, "node_modules/mississippi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz", @@ -26930,6 +26936,11 @@ "minipass": "^3.0.0" } }, + "minisearch": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-6.2.0.tgz", + "integrity": "sha512-BECkorDF1TY2rGKt9XHdSeP9TP29yUbrAaCh/C03wpyf1vx3uYcP/+8XlMcpTkgoU0rBVnHMAOaP83Rc9Tm+TQ==" + }, "mississippi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz", diff --git a/package.json b/package.json index 36de8a5..c86aef1 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "base64-arraybuffer": "^1.0.2", "codemirror": "^5.65.1", "mime-types": "^2.1.34", + "minisearch": "^6.2.0", "react": "^17.0.2", "react-codemirror2": "^7.2.1", "react-dom": "^17.0.2", diff --git a/src/components/LoadGist.tsx b/src/components/LoadGist.tsx index 6984a73..a52ee5f 100644 --- a/src/components/LoadGist.tsx +++ b/src/components/LoadGist.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import MiniSearch from 'minisearch' import EditLine from './EditLine.js'; import {classNames} from '../libs/css-utils.js'; import * as gists from '../libs/gists.js'; @@ -16,20 +17,29 @@ type Gist = { interface GistIdMap { [key: string]: Gist; -} +} + +interface ScoredGist extends Gist { + score: number, + id: string, +}; type LoadGistState = { loading: boolean, gists: GistIdMap, checks: Set, filter: string, // TODO: move up + newFilter: boolean, sortKey: string, // TODO: move up sortDir: string, // TODO: move up shift: boolean, + index: any, }; -function getSortFn(sortKey: string, checks: Set): (a: Gist, b: Gist) => number { +function getSortFn(sortKey: string, checks: Set): (a: ScoredGist, b: ScoredGist) => number { switch (sortKey) { + case 'score': + return (a: ScoredGist, b: ScoredGist) => Math.sign(a.score - b.score); case 'name': return (a: Gist, b: Gist) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : 0); case 'date': @@ -51,24 +61,32 @@ function getSortFn(sortKey: string, checks: Set): (a: Gist, b: Gist) => } } -function gistsToSortedArray(gists: GistIdMap, checks: Set, sortKey: string, sortDir: string) { +function scoredGistsToSortedArray(gists: ScoredGist[], checks: Set, sortKey: string, sortDir: string) { const compareDirMult = sortDir === 'down' ? 1 : -1; const compFn = getSortFn(sortKey, checks); - return Object.entries(gists).map(([id, {name, date, public: _public}]) => { - return {id, name, date, public: _public}; - }).sort((b, a) => compFn(a, b) * compareDirMult); + return gists.slice().sort((b, a) => compFn(a, b) * compareDirMult); } -function matchFilter(filter: string) { - filter = filter.trim().toLowerCase(); - return function(gist: Gist) { - const {name, date} = gist; - return filter === '' || - name.toLowerCase().includes(filter) || - date.substring(0, 10).includes(filter); - } +function createIndex(gists: GistIdMap) { + const miniSearch = new MiniSearch({ + fields: ['name'], // fields to index for full-text search + }); + miniSearch.addAll(Object.entries(gists).map(([id, {name}]) => ({id, name}))); + return miniSearch; } +function matchingGists(index: MiniSearch, filter: string, gists: GistIdMap): ScoredGist[] { + filter = filter.trim(); + if (filter === '') { + return Object.entries(gists) + .map(([id, gist]) => ({...gist, id, score: 0})) + } + const results = new Map(index.search(filter, { prefix: true, fuzzy: 0.2 }).map(r => [r.id, r.score])); + return Object.entries(gists) + .filter(([id]) => results.has(id)) + .map(([id, gist]) => ({...gist, id, score: results.get(id)! })) + +} type SortKeyInfo = { sortDir: string, @@ -106,14 +124,17 @@ export default class LoadGist extends React.Component<{}, LoadGistState> { gists: _gists, checks: new Set(), filter: '', + newFilter: false, sortKey: 'date', sortDir: 'down', shift: false, + index: createIndex(_gists), }; } handleNewGists = (gists: GistIdMap) => { this.setState({ gists, + index: createIndex(gists), }); } toggleCheck = (id: string) => { @@ -133,8 +154,7 @@ export default class LoadGist extends React.Component<{}, LoadGistState> { } } updateSort = (sortKey: string, sortDir: string) => { - console.log('update:', sortKey, sortDir); - this.setState({sortDir, sortKey}); + this.setState({sortDir, sortKey, newFilter: false}); } componentDidMount() { const {userManager} = this.context; @@ -219,10 +239,17 @@ export default class LoadGist extends React.Component<{}, LoadGistState> { } renderLoad() { const {userManager} = this.context; - const {gists, checks, loading, filter, sortKey, sortDir, shift} = this.state; + const {gists, checks, loading, index, filter, sortKey, sortDir, shift, newFilter} = this.state; const userData = userManager.getUserData(); const canLoad = !!userData && !loading; - const gistArray = gistsToSortedArray(gists, checks, sortKey, sortDir); + const effectiveSortKey = newFilter ? 'score' : sortKey; + const effectiveSortDir = newFilter ? 'down' : sortDir; + const gistArray = scoredGistsToSortedArray( + matchingGists(index, filter, gists), + checks, + effectiveSortKey, + effectiveSortDir); + return (

@@ -235,21 +262,26 @@ export default class LoadGist extends React.Component<{}, LoadGistState> { gistArray.length >= 0 &&

- {this.setState({filter})}} /> + { + this.setState({filter, newFilter: filter.trim() !== ''}); + } + } + />

- - - - + + + + { - gistArray.filter(matchFilter(filter)).map((gist, ndx) => { + gistArray.map((gist, ndx) => { return (
this.updateSort('check', dir)}/> this.updateSort('name', dir)}/> this.updateSort('date', dir)}/> this.updateSort('public', dir)}/> this.updateSort('check', dir)}/> this.updateSort('name', dir)}/> this.updateSort('date', dir)}/> this.updateSort('public', dir)}/>
this.toggleCheck(gist.id)}/>