7 Commits

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

Reviewed-on: #5
2023-11-13 17:52:57 +00:00
11 changed files with 258 additions and 90 deletions

View File

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

View File

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

View File

@@ -110,26 +110,6 @@ class State:
with self.lock: with self.lock:
del self._listing[self._slice(relpath)] del self._listing[self._slice(relpath)]
def _index(self, rel: PurePosixPath):
idx = 0
ret = []
def _dir(self, idx: int):
level = self._listing[idx].level + 1
end = len(self._listing)
idx += 1
ret = []
while idx < end and (r := self._listing[idx]).level >= level:
if r.level == level:
ret.append(idx)
return ret, idx
def update(self, rel: PurePosixPath, value: FileEntry):
begin = 0
parents = []
while self._listing[begin].level < len(rel.parts):
parents.append(begin)
state = State() state = State()
rootpath: Path = None # type: ignore rootpath: Path = None # type: ignore
@@ -160,8 +140,8 @@ def watcher_thread(loop):
state.root = new state.root = new
broadcast(format_update(old, new), loop) broadcast(format_update(old, new), loop)
# The watching is not entirely reliable, so do a full refresh every minute # The watching is not entirely reliable, so do a full refresh every 30 seconds
refreshdl = time.monotonic() + 60.0 refreshdl = time.monotonic() + 30.0
for event in i.event_gen(): for event in i.event_gen():
if quit: if quit:
@@ -337,7 +317,7 @@ async def abroadcast(msg):
async def start(app, loop): async def start(app, loop):
config.load_config() config.load_config()
use_inotify = False and sys.platform == "linux" use_inotify = sys.platform == "linux"
app.ctx.watcher = threading.Thread( app.ctx.watcher = threading.Thread(
target=watcher_thread if use_inotify else watcher_thread_poll, target=watcher_thread if use_inotify else watcher_thread_poll,
args=[loop], args=[loop],

View File

@@ -4,7 +4,7 @@
aria-label="Breadcrumb" aria-label="Breadcrumb"
@keyup.left.stop="move(-1)" @keyup.left.stop="move(-1)"
@keyup.right.stop="move(1)" @keyup.right.stop="move(1)"
@focus="move(0)" @keyup.enter="move(0)"
> >
<a href="#/" <a href="#/"
:ref="el => setLinkRef(0, el)" :ref="el => setLinkRef(0, el)"

View File

@@ -5,9 +5,9 @@
<th class="selection"> <th class="selection">
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate"> <input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
</th> </th>
<th class="sortcolumn" :class="{ sortactive: sort === 'name' }" @click="toggleSort('name')">Name</th> <th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
<th class="sortcolumn modified right" :class="{ sortactive: sort === 'modified' }" @click="toggleSort('modified')">Modified</th> <th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
<th class="sortcolumn size right" :class="{ sortactive: sort === 'size' }" @click="toggleSort('size')">Size</th> <th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
<th class="menu"></th> <th class="menu"></th>
</tr> </tr>
</thead> </thead>
@@ -21,7 +21,7 @@
<FileSize :doc=editing /> <FileSize :doc=editing />
<td class="menu"></td> <td class="menu"></td>
</tr> </tr>
<template v-for="(doc, index) in sortedDocuments" :key="doc.key"> <template v-for="(doc, index) in documents" :key="doc.key">
<tr class="folder-change" v-if="showFolderBreadcrumb(index)"> <tr class="folder-change" v-if="showFolderBreadcrumb(index)">
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th> <th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
</tr> </tr>
@@ -84,8 +84,10 @@ import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue' import FileRenameInput from './FileRenameInput.vue'
import { connect, controlUrl } from '@/repositories/WS' import { connect, controlUrl } from '@/repositories/WS'
import { collator, formatSize } from '@/utils' import { formatSize } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
import type { SortOrder } from '@/utils/docsort'
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
@@ -120,12 +122,6 @@ 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))
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 = Math.floor(Date.now() / 1000)
@@ -143,8 +139,8 @@ defineExpose({
allSelected.value = !allSelected.value allSelected.value = !allSelected.value
}, },
toggleSortColumn(column: number) { toggleSortColumn(column: number) {
const columns = ['', 'name', 'modified', 'size', ''] const order = ['', 'name', 'modified', 'size', ''][column]
toggleSort(columns[column]) if (order) store.toggleSort(order as SortOrder)
}, },
isCursor() { isCursor() {
return cursor.value !== null && editing.value === null return cursor.value !== null && editing.value === null
@@ -164,25 +160,25 @@ defineExpose({
}, },
cursorMove(d: number, select = false) { cursorMove(d: number, select = false) {
// Move cursor up or down (keyboard navigation) // Move cursor up or down (keyboard navigation)
const documents = sortedDocuments.value const docs = props.documents
if (documents.length === 0) { if (docs.length === 0) {
cursor.value = null cursor.value = null
return return
} }
const N = documents.length const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = const index =
cursor.value !== null ? documents.indexOf(cursor.value) : documents.length cursor.value !== null ? docs.indexOf(cursor.value) : docs.length
const moveto = increment(index, d) const moveto = increment(index, d)
cursor.value = documents[moveto] ?? null cursor.value = docs[moveto] ?? null
const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null
if (select) { if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled // Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index] let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) { for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue if (p === N) continue
const key = documents[p].key const key = docs[p].key
if (store.selected.has(key)) store.selected.delete(key) if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key) else store.selected.add(key)
} }
@@ -253,22 +249,10 @@ const mkdir = (doc: Doc, name: string) => {
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
} }
const showFolderBreadcrumb = (i: number) => {
// Column sort const docs = props.documents
const toggleSort = (name: string) => { const docloc = docs[i].loc
sort.value = sort.value === name ? '' : name return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
}
const sort = ref<string>('')
const sortCompare = {
name: (a: 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({ const selectionIndeterminate = computed({
get: () => { get: () => {
@@ -302,9 +286,13 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/')) const loc = computed(() => props.path.join('/'))
const contextMenu = (ev: Event, doc: Doc) => { const contextMenu = (ev: MouseEvent, doc: Doc) => {
cursor.value = doc cursor.value = doc
console.log('Context menu', ev, doc) ContextMenu.showContextMenu({
x: ev.x, y: ev.y, items: [
{ label: 'Rename', onClick: () => { editing.value = doc } },
],
})
} }
</script> </script>

View File

@@ -18,7 +18,6 @@
ref="search" ref="search"
type="search" type="search"
:value="query" :value="query"
@blur="ev => { if (!query) closeSearch(ev) }"
@input="updateSearch" @input="updateSearch"
placeholder="Search words" placeholder="Search words"
class="margin-input" class="margin-input"

129
frontend/src/stores/main.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
import { Doc } from '@/repositories/Document'
import { defineStore } from 'pinia'
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
privileged: boolean
isOpenLoginModal: boolean
isLoggedIn: boolean
}
export const useMainStore = defineStore({
id: 'main',
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,
isLoggedIn: false,
isOpenLoginModal: false
} as User
}),
persist: {
paths: ['prefs'],
},
actions: {
updateRoot(root: FileEntry[]) {
const docs = []
let loc = [] as string[]
for (const [level, name, key, mtime, size, isfile] of root) {
loc = loc.slice(0, level - 1)
docs.push(new Doc({
name,
loc: level ? loc.join('/') : '/',
key,
size,
mtime,
dir: !isfile,
}))
loc.push(name)
}
this.document = docs
},
login(username: string, privileged: boolean) {
this.user.username = username
this.user.privileged = privileged
this.user.isLoggedIn = true
this.user.isOpenLoginModal = false
if (!this.connected) watchConnect()
},
loginDialog() {
this.user.isOpenLoginModal = true
},
async logout() {
console.log("Logout")
await logoutUser()
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') },
selectedFiles(): SelectedItems {
const selected = this.selected
const found = new Set<FUID>()
const ret: SelectedItems = {
missing: new Set(),
docs: {},
keys: [],
recursive: [],
}
for (const doc of this.document) {
if (selected.has(doc.key)) {
found.add(doc.key)
ret.keys.push(doc.key)
ret.docs[doc.key] = doc
}
}
// What did we not select?
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
// Build a flat list including contents recursively
const relnames = new Set<string>()
function add(rel: string, full: string, doc: Doc) {
if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`)
relnames.add(rel)
ret.recursive.push([rel, full, doc])
}
for (const key of ret.keys) {
const base = ret.docs[key]
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
const nremove = base.loc.length
add(base.name, basepath, base)
for (const doc of this.document) {
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
const rel = full.slice(nremove)
add(rel, full, doc)
}
}
}
// Sort by rel (name stored as on download)
ret.recursive.sort((a, b) => collator.compare(a[0], b[0]))
return ret
}
}
})

View File

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

View File

@@ -13,6 +13,7 @@ import { watchEffect, ref, computed } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import Router from '@/router/index' import Router from '@/router/index'
import { needleFormat, localeIncludes, collator } from '@/utils'; import { needleFormat, localeIncludes, collator } from '@/utils';
import { sorted } from '@/utils/docsort';
const store = useMainStore() const store = useMainStore()
const fileExplorer = ref() const fileExplorer = ref()
@@ -24,7 +25,10 @@ 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 store.document.filter(doc => doc.loc === loc) if (!query) return sorted(
store.document.filter(doc => doc.loc === loc),
store.prefs.sortListing,
)
// Find up to 100 newest documents that match the search // Find up to 100 newest documents that match the search
const needle = needleFormat(query) const needle = needleFormat(query)
let limit = 100 let limit = 100
@@ -35,8 +39,11 @@ const documents = computed(() => {
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) ||
@@ -54,5 +61,6 @@ const documents = computed(() => {
watchEffect(() => { watchEffect(() => {
store.fileExplorer = fileExplorer.value store.fileExplorer = fileExplorer.value
store.query = props.query
}) })
</script> </script>

View File

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

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

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