Rewrite document store to keep all docs: filter and path selection without recreation. Much faster sorting and filtering.

This commit is contained in:
Leo Vasanko 2023-11-06 21:50:29 +00:00
parent 6938740b0f
commit e3af21af91
8 changed files with 84 additions and 131 deletions

View File

@ -15,7 +15,6 @@
"dependencies": { "dependencies": {
"@vueuse/core": "^10.4.1", "@vueuse/core": "^10.4.1",
"esbuild": "^0.19.5", "esbuild": "^0.19.5",
"locale-includes": "^1.0.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"pinia": "^2.1.6", "pinia": "^2.1.6",

View File

@ -27,7 +27,7 @@
display: none; display: none;
} }
} }
@media screen and (orientation: landscape) and (min-width: 800px) { @media screen and (orientation: landscape) and (min-width: 1200px) {
/* Breadcrumbs and buttons side by side */ /* Breadcrumbs and buttons side by side */
header { header {
display: flex; display: flex;

View File

@ -92,6 +92,7 @@
" "
/></template> /></template>
<template v-else> <template v-else>
<span class="loc" v-if="doc.loc.join('/') !== props.path.join('/')">{{ doc.loc.join('/') + '/'}}</span>
<a <a
:href="url_for(doc)" :href="url_for(doc)"
tabindex="-1" tabindex="-1"
@ -136,7 +137,7 @@ import { useDocumentStore } from '@/stores/documents'
import type { Document, FolderDocument } from '@/repositories/Document' import type { Document, FolderDocument } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue' import FileRenameInput from './FileRenameInput.vue'
import createWebSocket from '@/repositories/WS' import createWebSocket from '@/repositories/WS'
import { formatSize, formatUnixDate } from '@/utils' import { collator, formatSize, formatUnixDate } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const props = withDefaults( const props = withDefaults(
@ -292,8 +293,7 @@ const toggleSort = (name: string) => {
} }
const sort = ref<string>('') const sort = ref<string>('')
const sortCompare = { const sortCompare = {
name: (a: Document, b: Document) => name: (a: Document, b: Document) => collator.compare(a.name, b.name),
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
modified: (a: FolderDocument, b: FolderDocument) => b.mtime - a.mtime, modified: (a: FolderDocument, b: FolderDocument) => b.mtime - a.mtime,
size: (a: FolderDocument, b: FolderDocument) => b.size - a.size size: (a: FolderDocument, b: FolderDocument) => b.size - a.size
} }

View File

@ -41,9 +41,10 @@ defineExpose({
<input <input
ref="search" ref="search"
type="search" type="search"
v-model="documentStore.search"
class="margin-input" class="margin-input"
@blur="() => { if (documentStore.search === '') toggleSearchInput() }"
@keyup.esc="toggleSearchInput" @keyup.esc="toggleSearchInput"
@input="executeSearch"
/> />
</template> </template>
<SvgButton ref="searchButton" name="find" @click="toggleSearchInput" /> <SvgButton ref="searchButton" name="find" @click="toggleSearchInput" />

View File

@ -4,21 +4,19 @@ import createWebSocket from './WS'
export type FUID = string export type FUID = string
type BaseDocument = { export type Document = {
loc: string[]
name: string name: string
key: FUID key: FUID
}
export type FolderDocument = BaseDocument & {
type: 'folder' | 'file' type: 'folder' | 'file'
size: number size: number
sizedisp: string sizedisp: string
mtime: number mtime: number
modified: string modified: string
haystack: string
dir?: DirList
} }
export type Document = FolderDocument
export type errorEvent = { export type errorEvent = {
error: { error: {
code: number code: number
@ -110,9 +108,9 @@ export class DocumentHandler {
private handleRootMessage({ root }: { root: DirEntry }) { private handleRootMessage({ root }: { root: DirEntry }) {
console.log('Watch root', root) console.log('Watch root', root)
if (this.store && this.store.root) { if (this.store) {
this.store.user.isLoggedIn = true this.store.user.isLoggedIn = true
this.store.root = root this.store.updateRoot(root)
} }
} }
private handleUpdateMessage(updateData: { update: UpdateEntry[] }) { private handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
@ -132,6 +130,7 @@ export class DocumentHandler {
if (elem.mtime !== undefined) node.mtime = elem.mtime if (elem.mtime !== undefined) node.mtime = elem.mtime
if (elem.dir !== undefined) node.dir = elem.dir if (elem.dir !== undefined) node.dir = elem.dir
} }
this.store.updateRoot()
} }
private handleError(msg: errorEvent) { private handleError(msg: errorEvent) {
if (msg.error.code === 401) { if (msg.error.code === 401) {

View File

@ -6,10 +6,9 @@ import type {
DirList, DirList,
SelectedItems SelectedItems
} from '@/repositories/Document' } from '@/repositories/Document'
import { formatSize, formatUnixDate } from '@/utils' import { needleFormat, formatSize, formatUnixDate, haystackFormat, localeIncludes } from '@/utils'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
// @ts-ignore import { collator } from '@/utils'
import { localeIncludes } from 'locale-includes'
type FileData = { id: string; mtime: number; size: number; dir: DirectoryData } type FileData = { id: string; mtime: number; size: number; dir: DirectoryData }
type DirectoryData = { type DirectoryData = {
@ -25,6 +24,7 @@ type User = {
export type DocumentStore = { export type DocumentStore = {
root: DirEntry root: DirEntry
document: Document[] document: Document[]
search: string
selected: Set<FUID> selected: Set<FUID>
uploadingDocuments: Array<{ key: number; name: string; progress: number }> uploadingDocuments: Array<{ key: number; name: string; progress: number }>
uploadCount: number uploadCount: number
@ -40,6 +40,7 @@ export const useDocumentStore = defineStore({
state: (): DocumentStore => ({ state: (): DocumentStore => ({
root: {} as DirEntry, root: {} as DirEntry,
document: [] as Document[], document: [] as Document[],
search: "" as string,
selected: new Set<FUID>(), selected: new Set<FUID>(),
uploadingDocuments: [], uploadingDocuments: [],
uploadCount: 0 as number, uploadCount: 0 as number,
@ -56,89 +57,46 @@ export const useDocumentStore = defineStore({
}), }),
actions: { actions: {
updateTable(matched: DirList) { updateRoot(root: DirEntry | null = null) {
// Transform data root ??= this.root
const dataMapped = [] // Transform tree data to flat documents array
for (const [name, attr] of Object.entries(matched)) { let loc = [] as Array<string>
const { key, size, mtime } = attr const mapper = ([name, attr]: [string, FileEntry | DirEntry]) => ({
const element: Document = { loc,
name, name,
key, type: 'dir' in attr ? 'folder' : 'file' as 'folder' | 'file',
size, ...attr,
sizedisp: formatSize(size), sizedisp: formatSize(attr.size),
mtime, modified: formatUnixDate(attr.mtime),
modified: formatUnixDate(mtime), haystack: haystackFormat(name),
type: 'dir' in attr ? 'folder' : 'file' })
const queue = [...Object.entries(root.dir).map(mapper)]
const docs = []
for (let doc; (doc = queue.shift()) !== undefined;) {
docs.push(doc)
if ("dir" in doc) {
loc = [...doc.loc, doc.name]
queue.push(...Object.entries(doc.dir).map(mapper))
} }
dataMapped.push(element)
} }
// Pre sort directory entries folders first then files, names in natural ordering // Pre sort directory entries folders first then files, names in natural ordering
dataMapped.sort((a, b) => docs.sort((a, b) =>
a.type === b.type // @ts-ignore
? a.name.localeCompare(b.name, undefined, { (a.type === "file") - (b.type === "file") ||
numeric: true, collator.compare(a.name, b.name)
sensitivity: 'base'
})
: a.type === 'folder'
? -1
: 1
) )
this.document = dataMapped this.root = root
this.document = docs
}, },
setFilter(filter: string) { find(query: string): Document[] {
if (filter === '') return this.updateTable({}) const needle = needleFormat(query)
function traverseDir(data: DirEntry | FileEntry, path: string) { return this.document.filter(doc => localeIncludes(doc.haystack, needle))
if (!('dir' in data)) return
for (const [name, attr] of Object.entries(data.dir)) {
const fullname = `${path}/${name}`
if (
localeIncludes(name, filter, {
usage: 'search',
numeric: true,
sensitivity: 'base'
})
) {
matched[fullname.slice(1)] = attr // No initial slash on name
if (!--count) throw Error('Too many matches')
}
traverseDir(attr, fullname)
}
}
let count = 100
const matched: any = {}
try {
traverseDir(this.root, '')
} catch (error: any) {
if (error.message !== 'Too many matches') throw error
}
this.updateTable(matched)
}, },
setActualDocument(location: string) { directory(loc: string[]) {
location = decodeURIComponent(location) const ret = this.document.filter(
let data: FileEntry | DirEntry = this.root doc => doc.loc.length === loc.length && doc.loc.every((e, i) => e === loc[i])
const actualDirArr = [] )
try { return ret
// Navigate to target folder
for (const dirname of location.split('/').slice(1)) {
if (!dirname) continue
if (!('dir' in data)) throw Error('Target folder not available')
actualDirArr.push(dirname)
data = data.dir[dirname]
}
} catch (error) {
console.error(
'Cannot show requested folder',
location,
actualDirArr.join('/'),
error
)
}
if (!('dir' in data)) {
// Target folder not available
this.document = []
return
}
this.updateTable(data.dir)
}, },
updateUploadingDocuments(key: number, progress: number) { updateUploadingDocuments(key: number, progress: number) {
for (const d of this.uploadingDocuments) { for (const d of this.uploadingDocuments) {

View File

@ -57,44 +57,40 @@ export function getFileExtension(filename: string) {
return '' // No hay extensión return '' // No hay extensión
} }
} }
export function getFileType(extension: string): string { interface FileTypes {
const videoExtensions = ['mp4', 'avi', 'mkv', 'mov'] [key: string]: string[]
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif']
const pdfExtensions = ['pdf']
if (videoExtensions.includes(extension)) {
return 'video'
} else if (imageExtensions.includes(extension)) {
return 'image'
} else if (pdfExtensions.includes(extension)) {
return 'pdf'
} else {
return 'unknown'
}
} }
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' }) const filetypes: FileTypes = {
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
pdf: ['pdf'],
}
export function getFileType(name: string): string {
const ext = name.split('.').pop()?.toLowerCase()
if (!ext || ext.length === name.length) return 'unknown'
return Object.keys(filetypes).find(type => filetypes[type].includes(ext)) || 'unknown'
}
// Prebuilt for fast & consistent sorting
export const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
// Preformat document names for faster search
export function haystackFormat(str: string) { export function haystackFormat(str: string) {
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
return '^' + based + '$' return '^' + based + '$'
} }
export function localeIncludes(haystack: string, based: string, words: string[]) {
// Preformat search string for faster search
export function needleFormat(query: string) {
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
return {based, words: based.split(/\W+/)}
}
// Test if haystack includes needle
export function localeIncludes(haystack: string, filter: { based: string, words: string[] }) {
const {based, words} = filter
return haystack.includes(based) || words && words.every(word => haystack.includes(word)) return haystack.includes(based) || words && words.every(word => haystack.includes(word))
} }
export function buildCorpus(data: any[]) {
return data.map(item => [haystackFormat(item.name), item])
}
export function search(corpus: [string, any][], search: string) {
const based = search.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
const words = based.split(/\W+/)
const ret = []
for (const [haystack, item] of corpus) {
if (localeIncludes(haystack, based, words))
ret.push(item)
}
return ret
}

View File

@ -3,7 +3,12 @@
ref="fileExplorer" ref="fileExplorer"
:key="Router.currentRoute.value.path" :key="Router.currentRoute.value.path"
:path="props.path" :path="props.path"
:documents="documentStore.mainDocument" :documents="
documentStore.search ?
documentStore.find(documentStore.search) :
documentStore.directory(props.path)
"
v-if="props.path"
/> />
</template> </template>
@ -14,15 +19,10 @@ import Router from '@/router/index'
const documentStore = useDocumentStore() const documentStore = useDocumentStore()
const fileExplorer = ref() const fileExplorer = ref()
const props = defineProps({ const props = defineProps({
path: Array<string> path: Array<string>
}) })
watchEffect(() => { watchEffect(() => {
documentStore.fileExplorer = fileExplorer.value documentStore.fileExplorer = fileExplorer.value
}) })
watchEffect(async () => {
const path = new String(Router.currentRoute.value.path) as string
documentStore.setActualDocument(path.toString())
})
</script> </script>