upload volledige repo

This commit is contained in:
kodi
2026-03-11 09:39:41 +01:00
commit ce420cbb0e
110 changed files with 5660 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Service layer."""
@@ -0,0 +1,53 @@
from __future__ import annotations
import sqlite3
from backend.app.api.errors import AppError
from backend.app.api.schemas import BookmarkDeleteResponse, BookmarkItem, BookmarkListResponse
from backend.app.db.bookmark_repository import BookmarkRepository
from backend.app.security.path_guard import PathGuard
class BookmarkService:
def __init__(self, path_guard: PathGuard, repository: BookmarkRepository):
self._path_guard = path_guard
self._repository = repository
def create_bookmark(self, path: str, label: str) -> BookmarkItem:
normalized_label = (label or "").strip()
if not normalized_label:
raise AppError(
code="invalid_request",
message="Label is required",
status_code=400,
details={"label": label},
)
resolved = self._path_guard.resolve_path(path)
try:
bookmark = self._repository.create_bookmark(path=resolved.relative, label=normalized_label)
except sqlite3.IntegrityError:
raise AppError(
code="already_exists",
message="Bookmark already exists for path",
status_code=409,
details={"path": resolved.relative},
)
return BookmarkItem(**bookmark)
def list_bookmarks(self) -> BookmarkListResponse:
items = [BookmarkItem(**row) for row in self._repository.list_bookmarks()]
return BookmarkListResponse(items=items)
def delete_bookmark(self, bookmark_id: int) -> BookmarkDeleteResponse:
deleted = self._repository.delete_bookmark(bookmark_id)
if not deleted:
raise AppError(
code="path_not_found",
message="Bookmark was not found",
status_code=404,
details={"bookmark_id": str(bookmark_id)},
)
return BookmarkDeleteResponse(id=bookmark_id)
@@ -0,0 +1,36 @@
from __future__ import annotations
from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.security.path_guard import PathGuard
class BrowseService:
def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter):
self._path_guard = path_guard
self._filesystem = filesystem
def browse(self, path: str, show_hidden: bool) -> BrowseResponse:
resolved = self._path_guard.resolve_directory_path(path)
directories_raw, files_raw = self._filesystem.list_directory(resolved.absolute, show_hidden=show_hidden)
directories = [
DirectoryEntry(
name=item["name"],
path=self._path_guard.entry_relative_path(resolved.alias, item["absolute"]),
modified=item["modified"],
)
for item in directories_raw
]
files = [
FileEntry(
name=item["name"],
path=self._path_guard.entry_relative_path(resolved.alias, item["absolute"]),
size=item["size"],
modified=item["modified"],
)
for item in files_raw
]
return BrowseResponse(path=resolved.relative, directories=directories, files=files)
@@ -0,0 +1,77 @@
from __future__ import annotations
from pathlib import Path
from backend.app.api.errors import AppError
from backend.app.api.schemas import TaskCreateResponse
from backend.app.db.task_repository import TaskRepository
from backend.app.security.path_guard import PathGuard
from backend.app.tasks_runner import TaskRunner
class CopyTaskService:
def __init__(self, path_guard: PathGuard, repository: TaskRepository, runner: TaskRunner):
self._path_guard = path_guard
self._repository = repository
self._runner = runner
def create_copy_task(self, source: str, destination: str) -> TaskCreateResponse:
resolved_source = self._path_guard.resolve_existing_path(source)
_, _, lexical_source = self._path_guard.resolve_lexical_path(source)
if lexical_source.is_symlink():
raise AppError(
code="type_conflict",
message="Source must be a regular file",
status_code=409,
details={"path": source},
)
if not resolved_source.absolute.is_file():
raise AppError(
code="type_conflict",
message="Source must be a file",
status_code=409,
details={"path": source},
)
resolved_destination = self._path_guard.resolve_path(destination)
destination_parent = resolved_destination.absolute.parent
parent_relative = self._path_guard.entry_relative_path(resolved_destination.alias, destination_parent)
self._map_directory_validation(parent_relative)
if resolved_destination.absolute.exists():
raise AppError(
code="already_exists",
message="Target path already exists",
status_code=409,
details={"path": resolved_destination.relative},
)
total_bytes = int(resolved_source.absolute.stat().st_size)
task = self._repository.create_task(
operation="copy",
source=resolved_source.relative,
destination=resolved_destination.relative,
)
self._runner.enqueue_copy_file(
task_id=task["id"],
source=str(resolved_source.absolute),
destination=str(resolved_destination.absolute),
total_bytes=total_bytes,
)
return TaskCreateResponse(task_id=task["id"], status=task["status"])
def _map_directory_validation(self, relative_path: str) -> None:
try:
self._path_guard.resolve_directory_path(relative_path)
except AppError as exc:
if exc.code == "path_type_conflict":
raise AppError(
code="type_conflict",
message="Destination parent is not a directory",
status_code=409,
details=exc.details,
)
raise
@@ -0,0 +1,134 @@
from __future__ import annotations
from pathlib import Path
from backend.app.api.errors import AppError
from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.security.path_guard import PathGuard
class FileOpsService:
def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter):
self._path_guard = path_guard
self._filesystem = filesystem
def mkdir(self, parent_path: str, name: str) -> MkdirResponse:
resolved_parent = self._path_guard.resolve_directory_path(parent_path)
safe_name = self._path_guard.validate_name(name, field="name")
target_relative = self._join_relative(resolved_parent.relative, safe_name)
resolved_target = self._path_guard.resolve_path(target_relative)
if resolved_target.absolute.exists():
raise AppError(
code="already_exists",
message="Target path already exists",
status_code=409,
details={"path": resolved_target.relative},
)
try:
self._filesystem.make_directory(resolved_target.absolute)
except FileExistsError:
raise AppError(
code="already_exists",
message="Target path already exists",
status_code=409,
details={"path": resolved_target.relative},
)
except OSError as exc:
raise AppError(
code="io_error",
message="Filesystem operation failed",
status_code=500,
details={"reason": str(exc)},
)
return MkdirResponse(path=resolved_target.relative)
def rename(self, path: str, new_name: str) -> RenameResponse:
resolved_source = self._path_guard.resolve_existing_path(path)
safe_name = self._path_guard.validate_name(new_name, field="new_name")
parent_relative = self._path_guard.entry_relative_path(resolved_source.alias, resolved_source.absolute.parent)
target_relative = self._join_relative(parent_relative, safe_name)
resolved_target = self._path_guard.resolve_path(target_relative)
if resolved_target.absolute.exists():
raise AppError(
code="already_exists",
message="Target path already exists",
status_code=409,
details={"path": resolved_target.relative},
)
try:
self._filesystem.rename_path(resolved_source.absolute, resolved_target.absolute)
except FileNotFoundError:
raise AppError(
code="path_not_found",
message="Requested path was not found",
status_code=404,
details={"path": path},
)
except FileExistsError:
raise AppError(
code="already_exists",
message="Target path already exists",
status_code=409,
details={"path": resolved_target.relative},
)
except OSError as exc:
raise AppError(
code="io_error",
message="Filesystem operation failed",
status_code=500,
details={"reason": str(exc)},
)
return RenameResponse(path=resolved_target.relative)
def delete(self, path: str) -> DeleteResponse:
resolved_target = self._path_guard.resolve_existing_path(path)
try:
if resolved_target.absolute.is_file():
self._filesystem.delete_file(resolved_target.absolute)
elif resolved_target.absolute.is_dir():
if not self._filesystem.is_directory_empty(resolved_target.absolute):
raise AppError(
code="directory_not_empty",
message="Directory is not empty",
status_code=409,
details={"path": resolved_target.relative},
)
self._filesystem.delete_empty_directory(resolved_target.absolute)
else:
raise AppError(
code="type_conflict",
message="Unsupported path type for delete",
status_code=409,
details={"path": resolved_target.relative},
)
except AppError:
raise
except FileNotFoundError:
raise AppError(
code="path_not_found",
message="Requested path was not found",
status_code=404,
details={"path": path},
)
except OSError as exc:
raise AppError(
code="io_error",
message="Filesystem operation failed",
status_code=500,
details={"reason": str(exc)},
)
return DeleteResponse(path=resolved_target.relative)
@staticmethod
def _join_relative(base: str, name: str) -> str:
return f"{base}/{name}" if base else name
@@ -0,0 +1,77 @@
from __future__ import annotations
from backend.app.api.errors import AppError
from backend.app.api.schemas import TaskCreateResponse
from backend.app.db.task_repository import TaskRepository
from backend.app.security.path_guard import PathGuard
from backend.app.tasks_runner import TaskRunner
class MoveTaskService:
def __init__(self, path_guard: PathGuard, repository: TaskRepository, runner: TaskRunner):
self._path_guard = path_guard
self._repository = repository
self._runner = runner
def create_move_task(self, source: str, destination: str) -> TaskCreateResponse:
resolved_source = self._path_guard.resolve_existing_path(source)
_, _, lexical_source = self._path_guard.resolve_lexical_path(source)
if lexical_source.is_symlink():
raise AppError(
code="type_conflict",
message="Source must be a regular file",
status_code=409,
details={"path": source},
)
if not resolved_source.absolute.is_file():
raise AppError(
code="type_conflict",
message="Source must be a file",
status_code=409,
details={"path": source},
)
resolved_destination = self._path_guard.resolve_path(destination)
destination_parent = resolved_destination.absolute.parent
parent_relative = self._path_guard.entry_relative_path(resolved_destination.alias, destination_parent)
self._map_directory_validation(parent_relative)
if resolved_destination.absolute.exists():
raise AppError(
code="already_exists",
message="Target path already exists",
status_code=409,
details={"path": resolved_destination.relative},
)
total_bytes = int(resolved_source.absolute.stat().st_size)
task = self._repository.create_task(
operation="move",
source=resolved_source.relative,
destination=resolved_destination.relative,
)
same_root = resolved_source.alias == resolved_destination.alias
self._runner.enqueue_move_file(
task_id=task["id"],
source=str(resolved_source.absolute),
destination=str(resolved_destination.absolute),
total_bytes=total_bytes,
same_root=same_root,
)
return TaskCreateResponse(task_id=task["id"], status=task["status"])
def _map_directory_validation(self, relative_path: str) -> None:
try:
self._path_guard.resolve_directory_path(relative_path)
except AppError as exc:
if exc.code == "path_type_conflict":
raise AppError(
code="type_conflict",
message="Destination parent is not a directory",
status_code=409,
details=exc.details,
)
raise
@@ -0,0 +1,42 @@
from __future__ import annotations
from backend.app.api.errors import AppError
from backend.app.api.schemas import TaskDetailResponse, TaskListItem, TaskListResponse
from backend.app.db.task_repository import TaskRepository
class TaskService:
def __init__(self, repository: TaskRepository):
self._repository = repository
def create_task(self, operation: str, source: str, destination: str) -> TaskDetailResponse:
task = self._repository.create_task(operation=operation, source=source, destination=destination)
return TaskDetailResponse(**task)
def get_task(self, task_id: str) -> TaskDetailResponse:
task = self._repository.get_task(task_id)
if not task:
raise AppError(
code="task_not_found",
message="Task was not found",
status_code=404,
details={"task_id": task_id},
)
return TaskDetailResponse(**task)
def list_tasks(self) -> TaskListResponse:
tasks = self._repository.list_tasks()
return TaskListResponse(
items=[
TaskListItem(
id=task["id"],
operation=task["operation"],
status=task["status"],
source=task["source"],
destination=task["destination"],
created_at=task["created_at"],
finished_at=task["finished_at"],
)
for task in tasks
]
)