feat: upload - deel 03.02 - Skipp all toegevoegd

This commit is contained in:
kodi
2026-03-13 18:30:10 +01:00
parent 8fe9d0f436
commit 360815498e
13 changed files with 463 additions and 19 deletions
+2 -1
View File
@@ -37,10 +37,11 @@ async def delete(
@router.post("/upload", response_model=UploadResponse)
async def upload(
target_path: str = Form(...),
overwrite: bool = Form(False),
file: UploadFile = File(...),
service: FileOpsService = Depends(get_file_ops_service),
) -> UploadResponse:
return service.upload(target_path=target_path, upload_file=file)
return service.upload(target_path=target_path, upload_file=file, overwrite=overwrite)
@router.get("/view", response_model=ViewResponse)
+3 -2
View File
@@ -140,8 +140,9 @@ class FilesystemAdapter:
"modified": self.modified_iso(path),
}
def write_uploaded_file(self, path: Path, file_stream, chunk_size: int = 1024 * 1024) -> dict:
with path.open("xb") as handle:
def write_uploaded_file(self, path: Path, file_stream, chunk_size: int = 1024 * 1024, overwrite: bool = False) -> dict:
mode = "wb" if overwrite else "xb"
with path.open(mode) as handle:
while True:
chunk = file_stream.read(chunk_size)
if not chunk:
+20 -8
View File
@@ -204,7 +204,7 @@ class FileOpsService:
self._record_history_error(operation="delete", path=path, error=error)
raise error
def upload(self, target_path: str, upload_file) -> UploadResponse:
def upload(self, target_path: str, upload_file, overwrite: bool = False) -> UploadResponse:
destination_relative = None
history_path = target_path
try:
@@ -216,14 +216,26 @@ class FileOpsService:
resolved_destination = self._path_guard.resolve_path(destination_relative)
if resolved_destination.absolute.exists():
raise AppError(
code="already_exists",
message="Target path already exists",
status_code=409,
details={"path": resolved_destination.relative},
)
if not overwrite:
raise AppError(
code="already_exists",
message="Target path already exists",
status_code=409,
details={"path": resolved_destination.relative},
)
if resolved_destination.absolute.is_dir():
raise AppError(
code="type_conflict",
message="Cannot overwrite an existing directory",
status_code=409,
details={"path": resolved_destination.relative},
)
saved = self._filesystem.write_uploaded_file(resolved_destination.absolute, upload_file.file)
saved = self._filesystem.write_uploaded_file(
resolved_destination.absolute,
upload_file.file,
overwrite=overwrite,
)
self._record_history(
operation="upload",
status="completed",
Binary file not shown.
@@ -49,13 +49,13 @@ class UploadApiGoldenTest(unittest.TestCase):
app.dependency_overrides.clear()
self.temp_dir.cleanup()
def _upload(self, *, target_path: str, filename: str, content: bytes) -> httpx.Response:
def _upload(self, *, target_path: str, filename: str, content: bytes, overwrite: bool = False) -> 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.post(
"/api/files/upload",
data={"target_path": target_path},
data={"target_path": target_path, "overwrite": "true" if overwrite else "false"},
files={"file": (filename, content, "application/octet-stream")},
)
@@ -184,3 +184,21 @@ class UploadApiGoldenTest(unittest.TestCase):
self.assertEqual(history[0]["operation"], "upload")
self.assertEqual(history[0]["status"], "failed")
self.assertEqual(history[0]["error_code"], "already_exists")
def test_upload_overwrite_existing_file_success(self) -> None:
existing = self.uploads_dir / "hello.txt"
existing.write_text("existing", encoding="utf-8")
response = self._upload(
target_path="storage1/uploads",
filename="hello.txt",
content=b"replacement",
overwrite=True,
)
self.assertEqual(response.status_code, 200)
self.assertEqual((self.uploads_dir / "hello.txt").read_bytes(), b"replacement")
history = self._get_history()
self.assertEqual(history[0]["operation"], "upload")
self.assertEqual(history[0]["status"], "completed")
@@ -203,6 +203,19 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('async function handleUploadSelection(event)', app_js)
self.assertIn('uploadElements().input.onchange = handleUploadSelection;', app_js)
self.assertIn('"/api/files/upload"', app_js)
self.assertIn('function ensureUploadConflictModal()', app_js)
self.assertIn('function promptUploadConflict(', app_js)
self.assertIn('formData.append("overwrite", overwrite ? "true" : "false")', app_js)
self.assertIn('createButton("Overwrite"', app_js)
self.assertIn('createButton("Overwrite all"', app_js)
self.assertIn('createButton("Skip"', app_js)
self.assertIn('createButton("Skip all"', app_js)
self.assertIn('createButton("Cancel"', app_js)
self.assertIn('if (err.code !== "already_exists") {', app_js)
self.assertIn('if (choice === "overwrite_all") {', app_js)
self.assertIn('if (uploadState.skipAll) {', app_js)
self.assertIn('if (choice === "skip_all") {', app_js)
self.assertIn('uploadState.skipAll = true;', app_js)
self.assertIn('Upload to: ${uploadState.targetPath}', app_js)
self.assertIn('Uploading ${total} file', app_js)
self.assertIn('`/api/files/thumbnail?', app_js)
+185 -6
View File
@@ -62,6 +62,12 @@ let uploadState = {
targetPath: "",
files: [],
index: 0,
overwriteAll: false,
skipAll: false,
successfulCount: 0,
skippedCount: 0,
cancelled: false,
conflictResolver: null,
};
let settingsState = {
activeTab: "general",
@@ -319,6 +325,72 @@ function uploadElements() {
};
}
function uploadConflictElements() {
return {
overlay: document.getElementById("upload-conflict-modal"),
title: document.getElementById("upload-conflict-title"),
target: document.getElementById("upload-conflict-target"),
fileName: document.getElementById("upload-conflict-file-name"),
message: document.getElementById("upload-conflict-message"),
overwriteButton: document.getElementById("upload-conflict-overwrite-btn"),
overwriteAllButton: document.getElementById("upload-conflict-overwrite-all-btn"),
skipButton: document.getElementById("upload-conflict-skip-btn"),
skipAllButton: document.getElementById("upload-conflict-skip-all-btn"),
cancelButton: document.getElementById("upload-conflict-cancel-btn"),
};
}
function ensureUploadConflictModal() {
if (document.getElementById("upload-conflict-modal")) {
return uploadConflictElements();
}
const overlay = document.createElement("div");
overlay.id = "upload-conflict-modal";
overlay.className = "popup-overlay hidden";
const card = document.createElement("div");
card.className = "popup-card";
card.setAttribute("role", "dialog");
card.setAttribute("aria-modal", "true");
card.setAttribute("aria-labelledby", "upload-conflict-title");
const title = document.createElement("h3");
title.id = "upload-conflict-title";
title.textContent = "Upload conflict";
const target = document.createElement("div");
target.id = "upload-conflict-target";
target.className = "popup-meta";
const fileName = document.createElement("div");
fileName.id = "upload-conflict-file-name";
fileName.className = "popup-meta";
const message = document.createElement("div");
message.id = "upload-conflict-message";
message.className = "popup-meta";
const actions = document.createElement("div");
actions.className = "popup-actions";
const overwriteButton = createButton("Overwrite", () => resolveUploadConflict("overwrite"));
overwriteButton.id = "upload-conflict-overwrite-btn";
const overwriteAllButton = createButton("Overwrite all", () => resolveUploadConflict("overwrite_all"));
overwriteAllButton.id = "upload-conflict-overwrite-all-btn";
const skipButton = createButton("Skip", () => resolveUploadConflict("skip"));
skipButton.id = "upload-conflict-skip-btn";
const skipAllButton = createButton("Skip all", () => resolveUploadConflict("skip_all"));
skipAllButton.id = "upload-conflict-skip-all-btn";
const cancelButton = createButton("Cancel", () => resolveUploadConflict("cancel"));
cancelButton.id = "upload-conflict-cancel-btn";
actions.append(overwriteButton, overwriteAllButton, skipButton, skipAllButton, cancelButton);
card.append(title, target, fileName, message, actions);
overlay.append(card);
document.body.append(overlay);
return uploadConflictElements();
}
async function apiRequest(method, url, body) {
const options = { method, headers: {} };
if (body !== undefined) {
@@ -334,9 +406,19 @@ async function apiRequest(method, url, body) {
return data;
}
async function uploadFileRequest(targetPath, file) {
function createApiError(response, data) {
const error = data.error || {};
const err = new Error(error.message || `HTTP ${response.status}`);
err.code = error.code || null;
err.status = response.status;
err.details = error.details || {};
return err;
}
async function uploadFileRequest(targetPath, file, overwrite = false) {
const formData = new FormData();
formData.append("target_path", targetPath);
formData.append("overwrite", overwrite ? "true" : "false");
formData.append("file", file, file.name);
const response = await fetch("/api/files/upload", {
@@ -345,8 +427,7 @@ async function uploadFileRequest(targetPath, file) {
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = data.error || {};
throw new Error(error.message || `HTTP ${response.status}`);
throw createApiError(response, data);
}
return data;
}
@@ -377,6 +458,12 @@ function resetUploadProgress() {
uploadState.targetPath = "";
uploadState.files = [];
uploadState.index = 0;
uploadState.overwriteAll = false;
uploadState.skipAll = false;
uploadState.successfulCount = 0;
uploadState.skippedCount = 0;
uploadState.cancelled = false;
uploadState.conflictResolver = null;
elements.button.disabled = false;
elements.target.textContent = "";
elements.currentFile.textContent = "";
@@ -407,6 +494,34 @@ function openUploadPicker() {
elements.input.click();
}
function isUploadConflictOpen() {
const overlay = document.getElementById("upload-conflict-modal");
return Boolean(overlay) && !overlay.classList.contains("hidden");
}
function resolveUploadConflict(choice) {
const resolver = uploadState.conflictResolver;
if (!resolver) {
return;
}
uploadState.conflictResolver = null;
const elements = uploadConflictElements();
elements.overlay.classList.add("hidden");
resolver(choice);
}
function promptUploadConflict(fileName, targetPath, message) {
const elements = ensureUploadConflictModal();
elements.title.textContent = "Upload conflict";
elements.target.textContent = `Upload to: ${targetPath}`;
elements.fileName.textContent = `File: ${fileName}`;
elements.message.textContent = message || "Target path already exists.";
elements.overlay.classList.remove("hidden");
return new Promise((resolve) => {
uploadState.conflictResolver = resolve;
});
}
async function handleUploadSelection(event) {
const files = Array.from(event.target.files || []);
event.target.value = "";
@@ -419,18 +534,71 @@ async function handleUploadSelection(event) {
uploadState.targetPath = targetPath;
uploadState.files = files;
uploadState.index = 0;
uploadState.overwriteAll = false;
uploadState.skipAll = false;
uploadState.successfulCount = 0;
uploadState.skippedCount = 0;
uploadState.cancelled = false;
setError("actions-error", "");
updateUploadProgress();
try {
outer:
for (let index = 0; index < files.length; index += 1) {
uploadState.index = index;
updateUploadProgress();
await uploadFileRequest(targetPath, files[index]);
let overwrite = uploadState.overwriteAll;
while (true) {
try {
await uploadFileRequest(targetPath, files[index], overwrite);
uploadState.successfulCount += 1;
break;
} catch (err) {
if (err.code !== "already_exists") {
throw err;
}
if (uploadState.skipAll) {
uploadState.skippedCount += 1;
break;
}
const choice = await promptUploadConflict(files[index].name, targetPath, err.message);
if (choice === "overwrite") {
overwrite = true;
continue;
}
if (choice === "overwrite_all") {
uploadState.overwriteAll = true;
overwrite = true;
continue;
}
if (choice === "skip") {
uploadState.skippedCount += 1;
break;
}
if (choice === "skip_all") {
uploadState.skipAll = true;
uploadState.skippedCount += 1;
break;
}
uploadState.cancelled = true;
break outer;
}
}
}
if (uploadState.successfulCount > 0) {
await loadBrowsePane(state.activePane);
}
if (uploadState.cancelled) {
setStatus(`Upload: ${uploadState.successfulCount}/${files.length} file${files.length === 1 ? "" : "s"} uploaded before cancel`);
} else if (uploadState.skippedCount > 0) {
setStatus(`Upload: ${uploadState.successfulCount} uploaded, ${uploadState.skippedCount} skipped`);
} else {
setStatus(`Upload: ${uploadState.successfulCount} file${uploadState.successfulCount === 1 ? "" : "s"} uploaded`);
}
await loadBrowsePane(state.activePane);
setStatus(`Upload: ${files.length} file${files.length === 1 ? "" : "s"} uploaded`);
} catch (err) {
if (uploadState.successfulCount > 0) {
await loadBrowsePane(state.activePane);
}
setActionError("Upload", err);
} finally {
resetUploadProgress();
@@ -1558,6 +1726,10 @@ function isBatchMovePopupOpen() {
return !batchMoveElements().overlay.classList.contains("hidden");
}
function isUploadConflictModalOpen() {
return isUploadConflictOpen();
}
function isViewerOpen() {
return !viewerElements().overlay.classList.contains("hidden");
}
@@ -2664,6 +2836,13 @@ function handleKeyboardShortcuts(event) {
}
return;
}
if (isUploadConflictModalOpen()) {
if (event.key === "Escape") {
event.preventDefault();
resolveUploadConflict("cancel");
}
return;
}
if (isMovePopupOpen()) {
if (event.key === "Escape") {
event.preventDefault();