feat: CMD-ENTER file info toegevoegd

This commit is contained in:
kodi
2026-03-12 11:45:56 +01:00
parent 6f8f884d75
commit 76f5ed3e98
16 changed files with 476 additions and 2 deletions
+9 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, ViewResponse
from backend.app.api.schemas import DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, ViewResponse
from backend.app.dependencies import get_file_ops_service
from backend.app.services.file_ops_service import FileOpsService
@@ -43,6 +43,14 @@ async def view(
return service.view(path=path, for_edit=for_edit)
@router.get("/info", response_model=FileInfoResponse)
async def info(
path: str,
service: FileOpsService = Depends(get_file_ops_service),
) -> FileInfoResponse:
return service.info(path=path)
@router.get("/video")
async def video(
path: str,
+13
View File
@@ -81,6 +81,19 @@ class SaveResponse(BaseModel):
modified: str
class FileInfoResponse(BaseModel):
name: str
path: str
type: str
size: int | None = None
modified: str
root: str
extension: str | None = None
content_type: str | None = None
owner: str | None = None
group: str | None = None
class TaskListItem(BaseModel):
id: str
operation: str
@@ -1,11 +1,38 @@
from __future__ import annotations
import shutil
import mimetypes
import grp
import pwd
from datetime import datetime, timezone
from pathlib import Path
class FilesystemAdapter:
def stat_info(self, path: Path) -> dict:
stat = path.stat()
owner = None
group = None
try:
owner = pwd.getpwuid(stat.st_uid).pw_name
except (KeyError, ImportError, AttributeError):
owner = None
try:
group = grp.getgrgid(stat.st_gid).gr_name
except (KeyError, ImportError, AttributeError):
group = None
content_type, _ = mimetypes.guess_type(path.name)
return {
"name": path.name,
"size": int(stat.st_size) if path.is_file() else None,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z"),
"owner": owner,
"group": group,
"content_type": content_type,
"extension": path.suffix.lower() or None,
}
def list_directory(self, directory: Path, show_hidden: bool) -> tuple[list[dict], list[dict]]:
directories: list[dict] = []
files: list[dict] = []
+18 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from pathlib import Path
from backend.app.api.errors import AppError
from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse, SaveResponse, ViewResponse
from backend.app.api.schemas import DeleteResponse, FileInfoResponse, MkdirResponse, RenameResponse, SaveResponse, ViewResponse
from backend.app.db.history_repository import HistoryRepository
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.security.path_guard import PathGuard
@@ -245,6 +245,23 @@ class FileOpsService:
content=preview["content"],
)
def info(self, path: str) -> FileInfoResponse:
resolved_target = self._path_guard.resolve_existing_path(path)
metadata = self._filesystem.stat_info(resolved_target.absolute)
return FileInfoResponse(
name=metadata["name"],
path=resolved_target.relative,
type="directory" if resolved_target.absolute.is_dir() else "file",
size=metadata["size"],
modified=metadata["modified"],
root=resolved_target.alias,
extension=metadata["extension"],
content_type=metadata["content_type"],
owner=metadata["owner"],
group=metadata["group"],
)
def save(self, path: str, content: str, expected_modified: str) -> SaveResponse:
resolved_target = self._path_guard.resolve_existing_path(path)
@@ -0,0 +1,121 @@
from __future__ import annotations
import asyncio
import sys
import tempfile
import unittest
from pathlib import Path
import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from backend.app.dependencies import get_file_ops_service
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.main import app
from backend.app.security.path_guard import PathGuard
from backend.app.services.file_ops_service import FileOpsService
class FileInfoApiGoldenTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name) / "root"
self.root.mkdir(parents=True, exist_ok=True)
self.service = FileOpsService(path_guard=PathGuard({"storage1": str(self.root)}), filesystem=FilesystemAdapter())
async def _override_file_ops_service() -> FileOpsService:
return self.service
app.dependency_overrides[get_file_ops_service] = _override_file_ops_service
def tearDown(self) -> None:
app.dependency_overrides.clear()
self.temp_dir.cleanup()
def _request(self, path: str) -> httpx.Response:
async def _run() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
return await client.get("/api/files/info", params={"path": path})
return asyncio.run(_run())
def test_file_info_success(self) -> None:
file_path = self.root / "docs.txt"
file_path.write_text("hello", encoding="utf-8")
response = self._request("storage1/docs.txt")
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["name"], "docs.txt")
self.assertEqual(payload["path"], "storage1/docs.txt")
self.assertEqual(payload["type"], "file")
self.assertEqual(payload["size"], 5)
self.assertEqual(payload["root"], "storage1")
self.assertEqual(payload["extension"], ".txt")
self.assertEqual(payload["content_type"], "text/plain")
self.assertIn("modified", payload)
self.assertIn("owner", payload)
self.assertIn("group", payload)
def test_directory_info_success(self) -> None:
directory = self.root / "Media"
directory.mkdir()
response = self._request("storage1/Media")
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["name"], "Media")
self.assertEqual(payload["path"], "storage1/Media")
self.assertEqual(payload["type"], "directory")
self.assertIsNone(payload["size"])
self.assertEqual(payload["root"], "storage1")
self.assertIsNone(payload["extension"])
def test_info_path_not_found(self) -> None:
response = self._request("storage1/missing.txt")
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["error"]["code"], "path_not_found")
def test_info_traversal_blocked(self) -> None:
response = self._request("storage1/../etc/passwd")
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"]["code"], "path_traversal_detected")
def test_info_invalid_root_alias(self) -> None:
response = self._request("unknown/item.txt")
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"]["code"], "invalid_root_alias")
def test_info_response_shape(self) -> None:
file_path = self.root / "movie.mp4"
file_path.write_bytes(b"012345")
response = self._request("storage1/movie.mp4")
self.assertEqual(response.status_code, 200)
self.assertEqual(
set(response.json().keys()),
{
"name",
"path",
"type",
"size",
"modified",
"root",
"extension",
"content_type",
"owner",
"group",
},
)
if __name__ == "__main__":
unittest.main()
@@ -57,6 +57,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('id="search-modal"', body)
self.assertIn('id="search-input"', body)
self.assertIn('id="search-results"', body)
self.assertIn('id="info-modal"', body)
self.assertIn('id="rename-popup"', body)
self.assertIn('id="rename-input"', body)
self.assertIn('id="rename-apply-btn"', body)
@@ -84,6 +85,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('id="wildcard-popup"', body)
self.assertIn('id="wildcard-pattern-input"', body)
self.assertNotIn('id="search-btn"', body)
self.assertNotIn('id="info-btn"', body)
self.assertNotIn('id="bookmarks-panel"', body)
self.assertNotIn('id="tasks-panel"', body)
@@ -113,10 +115,14 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js)
self.assertIn('function openSearch()', app_js)
self.assertIn('async function submitSearch()', app_js)
self.assertIn('async function openInfo()', app_js)
self.assertIn('document.getElementById("info-modal")', app_js)
self.assertIn("`/api/files/info?", app_js)
self.assertIn('document.getElementById("search-input")', app_js)
self.assertIn("`/api/search?", app_js)
self.assertIn('event.key.toLowerCase() === "f"', app_js)
self.assertIn('(event.metaKey || event.ctrlKey)', app_js)
self.assertIn('const isInfoShortcut = event.key === "Enter"', app_js)
self.assertIn('if (event.key === "F1") {', app_js)
self.assertIn('if (event.key === "F2") {', app_js)
self.assertIn('function openSettings(tab = "general")', app_js)