Frontend created and rewritten a few times, with some backend fixes #1

Merged
leo merged 110 commits from plaintable into main 2023-11-08 20:38:40 +00:00
2 changed files with 93 additions and 30 deletions
Showing only changes of commit 63bbe84859 - Show all commits

View File

@ -11,19 +11,24 @@ from cista.util import filename
## Control commands ## Control commands
class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower): class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
def __call__(self): def __call__(self):
raise NotImplementedError raise NotImplementedError
class MkDir(ControlBase): class MkDir(ControlBase):
path: str path: str
def __call__(self): def __call__(self):
path = config.config.path / filename.sanitize(self.path) path = config.config.path / filename.sanitize(self.path)
path.mkdir(parents=False, exist_ok=False) path.mkdir(parents=False, exist_ok=False)
class Rename(ControlBase): class Rename(ControlBase):
path: str path: str
to: str to: str
def __call__(self): def __call__(self):
to = filename.sanitize(self.to) to = filename.sanitize(self.to)
if "/" in to: if "/" in to:
@ -31,17 +36,21 @@ class Rename(ControlBase):
path = config.config.path / filename.sanitize(self.path) path = config.config.path / filename.sanitize(self.path)
path.rename(path.with_name(to)) path.rename(path.with_name(to))
class Rm(ControlBase): class Rm(ControlBase):
sel: list[str] sel: list[str]
def __call__(self): def __call__(self):
root = config.config.path root = config.config.path
sel = [root / filename.sanitize(p) for p in self.sel] sel = [root / filename.sanitize(p) for p in self.sel]
for p in sel: for p in sel:
shutil.rmtree(p, ignore_errors=True) shutil.rmtree(p, ignore_errors=True)
class Mv(ControlBase): class Mv(ControlBase):
sel: list[str] sel: list[str]
dst: str dst: str
def __call__(self): def __call__(self):
root = config.config.path root = config.config.path
sel = [root / filename.sanitize(p) for p in self.sel] sel = [root / filename.sanitize(p) for p in self.sel]
@ -51,9 +60,11 @@ class Mv(ControlBase):
for p in sel: for p in sel:
shutil.move(p, dst) shutil.move(p, dst)
class Cp(ControlBase): class Cp(ControlBase):
sel: list[str] sel: list[str]
dst: str dst: str
def __call__(self): def __call__(self):
root = config.config.path root = config.config.path
sel = [root / filename.sanitize(p) for p in self.sel] sel = [root / filename.sanitize(p) for p in self.sel]
@ -62,30 +73,41 @@ class Cp(ControlBase):
raise BadRequest("The destination must be a directory") raise BadRequest("The destination must be a directory")
for p in sel: for p in sel:
# Note: copies as dst rather than in dst unless name is appended. # Note: copies as dst rather than in dst unless name is appended.
shutil.copytree(p, dst / p.name, dirs_exist_ok=True, ignore_dangling_symlinks=True) shutil.copytree(
p, dst / p.name, dirs_exist_ok=True, ignore_dangling_symlinks=True
)
## File uploads and downloads ## File uploads and downloads
class FileRange(msgspec.Struct): class FileRange(msgspec.Struct):
name: str name: str
size: int size: int
start: int start: int
end: int end: int
class StatusMsg(msgspec.Struct): class StatusMsg(msgspec.Struct):
status: str status: str
req: FileRange req: FileRange
class ErrorMsg(msgspec.Struct): class ErrorMsg(msgspec.Struct):
error: dict[str, Any] error: dict[str, Any]
## Directory listings ## Directory listings
class FileEntry(msgspec.Struct): class FileEntry(msgspec.Struct):
id: str
size: int size: int
mtime: int mtime: int
class DirEntry(msgspec.Struct): class DirEntry(msgspec.Struct):
id: str
size: int size: int
mtime: int mtime: int
dir: DirList dir: DirList
@ -104,30 +126,30 @@ class DirEntry(msgspec.Struct):
@property @property
def props(self): def props(self):
return { return {k: v for k, v in self.__struct_fields__ if k != "dir"}
k: v
for k, v in self.__struct_fields__
if k != "dir"
}
DirList = dict[str, FileEntry | DirEntry] DirList = dict[str, FileEntry | DirEntry]
class UpdateEntry(msgspec.Struct, omit_defaults=True): class UpdateEntry(msgspec.Struct, omit_defaults=True):
"""Updates the named entry in the tree. Fields that are set replace old values. A list of entries recurses directories.""" """Updates the named entry in the tree. Fields that are set replace old values. A list of entries recurses directories."""
name: str = "" name: str = ""
deleted: bool = False deleted: bool = False
id: str | None = None
size: int | None = None size: int | None = None
mtime: int | None = None mtime: int | None = None
dir: DirList | None = None dir: DirList | None = None
def make_dir_data(root): def make_dir_data(root):
if len(root) == 2: if len(root) == 3:
return FileEntry(*root) return FileEntry(*root)
size, mtime, listing = root id_, size, mtime, listing = root
converted = {} converted = {}
for name, data in listing.items(): for name, data in listing.items():
converted[name] = make_dir_data(data) converted[name] = make_dir_data(data)
sz = sum(x.size for x in converted.values()) sz = sum(x.size for x in converted.values())
mt = max(x.mtime for x in converted.values()) mt = max(x.mtime for x in converted.values())
return DirEntry(sz, max(mt, mtime), converted) return DirEntry(id_, sz, max(mt, mtime), converted)

View File

@ -8,6 +8,7 @@ import inotify.adapters
import msgspec import msgspec
from cista import config from cista import config
from cista.fileio import fuid
from cista.protocol import DirEntry, FileEntry, UpdateEntry from cista.protocol import DirEntry, FileEntry, UpdateEntry
pubsub = {} pubsub = {}
@ -15,9 +16,18 @@ tree = {"": None}
tree_lock = threading.Lock() tree_lock = threading.Lock()
rootpath: Path = None # type: ignore rootpath: Path = None # type: ignore
quit = False quit = False
modified_flags = "IN_CREATE", "IN_DELETE", "IN_DELETE_SELF", "IN_MODIFY", "IN_MOVE_SELF", "IN_MOVED_FROM", "IN_MOVED_TO" modified_flags = (
"IN_CREATE",
"IN_DELETE",
"IN_DELETE_SELF",
"IN_MODIFY",
"IN_MOVE_SELF",
"IN_MOVED_FROM",
"IN_MOVED_TO",
)
disk_usage = None disk_usage = None
def watcher_thread(loop): def watcher_thread(loop):
global disk_usage global disk_usage
@ -36,7 +46,8 @@ def watcher_thread(loop):
refreshdl = time.monotonic() + 60.0 refreshdl = time.monotonic() + 60.0
for event in i.event_gen(): for event in i.event_gen():
if quit: return if quit:
return
# Disk usage update # Disk usage update
du = shutil.disk_usage(rootpath) du = shutil.disk_usage(rootpath)
if du != disk_usage: if du != disk_usage:
@ -44,8 +55,10 @@ def watcher_thread(loop):
asyncio.run_coroutine_threadsafe(broadcast(format_du()), loop) asyncio.run_coroutine_threadsafe(broadcast(format_du()), loop)
break break
# Do a full refresh? # Do a full refresh?
if time.monotonic() > refreshdl: break if time.monotonic() > refreshdl:
if event is None: continue break
if event is None:
continue
_, flags, path, filename = event _, flags, path, filename = event
if not any(f in modified_flags for f in flags): if not any(f in modified_flags for f in flags):
continue continue
@ -58,50 +71,72 @@ def watcher_thread(loop):
break break
i = None # Free the inotify object i = None # Free the inotify object
def format_du(): def format_du():
return msgspec.json.encode({"space": { return msgspec.json.encode(
{
"space": {
"disk": disk_usage.total, "disk": disk_usage.total,
"used": disk_usage.used, "used": disk_usage.used,
"free": disk_usage.free, "free": disk_usage.free,
"storage": tree[""].size, "storage": tree[""].size,
}}).decode() }
}
).decode()
def format_tree(): def format_tree():
root = tree[""] root = tree[""]
return msgspec.json.encode({"update": [ return msgspec.json.encode(
UpdateEntry(size=root.size, mtime=root.mtime, dir=root.dir) {
]}).decode() "update": [
UpdateEntry(id=root.id, size=root.size, mtime=root.mtime, dir=root.dir)
]
}
).decode()
def walk(path: Path) -> DirEntry | FileEntry | None: def walk(path: Path) -> DirEntry | FileEntry | None:
try: try:
s = path.stat() s = path.stat()
id_ = fuid(s)
mtime = int(s.st_mtime) mtime = int(s.st_mtime)
if path.is_file(): if path.is_file():
return FileEntry(s.st_size, mtime) return FileEntry(id_, s.st_size, mtime)
tree = {p.name: v for p in path.iterdir() if not p.name.startswith('.') if (v := walk(p)) is not None} tree = {
p.name: v
for p in path.iterdir()
if not p.name.startswith(".")
if (v := walk(p)) is not None
}
if tree: if tree:
size = sum(v.size for v in tree.values()) size = sum(v.size for v in tree.values())
mtime = max(mtime, max(v.mtime for v in tree.values())) mtime = max(mtime, max(v.mtime for v in tree.values()))
else: else:
size = 0 size = 0
return DirEntry(size, mtime, tree) return DirEntry(id_, size, mtime, tree)
except FileNotFoundError: except FileNotFoundError:
return None return None
except OSError as e: except OSError as e:
print("OS error walking path", path, e) print("OS error walking path", path, e)
return None return None
def update(relpath: PurePosixPath, loop): def update(relpath: PurePosixPath, loop):
"""Called by inotify updates, check the filesystem and broadcast any changes.""" """Called by inotify updates, check the filesystem and broadcast any changes."""
new = walk(rootpath / relpath) new = walk(rootpath / relpath)
with tree_lock: with tree_lock:
update = update_internal(relpath, new) update = update_internal(relpath, new)
if not update: return # No changes if not update:
return # No changes
msg = msgspec.json.encode({"update": update}).decode() msg = msgspec.json.encode({"update": update}).decode()
asyncio.run_coroutine_threadsafe(broadcast(msg), loop) asyncio.run_coroutine_threadsafe(broadcast(msg), loop)
def update_internal(relpath: PurePosixPath, new: DirEntry | FileEntry | None) -> list[UpdateEntry]:
def update_internal(
relpath: PurePosixPath, new: DirEntry | FileEntry | None
) -> list[UpdateEntry]:
path = "", *relpath.parts path = "", *relpath.parts
old = tree old = tree
elems = [] elems = []
@ -142,25 +177,31 @@ def update_internal(relpath: PurePosixPath, new: DirEntry | FileEntry | None) ->
u = UpdateEntry(name) u = UpdateEntry(name)
if new: if new:
parent[name] = new parent[name] = new
if u.size != new.size: u.size = new.size if u.size != new.size:
if u.mtime != new.mtime: u.mtime = new.mtime u.size = new.size
if u.mtime != new.mtime:
u.mtime = new.mtime
if isinstance(new, DirEntry): if isinstance(new, DirEntry):
if u.dir == new.dir: u.dir = new.dir if u.dir == new.dir:
u.dir = new.dir
else: else:
del parent[name] del parent[name]
u.deleted = True u.deleted = True
update.append(u) update.append(u)
return update return update
async def broadcast(msg): async def broadcast(msg):
for queue in pubsub.values(): for queue in pubsub.values():
await queue.put_nowait(msg) await queue.put_nowait(msg)
async def start(app, loop): async def start(app, loop):
config.load_config() config.load_config()
app.ctx.watcher = threading.Thread(target=watcher_thread, args=[loop]) app.ctx.watcher = threading.Thread(target=watcher_thread, args=[loop])
app.ctx.watcher.start() app.ctx.watcher.start()
async def stop(app, loop): async def stop(app, loop):
global quit global quit
quit = True quit = True