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 6, 2024
1 parent ec23d7a commit 8a6ac46
Show file tree
Hide file tree
Showing 12 changed files with 281 additions and 178 deletions.
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

0 comments on commit 8a6ac46

Please sign in to comment.