diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index 717430655..bea54c645 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -1,6 +1,8 @@ import { basename } from "@/next/file"; import log from "@/next/log"; import type { CollectionMapping, Electron, ZipItem } from "@/next/types/ipc"; +import { firstNonEmpty } from "@/utils/array"; +import { ensure } from "@/utils/ensure"; import { CustomError } from "@ente/shared/error"; import { isPromise } from "@ente/shared/utils"; import DiscFullIcon from "@mui/icons-material/DiscFull"; @@ -324,17 +326,17 @@ export default function Uploader({ // Trigger an upload when any of the dependencies change. useEffect(() => { - // Re the paths: + // About the paths: // // - These are not necessarily the full paths. In particular, when // running on the browser they'll be the relative paths (at best) or // just the file-name otherwise. // // - All the paths use POSIX separators. See inline comments. + // const allItemAndPaths = [ - // See: [Note: webkitRelativePath]. In particular, they use POSIX - // separators. - webFiles.map((f) => [f, f.webkitRelativePath ?? f.name]), + // Relative path (using POSIX separators) or the file's name. + webFiles.map((f) => [f, pathLikeForWebFile(f)]), // The paths we get from the desktop app all eventually come either // from electron.selectDirectory or electron.pathForFile, both of // which return POSIX paths. @@ -822,6 +824,37 @@ const desktopFilesAndZipItems = async (electron: Electron, files: File[]) => { return { fileAndPaths, zipItems }; }; +/** + * Return the relative path or name of a File object selected or + * drag-and-dropped on the web. + * + * There are three cases here: + * + * 1. If the user selects individual file(s), then the returned File objects + * will only have a `name`. + * + * 2. If the user selects directory(ies), then the returned File objects will + * have a `webkitRelativePath`. For more details, see [Note: + * webkitRelativePath]. In particular, these will POSIX separators. + * + * 3. If the user drags-and-drops, then the react-dropzone library that we use + * will internally convert `webkitRelativePath` to `path`, but otherwise it + * behaves same as case 2. + * https://github.com/react-dropzone/file-selector/blob/master/src/file.ts#L1214 + */ +const pathLikeForWebFile = (file: File): string => + ensure( + firstNonEmpty([ + // We need to check first, since path is not a property of + // the standard File objects. + "path" in file && typeof file.path == "string" + ? file.path + : undefined, + file.webkitRelativePath, + file.name, + ]), + ); + // This is used to prompt the user the make upload strategy choice interface ImportSuggestion { rootFolderName: string; diff --git a/web/packages/utils/array.ts b/web/packages/utils/array.ts index 660aef679..10030b189 100644 --- a/web/packages/utils/array.ts +++ b/web/packages/utils/array.ts @@ -13,3 +13,18 @@ export const shuffled = (xs: T[]) => .map((x) => [Math.random(), x]) .sort() .map(([, x]) => x) as T[]; + +/** + * Return the first non-empty string from the given list of strings. + * + * This function is needed because the `a ?? b` idiom doesn't do what you'd + * expect when a is "". Perhaps the behaviour is wrong, perhaps the expecation + * is wrong; this function papers over the differences. + * + * If none of the strings are non-empty, or if there are no strings in the given + * array, return undefined. + */ +export const firstNonEmpty = (ss: (string | undefined)[]) => { + for (const s of ss) if (s && s.length > 0) return s; + return undefined; +};