From 1c1f8c138f5c600175bb064c23a80a44254894a6 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 16 Sep 2026 14:31:14 +0100 Subject: [PATCH 01/10] Detect name conflicts before file upload --- app/lib/helpers/copyUtils.ts | 2 +- app/lib/helpers/uploadUtils.ts | 48 +++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/app/lib/helpers/copyUtils.ts b/app/lib/helpers/copyUtils.ts index 1223052..00a5d4d 100644 --- a/app/lib/helpers/copyUtils.ts +++ b/app/lib/helpers/copyUtils.ts @@ -55,7 +55,7 @@ const shouldSkipResourceCopy = (resourceUrl: string): boolean => { }; -const resourceExists = async (url: string, fetchFn: typeof fetch): Promise => { +export const resourceExists = async (url: string, fetchFn: typeof fetch): Promise => { try { const response = await fetchFn(url, { method: "HEAD" }); if (response.status === 404) { diff --git a/app/lib/helpers/uploadUtils.ts b/app/lib/helpers/uploadUtils.ts index f4af109..632fecd 100644 --- a/app/lib/helpers/uploadUtils.ts +++ b/app/lib/helpers/uploadUtils.ts @@ -4,7 +4,12 @@ import { overwriteFile, UrlString, } from "@inrupt/solid-client"; -import { ensureTrailingSlash, getHttpStatus, sanitizeResourceName } from "."; +import { + ensureTrailingSlash, + getHttpStatus, + sanitizeResourceName, + resourceExists, +} from "."; import { toast } from "@/components/ui/toast"; export interface FolderUploadFile { @@ -17,6 +22,47 @@ export interface UploadResult { failedFiles: string[]; } +export type UploadConflictChoice = "replace" | "keepBoth" | "cancel"; + +export interface UploadConflict { + file: File; + existingName: string; + targetUrl: string; +} + +export interface UploadConflictCheckResult { + newFiles: File[]; + conflicts: UploadConflict[]; +} + +function buildFileTargetUrl(containerUrl: string, fileName: string): string { + const parent = ensureTrailingSlash(containerUrl); + return `${parent}${fileName}`; +} + +export async function findUploadConflicts( + files: File[], + currentContainerUrl: string, + fetchFn: typeof fetch, +): Promise { + const newFiles: File[] = []; + const conflicts: UploadConflict[] = []; + + for (const file of files) { + const existingName = sanitizeFilename(file.name); + const targetUrl = buildFileTargetUrl(currentContainerUrl, existingName); + const exists = await resourceExists(targetUrl, fetchFn); + + if (exists) { + conflicts.push({ file, existingName, targetUrl }); + } else { + newFiles.push(file); + } + } + + return { newFiles, conflicts }; +} + export async function uploadFilesToContainer( files: File[], currentContainerUrl: string, From ba0f5d8785cd68223c0fa856e5d78536325b8458 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 16 Sep 2026 15:14:16 +0100 Subject: [PATCH 02/10] Add replace and keep-both upload helpers --- app/lib/helpers/uploadUtils.ts | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/app/lib/helpers/uploadUtils.ts b/app/lib/helpers/uploadUtils.ts index 632fecd..4c3811c 100644 --- a/app/lib/helpers/uploadUtils.ts +++ b/app/lib/helpers/uploadUtils.ts @@ -9,6 +9,7 @@ import { getHttpStatus, sanitizeResourceName, resourceExists, + generateCopyTarget, } from "."; import { toast } from "@/components/ui/toast"; @@ -63,6 +64,42 @@ export async function findUploadConflicts( return { newFiles, conflicts }; } +export async function uploadFileWithConflictChoice( + conflict: UploadConflict, + choice: Exclude, + currentContainerUrl: string, + fetchFn: typeof fetch, +): Promise<{ uploadedName: string }> { + const { file, existingName, targetUrl } = conflict; + + if (choice === "replace") { + await overwriteFile(targetUrl as UrlString, file, { + contentType: file.type || "application/octet-stream", + fetch: fetchFn, + }); + return { uploadedName: existingName }; + } + + // Keep both: find a free name like "photo (1).jpg" + const lastDot = existingName.lastIndexOf("."); + const base = lastDot > 0 ? existingName.slice(0, lastDot) : existingName; + const ext = lastDot > 0 ? existingName.slice(lastDot) : ""; + + const { targetUrl: keepBothUrl, displayName } = await generateCopyTarget( + currentContainerUrl, + `${base} (1)${ext}`, + false, + fetchFn, + ); + + await overwriteFile(keepBothUrl as UrlString, file, { + contentType: file.type || "application/octet-stream", + fetch: fetchFn, + }); + + return { uploadedName: displayName }; +} + export async function uploadFilesToContainer( files: File[], currentContainerUrl: string, From 326e11ff0a3c3a517864b2f8f8b82437fe71854b Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 16 Sep 2026 15:35:56 +0100 Subject: [PATCH 03/10] Add upload conflict dialog UI --- app/components/UploadConflictDialog.tsx | 60 +++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 app/components/UploadConflictDialog.tsx diff --git a/app/components/UploadConflictDialog.tsx b/app/components/UploadConflictDialog.tsx new file mode 100644 index 0000000..520bd58 --- /dev/null +++ b/app/components/UploadConflictDialog.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogMedia, +} from "@/components/ui/alert-dialog"; +import { FileWarning } from "lucide-react"; + +interface UploadConflictDialogProps { + isOpen: boolean; + fileName: string | null; + onReplace: () => void; + onKeepBoth: () => void; + onCancel: () => void; +} + +export default function UploadConflictDialog({ + isOpen, + fileName, + onReplace, + onKeepBoth, + onCancel, +}: UploadConflictDialogProps) { + return ( + { + if (!open) onCancel(); + }} + > + + + + + + File already exists + + A file named{" "} + "{fileName}"{" "} + already exists in this folder. Do you want to replace it or keep both? + + + + Cancel + + Keep both + + Replace + + + + ) +} \ No newline at end of file From 15b5e476cb468080fbd413e22f5a29f47eda64fb Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 16 Sep 2026 16:14:14 +0100 Subject: [PATCH 04/10] fix naming style when keeping both uploads --- app/components/FileUploadHandler.tsx | 70 ++++++++++++++++++++++++++-- app/lib/helpers/uploadUtils.ts | 24 ++++++---- 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/app/components/FileUploadHandler.tsx b/app/components/FileUploadHandler.tsx index 0a6e41a..0665939 100644 --- a/app/components/FileUploadHandler.tsx +++ b/app/components/FileUploadHandler.tsx @@ -1,13 +1,18 @@ "use client"; -import { useRef, useEffect, type InputHTMLAttributes } from "react"; +import { useRef, useEffect, type InputHTMLAttributes, useState } from "react"; import { toast } from "@/components/ui/toast"; import { getAuthenticatedSession, uploadFilesToContainer, uploadFolderFilesToContainer, + findUploadConflicts, + uploadFileWithConflictChoice, FolderUploadFile, + type UploadConflictChoice, + type UploadConflict, } from "../lib/helpers"; +import UploadConflictDialog from "./UploadConflictDialog"; type FileWithRelativePath = File & { webkitRelativePath?: string; @@ -28,6 +33,22 @@ export default function FileUploadHandler({ const fileInputRef = useRef(null); const folderInputRef = useRef(null); + const [activeConflict, setActiveConflict] = useState(null); + const conflictResolverRef = useRef<((choice: UploadConflictChoice) => void) | null>(null); + + const resolveConflictChoice = (choice: UploadConflictChoice) => { + const resolve = conflictResolverRef.current; + conflictResolverRef.current = null; + setActiveConflict(null); + resolve?.(choice); + } + + const askConflictChoice = (conflict: UploadConflict) => + new Promise((resolve) => { + conflictResolverRef.current = resolve; + setActiveConflict(conflict); + }); + useEffect(() => { if (triggerUpload && triggerUpload > 0 && fileInputRef.current) { fileInputRef.current.click(); @@ -58,13 +79,47 @@ export default function FileUploadHandler({ e.target.value = ""; return; } + try { - const { uploadedFiles, failedFiles } = await uploadFilesToContainer( - Array.from(files), + const selectedFiles = Array.from(files); + const { newFiles, conflicts } = await findUploadConflicts( + selectedFiles, currentContainerUrl, - fetchFn + fetchFn, ); + const uploadedFiles: string[] = []; + const failedFiles: string[] = []; + + if (newFiles.length > 0) { + const result = await uploadFilesToContainer( + newFiles, + currentContainerUrl, + fetchFn, + ); + uploadedFiles.push(...result.uploadedFiles); + failedFiles.push(...result.failedFiles); + } + + for (const conflict of conflicts) { + const choice = await askConflictChoice(conflict); + if (choice === "cancel") { + continue; + } + + try { + const { uploadedName } = await uploadFileWithConflictChoice( + conflict, + choice, + currentContainerUrl, + fetchFn, + ); + uploadedFiles.push(uploadedName); + } catch { + failedFiles.push(conflict.existingName); + } + } + if (uploadedFiles.length > 0) { const message = uploadedFiles.length === 1 @@ -171,6 +226,13 @@ export default function FileUploadHandler({ className="hidden" onChange={handleFolderChange} /> + resolveConflictChoice("replace")} + onKeepBoth={() => resolveConflictChoice("keepBoth")} + onCancel={() => resolveConflictChoice("cancel")} + /> ); } diff --git a/app/lib/helpers/uploadUtils.ts b/app/lib/helpers/uploadUtils.ts index 4c3811c..35bf169 100644 --- a/app/lib/helpers/uploadUtils.ts +++ b/app/lib/helpers/uploadUtils.ts @@ -9,7 +9,6 @@ import { getHttpStatus, sanitizeResourceName, resourceExists, - generateCopyTarget, } from "."; import { toast } from "@/components/ui/toast"; @@ -80,17 +79,26 @@ export async function uploadFileWithConflictChoice( return { uploadedName: existingName }; } - // Keep both: find a free name like "photo (1).jpg" + // Keep both: Drive-style names before the extension — photo (1).jpg, photo (2).jpg const lastDot = existingName.lastIndexOf("."); const base = lastDot > 0 ? existingName.slice(0, lastDot) : existingName; const ext = lastDot > 0 ? existingName.slice(lastDot) : ""; - const { targetUrl: keepBothUrl, displayName } = await generateCopyTarget( - currentContainerUrl, - `${base} (1)${ext}`, - false, - fetchFn, - ); + let keepBothUrl = ""; + let displayName = ""; + + for (let attempt = 1; attempt < 100; attempt++) { + displayName = `${base} (${attempt})${ext}`; + const candidateName = sanitizeFilename(displayName); + keepBothUrl = buildFileTargetUrl(currentContainerUrl, candidateName); + const exists = await resourceExists(keepBothUrl, fetchFn); + if (!exists) { + break; + } + if (attempt === 99) { + throw new Error("Unable to generate a unique name for the upload"); + } + } await overwriteFile(keepBothUrl as UrlString, file, { contentType: file.type || "application/octet-stream", From fa05233bb8058d5221505847bf001b4881ff2a97 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 17 Sep 2026 10:56:08 +0100 Subject: [PATCH 05/10] Add useUploadConflictPrompt hook for shared upload conflicts --- app/components/useUploadConflictPrompt.tsx | 97 ++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 app/components/useUploadConflictPrompt.tsx diff --git a/app/components/useUploadConflictPrompt.tsx b/app/components/useUploadConflictPrompt.tsx new file mode 100644 index 0000000..4d3d489 --- /dev/null +++ b/app/components/useUploadConflictPrompt.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import UploadConflictDialog from "./UploadConflictDialog"; +import { + findUploadConflicts, + uploadFilesToContainer, + uploadFileWithConflictChoice, + type UploadConflict, + type UploadConflictChoice, + type UploadResult, +} from "../lib/helpers"; + +export function useUploadConflictPrompt() { + const [activeConflict, setActiveConflict] = useState(null); + const conflictResolverRef = useRef<((choice: UploadConflictChoice) => void) | null>(null); + + const resolveConflictChoice = useCallback((choice: UploadConflictChoice) => { + const resolve = conflictResolverRef.current; + conflictResolverRef.current = null; + setActiveConflict(null); + resolve?.(choice); + }, []); + + const askConflictChoice = useCallback( + (conflict: UploadConflict) => + new Promise((resolve) => { + conflictResolverRef.current = resolve; + setActiveConflict(conflict); + }), + [], + ); + + const uploadFilesWithConflictPrompt = useCallback( + async ( + files: File[], + currentContainerUrl: string, + fetchFn: typeof fetch, + ): Promise => { + const { newFiles, conflicts } = await findUploadConflicts( + files, + currentContainerUrl, + fetchFn, + ); + + const uploadedFiles: string[] = []; + const failedFiles: string[] = []; + + if (newFiles.length > 0) { + const result = await uploadFilesToContainer( + newFiles, + currentContainerUrl, + fetchFn, + ); + uploadedFiles.push(...result.uploadedFiles); + failedFiles.push(...result.failedFiles); + } + + for (const conflict of conflicts) { + const choice = await askConflictChoice(conflict); + if (choice === "cancel") { + continue; + } + + try { + const { uploadedName } = await uploadFileWithConflictChoice( + conflict, + choice, + currentContainerUrl, + fetchFn, + ); + uploadedFiles.push(uploadedName); + } catch { + failedFiles.push(conflict.existingName); + } + } + + return { uploadedFiles, failedFiles }; + }, + [askConflictChoice], + ); + + const conflictDialog = ( + resolveConflictChoice("replace")} + onKeepBoth={() => resolveConflictChoice("keepBoth")} + onCancel={() => resolveConflictChoice("cancel")} + /> + ); + + return { + uploadFilesWithConflictPrompt, + conflictDialog, + }; +} \ No newline at end of file From 580c086e23d852925235404674838d24ae80db49 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 17 Sep 2026 11:47:42 +0100 Subject: [PATCH 06/10] Use upload conflict prompt for picker and drag-and-drop --- app/components/FileUploadHandler.tsx | 70 ++----------------- .../file-manager/FileManagerContent.tsx | 19 ++++- 2 files changed, 22 insertions(+), 67 deletions(-) diff --git a/app/components/FileUploadHandler.tsx b/app/components/FileUploadHandler.tsx index 0665939..7ffa1ec 100644 --- a/app/components/FileUploadHandler.tsx +++ b/app/components/FileUploadHandler.tsx @@ -1,18 +1,13 @@ "use client"; -import { useRef, useEffect, type InputHTMLAttributes, useState } from "react"; +import { useRef, useEffect, type InputHTMLAttributes } from "react"; import { toast } from "@/components/ui/toast"; import { getAuthenticatedSession, - uploadFilesToContainer, uploadFolderFilesToContainer, - findUploadConflicts, - uploadFileWithConflictChoice, FolderUploadFile, - type UploadConflictChoice, - type UploadConflict, } from "../lib/helpers"; -import UploadConflictDialog from "./UploadConflictDialog"; +import { useUploadConflictPrompt } from "./useUploadConflictPrompt"; type FileWithRelativePath = File & { webkitRelativePath?: string; @@ -33,21 +28,7 @@ export default function FileUploadHandler({ const fileInputRef = useRef(null); const folderInputRef = useRef(null); - const [activeConflict, setActiveConflict] = useState(null); - const conflictResolverRef = useRef<((choice: UploadConflictChoice) => void) | null>(null); - - const resolveConflictChoice = (choice: UploadConflictChoice) => { - const resolve = conflictResolverRef.current; - conflictResolverRef.current = null; - setActiveConflict(null); - resolve?.(choice); - } - - const askConflictChoice = (conflict: UploadConflict) => - new Promise((resolve) => { - conflictResolverRef.current = resolve; - setActiveConflict(conflict); - }); + const { uploadFilesWithConflictPrompt, conflictDialog } = useUploadConflictPrompt(); useEffect(() => { if (triggerUpload && triggerUpload > 0 && fileInputRef.current) { @@ -81,45 +62,12 @@ export default function FileUploadHandler({ } try { - const selectedFiles = Array.from(files); - const { newFiles, conflicts } = await findUploadConflicts( - selectedFiles, + const { uploadedFiles, failedFiles } = await uploadFilesWithConflictPrompt( + Array.from(files), currentContainerUrl, fetchFn, ); - const uploadedFiles: string[] = []; - const failedFiles: string[] = []; - - if (newFiles.length > 0) { - const result = await uploadFilesToContainer( - newFiles, - currentContainerUrl, - fetchFn, - ); - uploadedFiles.push(...result.uploadedFiles); - failedFiles.push(...result.failedFiles); - } - - for (const conflict of conflicts) { - const choice = await askConflictChoice(conflict); - if (choice === "cancel") { - continue; - } - - try { - const { uploadedName } = await uploadFileWithConflictChoice( - conflict, - choice, - currentContainerUrl, - fetchFn, - ); - uploadedFiles.push(uploadedName); - } catch { - failedFiles.push(conflict.existingName); - } - } - if (uploadedFiles.length > 0) { const message = uploadedFiles.length === 1 @@ -226,13 +174,7 @@ export default function FileUploadHandler({ className="hidden" onChange={handleFolderChange} /> - resolveConflictChoice("replace")} - onKeepBoth={() => resolveConflictChoice("keepBoth")} - onCancel={() => resolveConflictChoice("cancel")} - /> + {conflictDialog} ); } diff --git a/app/components/file-manager/FileManagerContent.tsx b/app/components/file-manager/FileManagerContent.tsx index 8c11e8c..ee0f019 100644 --- a/app/components/file-manager/FileManagerContent.tsx +++ b/app/components/file-manager/FileManagerContent.tsx @@ -31,14 +31,20 @@ import LoadingSpinner from "../shared/LoadingSpinner"; import ErrorDisplay from "../shared/ErrorDisplay"; import { getAuthenticatedSession, - uploadFilesToContainer, uploadFolderFilesToContainer, processDragDropItems, hasFiles as hasFilesInDrag, isUnsupportedFolderDrag, } from "@/app/lib/helpers"; import { isDialog } from "./types/fileActions"; -import { useFileManagerNavigation, useFileManagerBrowse, useFileManagerActions, useFileManagerSelection, useFileManagerDialogs } from "./context/fileManagerContext"; +import { + useFileManagerNavigation, + useFileManagerBrowse, + useFileManagerActions, + useFileManagerSelection, + useFileManagerDialogs +} from "./context/fileManagerContext"; +import { useUploadConflictPrompt } from "../useUploadConflictPrompt"; type ContextMenuState = | { type: "new"; position: { x: number; y: number } } @@ -100,6 +106,7 @@ export default function FileManagerContent() { const [contextMenuState, setContextMenuState] = useState(null); const closeContextMenu = () => setContextMenuState(null); + const { uploadFilesWithConflictPrompt, conflictDialog } = useUploadConflictPrompt(); /** Require a selected container before create/upload actions. */ const ensureStorageSelected = () => { @@ -210,7 +217,12 @@ export default function FileManagerContent() { if (singleFiles.length > 0) { try { - const { uploadedFiles, failedFiles } = await uploadFilesToContainer(singleFiles, containerUrlToBrowse, fetchFn); + const { uploadedFiles, failedFiles } = await uploadFilesWithConflictPrompt( + singleFiles, + containerUrlToBrowse, + fetchFn, + ) + if (uploadedFiles.length > 0) { uploadedSomething = true; toast.add({ @@ -549,6 +561,7 @@ export default function FileManagerContent() { triggerUpload={fileUploadTrigger} triggerFolderUpload={folderUploadTrigger} /> + {conflictDialog} {contextMenuState && ( Date: Fri, 18 Sep 2026 12:31:53 +0100 Subject: [PATCH 07/10] add eol-last --- app/components/UploadConflictDialog.tsx | 2 +- app/components/useUploadConflictPrompt.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/UploadConflictDialog.tsx b/app/components/UploadConflictDialog.tsx index 520bd58..e00f94e 100644 --- a/app/components/UploadConflictDialog.tsx +++ b/app/components/UploadConflictDialog.tsx @@ -57,4 +57,4 @@ export default function UploadConflictDialog({ ) -} \ No newline at end of file +} diff --git a/app/components/useUploadConflictPrompt.tsx b/app/components/useUploadConflictPrompt.tsx index 4d3d489..6e049d1 100644 --- a/app/components/useUploadConflictPrompt.tsx +++ b/app/components/useUploadConflictPrompt.tsx @@ -94,4 +94,4 @@ export function useUploadConflictPrompt() { uploadFilesWithConflictPrompt, conflictDialog, }; -} \ No newline at end of file +} From 0a8225250998f6c3efbd4d32b1c4917dbe8f0510 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Fri, 18 Sep 2026 15:10:14 +0100 Subject: [PATCH 08/10] Detect upload conflicts from cached container listing --- app/lib/helpers/uploadUtils.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/app/lib/helpers/uploadUtils.ts b/app/lib/helpers/uploadUtils.ts index 35bf169..fedaa34 100644 --- a/app/lib/helpers/uploadUtils.ts +++ b/app/lib/helpers/uploadUtils.ts @@ -9,7 +9,9 @@ import { getHttpStatus, sanitizeResourceName, resourceExists, + fetchContainerListing, } from "."; +import { getContainerListing, loadContainerListing } from "../cache"; import { toast } from "@/components/ui/toast"; export interface FolderUploadFile { @@ -40,20 +42,38 @@ function buildFileTargetUrl(containerUrl: string, fileName: string): string { return `${parent}${fileName}`; } +async function getExistingChildNames( + currentContainerUrl: string, + fetchFn: typeof fetch, +): Promise> { + const cached = getContainerListing(currentContainerUrl); + const listing = + cached ?? + (await loadContainerListing(currentContainerUrl, () => + fetchContainerListing(currentContainerUrl, fetchFn), + )); + + return new Set(listing.map((item) => item.name)); +} + export async function findUploadConflicts( files: File[], currentContainerUrl: string, fetchFn: typeof fetch, ): Promise { + const existingNames = await getExistingChildNames( + currentContainerUrl, + fetchFn, + ); + const newFiles: File[] = []; const conflicts: UploadConflict[] = []; for (const file of files) { const existingName = sanitizeFilename(file.name); const targetUrl = buildFileTargetUrl(currentContainerUrl, existingName); - const exists = await resourceExists(targetUrl, fetchFn); - if (exists) { + if (existingNames.has(existingName)) { conflicts.push({ file, existingName, targetUrl }); } else { newFiles.push(file); From 2b55dde5f74288709a407f6a5f90176135f613bb Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Fri, 18 Sep 2026 15:22:22 +0100 Subject: [PATCH 09/10] Use create-only putFile for keep-both uploads --- app/lib/helpers/uploadUtils.ts | 65 ++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/app/lib/helpers/uploadUtils.ts b/app/lib/helpers/uploadUtils.ts index fedaa34..7099b3a 100644 --- a/app/lib/helpers/uploadUtils.ts +++ b/app/lib/helpers/uploadUtils.ts @@ -8,7 +8,6 @@ import { ensureTrailingSlash, getHttpStatus, sanitizeResourceName, - resourceExists, fetchContainerListing, } from "."; import { getContainerListing, loadContainerListing } from "../cache"; @@ -83,6 +82,35 @@ export async function findUploadConflicts( return { newFiles, conflicts }; } +/** Create-only PUT. Fails if the resource already exists (If-None-Match: *). */ +async function putFile( + fileUrl: string, + file: File, + fetchFn: typeof fetch, +): Promise { + const response = await fetchFn(fileUrl, { + method: "PUT", + headers: { + "Content-Type": file.type || "application/octet-stream", + "If-None-Match": "*", + }, + body: file, + }); + + if (!response.ok) { + const error = new Error( + `Failed to create file at [${fileUrl}]: [${response.status}] [${response.statusText}]`, + ) as Error & { status: number }; + error.status = response.status; + throw error; + } +} + +function isNameConflictError(error: unknown): boolean { + const status = getHttpStatus(error); + return status === 412 || status === 409; +} + export async function uploadFileWithConflictChoice( conflict: UploadConflict, choice: Exclude, @@ -104,28 +132,29 @@ export async function uploadFileWithConflictChoice( const base = lastDot > 0 ? existingName.slice(0, lastDot) : existingName; const ext = lastDot > 0 ? existingName.slice(lastDot) : ""; - let keepBothUrl = ""; - let displayName = ""; + const usedNames = await getExistingChildNames(currentContainerUrl, fetchFn); + let attempt = 1; - for (let attempt = 1; attempt < 100; attempt++) { - displayName = `${base} (${attempt})${ext}`; + while (true) { + const displayName = `${base} (${attempt})${ext}`; const candidateName = sanitizeFilename(displayName); - keepBothUrl = buildFileTargetUrl(currentContainerUrl, candidateName); - const exists = await resourceExists(keepBothUrl, fetchFn); - if (!exists) { - break; + if (usedNames.has(candidateName)) { + attempt += 1; + continue; } - if (attempt === 99) { - throw new Error("Unable to generate a unique name for the upload"); + const keepBothUrl = buildFileTargetUrl(currentContainerUrl, candidateName); + try { + await putFile(keepBothUrl, file, fetchFn); + return { uploadedName: displayName }; + } catch (error) { + if (isNameConflictError(error)) { + usedNames.add(candidateName); + attempt += 1; + continue; + } + throw error; } } - - await overwriteFile(keepBothUrl as UrlString, file, { - contentType: file.type || "application/octet-stream", - fetch: fetchFn, - }); - - return { uploadedName: displayName }; } export async function uploadFilesToContainer( From a42b51755ff171aacb078213569c47737bbf3cf0 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Fri, 18 Sep 2026 15:49:59 +0100 Subject: [PATCH 10/10] Use putFile for new uploads and retry on name conflicts --- app/lib/helpers/uploadUtils.ts | 115 +++++++++++++++++++++------------ 1 file changed, 75 insertions(+), 40 deletions(-) diff --git a/app/lib/helpers/uploadUtils.ts b/app/lib/helpers/uploadUtils.ts index 7099b3a..81a89b7 100644 --- a/app/lib/helpers/uploadUtils.ts +++ b/app/lib/helpers/uploadUtils.ts @@ -111,41 +111,55 @@ function isNameConflictError(error: unknown): boolean { return status === 412 || status === 409; } -export async function uploadFileWithConflictChoice( - conflict: UploadConflict, - choice: Exclude, +/** Try preferred name with putFile; on conflict, use Drive-style (1), (2), ... */ +async function uploadFileCreateOnly( + file: File, + preferredName: string, currentContainerUrl: string, fetchFn: typeof fetch, -): Promise<{ uploadedName: string }> { - const { file, existingName, targetUrl } = conflict; + usedNames: Set, +): Promise { + const preferred = sanitizeFilename(preferredName); - if (choice === "replace") { - await overwriteFile(targetUrl as UrlString, file, { - contentType: file.type || "application/octet-stream", - fetch: fetchFn, - }); - return { uploadedName: existingName }; + if (!usedNames.has(preferred)) { + try { + await putFile( + buildFileTargetUrl(currentContainerUrl, preferred), + file, + fetchFn, + ); + usedNames.add(preferred); + return preferred; + } catch (error) { + if (!isNameConflictError(error)) { + throw error; + } + usedNames.add(preferred); + } } - // Keep both: Drive-style names before the extension — photo (1).jpg, photo (2).jpg - const lastDot = existingName.lastIndexOf("."); - const base = lastDot > 0 ? existingName.slice(0, lastDot) : existingName; - const ext = lastDot > 0 ? existingName.slice(lastDot) : ""; + const lastDot = preferred.lastIndexOf("."); + const base = lastDot > 0 ? preferred.slice(0, lastDot) : preferred; + const ext = lastDot > 0 ? preferred.slice(lastDot) : ""; - const usedNames = await getExistingChildNames(currentContainerUrl, fetchFn); let attempt = 1; - while (true) { const displayName = `${base} (${attempt})${ext}`; const candidateName = sanitizeFilename(displayName); + if (usedNames.has(candidateName)) { attempt += 1; continue; } - const keepBothUrl = buildFileTargetUrl(currentContainerUrl, candidateName); + try { - await putFile(keepBothUrl, file, fetchFn); - return { uploadedName: displayName }; + await putFile( + buildFileTargetUrl(currentContainerUrl, candidateName), + file, + fetchFn, + ); + usedNames.add(candidateName); + return displayName; } catch (error) { if (isNameConflictError(error)) { usedNames.add(candidateName); @@ -157,37 +171,58 @@ export async function uploadFileWithConflictChoice( } } +export async function uploadFileWithConflictChoice( + conflict: UploadConflict, + choice: Exclude, + currentContainerUrl: string, + fetchFn: typeof fetch, +): Promise<{ uploadedName: string }> { + const { file, existingName, targetUrl } = conflict; + + if (choice === "replace") { + await overwriteFile(targetUrl as UrlString, file, { + contentType: file.type || "application/octet-stream", + fetch: fetchFn, + }); + return { uploadedName: existingName }; + } + + const usedNames = await getExistingChildNames(currentContainerUrl, fetchFn); + const uploadedName = await uploadFileCreateOnly( + file, + existingName, + currentContainerUrl, + fetchFn, + usedNames, + ); + return { uploadedName }; +} + export async function uploadFilesToContainer( files: File[], currentContainerUrl: string, fetchFn: typeof fetch, ): Promise { - const uploadPromises: Promise[] = []; + const usedNames = await getExistingChildNames(currentContainerUrl, fetchFn); const uploadedFiles: string[] = []; const failedFiles: string[] = []; for (const file of files) { - const sanitizedName = sanitizeFilename(file.name); - const fileUrl = currentContainerUrl.endsWith("/") - ? `${currentContainerUrl}${sanitizedName}` - : `${currentContainerUrl}/${sanitizedName}`; - - const uploadPromise = overwriteFile(fileUrl as UrlString, file, { - contentType: file.type || "application/octet-stream", - fetch: fetchFn, - }) - .then(() => { - uploadedFiles.push(sanitizedName); - }) - .catch(() => { - failedFiles.push(sanitizedName); - }); - - uploadPromises.push(uploadPromise); + const preferredName = sanitizeFilename(file.name); + try { + const uploadedName = await uploadFileCreateOnly( + file, + preferredName, + currentContainerUrl, + fetchFn, + usedNames, + ); + uploadedFiles.push(uploadedName); + } catch { + failedFiles.push(preferredName); + } } - await Promise.all(uploadPromises); - return { uploadedFiles, failedFiles }; }