Frontend created and rewritten a few times, with some backend fixes #1
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user