Skip to content

Commit

Permalink
wip: Implement fuzzy-search for docs
Browse files Browse the repository at this point in the history
  • Loading branch information
davidmyersdev committed Feb 4, 2024
1 parent 0aa6c35 commit 960ef88
Show file tree
Hide file tree
Showing 7 changed files with 228 additions and 143 deletions.
91 changes: 33 additions & 58 deletions components/DocList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
import { MERGE_DOCUMENTS } from '#root/src/store/actions'
import { type Doc } from '~/src/models/doc'
const REGEX_QUERY = /^\/(?<regex>.+)\/(?<flags>[a-z]*)$/s
export default defineComponent({
props: {
cols: {
Expand All @@ -15,23 +13,44 @@ export default defineComponent({
tag: String,
},
emits: ['update:query'],
setup() {
setup(props) {
const { query } = toRefs(props)
const isEditing = ref(false)
const searchQuery = ref(query.value ?? '')
const searchElement = ref<HTMLElement>()
const selectedDocs = ref<Doc[]>([])
const visibleCount = ref(25)
const filter = computed(() => props.tag ? `#${props.tag}` : props.filter)
const { docs } = useDocs({ filter })
const { searchResults } = useSearch(docs, { keys: ['text'], searchQuery })
const finalDocs = computed(() => {
return searchResults.value.map((doc: Doc) => ({
...doc,
selected: selectedDocs.value.includes(doc),
}))
})
const visibleDocs = computed(() => {
return finalDocs.value.slice(0, visibleCount.value)
})
onMounted(() => {
searchElement.value?.focus()
})
return {
docs,
searchResults,
finalDocs,
isEditing,
searchQuery,
searchElement,
}
},
data() {
return {
isEditing: false,
q: this.query ?? '',
selectedDocs: [] as Doc[],
visibleCount: 25,
selectedDocs,
visibleCount,
visibleDocs,
}
},
computed: {
Expand All @@ -41,56 +60,12 @@ export default defineComponent({
canMerge() {
return this.selectedDocs.length > 1
},
docs(): Doc[] {
if (this.tag) {
return this.$store.getters.withTag(this.tag)
}
if (this.filter === 'tasks') {
return this.$store.getters.tasks
}
if (this.filter === 'discarded') {
return this.$store.getters.discarded
}
if (this.filter === 'untagged') {
return this.$store.getters.untagged
}
return this.$store.getters.kept
},
filteredDocs() {
return this.docs.filter((doc: Doc) => {
if (!this.q) {
return true
}
try {
// @ts-expect-error Todo: Refactor this.
const { groups: { flags, regex } } = REGEX_QUERY.exec(this.q)
return (new RegExp(regex, flags)).test(doc.text)
} catch (_error) {
return doc.text.toLowerCase().includes(this.q.toLowerCase())
}
})
},
finalDocs() {
return this.filteredDocs.map((doc: Doc) => ({
...doc,
selected: this.selectedDocs.includes(doc),
}))
},
showLoadMore() {
return this.visibleCount <= this.finalDocs.length
},
visibleDocs() {
return this.finalDocs.slice(0, this.visibleCount)
},
},
watch: {
q(value) {
searchQuery(value) {
this.$emit('update:query', value)
},
},
Expand All @@ -115,7 +90,7 @@ export default defineComponent({
if (this.selectedDocs.find(doc => doc.id === id)) {
this.selectedDocs = this.selectedDocs.filter(doc => doc.id !== id)
} else {
const foundDoc = this.filteredDocs.find(doc => doc.id === id)
const foundDoc = this.searchResults.find(doc => doc.id === id)
if (foundDoc) {
this.selectedDocs.push(foundDoc)
Expand Down Expand Up @@ -150,7 +125,7 @@ export default defineComponent({
</div>
<div class="mb-4">
<CoreInput
v-model="q"
v-model="searchQuery"
autocomplete="off"
autofocus
label="Search"
Expand Down
95 changes: 91 additions & 4 deletions composables/useDocs.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,101 @@
import { useStore } from 'vuex'
import type Doc from '#root/src/models/doc'
import { type Doc } from '#root/src/models/doc'

export const useDocs = () => {
const filterByDiscarded = (docs: MaybeRef<Doc[]>) => {
return toValue(docs).filter((doc) => {
return !!doc.discardedAt
})
}

const filterByKept = (docs: MaybeRef<Doc[]>) => {
return toValue(docs).filter((doc) => {
return !doc.discardedAt
})
}

const filterByTag = (docs: MaybeRef<Doc[]>, tag: string) => {
return filterByTags(docs, [tag])
}

const filterByTags = (docs: MaybeRef<Doc[]>, tags: string[]) => {
return toValue(docs).filter((doc) => {
return tags.some((tag) => doc.tags.includes(tag))
})
}

const filterByTasks = (docs: MaybeRef<Doc[]>) => {
return toValue(docs).filter((doc) => {
return doc.tasks.length > 0
})
}

const filterByUntagged = (docs: MaybeRef<Doc[]>) => {
return toValue(docs).filter((doc) => {
return doc.tags.length === 0
})
}

const filterByWorkspace = (docs: MaybeRef<Doc[]>, workspace: { active: boolean, tags: string[] }) => {
if (!workspace.active) {
return toValue(docs)
}

return filterByTags(docs, workspace.tags)
}

const sortByRecent = (docs: MaybeRef<Doc[]>) => {
return toValue(docs).sort((a, b) => {
// Reverse chronological order.
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
})
}

export const useDocs = ({ filter }: { filter?: MaybeRef<string | undefined> } = {}) => {
const store = useStore()
const router = useRouter()
const docs = computed(() => store.getters.decrypted)
const doc = computed(() => docs.value.find((doc: Doc) => doc.id === router.currentRoute.value.params.docId))
const allDocs = computed<Doc[]>(() => store.state.documents.all)
const decryptedDocs = computed(() => allDocs.value.filter(doc => !doc.encrypted))
const sortedDocs = computed(() => sortByRecent(decryptedDocs))
const workspaceDocs = computed(() => filterByWorkspace(sortedDocs, store.state.context))
const keptDocs = computed(() => filterByKept(workspaceDocs))
const discardedDocs = computed(() => filterByDiscarded(workspaceDocs.value))
const taskDocs = computed(() => filterByTasks(keptDocs))
const untaggedDocs = computed(() => filterByUntagged(keptDocs))

const docs = computed(() => {
const filterValue = toValue(filter)

if (filterValue?.startsWith('#')) {
return filterByTag(keptDocs, filterValue.slice(1))
}

if (filterValue === 'tasks') {
return taskDocs.value
}

if (filterValue === 'discarded') {
return discardedDocs.value
}

if (filterValue === 'untagged') {
return untaggedDocs.value
}

return keptDocs.value
})

const doc = computed(() => decryptedDocs.value.find((doc: Doc) => doc.id === router.currentRoute.value.params.docId))

return {
allDocs,
decryptedDocs,
doc,
docs,
filterByTag,
filterByTasks,
filterByUntagged,
keptDocs,
sortedDocs,
workspaceDocs,
}
}
46 changes: 43 additions & 3 deletions composables/useSearch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Fuse from 'fuse.js'

const regexSearchShape = /^\/(?<regex>.+)\/(?<flags>[a-z]*)$/s

const sortByScore = <T extends { score?: number }>(list: T[]) => {
return list.sort((a, b) => {
return (a.score ?? 1) - (b.score ?? 1)
Expand All @@ -10,10 +12,48 @@ const fuzzy = <T>(list: Ref<T[]>, keys: string[]) => {
return new Fuse(list.value, { includeScore: true, keys })
}

export const useSearch = <T>(list: Ref<T[]>, { keys }: { keys: string[] }) => {
export const useSearch = <T extends Record<string, any>>(list: Ref<T[]>, { keys, searchQuery = ref('') }: { keys: string[], searchQuery?: Ref<string> }) => {
const filterer = computed(() => fuzzy(list, keys))
const searchQuery = ref('')
const searchResults = computed(() => sortByScore(filterer.value.search(searchQuery.value)))
const searchResults = computed(() => {
// 1. If there is no search query, return the entire list.
if (!searchQuery.value) {
return list.value
}

// 2. If the search query is a regex, attempt to regex-match entries in the list.
if (regexSearchShape.test(searchQuery.value)) {
try {
// @ts-expect-error Throwing an error is fine, because we handle it below.
const { groups: { flags, regex } } = regexSearchShape.exec(searchQuery.value)
const searchRegex = new RegExp(regex, flags)

return list.value.filter((item) => {
return keys.some((key) => {
return searchRegex.test(item[key])
})
})
} catch (error) {
console.error(error)
}
}

// 3. If the search query is not a regex, attempt to fuzzy-find entries in the list.
try {
const fuzzyResults = filterer.value.search(searchQuery.value)
const sortedFuzzyResults = sortByScore(fuzzyResults)

return sortedFuzzyResults.map((result) => result.item)
} catch (error) {
console.error(error)
}

// 4. If all else fails, do a case-insensitive full-text match.
return list.value.filter((item) => {
return keys.some((key) => {
return item[key].toLowerCase().includes(searchQuery.value.toLowerCase())
})
})
})

return {
searchQuery,
Expand Down
2 changes: 1 addition & 1 deletion pages/assistant/conversations/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default defineComponent({
const { db } = useDatabase()
const { result: chatMessages } = useQuery(() => db.chatMessages.toArray(), [])
const { searchQuery, searchResults } = useSearch(chatMessages, { keys: ['text'] })
const chatIdResults = computed(() => searchResults.value.map((result) => result.item.chatId))
const chatIdResults = computed(() => searchResults.value.map((result) => result.chatId))
const chatIds = computed(() => Array.from(new Set(chatIdResults.value)))
const filteredChats = computed(() => {
// Todo: There are sometimes undefined values. This might be due to leftover messages that point to deleted chats.
Expand Down
4 changes: 1 addition & 3 deletions pages/force-graph.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<script lang="ts">
import type { ForceGraphInstance } from 'force-graph'
import type Doc from '#root/src/models/doc'
import DocList from '#root/components/DocList.vue'
type GraphConnection = {
Expand All @@ -27,7 +26,6 @@ export default {
DocList,
},
setup() {
const { store } = useVuex()
const instance = ref<ForceGraphInstance>()
const tag = ref()
const graphElement = ref<HTMLElement>()
Expand All @@ -38,7 +36,7 @@ export default {
const maxNodeSize = computed(() => {
return Math.max(...nodes.value.map(node => node.size))
})
const docs = computed<Doc[]>(() => store.getters.kept)
const { keptDocs: docs } = useDocs()
const colors = computed(() => {
const textValue = rootStyles.value?.getPropertyValue('--layer-3-text') || '212 212 216'
const lineValue = rootStyles.value?.getPropertyValue('--layer-3-bg') || '39 39 42'
Expand Down
Loading

0 comments on commit 960ef88

Please sign in to comment.