diff --git a/app/components/FileUploadHandler.tsx b/app/components/FileUploadHandler.tsx index 0a6e41a..7ffa1ec 100644 --- a/app/components/FileUploadHandler.tsx +++ b/app/components/FileUploadHandler.tsx @@ -4,10 +4,10 @@ import { useRef, useEffect, type InputHTMLAttributes } from "react"; import { toast } from "@/components/ui/toast"; import { getAuthenticatedSession, - uploadFilesToContainer, uploadFolderFilesToContainer, FolderUploadFile, } from "../lib/helpers"; +import { useUploadConflictPrompt } from "./useUploadConflictPrompt"; type FileWithRelativePath = File & { webkitRelativePath?: string; @@ -28,6 +28,8 @@ export default function FileUploadHandler({ const fileInputRef = useRef(null); const folderInputRef = useRef(null); + const { uploadFilesWithConflictPrompt, conflictDialog } = useUploadConflictPrompt(); + useEffect(() => { if (triggerUpload && triggerUpload > 0 && fileInputRef.current) { fileInputRef.current.click(); @@ -58,11 +60,12 @@ export default function FileUploadHandler({ e.target.value = ""; return; } + try { - const { uploadedFiles, failedFiles } = await uploadFilesToContainer( + const { uploadedFiles, failedFiles } = await uploadFilesWithConflictPrompt( Array.from(files), currentContainerUrl, - fetchFn + fetchFn, ); if (uploadedFiles.length > 0) { @@ -171,6 +174,7 @@ export default function FileUploadHandler({ className="hidden" onChange={handleFolderChange} /> + {conflictDialog} ); } diff --git a/app/components/UploadConflictDialog.tsx b/app/components/UploadConflictDialog.tsx new file mode 100644 index 0000000..e00f94e --- /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 + + + + ) +} 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 && ( (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, + }; +} 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..81a89b7 100644 --- a/app/lib/helpers/uploadUtils.ts +++ b/app/lib/helpers/uploadUtils.ts @@ -4,7 +4,13 @@ import { overwriteFile, UrlString, } from "@inrupt/solid-client"; -import { ensureTrailingSlash, getHttpStatus, sanitizeResourceName } from "."; +import { + ensureTrailingSlash, + getHttpStatus, + sanitizeResourceName, + fetchContainerListing, +} from "."; +import { getContainerListing, loadContainerListing } from "../cache"; import { toast } from "@/components/ui/toast"; export interface FolderUploadFile { @@ -17,36 +23,205 @@ export interface UploadResult { failedFiles: string[]; } -export async function uploadFilesToContainer( +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}`; +} + +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 uploadPromises: Promise[] = []; - const uploadedFiles: string[] = []; - const failedFiles: string[] = []; +): Promise { + const existingNames = await getExistingChildNames( + currentContainerUrl, + fetchFn, + ); + + const newFiles: File[] = []; + const conflicts: UploadConflict[] = []; for (const file of files) { - const sanitizedName = sanitizeFilename(file.name); - const fileUrl = currentContainerUrl.endsWith("/") - ? `${currentContainerUrl}${sanitizedName}` - : `${currentContainerUrl}/${sanitizedName}`; + const existingName = sanitizeFilename(file.name); + const targetUrl = buildFileTargetUrl(currentContainerUrl, existingName); - const uploadPromise = overwriteFile(fileUrl as UrlString, file, { + if (existingNames.has(existingName)) { + conflicts.push({ file, existingName, targetUrl }); + } else { + newFiles.push(file); + } + } + + 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; +} + +/** Try preferred name with putFile; on conflict, use Drive-style (1), (2), ... */ +async function uploadFileCreateOnly( + file: File, + preferredName: string, + currentContainerUrl: string, + fetchFn: typeof fetch, + usedNames: Set, +): Promise { + const preferred = sanitizeFilename(preferredName); + + 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); + } + } + + const lastDot = preferred.lastIndexOf("."); + const base = lastDot > 0 ? preferred.slice(0, lastDot) : preferred; + const ext = lastDot > 0 ? preferred.slice(lastDot) : ""; + + let attempt = 1; + while (true) { + const displayName = `${base} (${attempt})${ext}`; + const candidateName = sanitizeFilename(displayName); + + if (usedNames.has(candidateName)) { + attempt += 1; + continue; + } + + try { + await putFile( + buildFileTargetUrl(currentContainerUrl, candidateName), + file, + fetchFn, + ); + usedNames.add(candidateName); + return displayName; + } catch (error) { + if (isNameConflictError(error)) { + usedNames.add(candidateName); + attempt += 1; + continue; + } + throw error; + } + } +} + +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, - }) - .then(() => { - uploadedFiles.push(sanitizedName); - }) - .catch(() => { - failedFiles.push(sanitizedName); - }); - - uploadPromises.push(uploadPromise); + }); + return { uploadedName: existingName }; } - await Promise.all(uploadPromises); + 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 usedNames = await getExistingChildNames(currentContainerUrl, fetchFn); + const uploadedFiles: string[] = []; + const failedFiles: string[] = []; + + for (const file of files) { + const preferredName = sanitizeFilename(file.name); + try { + const uploadedName = await uploadFileCreateOnly( + file, + preferredName, + currentContainerUrl, + fetchFn, + usedNames, + ); + uploadedFiles.push(uploadedName); + } catch { + failedFiles.push(preferredName); + } + } return { uploadedFiles, failedFiles }; }