Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement fuzzy-matching for docs search #267

Merged
merged 1 commit into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions components/CoreInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,14 @@ onMounted(() => {
:id="id"
ref="inputRef"
v-model="modelProxy"
aria-autocomplete="none"
autocomplete="off"
name="text"
:placeholder="placeholder"
:rows="lines"
:spellcheck="false"
:style="{ height }"
type="text"
:value="modelProxy"
class="unset-all cursor-text block min-h-0 overflow-hidden resize-none placeholder-current"
:class="{ 'whitespace-nowrap': !multiline }"
Expand Down
12 changes: 9 additions & 3 deletions components/Doc.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import moment from 'moment'
import CoreDivider from '#root/components/CoreDivider.vue'
import { DISCARD_DOCUMENT, RESTORE_DOCUMENT } from '#root/src/store/actions'
Expand All @@ -8,10 +8,16 @@ export default {
CoreDivider,
},
props: {
text: {
required: true,
type: String,
},
id: String,
text: String,
updatedAt: Date,
discardedAt: Date,
discardedAt: {
required: false,
type: Date as PropType<Date | null>,
},
allowDiscard: Boolean,
},
setup(props) {
Expand Down
106 changes: 44 additions & 62 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,17 +125,24 @@ export default defineComponent({
</div>
<div class="mb-4">
<CoreInput
v-model="q"
v-model="searchQuery"
autocomplete="off"
autofocus
label="Search"
description="Search with /regex/i or plain text..."
placeholder="Search with /regex/i or plain text..."
description="Supports /regex/i and fuzzy-matching."
placeholder="Start typing to filter results..."
/>
</div>
<div class="grid gap-4 grid-cols-1" :class="cols === 2 && 'lg:grid-cols-2'">
<div v-for="doc in visibleDocs" :key="doc.id" tabindex="0" class="rounded relative cursor-pointer outline-none focus:ring" @keypress.enter.prevent="selectDoc(doc.id)" @click="selectDoc(doc.id)">
<Doc v-bind="doc" :allow-discard="isEditing" class="h-96" />
<div
v-for="doc in visibleDocs"
:key="doc.id"
tabindex="0"
class="rounded relative cursor-pointer outline-none focus:ring"
@keypress.enter.prevent="selectDoc(doc.id)"
@click="selectDoc(doc.id)"
>
<LazyDoc v-bind="doc" :allow-discard="isEditing" class="h-96" />
<div v-if="doc.selected" class="flex items-center justify-center rounded absolute inset-0 bg-layer bg-opacity-50">
<svg height="3em" width="3em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
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,
}
}
31 changes: 15 additions & 16 deletions composables/useRouteQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,22 @@ import { isClient } from '#helpers/environment'

export const useRouteQuery = () => {
const router = useRouter()
const query = computed({
get: () => router.currentRoute.value.query.q as string,
set: (value: string) => {
const resolved = router.resolve({
...router.currentRoute.value,
query: {
...router.currentRoute.value.query,
q: value,
},
})
const query = ref(router.currentRoute.value.query.q as string || '')

if (isClient) {
// This will replace the browser history's current state with the new query.
// This is necessary to prevent the entire component from reloading (the behavior of router.replace).
window.history.replaceState(window.history.state, '', resolved.fullPath)
}
},
watch(query, () => {
const resolved = router.resolve({
...router.currentRoute.value,
query: {
...router.currentRoute.value.query,
q: query.value,
},
})

if (isClient) {
// This will replace the browser history's current state with the new query.
// This is necessary to prevent the entire component from reloading (the behavior of router.replace).
window.history.replaceState(window.history.state, '', resolved.fullPath)
}
})

return {
Expand Down
Loading
Loading