From 78641a80ddf7a6fa2cb951fa526249406a814495 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Thu, 1 Dec 2016 12:06:57 +0530 Subject: [PATCH] autocomplete: replace Fuse.js with liblevenshtein --- package.json | 2 +- src/autocomplete/AutocompleteProvider.js | 2 +- src/autocomplete/CommandProvider.js | 6 +- src/autocomplete/EmojiProvider.js | 6 +- src/autocomplete/FuzzyMatcher.js | 74 ++++++++++++++++++++++++ src/autocomplete/RoomProvider.js | 14 ++--- src/autocomplete/UserProvider.js | 8 +-- 7 files changed, 92 insertions(+), 20 deletions(-) create mode 100644 src/autocomplete/FuzzyMatcher.js diff --git a/package.json b/package.json index 1e5ee29d2d..1015eb3fe9 100644 --- a/package.json +++ b/package.json @@ -55,10 +55,10 @@ "file-saver": "^1.3.3", "filesize": "^3.1.2", "flux": "^2.0.3", - "fuse.js": "^2.2.0", "glob": "^5.0.14", "highlight.js": "^8.9.1", "isomorphic-fetch": "^2.2.1", + "liblevenshtein": "^2.0.4", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 5c90990295..c361dd295b 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -2,7 +2,7 @@ import React from 'react'; import type {Completion, SelectionRange} from './Autocompleter'; export default class AutocompleteProvider { - constructor(commandRegex?: RegExp, fuseOpts?: any) { + constructor(commandRegex?: RegExp) { if (commandRegex) { if (!commandRegex.global) { throw new Error('commandRegex must have global flag set'); diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 60171bc72f..8f98bf1aa5 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -1,6 +1,6 @@ import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; -import Fuse from 'fuse.js'; +import FuzzyMatcher from './FuzzyMatcher'; import {TextualCompletion} from './Components'; const COMMANDS = [ @@ -53,7 +53,7 @@ let instance = null; export default class CommandProvider extends AutocompleteProvider { constructor() { super(COMMAND_RE); - this.fuse = new Fuse(COMMANDS, { + this.matcher = new FuzzyMatcher(COMMANDS, { keys: ['command', 'args', 'description'], }); } @@ -62,7 +62,7 @@ export default class CommandProvider extends AutocompleteProvider { let completions = []; let {command, range} = this.getCurrentCommand(query, selection); if (command) { - completions = this.fuse.search(command[0]).map(result => { + completions = this.matcher.match(command[0]).map(result => { return { completion: result.command + ' ', component: ( { + completions = this.matcher.match(command[0]).map(result => { const shortname = EMOJI_SHORTNAMES[result]; const unicode = shortnameToUnicode(shortname); return { diff --git a/src/autocomplete/FuzzyMatcher.js b/src/autocomplete/FuzzyMatcher.js new file mode 100644 index 0000000000..c02ee9bbc0 --- /dev/null +++ b/src/autocomplete/FuzzyMatcher.js @@ -0,0 +1,74 @@ +import Levenshtein from 'liblevenshtein'; +import _at from 'lodash/at'; +import _flatMap from 'lodash/flatMap'; +import _sortBy from 'lodash/sortBy'; +import _sortedUniq from 'lodash/sortedUniq'; +import _keys from 'lodash/keys'; + +class KeyMap { + keys: Array; + objectMap: {[String]: Array}; + priorityMap: {[String]: number} +} + +const DEFAULT_RESULT_COUNT = 10; +const DEFAULT_DISTANCE = 5; + +export default class FuzzyMatcher { + /** + * Given an array of objects and keys, returns a KeyMap + * Keys can refer to object properties by name and as in JavaScript (for nested properties) + * + * To use, simply presort objects by required criteria, run through this function and create a FuzzyMatcher with the + * resulting KeyMap. + * + * TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it) + */ + static valuesToKeyMap(objects: Array, keys: Array): KeyMap { + const keyMap = new KeyMap(); + const map = {}; + const priorities = {}; + + objects.forEach((object, i) => { + const keyValues = _at(object, keys); + console.log(object, keyValues, keys); + for (const keyValue of keyValues) { + if (!map.hasOwnProperty(keyValue)) { + map[keyValue] = []; + } + map[keyValue].push(object); + } + priorities[object] = i; + }); + + keyMap.objectMap = map; + keyMap.priorityMap = priorities; + keyMap.keys = _sortBy(_keys(map), [value => priorities[value]]); + return keyMap; + } + + constructor(objects: Array, options: {[Object]: Object} = {}) { + this.options = options; + this.keys = options.keys; + this.setObjects(objects); + } + + setObjects(objects: Array) { + this.keyMap = FuzzyMatcher.valuesToKeyMap(objects, this.keys); + console.log(this.keyMap.keys); + this.matcher = new Levenshtein.Builder() + .dictionary(this.keyMap.keys, true) + .algorithm('transposition') + .sort_candidates(false) + .case_insensitive_sort(true) + .include_distance(false) + .maximum_candidates(this.options.resultCount || DEFAULT_RESULT_COUNT) // result count 0 doesn't make much sense + .build(); + } + + match(query: String): Array { + const candidates = this.matcher.transduce(query, this.options.distance || DEFAULT_DISTANCE); + return _sortedUniq(_sortBy(_flatMap(candidates, candidate => this.keyMap.objectMap[candidate]), + candidate => this.keyMap.priorityMap[candidate])); + } +} diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 85f94926d9..8659b8501f 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -1,7 +1,7 @@ import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; import MatrixClientPeg from '../MatrixClientPeg'; -import Fuse from 'fuse.js'; +import FuzzyMatcher from './FuzzyMatcher'; import {PillCompletion} from './Components'; import {getDisplayAliasForRoom} from '../Rooms'; import sdk from '../index'; @@ -12,11 +12,9 @@ let instance = null; export default class RoomProvider extends AutocompleteProvider { constructor() { - super(ROOM_REGEX, { - keys: ['displayName', 'userId'], - }); - this.fuse = new Fuse([], { - keys: ['name', 'roomId', 'aliases'], + super(ROOM_REGEX); + this.matcher = new FuzzyMatcher([], { + keys: ['name', 'aliases'], }); } @@ -28,14 +26,14 @@ export default class RoomProvider extends AutocompleteProvider { const {command, range} = this.getCurrentCommand(query, selection, force); if (command) { // the only reason we need to do this is because Fuse only matches on properties - this.fuse.set(client.getRooms().filter(room => !!room).map(room => { + this.matcher.setObjects(client.getRooms().filter(room => !!room).map(room => { return { room: room, name: room.name, aliases: room.getAliases(), }; })); - completions = this.fuse.search(command[0]).map(room => { + completions = this.matcher.match(command[0]).map(room => { let displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; return { completion: displayAlias + ' ', diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 4d40fbdf94..b65439181c 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -1,9 +1,9 @@ import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; -import Fuse from 'fuse.js'; import {PillCompletion} from './Components'; import sdk from '../index'; +import FuzzyMatcher from './FuzzyMatcher'; const USER_REGEX = /@\S*/g; @@ -15,7 +15,7 @@ export default class UserProvider extends AutocompleteProvider { keys: ['name', 'userId'], }); this.users = []; - this.fuse = new Fuse([], { + this.matcher = new FuzzyMatcher([], { keys: ['name', 'userId'], }); } @@ -26,8 +26,7 @@ export default class UserProvider extends AutocompleteProvider { let completions = []; let {command, range} = this.getCurrentCommand(query, selection, force); if (command) { - this.fuse.set(this.users); - completions = this.fuse.search(command[0]).map(user => { + completions = this.matcher.match(command[0]).map(user => { let displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done let completion = displayName; if (range.start === 0) { @@ -56,6 +55,7 @@ export default class UserProvider extends AutocompleteProvider { setUserList(users) { this.users = users; + this.matcher.setObjects(this.users); } static getInstance(): UserProvider {