19 Commits

Author SHA1 Message Date
Leo Vasanko
61f9026e23 Attempt to fix config handling on Windows 2023-11-13 16:31:35 -08:00
Leo Vasanko
3e50149d4d Add more quit points for watching thread. 2023-11-13 16:28:53 -08:00
Leo Vasanko
7077b21159 Add frontend build to Python packaging. Remove dead code, cleanup. 2023-11-13 16:19:33 -08:00
Leo Vasanko
938c5ca657 Add project URL 2023-11-13 14:59:22 -08:00
Leo Vasanko
e0aef07783 Update README 2023-11-13 14:49:08 -08:00
Leo Vasanko
36826a83c1 Remember sort order 2023-11-13 14:15:28 -08:00
Leo Vasanko
6880f82c19 Add file context menu (only rename for now). 2023-11-13 10:09:12 -08:00
Leo Vasanko
5dd1bd9bdc Add missing file 2023-11-13 09:55:32 -08:00
Leo Vasanko
41e8c78ecd Refactoring Document storage (#5)
- Major refactoring that makes Doc a class with properties
- Data made only shallow reactive, for a good speedup of initial load
- Minor bugfixes and UX improvements along the way
- Fixed handling of hash and question marks in URLs (was confusing Vue Router)
- Search made stricter to find good results (not ignore all punctuation)

Reviewed-on: #5
2023-11-13 17:52:57 +00:00
Leo Vasanko
dc4bb494f3 Use localStoragerather than sessionStorage for cache. Rename variable. 2023-11-13 13:04:39 +00:00
Leo Vasanko
9b58b887b4 Log messages on session loading 2023-11-13 12:17:47 +00:00
Leo Vasanko
07848907f3 Typing error 2023-11-13 12:11:02 +00:00
Leo Vasanko
7a08f7cbe2 Pasteing files and folders to upload. 2023-11-13 03:39:10 -08:00
Leo Vasanko
dd37238510 Update modified immediately when entering a folder 2023-11-13 02:19:13 -08:00
Leo Vasanko
c8d5f335b1 Fix upload of zero-sized files. 2023-11-13 02:13:11 -08:00
Leo Vasanko
bb80b3ee54 Clear file upload input to allow re-uploading the same item. 2023-11-13 01:38:22 -08:00
Leo Vasanko
06d860c601 Only update time-ago modified field on current folder (optimization, full update was slow for large storages). 2023-11-13 00:52:03 -08:00
Leo Vasanko
c321de13fd Don't reload backend on wwwroot changes. 2023-11-13 00:48:45 -08:00
Leo Vasanko
278e8303c4 Upload manager UI fix/tuning. 2023-11-13 00:37:56 -08:00
25 changed files with 378 additions and 254 deletions

View File

@@ -1,19 +1,23 @@
# Web File Storage # Web File Storage
Run directly from repository with Hatch (or use pip install as usual): The Python package installs a `cista` executable. Use `hatch shell` to initiate and install in a virtual environment, or `pip install` it on your system. Alternatively `hatch run cista` may be used to skip the shell step but stay virtual. `pip install hatch` first if needed.
```sh
hatch run cista -l :3000 /path/to/files
```
Settings incl. these arguments are stored to config file on the first startup and later `hatch run cista` is sufficient. If the `cista` script is missing, consider `pip install -e .` (within `hatch shell`) or some other trickery (known issue with installs made prior to adding the startup script).
Create your user account: Create your user account:
```sh ```sh
hatch run cista --user admin --privileged cista --user admin --privileged
``` ```
## Running the server
Serve your files on localhost:8000:
```sh
cista -l :8000 /path/to/files
```
The Git repository does not contain a frontend build, so you should first do that...
## Build frontend ## Build frontend
Frontend needs to be built before using and after any frontend changes: Frontend needs to be built before using and after any frontend changes:
@@ -25,3 +29,50 @@ npm run build
``` ```
This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`. This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`.
## Development setup
For rapid turnaround during development, you should run `npm run dev` Vite development server on the Vue frontend. While that is running, start the backend on another terminal `hatch run cista --dev -l :8000` and connect to the frontend.
The backend and the frontend will each reload automatically at any code or config changes.
## System deployment
Clone the repository to `/srv/cista/cista-storage` or other suitable location accessible to the storage user account you plan to use. `sudo -u storage -s` and build the frontend if you hadn't already.
Create **/etc/systemd/system/cista@.service**:
```ini
[Unit]
Description=Cista storage %i
[Service]
User=storage
WorkingDirectory=/srv/cista/cista-storage
ExecStart=hatch run cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/@%i/
TimeoutStopSec=2
Restart=always
[Install]
WantedBy=multi-user.target
```
This assumes you may want to run multiple separate storages, each having their files under `/media/storage/<domain>` and configuration under `/srv/cista/<domain>/`. Instead of numeric ports, we use UNIX sockets for convenience.
```sh
systemctl daemon-reload
systemctl enable --now cista@foo.example.com
systemctl enable --now cista@bar.example.com
```
Exposing this publicly online is the most convenient using the [Caddy](https://caddyserver.com/) web server but you can of course use Nginx or others as well. Or even run the server with `-l domain.example.com` given TLS certificates in the config folder.
**/etc/caddy/Caddyfile**:
```Caddyfile
foo.example.com, bar.example.com {
reverse_proxy unix//srv/cista/{host}/socket
}
```
Using the `{host}` placeholder we can just put all the domains on the same block. That's the full server configuration you need. `systemctl enable --now caddy` or `systemctl restart caddy` for the config to take effect.

View File

@@ -37,16 +37,23 @@ async def upload(req, ws):
) )
req = msgspec.json.decode(text, type=FileRange) req = msgspec.json.decode(text, type=FileRange)
pos = req.start pos = req.start
data = None while True:
while pos < req.end and (data := await ws.recv()) and isinstance(data, bytes): data = await ws.recv()
if not isinstance(data, bytes):
break
if len(data) > req.end - pos:
raise ValueError(
f"Expected up to {req.end - pos} bytes, got {len(data)} bytes"
)
sentsize = await alink(("upload", req.name, pos, data, req.size)) sentsize = await alink(("upload", req.name, pos, data, req.size))
pos += typing.cast(int, sentsize) pos += typing.cast(int, sentsize)
if pos >= req.end:
break
if pos != req.end: if pos != req.end:
d = f"{len(data)} bytes" if isinstance(data, bytes) else data d = f"{len(data)} bytes" if isinstance(data, bytes) else data
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}") raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
# Report success # Report success
res = StatusMsg(status="ack", req=req) res = StatusMsg(status="ack", req=req)
print("ack", res)
await asend(ws, res) await asend(ws, res)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import secrets import secrets
import sys
from functools import wraps from functools import wraps
from hashlib import sha256 from hashlib import sha256
from pathlib import Path, PurePath from pathlib import Path, PurePath
@@ -90,6 +91,8 @@ def config_update(modify):
return "read" return "read"
f.write(new) f.write(new)
f.close() f.close()
if sys.platform == "win32":
conffile.unlink() # Windows doesn't support atomic replace
tmpname.rename(conffile) # Atomic replace tmpname.rename(conffile) # Atomic replace
except: except:
f.close() f.close()

View File

@@ -34,9 +34,11 @@ class File:
self.open_rw() self.open_rw()
assert self.fd is not None assert self.fd is not None
if file_size is not None: if file_size is not None:
assert pos + len(buffer) <= file_size
os.ftruncate(self.fd, file_size) os.ftruncate(self.fd, file_size)
os.lseek(self.fd, pos, os.SEEK_SET) if buffer:
os.write(self.fd, buffer) os.lseek(self.fd, pos, os.SEEK_SET)
os.write(self.fd, buffer)
def __getitem__(self, slice): def __getitem__(self, slice):
if self.fd is None: if self.fd is None:

View File

@@ -149,15 +149,3 @@ class Space(msgspec.Struct):
free: int free: int
usage: int usage: int
storage: int storage: int
def make_dir_data(root):
if len(root) == 3:
return FileEntry(*root)
id_, size, mtime, listing = root
converted = {}
for name, data in listing.items():
converted[name] = make_dir_data(data)
sz = sum(x.size for x in converted.values())
mt = max(x.mtime for x in converted.values())
return DirEntry(id_, sz, max(mt, mtime), converted)

View File

@@ -1,6 +1,6 @@
import os import os
import re import re
from pathlib import Path, PurePath from pathlib import Path
from sanic import Sanic from sanic import Sanic
@@ -15,7 +15,6 @@ def run(*, dev=False):
# Silence Sanic's warning about running in production rather than debug # Silence Sanic's warning about running in production rather than debug
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
confdir = config.conffile.parent confdir = config.conffile.parent
wwwroot = PurePath(__file__).parent / "wwwroot"
if opts.get("ssl"): if opts.get("ssl"):
# Run plain HTTP redirect/acme server on port 80 # Run plain HTTP redirect/acme server on port 80
server80.app.prepare(port=80, motd=False) server80.app.prepare(port=80, motd=False)
@@ -27,7 +26,7 @@ def run(*, dev=False):
motd=False, motd=False,
dev=dev, dev=dev,
auto_reload=dev, auto_reload=dev,
reload_dir={confdir, wwwroot}, reload_dir={confdir},
access_log=True, access_log=True,
) # type: ignore ) # type: ignore
if dev: if dev:

View File

@@ -110,26 +110,6 @@ class State:
with self.lock: with self.lock:
del self._listing[self._slice(relpath)] del self._listing[self._slice(relpath)]
def _index(self, rel: PurePosixPath):
idx = 0
ret = []
def _dir(self, idx: int):
level = self._listing[idx].level + 1
end = len(self._listing)
idx += 1
ret = []
while idx < end and (r := self._listing[idx]).level >= level:
if r.level == level:
ret.append(idx)
return ret, idx
def update(self, rel: PurePosixPath, value: FileEntry):
begin = 0
parents = []
while self._listing[begin].level < len(rel.parts):
parents.append(begin)
state = State() state = State()
rootpath: Path = None # type: ignore rootpath: Path = None # type: ignore
@@ -149,7 +129,7 @@ def watcher_thread(loop):
global rootpath global rootpath
import inotify.adapters import inotify.adapters
while True: while not quit:
rootpath = config.config.path rootpath = config.config.path
i = inotify.adapters.InotifyTree(rootpath.as_posix()) i = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem # Initialize the tree from filesystem
@@ -160,8 +140,8 @@ def watcher_thread(loop):
state.root = new state.root = new
broadcast(format_update(old, new), loop) broadcast(format_update(old, new), loop)
# The watching is not entirely reliable, so do a full refresh every minute # The watching is not entirely reliable, so do a full refresh every 30 seconds
refreshdl = time.monotonic() + 60.0 refreshdl = time.monotonic() + 30.0
for event in i.event_gen(): for event in i.event_gen():
if quit: if quit:
@@ -238,11 +218,15 @@ def _walk(rel: PurePosixPath, isfile: int, st: stat_result) -> list[FileEntry]:
try: try:
li = [] li = []
for f in path.iterdir(): for f in path.iterdir():
if quit:
raise SystemExit("quit")
if f.name.startswith("."): if f.name.startswith("."):
continue # No dotfiles continue # No dotfiles
s = f.stat() s = f.stat()
li.append((int(not stat.S_ISDIR(s.st_mode)), f.name, s)) li.append((int(not stat.S_ISDIR(s.st_mode)), f.name, s))
for [isfile, name, s] in humansorted(li): for [isfile, name, s] in humansorted(li):
if quit:
raise SystemExit("quit")
subtree = _walk(rel / name, isfile, s) subtree = _walk(rel / name, isfile, s)
child = subtree[0] child = subtree[0]
entry.mtime = max(entry.mtime, child.mtime) entry.mtime = max(entry.mtime, child.mtime)
@@ -337,7 +321,7 @@ async def abroadcast(msg):
async def start(app, loop): async def start(app, loop):
config.load_config() config.load_config()
use_inotify = False and sys.platform == "linux" use_inotify = sys.platform == "linux"
app.ctx.watcher = threading.Thread( app.ctx.watcher = threading.Thread(
target=watcher_thread if use_inotify else watcher_thread_poll, target=watcher_thread if use_inotify else watcher_thread_poll,
args=[loop], args=[loop],

View File

@@ -17,7 +17,7 @@ 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, watchEffect } from 'vue'
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS' import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
import { useDocumentStore } from '@/stores/documents' import { useMainStore } from '@/stores/main'
import { computed } from 'vue' import { computed } from 'vue'
import Router from '@/router/index' import Router from '@/router/index'
@@ -27,7 +27,7 @@ interface Path {
pathList: string[] pathList: string[]
query: string query: string
} }
const documentStore = useDocumentStore() const store = useMainStore()
const path: ComputedRef<Path> = computed(() => { const path: ComputedRef<Path> = computed(() => {
const p = decodeURIComponent(Router.currentRoute.value.path).split('//') const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
const pathList = p[0].split('/').filter(value => value !== '') const pathList = p[0].split('/').filter(value => value !== '')
@@ -39,18 +39,16 @@ const path: ComputedRef<Path> = computed(() => {
} }
}) })
watchEffect(() => { watchEffect(() => {
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || documentStore.server.name || 'Cista Storage' document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage'
}) })
onMounted(loadSession) onMounted(loadSession)
onMounted(watchConnect) onMounted(watchConnect)
onUnmounted(watchDisconnect) onUnmounted(watchDisconnect)
// Update human-readable x seconds ago messages from mtimes
setInterval(documentStore.updateModified, 1000)
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
const globalShortcutHandler = (event: KeyboardEvent) => { const globalShortcutHandler = (event: KeyboardEvent) => {
const fileExplorer = documentStore.fileExplorer as any const fileExplorer = store.fileExplorer as any
if (!fileExplorer) return if (!fileExplorer) return
const c = fileExplorer.isCursor() const c = fileExplorer.isCursor()
const keyup = event.type === 'keyup' const keyup = event.type === 'keyup'
@@ -126,3 +124,4 @@ onUnmounted(() => {
}) })
export type { Path } export type { Path }
</script> </script>
@/stores/main

View File

@@ -4,7 +4,7 @@
aria-label="Breadcrumb" aria-label="Breadcrumb"
@keyup.left.stop="move(-1)" @keyup.left.stop="move(-1)"
@keyup.right.stop="move(1)" @keyup.right.stop="move(1)"
@focus="move(0)" @keyup.enter="move(0)"
> >
<a href="#/" <a href="#/"
:ref="el => setLinkRef(0, el)" :ref="el => setLinkRef(0, el)"
@@ -48,9 +48,11 @@ const navigate = (index: number) => {
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`) if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
const url = `/${longest.value.slice(0, index).join('/')}/` const url = `/${longest.value.slice(0, index).join('/')}/`
const here = `/${longest.value.join('/')}/` const here = `/${longest.value.join('/')}/`
const current = decodeURIComponent(location.hash.slice(1).split('//')[0])
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
if (here.startsWith(current)) router.replace(u)
else router.push(u)
link.focus() link.focus()
if (here.startsWith(location.hash.slice(1))) router.replace(url)
else router.push(url)
} }
const move = (dir: number) => { const move = (dir: number) => {

View File

@@ -5,9 +5,9 @@
<th class="selection"> <th class="selection">
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate"> <input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
</th> </th>
<th class="sortcolumn" :class="{ sortactive: sort === 'name' }" @click="toggleSort('name')">Name</th> <th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
<th class="sortcolumn modified right" :class="{ sortactive: sort === 'modified' }" @click="toggleSort('modified')">Modified</th> <th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
<th class="sortcolumn size right" :class="{ sortactive: sort === 'size' }" @click="toggleSort('size')">Size</th> <th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
<th class="menu"></th> <th class="menu"></th>
</tr> </tr>
</thead> </thead>
@@ -17,11 +17,11 @@
<td class="name"> <td class="name">
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" /> <FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
</td> </td>
<FileModified :doc=editing /> <FileModified :doc=editing :key=nowkey />
<FileSize :doc=editing /> <FileSize :doc=editing />
<td class="menu"></td> <td class="menu"></td>
</tr> </tr>
<template v-for="(doc, index) in sortedDocuments" :key="doc.key"> <template v-for="(doc, index) in documents" :key="doc.key">
<tr class="folder-change" v-if="showFolderBreadcrumb(index)"> <tr class="folder-change" v-if="showFolderBreadcrumb(index)">
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th> <th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
</tr> </tr>
@@ -36,11 +36,11 @@
<input <input
type="checkbox" type="checkbox"
tabindex="-1" tabindex="-1"
:checked="documentStore.selected.has(doc.key)" :checked="store.selected.has(doc.key)"
@change=" @change="
($event.target as HTMLInputElement).checked ($event.target as HTMLInputElement).checked
? documentStore.selected.add(doc.key) ? store.selected.add(doc.key)
: documentStore.selected.delete(doc.key) : store.selected.delete(doc.key)
" "
/> />
</td> </td>
@@ -50,7 +50,7 @@
</template> </template>
<template v-else> <template v-else>
<a <a
:href="url_for(doc)" :href="doc.url"
tabindex="-1" tabindex="-1"
@contextmenu.prevent @contextmenu.prevent
@focus.stop="cursor = doc" @focus.stop="cursor = doc"
@@ -61,7 +61,7 @@
<button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊</button> <button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊</button>
</template> </template>
</td> </td>
<FileModified :doc=doc /> <FileModified :doc=doc :key=nowkey />
<FileSize :doc=doc /> <FileSize :doc=doc />
<td class="menu"> <td class="menu">
<button tabindex="-1" @click.stop="contextMenu($event, doc)"></button> <button tabindex="-1" @click.stop="contextMenu($event, doc)"></button>
@@ -79,28 +79,26 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect } from 'vue' import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
import { useDocumentStore } from '@/stores/documents' import { useMainStore } from '@/stores/main'
import type { Document } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue' import FileRenameInput from './FileRenameInput.vue'
import { connect, controlUrl } from '@/repositories/WS' import { connect, controlUrl } from '@/repositories/WS'
import { collator, formatSize, formatUnixDate } from '@/utils' import { formatSize } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
import type { SortOrder } from '@/utils/docsort'
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
documents: Document[] documents: Doc[]
}>() }>()
const documentStore = useDocumentStore() const store = useMainStore()
const router = useRouter() const router = useRouter()
const url_for = (doc: Document) => { const cursor = shallowRef<Doc | null>(null)
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
return doc.dir ? `#/${p}/` : `/files/${p}`
}
const cursor = ref<Document | null>(null)
// File rename // File rename
const editing = ref<Document | null>(null) const editing = shallowRef<Doc | null>(null)
const rename = (doc: Document, newName: string) => { const rename = (doc: Doc, newName: string) => {
const oldName = doc.name const oldName = doc.name
const control = connect(controlUrl, { const control = connect(controlUrl, {
message(ev: MessageEvent) { message(ev: MessageEvent) {
@@ -124,35 +122,25 @@ const rename = (doc: Document, newName: string) => {
} }
doc.name = newName // We should get an update from watch but this is quicker doc.name = newName // We should get an update from watch but this is quicker
} }
const sortedDocuments = computed(() => sorted(props.documents as Document[]))
const showFolderBreadcrumb = (i: number) => {
const docs = sortedDocuments.value
const docloc = docs[i].loc
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
}
defineExpose({ defineExpose({
newFolder() { newFolder() {
const now = Date.now() / 1000 const now = Math.floor(Date.now() / 1000)
editing.value = { editing.value = new Doc({
loc: loc.value, loc: loc.value,
key: 'new', key: 'new',
name: 'New Folder', name: 'New Folder',
dir: true, dir: true,
mtime: now, mtime: now,
size: 0, size: 0,
sizedisp: formatSize(0), })
modified: formatUnixDate(now),
haystack: '',
}
console.log("New")
}, },
toggleSelectAll() { toggleSelectAll() {
console.log('Select') console.log('Select')
allSelected.value = !allSelected.value allSelected.value = !allSelected.value
}, },
toggleSortColumn(column: number) { toggleSortColumn(column: number) {
const columns = ['', 'name', 'modified', 'size', ''] const order = ['', 'name', 'modified', 'size', ''][column]
toggleSort(columns[column]) if (order) store.toggleSort(order as SortOrder)
}, },
isCursor() { isCursor() {
return cursor.value !== null && editing.value === null return cursor.value !== null && editing.value === null
@@ -163,36 +151,36 @@ defineExpose({
cursorSelect() { cursorSelect() {
const doc = cursor.value const doc = cursor.value
if (!doc) return if (!doc) return
if (documentStore.selected.has(doc.key)) { if (store.selected.has(doc.key)) {
documentStore.selected.delete(doc.key) store.selected.delete(doc.key)
} else { } else {
documentStore.selected.add(doc.key) store.selected.add(doc.key)
} }
this.cursorMove(1) this.cursorMove(1)
}, },
cursorMove(d: number, select = false) { cursorMove(d: number, select = false) {
// Move cursor up or down (keyboard navigation) // Move cursor up or down (keyboard navigation)
const documents = sortedDocuments.value const docs = props.documents
if (documents.length === 0) { if (docs.length === 0) {
cursor.value = null cursor.value = null
return return
} }
const N = documents.length const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = const index =
cursor.value !== null ? documents.indexOf(cursor.value) : documents.length cursor.value !== null ? docs.indexOf(cursor.value) : docs.length
const moveto = increment(index, d) const moveto = increment(index, d)
cursor.value = documents[moveto] ?? null cursor.value = docs[moveto] ?? null
const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null
if (select) { if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled // Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index] let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) { for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue if (p === N) continue
const key = documents[p].key const key = docs[p].key
if (documentStore.selected.has(key)) documentStore.selected.delete(key) if (store.selected.has(key)) store.selected.delete(key)
else documentStore.selected.add(key) else store.selected.add(key)
} }
} }
// @ts-ignore // @ts-ignore
@@ -229,7 +217,14 @@ watchEffect(() => {
focusBreadcrumb() focusBreadcrumb()
} }
}) })
const mkdir = (doc: Document, name: string) => { let nowkey = ref(0)
let modifiedTimer: any = null
const updateModified = () => {
nowkey.value = Math.floor(Date.now() / 1000)
}
onMounted(() => { updateModified(); modifiedTimer = setInterval(updateModified, 1000) })
onUnmounted(() => { clearInterval(modifiedTimer) })
const mkdir = (doc: Doc, name: string) => {
const control = connect(controlUrl, { const control = connect(controlUrl, {
open() { open() {
control.send( control.send(
@@ -246,34 +241,24 @@ const mkdir = (doc: Document, name: string) => {
editing.value = null editing.value = null
} else { } else {
console.log('mkdir', msg) console.log('mkdir', msg)
router.push(doc.loc ? `/${doc.loc}/${name}/` : `/${name}/`) router.push(doc.urlrouter)
} }
} }
}) })
doc.name = name // We should get an update from watch but this is quicker // We should get an update from watch but this is quicker
doc.name = name
doc.key = crypto.randomUUID()
} }
const showFolderBreadcrumb = (i: number) => {
// Column sort const docs = props.documents
const toggleSort = (name: string) => { const docloc = docs[i].loc
sort.value = sort.value === name ? '' : name return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
}
const sort = ref<string>('')
const sortCompare = {
name: (a: Document, b: Document) => collator.compare(a.name, b.name),
modified: (a: Document, b: Document) => b.mtime - a.mtime,
size: (a: Document, b: Document) => b.size - a.size
}
const sorted = (documents: Document[]) => {
const cmp = sortCompare[sort.value as keyof typeof sortCompare]
const sorted = [...documents]
if (cmp) sorted.sort(cmp)
return sorted
} }
const selectionIndeterminate = computed({ const selectionIndeterminate = computed({
get: () => { get: () => {
return ( return (
props.documents.length > 0 && props.documents.length > 0 &&
props.documents.some((doc: Document) => documentStore.selected.has(doc.key)) && props.documents.some((doc: Doc) => store.selected.has(doc.key)) &&
!allSelected.value !allSelected.value
) )
}, },
@@ -284,16 +269,16 @@ const allSelected = computed({
get: () => { get: () => {
return ( return (
props.documents.length > 0 && props.documents.length > 0 &&
props.documents.every((doc: Document) => documentStore.selected.has(doc.key)) props.documents.every((doc: Doc) => store.selected.has(doc.key))
) )
}, },
set: (value: boolean) => { set: (value: boolean) => {
console.log('Setting allSelected', value) console.log('Setting allSelected', value)
for (const doc of props.documents) { for (const doc of props.documents) {
if (value) { if (value) {
documentStore.selected.add(doc.key) store.selected.add(doc.key)
} else { } else {
documentStore.selected.delete(doc.key) store.selected.delete(doc.key)
} }
} }
} }
@@ -301,9 +286,13 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/')) const loc = computed(() => props.path.join('/'))
const contextMenu = (ev: Event, doc: Document) => { const contextMenu = (ev: MouseEvent, doc: Doc) => {
cursor.value = doc cursor.value = doc
console.log('Context menu', ev, doc) ContextMenu.showContextMenu({
x: ev.x, y: ev.y, items: [
{ label: 'Rename', onClick: () => { editing.value = doc } },
],
})
} }
</script> </script>
@@ -451,3 +440,4 @@ tbody .selection input {
color: #888; color: #888;
} }
</style> </style>
@/stores/main

View File

@@ -5,7 +5,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Document } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { computed } from 'vue' import { computed } from 'vue'
const datetime = computed(() => const datetime = computed(() =>
@@ -17,6 +17,6 @@ const tooltip = computed(() =>
) )
const props = defineProps<{ const props = defineProps<{
doc: Document doc: Doc
}>() }>()
</script> </script>

View File

@@ -12,7 +12,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Document } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { ref, onMounted, nextTick } from 'vue' import { ref, onMounted, nextTick } from 'vue'
const input = ref<HTMLInputElement | null>(null) const input = ref<HTMLInputElement | null>(null)
@@ -28,8 +28,8 @@ onMounted(() => {
}) })
const props = defineProps<{ const props = defineProps<{
doc: Document doc: Doc
rename: (doc: Document, newName: string) => void rename: (doc: Doc, newName: string) => void
exit: () => void exit: () => void
}>() }>()

View File

@@ -3,7 +3,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Document } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { computed } from 'vue' import { computed } from 'vue'
const sizeClass = computed(() => { const sizeClass = computed(() => {
@@ -12,7 +12,7 @@ const sizeClass = computed(() => {
}) })
const props = defineProps<{ const props = defineProps<{
doc: Document doc: Doc
}>() }>()
</script> </script>

View File

@@ -1,15 +1,15 @@
<template> <template>
<nav class="headermain"> <nav class="headermain">
<div class="buttons"> <div class="buttons">
<template v-if="documentStore.error"> <template v-if="store.error">
<div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div> <div class="error-message" @click="store.error = ''">{{ store.error }}</div>
<div class="smallgap"></div> <div class="smallgap"></div>
</template> </template>
<UploadButton :path="props.path" /> <UploadButton :path="props.path" />
<SvgButton <SvgButton
name="create-folder" name="create-folder"
data-tooltip="New folder" data-tooltip="New folder"
@click="() => documentStore.fileExplorer!.newFolder()" @click="() => store.fileExplorer!.newFolder()"
/> />
<slot></slot> <slot></slot>
<div class="spacer smallgap"></div> <div class="spacer smallgap"></div>
@@ -18,7 +18,6 @@
ref="search" ref="search"
type="search" type="search"
:value="query" :value="query"
@blur="ev => { if (!query) closeSearch(ev) }"
@input="updateSearch" @input="updateSearch"
placeholder="Search words" placeholder="Search words"
class="margin-input" class="margin-input"
@@ -32,15 +31,19 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useDocumentStore } from '@/stores/documents' import { useMainStore } from '@/stores/main'
import { ref, nextTick, watchEffect } from 'vue' import { ref, nextTick, watchEffect } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu' import ContextMenu from '@imengyu/vue3-context-menu'
import router from '@/router'; import router from '@/router';
const documentStore = useDocumentStore() const store = useMainStore()
const showSearchInput = ref<boolean>(false) const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>() const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>() const searchButton = ref<HTMLButtonElement | null>()
const props = defineProps<{
path: Array<string>
query: string
}>()
const closeSearch = (ev: Event) => { const closeSearch = (ev: Event) => {
if (!showSearchInput.value) return // Already closing if (!showSearchInput.value) return // Already closing
@@ -54,9 +57,9 @@ const updateSearch = (ev: Event) => {
let p = props.path.join('/') let p = props.path.join('/')
p = p ? `/${p}` : '' p = p ? `/${p}` : ''
const url = q ? `${p}//${q}` : (p || '/') const url = q ? `${p}//${q}` : (p || '/')
console.log("Update search", url) const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
if (!props.query && q) router.push(url) if (!props.query && q) router.push(u)
else router.replace(url) else router.replace(u)
} }
const toggleSearchInput = (ev: Event) => { const toggleSearchInput = (ev: Event) => {
showSearchInput.value = !showSearchInput.value showSearchInput.value = !showSearchInput.value
@@ -72,10 +75,10 @@ watchEffect(() => {
const settingsMenu = (e: Event) => { const settingsMenu = (e: Event) => {
// show the context menu // show the context menu
const items = [] const items = []
if (documentStore.user.isLoggedIn) { if (store.user.isLoggedIn) {
items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() }) items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
} else { } else {
items.push({ label: 'Login', onClick: () => documentStore.loginDialog() }) items.push({ label: 'Login', onClick: () => store.loginDialog() })
} }
ContextMenu.showContextMenu({ ContextMenu.showContextMenu({
// @ts-ignore // @ts-ignore
@@ -83,11 +86,6 @@ const settingsMenu = (e: Event) => {
items, items,
}) })
} }
const props = defineProps<{
path: Array<string>
query: string
}>()
defineExpose({ defineExpose({
toggleSearchInput, toggleSearchInput,
closeSearch, closeSearch,
@@ -116,3 +114,4 @@ input[type='search'] {
max-width: 30vw; max-width: 30vw;
} }
</style> </style>
@/stores/main

View File

@@ -1,29 +1,29 @@
<template> <template>
<template v-if="documentStore.selected.size"> <template v-if="store.selected.size">
<div class="smallgap"></div> <div class="smallgap"></div>
<p class="select-text">{{ documentStore.selected.size }} selected </p> <p class="select-text">{{ store.selected.size }} selected </p>
<SvgButton name="download" data-tooltip="Download" @click="download" /> <SvgButton name="download" data-tooltip="Download" @click="download" />
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" /> <SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" /> <SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
<SvgButton name="trash" data-tooltip="Delete " @click="op('rm')" /> <SvgButton name="trash" data-tooltip="Delete " @click="op('rm')" />
<button class="action-button unselect" data-tooltip="Unselect all" @click="documentStore.selected.clear()"></button> <button class="action-button unselect" data-tooltip="Unselect all" @click="store.selected.clear()"></button>
</template> </template>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {connect, controlUrl} from '@/repositories/WS' import {connect, controlUrl} from '@/repositories/WS'
import { useDocumentStore } from '@/stores/documents' import { useMainStore } from '@/stores/main'
import { computed } from 'vue' import { computed } from 'vue'
import type { SelectedItems } from '@/repositories/Document' import type { SelectedItems } from '@/repositories/Document'
const documentStore = useDocumentStore() const store = useMainStore()
const props = defineProps({ const props = defineProps({
path: Array<string> path: Array<string>
}) })
const dst = computed(() => props.path!.join('/')) const dst = computed(() => props.path!.join('/'))
const op = (op: string, dst?: string) => { const op = (op: string, dst?: string) => {
const sel = documentStore.selectedFiles const sel = store.selectedFiles
const msg = { const msg = {
op, op,
sel: sel.keys.map(key => { sel: sel.keys.map(key => {
@@ -38,12 +38,12 @@ const op = (op: string, dst?: string) => {
const res = JSON.parse(ev.data) const res = JSON.parse(ev.data)
if ('error' in res) { if ('error' in res) {
console.error('Control socket error', msg, res.error) console.error('Control socket error', msg, res.error)
documentStore.error = res.error.message store.error = res.error.message
return return
} else if (res.status === 'ack') { } else if (res.status === 'ack') {
console.log('Control ack OK', res) console.log('Control ack OK', res)
control.close() control.close()
documentStore.selected.clear() store.selected.clear()
return return
} else console.log('Unknown control response', msg, res) } else console.log('Unknown control response', msg, res)
} }
@@ -108,17 +108,17 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
} }
const download = async () => { const download = async () => {
const sel = documentStore.selectedFiles const sel = store.selectedFiles
console.log('Download', sel) console.log('Download', sel)
if (sel.keys.length === 0) { if (sel.keys.length === 0) {
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing) console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
documentStore.selected.clear() store.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 files = sel.recursive.filter(([rel, full, doc]) => !doc.dir) const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) { if (files.length === 1) {
documentStore.selected.clear() store.selected.clear()
return linkdl(`/files/${files[0][1]}`) 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
@@ -130,7 +130,7 @@ const download = async () => {
mode: 'readwrite' mode: 'readwrite'
}) })
filesystemdl(sel, handle).then(() => { filesystemdl(sel, handle).then(() => {
documentStore.selected.clear() store.selected.clear()
}) })
return return
} catch (e) { } catch (e) {
@@ -140,7 +140,7 @@ const download = async () => {
// Otherwise, zip and download // Otherwise, zip and download
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download' const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`) linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
documentStore.selected.clear() store.selected.clear()
} }
</script> </script>
@@ -152,3 +152,4 @@ const download = async () => {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
</style> </style>
@/stores/main

View File

@@ -39,10 +39,10 @@
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { loginUser } from '@/repositories/User' import { loginUser } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client' import type { ISimpleError } from '@/repositories/Client'
import { useDocumentStore } from '@/stores/documents' import { useMainStore } from '@/stores/main'
const confirmLoading = ref<boolean>(false) const confirmLoading = ref<boolean>(false)
const store = useDocumentStore() const store = useMainStore()
const loginForm = reactive({ const loginForm = reactive({
username: '', username: '',
@@ -99,3 +99,4 @@ const login = async () => {
height: 1em; height: 1em;
} }
</style> </style>
@/stores/main

View File

@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { connect, uploadUrl } from '@/repositories/WS'; import { connect, uploadUrl } from '@/repositories/WS';
import { useDocumentStore } from '@/stores/documents' import { useMainStore } from '@/stores/main'
import { collator } from '@/utils'; import { collator } from '@/utils';
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
const fileInput = ref() const fileInput = ref()
const folderInput = ref() const folderInput = ref()
const documentStore = useDocumentStore() const store = useMainStore()
const props = defineProps({ const props = defineProps({
path: Array<string> path: Array<string>
}) })
@@ -16,13 +16,51 @@ type CloudFile = {
cloudName: string cloudName: string
cloudPos: number cloudPos: number
} }
function pasteHandler(event: ClipboardEvent) {
const items = Array.from(event.clipboardData?.items ?? [])
const infiles = [] as File[]
const dirs = [] as FileSystemDirectoryEntry[]
for (const item of items) {
if (item.kind !== 'file') continue
const entry = item.webkitGetAsEntry()
if (entry?.isFile) {
const file = item.getAsFile()
if (file) infiles.push(file)
} else if (entry?.isDirectory) {
dirs.push(entry as FileSystemDirectoryEntry)
}
}
if (infiles.length || dirs.length) {
event.preventDefault()
uploadFiles(infiles)
for (const entry of dirs) pasteDirectory(entry, `${props.path!.join('/')}/${entry.name}`)
}
}
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
const reader = entry.createReader()
const entries = await new Promise<any[]>(resolve => reader.readEntries(resolve))
const cloudfiles = [] as CloudFile[]
for (const entry of entries) {
const cloudName = `${loc}/${entry.name}`
if (entry.isFile) {
const file = await new Promise(resolve => entry.file(resolve)) as File
cloudfiles.push({file, cloudName, cloudPos: 0})
} else if (entry.isDirectory) {
await pasteDirectory(entry, cloudName)
}
}
if (cloudfiles.length) uploadCloudFiles(cloudfiles)
}
function uploadHandler(event: Event) { function uploadHandler(event: Event) {
event.preventDefault() event.preventDefault()
event.stopPropagation()
// @ts-ignore // @ts-ignore
const infiles = Array.from(event.dataTransfer?.files || event.target.files) as File[] const input = event.target as HTMLInputElement | null
if (!infiles.length) return const infiles = Array.from((input ?? (event as DragEvent).dataTransfer)?.files ?? []) as File[]
if (input) input.value = ''
if (infiles.length) uploadFiles(infiles)
}
const uploadFiles = (infiles: File[]) => {
const loc = props.path!.join('/') const loc = props.path!.join('/')
let files = [] let files = []
for (const file of infiles) { for (const file of infiles) {
@@ -32,9 +70,12 @@ function uploadHandler(event: Event) {
cloudPos: 0, cloudPos: 0,
}) })
} }
uploadCloudFiles(files)
}
const uploadCloudFiles = (files: CloudFile[]) => {
const dotfiles = files.filter(f => f.cloudName.includes('/.')) const dotfiles = files.filter(f => f.cloudName.includes('/.'))
if (dotfiles.length) { if (dotfiles.length) {
documentStore.error = "Won't upload dotfiles" store.error = "Won't upload dotfiles"
console.log("Dotfiles omitted", dotfiles) console.log("Dotfiles omitted", dotfiles)
files = files.filter(f => !f.cloudName.includes('/.')) files = files.filter(f => !f.cloudName.includes('/.'))
} }
@@ -76,7 +117,7 @@ const speed = computed(() => {
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
return s // "Current speed" return s // "Current speed"
}) })
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 100 ? 1 : 0) + '\u202FMB/s': 'stalled') const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
setInterval(() => { setInterval(() => {
if (Date.now() - uprogress.tlast > 3000) { if (Date.now() - uprogress.tlast > 3000) {
// Reset // Reset
@@ -130,13 +171,13 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
open(ev: Event) { resolve(ws) }, open(ev: Event) { resolve(ws) },
error(ev: Event) { error(ev: Event) {
console.error('Upload socket error', ev) console.error('Upload socket error', ev)
documentStore.error = 'Upload socket error' store.error = 'Upload socket error'
}, },
message(ev: MessageEvent) { message(ev: MessageEvent) {
const res = JSON.parse(ev!.data) const res = JSON.parse(ev!.data)
if ('error' in res) { if ('error' in res) {
console.error('Upload socket error', res.error) console.error('Upload socket error', res.error)
documentStore.error = res.error.message store.error = res.error.message
return return
} }
if (res.status === 'ack') { if (res.status === 'ack') {
@@ -165,10 +206,6 @@ const worker = async () => {
const ws = await WSCreate() const ws = await WSCreate()
while (upqueue.length) { while (upqueue.length) {
const f = upqueue[0] const f = upqueue[0]
if (f.cloudPos === f.file.size) {
upqueue.shift()
continue
}
const start = f.cloudPos const start = f.cloudPos
const end = Math.min(f.file.size, start + (1<<20)) const end = Math.min(f.file.size, start + (1<<20))
const control = { name: f.cloudName, size: f.file.size, start, end } const control = { name: f.cloudName, size: f.file.size, start, end }
@@ -179,6 +216,7 @@ const worker = async () => {
ws.sendMsg(control) ws.sendMsg(control)
// @ts-ignore // @ts-ignore
await ws.sendData(data) await ws.sendData(data)
if (f.cloudPos === f.file.size) upqueue.shift()
} }
if (upqueue.length) startWorker() if (upqueue.length) startWorker()
uprogress.status = "idle" uprogress.status = "idle"
@@ -196,8 +234,10 @@ onMounted(() => {
// Need to prevent both to prevent browser from opening the file // Need to prevent both to prevent browser from opening the file
addEventListener('dragover', uploadHandler) addEventListener('dragover', uploadHandler)
addEventListener('drop', uploadHandler) addEventListener('drop', uploadHandler)
addEventListener('paste', pasteHandler)
}) })
onUnmounted(() => { onUnmounted(() => {
removeEventListener('paste', pasteHandler)
removeEventListener('dragover', uploadHandler) removeEventListener('dragover', uploadHandler)
removeEventListener('drop', uploadHandler) removeEventListener('drop', uploadHandler)
}) })
@@ -219,7 +259,7 @@ onUnmounted(() => {
{{ (uprogress.filepos / uprogress.filesize * 100).toFixed(0) + '\u202F%' }} {{ (uprogress.filepos / uprogress.filesize * 100).toFixed(0) + '\u202F%' }}
</span> </span>
</span> </span>
<span class="position" v-if="uprogress.filesize > 1e7"> <span class="position" v-if="uprogress.total > 1e7">
{{ (uprogress.uploaded / 1e6).toFixed(0) + '\u202F/\u202F' + (uprogress.total / 1e6).toFixed(0) + '\u202FMB' }} {{ (uprogress.uploaded / 1e6).toFixed(0) + '\u202F/\u202F' + (uprogress.total / 1e6).toFixed(0) + '\u202FMB' }}
</span> </span>
<span class="speed">{{ speeddisp }}</span> <span class="speed">{{ speeddisp }}</span>
@@ -262,3 +302,4 @@ span {
.position { min-width: 4em } .position { min-width: 4em }
.speed { min-width: 4em } .speed { min-width: 4em }
</style> </style>
@/stores/main

View File

@@ -1,17 +1,42 @@
import { formatSize, formatUnixDate, haystackFormat } from "@/utils"
export type FUID = string export type FUID = string
export type Document = { export type DocProps = {
loc: string loc: string
name: string name: string
key: FUID key: FUID
size: number size: number
sizedisp: string
mtime: number mtime: number
modified: string
haystack: string
dir: boolean dir: boolean
} }
export class Doc {
private _name: string = ""
public loc: string = ""
public key: FUID = ""
public size: number = 0
public mtime: number = 0
public haystack: string = ""
public dir: boolean = false
constructor(props: Partial<DocProps> = {}) { Object.assign(this, props) }
get name() { return this._name }
set name(name: string) {
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
this._name = name
this.haystack = haystackFormat(name)
}
get sizedisp(): string { return formatSize(this.size) }
get modified(): string { return formatUnixDate(this.mtime) }
get url(): string {
const p = this.loc ? `${this.loc}/${this.name}` : this.name
return this.dir ? '/#/' + `${p}/`.replaceAll('#', '%23') : `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
}
get urlrouter(): string {
return this.url.replace(/^\/#/, '')
}
}
export type errorEvent = { export type errorEvent = {
error: { error: {
code: number code: number
@@ -36,7 +61,7 @@ export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>
// Helper structure for selections // Helper structure for selections
export interface SelectedItems { export interface SelectedItems {
keys: FUID[] keys: FUID[]
docs: Record<FUID, Document> docs: Record<FUID, Doc>
recursive: Array<[string, string, Document]> recursive: Array<[string, string, Doc]>
missing: Set<FUID> missing: Set<FUID>
} }

View File

@@ -1,4 +1,4 @@
import { useDocumentStore } from "@/stores/documents" import { useMainStore } from "@/stores/main"
import type { FileEntry, UpdateEntry, errorEvent } from "./Document" import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
export const controlUrl = '/api/control' export const controlUrl = '/api/control'
@@ -6,22 +6,26 @@ export const uploadUrl = '/api/upload'
export const watchUrl = '/api/watch' export const watchUrl = '/api/watch'
let tree = [] as FileEntry[] let tree = [] as FileEntry[]
let reconnectDuration = 500 let reconnDelay = 500
let wsWatch = null as WebSocket | null let wsWatch = null as WebSocket | null
export const loadSession = () => { export const loadSession = () => {
const store = useDocumentStore() const s = localStorage['cista-files']
if (!s) return false
const store = useMainStore()
try { try {
tree = JSON.parse(sessionStorage["cista-files"]) tree = JSON.parse(s)
store.updateRoot(tree) store.updateRoot(tree)
console.log(`Loaded session with ${tree.length} items cached`)
return true return true
} catch (error) { } catch (error) {
console.log("Loading session failed", error)
return false return false
} }
} }
const saveSession = () => { const saveSession = () => {
sessionStorage["cista-files"] = JSON.stringify(tree) localStorage["cista-files"] = JSON.stringify(tree)
} }
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => { export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
@@ -35,7 +39,7 @@ export const watchConnect = () => {
clearTimeout(watchTimeout) clearTimeout(watchTimeout)
watchTimeout = null watchTimeout = null
} }
const store = useDocumentStore() const store = useMainStore()
if (store.error !== 'Reconnecting...') store.error = 'Connecting...' if (store.error !== 'Reconnecting...') store.error = 'Connecting...'
console.log(store.error) console.log(store.error)
@@ -59,7 +63,7 @@ export const watchConnect = () => {
console.log('Connected to backend', msg) console.log('Connected to backend', msg)
store.server = msg.server store.server = msg.server
store.connected = true store.connected = true
reconnectDuration = 500 reconnDelay = 500
store.error = '' store.error = ''
if (msg.user) store.login(msg.user.username, msg.user.privileged) if (msg.user) store.login(msg.user.username, msg.user.privileged)
else if (store.isUserLogged) store.logout() else if (store.isUserLogged) store.logout()
@@ -77,16 +81,16 @@ export const watchDisconnect = () => {
let watchTimeout: any = null let watchTimeout: any = null
const watchReconnect = (event: MessageEvent) => { const watchReconnect = (event: MessageEvent) => {
const store = useDocumentStore() const store = useMainStore()
if (store.connected) { if (store.connected) {
console.warn("Disconnected from server", event) console.warn("Disconnected from server", event)
store.connected = false store.connected = false
store.error = 'Reconnecting...' store.error = 'Reconnecting...'
} }
reconnectDuration = Math.min(5000, reconnectDuration + 500) reconnDelay = Math.min(5000, reconnDelay + 500)
// The server closes the websocket after errors, so we need to reopen it // The server closes the websocket after errors, so we need to reopen it
if (watchTimeout !== null) clearTimeout(watchTimeout) if (watchTimeout !== null) clearTimeout(watchTimeout)
watchTimeout = setTimeout(watchConnect, reconnectDuration) watchTimeout = setTimeout(watchConnect, reconnDelay)
} }
@@ -110,7 +114,7 @@ const handleWatchMessage = (event: MessageEvent) => {
} }
function handleRootMessage({ root }: { root: FileEntry[] }) { function handleRootMessage({ root }: { root: FileEntry[] }) {
const store = useDocumentStore() const store = useMainStore()
console.log('Watch root', root) console.log('Watch root', root)
store.updateRoot(root) store.updateRoot(root)
tree = root tree = root
@@ -118,7 +122,7 @@ function handleRootMessage({ root }: { root: FileEntry[] }) {
} }
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) { function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
const store = useDocumentStore() const store = useMainStore()
const update = updateData.update const update = updateData.update
console.log('Watch update', update) console.log('Watch update', update)
if (!tree) return console.error('Watch update before root') if (!tree) return console.error('Watch update before root')
@@ -142,7 +146,7 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
} }
function handleError(msg: errorEvent) { function handleError(msg: errorEvent) {
const store = useDocumentStore() const store = useMainStore()
if (msg.error.code === 401) { if (msg.error.code === 401) {
store.user.isOpenLoginModal = true store.user.isOpenLoginModal = true
store.user.isLoggedIn = false store.user.isLoggedIn = false

View File

@@ -1,14 +1,12 @@
import type { Document, FileEntry, FUID, SelectedItems } from '@/repositories/Document' import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
import { formatSize, formatUnixDate, haystackFormat } from '@/utils' import { Doc } from '@/repositories/Document'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { collator } from '@/utils' import { collator } from '@/utils'
import { logoutUser } from '@/repositories/User' import { logoutUser } from '@/repositories/User'
import { watchConnect } from '@/repositories/WS' import { watchConnect } from '@/repositories/WS'
import { shallowRef } from 'vue'
import { sorted, type SortOrder } from '@/utils/docsort'
type FileData = { id: string; mtime: number; size: number; dir: DirectoryData }
type DirectoryData = {
[filename: string]: FileData
}
type User = { type User = {
username: string username: string
privileged: boolean privileged: boolean
@@ -16,15 +14,20 @@ type User = {
isLoggedIn: boolean isLoggedIn: boolean
} }
export const useDocumentStore = defineStore({ export const useMainStore = defineStore({
id: 'documents', id: 'main',
state: () => ({ state: () => ({
document: [] as Document[], document: shallowRef<Doc[]>([]),
selected: new Set<FUID>(), selected: new Set<FUID>(),
query: '' as string,
fileExplorer: null as any, fileExplorer: null as any,
error: '' as string, error: '' as string,
connected: false, connected: false,
server: {} as Record<string, any>, server: {} as Record<string, any>,
prefs: {
sortListing: '' as SortOrder,
sortFiltered: '' as SortOrder,
},
user: { user: {
username: '', username: '',
privileged: false, privileged: false,
@@ -32,29 +35,26 @@ export const useDocumentStore = defineStore({
isOpenLoginModal: false isOpenLoginModal: false
} as User } as User
}), }),
persist: {
paths: ['prefs'],
},
actions: { actions: {
updateRoot(root: FileEntry[]) { updateRoot(root: FileEntry[]) {
const docs = [] const docs = []
let loc = [] as string[] let loc = [] as string[]
for (const [level, name, key, mtime, size, isfile] of root) { for (const [level, name, key, mtime, size, isfile] of root) {
loc = loc.slice(0, level - 1) loc = loc.slice(0, level - 1)
docs.push({ docs.push(new Doc({
name, name,
loc: level ? loc.join('/') : '/', loc: level ? loc.join('/') : '/',
key, key,
size, size,
sizedisp: formatSize(size),
mtime, mtime,
modified: formatUnixDate(mtime),
haystack: haystackFormat(name),
dir: !isfile, dir: !isfile,
}) }))
loc.push(name) loc.push(name)
} }
this.document = docs as Document[] this.document = docs
},
updateModified() {
for (const doc of this.document) doc.modified = formatUnixDate(doc.mtime)
}, },
login(username: string, privileged: boolean) { login(username: string, privileged: boolean) {
this.user.username = username this.user.username = username
@@ -70,23 +70,18 @@ export const useDocumentStore = defineStore({
console.log("Logout") console.log("Logout")
await logoutUser() await logoutUser()
this.$reset() this.$reset()
localStorage.clear()
history.go() // Reload page history.go() // Reload page
} },
toggleSort(name: SortOrder) {
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name
},
}, },
getters: { getters: {
isUserLogged(): boolean { sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
return this.user.isLoggedIn isUserLogged(): boolean { return this.user.isLoggedIn },
}, recentDocuments(): Doc[] { return sorted(this.document, 'modified') },
recentDocuments(): Document[] {
const ret = [...this.document]
ret.sort((a, b) => b.mtime - a.mtime)
return ret
},
largeDocuments(): Document[] {
const ret = [...this.document]
ret.sort((a, b) => b.size - a.size)
return ret
},
selectedFiles(): SelectedItems { selectedFiles(): SelectedItems {
const selected = this.selected const selected = this.selected
const found = new Set<FUID>() const found = new Set<FUID>()
@@ -107,7 +102,7 @@ export const useDocumentStore = defineStore({
for (const key of selected) if (!found.has(key)) ret.missing.add(key) for (const key of selected) if (!found.has(key)) ret.missing.add(key)
// Build a flat list including contents recursively // Build a flat list including contents recursively
const relnames = new Set<string>() const relnames = new Set<string>()
function add(rel: string, full: string, doc: Document) { function add(rel: string, full: string, doc: Doc) {
if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`) if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`)
relnames.add(rel) relnames.add(rel)
ret.recursive.push([rel, full, doc]) ret.recursive.push([rel, full, doc])

View File

@@ -0,0 +1,15 @@
import { Doc } from '@/repositories/Document'
import { collator } from '@/utils'
export const ordering = {
name: (a: Doc, b: Doc) => collator.compare(a.name, b.name),
modified: (a: Doc, b: Doc) => b.mtime - a.mtime,
size: (a: Doc, b: Doc) => b.size - a.size
}
export type SortOrder = keyof typeof ordering | ''
export const sorted = (documents: Doc[], order: SortOrder) => {
if (!order) return documents
const sorted = [...documents]
sorted.sort(ordering[order])
return sorted
}

View File

@@ -86,7 +86,7 @@ export function haystackFormat(str: string) {
// Preformat search string for faster search // Preformat search string for faster search
export function needleFormat(query: string) { export function needleFormat(query: string) {
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
return {based, words: based.split(/\W+/)} return {based, words: based.split(/\s+/)}
} }
// Test if haystack includes needle // Test if haystack includes needle

View File

@@ -10,11 +10,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { watchEffect, ref, computed } from 'vue' import { watchEffect, ref, computed } from 'vue'
import { useDocumentStore } from '@/stores/documents' import { useMainStore } from '@/stores/main'
import Router from '@/router/index' import Router from '@/router/index'
import { needleFormat, localeIncludes, collator } from '@/utils'; import { needleFormat, localeIncludes, collator } from '@/utils';
import { sorted } from '@/utils/docsort';
const documentStore = useDocumentStore() const store = useMainStore()
const fileExplorer = ref() const fileExplorer = ref()
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
@@ -24,19 +25,25 @@ const documents = computed(() => {
const loc = props.path.join('/') const loc = props.path.join('/')
const query = props.query const query = props.query
// List the current location // List the current location
if (!query) return documentStore.document.filter(doc => doc.loc === loc) if (!query) return sorted(
store.document.filter(doc => doc.loc === loc),
store.prefs.sortListing,
)
// Find up to 100 newest documents that match the search // Find up to 100 newest documents that match the search
const needle = needleFormat(query) const needle = needleFormat(query)
let limit = 100 let limit = 100
let docs = [] let docs = []
for (const doc of documentStore.recentDocuments) { for (const doc of store.recentDocuments) {
if (localeIncludes(doc.haystack, needle)) { if (localeIncludes(doc.haystack, needle)) {
docs.push(doc) docs.push(doc)
if (--limit === 0) break if (--limit === 0) break
} }
} }
// Organize by folder, by relevance
const locsub = loc + '/' const locsub = loc + '/'
// Custom sort override in effect?
const order = store.prefs.sortFiltered
if (order) return sorted(docs, order)
// Sort by relevance - current folder, then subfolders, then others
docs.sort((a, b) => ( docs.sort((a, b) => (
// @ts-ignore // @ts-ignore
(b.loc === loc) - (a.loc === loc) || (b.loc === loc) - (a.loc === loc) ||
@@ -53,6 +60,7 @@ const documents = computed(() => {
}) })
watchEffect(() => { watchEffect(() => {
documentStore.fileExplorer = fileExplorer.value store.fileExplorer = fileExplorer.value
store.query = props.query
}) })
</script> </script>

View File

@@ -29,7 +29,7 @@ dependencies = [
] ]
[project.urls] [project.urls]
Homepage = "" Homepage = "https://git.zi.fi/Vasanko/cista-storage"
[project.scripts] [project.scripts]
cista = "cista.__main__:main" cista = "cista.__main__:main"
@@ -40,20 +40,18 @@ dev = [
"ruff", "ruff",
] ]
[tool.hatchling]
# Build frontend
pre_build = "npm run build --prefix cista-front"
[tool.hatch.version] [tool.hatch.version]
source = "vcs" source = "vcs"
[tool.hatch.build] [tool.hatch.build]
artifacts = ["cista/wwwroot"]
hooks.custom.path = "scripts/build-frontend.py"
hooks.vcs.version-file = "cista/_version.py" hooks.vcs.version-file = "cista/_version.py"
hooks.vcs.template = """ hooks.vcs.template = """
# This file is automatically generated by hatch build. # This file is automatically generated by hatch build.
__version__ = {version!r} __version__ = {version!r}
""" """
only-packages = true
targets.sdist.include = [ targets.sdist.include = [
"/cista", "/cista",
] ]

12
scripts/build-frontend.py Normal file
View File

@@ -0,0 +1,12 @@
# noqa: INP001
import subprocess
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data):
super().initialize(version, build_data)
print("Building Cista frontend...")
subprocess.run("npm install --prefix frontend".split(" "), check=True) # noqa: S603
subprocess.run("npm run build --prefix frontend".split(" "), check=True) # noqa: S603