3 Commits

18 changed files with 180 additions and 240 deletions

View File

@@ -1,23 +1,19 @@
# 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:
```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
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`.
## 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

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

View File

@@ -149,3 +149,15 @@ class Space(msgspec.Struct):
free: int
usage: 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

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

View File

@@ -17,7 +17,7 @@ import type { ComputedRef } from 'vue'
import type HeaderMain from '@/components/HeaderMain.vue'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
import { useMainStore } from '@/stores/main'
import { useDocumentStore } from '@/stores/documents'
import { computed } from 'vue'
import Router from '@/router/index'
@@ -27,7 +27,7 @@ interface Path {
pathList: string[]
query: string
}
const store = useMainStore()
const documentStore = useDocumentStore()
const path: ComputedRef<Path> = computed(() => {
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
const pathList = p[0].split('/').filter(value => value !== '')
@@ -39,7 +39,7 @@ const path: ComputedRef<Path> = computed(() => {
}
})
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(watchConnect)
@@ -48,7 +48,7 @@ const headerMain = ref<typeof HeaderMain | null>(null)
let vert = 0
let timer: any = null
const globalShortcutHandler = (event: KeyboardEvent) => {
const fileExplorer = store.fileExplorer as any
const fileExplorer = documentStore.fileExplorer as any
if (!fileExplorer) return
const c = fileExplorer.isCursor()
const keyup = event.type === 'keyup'
@@ -124,4 +124,3 @@ onUnmounted(() => {
})
export type { Path }
</script>
@/stores/main

View File

@@ -4,7 +4,7 @@
aria-label="Breadcrumb"
@keyup.left.stop="move(-1)"
@keyup.right.stop="move(1)"
@keyup.enter="move(0)"
@focus="move(0)"
>
<a href="#/"
: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})`)
const url = `/${longest.value.slice(0, index).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()
if (here.startsWith(location.hash.slice(1))) router.replace(url)
else router.push(url)
}
const move = (dir: number) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { connect, uploadUrl } from '@/repositories/WS';
import { useMainStore } from '@/stores/main'
import { useDocumentStore } from '@/stores/documents'
import { collator } from '@/utils';
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
const fileInput = ref()
const folderInput = ref()
const store = useMainStore()
const documentStore = useDocumentStore()
const props = defineProps({
path: Array<string>
})
@@ -75,7 +75,7 @@ const uploadFiles = (infiles: File[]) => {
const uploadCloudFiles = (files: CloudFile[]) => {
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
if (dotfiles.length) {
store.error = "Won't upload dotfiles"
documentStore.error = "Won't upload dotfiles"
console.log("Dotfiles omitted", dotfiles)
files = files.filter(f => !f.cloudName.includes('/.'))
}
@@ -171,13 +171,13 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
open(ev: Event) { resolve(ws) },
error(ev: Event) {
console.error('Upload socket error', ev)
store.error = 'Upload socket error'
documentStore.error = 'Upload socket error'
},
message(ev: MessageEvent) {
const res = JSON.parse(ev!.data)
if ('error' in res) {
console.error('Upload socket error', res.error)
store.error = res.error.message
documentStore.error = res.error.message
return
}
if (res.status === 'ack') {
@@ -302,4 +302,3 @@ span {
.position { min-width: 4em }
.speed { min-width: 4em }
</style>
@/stores/main

View File

@@ -23,19 +23,11 @@ export class Doc {
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 = {
error: {

View File

@@ -1,4 +1,4 @@
import { useMainStore } from "@/stores/main"
import { useDocumentStore } from "@/stores/documents"
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
export const controlUrl = '/api/control'
@@ -12,7 +12,7 @@ let wsWatch = null as WebSocket | null
export const loadSession = () => {
const s = localStorage['cista-files']
if (!s) return false
const store = useMainStore()
const store = useDocumentStore()
try {
tree = JSON.parse(s)
store.updateRoot(tree)
@@ -39,7 +39,7 @@ export const watchConnect = () => {
clearTimeout(watchTimeout)
watchTimeout = null
}
const store = useMainStore()
const store = useDocumentStore()
if (store.error !== 'Reconnecting...') store.error = 'Connecting...'
console.log(store.error)
@@ -81,7 +81,7 @@ export const watchDisconnect = () => {
let watchTimeout: any = null
const watchReconnect = (event: MessageEvent) => {
const store = useMainStore()
const store = useDocumentStore()
if (store.connected) {
console.warn("Disconnected from server", event)
store.connected = false
@@ -114,7 +114,7 @@ const handleWatchMessage = (event: MessageEvent) => {
}
function handleRootMessage({ root }: { root: FileEntry[] }) {
const store = useMainStore()
const store = useDocumentStore()
console.log('Watch root', root)
store.updateRoot(root)
tree = root
@@ -122,7 +122,7 @@ function handleRootMessage({ root }: { root: FileEntry[] }) {
}
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
const store = useMainStore()
const store = useDocumentStore()
const update = updateData.update
console.log('Watch update', update)
if (!tree) return console.error('Watch update before root')
@@ -146,7 +146,7 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
}
function handleError(msg: errorEvent) {
const store = useMainStore()
const store = useDocumentStore()
if (msg.error.code === 401) {
store.user.isOpenLoginModal = true
store.user.isLoggedIn = false

View File

@@ -5,7 +5,6 @@ import { collator } from '@/utils'
import { logoutUser } from '@/repositories/User'
import { watchConnect } from '@/repositories/WS'
import { shallowRef } from 'vue'
import { sorted, type SortOrder } from '@/utils/docsort'
type User = {
username: string
@@ -14,20 +13,15 @@ type User = {
isLoggedIn: boolean
}
export const useMainStore = defineStore({
id: 'main',
export const useDocumentStore = defineStore({
id: 'documents',
state: () => ({
document: shallowRef<Doc[]>([]),
selected: new Set<FUID>(),
query: '' as string,
fileExplorer: null as any,
error: '' as string,
connected: false,
server: {} as Record<string, any>,
prefs: {
sortListing: '' as SortOrder,
sortFiltered: '' as SortOrder,
},
user: {
username: '',
privileged: false,
@@ -35,9 +29,6 @@ export const useMainStore = defineStore({
isOpenLoginModal: false
} as User
}),
persist: {
paths: ['prefs'],
},
actions: {
updateRoot(root: FileEntry[]) {
const docs = []
@@ -72,16 +63,22 @@ export const useMainStore = defineStore({
this.$reset()
localStorage.clear()
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: {
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
isUserLogged(): boolean { return this.user.isLoggedIn },
recentDocuments(): Doc[] { return sorted(this.document, 'modified') },
isUserLogged(): boolean {
return this.user.isLoggedIn
},
recentDocuments(): Doc[] {
const ret = [...this.document]
ret.sort((a, b) => b.mtime - a.mtime)
return ret
},
largeDocuments(): Doc[] {
const ret = [...this.document]
ret.sort((a, b) => b.size - a.size)
return ret
},
selectedFiles(): SelectedItems {
const selected = this.selected
const found = new Set<FUID>()

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

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

View File

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

View File

@@ -1,12 +0,0 @@
# 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