feat: upload - deel 03.02 - Skipp all toegevoegd
This commit is contained in:
Binary file not shown.
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -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:
|
||||
|
||||
Binary file not shown.
@@ -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.
Binary file not shown.
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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user