Frontend created and rewritten a few times, with some backend fixes #1

Merged
leo merged 110 commits from plaintable into main 2023-11-08 20:38:40 +00:00
8 changed files with 279 additions and 289 deletions
Showing only changes of commit d36605cd5b - Show all commits

View File

@ -15,14 +15,8 @@
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
import type HeaderMain from '@/components/HeaderMain.vue' import type HeaderMain from '@/components/HeaderMain.vue'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import createWebSocket from '@/repositories/WS' import { watchConnect, watchDisconnect } from '@/repositories/WS'
import {
url_document_watch_ws,
url_document_upload_ws,
DocumentHandler,
DocumentUploadHandler
} from '@/repositories/Document'
import { useDocumentStore } from '@/stores/documents' import { useDocumentStore } from '@/stores/documents'
import { computed } from 'vue' import { computed } from 'vue'
@ -41,23 +35,10 @@ const path: ComputedRef<Path> = computed(() => {
pathList pathList
} }
}) })
onMounted(watchConnect)
onUnmounted(watchDisconnect)
// Update human-readable x seconds ago messages from mtimes // Update human-readable x seconds ago messages from mtimes
setInterval(documentStore.updateModified, 1000) setInterval(documentStore.updateModified, 1000)
watchEffect(() => {
const documentHandler = new DocumentHandler()
const documentUploadHandler = new DocumentUploadHandler()
const wsWatch = createWebSocket(
url_document_watch_ws,
documentHandler.handleWebSocketMessage
)
const wsUpload = createWebSocket(
url_document_upload_ws,
documentUploadHandler.handleWebSocketMessage
)
documentStore.wsWatch = wsWatch
documentStore.wsUpload = wsUpload
})
const headerMain = ref<typeof HeaderMain | null>(null) const headerMain = ref<typeof HeaderMain | null>(null)
let vert = 0 let vert = 0
let timer: any = null let timer: any = null

View File

@ -197,6 +197,9 @@ main {
padding-bottom: 3em; /* convenience space on the bottom */ padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll; overflow-y: scroll;
} }
.spacer { flex-grow: 1 }
.smallgap { margin-left: 2em }
[data-tooltip]:hover:after { [data-tooltip]:hover:after {
z-index: 101; z-index: 101;
content: attr(data-tooltip); content: attr(data-tooltip);
@ -206,7 +209,7 @@ main {
padding: .5rem 1rem; padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0; border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color); box-shadow: 0 0 1rem var(--accent-color);
transform: translate(calc(1rem + -50%), 100%); transform: translate(calc(1rem + -50%), 150%);
background-color: var(--accent-color); background-color: var(--accent-color);
color: var(--primary-color); color: var(--primary-color);
white-space: pre; white-space: pre;

View File

@ -59,17 +59,13 @@
<template <template
v-for="doc of sorted(props.documents as Document[])" v-for="doc of sorted(props.documents as Document[])"
:key="doc.key"> :key="doc.key">
<tr v-if="doc.loc !== prevloc && ((prevloc = doc.loc) || true)"> <tr class="folder-change" v-if="doc.loc !== prevloc && ((prevloc = doc.loc) || true)">
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th> <th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
</tr> </tr>
<tr <tr
:id="`file-${doc.key}`" :id="`file-${doc.key}`"
:class="{ :class="{ file: !doc.dir, folder: doc.dir, cursor: cursor === doc }"
file: doc.type === 'file',
folder: doc.type === 'folder',
cursor: cursor === doc
}"
@click="cursor = cursor === doc ? null : doc" @click="cursor = cursor === doc ? null : doc"
@contextmenu.prevent="contextMenu($event, doc)" @contextmenu.prevent="contextMenu($event, doc)"
> >
@ -104,7 +100,7 @@
@focus.stop="cursor = doc" @focus.stop="cursor = doc"
@blur="ev => { if (!editing) cursor = null }" @blur="ev => { if (!editing) cursor = null }"
@keyup.left="router.back()" @keyup.left="router.back()"
@keyup.right.stop="ev => { if (doc.type === 'folder') (ev.target as HTMLElement).click() }" @keyup.right.stop="ev => { if (doc.dir) (ev.target as HTMLElement).click() }"
>{{ doc.name }}</a >{{ doc.name }}</a
> >
<button <button
@ -133,11 +129,11 @@
</td> </td>
</tr> </tr>
</template> </template>
<tr> <tr class="summary">
<td colspan="3" class="right">{{props.documents.length}} items shown:</td> <td colspan="3" class="right">{{props.documents.length}} items shown:</td>
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td> <td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
<td class="menu"></td> <td class="menu"></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div v-else class="empty-container">Nothing to see here</div> <div v-else class="empty-container">Nothing to see here</div>
@ -148,7 +144,7 @@ import { ref, computed, watchEffect, onBeforeUpdate } from 'vue'
import { useDocumentStore } from '@/stores/documents' import { useDocumentStore } from '@/stores/documents'
import type { Document } from '@/repositories/Document' import type { Document } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue' import FileRenameInput from './FileRenameInput.vue'
import createWebSocket from '@/repositories/WS' import { connect, controlUrl } from '@/repositories/WS'
import { collator, formatSize, formatUnixDate } from '@/utils' import { collator, formatSize, formatUnixDate } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@ -163,20 +159,22 @@ const documentStore = useDocumentStore()
const router = useRouter() const router = useRouter()
const url_for = (doc: Document) => { const url_for = (doc: Document) => {
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
return doc.type === 'folder' ? `#/${p}/` : `/files/${p}` return doc.dir ? `#/${p}/` : `/files/${p}`
} }
const cursor = ref<Document | null>(null) const cursor = ref<Document | null>(null)
// File rename // File rename
const editing = ref<Document | null>(null) const editing = ref<Document | null>(null)
const rename = (doc: Document, newName: string) => { const rename = (doc: Document, newName: string) => {
const oldName = doc.name const oldName = doc.name
const control = createWebSocket('/api/control', (ev: MessageEvent) => { const control = connect(controlUrl, {
const msg = JSON.parse(ev.data) message(ev: MessageEvent) {
if ('error' in msg) { const msg = JSON.parse(ev.data)
console.error('Rename failed', msg.error.message, msg.error) if ('error' in msg) {
doc.name = oldName console.error('Rename failed', msg.error.message, msg.error)
} else { doc.name = oldName
console.log('Rename succeeded', msg) } else {
console.log('Rename succeeded', msg)
}
} }
}) })
control.onopen = () => { control.onopen = () => {
@ -204,6 +202,7 @@ defineExpose({
modified: formatUnixDate(now), modified: formatUnixDate(now),
haystack: '', haystack: '',
} }
console.log("New")
}, },
toggleSelectAll() { toggleSelectAll() {
console.log('Select') console.log('Select')
@ -289,24 +288,26 @@ watchEffect(() => {
} }
}) })
const mkdir = (doc: Document, name: string) => { const mkdir = (doc: Document, name: string) => {
const control = createWebSocket('/api/control', (ev: MessageEvent) => { const control = connect(controlUrl, {
const msg = JSON.parse(ev.data) open() {
if ('error' in msg) { control.send(
console.error('Mkdir failed', msg.error.message, msg.error) JSON.stringify({
editing.value = null op: 'mkdir',
} else { path: `${doc.loc}/${name}`
console.log('mkdir', msg) })
router.push(`/${doc.loc}/${name}/`) )
},
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Mkdir failed', msg.error.message, msg.error)
editing.value = null
} else {
console.log('mkdir', msg)
router.push(`/${doc.loc}/${name}/`)
}
} }
}) })
control.onopen = () => {
control.send(
JSON.stringify({
op: 'mkdir',
path: `${doc.loc}/${name}`
})
)
}
doc.name = name // We should get an update from watch but this is quicker doc.name = name // We should get an update from watch but this is quicker
} }
@ -499,6 +500,9 @@ tbody .selection input {
font-size: 3rem; font-size: 3rem;
color: var(--accent-color); color: var(--accent-color);
} }
.folder-change {
margin-left: -.5rem;
}
.loc { .loc {
color: #888; color: #888;
} }

View File

@ -1,26 +1,3 @@
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
import { ref, nextTick } from 'vue'
const documentStore = useDocumentStore()
const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>()
const toggleSearchInput = () => {
showSearchInput.value = !showSearchInput.value
nextTick(() => {
const input = search.value
if (input) input.focus()
//else if (searchButton.value) document.querySelector('.breadcrumb')!.focus()
})
}
defineExpose({
toggleSearchInput
})
</script>
<template> <template>
<nav class="headermain"> <nav class="headermain">
<div class="buttons"> <div class="buttons">
@ -39,16 +16,46 @@ defineExpose({
v-model="documentStore.search" v-model="documentStore.search"
placeholder="Search words" placeholder="Search words"
class="margin-input" class="margin-input"
@blur="() => { if (documentStore.search === '') toggleSearchInput() }" @keyup.escape="closeSearch"
@keyup.esc="toggleSearchInput"
/> />
</template> </template>
<SvgButton ref="searchButton" name="find" @click="toggleSearchInput" /> <SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
<SvgButton name="cog" @click="console.log('settings menu')" /> <SvgButton name="cog" @click="console.log('settings menu')" />
</div> </div>
</nav> </nav>
</template> </template>
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
import { ref, nextTick } from 'vue'
const documentStore = useDocumentStore()
const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>()
const closeSearch = () => {
if (!showSearchInput.value) return // Already closing
showSearchInput.value = false
documentStore.search = ''
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
breadcrumb.focus()
}
const toggleSearchInput = () => {
showSearchInput.value = !showSearchInput.value
if (!showSearchInput.value) return closeSearch()
nextTick(() => {
const input = search.value
if (input) input.focus()
})
}
defineExpose({
toggleSearchInput,
closeSearch,
})
</script>
<style scoped> <style scoped>
.buttons { .buttons {
padding: 0; padding: 0;
@ -60,12 +67,6 @@ defineExpose({
.buttons > * { .buttons > * {
flex-shrink: 1; flex-shrink: 1;
} }
.spacer {
flex-grow: 1;
}
.smallgap {
margin-left: 2em;
}
input[type='search'] { input[type='search'] {
background: var(--primary-background); background: var(--primary-background);
color: var(--primary-color); color: var(--primary-color);

View File

@ -11,7 +11,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import createWebSocket from '@/repositories/WS' import {connect, controlUrl} from '@/repositories/WS'
import { useDocumentStore } from '@/stores/documents' import { useDocumentStore } from '@/stores/documents'
import { computed } from 'vue' import { computed } from 'vue'
import type { SelectedItems } from '@/repositories/Document' import type { SelectedItems } from '@/repositories/Document'
@ -26,21 +26,27 @@ const op = (op: string, dst?: string) => {
const sel = documentStore.selectedFiles const sel = documentStore.selectedFiles
const msg = { const msg = {
op, op,
sel: sel.ids.filter(id => sel.selected.has(id)).map(id => sel.fullpath[id]) sel: sel.keys.map(key => {
const doc = sel.docs[key]
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
} }
// @ts-ignore // @ts-ignore
if (dst !== undefined) msg.dst = dst if (dst !== undefined) msg.dst = dst
const control = createWebSocket('/api/control', ev => { const control = connect(controlUrl, {
const res = JSON.parse(ev.data) message(ev: WebSocmetMessageEvent) {
if ('error' in res) { const res = JSON.parse(ev.data)
console.error('Control socket error', msg, res.error) if ('error' in res) {
return console.error('Control socket error', msg, res.error)
} else if (res.status === 'ack') { documentStore.error = res.error.message
console.log('Control ack OK', res) return
control.close() } else if (res.status === 'ack') {
documentStore.selected.clear() console.log('Control ack OK', res)
return control.close()
} else console.log('Unknown control respons', msg, res) documentStore.selected.clear()
return
} else console.log('Unknown control response', msg, res)
}
}) })
control.onopen = () => { control.onopen = () => {
control.send(JSON.stringify(msg)) control.send(JSON.stringify(msg))
@ -57,21 +63,15 @@ const linkdl = (href: string) => {
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => { const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
let hdir = '' let hdir = ''
let h = handle let h = handle
let filelist = [] console.log('Downloading to filesystem', sel.recursive)
for (const id of sel.ids) { for (const [rel, full, doc] of sel.recursive) {
filelist.push(sel.relpath[id])
}
console.log('Downloading to filesystem', filelist)
for (const id of sel.ids) {
const rel = sel.relpath[id]
const url = sel.url[id] // Only files, not folders
// Create any missing directories // Create any missing directories
if (!rel.startsWith(hdir)) { if (hdir && !rel.startsWith(hdir + '/')) {
hdir = '' hdir = ''
h = handle h = handle
} }
const r = rel.slice(hdir.length) const r = rel.slice(hdir.length)
for (const dir of r.split('/').slice(0, url ? -1 : undefined)) { for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
hdir += `${dir}/` hdir += `${dir}/`
try { try {
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true }) h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
@ -81,17 +81,18 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
} }
console.log('Created', hdir) console.log('Created', hdir)
} }
if (!url) continue // Target was a folder and was created if (doc.dir) continue // Target was a folder and was created
const name = rel.split('/').pop()!.normalize('NFC') const name = rel.split('/').pop()!.normalize('NFC')
// Download file // Download file
let fileHandle let fileHandle
try { try {
fileHandle = await h.getFileHandle(name, { create: true }) fileHandle = await h.getFileHandle(name, { create: true })
} catch (error) { } catch (error) {
console.error('Failed to create file', hdir + name, error) console.error('Failed to create file', rel, full, hdir + name, error)
return return
} }
const writable = await fileHandle.createWritable() const writable = await fileHandle.createWritable()
const url = `/files/${rel}`
console.log('Fetching', url) console.log('Fetching', url)
const res = await fetch(url) const res = await fetch(url)
if (!res.ok) if (!res.ok)
@ -109,16 +110,16 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
const download = async () => { const download = async () => {
const sel = documentStore.selectedFiles const sel = documentStore.selectedFiles
console.log('Download', sel) console.log('Download', sel)
if (sel.selected.size === 0) { if (sel.keys.length === 0) {
console.warn('Attempted download but no files found. Missing:', sel.missing) console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
documentStore.selected.clear() documentStore.selected.clear()
return return
} }
// Plain old a href download if only one file (ignoring any folders) // Plain old a href download if only one file (ignoring any folders)
const urls = Object.values(sel.url) const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (urls.length === 1) { if (files.length === 1) {
documentStore.selected.clear() documentStore.selected.clear()
return linkdl(urls[0] as string) return linkdl(`/files/${files[0][1]}`)
} }
// Use FileSystem API if multiple files and the browser supports it // Use FileSystem API if multiple files and the browser supports it
if ('showDirectoryPicker' in window) { if ('showDirectoryPicker' in window) {
@ -137,7 +138,7 @@ const download = async () => {
} }
} }
// Otherwise, zip and download // Otherwise, zip and download
linkdl(`/zip/${Array.from(sel.selected).join('+')}/download.zip`) linkdl(`/zip/${Array.from(sel.keys).join('+')}/download.zip`)
documentStore.selected.clear() documentStore.selected.clear()
} }
</script> </script>

View File

@ -1,6 +1,3 @@
import { useDocumentStore } from '@/stores/documents'
import createWebSocket from './WS'
export type FUID = string export type FUID = string
export type Document = { export type Document = {
@ -13,7 +10,7 @@ export type Document = {
mtime: number mtime: number
modified: string modified: string
haystack: string haystack: string
dir?: DirList dir: boolean
} }
export type errorEvent = { export type errorEvent = {
@ -52,111 +49,8 @@ export type UpdateEntry = {
// Helper structure for selections // Helper structure for selections
export interface SelectedItems { export interface SelectedItems {
selected: Set<FUID> keys: FUID[]
docs: Record<FUID, Document>
recursive: Array<[string, string, Document]>
missing: Set<FUID> missing: Set<FUID>
rootdir: DirList
entries: Record<FUID, FileEntry | DirEntry>
fullpath: Record<FUID, string>
relpath: Record<FUID, string>
url: Record<FUID, string>
ids: FUID[]
}
export const url_document_watch_ws = '/api/watch'
export const url_document_upload_ws = '/api/upload'
export const url_document_get = '/files'
export class DocumentHandler {
constructor(private store = useDocumentStore()) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
}
handleWebSocketMessage(event: MessageEvent) {
const msg = JSON.parse(event.data)
if ('error' in msg) {
if (msg.error.code === 401) {
this.store.user.isLoggedIn = false
this.store.user.isOpenLoginModal = true
} else {
this.store.error = msg.error.message
}
// The server closes the websocket after errors, so we need to reopen it
setTimeout(() => {
this.store.wsWatch = createWebSocket(
url_document_watch_ws,
this.handleWebSocketMessage
)
}, 1000)
}
switch (true) {
case !!msg.root:
this.handleRootMessage(msg)
break
case !!msg.update:
this.handleUpdateMessage(msg)
break
case !!msg.space:
console.log('Watch space', msg.space)
break
case !!msg.error:
this.handleError(msg)
break
default:
}
}
private handleRootMessage({ root }: { root: DirEntry }) {
console.log('Watch root', root)
if (this.store) {
this.store.user.isLoggedIn = true
this.store.updateRoot(root)
}
}
private handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
console.log('Watch update', updateData.update)
let node: DirEntry = this.store.root
for (const elem of updateData.update) {
if (elem.deleted) {
delete node.dir[elem.name]
break // Deleted elements can't have further children
}
if (elem.name !== undefined) {
// @ts-ignore
node = node.dir[elem.name] ||= {}
}
if (elem.key !== undefined) node.key = elem.key
if (elem.size !== undefined) node.size = elem.size
if (elem.mtime !== undefined) node.mtime = elem.mtime
if (elem.dir !== undefined) node.dir = elem.dir
}
this.store.updateRoot()
}
private handleError(msg: errorEvent) {
if (msg.error.code === 401) {
this.store.user.isOpenLoginModal = true
this.store.user.isLoggedIn = false
return
}
}
}
export class DocumentUploadHandler {
constructor(private store = useDocumentStore()) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
}
handleWebSocketMessage(event: MessageEvent) {
const msg = JSON.parse(event.data)
switch (true) {
case !!msg.written:
this.handleWrittenMessage(msg)
break
default:
}
}
private handleWrittenMessage(msg: { written: number }) {
// if (this.store && this.store.root) this.store.root = root;
console.log('Written message', msg.written)
}
} }

View File

@ -1,8 +1,118 @@
function createWebSocket(url: string, eventHandler: (event: MessageEvent) => void) { import { useDocumentStore } from "@/stores/documents"
const urlObject = new URL(url, location.origin.replace(/^http/, 'ws')) import type { DirEntry, UpdateEntry, errorEvent } from "./Document"
const webSocket = new WebSocket(urlObject)
webSocket.onmessage = eventHandler export const controlUrl = '/api/control'
export const uploadUrl = '/api/upload'
export const watchUrl = '/api/watch'
let tree = null as DirEntry | null
let reconnectDuration = 500
let wsWatch = null as WebSocket | null
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws')))
for (const [event, handler] of Object.entries(handlers)) webSocket.addEventListener(event, handler)
return webSocket return webSocket
} }
export default createWebSocket export const watchConnect = async () => {
wsWatch = connect(watchUrl, {
open() { console.log("Connected to", watchUrl)},
message: handleWatchMessage,
close: watchReconnect,
})
await wsWatch
}
export const watchDisconnect = () => {
if (!wsWatch) return
wsWatch.close()
wsWatch = null
}
const watchReconnect = (event: MessageEvent) => {
const store = useDocumentStore()
if (store.connected) {
console.warn("Disconnected from server", event)
store.connected = false
}
reconnectDuration = Math.min(5000, reconnectDuration + 500)
// The server closes the websocket after errors, so we need to reopen it
setTimeout(() => {
wsWatch = connect(watchUrl, {
message: handleWatchMessage,
close: watchReconnect,
})
console.log("Attempting to reconnect...")
}, reconnectDuration)
}
const handleWatchMessage = (event: MessageEvent) => {
const store = useDocumentStore()
const msg = JSON.parse(event.data)
if ('error' in msg) {
if (msg.error.code === 401) {
store.user.isLoggedIn = false
store.user.isOpenLoginModal = true
} else {
store.error = msg.error.message
}
}
switch (true) {
case !!msg.root:
handleRootMessage(msg)
break
case !!msg.update:
handleUpdateMessage(msg)
break
case !!msg.space:
console.log('Watch space', msg.space)
break
case !!msg.error:
handleError(msg)
break
default:
}
}
function handleRootMessage({ root }: { root: DirEntry }) {
const store = useDocumentStore()
console.log('Watch root', root)
reconnectDuration = 500
store.connected = true
store.user.isLoggedIn = true
store.updateRoot(root)
tree = root
}
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
const store = useDocumentStore()
console.log('Watch update', updateData.update)
if (!tree) return console.error('Watch update before root')
let node: DirEntry = tree
for (const elem of updateData.update) {
if (elem.deleted) {
delete node.dir[elem.name]
break // Deleted elements can't have further children
}
if (elem.name !== undefined) {
// @ts-ignore
node = node.dir[elem.name] ||= {}
}
if (elem.key !== undefined) node.key = elem.key
if (elem.size !== undefined) node.size = elem.size
if (elem.mtime !== undefined) node.mtime = elem.mtime
if (elem.dir !== undefined) node.dir = elem.dir
}
store.updateRoot(tree)
}
function handleError(msg: errorEvent) {
const store = useDocumentStore()
if (msg.error.code === 401) {
store.user.isOpenLoginModal = true
store.user.isLoggedIn = false
return
}
}

View File

@ -30,10 +30,9 @@ export const useDocumentStore = defineStore({
selected: new Set<FUID>(), selected: new Set<FUID>(),
uploadingDocuments: [], uploadingDocuments: [],
uploadCount: 0 as number, uploadCount: 0 as number,
wsWatch: undefined,
wsUpload: undefined,
fileExplorer: null, fileExplorer: null,
error: '' as string, error: '' as string,
connected: false,
user: { user: {
username: '', username: '',
privileged: false, privileged: false,
@ -44,14 +43,16 @@ export const useDocumentStore = defineStore({
actions: { actions: {
updateRoot(root: DirEntry | null = null) { updateRoot(root: DirEntry | null = null) {
root ??= this.root if (!root) {
this.document = []
return
}
// Transform tree data to flat documents array // Transform tree data to flat documents array
let loc = "" let loc = ""
const mapper = ([name, attr]: [string, FileEntry | DirEntry]) => ({ const mapper = ([name, attr]: [string, FileEntry | DirEntry]) => ({
...attr,
loc, loc,
name, name,
type: 'dir' in attr ? 'folder' : 'file' as 'folder' | 'file',
...attr,
sizedisp: formatSize(attr.size), sizedisp: formatSize(attr.size),
modified: formatUnixDate(attr.mtime), modified: formatUnixDate(attr.mtime),
haystack: haystackFormat(name), haystack: haystackFormat(name),
@ -61,17 +62,19 @@ export const useDocumentStore = defineStore({
for (let doc; (doc = queue.shift()) !== undefined;) { for (let doc; (doc = queue.shift()) !== undefined;) {
docs.push(doc) docs.push(doc)
if ("dir" in doc) { if ("dir" in doc) {
// Recurse but replace recursive structure with boolean
loc = doc.loc ? `${doc.loc}/${doc.name}` : doc.name loc = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
queue.push(...Object.entries(doc.dir).map(mapper)) queue.push(...Object.entries(doc.dir).map(mapper))
doc.dir = true
} }
else doc.dir = false
} }
// Pre sort directory entries folders first then files, names in natural ordering // Pre sort directory entries folders first then files, names in natural ordering
docs.sort((a, b) => docs.sort((a, b) =>
// @ts-ignore // @ts-ignore
(a.type === "file") - (b.type === "file") || b.dir - a.dir ||
collator.compare(a.name, b.name) collator.compare(a.name, b.name)
) )
this.root = root
this.document = docs this.document = docs
}, },
updateUploadingDocuments(key: number, progress: number) { updateUploadingDocuments(key: number, progress: number) {
@ -119,53 +122,46 @@ export const useDocumentStore = defineStore({
return ret return ret
}, },
selectedFiles(): SelectedItems { selectedFiles(): SelectedItems {
function traverseDir(data: DirEntry | FileEntry, path: string, relpath: string) { const selected = this.selected
if (!('dir' in data)) return const found = new Set<FUID>()
for (const [name, attr] of Object.entries(data.dir)) { const ret: SelectedItems = {
const fullname = path ? `${path}/${name}` : name missing: new Set(),
const key = attr.key docs: {},
// Is this the file we are looking for? Ignore if nested within another selection. keys: [],
let r = relpath recursive: [],
if (selected.has(key) && !relpath) { }
ret.selected.add(key) for (const doc of this.document) {
ret.rootdir[name] = attr if (selected.has(doc.key)) {
r = name found.add(doc.key)
} else if (relpath) { ret.keys.push(doc.key)
r = `${relpath}/${name}` ret.docs[doc.key] = doc
}
if (r) {
ret.entries[key] = attr
ret.fullpath[key] = fullname
ret.relpath[key] = r
ret.ids.push(key)
if (!('dir' in attr)) ret.url[key] = `/files/${fullname}`
}
traverseDir(attr, fullname, r)
} }
} }
const selected = this.selected
const ret: SelectedItems = {
selected: new Set<FUID>(),
missing: new Set<FUID>(),
rootdir: {} as DirList,
entries: {} as Record<FUID, FileEntry | DirEntry>,
fullpath: {} as Record<FUID, string>,
relpath: {} as Record<FUID, string>,
url: {} as Record<FUID, string>,
ids: [] as FUID[]
}
traverseDir(this.root, '', '')
// What did we not select? // What did we not select?
for (const id of selected) { for (const key of selected) if (!found.has(key)) ret.missing.add(key)
if (!ret.selected.has(id)) ret.missing.add(id) // Build a flat list including contents recursively
const relnames = new Set<string>()
function add(rel: string, full: string, doc: Document) {
if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`)
relnames.add(rel)
ret.recursive.push([rel, full, doc])
} }
// Sorted array of FUIDs for easy traversal for (const key of ret.keys) {
ret.ids.sort((a, b) => const base = ret.docs[key]
ret.relpath[a].localeCompare(ret.relpath[b], undefined, { const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
numeric: true, const nremove = base.loc.length
sensitivity: 'base' add(base.name, basepath, base)
}) for (const doc of this.document) {
) if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
const rel = full.slice(nremove)
add(rel, full, doc)
}
}
}
// Sort by rel (name stored as on download)
ret.recursive.sort((a, b) => collator.compare(a[0], b[0]))
return ret return ret
} }
} }