12 Commits

Author SHA1 Message Date
Leo Vasanko
2978e0c968 Better special handling for root Document. Restore live updates of relative modified times. 2023-11-12 20:09:29 +00:00
Leo Vasanko
540e825cc3 Watching code cleanup 2023-11-12 19:58:37 +00:00
Leo Vasanko
0be72827db Remove test.zip generation 2023-11-12 19:40:05 +00:00
Leo Vasanko
88aca511e7 Colored file size 2023-11-12 19:38:42 +00:00
Leo Vasanko
be1c4c1504 Fixes to URL path handling, search query on URL 2023-11-12 19:38:23 +00:00
Leo Vasanko
00a4297c0b New filelist format on frontend 2023-11-12 19:37:17 +00:00
Leo Vasanko
ef5e37187d Completely revamped file list format. Fixes to zip creation. 2023-11-12 19:35:20 +00:00
Leo Vasanko
a70549e6ec Formatting 2023-11-12 12:08:31 +00:00
Leo Vasanko
535905780a Refactor file modified/size fields components, style different sizes 2023-11-12 11:55:28 +00:00
Leo Vasanko
82bc449bbc Rename frontend dir, update README. 2023-11-11 14:54:30 +00:00
Leo Vasanko
5d32396127 Modified column needs more space 2023-11-11 14:49:46 +00:00
Leo Vasanko
84ce4b9220 Fix various typing errors in backend. 2023-11-11 14:49:23 +00:00
26 changed files with 370 additions and 586 deletions

View File

@@ -1,23 +1,19 @@
# Web File Storage # Web File Storage
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. Run directly from repository with Hatch (or use pip install as usual):
```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
cista --user admin --privileged hatch run 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:
@@ -29,50 +25,3 @@ 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,23 +37,16 @@ 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
while True: data = None
data = await ws.recv() while pos < req.end and (data := await ws.recv()) and isinstance(data, bytes):
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

@@ -34,11 +34,9 @@ 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)
if buffer: os.lseek(self.fd, pos, os.SEEK_SET)
os.lseek(self.fd, pos, os.SEEK_SET) os.write(self.fd, buffer)
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

@@ -120,9 +120,6 @@ class FileEntry(msgspec.Struct, array_like=True):
size: int size: int
isfile: int isfile: int
def __repr__(self):
return self.key or "FileEntry()"
class Update(msgspec.Struct, array_like=True): class Update(msgspec.Struct, array_like=True):
... ...
@@ -140,10 +137,6 @@ class UpdIns(Update, tag="i"):
items: list[FileEntry] items: list[FileEntry]
class UpdateMessage(msgspec.Struct):
update: list[UpdKeep | UpdDel | UpdIns]
class Space(msgspec.Struct): class Space(msgspec.Struct):
disk: int disk: int
free: int free: int

View File

@@ -1,6 +1,6 @@
import os import os
import re import re
from pathlib import Path from pathlib import Path, PurePath
from sanic import Sanic from sanic import Sanic
@@ -15,6 +15,7 @@ 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)
@@ -26,7 +27,7 @@ def run(*, dev=False):
motd=False, motd=False,
dev=dev, dev=dev,
auto_reload=dev, auto_reload=dev,
reload_dir={confdir}, reload_dir={confdir, wwwroot},
access_log=True, access_log=True,
) # type: ignore ) # type: ignore
if dev: if dev:

View File

@@ -50,42 +50,37 @@ class State:
begin, end = 0, len(self._listing) begin, end = 0, len(self._listing)
level = 0 level = 0
isfile = 0 isfile = 0
while level < len(relpath.parts):
# Special case for root # Enter a subdirectory
if not relpath.parts:
return slice(begin, end)
begin += 1
for part in relpath.parts:
level += 1 level += 1
found = False begin += 1
if level == len(relpath.parts):
while begin < end: isfile = relfile
entry = self._listing[begin] name = relpath.parts[level - 1]
namesort = sortkey(name)
if entry.level < level: r = self._listing[begin]
break assert r.level == level
# Iterate over items at this level
if entry.level == level: while (
if entry.name == part: begin < end
found = True and r.name != name
if level == len(relpath.parts): and r.isfile <= isfile
isfile = relfile and sortkey(r.name) < namesort
else: ):
begin += 1 # Skip contents
break
cmp = entry.isfile - isfile or sortkey(entry.name) > sortkey(part)
if cmp > 0:
break
begin += 1 begin += 1
while begin < end and self._listing[begin].level > level:
if not found: begin += 1
# Not found?
if begin == end or self._listing[begin].level < level:
return slice(begin, begin)
r = self._listing[begin]
# Not found?
if begin == end or r.name != name:
return slice(begin, begin) return slice(begin, begin)
# Found an item, now find its end
# Found the starting point, now find the end of the slice for end in range(begin + 1, len(self._listing)):
for end in range(begin + 1, len(self._listing) + 1): if self._listing[end].level <= level:
if end == len(self._listing) or self._listing[end].level <= level:
break break
return slice(begin, end) return slice(begin, end)
@@ -153,12 +148,11 @@ def watcher_thread(loop):
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
new = walk() old, new = state.root, walk()
with state.lock: if old != new:
old = state.root with state.lock:
if old != new:
state.root = new state.root = new
broadcast(format_update(old, new), loop) broadcast(format_root(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 minute
refreshdl = time.monotonic() + 60.0 refreshdl = time.monotonic() + 60.0
@@ -196,10 +190,10 @@ def watcher_thread_poll(loop):
while not quit: while not quit:
rootpath = config.config.path rootpath = config.config.path
old = state.root
new = walk() new = walk()
with state.lock: if old != new:
old = state.root with state.lock:
if old != new:
state.root = new state.root = new
broadcast(format_update(old, new), loop) broadcast(format_update(old, new), loop)
@@ -289,11 +283,13 @@ def format_update(old, new):
del_count = 0 del_count = 0
rest = new[nidx:] rest = new[nidx:]
while oidx < len(old) and old[oidx] not in rest: while old[oidx] not in rest:
del_count += 1 del_count += 1
oidx += 1 oidx += 1
if del_count: if del_count:
update.append(UpdDel(del_count)) update.append(UpdDel(del_count))
oidx += 1
continue continue
insert_items = [] insert_items = []
@@ -337,9 +333,8 @@ 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"
app.ctx.watcher = threading.Thread( app.ctx.watcher = threading.Thread(
target=watcher_thread if use_inotify else watcher_thread_poll, target=watcher_thread if sys.platform == "linux" else watcher_thread_poll,
args=[loop], args=[loop],
) )
app.ctx.watcher.start() app.ctx.watcher.start()

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 { useMainStore } from '@/stores/main' import { useDocumentStore } from '@/stores/documents'
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 store = useMainStore() const documentStore = useDocumentStore()
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,16 +39,18 @@ const path: ComputedRef<Path> = computed(() => {
} }
}) })
watchEffect(() => { watchEffect(() => {
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage' document.title = path.value.path.replace(/\/$/, '').split('/').pop() || documentStore.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 = store.fileExplorer as any const fileExplorer = documentStore.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'
@@ -124,4 +126,3 @@ 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)"
@keyup.enter="move(0)" @focus="move(0)"
> >
<a href="#/" <a href="#/"
:ref="el => setLinkRef(0, el)" :ref="el => setLinkRef(0, el)"
@@ -48,11 +48,9 @@ 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: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th> <th class="sortcolumn" :class="{ sortactive: sort === 'name' }" @click="toggleSort('name')">Name</th>
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th> <th class="sortcolumn modified right" :class="{ sortactive: sort === 'modified' }" @click="toggleSort('modified')">Modified</th>
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th> <th class="sortcolumn size right" :class="{ sortactive: sort === 'size' }" @click="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 :key=nowkey /> <FileModified :doc=editing />
<FileSize :doc=editing /> <FileSize :doc=editing />
<td class="menu"></td> <td class="menu"></td>
</tr> </tr>
<template v-for="(doc, index) in documents" :key="doc.key"> <template v-for="(doc, index) in sortedDocuments" :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="store.selected.has(doc.key)" :checked="documentStore.selected.has(doc.key)"
@change=" @change="
($event.target as HTMLInputElement).checked ($event.target as HTMLInputElement).checked
? store.selected.add(doc.key) ? documentStore.selected.add(doc.key)
: store.selected.delete(doc.key) : documentStore.selected.delete(doc.key)
" "
/> />
</td> </td>
@@ -50,7 +50,7 @@
</template> </template>
<template v-else> <template v-else>
<a <a
:href="doc.url" :href="url_for(doc)"
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 :key=nowkey /> <FileModified :doc=doc />
<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,26 +79,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue' import { ref, computed, watchEffect } from 'vue'
import { useMainStore } from '@/stores/main' import { useDocumentStore } from '@/stores/documents'
import { Doc } from '@/repositories/Document' import type { Document } 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 { formatSize } from '@/utils' import { collator, formatSize, formatUnixDate } 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: Doc[] documents: Document[]
}>() }>()
const store = useMainStore() const documentStore = useDocumentStore()
const router = useRouter() const router = useRouter()
const cursor = shallowRef<Doc | null>(null) const url_for = (doc: Document) => {
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 = shallowRef<Doc | null>(null) const editing = ref<Document | null>(null)
const rename = (doc: Doc, newName: string) => { const rename = (doc: Document, newName: string) => {
const oldName = doc.name const oldName = doc.name
const control = connect(controlUrl, { const control = connect(controlUrl, {
message(ev: MessageEvent) { message(ev: MessageEvent) {
@@ -122,25 +124,35 @@ const rename = (doc: Doc, 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 = Math.floor(Date.now() / 1000) const now = Date.now() / 1000
editing.value = new Doc({ editing.value = {
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 order = ['', 'name', 'modified', 'size', ''][column] const columns = ['', 'name', 'modified', 'size', '']
if (order) store.toggleSort(order as SortOrder) toggleSort(columns[column])
}, },
isCursor() { isCursor() {
return cursor.value !== null && editing.value === null return cursor.value !== null && editing.value === null
@@ -151,36 +163,36 @@ defineExpose({
cursorSelect() { cursorSelect() {
const doc = cursor.value const doc = cursor.value
if (!doc) return if (!doc) return
if (store.selected.has(doc.key)) { if (documentStore.selected.has(doc.key)) {
store.selected.delete(doc.key) documentStore.selected.delete(doc.key)
} else { } else {
store.selected.add(doc.key) documentStore.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 docs = props.documents const documents = sortedDocuments.value
if (docs.length === 0) { if (documents.length === 0) {
cursor.value = null cursor.value = null
return return
} }
const N = docs.length const N = documents.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 ? docs.indexOf(cursor.value) : docs.length cursor.value !== null ? documents.indexOf(cursor.value) : documents.length
const moveto = increment(index, d) const moveto = increment(index, d)
cursor.value = docs[moveto] ?? null cursor.value = documents[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 = docs[p].key const key = documents[p].key
if (store.selected.has(key)) store.selected.delete(key) if (documentStore.selected.has(key)) documentStore.selected.delete(key)
else store.selected.add(key) else documentStore.selected.add(key)
} }
} }
// @ts-ignore // @ts-ignore
@@ -217,14 +229,7 @@ watchEffect(() => {
focusBreadcrumb() focusBreadcrumb()
} }
}) })
let nowkey = ref(0) const mkdir = (doc: Document, name: string) => {
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(
@@ -241,24 +246,34 @@ const mkdir = (doc: Doc, name: string) => {
editing.value = null editing.value = null
} else { } else {
console.log('mkdir', msg) console.log('mkdir', msg)
router.push(doc.urlrouter) router.push(doc.loc ? `/${doc.loc}/${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
doc.name = name
doc.key = crypto.randomUUID()
} }
const showFolderBreadcrumb = (i: number) => {
const docs = props.documents // Column sort
const docloc = docs[i].loc const toggleSort = (name: string) => {
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc sort.value = sort.value === name ? '' : name
}
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: Doc) => store.selected.has(doc.key)) && props.documents.some((doc: Document) => documentStore.selected.has(doc.key)) &&
!allSelected.value !allSelected.value
) )
}, },
@@ -269,16 +284,16 @@ const allSelected = computed({
get: () => { get: () => {
return ( return (
props.documents.length > 0 && props.documents.length > 0 &&
props.documents.every((doc: Doc) => store.selected.has(doc.key)) props.documents.every((doc: Document) => documentStore.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) {
store.selected.add(doc.key) documentStore.selected.add(doc.key)
} else { } else {
store.selected.delete(doc.key) documentStore.selected.delete(doc.key)
} }
} }
} }
@@ -286,13 +301,9 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/')) const loc = computed(() => props.path.join('/'))
const contextMenu = (ev: MouseEvent, doc: Doc) => { const contextMenu = (ev: Event, doc: Document) => {
cursor.value = doc cursor.value = doc
ContextMenu.showContextMenu({ console.log('Context menu', ev, doc)
x: ev.x, y: ev.y, items: [
{ label: 'Rename', onClick: () => { editing.value = doc } },
],
})
} }
</script> </script>
@@ -440,4 +451,3 @@ 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 { Doc } from '@/repositories/Document' import type { Document } 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: Doc doc: Document
}>() }>()
</script> </script>

View File

@@ -12,7 +12,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Doc } from '@/repositories/Document' import type { Document } 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: Doc doc: Document
rename: (doc: Doc, newName: string) => void rename: (doc: Document, newName: string) => void
exit: () => void exit: () => void
}>() }>()

View File

@@ -3,7 +3,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Doc } from '@/repositories/Document' import type { Document } 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: Doc doc: Document
}>() }>()
</script> </script>

View File

@@ -0,0 +1,52 @@
<template>
<object
v-if="props.type === 'pdf'"
:data="dataURL"
type="application/pdf"
width="100%"
height="100%"
></object>
<a-image
v-else-if="props.type === 'image'"
width="50%"
:src="dataURL"
@click="() => setVisible(true)"
:previewMask="false"
:preview="{
visibleImg,
onVisibleChange: setVisible
}"
/>
<!-- Unknown case -->
<h1 v-else>Unsupported file type</h1>
</template>
<script setup lang="ts">
import { watchEffect, ref } from 'vue'
import Router from '@/router/index'
import { url_document_get } from '@/repositories/Document'
const dataURL = ref('')
watchEffect(() => {
dataURL.value = new URL(
url_document_get + Router.currentRoute.value.path,
location.origin
).toString()
})
const emit = defineEmits({
visibleImg(value: boolean) {
return value
}
})
function setVisible(value: boolean) {
emit('visibleImg', value)
}
const props = defineProps<{
type?: string
visibleImg: boolean
}>()
</script>
<style></style>

View File

@@ -1,15 +1,15 @@
<template> <template>
<nav class="headermain"> <nav class="headermain">
<div class="buttons"> <div class="buttons">
<template v-if="store.error"> <template v-if="documentStore.error">
<div class="error-message" @click="store.error = ''">{{ store.error }}</div> <div class="error-message" @click="documentStore.error = ''">{{ documentStore.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="() => store.fileExplorer!.newFolder()" @click="() => documentStore.fileExplorer.newFolder()"
/> />
<slot></slot> <slot></slot>
<div class="spacer smallgap"></div> <div class="spacer smallgap"></div>
@@ -18,6 +18,7 @@
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"
@@ -31,39 +32,35 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main' import { useDocumentStore } from '@/stores/documents'
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 store = useMainStore() const documentStore = useDocumentStore()
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 => {
if (!showSearchInput.value) return // Already closing if (!showSearchInput.value) return // Already closing
showSearchInput.value = false showSearchInput.value = false
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
breadcrumb.focus() breadcrumb.focus()
updateSearch(ev) updateSearch(ev)
} }
const updateSearch = (ev: Event) => { const updateSearch = ev => {
const q = (ev.target as HTMLInputElement).value const q = ev.target.value
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 || '/')
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') console.log("Update search", url)
if (!props.query && q) router.push(u) if (!props.query && q) router.push(url)
else router.replace(u) else router.replace(url)
} }
const toggleSearchInput = (ev: Event) => { const toggleSearchInput = () => {
showSearchInput.value = !showSearchInput.value showSearchInput.value = !showSearchInput.value
if (!showSearchInput.value) return closeSearch(ev) if (!showSearchInput.value) return closeSearch()
nextTick(() => { nextTick(() => {
const input = search.value const input = search.value
if (input) input.focus() if (input) input.focus()
@@ -75,10 +72,10 @@ watchEffect(() => {
const settingsMenu = (e: Event) => { const settingsMenu = (e: Event) => {
// show the context menu // show the context menu
const items = [] const items = []
if (store.user.isLoggedIn) { if (documentStore.user.isLoggedIn) {
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() }) items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() })
} else { } else {
items.push({ label: 'Login', onClick: () => store.loginDialog() }) items.push({ label: 'Login', onClick: () => documentStore.loginDialog() })
} }
ContextMenu.showContextMenu({ ContextMenu.showContextMenu({
// @ts-ignore // @ts-ignore
@@ -86,6 +83,11 @@ const settingsMenu = (e: Event) => {
items, items,
}) })
} }
const props = defineProps<{
path: Array<string>
query: string
}>()
defineExpose({ defineExpose({
toggleSearchInput, toggleSearchInput,
closeSearch, closeSearch,
@@ -114,4 +116,3 @@ input[type='search'] {
max-width: 30vw; max-width: 30vw;
} }
</style> </style>
@/stores/main

View File

@@ -1,29 +1,29 @@
<template> <template>
<template v-if="store.selected.size"> <template v-if="documentStore.selected.size">
<div class="smallgap"></div> <div class="smallgap"></div>
<p class="select-text">{{ store.selected.size }} selected </p> <p class="select-text">{{ documentStore.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="store.selected.clear()"></button> <button class="action-button unselect" data-tooltip="Unselect all" @click="documentStore.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 { useMainStore } from '@/stores/main' 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'
const store = useMainStore() const documentStore = useDocumentStore()
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 = store.selectedFiles const sel = documentStore.selectedFiles
const msg = { const msg = {
op, op,
sel: sel.keys.map(key => { sel: sel.keys.map(key => {
@@ -34,16 +34,16 @@ const op = (op: string, dst?: string) => {
// @ts-ignore // @ts-ignore
if (dst !== undefined) msg.dst = dst if (dst !== undefined) msg.dst = dst
const control = connect(controlUrl, { const control = connect(controlUrl, {
message(ev: MessageEvent) { message(ev: WebSocmetMessageEvent) {
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)
store.error = res.error.message documentStore.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()
store.selected.clear() documentStore.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 = store.selectedFiles const sel = documentStore.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)
store.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 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) {
store.selected.clear() documentStore.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(() => {
store.selected.clear() documentStore.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`)
store.selected.clear() documentStore.selected.clear()
} }
</script> </script>
@@ -152,4 +152,3 @@ 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 { useMainStore } from '@/stores/main' import { useDocumentStore } from '@/stores/documents'
const confirmLoading = ref<boolean>(false) const confirmLoading = ref<boolean>(false)
const store = useMainStore() const store = useDocumentStore()
const loginForm = reactive({ const loginForm = reactive({
username: '', username: '',
@@ -99,4 +99,3 @@ const login = async () => {
height: 1em; height: 1em;
} }
</style> </style>
@/stores/main

View File

@@ -0,0 +1,27 @@
<template>
<template v-for="upload in documentStore.uploadingDocuments" :key="upload.key">
<span>{{ upload.name }}</span>
<div class="progress-container">
<a-progress :percent="upload.progress" />
<CloseCircleOutlined class="close-button" @click="dismissUpload(upload.key)" />
</div>
</template>
</template>
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
const documentStore = useDocumentStore()
function dismissUpload(key: number) {
documentStore.deleteUploadingDocument(key)
}
</script>
<style scoped>
.progress-container {
display: flex;
align-items: center;
}
.close-button:hover {
color: #b81414;
}
</style>

View File

@@ -1,89 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { connect, uploadUrl } from '@/repositories/WS'; import { connect, uploadUrl } from '@/repositories/WS';
import { useMainStore } from '@/stores/main' import { useDocumentStore } from '@/stores/documents'
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 store = useMainStore() const documentStore = useDocumentStore()
const props = defineProps({ const props = defineProps({
path: Array<string> path: Array<string>
}) })
type CloudFile = {
file: File
cloudName: string
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 input = event.target as HTMLInputElement | null let infiles = Array.from(event.dataTransfer?.files || event.target.files) as File[]
const infiles = Array.from((input ?? (event as DragEvent).dataTransfer)?.files ?? []) as File[] if (!infiles.length) return
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 = [] for (const f of infiles) {
for (const file of infiles) { f.cloudName = loc + '/' + (f.webkitRelativePath || f.name)
files.push({ f.cloudPos = 0
file,
cloudName: loc + '/' + (file.webkitRelativePath || file.name),
cloudPos: 0,
})
} }
uploadCloudFiles(files) const dotfiles = infiles.filter(f => f.cloudName.includes('/.'))
}
const uploadCloudFiles = (files: CloudFile[]) => {
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
if (dotfiles.length) { if (dotfiles.length) {
store.error = "Won't upload dotfiles" documentStore.error = "Won't upload dotfiles"
console.log("Dotfiles omitted", dotfiles) console.log("Dotfiles omitted", dotfiles)
files = files.filter(f => !f.cloudName.includes('/.')) infiles = infiles.filter(f => !f.cloudName.includes('/.'))
} }
if (!files.length) return if (!infiles.length) return
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName)) infiles.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
// @ts-ignore // @ts-ignore
upqueue = [...upqueue, ...files] upqueue = upqueue.concat(infiles)
statsAdd(files) statsAdd(infiles)
startWorker() startWorker()
} }
@@ -99,14 +49,13 @@ const uprogress_init = {
tlast: 0, tlast: 0,
statbytes: 0, statbytes: 0,
statdur: 0, statdur: 0,
files: [] as CloudFile[], files: [],
filestart: 0, filestart: 0,
fileidx: 0, fileidx: 0,
filecount: 0, filecount: 0,
filename: '', filename: '',
filesize: 0, filesize: 0,
filepos: 0, filepos: 0,
status: 'idle',
} }
const uprogress = reactive({...uprogress_init}) const uprogress = reactive({...uprogress_init})
const percent = computed(() => uprogress.uploaded / uprogress.total * 100) const percent = computed(() => uprogress.uploaded / uprogress.total * 100)
@@ -117,7 +66,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 < 10 ? 1 : 0) + '\u202FMB/s': 'stalled') const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 100 ? 1 : 0) + '\u202FMB/s': 'stalled')
setInterval(() => { setInterval(() => {
if (Date.now() - uprogress.tlast > 3000) { if (Date.now() - uprogress.tlast > 3000) {
// Reset // Reset
@@ -129,7 +78,7 @@ setInterval(() => {
uprogress.statdur *= .9 uprogress.statdur *= .9
} }
}, 100) }, 100)
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => { const statUpdate = ({name, size, start, end}) => {
if (name !== uprogress.filename) return // If stats have been reset if (name !== uprogress.filename) return // If stats have been reset
const now = Date.now() const now = Date.now()
uprogress.uploaded = uprogress.filestart + end uprogress.uploaded = uprogress.filestart + end
@@ -148,7 +97,7 @@ const statNextFile = () => {
const f = uprogress.files.shift() const f = uprogress.files.shift()
if (!f) return statReset() if (!f) return statReset()
uprogress.filepos = 0 uprogress.filepos = 0
uprogress.filesize = f.file.size uprogress.filesize = f.size
uprogress.filename = f.cloudName uprogress.filename = f.cloudName
} }
const statReset = () => { const statReset = () => {
@@ -156,14 +105,14 @@ const statReset = () => {
uprogress.t0 = Date.now() uprogress.t0 = Date.now()
uprogress.tlast = uprogress.t0 + 1 uprogress.tlast = uprogress.t0 + 1
} }
const statsAdd = (f: CloudFile[]) => { const statsAdd = (f: Array<File>) => {
if (uprogress.files.length === 0) statReset() if (uprogress.files.length === 0) statReset()
uprogress.total += f.reduce((a, b) => a + b.file.size, 0) uprogress.total += f.reduce((a, b) => a + b.size, 0)
uprogress.filecount += f.length uprogress.filecount += f.length
uprogress.files = [...uprogress.files, ...f] uprogress.files = uprogress.files.concat(f)
statNextFile() statNextFile()
} }
let upqueue = [] as CloudFile[] let upqueue = [] as File[]
// TODO: Rewrite as WebSocket class // TODO: Rewrite as WebSocket class
const WSCreate = async () => await new Promise<WebSocket>(resolve => { const WSCreate = async () => await new Promise<WebSocket>(resolve => {
@@ -171,13 +120,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)
store.error = 'Upload socket error' documentStore.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)
store.error = res.error.message documentStore.error = res.error.message
return return
} }
if (res.status === 'ack') { if (res.status === 'ack') {
@@ -206,17 +155,18 @@ 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.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.size, start + (1<<20))
const control = { name: f.cloudName, size: f.file.size, start, end } const control = { name: f.cloudName, size: f.size, start, end }
const data = f.file.slice(start, end) const data = f.slice(start, end)
f.cloudPos = end f.cloudPos = end
// Note: files may get modified during I/O // Note: files may get modified during I/O
// @ts-ignore FIXME proper WebSocket class, avoid attaching functions to WebSocket object
ws.sendMsg(control) ws.sendMsg(control)
// @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"
@@ -234,10 +184,8 @@ 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)
}) })
@@ -259,7 +207,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.total > 1e7"> <span class="position" v-if="uprogress.filesize > 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>
@@ -302,4 +250,3 @@ span {
.position { min-width: 4em } .position { min-width: 4em }
.speed { min-width: 4em } .speed { min-width: 4em }
</style> </style>
@/stores/main

View File

@@ -1,42 +1,17 @@
import { formatSize, formatUnixDate, haystackFormat } from "@/utils"
export type FUID = string export type FUID = string
export type DocProps = { export type Document = {
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
@@ -61,7 +36,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, Doc> docs: Record<FUID, Document>
recursive: Array<[string, string, Doc]> recursive: Array<[string, string, Document]>
missing: Set<FUID> missing: Set<FUID>
} }

View File

@@ -1,4 +1,4 @@
import { useMainStore } from "@/stores/main" import { useDocumentStore } from "@/stores/documents"
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,26 +6,22 @@ export const uploadUrl = '/api/upload'
export const watchUrl = '/api/watch' export const watchUrl = '/api/watch'
let tree = [] as FileEntry[] let tree = [] as FileEntry[]
let reconnDelay = 500 let reconnectDuration = 500
let wsWatch = null as WebSocket | null let wsWatch = null as WebSocket | null
export const loadSession = () => { export const loadSession = () => {
const s = localStorage['cista-files'] const store = useDocumentStore()
if (!s) return false
const store = useMainStore()
try { try {
tree = JSON.parse(s) tree = JSON.parse(sessionStorage["cista-files"])
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 = () => {
localStorage["cista-files"] = JSON.stringify(tree) sessionStorage["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>>) => {
@@ -39,7 +35,7 @@ export const watchConnect = () => {
clearTimeout(watchTimeout) clearTimeout(watchTimeout)
watchTimeout = null watchTimeout = null
} }
const store = useMainStore() const store = useDocumentStore()
if (store.error !== 'Reconnecting...') store.error = 'Connecting...' if (store.error !== 'Reconnecting...') store.error = 'Connecting...'
console.log(store.error) console.log(store.error)
@@ -63,7 +59,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
reconnDelay = 500 reconnectDuration = 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()
@@ -81,16 +77,16 @@ export const watchDisconnect = () => {
let watchTimeout: any = null let watchTimeout: any = null
const watchReconnect = (event: MessageEvent) => { const watchReconnect = (event: MessageEvent) => {
const store = useMainStore() const store = useDocumentStore()
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...'
} }
reconnDelay = Math.min(5000, reconnDelay + 500) reconnectDuration = Math.min(5000, reconnectDuration + 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, reconnDelay) watchTimeout = setTimeout(watchConnect, reconnectDuration)
} }
@@ -113,8 +109,8 @@ const handleWatchMessage = (event: MessageEvent) => {
} }
} }
function handleRootMessage({ root }: { root: FileEntry[] }) { function handleRootMessage({ root }: { root: DirEntry }) {
const store = useMainStore() const store = useDocumentStore()
console.log('Watch root', root) console.log('Watch root', root)
store.updateRoot(root) store.updateRoot(root)
tree = root tree = root
@@ -122,7 +118,7 @@ function handleRootMessage({ root }: { root: FileEntry[] }) {
} }
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) { function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
const store = useMainStore() const store = useDocumentStore()
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')
@@ -130,23 +126,23 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
let oidx = 0 let oidx = 0
for (const [action, arg] of update) { for (const [action, arg] of update) {
if (action === 'k') { if (action === 'k') {
newtree.push(...tree.slice(oidx, oidx + arg)) newtree.push(...tree.slice(oidx, oidx + arg))
oidx += arg oidx += arg
} }
else if (action === 'd') oidx += arg else if (action === 'd') oidx += arg
else if (action === 'i') newtree.push(...arg) else if (action === 'i') newtree.push(...arg)
else console.log("Unknown update action", action, arg) else console.log("Unknown update action", action, arg)
} }
if (oidx != tree.length) if (oidx != tree.length)
throw Error(`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`) throw Error(`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}`)
store.updateRoot(newtree) store.updateRoot(newtree)
tree = newtree tree = newtree
saveSession() saveSession()
} }
function handleError(msg: errorEvent) { function handleError(msg: errorEvent) {
const store = useMainStore() const store = useDocumentStore()
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,12 +1,15 @@
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document' import type { Document, FileEntry, FUID, SelectedItems } from '@/repositories/Document'
import { Doc } from '@/repositories/Document' import { formatSize, formatUnixDate, haystackFormat } from '@/utils'
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 { format } from 'path'
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
@@ -14,20 +17,17 @@ type User = {
isLoggedIn: boolean isLoggedIn: boolean
} }
export const useMainStore = defineStore({ export const useDocumentStore = defineStore({
id: 'main', id: 'documents',
state: () => ({ state: () => ({
document: shallowRef<Doc[]>([]), document: [] as Document[],
selected: new Set<FUID>(), selected: new Set<FUID>(),
query: '' as string, uploadingDocuments: [],
fileExplorer: null as any, uploadCount: 0 as number,
fileExplorer: null,
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,
@@ -35,26 +35,30 @@ export const useMainStore = 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(new Doc({ docs.push({
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 console.log("Documents", docs)
this.document = docs as Document[]
},
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,18 +74,23 @@ export const useMainStore = 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: {
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing }, isUserLogged(): boolean {
isUserLogged(): boolean { return this.user.isLoggedIn }, 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>()
@@ -102,7 +111,7 @@ export const useMainStore = 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: Doc) { function add(rel: string, full: string, doc: Document) {
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

@@ -1,15 +0,0 @@
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(/\s+/)} return {based, words: based.split(/\W+/)}
} }
// Test if haystack includes needle // Test if haystack includes needle

View File

@@ -10,12 +10,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { watchEffect, ref, computed } from 'vue' import { watchEffect, ref, computed } from 'vue'
import { useMainStore } from '@/stores/main' import { useDocumentStore } from '@/stores/documents'
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 store = useMainStore() const documentStore = useDocumentStore()
const fileExplorer = ref() const fileExplorer = ref()
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
@@ -25,25 +24,19 @@ 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 sorted( if (!query) return documentStore.document.filter(doc => doc.loc === loc)
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 store.recentDocuments) { for (const doc of documentStore.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) ||
@@ -60,7 +53,6 @@ const documents = computed(() => {
}) })
watchEffect(() => { watchEffect(() => {
store.fileExplorer = fileExplorer.value documentStore.fileExplorer = fileExplorer.value
store.query = props.query
}) })
</script> </script>

View File

@@ -29,7 +29,7 @@ dependencies = [
] ]
[project.urls] [project.urls]
Homepage = "https://git.zi.fi/Vasanko/cista-storage" Homepage = ""
[project.scripts] [project.scripts]
cista = "cista.__main__:main" cista = "cista.__main__:main"

View File

@@ -1,136 +0,0 @@
from pathlib import PurePosixPath
import msgspec
import pytest
from cista.protocol import FileEntry, UpdateMessage, UpdDel, UpdIns, UpdKeep
from cista.watching import State, format_update
def decode(data: str):
return msgspec.json.decode(data, type=UpdateMessage).update
# Helper function to create a list of FileEntry objects
def f(count, start=0):
return [FileEntry(i, str(i), str(i), 0, 0, 0) for i in range(start, start + count)]
def test_identical_lists():
old_list = f(3)
new_list = old_list.copy()
expected = [UpdKeep(3)]
assert decode(format_update(old_list, new_list)) == expected
def test_completely_different_lists():
old_list = f(3)
new_list = f(3, 3) # Different entries
expected = [UpdDel(3), UpdIns(new_list)]
assert decode(format_update(old_list, new_list)) == expected
def test_insertions():
old_list = f(3)
new_list = old_list[:2] + f(1, 10) + old_list[2:]
expected = [UpdKeep(2), UpdIns(f(1, 10)), UpdKeep(1)]
assert decode(format_update(old_list, new_list)) == expected
def test_deletions():
old_list = f(3)
new_list = [old_list[0], old_list[2]]
expected = [UpdKeep(1), UpdDel(1), UpdKeep(1)]
assert decode(format_update(old_list, new_list)) == expected
def test_mixed_operations():
old_list = f(4)
new_list = [old_list[0], old_list[2], *f(1, 10)]
expected = [UpdKeep(1), UpdDel(1), UpdKeep(1), UpdDel(1), UpdIns(f(1, 10))]
assert decode(format_update(old_list, new_list)) == expected
def test_empty_old_list():
old_list = []
new_list = f(3)
expected = [UpdIns(new_list)]
assert decode(format_update(old_list, new_list)) == expected
def test_empty_new_list():
old_list = f(3)
new_list = []
expected = [UpdDel(3)]
assert decode(format_update(old_list, new_list)) == expected
def test_longer_lists():
old_list = f(6)
new_list = f(1, 6) + old_list[1:3] + old_list[4:5] + f(2, 7)
expected = [
UpdDel(1),
UpdIns(f(1, 6)),
UpdKeep(2),
UpdDel(1),
UpdKeep(1),
UpdDel(1),
UpdIns(f(2, 7)),
]
assert decode(format_update(old_list, new_list)) == expected
def sortkey(name):
# Define the sorting key for names here
return name.lower()
@pytest.fixture()
def state():
entries = [
FileEntry(0, "", "root", 0, 0, 0),
FileEntry(1, "bar", "bar", 0, 0, 0),
FileEntry(2, "baz", "bar/baz", 0, 0, 0),
FileEntry(1, "foo", "foo", 0, 0, 0),
FileEntry(1, "xxx", "xxx", 0, 0, 0),
FileEntry(2, "yyy", "xxx/yyy", 0, 0, 1),
]
s = State()
s._listing = entries
return s
def test_existing_directory(state):
path = PurePosixPath("bar")
expected_slice = slice(1, 3) # Includes 'bar' and 'baz'
assert state._slice(path) == expected_slice
def test_existing_file(state):
path = PurePosixPath("xxx/yyy")
expected_slice = slice(5, 6) # Only includes 'yyy'
assert state._slice(path) == expected_slice
def test_nonexistent_directory(state):
path = PurePosixPath("zzz")
expected_slice = slice(6, 6) # 'zzz' would be inserted at end
assert state._slice(path) == expected_slice
def test_nonexistent_file(state):
path = (PurePosixPath("bar/mmm"), 1)
expected_slice = slice(3, 3) # A file would be inserted after 'baz' under 'bar'
assert state._slice(path) == expected_slice
def test_root_directory(state):
path = PurePosixPath()
expected_slice = slice(0, 6) # Entire tree
assert state._slice(path) == expected_slice
def test_directory_with_subdirs_and_files(state):
path = PurePosixPath("xxx")
expected_slice = slice(4, 6) # Includes 'xxx' and 'yyy'
assert state._slice(path) == expected_slice