Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f94ccc01c6 | ||
|
|
bc355d991d | ||
|
|
e7ebad3c36 | ||
|
|
036c4342de | ||
|
|
160f929e0c | ||
|
|
1970d40d8a | ||
|
|
1a164e0a08 | ||
|
|
46d222006a | ||
|
|
98949e6b30 | ||
|
|
8f76d770ee |
@@ -38,6 +38,39 @@ pip install cista --break-system-packages
|
|||||||
|
|
||||||
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Cista supports three authentication modes:
|
||||||
|
|
||||||
|
### Built-in Authentication (default)
|
||||||
|
|
||||||
|
User accounts are managed directly by Cista. Create users with the `--user` flag:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
uvx cista --user admin --privileged # Create admin user
|
||||||
|
uvx cista --user guest # Create regular user
|
||||||
|
```
|
||||||
|
|
||||||
|
Privileged users can manage other users and change settings via the Admin Settings menu.
|
||||||
|
|
||||||
|
### Public Mode
|
||||||
|
|
||||||
|
In public mode, anyone can read, send and even delete files without without logging in. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
|
||||||
|
|
||||||
|
### Paskia SSO Authentication
|
||||||
|
|
||||||
|
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. Set the `PASKIA_BACKEND_URL` environment variable:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
||||||
|
```
|
||||||
|
|
||||||
|
In Paskia mode:
|
||||||
|
- All `/auth/*` requests are proxied to the Paskia backend
|
||||||
|
- Users with `cista:login` permission can access files
|
||||||
|
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
||||||
|
- Public mode works with Paskia: unauthenticated users can browse, while the menu has option to login
|
||||||
|
|
||||||
### Internet Access
|
### Internet Access
|
||||||
|
|
||||||
Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
|
Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
|
||||||
|
|||||||
+36
-9
@@ -25,6 +25,28 @@ def create_banner():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
|
||||||
|
"""Create a framed startup box with server information."""
|
||||||
|
title = f"Cista {cista.__version__}"
|
||||||
|
listen = unix if unix else url
|
||||||
|
location = f"{folder} @ {listen}"
|
||||||
|
lines = [title, location]
|
||||||
|
if paskia_url:
|
||||||
|
lines.append(f"Paskia: {paskia_url}")
|
||||||
|
if dev:
|
||||||
|
lines.append("dev mode")
|
||||||
|
|
||||||
|
# Calculate width based on content
|
||||||
|
inner_width = max(len(line) for line in lines) + 2
|
||||||
|
|
||||||
|
# Build the box
|
||||||
|
box = [f"╭{'─' * inner_width}╮"]
|
||||||
|
for line in lines:
|
||||||
|
box.append(f"│ {line:<{inner_width - 1}}│")
|
||||||
|
box.append(f"╰{'─' * inner_width}╯")
|
||||||
|
return "\n".join(box) + "\n"
|
||||||
|
|
||||||
|
|
||||||
banner = create_banner()
|
banner = create_banner()
|
||||||
|
|
||||||
doc = """\
|
doc = """\
|
||||||
@@ -83,8 +105,7 @@ def _main():
|
|||||||
elif "--version" in sys.argv:
|
elif "--version" in sys.argv:
|
||||||
sys.stdout.write(f"cista {cista.__version__}\n")
|
sys.stdout.write(f"cista {cista.__version__}\n")
|
||||||
return 0
|
return 0
|
||||||
else:
|
# Don't print banner yet for normal startup - we'll print the startup box later
|
||||||
sys.stderr.write(banner)
|
|
||||||
args = docopt(doc)
|
args = docopt(doc)
|
||||||
if args["--user"]:
|
if args["--user"]:
|
||||||
return _user(args)
|
return _user(args)
|
||||||
@@ -121,17 +142,23 @@ def _main():
|
|||||||
elif not exists:
|
elif not exists:
|
||||||
settings["listen"] = ":8000"
|
settings["listen"] = ":8000"
|
||||||
operation = config.update_config(settings)
|
operation = config.update_config(settings)
|
||||||
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
|
|
||||||
# Prepare to serve
|
# Prepare to serve
|
||||||
unix = None
|
url, opts = serve.parse_listen(config.config.listen)
|
||||||
url, _ = serve.parse_listen(config.config.listen)
|
|
||||||
if not config.config.path.is_dir():
|
if not config.config.path.is_dir():
|
||||||
raise ValueError(f"No such directory: {config.config.path}")
|
raise ValueError(f"No such directory: {config.config.path}")
|
||||||
extra = f" ({unix})" if unix else ""
|
|
||||||
dev = args["--dev"]
|
dev = args["--dev"]
|
||||||
if dev:
|
# Check for Paskia SSO
|
||||||
extra += " (dev mode)"
|
from cista.sso import PASKIA_BACKEND_URL
|
||||||
sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n")
|
|
||||||
|
# Print startup box
|
||||||
|
startup_box = create_startup_box(
|
||||||
|
folder=config.config.path,
|
||||||
|
url=url,
|
||||||
|
unix=opts.get("unix"),
|
||||||
|
dev=dev,
|
||||||
|
paskia_url=PASKIA_BACKEND_URL or None,
|
||||||
|
)
|
||||||
|
sys.stderr.write(startup_box)
|
||||||
# Run the server
|
# Run the server
|
||||||
serve.run(dev=dev)
|
serve.run(dev=dev)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+13
-8
@@ -95,14 +95,19 @@ async def control(req, ws):
|
|||||||
async def watch(req, ws):
|
async def watch(req, ws):
|
||||||
# Build user info from either built-in auth or SSO
|
# Build user info from either built-in auth or SSO
|
||||||
user_info = None
|
user_info = None
|
||||||
if sso_user := getattr(req.ctx, "sso_user", None):
|
if sso.paskia_enabled():
|
||||||
# SSO auth (paskia mode): extract from validation response
|
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
||||||
ctx = sso_user.get("ctx", {})
|
try:
|
||||||
perms = ctx.get("permissions", [])
|
await sso.validate_sso_request(req)
|
||||||
user_info = {
|
except Exception:
|
||||||
"username": ctx.get("user", {}).get("display_name", ""),
|
pass # Ignore auth errors, user_info stays None
|
||||||
"privileged": "cista:admin" in perms,
|
if sso_user := getattr(req.ctx, "sso_user", None):
|
||||||
}
|
ctx = sso_user.get("ctx", {})
|
||||||
|
perms = ctx.get("permissions", [])
|
||||||
|
user_info = {
|
||||||
|
"username": ctx.get("user", {}).get("display_name", ""),
|
||||||
|
"privileged": "cista:admin" in perms,
|
||||||
|
}
|
||||||
elif req.ctx.user:
|
elif req.ctx.user:
|
||||||
# Built-in auth: use local user database
|
# Built-in auth: use local user database
|
||||||
user_info = {
|
user_info = {
|
||||||
|
|||||||
+25
-11
@@ -43,10 +43,13 @@ setproctitle("cista-main")
|
|||||||
async def main_start(app):
|
async def main_start(app):
|
||||||
config.load_config()
|
config.load_config()
|
||||||
setproctitle(f"cista {config.config.path.name}")
|
setproctitle(f"cista {config.config.path.name}")
|
||||||
workers = max(2, min(8, cpu_count()))
|
# Small pool for memory-intensive preview generation
|
||||||
|
preview_workers = max(2, min(8, cpu_count()))
|
||||||
app.ctx.threadexec = ThreadPoolExecutor(
|
app.ctx.threadexec = ThreadPoolExecutor(
|
||||||
max_workers=workers, thread_name_prefix="cista-ioworker"
|
max_workers=preview_workers, thread_name_prefix="cista-preview"
|
||||||
)
|
)
|
||||||
|
# Larger pool for long-running but low-memory zip operations
|
||||||
|
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
|
||||||
watching.start(app)
|
watching.start(app)
|
||||||
|
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ async def main_stop(app):
|
|||||||
quit.set()
|
quit.set()
|
||||||
watching.stop(app)
|
watching.stop(app)
|
||||||
app.ctx.threadexec.shutdown()
|
app.ctx.threadexec.shutdown()
|
||||||
|
app.ctx.zipexec.shutdown(cancel_futures=True)
|
||||||
await sso.close_client()
|
await sso.close_client()
|
||||||
logger.debug("Cista worker threads all finished")
|
logger.debug("Cista worker threads all finished")
|
||||||
|
|
||||||
@@ -257,10 +261,7 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
|||||||
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
||||||
async def zip_download(req, keys, zipfile, ext):
|
async def zip_download(req, keys, zipfile, ext):
|
||||||
"""Download a zip archive of the given keys"""
|
"""Download a zip archive of the given keys"""
|
||||||
if config.config.authentication == "paskia":
|
await auth.verify(req)
|
||||||
await auth.verify_sso(req)
|
|
||||||
else:
|
|
||||||
auth.verify(req)
|
|
||||||
|
|
||||||
wanted = set(keys.split("+"))
|
wanted = set(keys.split("+"))
|
||||||
files = get_files(wanted)
|
files = get_files(wanted)
|
||||||
@@ -291,27 +292,40 @@ async def zip_download(req, keys, zipfile, ext):
|
|||||||
yield chunk
|
yield chunk
|
||||||
assert size == 0
|
assert size == 0
|
||||||
|
|
||||||
|
pending_put = None # Current queue.put future, can be cancelled
|
||||||
|
|
||||||
def worker():
|
def worker():
|
||||||
|
nonlocal pending_put
|
||||||
try:
|
try:
|
||||||
for chunk in stream_zip(local_files(files)):
|
for chunk in stream_zip(local_files(files)):
|
||||||
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
|
future = asyncio.run_coroutine_threadsafe(queue.put(chunk), loop)
|
||||||
|
pending_put = future
|
||||||
|
future.result() # Blocks until queue has space
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("ZIP download cancelled by client disconnect")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error streaming ZIP")
|
logger.exception("Error streaming ZIP")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
pending_put = None
|
||||||
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
|
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
|
||||||
|
|
||||||
# Don't block the event loop: run in a thread
|
# Don't block the event loop: run in a thread (use larger zip pool)
|
||||||
queue = asyncio.Queue(maxsize=1)
|
queue = asyncio.Queue(maxsize=1)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
thread = loop.run_in_executor(app.ctx.threadexec, worker)
|
thread = loop.run_in_executor(app.ctx.zipexec, worker)
|
||||||
|
|
||||||
# Stream the response
|
# Stream the response
|
||||||
res = await req.respond(
|
res = await req.respond(
|
||||||
content_type="application/zip",
|
content_type="application/zip",
|
||||||
headers={"cache-control": "no-store"},
|
headers={"cache-control": "no-store"},
|
||||||
)
|
)
|
||||||
while chunk := await queue.get():
|
try:
|
||||||
await res.send(chunk)
|
while chunk := await queue.get():
|
||||||
|
await res.send(chunk)
|
||||||
|
finally:
|
||||||
|
# Cancel any pending put to unblock and stop the worker
|
||||||
|
if pending_put:
|
||||||
|
pending_put.cancel()
|
||||||
|
|
||||||
await thread # If it raises, the response will fail download
|
await thread # If it raises, the response will fail download
|
||||||
|
|||||||
+13
-16
@@ -236,39 +236,36 @@ async def verify(request, *, privileged=False):
|
|||||||
|
|
||||||
For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
|
For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
|
||||||
For built-in mode, checks session-based authentication.
|
For built-in mode, checks session-based authentication.
|
||||||
For public mode (config.public=True), allows all requests.
|
For public mode (config.public=True), skips auth unless privileged is required.
|
||||||
|
|
||||||
All 401/403 responses include auth.iframe URL for consistent frontend handling
|
|
||||||
via the paskia library's showAuthIframe().
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The Sanic request object
|
request: The Sanic request object
|
||||||
privileged: If True, requires admin privileges
|
privileged: If True, requires admin privileges (always enforced even in public mode)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Unauthorized: If authentication is required
|
Unauthorized: If authentication is required
|
||||||
Forbidden: If access is denied
|
Forbidden: If access is denied
|
||||||
"""
|
"""
|
||||||
|
# Public mode: skip auth unless privileged access is required
|
||||||
|
if config.config.public and not privileged:
|
||||||
|
return
|
||||||
|
|
||||||
sso = _get_sso()
|
sso = _get_sso()
|
||||||
if sso.paskia_enabled():
|
if sso.paskia_enabled():
|
||||||
# SSO validation against auth backend
|
|
||||||
# Always check cista:login; privileged flag comes from response perm list
|
|
||||||
perm = "cista:admin" if privileged else "cista:login"
|
perm = "cista:admin" if privileged else "cista:login"
|
||||||
await sso.validate_sso_request(request, perm=perm)
|
await sso.validate_sso_request(request, perm=perm)
|
||||||
return
|
return
|
||||||
|
|
||||||
user = getattr(request.ctx, "user", None)
|
user = getattr(request.ctx, "user", None)
|
||||||
if privileged:
|
if privileged:
|
||||||
if user:
|
if user and user.privileged:
|
||||||
if user.privileged:
|
return
|
||||||
return
|
raise Forbidden(
|
||||||
raise Forbidden(
|
"Access Forbidden: Only for privileged users",
|
||||||
"Access Forbidden: Only for privileged users",
|
quiet=True,
|
||||||
quiet=True,
|
)
|
||||||
)
|
if user:
|
||||||
elif config.config.public or user:
|
|
||||||
return
|
return
|
||||||
# Return iframe URL for paskia library to show login dialog
|
|
||||||
raise Unauthorized(
|
raise Unauthorized(
|
||||||
f"Login required for {request.path}",
|
f"Login required for {request.path}",
|
||||||
"cookie",
|
"cookie",
|
||||||
|
|||||||
+2
-2
@@ -89,9 +89,9 @@ def dispatch(path, quality, maxsize, maxzoom):
|
|||||||
return process_video(path, quality=quality, maxsize=maxsize)
|
return process_video(path, quality=quality, maxsize=maxsize)
|
||||||
return process_image(path, quality=quality, maxsize=maxsize)
|
return process_image(path, quality=quality, maxsize=maxsize)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Cannot generate preview for {path.name}: {e}")
|
logger.warning(f"Cannot generate preview for {path}: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error generating preview for {path.name}: {e}")
|
logger.exception(f"Error generating preview for {path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def process_image(path, *, maxsize, quality):
|
def process_image(path, *, maxsize, quality):
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="store.error && !store.authInProgress" class="toast-message" @click="store.error = ''">
|
<div v-if="store.toast" class="toast-message" @click="store.clearToast()">
|
||||||
|
{{ store.toast }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="store.error && !store.authInProgress" class="toast-message status" @click="store.error = ''">
|
||||||
{{ store.error }}
|
{{ store.error }}
|
||||||
</div>
|
</div>
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
@@ -100,6 +103,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
// Globally close search, clear errors on Escape
|
// Globally close search, clear errors on Escape
|
||||||
else if (keyup && event.key === 'Escape') {
|
else if (keyup && event.key === 'Escape') {
|
||||||
store.error = ''
|
store.error = ''
|
||||||
|
store.clearToast()
|
||||||
headerMain.value!.closeSearch(event)
|
headerMain.value!.closeSearch(event)
|
||||||
store.focusBreadcrumb()
|
store.focusBreadcrumb()
|
||||||
}
|
}
|
||||||
@@ -181,4 +185,8 @@ export type { Path }
|
|||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.toast-message.status {
|
||||||
|
background: #555;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { apiFetch } from '@/repositories/Client'
|
import { apiFetch } from '@/repositories/Client'
|
||||||
import type { SelectedItems } from '@/repositories/Document'
|
import type { SelectedItems } from '@/repositories/Document'
|
||||||
|
import { zipName } from '@/utils/fileutil'
|
||||||
import { reactive } from 'vue';
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
@@ -106,7 +107,6 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
|||||||
++store.dprogress.fileidx
|
++store.dprogress.fileidx
|
||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader()
|
||||||
await writable.truncate(0)
|
await writable.truncate(0)
|
||||||
store.error = "Direct download."
|
|
||||||
store.dprogress.tlast = Date.now()
|
store.dprogress.tlast = Date.now()
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done } = await reader.read()
|
const { value, done } = await reader.read()
|
||||||
@@ -136,7 +136,7 @@ const download = async () => {
|
|||||||
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.error = 'No existing files selected'
|
store.showToast('No existing files selected')
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -144,7 +144,7 @@ const download = async () => {
|
|||||||
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()
|
store.selected.clear()
|
||||||
store.error = "Single file via browser downloads"
|
store.showToast(`Downloading ${files[0]![0].split('/').pop()}`)
|
||||||
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
|
||||||
@@ -164,9 +164,10 @@ const download = async () => {
|
|||||||
}
|
}
|
||||||
// Otherwise, zip and download
|
// Otherwise, zip and download
|
||||||
console.log("Falling back to zip download")
|
console.log("Falling back to zip download")
|
||||||
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]!]!.name : 'download'
|
const items = sel.keys.map(k => sel.docs[k]!)
|
||||||
|
const name = zipName(items)
|
||||||
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
||||||
store.error = "Downloading as ZIP via browser downloads"
|
store.showToast(`Downloading ${name}.zip`)
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,14 +51,22 @@ const closeSearch = (ev: Event) => {
|
|||||||
breadcrumb.focus()
|
breadcrumb.focus()
|
||||||
updateSearch(ev)
|
updateSearch(ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSearch = (ev: Event) => {
|
const updateSearch = (ev: Event) => {
|
||||||
const q = (ev.target as HTMLInputElement).value
|
const q = (ev.target as HTMLInputElement).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')
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
if (!props.query && q) router.push(u)
|
|
||||||
else router.replace(u)
|
// Start search immediately via store (worker handles it async)
|
||||||
|
store.search(q, props.path.join('/'))
|
||||||
|
|
||||||
|
// Update route in next frame to keep typing responsive
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!props.query && q) router.push(u)
|
||||||
|
else router.replace(u)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const toggleSearchInput = (ev: Event) => {
|
const toggleSearchInput = (ev: Event) => {
|
||||||
showSearchInput.value = !showSearchInput.value
|
showSearchInput.value = !showSearchInput.value
|
||||||
@@ -94,11 +102,11 @@ const settingsMenu = (e: Event) => {
|
|||||||
|
|
||||||
if (store.user.isLoggedIn) {
|
if (store.user.isLoggedIn) {
|
||||||
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
|
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
|
||||||
} else if (!ssoStore.isExternalAuth) {
|
} else if (store.server.public) {
|
||||||
// Show login in paskia iframe overlay
|
// Show login option only in public mode (non-public modes trigger auth automatically)
|
||||||
items.push({ label: '🔐 Login', onClick: async () => {
|
items.push({ label: '🔐 Login', onClick: async () => {
|
||||||
try {
|
try {
|
||||||
await showAuthIframe('/auth/restricted')
|
await showAuthIframe('/auth/restricted#theme=light')
|
||||||
resumeWatching()
|
resumeWatching()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Login cancelled')
|
console.log('Login cancelled')
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const submit = async (ev: Event) => {
|
|||||||
try {
|
try {
|
||||||
if (form.passwordChange) {
|
if (form.passwordChange) {
|
||||||
if (!form.password) {
|
if (!form.password) {
|
||||||
store.error = '⚠️ Current password is required'
|
store.showToast('⚠️ Current password is required')
|
||||||
password.value!.focus()
|
password.value!.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ const submit = async (ev: Event) => {
|
|||||||
close()
|
close()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const httpError = error as ISimpleError
|
const httpError = error as ISimpleError
|
||||||
store.error = httpError.message || '🛑 Unknown error'
|
store.showToast(httpError.message || '🛑 Unknown error')
|
||||||
} finally {
|
} finally {
|
||||||
confirmLoading.value = false
|
confirmLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const uploadFiles = (infiles: File[]) => {
|
|||||||
const uploadCloudFiles = (files: CloudFile[]) => {
|
const uploadCloudFiles = (files: CloudFile[]) => {
|
||||||
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
||||||
if (dotfiles.length) {
|
if (dotfiles.length) {
|
||||||
store.error = "Won't upload dotfiles"
|
store.showToast("Won't upload dotfiles")
|
||||||
console.log("Dotfiles omitted", dotfiles)
|
console.log("Dotfiles omitted", dotfiles)
|
||||||
files = files.filter(f => !f.cloudName.includes('/.'))
|
files = files.filter(f => !f.cloudName.includes('/.'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const loadUsers = async () => {
|
|||||||
users.value = data.users
|
users.value = data.users
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to load users'
|
store.showToast(httpError.message || 'Failed to load users')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ const addUser = async () => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to add user'
|
store.showToast(httpError.message || 'Failed to add user')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ const toggleAdmin = async (user: User, event: Event) => {
|
|||||||
user.privileged = target.checked
|
user.privileged = target.checked
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to update user'
|
store.showToast(httpError.message || 'Failed to update user')
|
||||||
target.checked = user.privileged // revert
|
target.checked = user.privileged // revert
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ const renameUser = async (user: User) => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to rename user'
|
store.showToast(httpError.message || 'Failed to rename user')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ const resetPassword = async (user: User) => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to reset password'
|
store.showToast(httpError.message || 'Failed to reset password')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ const deleteUserAction = async (username: string) => {
|
|||||||
await loadUsers()
|
await loadUsers()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to delete user'
|
store.showToast(httpError.message || 'Failed to delete user')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ const updateServerSettings = async () => {
|
|||||||
success.value = 'Server settings updated'
|
success.value = 'Server settings updated'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to update settings'
|
store.showToast(httpError.message || 'Failed to update settings')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatSize, formatUnixDate, haystackFormat } from "@/utils"
|
import { formatSize, formatUnixDate } from "@/utils"
|
||||||
|
|
||||||
export type FUID = string
|
export type FUID = string
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@ export class Doc {
|
|||||||
public key: FUID = ""
|
public key: FUID = ""
|
||||||
public size: number = 0
|
public size: number = 0
|
||||||
public mtime: number = 0
|
public mtime: number = 0
|
||||||
public haystack: string = ""
|
|
||||||
public dir: boolean = false
|
public dir: boolean = false
|
||||||
/** @internal Use the name getter/setter instead */
|
/** @internal Use the name getter/setter instead */
|
||||||
public _name: string = ""
|
public _name: string = ""
|
||||||
@@ -24,13 +23,12 @@ export class Doc {
|
|||||||
constructor(props: Partial<DocProps> = {}) {
|
constructor(props: Partial<DocProps> = {}) {
|
||||||
const { name, ...rest } = props
|
const { name, ...rest } = props
|
||||||
Object.assign(this, rest)
|
Object.assign(this, rest)
|
||||||
if (name) this.name = name // Use setter for validation
|
if (name) this._name = name // Skip validation/haystack for bulk loading
|
||||||
}
|
}
|
||||||
get name() { return this._name }
|
get name() { return this._name }
|
||||||
set name(name: string) {
|
set name(name: string) {
|
||||||
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
||||||
this._name = name
|
this._name = name
|
||||||
this.haystack = haystackFormat(name)
|
|
||||||
}
|
}
|
||||||
get sizedisp(): string { return formatSize(this.size) }
|
get sizedisp(): string { return formatSize(this.size) }
|
||||||
get modified(): string { return formatUnixDate(this.mtime) }
|
get modified(): string { return formatUnixDate(this.mtime) }
|
||||||
|
|||||||
@@ -4,14 +4,47 @@ import { defineStore, type StateTree } from 'pinia'
|
|||||||
import { collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { watchConnect, resumeWatching } from '@/repositories/WS'
|
import { watchConnect, resumeWatching } from '@/repositories/WS'
|
||||||
import { sorted, type SortOrder } from '@/utils/docsort'
|
import { sorted, type SortOrder } from '@/utils/docsort'
|
||||||
|
import SearchWorker from '@/workers/searchWorker?worker'
|
||||||
|
|
||||||
|
// Singleton search worker instance
|
||||||
|
let searchWorker: Worker | null = null
|
||||||
|
let searchId = 0
|
||||||
|
let searchStore: ReturnType<typeof useMainStore> | null = null
|
||||||
|
|
||||||
|
function getSearchWorker(): Worker {
|
||||||
|
if (!searchWorker) {
|
||||||
|
searchWorker = new SearchWorker()
|
||||||
|
// Set up message handler once
|
||||||
|
searchWorker.onmessage = (e) => {
|
||||||
|
if (!searchStore || e.data.id !== searchId) return // Stale result
|
||||||
|
|
||||||
|
// Convert plain data back to Doc instances (constructor is now lightweight)
|
||||||
|
const docs = []
|
||||||
|
for (const d of e.data.docs) {
|
||||||
|
docs.push(new Doc(d))
|
||||||
|
}
|
||||||
|
searchStore.searchResults = docs
|
||||||
|
|
||||||
|
if (e.data.done) {
|
||||||
|
searchStore.searchLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return searchWorker
|
||||||
|
}
|
||||||
|
|
||||||
export const useMainStore = defineStore('main', {
|
export const useMainStore = defineStore('main', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
document: [] as Doc[],
|
document: [] as Doc[],
|
||||||
selected: new Set<FUID>([]),
|
selected: new Set<FUID>([]),
|
||||||
query: '' as string,
|
query: '' as string,
|
||||||
|
searchResults: [] as Doc[],
|
||||||
|
searchLoading: false,
|
||||||
|
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
fileExplorer: null as any,
|
fileExplorer: null as any,
|
||||||
error: '' as string,
|
error: '' as string, // Permanent status message (e.g., "Reconnecting...")
|
||||||
|
toast: '' as string, // Temporary toast (auto-dismisses)
|
||||||
|
toastTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||||
connected: false,
|
connected: false,
|
||||||
authInProgress: false,
|
authInProgress: false,
|
||||||
cursor: '' as string,
|
cursor: '' as string,
|
||||||
@@ -61,6 +94,55 @@ export const useMainStore = defineStore('main', {
|
|||||||
loc.push(name)
|
loc.push(name)
|
||||||
}
|
}
|
||||||
this.document = docs
|
this.document = docs
|
||||||
|
// Sync documents to search worker
|
||||||
|
this.syncSearchWorker()
|
||||||
|
},
|
||||||
|
/** Show a temporary toast message that auto-dismisses */
|
||||||
|
showToast(message: string, duration = 3000) {
|
||||||
|
if (this.toastTimeout) {
|
||||||
|
clearTimeout(this.toastTimeout)
|
||||||
|
this.toastTimeout = null
|
||||||
|
}
|
||||||
|
this.toast = message
|
||||||
|
this.toastTimeout = setTimeout(() => {
|
||||||
|
this.toast = ''
|
||||||
|
this.toastTimeout = null
|
||||||
|
}, duration)
|
||||||
|
},
|
||||||
|
/** Clear the current toast immediately */
|
||||||
|
clearToast() {
|
||||||
|
if (this.toastTimeout) {
|
||||||
|
clearTimeout(this.toastTimeout)
|
||||||
|
this.toastTimeout = null
|
||||||
|
}
|
||||||
|
this.toast = ''
|
||||||
|
},
|
||||||
|
syncSearchWorker() {
|
||||||
|
const worker = getSearchWorker()
|
||||||
|
// Send plain data to worker (no class instances)
|
||||||
|
const docData = this.document.map(doc => ({
|
||||||
|
loc: doc.loc,
|
||||||
|
name: doc.name,
|
||||||
|
key: doc.key,
|
||||||
|
size: doc.size,
|
||||||
|
mtime: doc.mtime,
|
||||||
|
dir: doc.dir,
|
||||||
|
}))
|
||||||
|
worker.postMessage({ type: 'update', documents: docData })
|
||||||
|
},
|
||||||
|
search(query: string, loc: string) {
|
||||||
|
const worker = getSearchWorker()
|
||||||
|
const id = ++searchId
|
||||||
|
searchStore = this // Store reference for worker callback
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
this.searchResults = []
|
||||||
|
this.searchLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchLoading = true
|
||||||
|
worker.postMessage({ type: 'search', query, loc, id })
|
||||||
},
|
},
|
||||||
login(username: string, privileged: boolean) {
|
login(username: string, privileged: boolean) {
|
||||||
this.user.username = username
|
this.user.username = username
|
||||||
|
|||||||
@@ -6,3 +6,44 @@ export const exists = (path: string[]) => {
|
|||||||
const p = path.join('/')
|
const p = path.join('/')
|
||||||
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
|
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
||||||
|
export const stripExt = (name: string): string => {
|
||||||
|
// Common compound extensions
|
||||||
|
const compoundExts = ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
for (const ext of compoundExts) {
|
||||||
|
if (lower.endsWith(ext)) return name.slice(0, -ext.length)
|
||||||
|
}
|
||||||
|
// Regular extension: only strip if the extension looks like one (2-5 chars, alphanumeric)
|
||||||
|
const lastDot = name.lastIndexOf('.')
|
||||||
|
if (lastDot > 0) {
|
||||||
|
const ext = name.slice(lastDot + 1)
|
||||||
|
if (ext.length >= 2 && ext.length <= 5 && /^[a-zA-Z0-9]+$/.test(ext)) {
|
||||||
|
return name.slice(0, lastDot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a sensible zip filename for a selection of items */
|
||||||
|
export const zipName = (items: { name: string; loc: string }[]): string => {
|
||||||
|
const names = items.map(d => d.name)
|
||||||
|
if (names.length === 1) {
|
||||||
|
// Single item - use its name
|
||||||
|
return stripExt(names[0]!)
|
||||||
|
}
|
||||||
|
// Check if all items share the same direct parent folder
|
||||||
|
const locs = items.map(d => d.loc)
|
||||||
|
const sameLoc = locs.every(loc => loc === locs[0])
|
||||||
|
if (sameLoc && locs[0]) {
|
||||||
|
// All items in same folder - use folder name
|
||||||
|
return locs[0].split('/').pop()!
|
||||||
|
}
|
||||||
|
if (names.length <= 3) {
|
||||||
|
// Few items from different folders - join basenames with dot
|
||||||
|
return names.map(stripExt).join('.')
|
||||||
|
}
|
||||||
|
// Many items from different folders - first basename + indicator
|
||||||
|
return `${stripExt(names[0]!)}.etc`
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,25 +2,25 @@
|
|||||||
<Gallery
|
<Gallery
|
||||||
v-if="store.prefs.gallery"
|
v-if="store.prefs.gallery"
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`gallery-${Router.currentRoute.value.path}`"
|
:key="`gallery-${folderPath}`"
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
<FileExplorer
|
<FileExplorer
|
||||||
v-else
|
v-else
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`explorer-${Router.currentRoute.value.path}`"
|
:key="`explorer-${folderPath}`"
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
||||||
<EmptyFolder :documents=documents :path=props.path />
|
<EmptyFolder :documents=documents :path=props.path />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watchEffect, ref, computed, watch } from 'vue'
|
import { watchEffect, ref, computed, watch } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import Router from '@/router/index'
|
import { collator } from '@/utils'
|
||||||
import { needleFormat, localeIncludes, collator } from '@/utils'
|
|
||||||
import { sorted, sortedGrouped } from '@/utils/docsort'
|
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||||
import FileExplorer from '@/components/FileExplorer.vue'
|
import FileExplorer from '@/components/FileExplorer.vue'
|
||||||
|
|
||||||
@@ -30,41 +30,42 @@ const props = defineProps<{
|
|||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Folder path for component keys - only recreate component when folder changes, not search
|
||||||
|
const folderPath = computed(() => props.path.join('/'))
|
||||||
|
// Trigger search when query changes (from route, e.g., page load or back button)
|
||||||
|
// Note: Direct typing triggers search immediately via HeaderMain, this is for route-based changes
|
||||||
|
watch(
|
||||||
|
() => [props.query, props.path.join('/')] as const,
|
||||||
|
([query, loc]) => {
|
||||||
|
// Only trigger if results don't match current query (avoid duplicate searches)
|
||||||
|
if (query && store.searchResults.length === 0) {
|
||||||
|
store.search(query, loc)
|
||||||
|
} else if (!query) {
|
||||||
|
store.search('', loc) // Clear search results
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const documents = computed(() => {
|
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 (no search)
|
||||||
if (!query) return sorted(
|
if (!query) return sorted(
|
||||||
store.document.filter(doc => doc.loc === loc),
|
store.document.filter(doc => doc.loc === loc),
|
||||||
store.prefs.sortListing,
|
store.prefs.sortListing,
|
||||||
)
|
)
|
||||||
// Find up to 100 newest documents that match the search
|
|
||||||
const needle = needleFormat(query)
|
// Search results from worker
|
||||||
let limit = 100
|
const docs = store.searchResults
|
||||||
let docs = []
|
|
||||||
for (const doc of store.recentDocuments) {
|
|
||||||
if (localeIncludes(doc.haystack, needle)) {
|
|
||||||
docs.push(doc)
|
|
||||||
if (--limit === 0) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const locsub = loc + '/'
|
|
||||||
// Custom sort override in effect? Use grouped sorting to keep folders together
|
// Custom sort override in effect? Use grouped sorting to keep folders together
|
||||||
const order = store.prefs.sortFiltered
|
const order = store.prefs.sortFiltered
|
||||||
if (order) return sortedGrouped(docs, order)
|
if (order) return sortedGrouped(docs, order)
|
||||||
// Sort by relevance - current folder, then subfolders, then others
|
|
||||||
docs.sort((a, b) => (
|
// Results are already sorted by relevance in the worker
|
||||||
// @ts-ignore
|
|
||||||
(b.loc === loc) - (a.loc === loc) ||
|
|
||||||
// @ts-ignore
|
|
||||||
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
|
|
||||||
collator.compare(a.loc, b.loc) ||
|
|
||||||
// @ts-ignore
|
|
||||||
(a.type === 'file') - (b.type === 'file') ||
|
|
||||||
// @ts-ignore
|
|
||||||
b.name.includes(query) - a.name.includes(query) ||
|
|
||||||
collator.compare(a.name, b.name)
|
|
||||||
))
|
|
||||||
return docs
|
return docs
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,4 +93,15 @@ watch([() => props.path.join('/'), () => store.document.length], ([path, len], [
|
|||||||
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
.search-loading {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--accent-color, #007bff);
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Search worker - runs search in background thread
|
||||||
|
// Receives document updates and search queries, returns incremental results
|
||||||
|
|
||||||
|
interface DocData {
|
||||||
|
loc: string
|
||||||
|
name: string
|
||||||
|
key: string
|
||||||
|
size: number
|
||||||
|
mtime: number
|
||||||
|
dir: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkerDoc extends DocData {
|
||||||
|
haystack: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchMessage {
|
||||||
|
type: 'search'
|
||||||
|
query: string
|
||||||
|
loc: string
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateMessage {
|
||||||
|
type: 'update'
|
||||||
|
documents: DocData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncomingMessage = SearchMessage | UpdateMessage
|
||||||
|
|
||||||
|
interface ResultMessage {
|
||||||
|
type: 'results'
|
||||||
|
docs: DocData[]
|
||||||
|
id: number
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker state
|
||||||
|
let documents: WorkerDoc[] = []
|
||||||
|
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
|
||||||
|
let currentSearchId = 0
|
||||||
|
|
||||||
|
// Haystack formatting (same as main thread utils)
|
||||||
|
function haystackFormat(str: string): string {
|
||||||
|
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||||
|
return '^' + based + '$'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needle formatting
|
||||||
|
function needleFormat(query: string) {
|
||||||
|
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||||
|
return { based, words: based.split(/\s+/) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test if haystack includes needle
|
||||||
|
function localeIncludes(haystack: string, filter: { based: string; words: string[] }): boolean {
|
||||||
|
const { based, words } = filter
|
||||||
|
return haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collator for sorting
|
||||||
|
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
|
||||||
|
|
||||||
|
// Sort by mtime descending
|
||||||
|
function sortByRecent(docs: WorkerDoc[]): WorkerDoc[] {
|
||||||
|
return [...docs].sort((a, b) => b.mtime - a.mtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield control to check for new messages
|
||||||
|
function yieldControl(): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform search with incremental results
|
||||||
|
async function performSearch(query: string, loc: string, searchId: number) {
|
||||||
|
const needle = needleFormat(query)
|
||||||
|
const limit = 100
|
||||||
|
const batchSize = 500 // Smaller batches for faster incremental feedback
|
||||||
|
const results: WorkerDoc[] = []
|
||||||
|
let lastResultCount = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < recentDocuments.length && results.length < limit; i += batchSize) {
|
||||||
|
// Check if search was superseded
|
||||||
|
if (currentSearchId !== searchId) return
|
||||||
|
|
||||||
|
// Process batch
|
||||||
|
const end = Math.min(i + batchSize, recentDocuments.length)
|
||||||
|
for (let j = i; j < end && results.length < limit; j++) {
|
||||||
|
const doc = recentDocuments[j]!
|
||||||
|
if (localeIncludes(doc.haystack, needle)) {
|
||||||
|
results.push(doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post incremental results if we found new matches
|
||||||
|
if (results.length > lastResultCount && currentSearchId === searchId) {
|
||||||
|
lastResultCount = results.length
|
||||||
|
const sortedResults = sortResults(results, query, loc)
|
||||||
|
postMessage({
|
||||||
|
type: 'results',
|
||||||
|
docs: sortedResults.map(stripHaystack),
|
||||||
|
id: searchId,
|
||||||
|
done: false
|
||||||
|
} as ResultMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield control between batches to allow new search requests to interrupt
|
||||||
|
if (i + batchSize < recentDocuments.length && results.length < limit) {
|
||||||
|
await yieldControl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post final results
|
||||||
|
if (currentSearchId === searchId) {
|
||||||
|
const sortedResults = sortResults(results, query, loc)
|
||||||
|
postMessage({
|
||||||
|
type: 'results',
|
||||||
|
docs: sortedResults.map(stripHaystack),
|
||||||
|
id: searchId,
|
||||||
|
done: true
|
||||||
|
} as ResultMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort results by relevance
|
||||||
|
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
|
||||||
|
const locsub = loc + '/'
|
||||||
|
return [...docs].sort((a, b) => (
|
||||||
|
// Current folder first
|
||||||
|
// @ts-ignore
|
||||||
|
(b.loc === loc) - (a.loc === loc) ||
|
||||||
|
// Then subfolders
|
||||||
|
// @ts-ignore
|
||||||
|
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
|
||||||
|
// Then by location
|
||||||
|
collator.compare(a.loc, b.loc) ||
|
||||||
|
// Files after folders
|
||||||
|
// @ts-ignore
|
||||||
|
(a.dir === false) - (b.dir === false) ||
|
||||||
|
// Exact name match first
|
||||||
|
// @ts-ignore
|
||||||
|
b.name.includes(query) - a.name.includes(query) ||
|
||||||
|
// Finally by name
|
||||||
|
collator.compare(a.name, b.name)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip haystack before sending back to main thread
|
||||||
|
function stripHaystack(doc: WorkerDoc): DocData {
|
||||||
|
const { haystack, ...rest } = doc
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming messages
|
||||||
|
self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
|
||||||
|
const msg = e.data
|
||||||
|
|
||||||
|
if (msg.type === 'update') {
|
||||||
|
// Update document list with haystacks
|
||||||
|
documents = msg.documents.map(doc => ({
|
||||||
|
...doc,
|
||||||
|
haystack: haystackFormat(doc.name)
|
||||||
|
}))
|
||||||
|
recentDocuments = sortByRecent(documents)
|
||||||
|
} else if (msg.type === 'search') {
|
||||||
|
currentSearchId = msg.id
|
||||||
|
if (msg.query) {
|
||||||
|
await performSearch(msg.query, msg.loc, msg.id)
|
||||||
|
} else {
|
||||||
|
// Empty query - no results needed (main thread handles folder listing)
|
||||||
|
postMessage({
|
||||||
|
type: 'results',
|
||||||
|
docs: [],
|
||||||
|
id: msg.id,
|
||||||
|
done: true
|
||||||
|
} as ResultMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -27,7 +27,7 @@ dependencies = [
|
|||||||
"argon2-cffi>=25.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"av>=15.0.0",
|
"av>=15.0.0",
|
||||||
"blake3>=1.0.5",
|
"blake3>=1.0.5",
|
||||||
"docopt>=0.6.2",
|
"docopt-ng>=0.9.0",
|
||||||
"fastapi-vue>=0.5.1",
|
"fastapi-vue>=0.5.1",
|
||||||
"fastapi[standard]>=0.128.0",
|
"fastapi[standard]>=0.128.0",
|
||||||
"html5tagger>=1.3.0",
|
"html5tagger>=1.3.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user