feat: use flexsearch for all local searches (#997)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/997
Reviewed-by: dpschen <dpschen@noreply.kolaente.de>
Co-authored-by: konrad <k@knt.li>
Co-committed-by: konrad <k@knt.li>
This commit is contained in:
konrad 2021-11-14 20:49:52 +00:00
parent 1fa164453c
commit 507a73e74c
11 changed files with 157 additions and 80 deletions

View file

@ -30,6 +30,7 @@
"dompurify": "2.3.3", "dompurify": "2.3.3",
"easymde": "2.15.0", "easymde": "2.15.0",
"flatpickr": "4.6.9", "flatpickr": "4.6.9",
"flexsearch": "^0.7.21",
"highlight.js": "11.3.1", "highlight.js": "11.3.1",
"is-touch-device": "1.0.1", "is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0", "lodash.clonedeep": "4.5.0",
@ -54,6 +55,7 @@
"@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/vue-fontawesome": "3.0.0-5", "@fortawesome/vue-fontawesome": "3.0.0-5",
"@types/flexsearch": "^0.7.2",
"@types/jest": "27.0.2", "@types/jest": "27.0.2",
"@typescript-eslint/eslint-plugin": "5.3.1", "@typescript-eslint/eslint-plugin": "5.3.1",
"@typescript-eslint/parser": "5.3.1", "@typescript-eslint/parser": "5.3.1",
@ -81,8 +83,8 @@
"typescript": "4.4.4", "typescript": "4.4.4",
"vite": "2.6.14", "vite": "2.6.14",
"vite-plugin-pwa": "0.11.5", "vite-plugin-pwa": "0.11.5",
"vue-tsc": "0.29.4",
"vite-svg-loader": "3.1.0", "vite-svg-loader": "3.1.0",
"vue-tsc": "0.29.4",
"wait-on": "6.0.0", "wait-on": "6.0.0",
"workbox-cli": "6.3.0" "workbox-cli": "6.3.0"
}, },

View file

@ -25,15 +25,7 @@ export default {
}, },
computed: { computed: {
namespaces() { namespaces() {
if (this.query === '') { return this.$store.getters['namespaces/searchNamespace'](this.query)
return []
}
return this.$store.state.namespaces.namespaces.filter(n => {
return !n.isArchived &&
n.id > 0 &&
n.title.toLowerCase().includes(this.query.toLowerCase())
})
}, },
}, },
methods: { methods: {

View file

@ -110,40 +110,32 @@ export default {
results() { results() {
let lists = [] let lists = []
if (this.searchMode === SEARCH_MODE_ALL || this.searchMode === SEARCH_MODE_LISTS) { if (this.searchMode === SEARCH_MODE_ALL || this.searchMode === SEARCH_MODE_LISTS) {
const ncache = {}
const history = getHistory()
// Puts recently visited lists at the top
const allLists = [...new Set([
...history.map(l => {
return this.$store.getters['lists/getListById'](l.id)
}),
...Object.values(this.$store.state.lists)])]
const {list} = this.parsedQuery const {list} = this.parsedQuery
if (list === null) { if (list === null) {
lists = [] lists = []
} else { } else {
const ncache = {}
const history = getHistory()
// Puts recently visited lists at the top
const allLists = [...new Set([
...history.map(l => {
return this.$store.getters['lists/getListById'](l.id)
}),
...this.$store.getters['lists/searchList'](list),
])]
lists = allLists.filter(l => { lists = allLists.filter(l => {
if (typeof l === 'undefined' || l === null) { if (typeof l === 'undefined' || l === null) {
return false return false
} }
if (l.isArchived) {
return false
}
if (typeof ncache[l.namespaceId] === 'undefined') { if (typeof ncache[l.namespaceId] === 'undefined') {
ncache[l.namespaceId] = this.$store.getters['namespaces/getNamespaceById'](l.namespaceId) ncache[l.namespaceId] = this.$store.getters['namespaces/getNamespaceById'](l.namespaceId)
} }
if (ncache[l.namespaceId].isArchived) { return !ncache[l.namespaceId].isArchived
return false })
}
return l.title.toLowerCase().includes(list.toLowerCase())
}) ?? []
} }
} }

View file

@ -1,7 +1,6 @@
<template> <template>
<multiselect <multiselect
class="control is-expanded" class="control is-expanded"
:loading="listSerivce.loading"
:placeholder="$t('list.search')" :placeholder="$t('list.search')"
@search="findLists" @search="findLists"
:search-results="foundLists" :search-results="foundLists"
@ -18,7 +17,6 @@
</template> </template>
<script> <script>
import ListService from '../../../services/list'
import ListModel from '../../../models/list' import ListModel from '../../../models/list'
import Multiselect from '@/components/input/multiselect.vue' import Multiselect from '@/components/input/multiselect.vue'
@ -26,7 +24,6 @@ export default {
name: 'listSearch', name: 'listSearch',
data() { data() {
return { return {
listSerivce: new ListService(),
list: new ListModel(), list: new ListModel(),
foundLists: [], foundLists: [],
} }
@ -50,17 +47,8 @@ export default {
}, },
}, },
methods: { methods: {
async findLists(query) { findLists(query) {
if (query === '') { this.foundLists = this.$store.getters['lists/searchList'](query)
this.clearAll()
return
}
this.foundLists = await this.listSerivce.getAll({}, {s: query})
},
clearAll() {
this.foundLists = []
}, },
select(list) { select(list) {
@ -82,6 +70,6 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.list-namespace-title { .list-namespace-title {
color: $grey-500; color: $grey-500;
} }
</style> </style>

View file

@ -1,20 +1,25 @@
import {filterLabelsByQuery} from './labels' import {filterLabelsByQuery} from './labels'
import {createNewIndexer} from '../indexes'
const {add} = createNewIndexer('labels', ['title', 'description'])
describe('filter labels', () => { describe('filter labels', () => {
const state = { const state = {
labels: [ labels: {
{id: 1, title: 'label1'}, 1: {id: 1, title: 'label1'},
{id: 2, title: 'label2'}, 2: {id: 2, title: 'label2'},
{id: 3, title: 'label3'}, 3: {id: 3, title: 'label3'},
{id: 4, title: 'label4'}, 4: {id: 4, title: 'label4'},
{id: 5, title: 'label5'}, 5: {id: 5, title: 'label5'},
{id: 6, title: 'label6'}, 6: {id: 6, title: 'label6'},
{id: 7, title: 'label7'}, 7: {id: 7, title: 'label7'},
{id: 8, title: 'label8'}, 8: {id: 8, title: 'label8'},
{id: 9, title: 'label9'}, 9: {id: 9, title: 'label9'},
], },
} }
Object.values(state.labels).forEach(add)
it('should return an empty array for an empty query', () => { it('should return an empty array for an empty query', () => {
const labels = filterLabelsByQuery(state, [], '') const labels = filterLabelsByQuery(state, [], '')
@ -31,7 +36,7 @@ describe('filter labels', () => {
id: number, id: number,
title: string, title: string,
} }
const labelsToHide: label[] = [{id: 1, title: 'label1'}] const labelsToHide: label[] = [{id: 1, title: 'label1'}]
const labels = filterLabelsByQuery(state, labelsToHide, 'label1') const labels = filterLabelsByQuery(state, labelsToHide, 'label1')

View file

@ -1,10 +1,16 @@
interface label { import {createNewIndexer} from '../indexes'
const {search} = createNewIndexer('labels', ['title', 'description'])
export interface label {
id: number, id: number,
title: string, title: string,
} }
interface labelState { interface labelState {
labels: label[], labels: {
[k: number]: label,
},
} }
/** /**
@ -15,17 +21,12 @@ interface labelState {
* @returns {Array} * @returns {Array}
*/ */
export function filterLabelsByQuery(state: labelState, labelsToHide: label[], query: string) { export function filterLabelsByQuery(state: labelState, labelsToHide: label[], query: string) {
if (query === '') { const labelIdsToHide: number[] = labelsToHide.map(({id}) => id)
return []
}
const labelQuery = query.toLowerCase() return search(query)
const labelIds = labelsToHide.map(({id}) => id) ?.filter(value => !labelIdsToHide.includes(value))
return Object .map(id => state.labels[id])
.values(state.labels) || []
.filter(({id, title}) => {
return !labelIds.includes(id) && title.toLowerCase().includes(labelQuery)
})
} }

52
src/indexes/index.ts Normal file
View file

@ -0,0 +1,52 @@
import {Document, SimpleDocumentSearchResultSetUnit} from 'flexsearch'
export interface withId {
id: number,
}
const indexes: { [k: string]: Document<withId> } = {}
export const createNewIndexer = (name: string, fieldsToIndex: string[]) => {
if (typeof indexes[name] === 'undefined') {
indexes[name] = new Document<withId>({
tokenize: 'full',
document: {
id: 'id',
index: fieldsToIndex,
},
})
}
const index = indexes[name]
function add(item: withId) {
return index.add(item.id, item)
}
function remove(item: withId) {
return index.remove(item.id)
}
function update(item: withId) {
return index.update(item.id, item)
}
function search(query: string | null): number[] | null {
if (query === '' || query === null) {
return null
}
// @ts-ignore
return index.search(query)
?.flatMap(r => r.result)
.filter((value, index, self) => self.indexOf(value) === index)
|| null
}
return {
add,
remove,
update,
search,
}
}

View file

@ -3,6 +3,9 @@ import {setLoading} from '@/store/helper'
import {success} from '@/message' import {success} from '@/message'
import {i18n} from '@/i18n' import {i18n} from '@/i18n'
import {getLabelsByIds, filterLabelsByQuery} from '@/helpers/labels' import {getLabelsByIds, filterLabelsByQuery} from '@/helpers/labels'
import {createNewIndexer} from '@/indexes'
const {add, remove, update} = createNewIndexer('labels', ['title', 'description'])
async function getAllLabels(page = 1) { async function getAllLabels(page = 1) {
const labelService = new LabelService() const labelService = new LabelService()
@ -26,13 +29,16 @@ export default {
setLabels(state, labels) { setLabels(state, labels) {
labels.forEach(l => { labels.forEach(l => {
state.labels[l.id] = l state.labels[l.id] = l
add(l)
}) })
}, },
setLabel(state, label) { setLabel(state, label) {
state.labels[label.id] = label state.labels[label.id] = label
update(label)
}, },
removeLabelById(state, label) { removeLabelById(state, label) {
delete state.labels[label.id] delete state.labels[label.id]
remove(label)
}, },
setLoaded(state, loaded) { setLoaded(state, loaded) {
state.loaded = loaded state.loaded = loaded

View file

@ -1,6 +1,9 @@
import ListService from '@/services/list' import ListService from '@/services/list'
import {setLoading} from '@/store/helper' import {setLoading} from '@/store/helper'
import {removeListFromHistory} from '@/modules/listHistory.ts' import {removeListFromHistory} from '@/modules/listHistory.ts'
import {createNewIndexer} from '@/indexes'
const {add, remove, search, update} = createNewIndexer('lists', ['title', 'description'])
const FavoriteListsNamespace = -2 const FavoriteListsNamespace = -2
@ -11,14 +14,17 @@ export default {
mutations: { mutations: {
setList(state, list) { setList(state, list) {
state[list.id] = list state[list.id] = list
update(list)
}, },
setLists(state, lists) { setLists(state, lists) {
lists.forEach(l => { lists.forEach(l => {
state[l.id] = l state[l.id] = l
add(l)
}) })
}, },
removeListById(state, list) { removeListById(state, list) {
delete state[list.id] delete state[list.id]
remove(list)
}, },
}, },
getters: { getters: {
@ -34,6 +40,13 @@ export default {
}) })
return typeof list === 'undefined' ? null : list return typeof list === 'undefined' ? null : list
}, },
searchList: state => (query, includeArchived = false) => {
return search(query)
?.filter(value => value > 0)
.map(id => state[id])
.filter(list => list.isArchived === includeArchived)
|| []
},
}, },
actions: { actions: {
toggleListFavorite(ctx, list) { toggleListFavorite(ctx, list) {
@ -66,7 +79,7 @@ export default {
await listService.update(list) await listService.update(list)
ctx.commit('setList', list) ctx.commit('setList', list)
ctx.commit('namespaces/setListInNamespaceById', list, {root: true}) ctx.commit('namespaces/setListInNamespaceById', list, {root: true})
// the returned list from listService.update is the same! // the returned list from listService.update is the same!
// in order to not validate vuex mutations we have to create a new copy // in order to not validate vuex mutations we have to create a new copy
const newList = { const newList = {
@ -81,7 +94,7 @@ export default {
ctx.dispatch('namespaces/loadNamespacesIfFavoritesDontExist', null, {root: true}) ctx.dispatch('namespaces/loadNamespacesIfFavoritesDontExist', null, {root: true})
ctx.dispatch('namespaces/removeFavoritesNamespaceIfEmpty', null, {root: true}) ctx.dispatch('namespaces/removeFavoritesNamespaceIfEmpty', null, {root: true})
return newList return newList
} catch(e) { } catch (e) {
// Reset the list state to the initial one to avoid confusion for the user // Reset the list state to the initial one to avoid confusion for the user
ctx.commit('setList', { ctx.commit('setList', {
...list, ...list,
@ -97,13 +110,13 @@ export default {
const cancel = setLoading(ctx, 'lists') const cancel = setLoading(ctx, 'lists')
const listService = new ListService() const listService = new ListService()
try { try {
const response = await listService.delete(list) const response = await listService.delete(list)
ctx.commit('removeListById', list) ctx.commit('removeListById', list)
ctx.commit('namespaces/removeListFromNamespaceById', list, {root: true}) ctx.commit('namespaces/removeListFromNamespaceById', list, {root: true})
removeListFromHistory({id: list.id}) removeListFromHistory({id: list.id})
return response return response
} finally{ } finally {
cancel() cancel()
} }
}, },

View file

@ -1,5 +1,8 @@
import NamespaceService from '../../services/namespace' import NamespaceService from '../../services/namespace'
import {setLoading} from '@/store/helper' import {setLoading} from '@/store/helper'
import {createNewIndexer} from '@/indexes'
const {add, remove, search, update} = createNewIndexer('namespaces', ['title', 'description'])
export default { export default {
namespaced: true, namespaced: true,
@ -9,6 +12,9 @@ export default {
mutations: { mutations: {
namespaces(state, namespaces) { namespaces(state, namespaces) {
state.namespaces = namespaces state.namespaces = namespaces
namespaces.forEach(n => {
add(n)
})
}, },
setNamespaceById(state, namespace) { setNamespaceById(state, namespace) {
const namespaceIndex = state.namespaces.findIndex(n => n.id === namespace.id) const namespaceIndex = state.namespaces.findIndex(n => n.id === namespace.id)
@ -22,8 +28,9 @@ export default {
if (!namespace.lists || namespace.lists.length === 0) { if (!namespace.lists || namespace.lists.length === 0) {
namespace.lists = state.namespaces[namespaceIndex].lists namespace.lists = state.namespaces[namespaceIndex].lists
} }
state.namespaces[namespaceIndex] = namespace state.namespaces[namespaceIndex] = namespace
update(namespace)
}, },
setListInNamespaceById(state, list) { setListInNamespaceById(state, list) {
for (const n in state.namespaces) { for (const n in state.namespaces) {
@ -43,11 +50,13 @@ export default {
}, },
addNamespace(state, namespace) { addNamespace(state, namespace) {
state.namespaces.push(namespace) state.namespaces.push(namespace)
add(namespace)
}, },
removeNamespaceById(state, namespaceId) { removeNamespaceById(state, namespaceId) {
for (const n in state.namespaces) { for (const n in state.namespaces) {
if (state.namespaces[n].id === namespaceId) { if (state.namespaces[n].id === namespaceId) {
state.namespaces.splice(n, 1) state.namespaces.splice(n, 1)
remove(state.namespaces[n])
return return
} }
} }
@ -78,11 +87,11 @@ export default {
getters: { getters: {
getListAndNamespaceById: state => (listId, ignorePseudoNamespaces = false) => { getListAndNamespaceById: state => (listId, ignorePseudoNamespaces = false) => {
for (const n in state.namespaces) { for (const n in state.namespaces) {
if(ignorePseudoNamespaces && state.namespaces[n].id < 0) { if (ignorePseudoNamespaces && state.namespaces[n].id < 0) {
continue continue
} }
for (const l in state.namespaces[n].lists) { for (const l in state.namespaces[n].lists) {
if (state.namespaces[n].lists[l].id === listId) { if (state.namespaces[n].lists[l].id === listId) {
return { return {
@ -97,6 +106,13 @@ export default {
getNamespaceById: state => namespaceId => { getNamespaceById: state => namespaceId => {
return state.namespaces.find(({id}) => id == namespaceId) || null return state.namespaces.find(({id}) => id == namespaceId) || null
}, },
searchNamespace: (state, getters) => query => {
return search(query)
?.filter(value => value > 0)
.map(getters.getNamespaceById)
.filter(n => n !== null)
|| []
},
}, },
actions: { actions: {
async loadNamespaces(ctx) { async loadNamespaces(ctx) {
@ -107,12 +123,12 @@ export default {
// We always load all namespaces and filter them on the frontend // We always load all namespaces and filter them on the frontend
const namespaces = await namespaceService.getAll({}, {is_archived: true}) const namespaces = await namespaceService.getAll({}, {is_archived: true})
ctx.commit('namespaces', namespaces) ctx.commit('namespaces', namespaces)
// Put all lists in the list state // Put all lists in the list state
const lists = namespaces.flatMap(({lists}) => lists) const lists = namespaces.flatMap(({lists}) => lists)
ctx.commit('lists/setLists', lists, {root: true}) ctx.commit('lists/setLists', lists, {root: true})
return namespaces return namespaces
} finally { } finally {
cancel() cancel()

View file

@ -3168,6 +3168,11 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
"@types/flexsearch@^0.7.2":
version "0.7.2"
resolved "https://registry.yarnpkg.com/@types/flexsearch/-/flexsearch-0.7.2.tgz#05d982d292e70fcb9fc59347a4a4f98c4ecd9e56"
integrity sha512-Nq0CSpOCyUhaF7tAXSvMtoyBMPGlhNyF+uElhIrrgSiXDmX/bnn9jUX7Us3l81Hzowb9rcgNISke0Nj+3xhd3g==
"@types/glob@^7.1.1": "@types/glob@^7.1.1":
version "7.2.0" version "7.2.0"
resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb"
@ -7121,6 +7126,11 @@ flatten@^1.0.2:
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b"
integrity sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg== integrity sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==
flexsearch@^0.7.21:
version "0.7.21"
resolved "https://registry.yarnpkg.com/flexsearch/-/flexsearch-0.7.21.tgz#0f5ede3f2aae67ddc351efbe3b24b69d29e9d48b"
integrity sha512-W7cHV7Hrwjid6lWmy0IhsWDFQboWSng25U3VVywpHOTJnnAZNPScog67G+cVpeX9f7yDD21ih0WDrMMT+JoaYg==
flush-write-stream@^2.0.0: flush-write-stream@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-2.0.0.tgz#6f58e776154f5eefacff92a6e5a681c88ac50f7c" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-2.0.0.tgz#6f58e776154f5eefacff92a6e5a681c88ac50f7c"