From 69193e374cff92a1b316024ddb24f88b075b4fc4 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sun, 28 Apr 2024 15:51:08 +0530 Subject: [PATCH 01/49] Wait for i18n loaded before accessing messages --- web/apps/auth/src/pages/_app.tsx | 2 +- web/apps/photos/src/pages/_app.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/apps/auth/src/pages/_app.tsx b/web/apps/auth/src/pages/_app.tsx index bf1093c907..a5aa55f98d 100644 --- a/web/apps/auth/src/pages/_app.tsx +++ b/web/apps/auth/src/pages/_app.tsx @@ -140,7 +140,7 @@ export default function App({ Component, pageProps }: AppProps) { {showNavbar && } - {offline && t("OFFLINE_MSG")} + {isI18nReady && offline && t("OFFLINE_MSG")} diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 4b5fe31071..f3994d0817 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -352,7 +352,7 @@ export default function App({ Component, pageProps }: AppProps) { {showNavbar && } - {offline && t("OFFLINE_MSG")} + {isI18nReady && offline && t("OFFLINE_MSG")} {sharedFiles && (router.pathname === "/gallery" ? ( From 7179b0a6038dec27103d001597e2b2148db9e057 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sun, 28 Apr 2024 15:58:15 +0530 Subject: [PATCH 02/49] less line --- web/apps/photos/src/components/PhotoFrame.tsx | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/web/apps/photos/src/components/PhotoFrame.tsx b/web/apps/photos/src/components/PhotoFrame.tsx index 8c935ee274..f7db350daa 100644 --- a/web/apps/photos/src/components/PhotoFrame.tsx +++ b/web/apps/photos/src/components/PhotoFrame.tsx @@ -308,11 +308,7 @@ const PhotoFrame = ({ item: EnteFile, ) => { log.info( - `[${ - item.id - }] getSlideData called for thumbnail:${!!item.msrc} sourceLoaded:${ - item.isSourceLoaded - } fetching:${fetching[item.id]}`, + `[${item.id}] getSlideData called for thumbnail: ${!!item.msrc} sourceLoaded: ${item.isSourceLoaded} fetching:${fetching[item.id]}`, ); if (!item.msrc) { @@ -327,9 +323,7 @@ const PhotoFrame = ({ try { updateURL(index)(item.id, url); log.info( - `[${ - item.id - }] calling invalidateCurrItems for thumbnail msrc :${!!item.msrc}`, + `[${item.id}] calling invalidateCurrItems for thumbnail msrc: ${!!item.msrc}`, ); instance.invalidateCurrItems(); if ((instance as any).isOpen()) { @@ -381,7 +375,7 @@ const PhotoFrame = ({ try { await updateSrcURL(index, item.id, dummyImgSrcUrl); log.info( - `[${item.id}] calling invalidateCurrItems for live photo imgSrc, source loaded :${item.isSourceLoaded}`, + `[${item.id}] calling invalidateCurrItems for live photo imgSrc, source loaded: ${item.isSourceLoaded}`, ); instance.invalidateCurrItems(); if ((instance as any).isOpen()) { @@ -415,7 +409,7 @@ const PhotoFrame = ({ true, ); log.info( - `[${item.id}] calling invalidateCurrItems for live photo complete, source loaded :${item.isSourceLoaded}`, + `[${item.id}] calling invalidateCurrItems for live photo complete, source loaded: ${item.isSourceLoaded}`, ); instance.invalidateCurrItems(); if ((instance as any).isOpen()) { @@ -433,7 +427,7 @@ const PhotoFrame = ({ try { await updateSrcURL(index, item.id, srcURLs); log.info( - `[${item.id}] calling invalidateCurrItems for src, source loaded :${item.isSourceLoaded}`, + `[${item.id}] calling invalidateCurrItems for src, source loaded: ${item.isSourceLoaded}`, ); instance.invalidateCurrItems(); if ((instance as any).isOpen()) { @@ -476,9 +470,7 @@ const PhotoFrame = ({ try { updateURL(index)(item.id, item.msrc, true); log.info( - `[${ - item.id - }] calling invalidateCurrItems for thumbnail msrc :${!!item.msrc}`, + `[${item.id}] calling invalidateCurrItems for thumbnail msrc: ${!!item.msrc}`, ); instance.invalidateCurrItems(); if ((instance as any).isOpen()) { @@ -495,7 +487,7 @@ const PhotoFrame = ({ } try { log.info( - `[${item.id}] new file getConvertedVideo request- ${item.metadata.title}}`, + `[${item.id}] new file getConvertedVideo request ${item.metadata.title}}`, ); fetching[item.id] = true; @@ -504,7 +496,7 @@ const PhotoFrame = ({ try { await updateSrcURL(index, item.id, srcURL, true); log.info( - `[${item.id}] calling invalidateCurrItems for src, source loaded :${item.isSourceLoaded}`, + `[${item.id}] calling invalidateCurrItems for src, source loaded: ${item.isSourceLoaded}`, ); instance.invalidateCurrItems(); if ((instance as any).isOpen()) { From 42b0b6e9bbf837aad684a35591dfdffa32802ed9 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sun, 28 Apr 2024 16:14:13 +0530 Subject: [PATCH 03/49] convert to mp4 --- .../photos/src/services/download/index.ts | 16 ++-- web/apps/photos/src/services/ffmpeg.ts | 92 ++++++++----------- web/packages/shared/hooks/useFileInput.tsx | 2 +- 3 files changed, 43 insertions(+), 67 deletions(-) diff --git a/web/apps/photos/src/services/download/index.ts b/web/apps/photos/src/services/download/index.ts index 37eeac440d..6440b83300 100644 --- a/web/apps/photos/src/services/download/index.ts +++ b/web/apps/photos/src/services/download/index.ts @@ -10,7 +10,7 @@ import { Events, eventBus } from "@ente/shared/events"; import { isPlaybackPossible } from "@ente/shared/media/video-playback"; import { Remote } from "comlink"; import isElectron from "is-electron"; -import * as ffmpegService from "services/ffmpeg"; +import * as ffmpeg from "services/ffmpeg"; import { EnteFile } from "types/file"; import { generateStreamFromArrayBuffer, getRenderableImage } from "utils/file"; import { PhotosDownloadClient } from "./clients/photos"; @@ -610,17 +610,13 @@ async function getPlayableVideo( if (!forceConvert && !runOnWeb && !isElectron()) { return null; } - log.info( - `video format not supported, converting it name: ${videoNameTitle}`, - ); - const mp4ConvertedVideo = await ffmpegService.convertToMP4( - new File([videoBlob], videoNameTitle), - ); - log.info(`video successfully converted ${videoNameTitle}`); - return new Blob([mp4ConvertedVideo]); + // TODO(MR): This might not work for very large (~ GB) videos. Test. + log.info(`Converting video ${videoNameTitle} to mp4`); + const convertedVideoData = await ffmpeg.convertToMP4(videoBlob); + return new Blob([convertedVideoData]); } } catch (e) { - log.error("video conversion failed", e); + log.error("Video conversion failed", e); return null; } } diff --git a/web/apps/photos/src/services/ffmpeg.ts b/web/apps/photos/src/services/ffmpeg.ts index 6fc2404e2c..6383a8ce0d 100644 --- a/web/apps/photos/src/services/ffmpeg.ts +++ b/web/apps/photos/src/services/ffmpeg.ts @@ -1,4 +1,3 @@ -import { ElectronFile } from "@/next/types/file"; import type { Electron } from "@/next/types/ipc"; import { ComlinkWorker } from "@/next/worker/comlink-worker"; import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time"; @@ -200,23 +199,6 @@ function parseCreationTime(creationTime: string) { return dateTime; } -/** Called when viewing a file */ -export async function convertToMP4(file: File) { - return await ffmpegExec2( - [ - ffmpegPathPlaceholder, - "-i", - inputPathPlaceholder, - "-preset", - "ultrafast", - outputPathPlaceholder, - ], - file, - "mp4", - 30 * 1000, - ); -} - /** * Run the given FFmpeg command using a wasm FFmpeg running in a web worker. * @@ -234,55 +216,53 @@ const ffmpegExecWeb = async ( }; /** - * Run the given FFmpeg command using a native FFmpeg binary bundled with our - * desktop app. + * Convert a video from a format that is not supported in the browser to MP4. + * + * This function is called when the user views a video or a live photo, and we + * want to play it back. The idea is to convert it to MP4 which has much more + * universal support in browsers. + * + * @param blob The video blob. + * + * @returns The mp4 video data. + */ +export const convertToMP4 = async (blob: Blob) => + ffmpegExecNativeOrWeb( + [ + ffmpegPathPlaceholder, + "-i", + inputPathPlaceholder, + "-preset", + "ultrafast", + outputPathPlaceholder, + ], + blob, + "mp4", + 30 * 1000, + ); + +/** + * Run the given FFmpeg command using a native FFmpeg binary when we're running + * in the context of our desktop app, otherwise using the browser based wasm + * FFmpeg implemenation. * * See also: {@link ffmpegExecWeb}. */ -/* -TODO(MR): Remove me -const ffmpegExecNative = async ( - electron: Electron, +const ffmpegExecNativeOrWeb = async ( command: string[], blob: Blob, - timeoutMs: number = 0, -) => { - const electron = globalThis.electron; - if (electron) { - const data = new Uint8Array(await blob.arrayBuffer()); - return await electron.ffmpegExec(command, data, timeoutMs); - } else { - const worker = await workerFactory.lazy(); - return await worker.exec(command, blob, timeoutMs); - } -}; -*/ - -const ffmpegExec2 = async ( - command: string[], - inputFile: File | ElectronFile, outputFileExtension: string, - timeoutMS: number = 0, + timeoutMs: number, ) => { const electron = globalThis.electron; - if (electron || false) { - throw new Error("WIP"); - // return electron.ffmpegExec( - // command, - // /* TODO(MR): ElectronFile changes */ - // inputFile as unknown as string, - // outputFileName, - // timeoutMS, - // ); - } else { - /* TODO(MR): ElectronFile changes */ - return ffmpegExecWeb( + if (electron) + return electron.ffmpegExec( command, - inputFile as File, + new Uint8Array(await blob.arrayBuffer()), outputFileExtension, - timeoutMS, + timeoutMs, ); - } + else return ffmpegExecWeb(command, blob, outputFileExtension, timeoutMs); }; /** Lazily create a singleton instance of our worker */ diff --git a/web/packages/shared/hooks/useFileInput.tsx b/web/packages/shared/hooks/useFileInput.tsx index b53fecb585..ac74ec6957 100644 --- a/web/packages/shared/hooks/useFileInput.tsx +++ b/web/packages/shared/hooks/useFileInput.tsx @@ -1,6 +1,6 @@ import { useCallback, useRef, useState } from "react"; -/* +/** * TODO (MR): Understand how this is happening, and validate it further (on * first glance this is correct). * From 0202f8f38b88b59007acc662ac930ec43ce7df80 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sun, 28 Apr 2024 16:32:45 +0530 Subject: [PATCH 04/49] More debug --- web/apps/photos/src/components/Upload/Uploader.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index d7485398f5..cf4578fd80 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -366,12 +366,13 @@ export default function Uploader(props: Props) { setDesktopFilePaths([]); } - log.debug(() => "Uploader received:"); + log.debug(() => "Uploader invoked"); log.debug(() => fileOrPathsToUpload.current); fileOrPathsToUpload.current = pruneHiddenFiles( fileOrPathsToUpload.current, ); + if (fileOrPathsToUpload.current.length === 0) { props.setLoading(false); return; @@ -386,6 +387,8 @@ export default function Uploader(props: Props) { ); setImportSuggestion(importSuggestion); + log.debug(() => importSuggestion); + handleCollectionCreationAndUpload( importSuggestion, props.isFirstUpload, From 0e9507be34b19b1400e32019c26d65e674de64e3 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sun, 28 Apr 2024 16:47:03 +0530 Subject: [PATCH 05/49] Understand better --- web/packages/shared/hooks/useFileInput.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/web/packages/shared/hooks/useFileInput.tsx b/web/packages/shared/hooks/useFileInput.tsx index ac74ec6957..e2c6d83e29 100644 --- a/web/packages/shared/hooks/useFileInput.tsx +++ b/web/packages/shared/hooks/useFileInput.tsx @@ -18,6 +18,24 @@ export interface FileWithPath extends File { readonly path?: string; } +/** + * Return three things: + * + * - A function that can be called to trigger the showing of the select file / + * directory dialog. + * + * - The list of properties that should be passed to a dummy `input` element + * that needs to be created to anchor the select file dialog. This input HTML + * element is not going to be visible, but it needs to be part of the DOM fro + * the open trigger to have effect. + * + * - The list of files that the user selected. This will be a list even if the + * user selected directories - in that case, it will be the recursive list of + * files within this directory. + * + * @param param0 If {@link directory} is true, the file open dialog will ask the + * user to select directories. Otherwise it'll ask the user to select files. + */ export default function useFileInput({ directory }: { directory?: boolean }) { const [selectedFiles, setSelectedFiles] = useState([]); const inputRef = useRef(); From e65307517db1a92491ff013c63bd3c905b3c7fb8 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sun, 28 Apr 2024 17:08:22 +0530 Subject: [PATCH 06/49] Scaffold --- desktop/src/main/ipc.ts | 3 +++ desktop/src/main/services/upload.ts | 4 ++++ desktop/src/preload.ts | 4 ++++ web/apps/photos/src/utils/native-stream.ts | 2 ++ web/packages/next/types/ipc.ts | 13 +++++++++++++ 5 files changed, 26 insertions(+) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 825a2ed32b..093724251a 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -53,6 +53,7 @@ import { } from "./services/store"; import { getElectronFilesFromGoogleZip, + lsZip, pendingUploads, setPendingUploadCollection, setPendingUploadFiles, @@ -210,6 +211,8 @@ export const attachIPCHandlers = () => { setPendingUploadFiles(type, filePaths), ); + ipcMain.handle("lsZip", (_, zipPath: string) => lsZip(zipPath)); + // - ipcMain.handle("getElectronFilesFromGoogleZip", (_, filePath: string) => diff --git a/desktop/src/main/services/upload.ts b/desktop/src/main/services/upload.ts index 88c2d88d19..245edafe6c 100644 --- a/desktop/src/main/services/upload.ts +++ b/desktop/src/main/services/upload.ts @@ -69,6 +69,10 @@ const storeKey = (type: PendingUploads["type"]): keyof UploadStatusStore => { } }; +export const lsZip = async (zipPath: string) => { + return [zipPath]; +}; + export const getElectronFilesFromGoogleZip = async (filePath: string) => { const zip = new StreamZip.async({ file: filePath, diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 18fb550130..244cc9ffce 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -253,6 +253,9 @@ const setPendingUploadFiles = ( ): Promise => ipcRenderer.invoke("setPendingUploadFiles", type, filePaths); +const lsZip = (zipPath: string): Promise => + ipcRenderer.invoke("lsZip", zipPath); + // - TODO: AUDIT below this // - @@ -373,6 +376,7 @@ contextBridge.exposeInMainWorld("electron", { pendingUploads, setPendingUploadCollection, setPendingUploadFiles, + lsZip, // - diff --git a/web/apps/photos/src/utils/native-stream.ts b/web/apps/photos/src/utils/native-stream.ts index 85d54b7907..ed7b16a793 100644 --- a/web/apps/photos/src/utils/native-stream.ts +++ b/web/apps/photos/src/utils/native-stream.ts @@ -2,6 +2,8 @@ * @file Streaming IPC communication with the Node.js layer of our desktop app. * * NOTE: These functions only work when we're running in our desktop app. + * + * See: [Note: IPC streams]. */ import type { Electron } from "@/next/types/ipc"; diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index 1622a820d9..f00409b008 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -491,6 +491,19 @@ export interface Electron { filePaths: string[], ) => Promise; + /** + * Get the list of files that are present in the given zip file. + * + * @param zipPath The path of the zip file on the user's local file system. + * + * @returns A list of paths, one for each file in the given zip. Directories + * are traversed recursively, but the directory entries themselves will be + * excluded from the returned list. + * + * To read the contents of the files themselves, see [Note: IPC streams]. + */ + lsZip: (zipPath: string) => Promise; + /* * TODO: AUDIT below this - Some of the types we use below are not copyable * across process boundaries, and such functions will (expectedly) fail at From 243d019e8bea7ebccba37446162f4b6609eb87ef Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sun, 28 Apr 2024 17:24:11 +0530 Subject: [PATCH 07/49] Potential implementation --- desktop/src/main/stream.ts | 45 ++++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index 88d85db8e8..26e5d1f3f5 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -2,6 +2,7 @@ * @file stream data to-from renderer using a custom protocol handler. */ import { net, protocol } from "electron/main"; +import StreamZip from "node-stream-zip"; import { createWriteStream, existsSync } from "node:fs"; import fs from "node:fs/promises"; import { Readable } from "node:stream"; @@ -34,17 +35,23 @@ export const registerStreamProtocol = () => { protocol.handle("stream", async (request: Request) => { const url = request.url; // The request URL contains the command to run as the host, and the - // pathname of the file as the path. For example, + // pathname of the file as the path. An additional path can be specified + // as the URL hash. // - // stream://write/path/to/file - // host-pathname----- + // For example, // - const { host, pathname } = new URL(url); + // stream://write/path/to/file#/path/to/another/file + // host[pathname----] [pathname-2---------] + // + const { host, pathname, hash } = new URL(url); // Convert e.g. "%20" to spaces. const path = decodeURIComponent(pathname); + const hashPath = decodeURIComponent(hash); switch (host) { case "read": return handleRead(path); + case "read-zip": + return handleReadZip(path, hashPath); case "write": return handleWrite(path, request); default: @@ -88,6 +95,36 @@ const handleRead = async (path: string) => { } }; +const handleReadZip = async (zipPath: string, zipEntryPath: string) => { + try { + const zip = new StreamZip.async({ + file: zipPath, + }); + const entry = await zip.entry(zipEntryPath); + const stream = await zip.stream(entry); + + return new Response(Readable.toWeb(new Readable(stream)), { + headers: { + // We don't know the exact type, but it doesn't really matter, + // just set it to a generic binary content-type so that the + // browser doesn't tinker with it thinking of it as text. + "Content-Type": "application/octet-stream", + "Content-Length": `${entry.size}`, + // !!TODO(MR): Is this ms + "X-Last-Modified-Ms": `${entry.time}`, + }, + }); + } catch (e) { + log.error( + `Failed to read entry ${zipEntryPath} from zip file at ${zipPath}`, + e, + ); + return new Response(`Failed to read stream: ${e.message}`, { + status: 500, + }); + } +}; + const handleWrite = async (path: string, request: Request) => { try { await writeStream(path, request.body); From a3d06c54afc051bbb1d9134b2dd0010cf59fc727 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sun, 28 Apr 2024 17:37:39 +0530 Subject: [PATCH 08/49] Prune --- .../src/components/PhotoList/dedupe.tsx | 2 +- .../photos/src/components/PhotoList/index.tsx | 2 +- web/apps/photos/src/services/heic-convert.ts | 27 ++++--------------- web/apps/photos/src/utils/file/index.ts | 13 +++++++++ web/packages/next/file.ts | 26 ------------------ 5 files changed, 20 insertions(+), 50 deletions(-) diff --git a/web/apps/photos/src/components/PhotoList/dedupe.tsx b/web/apps/photos/src/components/PhotoList/dedupe.tsx index 9c86ba24f1..7181f62675 100644 --- a/web/apps/photos/src/components/PhotoList/dedupe.tsx +++ b/web/apps/photos/src/components/PhotoList/dedupe.tsx @@ -1,4 +1,3 @@ -import { convertBytesToHumanReadable } from "@/next/file"; import { FlexWrapper } from "@ente/shared/components/Container"; import { Box, styled } from "@mui/material"; import { @@ -20,6 +19,7 @@ import { } from "react-window"; import { Duplicate } from "services/deduplicationService"; import { EnteFile } from "types/file"; +import { convertBytesToHumanReadable } from "utils/file"; export enum ITEM_TYPE { TIME = "TIME", diff --git a/web/apps/photos/src/components/PhotoList/index.tsx b/web/apps/photos/src/components/PhotoList/index.tsx index 48454fa691..91f712df1b 100644 --- a/web/apps/photos/src/components/PhotoList/index.tsx +++ b/web/apps/photos/src/components/PhotoList/index.tsx @@ -1,4 +1,3 @@ -import { convertBytesToHumanReadable } from "@/next/file"; import { FlexWrapper } from "@ente/shared/components/Container"; import { formatDate, getDate, isSameDay } from "@ente/shared/time/format"; import { Box, Checkbox, Link, Typography, styled } from "@mui/material"; @@ -23,6 +22,7 @@ import { areEqual, } from "react-window"; import { EnteFile } from "types/file"; +import { convertBytesToHumanReadable } from "utils/file"; import { handleSelectCreator } from "utils/photoFrame"; import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery"; diff --git a/web/apps/photos/src/services/heic-convert.ts b/web/apps/photos/src/services/heic-convert.ts index 478cce2185..c2ea198391 100644 --- a/web/apps/photos/src/services/heic-convert.ts +++ b/web/apps/photos/src/services/heic-convert.ts @@ -1,4 +1,3 @@ -import { convertBytesToHumanReadable } from "@/next/file"; import log from "@/next/log"; import { ComlinkWorker } from "@/next/worker/comlink-worker"; import { CustomError } from "@ente/shared/error"; @@ -51,15 +50,10 @@ class HEICConverter { const startTime = Date.now(); const convertedHEIC = await worker.heicToJPEG(fileBlob); - log.info( - `originalFileSize:${convertBytesToHumanReadable( - fileBlob?.size, - )},convertedFileSize:${convertBytesToHumanReadable( - convertedHEIC?.size, - )}, heic conversion time: ${ - Date.now() - startTime - }ms `, + const ms = Math.round( + Date.now() - startTime, ); + log.debug(() => `heic => jpeg (${ms} ms)`); clearTimeout(timeout); resolve(convertedHEIC); } catch (e) { @@ -71,18 +65,7 @@ class HEICConverter { ); if (!convertedHEIC || convertedHEIC?.size === 0) { log.error( - `converted heic fileSize is Zero - ${JSON.stringify( - { - originalFileSize: - convertBytesToHumanReadable( - fileBlob?.size ?? 0, - ), - convertedFileSize: - convertBytesToHumanReadable( - convertedHEIC?.size ?? 0, - ), - }, - )}`, + `Converted HEIC file is empty (original was ${fileBlob?.size} bytes)`, ); } await new Promise((resolve) => { @@ -94,7 +77,7 @@ class HEICConverter { this.workerPool.push(convertWorker); return convertedHEIC; } catch (e) { - log.error("heic conversion failed", e); + log.error("HEIC conversion failed", e); convertWorker.terminate(); this.workerPool.push(createComlinkWorker()); throw e; diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 5d7762abfc..abbc8b0fa3 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -116,6 +116,19 @@ export async function getUpdatedEXIFFileForDownload( } } +export function convertBytesToHumanReadable( + bytes: number, + precision = 2, +): string { + if (bytes === 0 || isNaN(bytes)) { + return "0 MB"; + } + + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + return (bytes / Math.pow(1024, i)).toFixed(precision) + " " + sizes[i]; +} + export async function downloadFile(file: EnteFile) { try { const fileReader = new FileReader(); diff --git a/web/packages/next/file.ts b/web/packages/next/file.ts index 56d27b79b5..bd2c043930 100644 --- a/web/packages/next/file.ts +++ b/web/packages/next/file.ts @@ -1,5 +1,3 @@ -import type { ElectronFile } from "./types/file"; - /** * The two parts of a file name - the name itself, and an (optional) extension. * @@ -82,27 +80,3 @@ export const dirname = (path: string) => { } return pathComponents.join("/"); }; - -/** - * Return a short description of the given {@link fileOrPath} suitable for - * helping identify it in log messages. - */ -export const fopLabel = (fileOrPath: File | string) => - fileOrPath instanceof File ? `File(${fileOrPath.name})` : fileOrPath; - -export function getFileNameSize(file: File | ElectronFile) { - return `${file.name}_${convertBytesToHumanReadable(file.size)}`; -} - -export function convertBytesToHumanReadable( - bytes: number, - precision = 2, -): string { - if (bytes === 0 || isNaN(bytes)) { - return "0 MB"; - } - - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; - return (bytes / Math.pow(1024, i)).toFixed(precision) + " " + sizes[i]; -} From 13f0ff3af52d7cc31cd9cfc94342d0eb31f00ea9 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sun, 28 Apr 2024 20:31:11 +0530 Subject: [PATCH 09/49] wip zip selection on web itself --- .../photos/src/components/Upload/Uploader.tsx | 59 ++++++++++++++++--- .../src/components/UploadSelectorInputs.tsx | 4 ++ web/apps/photos/src/pages/gallery/index.tsx | 11 ++++ .../photos/src/pages/shared-albums/index.tsx | 1 + web/packages/shared/hooks/useFileInput.tsx | 20 ++++++- 5 files changed, 85 insertions(+), 10 deletions(-) diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index cf4578fd80..2bf1e79d20 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -76,8 +76,10 @@ interface Props { showSessionExpiredMessage: () => void; showUploadFilesDialog: () => void; showUploadDirsDialog: () => void; + showUploadZipFilesDialog?: () => void; webFolderSelectorFiles: File[]; webFileSelectorFiles: File[]; + webFileSelectorZipFiles?: File[]; dragAndDropFiles: File[]; uploadCollection?: Collection; uploadTypeSelectorIntent: UploadTypeSelectorIntent; @@ -255,24 +257,59 @@ export default function Uploader(props: Props) { ) { log.info(`received file upload request`); setWebFiles(props.webFileSelectorFiles); + } else if ( + pickedUploadType.current === PICKED_UPLOAD_TYPE.ZIPS && + props.webFileSelectorZipFiles?.length > 0 + ) { + if (electron) { + const main = async () => { + const zips: File[] = []; + let electronFiles = [] as ElectronFile[]; + for (const file of props.webFileSelectorZipFiles) { + if (file.name.endsWith(".zip")) { + const zipFiles = await electron.lsZip( + (file as any).path, + ); + log.info( + `zip file - ${file.name} contains ${zipFiles.length} files`, + ); + zips.push(file); + // TODO(MR): This cast is incorrect, but interim. + electronFiles = [ + ...electronFiles, + ...(zipFiles as unknown as ElectronFile[]), + ]; + } + } + // setWebFiles(props.webFileSelectorZipFiles); + zipPaths.current = zips.map((file) => (file as any).path); + setElectronFiles(electronFiles); + }; + main(); + } } else if (props.dragAndDropFiles?.length > 0) { isDragAndDrop.current = true; if (electron) { const main = async () => { try { - log.info(`uploading dropped files from desktop app`); // check and parse dropped files which are zip files + log.info(`uploading dropped files from desktop app`); + const zips: File[] = []; let electronFiles = [] as ElectronFile[]; for (const file of props.dragAndDropFiles) { if (file.name.endsWith(".zip")) { - const zipFiles = - await electron.getElectronFilesFromGoogleZip( - (file as any).path, - ); + const zipFiles = await electron.lsZip( + (file as any).path, + ); log.info( `zip file - ${file.name} contains ${zipFiles.length} files`, ); - electronFiles = [...electronFiles, ...zipFiles]; + zips.push(file); + // TODO(MR): This cast is incorrect, but interim. + electronFiles = [ + ...electronFiles, + ...(zipFiles as unknown as ElectronFile[]), + ]; } else { // type cast to ElectronFile as the file is dropped from desktop app // type file and ElectronFile should be interchangeable, but currently they have some differences. @@ -290,6 +327,9 @@ export default function Uploader(props: Props) { log.info( `uploading dropped files from desktop app - ${electronFiles.length} files found`, ); + zipPaths.current = zips.map( + (file) => (file as any).path, + ); setElectronFiles(electronFiles); } catch (e) { log.error("failed to upload desktop dropped files", e); @@ -306,6 +346,7 @@ export default function Uploader(props: Props) { props.dragAndDropFiles, props.webFileSelectorFiles, props.webFolderSelectorFiles, + props.webFileSelectorZipFiles, ]); useEffect(() => { @@ -768,7 +809,11 @@ export default function Uploader(props: Props) { } else if (type === PICKED_UPLOAD_TYPE.FOLDERS) { props.showUploadDirsDialog(); } else { - appContext.setDialogMessage(getDownloadAppMessage()); + if (props.showUploadZipFilesDialog && electron) { + props.showUploadZipFilesDialog(); + } else { + appContext.setDialogMessage(getDownloadAppMessage()); + } } }; diff --git a/web/apps/photos/src/components/UploadSelectorInputs.tsx b/web/apps/photos/src/components/UploadSelectorInputs.tsx index 1b110d532b..13e33fc6d3 100644 --- a/web/apps/photos/src/components/UploadSelectorInputs.tsx +++ b/web/apps/photos/src/components/UploadSelectorInputs.tsx @@ -2,12 +2,16 @@ export default function UploadSelectorInputs({ getDragAndDropInputProps, getFileSelectorInputProps, getFolderSelectorInputProps, + getZipFileSelectorInputProps, }) { return ( <> + {getZipFileSelectorInputProps && ( + + )} ); } diff --git a/web/apps/photos/src/pages/gallery/index.tsx b/web/apps/photos/src/pages/gallery/index.tsx index 38f559814c..658f62b459 100644 --- a/web/apps/photos/src/pages/gallery/index.tsx +++ b/web/apps/photos/src/pages/gallery/index.tsx @@ -224,6 +224,14 @@ export default function Gallery() { } = useFileInput({ directory: true, }); + const { + selectedFiles: webFileSelectorZipFiles, + open: openZipFileSelector, + getInputProps: getZipFileSelectorInputProps, + } = useFileInput({ + directory: false, + accept: ".zip" + }); const [isInSearchMode, setIsInSearchMode] = useState(false); const [searchResultSummary, setSetSearchResultSummary] = @@ -1023,6 +1031,7 @@ export default function Gallery() { getDragAndDropInputProps={getDragAndDropInputProps} getFileSelectorInputProps={getFileSelectorInputProps} getFolderSelectorInputProps={getFolderSelectorInputProps} + getZipFileSelectorInputProps={getZipFileSelectorInputProps} /> {blockingLoad && ( @@ -1123,10 +1132,12 @@ export default function Gallery() { } webFileSelectorFiles={webFileSelectorFiles} webFolderSelectorFiles={webFolderSelectorFiles} + webFileSelectorZipFiles={webFileSelectorZipFiles} dragAndDropFiles={dragAndDropFiles} uploadTypeSelectorView={uploadTypeSelectorView} showUploadFilesDialog={openFileSelector} showUploadDirsDialog={openFolderSelector} + showUploadZipFilesDialog={openZipFileSelector} showSessionExpiredMessage={showSessionExpiredMessage} /> ([]); const inputRef = useRef(); @@ -66,6 +79,7 @@ export default function useFileInput({ directory }: { directory?: boolean }) { ...(directory ? { directory: "", webkitdirectory: "" } : {}), ref: inputRef, onChange: handleChange, + ...(accept ? { accept } : {}), }), [], ); From 24b64f9522495072119f8f2a3c07135001a76de0 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sun, 28 Apr 2024 20:38:14 +0530 Subject: [PATCH 10/49] Verify assumption --- desktop/src/main/stream.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index 26e5d1f3f5..ddd639c30b 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -110,7 +110,11 @@ const handleReadZip = async (zipPath: string, zipEntryPath: string) => { // browser doesn't tinker with it thinking of it as text. "Content-Type": "application/octet-stream", "Content-Length": `${entry.size}`, - // !!TODO(MR): Is this ms + // While it is documented that entry.time is the modification + // time, the units are not mentioned. By seeing the source code, + // we can verify that it is indeed epoch milliseconds. See + // `parseZipTime` in the node-stream-zip source, + // https://github.com/antelle/node-stream-zip/blob/master/node_stream_zip.js "X-Last-Modified-Ms": `${entry.time}`, }, }); From 75c058fc4c9abbb6acfbf556482adfb869dc5f04 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 09:53:54 +0530 Subject: [PATCH 11/49] This is where it comes from --- web/packages/shared/hooks/useFileInput.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/web/packages/shared/hooks/useFileInput.tsx b/web/packages/shared/hooks/useFileInput.tsx index 3f7f2d4321..4eb346d39c 100644 --- a/web/packages/shared/hooks/useFileInput.tsx +++ b/web/packages/shared/hooks/useFileInput.tsx @@ -1,18 +1,23 @@ import { useCallback, useRef, useState } from "react"; /** - * TODO (MR): Understand how this is happening, and validate it further (on - * first glance this is correct). - * * [Note: File paths when running under Electron] * * We have access to the absolute path of the web {@link File} object when we * are running in the context of our desktop app. * + * https://www.electronjs.org/docs/latest/api/file-object + * * This is in contrast to the `webkitRelativePath` that we get when we're * running in the browser, which is the relative path to the directory that the * user selected (or just the name of the file if the user selected or * drag/dropped a single one). + * + * Note that this is a deprecated approach. From Electron docs: + * + * > Warning: The path property that Electron adds to the File interface is + * > deprecated and will be removed in a future Electron release. We recommend + * > you use `webUtils.getPathForFile` instead. */ export interface FileWithPath extends File { readonly path?: string; @@ -49,7 +54,10 @@ interface UseFileInputParams { * accept can be an extension or a MIME type (See * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept). */ -export default function useFileInput({ directory, accept }: UseFileInputParams) { +export default function useFileInput({ + directory, + accept, +}: UseFileInputParams) { const [selectedFiles, setSelectedFiles] = useState([]); const inputRef = useRef(); From aa111b2a245e2a5b7f64c41d673d924e6bf16711 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 10:12:53 +0530 Subject: [PATCH 12/49] Outline the plan --- desktop/src/main/stores/upload-status.ts | 27 ++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/stores/upload-status.ts b/desktop/src/main/stores/upload-status.ts index 25af7a49e2..e2d1880ce2 100644 --- a/desktop/src/main/stores/upload-status.ts +++ b/desktop/src/main/stores/upload-status.ts @@ -1,12 +1,31 @@ import Store, { Schema } from "electron-store"; export interface UploadStatusStore { - filePaths: string[]; - zipPaths: string[]; + /** + * The name of the collection (when uploading to a singular collection) or + * the root collection (when uploading to separate * albums) to which we + * these uploads are meant to go to. + */ collectionName: string; + /** + * Paths of regular files that need to be uploaded. + */ + filePaths: string[]; + /** + * Paths of zip files that need to be uploaded. + */ + zipPaths: string[]; + /** + * For each zip file, which of its entries (paths) within the zip file that + * need to be uploaded. + */ + zipEntries: Record; } const uploadStatusSchema: Schema = { + collectionName: { + type: "string", + }, filePaths: { type: "array", items: { @@ -19,8 +38,8 @@ const uploadStatusSchema: Schema = { type: "string", }, }, - collectionName: { - type: "string", + zipEntries: { + type: "object", }, }; From e8687caba2c84afc53b82dda2f20200374fe6c37 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 11:07:57 +0530 Subject: [PATCH 13/49] wip pending --- desktop/src/main/ipc.ts | 5 +++- desktop/src/main/services/upload.ts | 26 ++++++++++++++++--- desktop/src/main/stores/upload-status.ts | 15 ----------- desktop/src/preload.ts | 10 ++++--- web/packages/next/types/ipc.ts | 33 +++++++++++++++++++----- 5 files changed, 59 insertions(+), 30 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 093724251a..bde28d940a 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -52,6 +52,7 @@ import { saveEncryptionKey, } from "./services/store"; import { + clearPendingUploads, getElectronFilesFromGoogleZip, lsZip, pendingUploads, @@ -199,6 +200,8 @@ export const attachIPCHandlers = () => { // - Upload + ipcMain.handle("lsZip", (_, zipPath: string) => lsZip(zipPath)); + ipcMain.handle("pendingUploads", () => pendingUploads()); ipcMain.handle("setPendingUploadCollection", (_, collectionName: string) => @@ -211,7 +214,7 @@ export const attachIPCHandlers = () => { setPendingUploadFiles(type, filePaths), ); - ipcMain.handle("lsZip", (_, zipPath: string) => lsZip(zipPath)); + ipcMain.handle("clearPendingUploads", () => clearPendingUploads()); // - diff --git a/desktop/src/main/services/upload.ts b/desktop/src/main/services/upload.ts index 245edafe6c..1ca7c2bc4e 100644 --- a/desktop/src/main/services/upload.ts +++ b/desktop/src/main/services/upload.ts @@ -8,6 +8,24 @@ import { } from "../stores/upload-status"; import { getElectronFile, getZipFileStream } from "./fs"; +export const lsZip = async (zipPath: string) => { + const zip = new StreamZip.async({ file: zipPath }); + + const entries = await zip.entries(); + const entryPaths: string[] = []; + + for (const entry of Object.values(entries)) { + const basename = path.basename(entry.name); + // Ignore "hidden" files (files whose names begins with a dot). + if (entry.isFile && basename.length > 0 && basename[0] != ".") { + // `entry.name` is the path within the zip. + entryPaths.push(entry.name); + } + } + + return [entryPaths]; +}; + export const pendingUploads = async () => { const collectionName = uploadStatusStore.get("collectionName"); const filePaths = validSavedPaths("files"); @@ -60,6 +78,10 @@ export const setPendingUploadFiles = ( else uploadStatusStore.delete(key); }; +export const clearPendingUploads = () => { + uploadStatusStore.clear(); +}; + const storeKey = (type: PendingUploads["type"]): keyof UploadStatusStore => { switch (type) { case "zips": @@ -69,10 +91,6 @@ const storeKey = (type: PendingUploads["type"]): keyof UploadStatusStore => { } }; -export const lsZip = async (zipPath: string) => { - return [zipPath]; -}; - export const getElectronFilesFromGoogleZip = async (filePath: string) => { const zip = new StreamZip.async({ file: filePath, diff --git a/desktop/src/main/stores/upload-status.ts b/desktop/src/main/stores/upload-status.ts index e2d1880ce2..5b7f7c1127 100644 --- a/desktop/src/main/stores/upload-status.ts +++ b/desktop/src/main/stores/upload-status.ts @@ -1,24 +1,9 @@ import Store, { Schema } from "electron-store"; export interface UploadStatusStore { - /** - * The name of the collection (when uploading to a singular collection) or - * the root collection (when uploading to separate * albums) to which we - * these uploads are meant to go to. - */ collectionName: string; - /** - * Paths of regular files that need to be uploaded. - */ filePaths: string[]; - /** - * Paths of zip files that need to be uploaded. - */ zipPaths: string[]; - /** - * For each zip file, which of its entries (paths) within the zip file that - * need to be uploaded. - */ zipEntries: Record; } diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 244cc9ffce..ad93a40f6a 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -241,6 +241,9 @@ const watchFindFiles = (folderPath: string): Promise => // - Upload +const lsZip = (zipPath: string): Promise => + ipcRenderer.invoke("lsZip", zipPath); + const pendingUploads = (): Promise => ipcRenderer.invoke("pendingUploads"); @@ -253,8 +256,8 @@ const setPendingUploadFiles = ( ): Promise => ipcRenderer.invoke("setPendingUploadFiles", type, filePaths); -const lsZip = (zipPath: string): Promise => - ipcRenderer.invoke("lsZip", zipPath); +const clearPendingUploads = (): Promise => + ipcRenderer.invoke("clearPendingUploads"); // - TODO: AUDIT below this // - @@ -373,10 +376,11 @@ contextBridge.exposeInMainWorld("electron", { // - Upload + lsZip, pendingUploads, setPendingUploadCollection, setPendingUploadFiles, - lsZip, + clearPendingUploads, // - diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index f00409b008..1056610366 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -601,14 +601,33 @@ export interface FolderWatchSyncedFile { } /** - * When the user starts an upload, we remember the files they'd selected or drag - * and dropped so that we can resume (if needed) when the app restarts after - * being stopped in the middle of the uploads. + * State about pending and in-progress uploads. + * + * When the user starts an upload, we remember the files they'd selected (or + * drag-dropped) so that we can resume if they restart the app in before the + * uploads have been completed. This state is kept on the Electron side, and + * this object is the IPC intermediary. */ export interface PendingUploads { - /** The collection to which we're uploading */ + /** + * The collection to which we're uploading, or the root collection. + * + * This is name of the collection (when uploading to a singular collection) + * or the root collection (when uploading to separate * albums) to which we + * these uploads are meant to go to. See {@link CollectionMapping}. + */ collectionName: string; - /* The upload can be either of a Google Takeout zip, or regular files */ - type: "files" | "zips"; - files: ElectronFile[]; + /** + * Paths of regular files that need to be uploaded. + */ + filePaths: string[]; + /** + * Paths of zip files that need to be uploaded. + */ + zipPaths: string[]; + /** + * For each zip file, which of its entries (paths) within the zip file that + * need to be uploaded. + */ + zipEntries: Record; } From 63841abd301792ecd0df0fa8da8f9be5f36ac9a1 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 11:44:43 +0530 Subject: [PATCH 14/49] Envision --- desktop/src/main/stores/upload-status.ts | 20 ++++-- web/packages/next/types/ipc.ts | 82 +++++++++++------------- 2 files changed, 55 insertions(+), 47 deletions(-) diff --git a/desktop/src/main/stores/upload-status.ts b/desktop/src/main/stores/upload-status.ts index 5b7f7c1127..4a402333a7 100644 --- a/desktop/src/main/stores/upload-status.ts +++ b/desktop/src/main/stores/upload-status.ts @@ -1,10 +1,16 @@ import Store, { Schema } from "electron-store"; export interface UploadStatusStore { + /* The collection to which we're uploading, or the root collection. */ collectionName: string; + /** Paths to regular files that are pending upload */ filePaths: string[]; + /** + * Each item is the path to a zip file and the name of an entry within it. + */ + zipEntries: [zipPath: string, entryName: string][]; + /** Legacy paths to zip files, now subsumed into zipEntries */ zipPaths: string[]; - zipEntries: Record; } const uploadStatusSchema: Schema = { @@ -17,15 +23,21 @@ const uploadStatusSchema: Schema = { type: "string", }, }, + zipEntries: { + type: "array", + items: { + type: "array", + items: { + type: "string", + }, + }, + }, zipPaths: { type: "array", items: { type: "string", }, }, - zipEntries: { - type: "object", - }, }; export const uploadStatusStore = new Store({ diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index 1056610366..e340e7a061 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -465,32 +465,6 @@ export interface Electron { // - Upload - /** - * Return any pending uploads that were previously enqueued but haven't yet - * been completed. - * - * The state of pending uploads is persisted in the Node.js layer. - * - * Note that we might have both outstanding zip and regular file uploads at - * the same time. In such cases, the zip file ones get precedence. - */ - pendingUploads: () => Promise; - - /** - * Set or clear the name of the collection where the pending upload is - * directed to. - */ - setPendingUploadCollection: (collectionName: string) => Promise; - - /** - * Update the list of files (of {@link type}) associated with the pending - * upload. - */ - setPendingUploadFiles: ( - type: PendingUploads["type"], - filePaths: string[], - ) => Promise; - /** * Get the list of files that are present in the given zip file. * @@ -498,24 +472,46 @@ export interface Electron { * * @returns A list of paths, one for each file in the given zip. Directories * are traversed recursively, but the directory entries themselves will be - * excluded from the returned list. + * excluded from the returned list. File entries whose file name begins with + * a dot (i.e. "hidden" files) will also be excluded. * * To read the contents of the files themselves, see [Note: IPC streams]. */ lsZip: (zipPath: string) => Promise; - /* - * TODO: AUDIT below this - Some of the types we use below are not copyable - * across process boundaries, and such functions will (expectedly) fail at - * runtime. For such functions, find an efficient alternative or refactor - * the dataflow. + /** + * Return any pending uploads that were previously enqueued but haven't yet + * been completed. + * + * The state of pending uploads is persisted in the Node.js layer. Or app + * start, we read in this data from the Node.js layer via this IPC method. + * The Node.js code returns the persisted data after filtering out any files + * that no longer exist on disk. */ + pendingUploads: () => Promise; - // - + /** + * Set the state of pending uploads. + * + * Typically, this would be called at the start of an upload. Thereafter, as + * each item gets uploaded one by one, we'd call {@link markUploaded}. + * Finally, once the upload completes (or gets cancelled), we'd call + * {@link clearPendingUploads} to complete the circle. + */ + setPendingUploads: (pendingUploads: PendingUploads) => Promise; - getElectronFilesFromGoogleZip: ( - filePath: string, - ) => Promise; + /** + * Update the list of files (of {@link type}) associated with the pending + * upload. + */ + markUploaded: ( + pathOrZipEntry: string | [zipPath: string, entryName: string], + ) => Promise; + + /** + * Clear any pending uploads. + */ + clearPendingUploads: () => Promise; } /** @@ -622,12 +618,12 @@ export interface PendingUploads { */ filePaths: string[]; /** - * Paths of zip files that need to be uploaded. + * When the user uploads a zip file, we create a "zip entry" for each entry + * within that zip file. Such an entry is a tuple containin the path to a + * zip file itself, and the name of an entry within it. + * + * These are the remaining of those zip entries that still need to be + * uploaded. */ - zipPaths: string[]; - /** - * For each zip file, which of its entries (paths) within the zip file that - * need to be uploaded. - */ - zipEntries: Record; + zipEntries: [zipPath: string, entryName: string][]; } From 2d8bcd2530877d41f202658699175436ac6f2426 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 11:55:53 +0530 Subject: [PATCH 15/49] Propagate --- desktop/src/main/ipc.ts | 18 ++++++--------- desktop/src/main/services/upload.ts | 20 +++++++++++------ desktop/src/main/stores/upload-status.ts | 2 +- desktop/src/preload.ts | 28 ++++++------------------ desktop/src/types/ipc.ts | 4 ++-- 5 files changed, 30 insertions(+), 42 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index bde28d940a..6e8bbe3f71 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -52,12 +52,11 @@ import { saveEncryptionKey, } from "./services/store"; import { - clearPendingUploads, - getElectronFilesFromGoogleZip, lsZip, pendingUploads, - setPendingUploadCollection, - setPendingUploadFiles, + setPendingUploads, + markUploaded, + clearPendingUploads, } from "./services/upload"; import { watchAdd, @@ -204,14 +203,11 @@ export const attachIPCHandlers = () => { ipcMain.handle("pendingUploads", () => pendingUploads()); - ipcMain.handle("setPendingUploadCollection", (_, collectionName: string) => - setPendingUploadCollection(collectionName), - ); + ipcMain.handle("setPendingUploads", (_, pendingUploads: PendingUploads) => + setPendingUploads(pendingUploads), - ipcMain.handle( - "setPendingUploadFiles", - (_, type: PendingUploads["type"], filePaths: string[]) => - setPendingUploadFiles(type, filePaths), + ipcMain.handle("markUploaded", (_, pathOrZipEntry: string | [zipPath: string, entryName: string]) => + markUploaded(pathOrZipEntry), ); ipcMain.handle("clearPendingUploads", () => clearPendingUploads()); diff --git a/desktop/src/main/services/upload.ts b/desktop/src/main/services/upload.ts index 1ca7c2bc4e..66c97e34c7 100644 --- a/desktop/src/main/services/upload.ts +++ b/desktop/src/main/services/upload.ts @@ -26,7 +26,8 @@ export const lsZip = async (zipPath: string) => { return [entryPaths]; }; -export const pendingUploads = async () => { +export const pendingUploads = async (): Promise => { + /* TODO */ const collectionName = uploadStatusStore.get("collectionName"); const filePaths = validSavedPaths("files"); const zipPaths = validSavedPaths("zips"); @@ -56,7 +57,14 @@ export const pendingUploads = async () => { }; }; -export const validSavedPaths = (type: PendingUploads["type"]) => { +export const setPendingUploads = async (pendingUploads: PendingUploads) => + uploadStatusStore.set(pendingUploads); + +export const markUploaded = async ( + pathOrZipEntry: string | [zipPath: string, entryName: string], +) => {}; + +const validSavedPaths = (type: PendingUploads["type"]) => { const key = storeKey(type); const savedPaths = (uploadStatusStore.get(key) as string[]) ?? []; const paths = savedPaths.filter((p) => existsSync(p)); @@ -64,12 +72,12 @@ export const validSavedPaths = (type: PendingUploads["type"]) => { return paths; }; -export const setPendingUploadCollection = (collectionName: string) => { +const setPendingUploadCollection = (collectionName: string) => { if (collectionName) uploadStatusStore.set("collectionName", collectionName); else uploadStatusStore.delete("collectionName"); }; -export const setPendingUploadFiles = ( +const setPendingUploadFiles = ( type: PendingUploads["type"], filePaths: string[], ) => { @@ -78,9 +86,7 @@ export const setPendingUploadFiles = ( else uploadStatusStore.delete(key); }; -export const clearPendingUploads = () => { - uploadStatusStore.clear(); -}; +export const clearPendingUploads = () => uploadStatusStore.clear(); const storeKey = (type: PendingUploads["type"]): keyof UploadStatusStore => { switch (type) { diff --git a/desktop/src/main/stores/upload-status.ts b/desktop/src/main/stores/upload-status.ts index 4a402333a7..36a7d1fa72 100644 --- a/desktop/src/main/stores/upload-status.ts +++ b/desktop/src/main/stores/upload-status.ts @@ -10,7 +10,7 @@ export interface UploadStatusStore { */ zipEntries: [zipPath: string, entryName: string][]; /** Legacy paths to zip files, now subsumed into zipEntries */ - zipPaths: string[]; + zipPaths?: string[]; } const uploadStatusSchema: Schema = { diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index ad93a40f6a..c3737aceba 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -247,26 +247,16 @@ const lsZip = (zipPath: string): Promise => const pendingUploads = (): Promise => ipcRenderer.invoke("pendingUploads"); -const setPendingUploadCollection = (collectionName: string): Promise => - ipcRenderer.invoke("setPendingUploadCollection", collectionName); +const setPendingUploads = (pendingUploads: PendingUploads): Promise => + ipcRenderer.invoke("setPendingUploads", pendingUploads); -const setPendingUploadFiles = ( - type: PendingUploads["type"], - filePaths: string[], -): Promise => - ipcRenderer.invoke("setPendingUploadFiles", type, filePaths); +const markUploaded = ( + pathOrZipEntry: string | [zipPath: string, entryName: string], +): Promise => ipcRenderer.invoke("markUploaded", pathOrZipEntry); const clearPendingUploads = (): Promise => ipcRenderer.invoke("clearPendingUploads"); -// - TODO: AUDIT below this -// - - -const getElectronFilesFromGoogleZip = ( - filePath: string, -): Promise => - ipcRenderer.invoke("getElectronFilesFromGoogleZip", filePath); - /** * These objects exposed here will become available to the JS code in our * renderer (the web/ code) as `window.ElectronAPIs.*` @@ -378,11 +368,7 @@ contextBridge.exposeInMainWorld("electron", { lsZip, pendingUploads, - setPendingUploadCollection, - setPendingUploadFiles, + setPendingUploads, + markUploaded, clearPendingUploads, - - // - - - getElectronFilesFromGoogleZip, }); diff --git a/desktop/src/types/ipc.ts b/desktop/src/types/ipc.ts index 3fa375eabf..f343e2bba3 100644 --- a/desktop/src/types/ipc.ts +++ b/desktop/src/types/ipc.ts @@ -27,8 +27,8 @@ export interface FolderWatchSyncedFile { export interface PendingUploads { collectionName: string; - type: "files" | "zips"; - files: ElectronFile[]; + filePaths: string[]; + zipEntries: [zipPath: string, entryName: string][]; } /** From 3b6204f47dd5dc9fad5965bc7416433429af7964 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 13:49:02 +0530 Subject: [PATCH 16/49] Take 2 --- desktop/src/main/ipc.ts | 23 +++++++++++++---------- desktop/src/main/services/upload.ts | 18 +++++++++++++++--- desktop/src/preload.ts | 12 ++++++++---- web/packages/next/types/ipc.ts | 13 +++++++++---- 4 files changed, 45 insertions(+), 21 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 6e8bbe3f71..271577aa04 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -52,11 +52,12 @@ import { saveEncryptionKey, } from "./services/store"; import { + clearPendingUploads, lsZip, + markUploadedFiles, + markUploadedZipEntries, pendingUploads, setPendingUploads, - markUploaded, - clearPendingUploads, } from "./services/upload"; import { watchAdd, @@ -205,18 +206,20 @@ export const attachIPCHandlers = () => { ipcMain.handle("setPendingUploads", (_, pendingUploads: PendingUploads) => setPendingUploads(pendingUploads), + ); - ipcMain.handle("markUploaded", (_, pathOrZipEntry: string | [zipPath: string, entryName: string]) => - markUploaded(pathOrZipEntry), + ipcMain.handle( + "markUploadedFiles", + (_, paths: PendingUploads["filePaths"]) => markUploadedFiles(paths), + ); + + ipcMain.handle( + "markUploadedZipEntries", + (_, zipEntries: PendingUploads["zipEntries"]) => + markUploadedZipEntries(zipEntries), ); ipcMain.handle("clearPendingUploads", () => clearPendingUploads()); - - // - - - ipcMain.handle("getElectronFilesFromGoogleZip", (_, filePath: string) => - getElectronFilesFromGoogleZip(filePath), - ); }; /** diff --git a/desktop/src/main/services/upload.ts b/desktop/src/main/services/upload.ts index 66c97e34c7..ebd1f481f1 100644 --- a/desktop/src/main/services/upload.ts +++ b/desktop/src/main/services/upload.ts @@ -60,9 +60,21 @@ export const pendingUploads = async (): Promise => { export const setPendingUploads = async (pendingUploads: PendingUploads) => uploadStatusStore.set(pendingUploads); -export const markUploaded = async ( - pathOrZipEntry: string | [zipPath: string, entryName: string], -) => {}; +export const markUploadedFiles = async (paths: string[]) => { + const existing = uploadStatusStore.get("filePaths"); + const updated = existing.filter((p) => !paths.includes(p)); + uploadStatusStore.set("filePaths", updated); +}; + +export const markUploadedZipEntries = async ( + entries: [zipPath: string, entryName: string][], +) => { + const existing = uploadStatusStore.get("zipEntries"); + const updated = existing.filter( + (z) => !entries.some((e) => z[0] == e[0] && z[1] == e[1]), + ); + uploadStatusStore.set("zipEntries", updated); +}; const validSavedPaths = (type: PendingUploads["type"]) => { const key = storeKey(type); diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index c3737aceba..484a3bc0e2 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -250,9 +250,12 @@ const pendingUploads = (): Promise => const setPendingUploads = (pendingUploads: PendingUploads): Promise => ipcRenderer.invoke("setPendingUploads", pendingUploads); -const markUploaded = ( - pathOrZipEntry: string | [zipPath: string, entryName: string], -): Promise => ipcRenderer.invoke("markUploaded", pathOrZipEntry); +const markUploadedFiles = (paths: PendingUploads["filePaths"]): Promise => + ipcRenderer.invoke("markUploadedFiles", paths); + +const markUploadedZipEntries = ( + zipEntries: PendingUploads["zipEntries"], +): Promise => ipcRenderer.invoke("markUploadedZipEntries", zipEntries); const clearPendingUploads = (): Promise => ipcRenderer.invoke("clearPendingUploads"); @@ -369,6 +372,7 @@ contextBridge.exposeInMainWorld("electron", { lsZip, pendingUploads, setPendingUploads, - markUploaded, + markUploadedFiles, + markUploadedZipEntries, clearPendingUploads, }); diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index e340e7a061..761cd72f1d 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -501,11 +501,16 @@ export interface Electron { setPendingUploads: (pendingUploads: PendingUploads) => Promise; /** - * Update the list of files (of {@link type}) associated with the pending - * upload. + * Mark the given files (given by their {@link paths}) as having been + * uploaded. */ - markUploaded: ( - pathOrZipEntry: string | [zipPath: string, entryName: string], + markUploadedFiles: (paths: PendingUploads["filePaths"]) => Promise; + + /** + * Mark the given zip file entries as having been uploaded. + */ + markUploadedZipEntries: ( + entries: PendingUploads["zipEntries"], ) => Promise; /** From 3d298a9cd40c50e4bfc0914002be182323dae779 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 14:23:33 +0530 Subject: [PATCH 17/49] separate type --- desktop/src/main/ipc.ts | 4 +-- desktop/src/main/services/upload.ts | 32 +++++++++++++---- desktop/src/main/stores/upload-status.ts | 4 ++- desktop/src/preload.ts | 7 ++-- desktop/src/types/ipc.ts | 4 ++- web/packages/next/types/ipc.ts | 44 +++++++++++++++--------- 6 files changed, 65 insertions(+), 30 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 271577aa04..d7d4fdc098 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -53,11 +53,11 @@ import { } from "./services/store"; import { clearPendingUploads, - lsZip, markUploadedFiles, markUploadedZipEntries, pendingUploads, setPendingUploads, + zipEntries, } from "./services/upload"; import { watchAdd, @@ -200,7 +200,7 @@ export const attachIPCHandlers = () => { // - Upload - ipcMain.handle("lsZip", (_, zipPath: string) => lsZip(zipPath)); + ipcMain.handle("zipEntries", (_, zipPath: string) => zipEntries(zipPath)); ipcMain.handle("pendingUploads", () => pendingUploads()); diff --git a/desktop/src/main/services/upload.ts b/desktop/src/main/services/upload.ts index ebd1f481f1..c5a987e7bc 100644 --- a/desktop/src/main/services/upload.ts +++ b/desktop/src/main/services/upload.ts @@ -1,35 +1,53 @@ import StreamZip from "node-stream-zip"; import { existsSync } from "original-fs"; import path from "path"; -import { ElectronFile, type PendingUploads } from "../../types/ipc"; +import type { ElectronFile, PendingUploads, ZipEntry } from "../../types/ipc"; import { uploadStatusStore, type UploadStatusStore, } from "../stores/upload-status"; import { getElectronFile, getZipFileStream } from "./fs"; -export const lsZip = async (zipPath: string) => { +export const zipEntries = async (zipPath: string): Promise => { const zip = new StreamZip.async({ file: zipPath }); const entries = await zip.entries(); - const entryPaths: string[] = []; + const entryNames: string[] = []; for (const entry of Object.values(entries)) { const basename = path.basename(entry.name); // Ignore "hidden" files (files whose names begins with a dot). if (entry.isFile && basename.length > 0 && basename[0] != ".") { // `entry.name` is the path within the zip. - entryPaths.push(entry.name); + entryNames.push(entry.name); } } - return [entryPaths]; + return entryNames.map((entryName) => [zipPath, entryName]); }; export const pendingUploads = async (): Promise => { - /* TODO */ const collectionName = uploadStatusStore.get("collectionName"); - const filePaths = validSavedPaths("files"); + if (!collectionName) return undefined; + + const allFilePaths = uploadStatusStore.get("filePaths"); + const filePaths = allFilePaths.filter((f) => existsSync(f)); + + let allZipEntries = uploadStatusStore.get("zipEntries"); + // Migration code - May 2024. Remove after a bit. + // + // The older store formats will not have zipEntries and instead will have + // zipPaths. If we find such a case, read the zipPaths and enqueue all of + // their files as zipEntries in the result. This potentially can be cause us + // to try reuploading an already uploaded file, but the dedup logic will + // kick in at that point so no harm will come off it. + if (allZipEntries === undefined) { + const allZipPaths = uploadStatusStore.get("filePaths"); + const zipPaths = allZipPaths.filter((f) => existsSync(f)); + lsZip(); + } + + if (allZipEntries) "files"; const zipPaths = validSavedPaths("zips"); let files: ElectronFile[] = []; diff --git a/desktop/src/main/stores/upload-status.ts b/desktop/src/main/stores/upload-status.ts index 36a7d1fa72..edd086fbef 100644 --- a/desktop/src/main/stores/upload-status.ts +++ b/desktop/src/main/stores/upload-status.ts @@ -7,8 +7,10 @@ export interface UploadStatusStore { filePaths: string[]; /** * Each item is the path to a zip file and the name of an entry within it. + * + * This is marked optional since legacy stores will not have it. */ - zipEntries: [zipPath: string, entryName: string][]; + zipEntries?: [zipPath: string, entryName: string][]; /** Legacy paths to zip files, now subsumed into zipEntries */ zipPaths?: string[]; } diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 484a3bc0e2..ac149ad133 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -47,6 +47,7 @@ import type { ElectronFile, FolderWatch, PendingUploads, + ZipEntry, } from "./types/ipc"; // - General @@ -241,8 +242,8 @@ const watchFindFiles = (folderPath: string): Promise => // - Upload -const lsZip = (zipPath: string): Promise => - ipcRenderer.invoke("lsZip", zipPath); +const zipEntries = (zipPath: string): Promise => + ipcRenderer.invoke("zipEntries", zipPath); const pendingUploads = (): Promise => ipcRenderer.invoke("pendingUploads"); @@ -369,7 +370,7 @@ contextBridge.exposeInMainWorld("electron", { // - Upload - lsZip, + zipEntries, pendingUploads, setPendingUploads, markUploadedFiles, diff --git a/desktop/src/types/ipc.ts b/desktop/src/types/ipc.ts index f343e2bba3..307fb7de32 100644 --- a/desktop/src/types/ipc.ts +++ b/desktop/src/types/ipc.ts @@ -25,10 +25,12 @@ export interface FolderWatchSyncedFile { collectionID: number; } +export type ZipEntry = [zipPath: string, entryName: string]; + export interface PendingUploads { collectionName: string; filePaths: string[]; - zipEntries: [zipPath: string, entryName: string][]; + zipEntries: ZipEntry[]; } /** diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index 761cd72f1d..6aa394c6c5 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -470,19 +470,22 @@ export interface Electron { * * @param zipPath The path of the zip file on the user's local file system. * - * @returns A list of paths, one for each file in the given zip. Directories - * are traversed recursively, but the directory entries themselves will be - * excluded from the returned list. File entries whose file name begins with - * a dot (i.e. "hidden" files) will also be excluded. + * @returns A list of (zipPath, entryName) tuples, one for each file in the + * given zip. Directories are traversed recursively, but the directory + * entries themselves will be excluded from the returned list. File entries + * whose file name begins with a dot (i.e. "hidden" files) will also be + * excluded. * * To read the contents of the files themselves, see [Note: IPC streams]. */ - lsZip: (zipPath: string) => Promise; + zipEntries : (zipPath: string) => Promise /** * Return any pending uploads that were previously enqueued but haven't yet * been completed. * + * Return undefined if there are no such pending uploads. + * * The state of pending uploads is persisted in the Node.js layer. Or app * start, we read in this data from the Node.js layer via this IPC method. * The Node.js code returns the persisted data after filtering out any files @@ -493,10 +496,13 @@ export interface Electron { /** * Set the state of pending uploads. * - * Typically, this would be called at the start of an upload. Thereafter, as - * each item gets uploaded one by one, we'd call {@link markUploaded}. - * Finally, once the upload completes (or gets cancelled), we'd call - * {@link clearPendingUploads} to complete the circle. + * - Typically, this would be called at the start of an upload. + * + * - Thereafter, as each item gets uploaded one by one, we'd call + * {@link markUploadedFiles} or {@link markUploadedZipEntries}. + * + * - Finally, once the upload completes (or gets cancelled), we'd call + * {@link clearPendingUploads} to complete the circle. */ setPendingUploads: (pendingUploads: PendingUploads) => Promise; @@ -601,6 +607,17 @@ export interface FolderWatchSyncedFile { collectionID: number; } +/** + * When the user uploads a zip file, we create a "zip entry" for each entry + * within that zip file. Such an entry is a tuple containin the path to a zip + * file itself, and the name of an entry within it. + * + * The name of the entry is not just the file name, but rather is the full path + * of the file within the zip. That is, each entry name uniquely identifies a + * particular file within the given zip. + */ +export type ZipEntry = [zipPath: string, entryName: string]; + /** * State about pending and in-progress uploads. * @@ -623,12 +640,7 @@ export interface PendingUploads { */ filePaths: string[]; /** - * When the user uploads a zip file, we create a "zip entry" for each entry - * within that zip file. Such an entry is a tuple containin the path to a - * zip file itself, and the name of an entry within it. - * - * These are the remaining of those zip entries that still need to be - * uploaded. + * {@link ZipEntry} (zip path and entry name) that need to be uploaded. */ - zipEntries: [zipPath: string, entryName: string][]; + zipEntries: ZipEntry[]; } From 2fa1fcac655251b523d55785cdaed8bb222124eb Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 14:34:05 +0530 Subject: [PATCH 18/49] impl --- desktop/src/main/ipc.ts | 6 ++- desktop/src/main/services/upload.ts | 75 +++++------------------------ desktop/src/preload.ts | 6 +-- web/packages/next/types/ipc.ts | 2 +- 4 files changed, 21 insertions(+), 68 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index d7d4fdc098..a99a32d097 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -53,11 +53,11 @@ import { } from "./services/store"; import { clearPendingUploads, + listZipEntries, markUploadedFiles, markUploadedZipEntries, pendingUploads, setPendingUploads, - zipEntries, } from "./services/upload"; import { watchAdd, @@ -200,7 +200,9 @@ export const attachIPCHandlers = () => { // - Upload - ipcMain.handle("zipEntries", (_, zipPath: string) => zipEntries(zipPath)); + ipcMain.handle("listZipEntries", (_, zipPath: string) => + listZipEntries(zipPath), + ); ipcMain.handle("pendingUploads", () => pendingUploads()); diff --git a/desktop/src/main/services/upload.ts b/desktop/src/main/services/upload.ts index c5a987e7bc..8487f8327e 100644 --- a/desktop/src/main/services/upload.ts +++ b/desktop/src/main/services/upload.ts @@ -2,13 +2,10 @@ import StreamZip from "node-stream-zip"; import { existsSync } from "original-fs"; import path from "path"; import type { ElectronFile, PendingUploads, ZipEntry } from "../../types/ipc"; -import { - uploadStatusStore, - type UploadStatusStore, -} from "../stores/upload-status"; -import { getElectronFile, getZipFileStream } from "./fs"; +import { uploadStatusStore } from "../stores/upload-status"; +import { getZipFileStream } from "./fs"; -export const zipEntries = async (zipPath: string): Promise => { +export const listZipEntries = async (zipPath: string): Promise => { const zip = new StreamZip.async({ file: zipPath }); const entries = await zip.entries(); @@ -33,7 +30,9 @@ export const pendingUploads = async (): Promise => { const allFilePaths = uploadStatusStore.get("filePaths"); const filePaths = allFilePaths.filter((f) => existsSync(f)); - let allZipEntries = uploadStatusStore.get("zipEntries"); + const allZipEntries = uploadStatusStore.get("zipEntries"); + let zipEntries: typeof allZipEntries; + // Migration code - May 2024. Remove after a bit. // // The older store formats will not have zipEntries and instead will have @@ -44,34 +43,17 @@ export const pendingUploads = async (): Promise => { if (allZipEntries === undefined) { const allZipPaths = uploadStatusStore.get("filePaths"); const zipPaths = allZipPaths.filter((f) => existsSync(f)); - lsZip(); - } - - if (allZipEntries) "files"; - const zipPaths = validSavedPaths("zips"); - - let files: ElectronFile[] = []; - let type: PendingUploads["type"]; - - if (zipPaths.length) { - type = "zips"; - for (const zipPath of zipPaths) { - files = [ - ...files, - ...(await getElectronFilesFromGoogleZip(zipPath)), - ]; - } - const pendingFilePaths = new Set(filePaths); - files = files.filter((file) => pendingFilePaths.has(file.path)); - } else if (filePaths.length) { - type = "files"; - files = await Promise.all(filePaths.map(getElectronFile)); + zipEntries = []; + for (const zip of zipPaths) + zipEntries = zipEntries.concat(await listZipEntries(zip)); + } else { + zipEntries = allZipEntries.filter(([z]) => existsSync(z)); } return { - files, collectionName, - type, + filePaths, + zipEntries, }; }; @@ -94,39 +76,8 @@ export const markUploadedZipEntries = async ( uploadStatusStore.set("zipEntries", updated); }; -const validSavedPaths = (type: PendingUploads["type"]) => { - const key = storeKey(type); - const savedPaths = (uploadStatusStore.get(key) as string[]) ?? []; - const paths = savedPaths.filter((p) => existsSync(p)); - uploadStatusStore.set(key, paths); - return paths; -}; - -const setPendingUploadCollection = (collectionName: string) => { - if (collectionName) uploadStatusStore.set("collectionName", collectionName); - else uploadStatusStore.delete("collectionName"); -}; - -const setPendingUploadFiles = ( - type: PendingUploads["type"], - filePaths: string[], -) => { - const key = storeKey(type); - if (filePaths) uploadStatusStore.set(key, filePaths); - else uploadStatusStore.delete(key); -}; - export const clearPendingUploads = () => uploadStatusStore.clear(); -const storeKey = (type: PendingUploads["type"]): keyof UploadStatusStore => { - switch (type) { - case "zips": - return "zipPaths"; - case "files": - return "filePaths"; - } -}; - export const getElectronFilesFromGoogleZip = async (filePath: string) => { const zip = new StreamZip.async({ file: filePath, diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index ac149ad133..e80625de99 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -242,8 +242,8 @@ const watchFindFiles = (folderPath: string): Promise => // - Upload -const zipEntries = (zipPath: string): Promise => - ipcRenderer.invoke("zipEntries", zipPath); +const listZipEntries = (zipPath: string): Promise => + ipcRenderer.invoke("listZipEntries", zipPath); const pendingUploads = (): Promise => ipcRenderer.invoke("pendingUploads"); @@ -370,7 +370,7 @@ contextBridge.exposeInMainWorld("electron", { // - Upload - zipEntries, + listZipEntries, pendingUploads, setPendingUploads, markUploadedFiles, diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index 6aa394c6c5..7198a2ebce 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -478,7 +478,7 @@ export interface Electron { * * To read the contents of the files themselves, see [Note: IPC streams]. */ - zipEntries : (zipPath: string) => Promise + listZipEntries : (zipPath: string) => Promise /** * Return any pending uploads that were previously enqueued but haven't yet From d94f0a0f56c7689039674f9b43683ac6ee1ad720 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 15:08:46 +0530 Subject: [PATCH 19/49] wip --- .../photos/src/components/Upload/Uploader.tsx | 78 ++++++++----------- web/packages/next/types/file.ts | 10 +++ 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index 2bf1e79d20..1f36216296 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -1,6 +1,6 @@ import log from "@/next/log"; -import { ElectronFile } from "@/next/types/file"; -import type { CollectionMapping, Electron } from "@/next/types/ipc"; +import { ElectronFile, type FileAndPath } from "@/next/types/file"; +import type { CollectionMapping, ZipEntry } from "@/next/types/ipc"; import { CustomError } from "@ente/shared/error"; import { isPromise } from "@ente/shared/utils"; import DiscFullIcon from "@mui/icons-material/DiscFull"; @@ -123,21 +123,40 @@ export default function Uploader(props: Props) { * browser. */ const [webFiles, setWebFiles] = useState([]); + /** + * {@link File}s that the user drag-dropped or selected for uploads, + * augmented with their paths. These siblings of {@link webFiles} come into + * play when we are running in the context of our desktop app. + */ + const [desktopFiles, setDesktopFiles] = useState([]); /** * Paths of file to upload that we've received over the IPC bridge from the * code running in the Node.js layer of our desktop app. + * + * Unlike {@link filesWithPaths} which are still user initiated, + * {@link desktopFilePaths} can be set via programmatic action. For example, + * if the user has setup a folder watch, and a new file is added on their + * local filesystem in one of the watched folders, then the relevant path of + * the new file would get added to {@link desktopFilePaths}. */ const [desktopFilePaths, setDesktopFilePaths] = useState([]); /** - * TODO(MR): When? + * (zip file path, entry within zip file) tuples for zip files that the user + * is trying to upload. These are only set when we are running in the + * context of our desktop app. They may be set either on a user action (when + * the user selects or drag-drops zip files) or programmatically (when the + * app is trying to resume pending uploads from a previous session). */ - const [electronFiles, setElectronFiles] = useState([]); + const [desktopZipEntries, setDesktopZipEntries] = useState([]); /** - * Consolidated and cleaned list obtained from {@link webFiles} and - * {@link desktopFilePaths}. + * Consolidated and cleaned list obtained from {@link webFiles}, + * {@link desktopFiles}, {@link desktopFilePaths} and + * {@link desktopZipEntries}. */ - const fileOrPathsToUpload = useRef<(File | string)[]>([]); + const itemsToUpload = useRef<(File | FileAndPath | string | ZipEntry)[]>( + [], + ); /** * If true, then the next upload we'll be processing was initiated by our @@ -151,9 +170,12 @@ export default function Uploader(props: Props) { */ const pendingDesktopUploadCollectionName = useRef(""); - // This is set when the user choses a type to upload from the upload type selector dialog + /** + * This is set to thue user's choice when the user chooses one of the + * predefined type to upload from the upload type selector dialog + */ const pickedUploadType = useRef(null); - const zipPaths = useRef(null); + const currentUploadPromise = useRef>(null); const uploadRunning = useRef(false); const uploaderNameRef = useRef(null); @@ -778,31 +800,11 @@ export default function Uploader(props: Props) { } }; - const handleDesktopUpload = async ( - type: PICKED_UPLOAD_TYPE, - electron: Electron, - ) => { - let files: ElectronFile[]; - pickedUploadType.current = type; - if (type === PICKED_UPLOAD_TYPE.FILES) { - files = await electron.showUploadFilesDialog(); - } else if (type === PICKED_UPLOAD_TYPE.FOLDERS) { - files = await electron.showUploadDirsDialog(); - } else { - const response = await electron.showUploadZipDialog(); - files = response.files; - zipPaths.current = response.zipPaths; - } - if (files?.length > 0) { - log.info( - ` desktop upload for type:${type} and fileCount: ${files?.length} requested`, - ); - setElectronFiles(files); - props.closeUploadTypeSelector(); - } + const cancelUploads = () => { + uploadManager.cancelRunningUpload(); }; - const handleWebUpload = async (type: PICKED_UPLOAD_TYPE) => { + const handleUpload = async (type: PICKED_UPLOAD_TYPE) => { pickedUploadType.current = type; if (type === PICKED_UPLOAD_TYPE.FILES) { props.showUploadFilesDialog(); @@ -817,18 +819,6 @@ export default function Uploader(props: Props) { } }; - const cancelUploads = () => { - uploadManager.cancelRunningUpload(); - }; - - const handleUpload = (type) => () => { - if (electron) { - handleDesktopUpload(type, electron); - } else { - handleWebUpload(type); - } - }; - const handleFileUpload = handleUpload(PICKED_UPLOAD_TYPE.FILES); const handleFolderUpload = handleUpload(PICKED_UPLOAD_TYPE.FOLDERS); const handleZipUpload = handleUpload(PICKED_UPLOAD_TYPE.ZIPS); diff --git a/web/packages/next/types/file.ts b/web/packages/next/types/file.ts index 75641e3a27..5d6b62550d 100644 --- a/web/packages/next/types/file.ts +++ b/web/packages/next/types/file.ts @@ -16,6 +16,16 @@ export interface ElectronFile { arrayBuffer: () => Promise; } +/** + * When we are running in the context of our desktop app, we have access to the + * absolute path of the file under consideration. This type combines these two + * bits of information to remove the need to query it again and again. + */ +export interface FileAndPath { + file: File; + path: string; +} + export interface EventQueueItem { type: "upload" | "trash"; folderPath: string; From 864a53afa2c7f70f14a8cbf30719e6fa9a3b8554 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 15:13:52 +0530 Subject: [PATCH 20/49] more --- .../photos/src/components/Upload/Uploader.tsx | 50 +++++++------------ web/apps/photos/src/pages/gallery/index.tsx | 30 +++++------ .../photos/src/pages/shared-albums/index.tsx | 16 +++--- 3 files changed, 43 insertions(+), 53 deletions(-) diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index 1f36216296..d8087eb5b0 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -74,13 +74,13 @@ interface Props { isFirstUpload?: boolean; uploadTypeSelectorView: boolean; showSessionExpiredMessage: () => void; - showUploadFilesDialog: () => void; - showUploadDirsDialog: () => void; - showUploadZipFilesDialog?: () => void; - webFolderSelectorFiles: File[]; - webFileSelectorFiles: File[]; - webFileSelectorZipFiles?: File[]; dragAndDropFiles: File[]; + openFileSelector: () => void; + fileSelectorFiles: File[]; + openFolderSelector: () => void; + folderSelectorFiles: File[]; + openZipFileSelector?: () => void; + fileSelectorZipFiles?: File[]; uploadCollection?: Collection; uploadTypeSelectorIntent: UploadTypeSelectorIntent; activeCollection?: Collection; @@ -239,16 +239,16 @@ export default function Uploader(props: Props) { watcher.init(upload, requestSyncWithRemote); electron.pendingUploads().then((pending) => { - if (pending) { - log.info("Resuming pending desktop upload", pending); - resumeDesktopUpload( - pending.type == "files" - ? PICKED_UPLOAD_TYPE.FILES - : PICKED_UPLOAD_TYPE.ZIPS, - pending.files, - pending.collectionName, - ); - } + if (!pending) return; + + const { collectionName, filePaths, zipEntries } = pending; + if (filePaths.length == 0 && zipEntries.length == 0) return; + + log.info("Resuming pending upload", pending); + isPendingDesktopUpload.current = true; + pendingDesktopUploadCollectionName.current = collectionName; + setDesktopFilePaths(filePaths); + setDesktopZipEntries(zipEntries); }); } }, [ @@ -258,9 +258,8 @@ export default function Uploader(props: Props) { appContext.isCFProxyDisabled, ]); - // this handles the change of selectorFiles changes on web when user selects - // files for upload through the opened file/folder selector or dragAndDrop them - // the webFiles state is update which triggers the upload of those files + // Handle selected files when user selects files for upload through the open + // file / open folder selection dialog, or drag-and-drops them. useEffect(() => { if (appContext.watchFolderView) { // if watch folder dialog is open don't catch the dropped file @@ -463,19 +462,6 @@ export default function Uploader(props: Props) { } }, [webFiles, appContext.sharedFiles, electronFiles, desktopFilePaths]); - const resumeDesktopUpload = async ( - type: PICKED_UPLOAD_TYPE, - electronFiles: ElectronFile[], - collectionName: string, - ) => { - if (electronFiles && electronFiles?.length > 0) { - isPendingDesktopUpload.current = true; - pendingDesktopUploadCollectionName.current = collectionName; - pickedUploadType.current = type; - setElectronFiles(electronFiles); - } - }; - const preCollectionCreationAction = async () => { props.closeCollectionSelector?.(); props.setShouldDisableDropzone(!uploadManager.shouldAllowNewUpload()); diff --git a/web/apps/photos/src/pages/gallery/index.tsx b/web/apps/photos/src/pages/gallery/index.tsx index 658f62b459..70b48c3cc6 100644 --- a/web/apps/photos/src/pages/gallery/index.tsx +++ b/web/apps/photos/src/pages/gallery/index.tsx @@ -211,26 +211,26 @@ export default function Gallery() { disabled: shouldDisableDropzone, }); const { - selectedFiles: webFileSelectorFiles, + selectedFiles: fileSelectorFiles, open: openFileSelector, getInputProps: getFileSelectorInputProps, } = useFileInput({ directory: false, }); const { - selectedFiles: webFolderSelectorFiles, + selectedFiles: folderSelectorFiles, open: openFolderSelector, getInputProps: getFolderSelectorInputProps, } = useFileInput({ directory: true, }); const { - selectedFiles: webFileSelectorZipFiles, + selectedFiles: fileSelectorZipFiles, open: openZipFileSelector, getInputProps: getZipFileSelectorInputProps, } = useFileInput({ directory: false, - accept: ".zip" + accept: ".zip", }); const [isInSearchMode, setIsInSearchMode] = useState(false); @@ -1121,7 +1121,6 @@ export default function Gallery() { null, false, )} - uploadTypeSelectorIntent={uploadTypeSelectorIntent} setLoading={setBlockingLoad} setCollectionNamerAttributes={setCollectionNamerAttributes} setShouldDisableDropzone={setShouldDisableDropzone} @@ -1130,15 +1129,18 @@ export default function Gallery() { isFirstUpload={ !hasNonSystemCollections(collectionSummaries) } - webFileSelectorFiles={webFileSelectorFiles} - webFolderSelectorFiles={webFolderSelectorFiles} - webFileSelectorZipFiles={webFileSelectorZipFiles} - dragAndDropFiles={dragAndDropFiles} - uploadTypeSelectorView={uploadTypeSelectorView} - showUploadFilesDialog={openFileSelector} - showUploadDirsDialog={openFolderSelector} - showUploadZipFilesDialog={openZipFileSelector} - showSessionExpiredMessage={showSessionExpiredMessage} + {...{ + dragAndDropFiles, + openFileSelector, + fileSelectorFiles, + openFolderSelector, + folderSelectorFiles, + openZipFileSelector, + fileSelectorZipFiles, + uploadTypeSelectorIntent, + uploadTypeSelectorView, + showSessionExpiredMessage, + }} /> Date: Mon, 29 Apr 2024 15:43:17 +0530 Subject: [PATCH 21/49] more --- desktop/src/preload.ts | 5 +- .../photos/src/components/Upload/Uploader.tsx | 149 ++++++------------ web/packages/next/types/file.ts | 5 +- web/packages/next/types/ipc.ts | 14 +- 4 files changed, 67 insertions(+), 106 deletions(-) diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index e80625de99..4bb23b9ac6 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -37,7 +37,7 @@ * - [main] desktop/src/main/ipc.ts contains impl */ -import { contextBridge, ipcRenderer } from "electron/renderer"; +import { contextBridge, ipcRenderer, webUtils } from "electron/renderer"; // While we can't import other code, we can import types since they're just // needed when compiling and will not be needed or looked around for at runtime. @@ -242,6 +242,8 @@ const watchFindFiles = (folderPath: string): Promise => // - Upload +const pathForFile = (file: File) => webUtils.getPathForFile(file); + const listZipEntries = (zipPath: string): Promise => ipcRenderer.invoke("listZipEntries", zipPath); @@ -370,6 +372,7 @@ contextBridge.exposeInMainWorld("electron", { // - Upload + pathForFile, listZipEntries, pendingUploads, setPendingUploads, diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index d8087eb5b0..41b9f48540 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -1,6 +1,6 @@ import log from "@/next/log"; import { ElectronFile, type FileAndPath } from "@/next/types/file"; -import type { CollectionMapping, ZipEntry } from "@/next/types/ipc"; +import type { CollectionMapping, Electron, ZipEntry } from "@/next/types/ipc"; import { CustomError } from "@ente/shared/error"; import { isPromise } from "@ente/shared/utils"; import DiscFullIcon from "@mui/icons-material/DiscFull"; @@ -86,7 +86,13 @@ interface Props { activeCollection?: Collection; } -export default function Uploader(props: Props) { +export default function Uploader({ + dragAndDropFiles, + fileSelectorFiles, + folderSelectorFiles, + fileSelectorZipFiles, + ...props +}: Props) { const appContext = useContext(AppContext); const galleryContext = useContext(GalleryContext); const publicCollectionGalleryContext = useContext( @@ -266,108 +272,28 @@ export default function Uploader(props: Props) { // as they are folder being dropped for watching return; } - if ( - pickedUploadType.current === PICKED_UPLOAD_TYPE.FOLDERS && - props.webFolderSelectorFiles?.length > 0 - ) { - log.info(`received folder upload request`); - setWebFiles(props.webFolderSelectorFiles); - } else if ( - pickedUploadType.current === PICKED_UPLOAD_TYPE.FILES && - props.webFileSelectorFiles?.length > 0 - ) { - log.info(`received file upload request`); - setWebFiles(props.webFileSelectorFiles); - } else if ( - pickedUploadType.current === PICKED_UPLOAD_TYPE.ZIPS && - props.webFileSelectorZipFiles?.length > 0 - ) { - if (electron) { - const main = async () => { - const zips: File[] = []; - let electronFiles = [] as ElectronFile[]; - for (const file of props.webFileSelectorZipFiles) { - if (file.name.endsWith(".zip")) { - const zipFiles = await electron.lsZip( - (file as any).path, - ); - log.info( - `zip file - ${file.name} contains ${zipFiles.length} files`, - ); - zips.push(file); - // TODO(MR): This cast is incorrect, but interim. - electronFiles = [ - ...electronFiles, - ...(zipFiles as unknown as ElectronFile[]), - ]; - } - } - // setWebFiles(props.webFileSelectorZipFiles); - zipPaths.current = zips.map((file) => (file as any).path); - setElectronFiles(electronFiles); - }; - main(); - } - } else if (props.dragAndDropFiles?.length > 0) { - isDragAndDrop.current = true; - if (electron) { - const main = async () => { - try { - // check and parse dropped files which are zip files - log.info(`uploading dropped files from desktop app`); - const zips: File[] = []; - let electronFiles = [] as ElectronFile[]; - for (const file of props.dragAndDropFiles) { - if (file.name.endsWith(".zip")) { - const zipFiles = await electron.lsZip( - (file as any).path, - ); - log.info( - `zip file - ${file.name} contains ${zipFiles.length} files`, - ); - zips.push(file); - // TODO(MR): This cast is incorrect, but interim. - electronFiles = [ - ...electronFiles, - ...(zipFiles as unknown as ElectronFile[]), - ]; - } else { - // type cast to ElectronFile as the file is dropped from desktop app - // type file and ElectronFile should be interchangeable, but currently they have some differences. - // Typescript is giving error - // Conversion of type 'File' to type 'ElectronFile' may be a mistake because neither type sufficiently - // overlaps with the other. If this was intentional, convert the expression to 'unknown' first. - // Type 'File' is missing the following properties from type 'ElectronFile': path, blob - // for now patching by type casting first to unknown and then to ElectronFile - // TODO: fix types and remove type cast - electronFiles.push( - file as unknown as ElectronFile, - ); - } - } - log.info( - `uploading dropped files from desktop app - ${electronFiles.length} files found`, - ); - zipPaths.current = zips.map( - (file) => (file as any).path, - ); - setElectronFiles(electronFiles); - } catch (e) { - log.error("failed to upload desktop dropped files", e); - setWebFiles(props.dragAndDropFiles); - } - }; - main(); - } else { - log.info(`uploading dropped files from web app`); - setWebFiles(props.dragAndDropFiles); - } + + const files = [ + dragAndDropFiles, + fileSelectorFiles, + folderSelectorFiles, + fileSelectorZipFiles, + ].flat(); + if (electron) { + desktopFilesAndZipEntries(electron, files).then( + ({ fileAndPaths, zipEntries }) => { + setDesktopFiles(fileAndPaths); + setDesktopZipEntries(zipEntries); + }, + ); + } else { + setWebFiles(files); } }, [ - props.dragAndDropFiles, - props.webFileSelectorFiles, - props.webFolderSelectorFiles, - props.webFileSelectorZipFiles, + dragAndDropFiles, + fileSelectorFiles, + folderSelectorFiles, + fileSelectorZipFiles, ]); useEffect(() => { @@ -905,6 +831,25 @@ async function waitAndRun( await task(); } +const desktopFilesAndZipEntries = async ( + electron: Electron, + files: File[], +): Promise<{ fileAndPaths: FileAndPath[]; zipEntries: ZipEntry[] }> => { + const fileAndPaths: FileAndPath[] = []; + const zipEntries: ZipEntry[] = []; + + for (const file of files) { + const path = electron.pathForFile(file); + if (file.name.endsWith(".zip")) { + zipEntries = zipEntries.concat(await electron.listZipEntries(path)); + } else { + fileAndPaths.push({ file, path }); + } + } + + return { fileAndPaths, zipEntries }; +}; + // This is used to prompt the user the make upload strategy choice interface ImportSuggestion { rootFolderName: string; diff --git a/web/packages/next/types/file.ts b/web/packages/next/types/file.ts index 5d6b62550d..6dd1032cdb 100644 --- a/web/packages/next/types/file.ts +++ b/web/packages/next/types/file.ts @@ -18,8 +18,9 @@ export interface ElectronFile { /** * When we are running in the context of our desktop app, we have access to the - * absolute path of the file under consideration. This type combines these two - * bits of information to remove the need to query it again and again. + * absolute path of {@link File} objects. This convenience type clubs these two + * bits of information, saving us the need to query the path again and again + * using the {@link getPathForFile} method of {@link Electron}. */ export interface FileAndPath { file: File; diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index 7198a2ebce..dab10cc8e0 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -465,6 +465,18 @@ export interface Electron { // - Upload + /** + * Return the file system path that this File object points to. + * + * This method is a bit different from the other methods on the Electron + * object in the sense that there is no actual IPC happening - the + * implementation of this method is completely in the preload script. Thus + * we can pass it an otherwise unserializable File object. + * + * Consequently, it is also _not_ async. + */ + pathForFile: (file: File) => string; + /** * Get the list of files that are present in the given zip file. * @@ -478,7 +490,7 @@ export interface Electron { * * To read the contents of the files themselves, see [Note: IPC streams]. */ - listZipEntries : (zipPath: string) => Promise + listZipEntries: (zipPath: string) => Promise; /** * Return any pending uploads that were previously enqueued but haven't yet From 0fbafcc4f5f93152128492c3fbd1a31e04e47203 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 16:33:04 +0530 Subject: [PATCH 22/49] Remove unused sharedFiles app context prop setSharedFiles was removed in 3b468cb1545e5ee5065fa89e06f5738c4fd0c06f (years ago). --- .../photos/src/components/Upload/Uploader.tsx | 15 +++++--------- web/apps/photos/src/pages/_app.tsx | 20 ------------------- 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index 41b9f48540..7f529eef9d 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -198,7 +198,6 @@ export default function Uploader({ }; const handleCollectionSelectorCancel = () => { uploadRunning.current = false; - appContext.resetSharedFiles(); }; const handleUserNameInputDialogClose = () => { @@ -298,10 +297,10 @@ export default function Uploader({ useEffect(() => { if ( + webFiles.length > 0 || desktopFilePaths.length > 0 || electronFiles.length > 0 || - webFiles.length > 0 || - appContext.sharedFiles?.length > 0 + ) { log.info( `upload request type: ${ @@ -311,12 +310,11 @@ export default function Uploader({ ? "electronFiles" : webFiles.length > 0 ? "webFiles" - : "sharedFiles" + : "-" } count ${ desktopFilePaths.length + electronFiles.length + - webFiles.length + - (appContext.sharedFiles?.length ?? 0) + webFiles.length }`, ); if (uploadManager.isUploadRunning()) { @@ -340,9 +338,6 @@ export default function Uploader({ // File selection by drag and drop or selection of file. fileOrPathsToUpload.current = webFiles; setWebFiles([]); - } else if (appContext.sharedFiles?.length > 0) { - fileOrPathsToUpload.current = appContext.sharedFiles; - appContext.resetSharedFiles(); } else if (electronFiles?.length > 0) { // File selection from desktop app - deprecated log.warn("Using deprecated code path for ElectronFiles"); @@ -386,7 +381,7 @@ export default function Uploader({ pickedUploadType.current = null; props.setLoading(false); } - }, [webFiles, appContext.sharedFiles, electronFiles, desktopFilePaths]); + }, [webFiles, , electronFiles, desktopFilePaths]); const preCollectionCreationAction = async () => { props.closeCollectionSelector?.(); diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index f3994d0817..0e80d0df9f 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -80,8 +80,6 @@ const redirectMap = new Map([ type AppContextType = { showNavBar: (show: boolean) => void; - sharedFiles: File[]; - resetSharedFiles: () => void; mlSearchEnabled: boolean; mapEnabled: boolean; updateMlSearchEnabled: (enabled: boolean) => Promise; @@ -114,7 +112,6 @@ export default function App({ Component, pageProps }: AppProps) { typeof window !== "undefined" && !window.navigator.onLine, ); const [showNavbar, setShowNavBar] = useState(false); - const [sharedFiles, setSharedFiles] = useState(null); const [redirectName, setRedirectName] = useState(null); const [mlSearchEnabled, setMlSearchEnabled] = useState(false); const [mapEnabled, setMapEnabled] = useState(false); @@ -227,7 +224,6 @@ export default function App({ Component, pageProps }: AppProps) { const setUserOnline = () => setOffline(false); const setUserOffline = () => setOffline(true); - const resetSharedFiles = () => setSharedFiles(null); useEffect(() => { const redirectTo = async (redirect) => { @@ -354,20 +350,6 @@ export default function App({ Component, pageProps }: AppProps) { {isI18nReady && offline && t("OFFLINE_MSG")} - {sharedFiles && - (router.pathname === "/gallery" ? ( - - {t("files_to_be_uploaded", { - count: sharedFiles.length, - })} - - ) : ( - - {t("login_to_upload_files", { - count: sharedFiles.length, - })} - - ))} Date: Mon, 29 Apr 2024 17:13:15 +0530 Subject: [PATCH 23/49] Partial integration --- .../photos/src/components/Upload/Uploader.tsx | 177 ++++++++---------- 1 file changed, 78 insertions(+), 99 deletions(-) diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index 7f529eef9d..8929bb60f3 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -1,3 +1,4 @@ +import { basename } from "@/next/file"; import log from "@/next/log"; import { ElectronFile, type FileAndPath } from "@/next/types/file"; import type { CollectionMapping, Electron, ZipEntry } from "@/next/types/ipc"; @@ -29,7 +30,6 @@ import type { import uploadManager, { setToUploadCollection, } from "services/upload/uploadManager"; -import { fopFileName } from "services/upload/uploadService"; import watcher from "services/watch"; import { NotificationAttributes } from "types/Notification"; import { Collection } from "types/collection"; @@ -88,8 +88,11 @@ interface Props { export default function Uploader({ dragAndDropFiles, + openFileSelector, fileSelectorFiles, + openFolderSelector, folderSelectorFiles, + openZipFileSelector, fileSelectorZipFiles, ...props }: Props) { @@ -164,6 +167,9 @@ export default function Uploader({ [], ); + // TODO(MR): temp, doesn't have zips + const fileOrPathsToUpload = useRef<(File | string)[]>([]); + /** * If true, then the next upload we'll be processing was initiated by our * desktop app. @@ -295,93 +301,74 @@ export default function Uploader({ fileSelectorZipFiles, ]); + // Trigger an upload when any of the dependencies change. useEffect(() => { - if ( - webFiles.length > 0 || - desktopFilePaths.length > 0 || - electronFiles.length > 0 || + const itemAndPaths = [ + /* TODO(MR): use webkitRelativePath || name here */ + webFiles.map((f) => [f, f["path"]]), + desktopFiles.map((fp) => [fp, fp.path]), + desktopFilePaths.map((p) => [p, p]), + desktopZipEntries.map((ze) => [ze, ze[1]]), + ].flat(); - ) { - log.info( - `upload request type: ${ - desktopFilePaths.length > 0 - ? "desktopFilePaths" - : electronFiles.length > 0 - ? "electronFiles" - : webFiles.length > 0 - ? "webFiles" - : "-" - } count ${ - desktopFilePaths.length + - electronFiles.length + - webFiles.length - }`, - ); - if (uploadManager.isUploadRunning()) { - if (watcher.isUploadRunning()) { - // Pause watch folder sync on user upload - log.info( - "Folder watcher was uploading, pausing it to first run user upload", - ); - watcher.pauseRunningSync(); - } else { - log.info( - "Ignoring new upload request because an upload is already running", - ); - return; - } - } - uploadRunning.current = true; - props.closeUploadTypeSelector(); - props.setLoading(true); - if (webFiles?.length > 0) { - // File selection by drag and drop or selection of file. - fileOrPathsToUpload.current = webFiles; - setWebFiles([]); - } else if (electronFiles?.length > 0) { - // File selection from desktop app - deprecated - log.warn("Using deprecated code path for ElectronFiles"); - fileOrPathsToUpload.current = electronFiles.map((f) => f.path); - setElectronFiles([]); - } else if (desktopFilePaths && desktopFilePaths.length > 0) { - // File selection from our desktop app - fileOrPathsToUpload.current = desktopFilePaths; - setDesktopFilePaths([]); - } + if (itemAndPaths.length == 0) return; - log.debug(() => "Uploader invoked"); - log.debug(() => fileOrPathsToUpload.current); - - fileOrPathsToUpload.current = pruneHiddenFiles( - fileOrPathsToUpload.current, - ); - - if (fileOrPathsToUpload.current.length === 0) { - props.setLoading(false); + if (uploadManager.isUploadRunning()) { + if (watcher.isUploadRunning()) { + log.info("Pausing watch folder sync to prioritize user upload"); + watcher.pauseRunningSync(); + } else { + log.info( + "Ignoring new upload request when upload is already running", + ); return; } - - const importSuggestion = getImportSuggestion( - pickedUploadType.current, - fileOrPathsToUpload.current.map((file) => - /** TODO(MR): Is path valid for Web files? */ - typeof file == "string" ? file : file["path"], - ), - ); - setImportSuggestion(importSuggestion); - - log.debug(() => importSuggestion); - - handleCollectionCreationAndUpload( - importSuggestion, - props.isFirstUpload, - pickedUploadType.current, - publicCollectionGalleryContext.accessedThroughSharedURL, - ); - pickedUploadType.current = null; - props.setLoading(false); } - }, [webFiles, , electronFiles, desktopFilePaths]); + uploadRunning.current = true; + props.closeUploadTypeSelector(); + props.setLoading(true); + + setWebFiles([]); + setDesktopFiles([]); + setDesktopFilePaths([]); + setDesktopZipEntries([]); + + // Remove hidden files (files whose names begins with a "."). + const prunedItemAndPaths = itemAndPaths.filter( + ([_, p]) => !basename(p).startsWith("."), + ); + + itemsToUpload.current = prunedItemAndPaths.map(([i]) => i); + fileOrPathsToUpload.current = itemsToUpload.current.map((i) => { + if (typeof i == "string" || i instanceof File) return i; + if (Array.isArray(i)) return undefined; + return i.file; + }).filter((x) => x); + itemsToUpload.current = []; + if (fileOrPathsToUpload.current.length === 0) { + props.setLoading(false); + return; + } + + const importSuggestion = getImportSuggestion( + pickedUploadType.current, + prunedItemAndPaths.map(([_, p]) => p), + ); + setImportSuggestion(importSuggestion); + + log.debug(() => "Uploader invoked:"); + log.debug(() => fileOrPathsToUpload.current); + log.debug(() => importSuggestion); + + handleCollectionCreationAndUpload( + importSuggestion, + props.isFirstUpload, + pickedUploadType.current, + publicCollectionGalleryContext.accessedThroughSharedURL, + ); + pickedUploadType.current = null; + props.setLoading(false); + }, [webFiles, desktopFiles, desktopFilePaths, desktopZipEntries]); const preCollectionCreationAction = async () => { props.closeCollectionSelector?.(); @@ -711,24 +698,24 @@ export default function Uploader({ uploadManager.cancelRunningUpload(); }; - const handleUpload = async (type: PICKED_UPLOAD_TYPE) => { + const handleUpload = (type: PICKED_UPLOAD_TYPE) => { pickedUploadType.current = type; if (type === PICKED_UPLOAD_TYPE.FILES) { - props.showUploadFilesDialog(); + openFileSelector(); } else if (type === PICKED_UPLOAD_TYPE.FOLDERS) { - props.showUploadDirsDialog(); + openFolderSelector(); } else { - if (props.showUploadZipFilesDialog && electron) { - props.showUploadZipFilesDialog(); + if (openZipFileSelector && electron) { + openZipFileSelector(); } else { appContext.setDialogMessage(getDownloadAppMessage()); } } }; - const handleFileUpload = handleUpload(PICKED_UPLOAD_TYPE.FILES); - const handleFolderUpload = handleUpload(PICKED_UPLOAD_TYPE.FOLDERS); - const handleZipUpload = handleUpload(PICKED_UPLOAD_TYPE.ZIPS); + const handleFileUpload = () => handleUpload(PICKED_UPLOAD_TYPE.FILES); + const handleFolderUpload = () => handleUpload(PICKED_UPLOAD_TYPE.FOLDERS); + const handleZipUpload = () => handleUpload(PICKED_UPLOAD_TYPE.ZIPS); const handlePublicUpload = async ( uploaderName: string, @@ -831,7 +818,7 @@ const desktopFilesAndZipEntries = async ( files: File[], ): Promise<{ fileAndPaths: FileAndPath[]; zipEntries: ZipEntry[] }> => { const fileAndPaths: FileAndPath[] = []; - const zipEntries: ZipEntry[] = []; + let zipEntries: ZipEntry[] = []; for (const file of files) { const path = electron.pathForFile(file); @@ -936,11 +923,3 @@ const groupFilesBasedOnParentFolder = (fileOrPaths: (File | string)[]) => { } return result; }; - -/** - * Filter out hidden files from amongst {@link fileOrPaths}. - * - * Hidden files are those whose names begin with a "." (dot). - */ -const pruneHiddenFiles = (fileOrPaths: (File | string)[]) => - fileOrPaths.filter((f) => !fopFileName(f).startsWith(".")); From cca33074fbed075aebe031cd5a35053668f73b00 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 19:29:52 +0530 Subject: [PATCH 24/49] Pending uploads --- desktop/src/main/services/upload.ts | 5 +- desktop/src/main/stores/upload-status.ts | 4 +- .../photos/src/components/Upload/Uploader.tsx | 81 ++++++++++++------- 3 files changed, 58 insertions(+), 32 deletions(-) diff --git a/desktop/src/main/services/upload.ts b/desktop/src/main/services/upload.ts index 8487f8327e..1f52fe1e7c 100644 --- a/desktop/src/main/services/upload.ts +++ b/desktop/src/main/services/upload.ts @@ -25,9 +25,8 @@ export const listZipEntries = async (zipPath: string): Promise => { export const pendingUploads = async (): Promise => { const collectionName = uploadStatusStore.get("collectionName"); - if (!collectionName) return undefined; - const allFilePaths = uploadStatusStore.get("filePaths"); + const allFilePaths = uploadStatusStore.get("filePaths") ?? []; const filePaths = allFilePaths.filter((f) => existsSync(f)); const allZipEntries = uploadStatusStore.get("zipEntries"); @@ -50,6 +49,8 @@ export const pendingUploads = async (): Promise => { zipEntries = allZipEntries.filter(([z]) => existsSync(z)); } + if (filePaths.length == 0 && zipEntries.length == 0) return undefined; + return { collectionName, filePaths, diff --git a/desktop/src/main/stores/upload-status.ts b/desktop/src/main/stores/upload-status.ts index edd086fbef..20a431fd9b 100644 --- a/desktop/src/main/stores/upload-status.ts +++ b/desktop/src/main/stores/upload-status.ts @@ -2,9 +2,9 @@ import Store, { Schema } from "electron-store"; export interface UploadStatusStore { /* The collection to which we're uploading, or the root collection. */ - collectionName: string; + collectionName?: string; /** Paths to regular files that are pending upload */ - filePaths: string[]; + filePaths?: string[]; /** * Each item is the path to a zip file and the name of an entry within it. * diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index 8929bb60f3..672c418b26 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -1,6 +1,6 @@ import { basename } from "@/next/file"; import log from "@/next/log"; -import { ElectronFile, type FileAndPath } from "@/next/types/file"; +import { type FileAndPath } from "@/next/types/file"; import type { CollectionMapping, Electron, ZipEntry } from "@/next/types/ipc"; import { CustomError } from "@ente/shared/error"; import { isPromise } from "@ente/shared/utils"; @@ -27,9 +27,7 @@ import type { UploadCounter, UploadFileNames, } from "services/upload/uploadManager"; -import uploadManager, { - setToUploadCollection, -} from "services/upload/uploadManager"; +import uploadManager from "services/upload/uploadManager"; import watcher from "services/watch"; import { NotificationAttributes } from "types/Notification"; import { Collection } from "types/collection"; @@ -253,8 +251,6 @@ export default function Uploader({ if (!pending) return; const { collectionName, filePaths, zipEntries } = pending; - if (filePaths.length == 0 && zipEntries.length == 0) return; - log.info("Resuming pending upload", pending); isPendingDesktopUpload.current = true; pendingDesktopUploadCollectionName.current = collectionName; @@ -339,11 +335,13 @@ export default function Uploader({ ); itemsToUpload.current = prunedItemAndPaths.map(([i]) => i); - fileOrPathsToUpload.current = itemsToUpload.current.map((i) => { - if (typeof i == "string" || i instanceof File) return i; - if (Array.isArray(i)) return undefined; - return i.file; - }).filter((x) => x); + fileOrPathsToUpload.current = itemsToUpload.current + .map((i) => { + if (typeof i == "string" || i instanceof File) return i; + if (Array.isArray(i)) return undefined; + return i.file; + }) + .filter((x) => x); itemsToUpload.current = []; if (fileOrPathsToUpload.current.length === 0) { props.setLoading(false); @@ -515,23 +513,10 @@ export default function Uploader({ !isPendingDesktopUpload.current && !watcher.isUploadRunning() ) { - await setToUploadCollection(collections); - if (zipPaths.current) { - await electron.setPendingUploadFiles( - "zips", - zipPaths.current, - ); - zipPaths.current = null; - } - await electron.setPendingUploadFiles( - "files", - filesWithCollectionToUploadIn.map( - // TODO(MR): ElectronFile - ({ fileOrPath }) => - typeof fileOrPath == "string" - ? fileOrPath - : (fileOrPath as any as ElectronFile).path, - ), + setPendingUploads( + electron, + collections, + filesWithCollectionToUploadIn, ); } const wereFilesProcessed = await uploadManager.uploadFiles( @@ -923,3 +908,43 @@ const groupFilesBasedOnParentFolder = (fileOrPaths: (File | string)[]) => { } return result; }; + +export const setPendingUploads = async ( + electron: Electron, + collections: Collection[], + filesWithCollectionToUploadIn: FileWithCollection[], +) => { + let collectionName: string | undefined; + /* collection being one suggest one of two things + 1. Either the user has upload to a single existing collection + 2. Created a new single collection to upload to + may have had multiple folder, but chose to upload + to one album + hence saving the collection name when upload collection count is 1 + helps the info of user choosing this options + and on next upload we can directly start uploading to this collection + */ + if (collections.length === 1) { + collectionName = collections[0].name; + } + + const filePaths: string[] = []; + const zipEntries: ZipEntry[] = []; + for (const file of filesWithCollectionToUploadIn) { + if (file instanceof File) { + throw new Error("Unexpected web file for a desktop pending upload"); + } else if (typeof file == "string") { + filePaths.push(file); + } else if (Array.isArray(file)) { + zipEntries.push(file); + } else { + filePaths.push(file.path); + } + } + + await electron.setPendingUploads({ + collectionName, + filePaths, + zipEntries, + }); +}; From 3ef727537c7a0057cafd802c2f553efe66cfbb55 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 19:46:50 +0530 Subject: [PATCH 25/49] UploadItem --- .../photos/src/components/Upload/Uploader.tsx | 4 +- .../src/services/upload/uploadManager.ts | 47 +++++++++++-------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index 672c418b26..14b0c79c69 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -161,7 +161,7 @@ export default function Uploader({ * {@link desktopFiles}, {@link desktopFilePaths} and * {@link desktopZipEntries}. */ - const itemsToUpload = useRef<(File | FileAndPath | string | ZipEntry)[]>( + const itemsToUpload = useRef( [], ); @@ -924,7 +924,7 @@ export const setPendingUploads = async ( helps the info of user choosing this options and on next upload we can directly start uploading to this collection */ - if (collections.length === 1) { + if (collections.length == 1) { collectionName = collections[0].name; } diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index 665cd76c87..9a8cb6c6d3 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -3,8 +3,8 @@ import { potentialFileTypeFromExtension } from "@/media/live-photo"; import { ensureElectron } from "@/next/electron"; import { lowercaseExtension, nameAndExtension } from "@/next/file"; import log from "@/next/log"; -import { ElectronFile } from "@/next/types/file"; -import type { Electron } from "@/next/types/ipc"; +import { ElectronFile, type FileAndPath } from "@/next/types/file"; +import type { Electron, ZipEntry } from "@/next/types/ipc"; import { ComlinkWorker } from "@/next/worker/comlink-worker"; import { ensure } from "@/utils/ensure"; import { getDedicatedCryptoWorker } from "@ente/shared/crypto"; @@ -83,6 +83,32 @@ export interface ProgressUpdater { /** The number of uploads to process in parallel. */ const maxConcurrentUploads = 4; +/** + * An item to upload is one of the following: + * + * 1. A file drag-and-dropped or selected by the user when we are running in the + * web browser. These is the {@link File} case. + * + * 2. A file drag-and-dropped or selected by the user when we are running in the + * context of our desktop app. In such cases, we also have the absolute path + * of the file in the user's local filesystem. this is the + * {@link FileAndPath} case. + * + * 3. A file path programmatically requested by the desktop app. For example, we + * might be resuming a previously interrupted upload after an app restart + * (thus we no longer have access to the {@link File} from case 2). Or we + * could be uploading a file this is in one of the folders the user has asked + * us to watch for changes. This is the {@link string} case. + * + * 4. A file within a zip file. This too is only possible when we are running in + * the context of our desktop app. The user might have drag-and-dropped or + * selected the zip file, or it might be a zip file that they'd previously + * selected but we now are resuming an interrupted upload. Either ways, what + * we have is a path to zip file, and the name of an entry within that zip + * file. This is the {@link ZipEntry} case. + */ +export type UploadItem = File | FileAndPath | string | ZipEntry; + export interface FileWithCollection { localID: number; collectionID: number; @@ -806,23 +832,6 @@ const splitMetadataAndMediaFiles = ( [[], []], ); -export const setToUploadCollection = async (collections: Collection[]) => { - let collectionName: string = null; - /* collection being one suggest one of two things - 1. Either the user has upload to a single existing collection - 2. Created a new single collection to upload to - may have had multiple folder, but chose to upload - to one album - hence saving the collection name when upload collection count is 1 - helps the info of user choosing this options - and on next upload we can directly start uploading to this collection - */ - if (collections.length === 1) { - collectionName = collections[0].name; - } - await ensureElectron().setPendingUploadCollection(collectionName); -}; - const updatePendingUploads = async ( electron: Electron, files: ClusteredFile[], From 61de0c9c9c02ba55ed6548c9ad67aab9a3da6653 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 19:55:04 +0530 Subject: [PATCH 26/49] Before the changes --- .../photos/src/components/Upload/Uploader.tsx | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index 14b0c79c69..f3a6968af6 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -26,6 +26,7 @@ import type { SegregatedFinishedUploads, UploadCounter, UploadFileNames, + UploadItem, } from "services/upload/uploadManager"; import uploadManager from "services/upload/uploadManager"; import watcher from "services/watch"; @@ -160,10 +161,10 @@ export default function Uploader({ * Consolidated and cleaned list obtained from {@link webFiles}, * {@link desktopFiles}, {@link desktopFilePaths} and * {@link desktopZipEntries}. + * + * See the documentation of {@link UploadItem} for more details. */ - const itemsToUpload = useRef( - [], - ); + const uploadItems = useRef([]); // TODO(MR): temp, doesn't have zips const fileOrPathsToUpload = useRef<(File | string)[]>([]); @@ -251,6 +252,7 @@ export default function Uploader({ if (!pending) return; const { collectionName, filePaths, zipEntries } = pending; + log.info("Resuming pending upload", pending); isPendingDesktopUpload.current = true; pendingDesktopUploadCollectionName.current = collectionName; @@ -300,7 +302,7 @@ export default function Uploader({ // Trigger an upload when any of the dependencies change. useEffect(() => { const itemAndPaths = [ - /* TODO(MR): use webkitRelativePath || name here */ + /* TODO(MR): ElectronFile | use webkitRelativePath || name here */ webFiles.map((f) => [f, f["path"]]), desktopFiles.map((fp) => [fp, fp.path]), desktopFilePaths.map((p) => [p, p]), @@ -320,6 +322,7 @@ export default function Uploader({ return; } } + uploadRunning.current = true; props.closeUploadTypeSelector(); props.setLoading(true); @@ -334,15 +337,15 @@ export default function Uploader({ ([_, p]) => !basename(p).startsWith("."), ); - itemsToUpload.current = prunedItemAndPaths.map(([i]) => i); - fileOrPathsToUpload.current = itemsToUpload.current + uploadItems.current = prunedItemAndPaths.map(([i]) => i); + fileOrPathsToUpload.current = uploadItems.current .map((i) => { if (typeof i == "string" || i instanceof File) return i; if (Array.isArray(i)) return undefined; return i.file; }) .filter((x) => x); - itemsToUpload.current = []; + uploadItems.current = []; if (fileOrPathsToUpload.current.length === 0) { props.setLoading(false); return; @@ -912,7 +915,7 @@ const groupFilesBasedOnParentFolder = (fileOrPaths: (File | string)[]) => { export const setPendingUploads = async ( electron: Electron, collections: Collection[], - filesWithCollectionToUploadIn: FileWithCollection[], + uploadItems: UploadItem[], ) => { let collectionName: string | undefined; /* collection being one suggest one of two things @@ -930,15 +933,15 @@ export const setPendingUploads = async ( const filePaths: string[] = []; const zipEntries: ZipEntry[] = []; - for (const file of filesWithCollectionToUploadIn) { - if (file instanceof File) { + for (const item of uploadItems) { + if (item instanceof File) { throw new Error("Unexpected web file for a desktop pending upload"); - } else if (typeof file == "string") { - filePaths.push(file); - } else if (Array.isArray(file)) { - zipEntries.push(file); + } else if (typeof item == "string") { + filePaths.push(item); + } else if (Array.isArray(item)) { + zipEntries.push(item); } else { - filePaths.push(file.path); + filePaths.push(item.path); } } From 2c62f983a8318fbb5910a217854a33b16e19c708 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 20:35:00 +0530 Subject: [PATCH 27/49] wipx --- .../photos/src/components/Upload/Uploader.tsx | 357 ++++++++---------- .../src/services/upload/uploadManager.ts | 18 +- web/apps/photos/src/services/watch.ts | 59 +-- 3 files changed, 197 insertions(+), 237 deletions(-) diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index f3a6968af6..dd90bb98c9 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -21,12 +21,12 @@ import { savePublicCollectionUploaderName, } from "services/publicCollectionService"; import type { - FileWithCollection, InProgressUpload, SegregatedFinishedUploads, UploadCounter, UploadFileNames, UploadItem, + UploadItemWithCollection, } from "services/upload/uploadManager"; import uploadManager from "services/upload/uploadManager"; import watcher from "services/watch"; @@ -86,6 +86,7 @@ interface Props { } export default function Uploader({ + isFirstUpload, dragAndDropFiles, openFileSelector, fileSelectorFiles, @@ -162,12 +163,14 @@ export default function Uploader({ * {@link desktopFiles}, {@link desktopFilePaths} and * {@link desktopZipEntries}. * + * Augment each {@link UploadItem} with its "path" (relative path or name in + * the case of {@link webFiles}, absolute path in the case of + * {@link desktopFiles}, {@link desktopFilePaths}, and the path within the + * zip file for {@link desktopZipEntries}). + * * See the documentation of {@link UploadItem} for more details. */ - const uploadItems = useRef([]); - - // TODO(MR): temp, doesn't have zips - const fileOrPathsToUpload = useRef<(File | string)[]>([]); + const uploadItemsAndPaths = useRef<[UploadItem, string][]>([]); /** * If true, then the next upload we'll be processing was initiated by our @@ -301,15 +304,15 @@ export default function Uploader({ // Trigger an upload when any of the dependencies change. useEffect(() => { - const itemAndPaths = [ + const allItemAndPaths = [ /* TODO(MR): ElectronFile | use webkitRelativePath || name here */ - webFiles.map((f) => [f, f["path"]]), + webFiles.map((f) => [f, f["path"] ?? f.name]), desktopFiles.map((fp) => [fp, fp.path]), desktopFilePaths.map((p) => [p, p]), desktopZipEntries.map((ze) => [ze, ze[1]]), - ].flat(); + ].flat() as [UploadItem, string][]; - if (itemAndPaths.length == 0) return; + if (allItemAndPaths.length == 0) return; if (uploadManager.isUploadRunning()) { if (watcher.isUploadRunning()) { @@ -333,42 +336,93 @@ export default function Uploader({ setDesktopZipEntries([]); // Remove hidden files (files whose names begins with a "."). - const prunedItemAndPaths = itemAndPaths.filter( + const prunedItemAndPaths = allItemAndPaths.filter( + // eslint-disable-next-line @typescript-eslint/no-unused-vars ([_, p]) => !basename(p).startsWith("."), ); - uploadItems.current = prunedItemAndPaths.map(([i]) => i); - fileOrPathsToUpload.current = uploadItems.current - .map((i) => { - if (typeof i == "string" || i instanceof File) return i; - if (Array.isArray(i)) return undefined; - return i.file; - }) - .filter((x) => x); - uploadItems.current = []; - if (fileOrPathsToUpload.current.length === 0) { + uploadItemsAndPaths.current = prunedItemAndPaths; + if (uploadItemsAndPaths.current.length === 0) { props.setLoading(false); return; } const importSuggestion = getImportSuggestion( pickedUploadType.current, + // eslint-disable-next-line @typescript-eslint/no-unused-vars prunedItemAndPaths.map(([_, p]) => p), ); setImportSuggestion(importSuggestion); log.debug(() => "Uploader invoked:"); - log.debug(() => fileOrPathsToUpload.current); + log.debug(() => uploadItemsAndPaths.current); log.debug(() => importSuggestion); - handleCollectionCreationAndUpload( - importSuggestion, - props.isFirstUpload, - pickedUploadType.current, - publicCollectionGalleryContext.accessedThroughSharedURL, - ); + const _pickedUploadType = pickedUploadType.current; pickedUploadType.current = null; props.setLoading(false); + + (async () => { + if (publicCollectionGalleryContext.accessedThroughSharedURL) { + const uploaderName = await getPublicCollectionUploaderName( + getPublicCollectionUID( + publicCollectionGalleryContext.token, + ), + ); + uploaderNameRef.current = uploaderName; + showUserNameInputDialog(); + return; + } + + if (isPendingDesktopUpload.current) { + isPendingDesktopUpload.current = false; + if (pendingDesktopUploadCollectionName.current) { + uploadFilesToNewCollections( + "root", + pendingDesktopUploadCollectionName.current, + ); + pendingDesktopUploadCollectionName.current = null; + } else { + uploadFilesToNewCollections("parent"); + } + return; + } + + if (electron && _pickedUploadType === PICKED_UPLOAD_TYPE.ZIPS) { + uploadFilesToNewCollections("parent"); + return; + } + + if (isFirstUpload && !importSuggestion.rootFolderName) { + importSuggestion.rootFolderName = FIRST_ALBUM_NAME; + } + + if (isDragAndDrop.current) { + isDragAndDrop.current = false; + if ( + props.activeCollection && + props.activeCollection.owner.id === galleryContext.user?.id + ) { + uploadFilesToExistingCollection(props.activeCollection); + return; + } + } + + let showNextModal = () => {}; + if (importSuggestion.hasNestedFolders) { + showNextModal = () => setChoiceModalView(true); + } else { + showNextModal = () => + showCollectionCreateModal(importSuggestion.rootFolderName); + } + + props.setCollectionSelectorAttributes({ + callback: uploadFilesToExistingCollection, + onCancel: handleCollectionSelectorCancel, + showNextModal, + intent: CollectionSelectorIntent.upload, + }); + })(); }, [webFiles, desktopFiles, desktopFilePaths, desktopZipEntries]); const preCollectionCreationAction = async () => { @@ -382,100 +436,78 @@ export default function Uploader({ collection: Collection, uploaderName?: string, ) => { - try { - log.info( - `Uploading files existing collection id ${collection.id} (${collection.name})`, - ); - await preCollectionCreationAction(); - const filesWithCollectionToUpload = fileOrPathsToUpload.current.map( - (fileOrPath, index) => ({ - fileOrPath, - localID: index, - collectionID: collection.id, - }), - ); - await waitInQueueAndUploadFiles( - filesWithCollectionToUpload, - [collection], - uploaderName, - ); - } catch (e) { - log.error("Failed to upload files to existing collection", e); - } + await preCollectionCreationAction(); + const uploadItemsWithCollection = uploadItemsAndPaths.current.map( + ([uploadItem], index) => ({ + uploadItem, + localID: index, + collectionID: collection.id, + }), + ); + await waitInQueueAndUploadFiles( + uploadItemsWithCollection, + [collection], + uploaderName, + ); + uploadItemsAndPaths.current = null; }; const uploadFilesToNewCollections = async ( mapping: CollectionMapping, collectionName?: string, ) => { - try { - log.info( - `Uploading files to collection using ${mapping} mapping (${collectionName ?? ""})`, + await preCollectionCreationAction(); + let uploadItemsWithCollection: UploadItemWithCollection[] = []; + const collections: Collection[] = []; + let collectionNameToUploadItems = new Map(); + if (mapping == "root") { + collectionNameToUploadItems.set( + collectionName, + uploadItemsAndPaths.current.map(([i]) => i), ); - await preCollectionCreationAction(); - let filesWithCollectionToUpload: FileWithCollection[] = []; - const collections: Collection[] = []; - let collectionNameToFileOrPaths = new Map< - string, - (File | string)[] - >(); - if (mapping == "root") { - collectionNameToFileOrPaths.set( - collectionName, - fileOrPathsToUpload.current, - ); - } else { - collectionNameToFileOrPaths = groupFilesBasedOnParentFolder( - fileOrPathsToUpload.current, - ); - } - try { - const existingCollections = await getLatestCollections(); - let index = 0; - for (const [ - collectionName, - fileOrPaths, - ] of collectionNameToFileOrPaths) { - const collection = await getOrCreateAlbum( - collectionName, - existingCollections, - ); - collections.push(collection); - props.setCollections([ - ...existingCollections, - ...collections, - ]); - filesWithCollectionToUpload = [ - ...filesWithCollectionToUpload, - ...fileOrPaths.map((fileOrPath) => ({ - localID: index++, - collectionID: collection.id, - fileOrPath, - })), - ]; - } - } catch (e) { - closeUploadProgress(); - log.error("Failed to create album", e); - appContext.setDialogMessage({ - title: t("ERROR"), - close: { variant: "critical" }, - content: t("CREATE_ALBUM_FAILED"), - }); - throw e; - } - await waitInQueueAndUploadFiles( - filesWithCollectionToUpload, - collections, + } else { + collectionNameToUploadItems = groupFilesBasedOnParentFolder( + uploadItemsAndPaths.current, ); - fileOrPathsToUpload.current = null; - } catch (e) { - log.error("Failed to upload files to new collections", e); } + try { + const existingCollections = await getLatestCollections(); + let index = 0; + for (const [ + collectionName, + fileOrPaths, + ] of collectionNameToUploadItems) { + const collection = await getOrCreateAlbum( + collectionName, + existingCollections, + ); + collections.push(collection); + props.setCollections([...existingCollections, ...collections]); + uploadItemsWithCollection = [ + ...uploadItemsWithCollection, + ...fileOrPaths.map((fileOrPath) => ({ + localID: index++, + collectionID: collection.id, + fileOrPath, + })), + ]; + } + } catch (e) { + closeUploadProgress(); + log.error("Failed to create album", e); + appContext.setDialogMessage({ + title: t("ERROR"), + close: { variant: "critical" }, + content: t("CREATE_ALBUM_FAILED"), + }); + throw e; + } + await waitInQueueAndUploadFiles(uploadItemsWithCollection, collections); + uploadItemsAndPaths.current = null; }; const waitInQueueAndUploadFiles = async ( - filesWithCollectionToUploadIn: FileWithCollection[], + uploadItemsWithCollection: UploadItemWithCollection[], collections: Collection[], uploaderName?: string, ) => { @@ -484,7 +516,7 @@ export default function Uploader({ currentPromise, async () => await uploadFiles( - filesWithCollectionToUploadIn, + uploadItemsWithCollection, collections, uploaderName, ), @@ -505,7 +537,7 @@ export default function Uploader({ } const uploadFiles = async ( - filesWithCollectionToUploadIn: FileWithCollection[], + uploadItemsWithCollection: UploadItemWithCollection[], collections: Collection[], uploaderName?: string, ) => { @@ -519,11 +551,13 @@ export default function Uploader({ setPendingUploads( electron, collections, - filesWithCollectionToUploadIn, + uploadItemsWithCollection + .map(({ uploadItem }) => uploadItem) + .filter((x) => x), ); } const wereFilesProcessed = await uploadManager.uploadFiles( - filesWithCollectionToUploadIn, + uploadItemsWithCollection, collections, uploaderName, ); @@ -531,11 +565,12 @@ export default function Uploader({ if (isElectron()) { if (watcher.isUploadRunning()) { await watcher.allFileUploadsDone( - filesWithCollectionToUploadIn, + uploadItemsWithCollection, collections, ); } else if (watcher.isSyncPaused()) { - // resume the service after user upload is done + // Resume folder watch after the user upload that + // interrupted it is done. watcher.resumePausedSync(); } } @@ -610,78 +645,6 @@ export default function Uploader({ }); }; - const handleCollectionCreationAndUpload = async ( - importSuggestion: ImportSuggestion, - isFirstUpload: boolean, - pickedUploadType: PICKED_UPLOAD_TYPE, - accessedThroughSharedURL?: boolean, - ) => { - try { - if (accessedThroughSharedURL) { - const uploaderName = await getPublicCollectionUploaderName( - getPublicCollectionUID( - publicCollectionGalleryContext.token, - ), - ); - uploaderNameRef.current = uploaderName; - showUserNameInputDialog(); - return; - } - - if (isPendingDesktopUpload.current) { - isPendingDesktopUpload.current = false; - if (pendingDesktopUploadCollectionName.current) { - uploadFilesToNewCollections( - "root", - pendingDesktopUploadCollectionName.current, - ); - pendingDesktopUploadCollectionName.current = null; - } else { - uploadFilesToNewCollections("parent"); - } - return; - } - - if (isElectron() && pickedUploadType === PICKED_UPLOAD_TYPE.ZIPS) { - uploadFilesToNewCollections("parent"); - return; - } - - if (isFirstUpload && !importSuggestion.rootFolderName) { - importSuggestion.rootFolderName = FIRST_ALBUM_NAME; - } - - if (isDragAndDrop.current) { - isDragAndDrop.current = false; - if ( - props.activeCollection && - props.activeCollection.owner.id === galleryContext.user?.id - ) { - uploadFilesToExistingCollection(props.activeCollection); - return; - } - } - - let showNextModal = () => {}; - if (importSuggestion.hasNestedFolders) { - showNextModal = () => setChoiceModalView(true); - } else { - showNextModal = () => - showCollectionCreateModal(importSuggestion.rootFolderName); - } - - props.setCollectionSelectorAttributes({ - callback: uploadFilesToExistingCollection, - onCancel: handleCollectionSelectorCancel, - showNextModal, - intent: CollectionSelectorIntent.upload, - }); - } catch (e) { - // TODO(MR): Why? - log.warn("Ignoring error in handleCollectionCreationAndUpload", e); - } - }; - const cancelUploads = () => { uploadManager.cancelRunningUpload(); }; @@ -784,7 +747,7 @@ export default function Uploader({ open={userNameInputDialogView} onClose={handleUserNameInputDialogClose} onNameSubmit={handlePublicUpload} - toUploadFilesCount={fileOrPathsToUpload.current?.length} + toUploadFilesCount={uploadItemsAndPaths.current?.length} uploaderName={uploaderNameRef.current} /> @@ -884,16 +847,12 @@ function getImportSuggestion( // [a => [j], // b => [e,f,g], // c => [h, i]] -const groupFilesBasedOnParentFolder = (fileOrPaths: (File | string)[]) => { - const result = new Map(); - for (const fileOrPath of fileOrPaths) { - const filePath = - /* TODO(MR): ElectronFile */ - typeof fileOrPath == "string" - ? fileOrPath - : (fileOrPath["path"] as string); - - let folderPath = filePath.substring(0, filePath.lastIndexOf("/")); +const groupFilesBasedOnParentFolder = ( + uploadItemsAndPaths: [UploadItem, string][], +) => { + const result = new Map(); + for (const [uploadItem, pathOrName] of uploadItemsAndPaths) { + let folderPath = pathOrName.substring(0, pathOrName.lastIndexOf("/")); // If the parent folder of a file is "metadata" // we consider it to be part of the parent folder // For Eg,For FileList -> [a/x.png, a/metadata/x.png.json] @@ -907,7 +866,7 @@ const groupFilesBasedOnParentFolder = (fileOrPaths: (File | string)[]) => { ); if (!folderName) throw Error("Unexpected empty folder name"); if (!result.has(folderName)) result.set(folderName, []); - result.get(folderName).push(fileOrPath); + result.get(folderName).push(uploadItem); } return result; }; diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index 9a8cb6c6d3..00741843c8 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -109,17 +109,17 @@ const maxConcurrentUploads = 4; */ export type UploadItem = File | FileAndPath | string | ZipEntry; -export interface FileWithCollection { +export interface UploadItemWithCollection { localID: number; collectionID: number; isLivePhoto?: boolean; - fileOrPath?: File | string; + uploadItem?: UploadItem; livePhotoAssets?: LivePhotoAssets; } export interface LivePhotoAssets { - image: File | string; - video: File | string; + image: UploadItem; + video: UploadItem; } export interface PublicUploadProps { @@ -419,7 +419,7 @@ class UploadManager { * @returns `true` if at least one file was processed */ public async uploadFiles( - filesWithCollectionToUploadIn: FileWithCollection[], + filesWithCollectionToUploadIn: UploadItemWithCollection[], collections: Collection[], uploaderName?: string, ) { @@ -735,8 +735,8 @@ export default new UploadManager(); * As files progress through stages, they get more and more bits tacked on to * them. These types document the journey. * - * - The input is {@link FileWithCollection}. This can either be a new - * {@link FileWithCollection}, in which case it'll only have a + * - The input is {@link UploadItemWithCollection}. This can either be a new + * {@link UploadItemWithCollection}, in which case it'll only have a * {@link localID}, {@link collectionID} and a {@link fileOrPath}. Or it could * be a retry, in which case it'll not have a {@link fileOrPath} but instead * will have data from a previous stage (concretely, it'll just be a @@ -772,9 +772,9 @@ type FileWithCollectionIDAndName = { }; const makeFileWithCollectionIDAndName = ( - f: FileWithCollection, + f: UploadItemWithCollection, ): FileWithCollectionIDAndName => { - const fileOrPath = f.fileOrPath; + const fileOrPath = f.uploadItem; /* TODO(MR): ElectronFile */ if (!(fileOrPath instanceof File || typeof fileOrPath == "string")) throw new Error(`Unexpected file ${f}`); diff --git a/web/apps/photos/src/services/watch.ts b/web/apps/photos/src/services/watch.ts index 4de5881aa8..82d3b2f4ec 100644 --- a/web/apps/photos/src/services/watch.ts +++ b/web/apps/photos/src/services/watch.ts @@ -15,7 +15,7 @@ import { ensureString } from "@/utils/ensure"; import { UPLOAD_RESULT } from "constants/upload"; import debounce from "debounce"; import uploadManager, { - type FileWithCollection, + type UploadItemWithCollection, } from "services/upload/uploadManager"; import { Collection } from "types/collection"; import { EncryptedEnteFile } from "types/file"; @@ -317,16 +317,17 @@ class FolderWatcher { } /** - * Callback invoked by the uploader whenever a file we requested to + * Callback invoked by the uploader whenever a item we requested to * {@link upload} gets uploaded. */ async onFileUpload( fileUploadResult: UPLOAD_RESULT, - fileWithCollection: FileWithCollection, + item: UploadItemWithCollection, file: EncryptedEnteFile, ) { - // The files we get here will have fileWithCollection.file as a string, - // not as a File or a ElectronFile + // Re the usage of ensureString: For desktop watch, the only possibility + // for a UploadItem is for it to be a string (the absolute path to a + // file on disk). if ( [ UPLOAD_RESULT.ADDED_SYMLINK, @@ -335,18 +336,18 @@ class FolderWatcher { UPLOAD_RESULT.ALREADY_UPLOADED, ].includes(fileUploadResult) ) { - if (fileWithCollection.isLivePhoto) { + if (item.isLivePhoto) { this.uploadedFileForPath.set( - ensureString(fileWithCollection.livePhotoAssets.image), + ensureString(item.livePhotoAssets.image), file, ); this.uploadedFileForPath.set( - ensureString(fileWithCollection.livePhotoAssets.video), + ensureString(item.livePhotoAssets.video), file, ); } else { this.uploadedFileForPath.set( - ensureString(fileWithCollection.fileOrPath), + ensureString(item.uploadItem), file, ); } @@ -355,17 +356,15 @@ class FolderWatcher { fileUploadResult, ) ) { - if (fileWithCollection.isLivePhoto) { + if (item.isLivePhoto) { this.unUploadableFilePaths.add( - ensureString(fileWithCollection.livePhotoAssets.image), + ensureString(item.livePhotoAssets.image), ); this.unUploadableFilePaths.add( - ensureString(fileWithCollection.livePhotoAssets.video), + ensureString(item.livePhotoAssets.video), ); } else { - this.unUploadableFilePaths.add( - ensureString(fileWithCollection.fileOrPath), - ); + this.unUploadableFilePaths.add(ensureString(item.uploadItem)); } } } @@ -375,7 +374,7 @@ class FolderWatcher { * {@link upload} get uploaded. */ async allFileUploadsDone( - filesWithCollection: FileWithCollection[], + uploadItemsWithCollection: UploadItemWithCollection[], collections: Collection[], ) { const electron = ensureElectron(); @@ -384,14 +383,15 @@ class FolderWatcher { log.debug(() => JSON.stringify({ f: "watch/allFileUploadsDone", - filesWithCollection, + uploadItemsWithCollection, collections, watch, }), ); - const { syncedFiles, ignoredFiles } = - this.deduceSyncedAndIgnored(filesWithCollection); + const { syncedFiles, ignoredFiles } = this.deduceSyncedAndIgnored( + uploadItemsWithCollection, + ); if (syncedFiles.length > 0) await electron.watch.updateSyncedFiles( @@ -411,7 +411,9 @@ class FolderWatcher { this.debouncedRunNextEvent(); } - private deduceSyncedAndIgnored(filesWithCollection: FileWithCollection[]) { + private deduceSyncedAndIgnored( + uploadItemsWithCollection: UploadItemWithCollection[], + ) { const syncedFiles: FolderWatch["syncedFiles"] = []; const ignoredFiles: FolderWatch["ignoredFiles"] = []; @@ -430,14 +432,13 @@ class FolderWatcher { this.unUploadableFilePaths.delete(path); }; - for (const fileWithCollection of filesWithCollection) { - if (fileWithCollection.isLivePhoto) { - const imagePath = ensureString( - fileWithCollection.livePhotoAssets.image, - ); - const videoPath = ensureString( - fileWithCollection.livePhotoAssets.video, - ); + for (const item of uploadItemsWithCollection) { + // Re the usage of ensureString: For desktop watch, the only + // possibility for a UploadItem is for it to be a string (the + // absolute path to a file on disk). + if (item.isLivePhoto) { + const imagePath = ensureString(item.livePhotoAssets.image); + const videoPath = ensureString(item.livePhotoAssets.video); const imageFile = this.uploadedFileForPath.get(imagePath); const videoFile = this.uploadedFileForPath.get(videoPath); @@ -453,7 +454,7 @@ class FolderWatcher { markIgnored(videoPath); } } else { - const path = ensureString(fileWithCollection.fileOrPath); + const path = ensureString(item.uploadItem); const file = this.uploadedFileForPath.get(path); if (file) { markSynced(file, path); From 38094f317a068630d14cb52b174f8f86cbbdb01b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 20:58:58 +0530 Subject: [PATCH 28/49] wipx --- .../PhotoViewer/ImageEditorOverlay/index.tsx | 2 +- .../photos/src/components/Upload/Uploader.tsx | 14 ++-- .../src/services/upload/uploadManager.ts | 72 +++++++++++-------- .../src/services/upload/uploadService.ts | 45 ++++++------ 4 files changed, 75 insertions(+), 58 deletions(-) diff --git a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx index ff795aca78..3c7b6a9cab 100644 --- a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx @@ -514,7 +514,7 @@ const ImageEditorOverlay = (props: IProps) => { uploadManager.prepareForNewUpload(); uploadManager.showUploadProgressDialog(); - uploadManager.uploadFiles([file], [collection]); + uploadManager.uploadItems([file], [collection]); setFileURL(null); props.onClose(); props.closePhotoViewer(); diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index dd90bb98c9..c895894ccc 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -475,7 +475,7 @@ export default function Uploader({ let index = 0; for (const [ collectionName, - fileOrPaths, + uploadItems, ] of collectionNameToUploadItems) { const collection = await getOrCreateAlbum( collectionName, @@ -485,10 +485,10 @@ export default function Uploader({ props.setCollections([...existingCollections, ...collections]); uploadItemsWithCollection = [ ...uploadItemsWithCollection, - ...fileOrPaths.map((fileOrPath) => ({ + ...uploadItems.map((uploadItem) => ({ localID: index++, collectionID: collection.id, - fileOrPath, + uploadItem, })), ]; } @@ -556,7 +556,7 @@ export default function Uploader({ .filter((x) => x), ); } - const wereFilesProcessed = await uploadManager.uploadFiles( + const wereFilesProcessed = await uploadManager.uploadItems( uploadItemsWithCollection, collections, uploaderName, @@ -586,11 +586,11 @@ export default function Uploader({ const retryFailed = async () => { try { log.info("Retrying failed uploads"); - const { files, collections } = - uploadManager.getFailedFilesWithCollections(); + const { items, collections } = + uploadManager.getFailedItemsWithCollections(); const uploaderName = uploadManager.getUploaderName(); await preUploadAction(); - await uploadManager.uploadFiles(files, collections, uploaderName); + await uploadManager.uploadItems(items, collections, uploaderName); } catch (e) { log.error("Retrying failed uploads failed", e); showUserFacingError(e.message); diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index 00741843c8..3ae22e4ae4 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -36,7 +36,11 @@ import { tryParseTakeoutMetadataJSON, type ParsedMetadataJSON, } from "./takeout"; -import UploadService, { fopFileName, fopSize, uploader } from "./uploadService"; +import UploadService, { + uploadItemFileName, + uploadItemSize, + uploader, +} from "./uploadService"; export type FileID = number; @@ -413,28 +417,28 @@ class UploadManager { * It is an error to call this method when there is already an in-progress * upload. * - * @param filesWithCollectionToUploadIn The files to upload, each paired - * with the id of the collection that they should be uploaded into. + * @param itemsWithCollection The items to upload, each paired with the id + * of the collection that they should be uploaded into. * * @returns `true` if at least one file was processed */ - public async uploadFiles( - filesWithCollectionToUploadIn: UploadItemWithCollection[], + public async uploadItems( + itemsWithCollection: UploadItemWithCollection[], collections: Collection[], uploaderName?: string, ) { if (this.uploadInProgress) throw new Error("Cannot run multiple uploads at once"); - log.info(`Uploading ${filesWithCollectionToUploadIn.length} files`); + log.info(`Uploading ${itemsWithCollection.length} files`); this.uploadInProgress = true; this.uploaderName = uploaderName; try { await this.updateExistingFilesAndCollections(collections); - const namedFiles = filesWithCollectionToUploadIn.map( - makeFileWithCollectionIDAndName, + const namedFiles = itemsWithCollection.map( + makeUploadItemWithCollectionIDAndName, ); this.uiService.setFiles(namedFiles); @@ -505,10 +509,16 @@ class UploadManager { ); } - private async parseMetadataJSONFiles(files: FileWithCollectionIDAndName[]) { + private async parseMetadataJSONFiles( + files: UploadItemWithCollectionIDAndName[], + ) { this.uiService.reset(files.length); - for (const { fileOrPath, fileName, collectionID } of files) { + for (const { + uploadItem: fileOrPath, + fileName, + collectionID, + } of files) { this.abortIfCancelled(); log.info(`Parsing metadata JSON ${fileName}`); @@ -687,9 +697,9 @@ class UploadManager { uploadCancelService.requestUploadCancelation(); } - public getFailedFilesWithCollections() { + public getFailedItemsWithCollections() { return { - files: this.failedFiles, + items: this.failedFiles, collections: [...this.collections.values()], }; } @@ -742,7 +752,7 @@ export default new UploadManager(); * will have data from a previous stage (concretely, it'll just be a * relabelled {@link ClusteredFile}), like a snake eating its tail. * - * - Immediately we convert it to {@link FileWithCollectionIDAndName}. This is + * - Immediately we convert it to {@link UploadItemWithCollectionIDAndName}. This is * to mostly systematize what we have, and also attach a {@link fileName}. * * - These then get converted to "assets", whereby both parts of a live photo @@ -752,7 +762,7 @@ export default new UploadManager(); * {@link collection}, giving us {@link UploadableFile}. This is what gets * queued and then passed to the {@link uploader}. */ -type FileWithCollectionIDAndName = { +type UploadItemWithCollectionIDAndName = { /** A unique ID for the duration of the upload */ localID: number; /** The ID of the collection to which this file should be uploaded. */ @@ -766,14 +776,14 @@ type FileWithCollectionIDAndName = { /** `true` if this is a live photo. */ isLivePhoto?: boolean; /* Valid for non-live photos */ - fileOrPath?: File | string; + uploadItem?: UploadItem; /* Valid for live photos */ livePhotoAssets?: LivePhotoAssets; }; -const makeFileWithCollectionIDAndName = ( +const makeUploadItemWithCollectionIDAndName = ( f: UploadItemWithCollection, -): FileWithCollectionIDAndName => { +): UploadItemWithCollectionIDAndName => { const fileOrPath = f.uploadItem; /* TODO(MR): ElectronFile */ if (!(fileOrPath instanceof File || typeof fileOrPath == "string")) @@ -784,11 +794,11 @@ const makeFileWithCollectionIDAndName = ( collectionID: ensure(f.collectionID), fileName: ensure( f.isLivePhoto - ? fopFileName(f.livePhotoAssets.image) - : fopFileName(fileOrPath), + ? uploadItemFileName(f.livePhotoAssets.image) + : uploadItemFileName(fileOrPath), ), isLivePhoto: f.isLivePhoto, - fileOrPath: fileOrPath, + uploadItem: fileOrPath, livePhotoAssets: f.livePhotoAssets, }; }; @@ -818,10 +828,10 @@ export type UploadableFile = ClusteredFile & { }; const splitMetadataAndMediaFiles = ( - files: FileWithCollectionIDAndName[], + files: UploadItemWithCollectionIDAndName[], ): [ - metadata: FileWithCollectionIDAndName[], - media: FileWithCollectionIDAndName[], + metadata: UploadItemWithCollectionIDAndName[], + media: UploadItemWithCollectionIDAndName[], ] => files.reduce( ([metadata, media], f) => { @@ -865,7 +875,9 @@ const cancelRemainingUploads = async () => { * Go through the given files, combining any sibling image + video assets into a * single live photo when appropriate. */ -const clusterLivePhotos = async (files: FileWithCollectionIDAndName[]) => { +const clusterLivePhotos = async ( + files: UploadItemWithCollectionIDAndName[], +) => { const result: ClusteredFile[] = []; files .sort((f, g) => @@ -884,13 +896,13 @@ const clusterLivePhotos = async (files: FileWithCollectionIDAndName[]) => { fileName: f.fileName, fileType: fFileType, collectionID: f.collectionID, - fileOrPath: f.fileOrPath, + fileOrPath: f.uploadItem, }; const ga: PotentialLivePhotoAsset = { fileName: g.fileName, fileType: gFileType, collectionID: g.collectionID, - fileOrPath: g.fileOrPath, + fileOrPath: g.uploadItem, }; if (await areLivePhotoAssets(fa, ga)) { const [image, video] = @@ -901,8 +913,8 @@ const clusterLivePhotos = async (files: FileWithCollectionIDAndName[]) => { fileName: image.fileName, isLivePhoto: true, livePhotoAssets: { - image: image.fileOrPath, - video: video.fileOrPath, + image: image.uploadItem, + video: video.uploadItem, }, }); index += 2; @@ -970,8 +982,8 @@ const areLivePhotoAssets = async ( // we use doesn't support stream as a input. const maxAssetSize = 20 * 1024 * 1024; /* 20MB */ - const fSize = await fopSize(f.fileOrPath); - const gSize = await fopSize(g.fileOrPath); + const fSize = await uploadItemSize(f.fileOrPath); + const gSize = await uploadItemSize(g.fileOrPath); if (fSize > maxAssetSize || gSize > maxAssetSize) { log.info( `Not classifying assets with too large sizes ${[fSize, gSize]} as a live photo`, diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index d49b32129f..a93d98975d 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -51,7 +51,7 @@ import { generateThumbnailWeb, } from "./thumbnail"; import UploadHttpClient from "./uploadHttpClient"; -import type { UploadableFile } from "./uploadManager"; +import type { UploadItem, UploadableFile } from "./uploadManager"; /** * A readable stream for a file, and its associated size and last modified time. @@ -181,24 +181,29 @@ const uploadService = new UploadService(); export default uploadService; /** - * Return the file name for the given {@link fileOrPath}. - * - * @param fileOrPath The {@link File}, or the path to it. Note that it is only - * valid to specify a path if we are running in the context of our desktop app. + * Return the file name for the given {@link uploadItem}. */ -export const fopFileName = (fileOrPath: File | string) => - typeof fileOrPath == "string" ? basename(fileOrPath) : fileOrPath.name; +export const uploadItemFileName = (uploadItem: UploadItem) => { + if (uploadItem instanceof File) return uploadItem.name; + if (typeof uploadItem == "string") return basename(uploadItem); + if (Array.isArray(uploadItem)) return basename(uploadItem[1]); + return uploadItem.file.name; +}; /** - * Return the size of the given {@link fileOrPath}. - * - * @param fileOrPath The {@link File}, or the path to it. Note that it is only - * valid to specify a path if we are running in the context of our desktop app. + * Return the size of the given {@link uploadItem}. */ -export const fopSize = async (fileOrPath: File | string): Promise => - fileOrPath instanceof File - ? fileOrPath.size - : await ensureElectron().fs.size(fileOrPath); +export const uploadItemSize = async ( + uploadItem: UploadItem, +): Promise => { + if (uploadItem instanceof File) return uploadItem.size; + if (typeof uploadItem == "string") return basename(uploadItem); + if (Array.isArray(uploadItem)) return basename(uploadItem[1]); + return uploadItem.file.size; +}; +uploadItem instanceof File + ? uploadItem.size + : await ensureElectron().fs.size(uploadItem); /* -- Various intermediate type used during upload -- */ @@ -643,7 +648,7 @@ const readImageOrVideoDetails = async (fileOrPath: File | string) => { const chunk = ensure((await reader.read()).value); await reader.cancel(); return chunk; - }, fopFileName(fileOrPath)); + }, uploadItemFileName(fileOrPath)); return { fileTypeInfo, fileSize, lastModifiedMs }; }; @@ -721,7 +726,7 @@ const extractLivePhotoMetadata = async ( return { metadata: { ...imageMetadata, - title: fopFileName(livePhotoAssets.image), + title: uploadItemFileName(livePhotoAssets.image), fileType: FILE_TYPE.LIVE_PHOTO, imageHash: imageMetadata.hash, videoHash: videoHash, @@ -739,7 +744,7 @@ const extractImageOrVideoMetadata = async ( parsedMetadataJSONMap: Map, worker: Remote, ) => { - const fileName = fopFileName(fileOrPath); + const fileName = uploadItemFileName(fileOrPath); const { fileType } = fileTypeInfo; let extractedMetadata: ParsedExtractedMetadata; @@ -949,9 +954,9 @@ const readLivePhoto = async ( return { fileStreamOrData: await encodeLivePhoto({ - imageFileName: fopFileName(livePhotoAssets.image), + imageFileName: uploadItemFileName(livePhotoAssets.image), imageFileOrData: await fileOrData(imageFileStreamOrData), - videoFileName: fopFileName(livePhotoAssets.video), + videoFileName: uploadItemFileName(livePhotoAssets.video), videoFileOrData: await fileOrData(videoFileStreamOrData), }), thumbnail, From fca398f296a835873e755681be7623382714849b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 21:06:20 +0530 Subject: [PATCH 29/49] impl --- desktop/src/main/fs.ts | 2 -- desktop/src/main/ipc.ts | 11 ++++++++--- desktop/src/main/services/upload.ts | 15 +++++++++++++++ desktop/src/preload.ts | 9 +++++---- .../photos/src/services/upload/uploadService.ts | 9 ++++----- web/packages/next/types/ipc.ts | 11 ++++++----- 6 files changed, 38 insertions(+), 19 deletions(-) diff --git a/desktop/src/main/fs.ts b/desktop/src/main/fs.ts index fc181cf46c..2428d3a80c 100644 --- a/desktop/src/main/fs.ts +++ b/desktop/src/main/fs.ts @@ -27,5 +27,3 @@ export const fsIsDir = async (dirPath: string) => { const stat = await fs.stat(dirPath); return stat.isDirectory(); }; - -export const fsSize = (path: string) => fs.stat(path).then((s) => s.size); diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index a99a32d097..01f481f8eb 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -14,6 +14,7 @@ import type { CollectionMapping, FolderWatch, PendingUploads, + ZipEntry, } from "../types/ipc"; import { selectDirectory, @@ -29,7 +30,6 @@ import { fsRename, fsRm, fsRmdir, - fsSize, fsWriteFile, } from "./fs"; import { logToDisk } from "./log"; @@ -54,6 +54,7 @@ import { import { clearPendingUploads, listZipEntries, + pathOrZipEntrySize, markUploadedFiles, markUploadedZipEntries, pendingUploads, @@ -141,8 +142,6 @@ export const attachIPCHandlers = () => { ipcMain.handle("fsIsDir", (_, dirPath: string) => fsIsDir(dirPath)); - ipcMain.handle("fsSize", (_, path: string) => fsSize(path)); - // - Conversion ipcMain.handle("convertToJPEG", (_, imageData: Uint8Array) => @@ -204,6 +203,12 @@ export const attachIPCHandlers = () => { listZipEntries(zipPath), ); + ipcMain.handle( + "pathOrZipEntrySize", + (_, pathOrZipEntry: string | ZipEntry) => + pathOrZipEntrySize(pathOrZipEntry), + ); + ipcMain.handle("pendingUploads", () => pendingUploads()); ipcMain.handle("setPendingUploads", (_, pendingUploads: PendingUploads) => diff --git a/desktop/src/main/services/upload.ts b/desktop/src/main/services/upload.ts index 1f52fe1e7c..a26722cb80 100644 --- a/desktop/src/main/services/upload.ts +++ b/desktop/src/main/services/upload.ts @@ -1,4 +1,5 @@ import StreamZip from "node-stream-zip"; +import fs from "node:fs/promises"; import { existsSync } from "original-fs"; import path from "path"; import type { ElectronFile, PendingUploads, ZipEntry } from "../../types/ipc"; @@ -23,6 +24,20 @@ export const listZipEntries = async (zipPath: string): Promise => { return entryNames.map((entryName) => [zipPath, entryName]); }; +export const pathOrZipEntrySize = async ( + pathOrZipEntry: string | ZipEntry, +): Promise => { + if (typeof pathOrZipEntry == "string") { + const stat = await fs.stat(pathOrZipEntry); + return stat.size; + } else { + const [zipPath, entryName] = pathOrZipEntry; + const zip = new StreamZip.async({ file: zipPath }); + const entry = await zip.entry(entryName); + return entry.size; + } +}; + export const pendingUploads = async (): Promise => { const collectionName = uploadStatusStore.get("collectionName"); diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 4bb23b9ac6..226a80767a 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -123,9 +123,6 @@ const fsWriteFile = (path: string, contents: string): Promise => const fsIsDir = (dirPath: string): Promise => ipcRenderer.invoke("fsIsDir", dirPath); -const fsSize = (path: string): Promise => - ipcRenderer.invoke("fsSize", path); - // - Conversion const convertToJPEG = (imageData: Uint8Array): Promise => @@ -247,6 +244,10 @@ const pathForFile = (file: File) => webUtils.getPathForFile(file); const listZipEntries = (zipPath: string): Promise => ipcRenderer.invoke("listZipEntries", zipPath); +const pathOrZipEntrySize = ( + pathOrZipEntry: string | ZipEntry, +): Promise => ipcRenderer.invoke("pathOrZipEntrySize", pathOrZipEntry); + const pendingUploads = (): Promise => ipcRenderer.invoke("pendingUploads"); @@ -333,7 +334,6 @@ contextBridge.exposeInMainWorld("electron", { readTextFile: fsReadTextFile, writeFile: fsWriteFile, isDir: fsIsDir, - size: fsSize, }, // - Conversion @@ -374,6 +374,7 @@ contextBridge.exposeInMainWorld("electron", { pathForFile, listZipEntries, + pathOrZipEntrySize, pendingUploads, setPendingUploads, markUploadedFiles, diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index a93d98975d..954f171f9a 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -197,13 +197,12 @@ export const uploadItemSize = async ( uploadItem: UploadItem, ): Promise => { if (uploadItem instanceof File) return uploadItem.size; - if (typeof uploadItem == "string") return basename(uploadItem); - if (Array.isArray(uploadItem)) return basename(uploadItem[1]); + if (typeof uploadItem == "string") + return ensureElectron().pathOrZipEntrySize(uploadItem); + if (Array.isArray(uploadItem)) + return ensureElectron().pathOrZipEntrySize(uploadItem); return uploadItem.file.size; }; -uploadItem instanceof File - ? uploadItem.size - : await ensureElectron().fs.size(uploadItem); /* -- Various intermediate type used during upload -- */ diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index dab10cc8e0..34bb9196a2 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -189,11 +189,6 @@ export interface Electron { * directory. */ isDir: (dirPath: string) => Promise; - - /** - * Return the size in bytes of the file at {@link path}. - */ - size: (path: string) => Promise; }; // - Conversion @@ -492,6 +487,12 @@ export interface Electron { */ listZipEntries: (zipPath: string) => Promise; + /** + * Return the size in bytes of the file at the given path or of a particular + * entry within a zip file. + */ + pathOrZipEntrySize: (pathOrZipEntry: string | ZipEntry) => Promise; + /** * Return any pending uploads that were previously enqueued but haven't yet * been completed. From eb608f4bdd53db426f1704f37d42b06b8ff67c27 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 21:12:47 +0530 Subject: [PATCH 30/49] ren --- .../src/services/upload/uploadManager.ts | 53 ++++++++++--------- .../src/services/upload/uploadService.ts | 6 +-- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index 3ae22e4ae4..53b6c7abff 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -350,9 +350,9 @@ class UploadManager { ComlinkWorker >(maxConcurrentUploads); private parsedMetadataJSONMap: Map; - private filesToBeUploaded: ClusteredFile[]; - private remainingFiles: ClusteredFile[] = []; - private failedFiles: ClusteredFile[]; + private filesToBeUploaded: ClusteredUploadItem[]; + private remainingFiles: ClusteredUploadItem[] = []; + private failedFiles: ClusteredUploadItem[]; private existingFiles: EnteFile[]; private setFiles: SetFiles; private collections: Map; @@ -533,7 +533,7 @@ class UploadManager { } } - private async uploadMediaFiles(mediaFiles: ClusteredFile[]) { + private async uploadMediaFiles(mediaFiles: ClusteredUploadItem[]) { this.filesToBeUploaded = [...this.filesToBeUploaded, ...mediaFiles]; if (isElectron()) { @@ -608,7 +608,7 @@ class UploadManager { } private async postUploadTask( - uploadableFile: UploadableFile, + uploadableFile: UploadableUploadItem, uploadResult: UPLOAD_RESULT, uploadedFile: EncryptedEnteFile | EnteFile | undefined, ) { @@ -655,7 +655,7 @@ class UploadManager { eventBus.emit(Events.FILE_UPLOADED, { enteFile: decryptedFile, localFile: - uploadableFile.fileOrPath ?? + uploadableFile.uploadItem ?? uploadableFile.livePhotoAssets.image, }); } catch (e) { @@ -677,7 +677,7 @@ class UploadManager { private async watchFolderCallback( fileUploadResult: UPLOAD_RESULT, - fileWithCollection: ClusteredFile, + fileWithCollection: ClusteredUploadItem, uploadedFile: EncryptedEnteFile, ) { if (isElectron()) { @@ -720,7 +720,7 @@ class UploadManager { this.setFiles((files) => sortFiles([...files, decryptedFile])); } - private async removeFromPendingUploads({ localID }: ClusteredFile) { + private async removeFromPendingUploads({ localID }: ClusteredUploadItem) { const electron = globalThis.electron; if (electron) { this.remainingFiles = this.remainingFiles.filter( @@ -747,20 +747,21 @@ export default new UploadManager(); * * - The input is {@link UploadItemWithCollection}. This can either be a new * {@link UploadItemWithCollection}, in which case it'll only have a - * {@link localID}, {@link collectionID} and a {@link fileOrPath}. Or it could - * be a retry, in which case it'll not have a {@link fileOrPath} but instead + * {@link localID}, {@link collectionID} and a {@link uploadItem}. Or it could + * be a retry, in which case it'll not have a {@link uploadItem} but instead * will have data from a previous stage (concretely, it'll just be a - * relabelled {@link ClusteredFile}), like a snake eating its tail. + * relabelled {@link ClusteredUploadItem}), like a snake eating its tail. * - * - Immediately we convert it to {@link UploadItemWithCollectionIDAndName}. This is - * to mostly systematize what we have, and also attach a {@link fileName}. + * - Immediately we convert it to {@link UploadItemWithCollectionIDAndName}. + * This is to mostly systematize what we have, and also attach a + * {@link fileName}. * * - These then get converted to "assets", whereby both parts of a live photo - * are combined. This is a {@link ClusteredFile}. + * are combined. This is a {@link ClusteredUploadItem}. * - * - On to the {@link ClusteredFile} we attach the corresponding - * {@link collection}, giving us {@link UploadableFile}. This is what gets - * queued and then passed to the {@link uploader}. + * - On to the {@link ClusteredUploadItem} we attach the corresponding + * {@link collection}, giving us {@link UploadableUploadItem}. This is what + * gets queued and then passed to the {@link uploader}. */ type UploadItemWithCollectionIDAndName = { /** A unique ID for the duration of the upload */ @@ -804,26 +805,26 @@ const makeUploadItemWithCollectionIDAndName = ( }; /** - * A file with both parts of a live photo clubbed together. + * An upload item with both parts of a live photo clubbed together. * * See: [Note: Intermediate file types during upload]. */ -type ClusteredFile = { +type ClusteredUploadItem = { localID: number; collectionID: number; fileName: string; isLivePhoto: boolean; - fileOrPath?: File | string; + uploadItem?: UploadItem; livePhotoAssets?: LivePhotoAssets; }; /** - * The file that we hand off to the uploader. Essentially {@link ClusteredFile} - * with the {@link collection} attached to it. + * The file that we hand off to the uploader. Essentially + * {@link ClusteredUploadItem} with the {@link collection} attached to it. * * See: [Note: Intermediate file types during upload]. */ -export type UploadableFile = ClusteredFile & { +export type UploadableUploadItem = ClusteredUploadItem & { collection: Collection; }; @@ -844,13 +845,13 @@ const splitMetadataAndMediaFiles = ( const updatePendingUploads = async ( electron: Electron, - files: ClusteredFile[], + files: ClusteredUploadItem[], ) => { const paths = files .map((file) => file.isLivePhoto ? [file.livePhotoAssets.image, file.livePhotoAssets.video] - : [file.fileOrPath], + : [file.uploadItem], ) .flat() .map((f) => getFilePathElectron(f)); @@ -878,7 +879,7 @@ const cancelRemainingUploads = async () => { const clusterLivePhotos = async ( files: UploadItemWithCollectionIDAndName[], ) => { - const result: ClusteredFile[] = []; + const result: ClusteredUploadItem[] = []; files .sort((f, g) => nameAndExtension(f.fileName)[0].localeCompare( diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index 954f171f9a..f5ae6b650b 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -51,7 +51,7 @@ import { generateThumbnailWeb, } from "./thumbnail"; import UploadHttpClient from "./uploadHttpClient"; -import type { UploadItem, UploadableFile } from "./uploadManager"; +import type { UploadItem, UploadableUploadItem } from "./uploadManager"; /** * A readable stream for a file, and its associated size and last modified time. @@ -315,14 +315,14 @@ interface UploadResponse { } /** - * Upload the given {@link UploadableFile} + * Upload the given {@link UploadableUploadItem} * * This is lower layer implementation of the upload. It is invoked by * {@link UploadManager} after it has assembled all the relevant bits we need to * go forth and upload. */ export const uploader = async ( - { collection, localID, fileName, ...uploadAsset }: UploadableFile, + { collection, localID, fileName, ...uploadAsset }: UploadableUploadItem, uploaderName: string, existingFiles: EnteFile[], parsedMetadataJSONMap: Map, From 6bcf98539025cf17df9fe3219fabdded16d52734 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 21:27:45 +0530 Subject: [PATCH 31/49] or can it --- .../src/services/upload/uploadManager.ts | 102 ++++++++++-------- 1 file changed, 59 insertions(+), 43 deletions(-) diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index 53b6c7abff..d5a7b4caff 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -720,13 +720,15 @@ class UploadManager { this.setFiles((files) => sortFiles([...files, decryptedFile])); } - private async removeFromPendingUploads({ localID }: ClusteredUploadItem) { + private async removeFromPendingUploads( + clusteredUploadItem: ClusteredUploadItem, + ) { const electron = globalThis.electron; if (electron) { this.remainingFiles = this.remainingFiles.filter( - (f) => f.localID != localID, + (f) => f.localID != clusteredUploadItem.localID, ); - await updatePendingUploads(electron, this.remainingFiles); + await markUploaded(electron, clusteredUploadItem); } } @@ -784,25 +786,18 @@ type UploadItemWithCollectionIDAndName = { const makeUploadItemWithCollectionIDAndName = ( f: UploadItemWithCollection, -): UploadItemWithCollectionIDAndName => { - const fileOrPath = f.uploadItem; - /* TODO(MR): ElectronFile */ - if (!(fileOrPath instanceof File || typeof fileOrPath == "string")) - throw new Error(`Unexpected file ${f}`); - - return { - localID: ensure(f.localID), - collectionID: ensure(f.collectionID), - fileName: ensure( - f.isLivePhoto - ? uploadItemFileName(f.livePhotoAssets.image) - : uploadItemFileName(fileOrPath), - ), - isLivePhoto: f.isLivePhoto, - uploadItem: fileOrPath, - livePhotoAssets: f.livePhotoAssets, - }; -}; +): UploadItemWithCollectionIDAndName => ({ + localID: ensure(f.localID), + collectionID: ensure(f.collectionID), + fileName: ensure( + f.isLivePhoto + ? uploadItemFileName(f.livePhotoAssets.image) + : uploadItemFileName(f.uploadItem), + ), + isLivePhoto: f.isLivePhoto, + uploadItem: f.uploadItem, + livePhotoAssets: f.livePhotoAssets, +}); /** * An upload item with both parts of a live photo clubbed together. @@ -829,12 +824,12 @@ export type UploadableUploadItem = ClusteredUploadItem & { }; const splitMetadataAndMediaFiles = ( - files: UploadItemWithCollectionIDAndName[], + items: UploadItemWithCollectionIDAndName[], ): [ metadata: UploadItemWithCollectionIDAndName[], media: UploadItemWithCollectionIDAndName[], ] => - files.reduce( + items.reduce( ([metadata, media], f) => { if (lowercaseExtension(f.fileName) == "json") metadata.push(f); else media.push(f); @@ -843,19 +838,45 @@ const splitMetadataAndMediaFiles = ( [[], []], ); -const updatePendingUploads = async ( - electron: Electron, - files: ClusteredUploadItem[], -) => { - const paths = files - .map((file) => - file.isLivePhoto - ? [file.livePhotoAssets.image, file.livePhotoAssets.video] - : [file.uploadItem], - ) - .flat() - .map((f) => getFilePathElectron(f)); - await electron.setPendingUploadFiles("files", paths); +const markUploaded = async (electron: Electron, item: ClusteredUploadItem) => { + // TODO: This can be done better + if (item.isLivePhoto) { + const [p0, p1] = [ + item.livePhotoAssets.image, + item.livePhotoAssets.video, + ]; + if (Array.isArray(p0) && Array.isArray(p1)) { + electron.markUploadedZipEntries([p0, p1]); + } else if (typeof p0 == "string" && typeof p1 == "string") { + electron.markUploadedFiles([p0, p1]); + } else if ( + p0 && + typeof p0 == "object" && + "path" in p0 && + p1 && + typeof p1 == "object" && + "path" in p1 + ) { + electron.markUploadedFiles([p0.path, p1.path]); + } else { + throw new Error( + "Attempting to mark upload completion of unexpected desktop upload items", + ); + } + } else { + const p = ensure(item.uploadItem); + if (Array.isArray(p)) { + electron.markUploadedZipEntries([p]); + } else if (typeof p == "string") { + electron.markUploadedFiles([p]); + } else if (p && typeof p == "object" && "path" in p) { + electron.markUploadedFiles([p]); + } else { + throw new Error( + "Attempting to mark upload completion of unexpected desktop upload items", + ); + } + } }; /** @@ -865,12 +886,7 @@ const updatePendingUploads = async ( export const getFilePathElectron = (file: File | ElectronFile | string) => typeof file == "string" ? file : (file as ElectronFile).path; -const cancelRemainingUploads = async () => { - const electron = ensureElectron(); - await electron.setPendingUploadCollection(undefined); - await electron.setPendingUploadFiles("zips", []); - await electron.setPendingUploadFiles("files", []); -}; +const cancelRemainingUploads = () => ensureElectron().clearPendingUploads(); /** * Go through the given files, combining any sibling image + video assets into a From 39737b985b2df396f096f6c0b2b93f702d865f7c Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 21:38:21 +0530 Subject: [PATCH 32/49] teach readstream about zips --- .../photos/src/services/upload/takeout.ts | 24 ++++++--- .../src/services/upload/uploadManager.ts | 53 ++++++++----------- web/apps/photos/src/utils/native-stream.ts | 24 ++++++--- 3 files changed, 56 insertions(+), 45 deletions(-) diff --git a/web/apps/photos/src/services/upload/takeout.ts b/web/apps/photos/src/services/upload/takeout.ts index 5cd16130ef..2a71e420a0 100644 --- a/web/apps/photos/src/services/upload/takeout.ts +++ b/web/apps/photos/src/services/upload/takeout.ts @@ -5,6 +5,8 @@ import { nameAndExtension } from "@/next/file"; import log from "@/next/log"; import { NULL_LOCATION } from "constants/upload"; import type { Location } from "types/metadata"; +import { readStream } from "utils/native-stream"; +import type { UploadItem } from "./uploadManager"; export interface ParsedMetadataJSON { creationTime: number; @@ -75,21 +77,29 @@ function getFileOriginalName(fileName: string) { /** Try to parse the contents of a metadata JSON file from a Google Takeout. */ export const tryParseTakeoutMetadataJSON = async ( - fileOrPath: File | string, + uploadItem: UploadItem, ): Promise => { try { - const text = - fileOrPath instanceof File - ? await fileOrPath.text() - : await ensureElectron().fs.readTextFile(fileOrPath); - - return parseMetadataJSONText(text); + return parseMetadataJSONText(await uploadItemText(uploadItem)); } catch (e) { log.error("Failed to parse takeout metadata JSON", e); return undefined; } }; +const uploadItemText = async (uploadItem: UploadItem) => { + if (uploadItem instanceof File) { + return await uploadItem.text(); + } else if (typeof uploadItem == "string") { + return await ensureElectron().fs.readTextFile(uploadItem); + } else if (Array.isArray(uploadItem)) { + const { response } = await readStream(ensureElectron(), uploadItem); + return await response.text(); + } else { + return await uploadItem.file.text(); + } +}; + const NULL_PARSED_METADATA_JSON: ParsedMetadataJSON = { creationTime: null, modificationTime: null, diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index d5a7b4caff..b561cb02ff 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -3,7 +3,7 @@ import { potentialFileTypeFromExtension } from "@/media/live-photo"; import { ensureElectron } from "@/next/electron"; import { lowercaseExtension, nameAndExtension } from "@/next/file"; import log from "@/next/log"; -import { ElectronFile, type FileAndPath } from "@/next/types/file"; +import { type FileAndPath } from "@/next/types/file"; import type { Electron, ZipEntry } from "@/next/types/ipc"; import { ComlinkWorker } from "@/next/worker/comlink-worker"; import { ensure } from "@/utils/ensure"; @@ -437,25 +437,25 @@ class UploadManager { try { await this.updateExistingFilesAndCollections(collections); - const namedFiles = itemsWithCollection.map( + const namedItems = itemsWithCollection.map( makeUploadItemWithCollectionIDAndName, ); - this.uiService.setFiles(namedFiles); + this.uiService.setFiles(namedItems); - const [metadataFiles, mediaFiles] = - splitMetadataAndMediaFiles(namedFiles); + const [metadataItems, mediaItems] = + splitMetadataAndMediaItems(namedItems); - if (metadataFiles.length) { + if (metadataItems.length) { this.uiService.setUploadStage( UPLOAD_STAGES.READING_GOOGLE_METADATA_FILES, ); - await this.parseMetadataJSONFiles(metadataFiles); + await this.parseMetadataJSONFiles(metadataItems); } - if (mediaFiles.length) { - const clusteredMediaFiles = await clusterLivePhotos(mediaFiles); + if (mediaItems.length) { + const clusteredMediaFiles = await clusterLivePhotos(mediaItems); this.abortIfCancelled(); @@ -464,7 +464,7 @@ class UploadManager { this.uiService.setFiles(clusteredMediaFiles); this.uiService.setHasLivePhoto( - mediaFiles.length != clusteredMediaFiles.length, + mediaItems.length != clusteredMediaFiles.length, ); await this.uploadMediaFiles(clusteredMediaFiles); @@ -510,19 +510,17 @@ class UploadManager { } private async parseMetadataJSONFiles( - files: UploadItemWithCollectionIDAndName[], + items: UploadItemWithCollectionIDAndName[], ) { - this.uiService.reset(files.length); + this.uiService.reset(items.length); - for (const { - uploadItem: fileOrPath, - fileName, - collectionID, - } of files) { + for (const { uploadItem, fileName, collectionID } of items) { this.abortIfCancelled(); log.info(`Parsing metadata JSON ${fileName}`); - const metadataJSON = await tryParseTakeoutMetadataJSON(fileOrPath); + const metadataJSON = await tryParseTakeoutMetadataJSON( + ensure(uploadItem), + ); if (metadataJSON) { this.parsedMetadataJSONMap.set( getMetadataJSONMapKeyForJSON(collectionID, fileName), @@ -823,7 +821,7 @@ export type UploadableUploadItem = ClusteredUploadItem & { collection: Collection; }; -const splitMetadataAndMediaFiles = ( +const splitMetadataAndMediaItems = ( items: UploadItemWithCollectionIDAndName[], ): [ metadata: UploadItemWithCollectionIDAndName[], @@ -879,13 +877,6 @@ const markUploaded = async (electron: Electron, item: ClusteredUploadItem) => { } }; -/** - * NOTE: a stop gap measure, only meant to be called by code that is running in - * the context of a desktop app initiated upload - */ -export const getFilePathElectron = (file: File | ElectronFile | string) => - typeof file == "string" ? file : (file as ElectronFile).path; - const cancelRemainingUploads = () => ensureElectron().clearPendingUploads(); /** @@ -913,13 +904,13 @@ const clusterLivePhotos = async ( fileName: f.fileName, fileType: fFileType, collectionID: f.collectionID, - fileOrPath: f.uploadItem, + uploadItem: f.uploadItem, }; const ga: PotentialLivePhotoAsset = { fileName: g.fileName, fileType: gFileType, collectionID: g.collectionID, - fileOrPath: g.uploadItem, + uploadItem: g.uploadItem, }; if (await areLivePhotoAssets(fa, ga)) { const [image, video] = @@ -956,7 +947,7 @@ interface PotentialLivePhotoAsset { fileName: string; fileType: FILE_TYPE; collectionID: number; - fileOrPath: File | string; + uploadItem: UploadItem; } const areLivePhotoAssets = async ( @@ -999,8 +990,8 @@ const areLivePhotoAssets = async ( // we use doesn't support stream as a input. const maxAssetSize = 20 * 1024 * 1024; /* 20MB */ - const fSize = await uploadItemSize(f.fileOrPath); - const gSize = await uploadItemSize(g.fileOrPath); + const fSize = await uploadItemSize(f.uploadItem); + const gSize = await uploadItemSize(g.uploadItem); if (fSize > maxAssetSize || gSize > maxAssetSize) { log.info( `Not classifying assets with too large sizes ${[fSize, gSize]} as a live photo`, diff --git a/web/apps/photos/src/utils/native-stream.ts b/web/apps/photos/src/utils/native-stream.ts index ed7b16a793..c882d5031a 100644 --- a/web/apps/photos/src/utils/native-stream.ts +++ b/web/apps/photos/src/utils/native-stream.ts @@ -6,10 +6,10 @@ * See: [Note: IPC streams]. */ -import type { Electron } from "@/next/types/ipc"; +import type { Electron, ZipEntry } from "@/next/types/ipc"; /** - * Stream the given file from the user's local filesystem. + * Stream the given file or zip entry from the user's local filesystem. * * This only works when we're running in our desktop app since it uses the * "stream://" protocol handler exposed by our custom code in the Node.js layer. @@ -18,8 +18,9 @@ import type { Electron } from "@/next/types/ipc"; * To avoid accidentally invoking it in a non-desktop app context, it requires * the {@link Electron} object as a parameter (even though it doesn't use it). * - * @param path The path on the file on the user's local filesystem whose - * contents we want to stream. + * @param pathOrZipEntry Either the path on the file on the user's local + * filesystem whose contents we want to stream. Or a tuple containing the path + * to a zip file and the name of the entry within it. * * @return A ({@link Response}, size, lastModifiedMs) triple. * @@ -34,16 +35,25 @@ import type { Electron } from "@/next/types/ipc"; */ export const readStream = async ( _: Electron, - path: string, + pathOrZipEntry: string | ZipEntry, ): Promise<{ response: Response; size: number; lastModifiedMs: number }> => { - const req = new Request(`stream://read${path}`, { + let url: URL; + if (typeof pathOrZipEntry == "string") { + url = new URL(`stream://read${pathOrZipEntry}`); + } else { + const [zipPath, entryName] = pathOrZipEntry; + url = new URL(`stream://read${zipPath}`); + url.hash = entryName; + } + + const req = new Request(url, { method: "GET", }); const res = await fetch(req); if (!res.ok) throw new Error( - `Failed to read stream from ${path}: HTTP ${res.status}`, + `Failed to read stream from ${url}: HTTP ${res.status}`, ); const size = readNumericHeader(res, "Content-Length"); From ff8aba816a0a76271b09374e78898e29390dfbeb Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Apr 2024 21:41:47 +0530 Subject: [PATCH 33/49] cont --- .../src/services/upload/uploadManager.ts | 106 ++++++++++-------- .../src/services/upload/uploadService.ts | 21 +--- 2 files changed, 61 insertions(+), 66 deletions(-) diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index b561cb02ff..06772b2d29 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -36,11 +36,7 @@ import { tryParseTakeoutMetadataJSON, type ParsedMetadataJSON, } from "./takeout"; -import UploadService, { - uploadItemFileName, - uploadItemSize, - uploader, -} from "./uploadService"; +import UploadService, { uploadItemFileName, uploader } from "./uploadService"; export type FileID = number; @@ -350,9 +346,9 @@ class UploadManager { ComlinkWorker >(maxConcurrentUploads); private parsedMetadataJSONMap: Map; - private filesToBeUploaded: ClusteredUploadItem[]; - private remainingFiles: ClusteredUploadItem[] = []; - private failedFiles: ClusteredUploadItem[]; + private itemsToBeUploaded: ClusteredUploadItem[]; + private remainingItems: ClusteredUploadItem[] = []; + private failedItems: ClusteredUploadItem[]; private existingFiles: EnteFile[]; private setFiles: SetFiles; private collections: Map; @@ -389,9 +385,9 @@ class UploadManager { } private resetState() { - this.filesToBeUploaded = []; - this.remainingFiles = []; - this.failedFiles = []; + this.itemsToBeUploaded = []; + this.remainingItems = []; + this.failedItems = []; this.parsedMetadataJSONMap = new Map(); this.uploaderName = null; @@ -455,24 +451,24 @@ class UploadManager { } if (mediaItems.length) { - const clusteredMediaFiles = await clusterLivePhotos(mediaItems); + const clusteredMediaItems = await clusterLivePhotos(mediaItems); this.abortIfCancelled(); // Live photos might've been clustered together, reset the list // of files to reflect that. - this.uiService.setFiles(clusteredMediaFiles); + this.uiService.setFiles(clusteredMediaItems); this.uiService.setHasLivePhoto( - mediaItems.length != clusteredMediaFiles.length, + mediaItems.length != clusteredMediaItems.length, ); - await this.uploadMediaFiles(clusteredMediaFiles); + await this.uploadMediaItems(clusteredMediaItems); } } catch (e) { if (e.message === CustomError.UPLOAD_CANCELLED) { if (isElectron()) { - this.remainingFiles = []; + this.remainingItems = []; await cancelRemainingUploads(); } } else { @@ -531,48 +527,48 @@ class UploadManager { } } - private async uploadMediaFiles(mediaFiles: ClusteredUploadItem[]) { - this.filesToBeUploaded = [...this.filesToBeUploaded, ...mediaFiles]; + private async uploadMediaItems(mediaItems: ClusteredUploadItem[]) { + this.itemsToBeUploaded = [...this.itemsToBeUploaded, ...mediaItems]; if (isElectron()) { - this.remainingFiles = [...this.remainingFiles, ...mediaFiles]; + this.remainingItems = [...this.remainingItems, ...mediaItems]; } - this.uiService.reset(mediaFiles.length); + this.uiService.reset(mediaItems.length); - await UploadService.setFileCount(mediaFiles.length); + await UploadService.setFileCount(mediaItems.length); this.uiService.setUploadStage(UPLOAD_STAGES.UPLOADING); const uploadProcesses = []; for ( let i = 0; - i < maxConcurrentUploads && this.filesToBeUploaded.length > 0; + i < maxConcurrentUploads && this.itemsToBeUploaded.length > 0; i++ ) { this.cryptoWorkers[i] = getDedicatedCryptoWorker(); const worker = await this.cryptoWorkers[i].remote; - uploadProcesses.push(this.uploadNextFileInQueue(worker)); + uploadProcesses.push(this.uploadNextItemInQueue(worker)); } await Promise.all(uploadProcesses); } - private async uploadNextFileInQueue(worker: Remote) { + private async uploadNextItemInQueue(worker: Remote) { const uiService = this.uiService; - while (this.filesToBeUploaded.length > 0) { + while (this.itemsToBeUploaded.length > 0) { this.abortIfCancelled(); - const clusteredFile = this.filesToBeUploaded.pop(); - const { localID, collectionID } = clusteredFile; + const clusteredItem = this.itemsToBeUploaded.pop(); + const { localID, collectionID } = clusteredItem; const collection = this.collections.get(collectionID); - const uploadableFile = { ...clusteredFile, collection }; + const uploadableItem = { ...clusteredItem, collection }; uiService.setFileProgress(localID, 0); await wait(0); const { uploadResult, uploadedFile } = await uploader( - uploadableFile, + uploadableItem, this.uploaderName, this.existingFiles, this.parsedMetadataJSONMap, @@ -594,7 +590,7 @@ class UploadManager { ); const finalUploadResult = await this.postUploadTask( - uploadableFile, + uploadableItem, uploadResult, uploadedFile, ); @@ -606,20 +602,20 @@ class UploadManager { } private async postUploadTask( - uploadableFile: UploadableUploadItem, + uploadableItem: UploadableUploadItem, uploadResult: UPLOAD_RESULT, uploadedFile: EncryptedEnteFile | EnteFile | undefined, ) { log.info( - `Uploaded ${uploadableFile.fileName} with result ${uploadResult}`, + `Uploaded ${uploadableItem.fileName} with result ${uploadResult}`, ); try { let decryptedFile: EnteFile; - await this.removeFromPendingUploads(uploadableFile); + await this.removeFromPendingUploads(uploadableItem); switch (uploadResult) { case UPLOAD_RESULT.FAILED: case UPLOAD_RESULT.BLOCKED: - this.failedFiles.push(uploadableFile); + this.failedItems.push(uploadableItem); break; case UPLOAD_RESULT.ALREADY_UPLOADED: decryptedFile = uploadedFile as EnteFile; @@ -632,7 +628,7 @@ class UploadManager { case UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL: decryptedFile = await decryptFile( uploadedFile as EncryptedEnteFile, - uploadableFile.collection.key, + uploadableItem.collection.key, ); break; case UPLOAD_RESULT.UNSUPPORTED: @@ -653,8 +649,8 @@ class UploadManager { eventBus.emit(Events.FILE_UPLOADED, { enteFile: decryptedFile, localFile: - uploadableFile.uploadItem ?? - uploadableFile.livePhotoAssets.image, + uploadableItem.uploadItem ?? + uploadableItem.livePhotoAssets.image, }); } catch (e) { log.warn("Ignoring error in fileUploaded handlers", e); @@ -663,7 +659,7 @@ class UploadManager { } await this.watchFolderCallback( uploadResult, - uploadableFile, + uploadableItem, uploadedFile as EncryptedEnteFile, ); return uploadResult; @@ -697,7 +693,7 @@ class UploadManager { public getFailedItemsWithCollections() { return { - items: this.failedFiles, + items: this.failedItems, collections: [...this.collections.values()], }; } @@ -723,7 +719,7 @@ class UploadManager { ) { const electron = globalThis.electron; if (electron) { - this.remainingFiles = this.remainingFiles.filter( + this.remainingItems = this.remainingItems.filter( (f) => f.localID != clusteredUploadItem.localID, ); await markUploaded(electron, clusteredUploadItem); @@ -868,7 +864,7 @@ const markUploaded = async (electron: Electron, item: ClusteredUploadItem) => { } else if (typeof p == "string") { electron.markUploadedFiles([p]); } else if (p && typeof p == "object" && "path" in p) { - electron.markUploadedFiles([p]); + electron.markUploadedFiles([p.path]); } else { throw new Error( "Attempting to mark upload completion of unexpected desktop upload items", @@ -884,10 +880,10 @@ const cancelRemainingUploads = () => ensureElectron().clearPendingUploads(); * single live photo when appropriate. */ const clusterLivePhotos = async ( - files: UploadItemWithCollectionIDAndName[], + items: UploadItemWithCollectionIDAndName[], ) => { const result: ClusteredUploadItem[] = []; - files + items .sort((f, g) => nameAndExtension(f.fileName)[0].localeCompare( nameAndExtension(g.fileName)[0], @@ -895,9 +891,9 @@ const clusterLivePhotos = async ( ) .sort((f, g) => f.collectionID - g.collectionID); let index = 0; - while (index < files.length - 1) { - const f = files[index]; - const g = files[index + 1]; + while (index < items.length - 1) { + const f = items[index]; + const g = items[index + 1]; const fFileType = potentialFileTypeFromExtension(f.fileName); const gFileType = potentialFileTypeFromExtension(g.fileName); const fa: PotentialLivePhotoAsset = { @@ -934,9 +930,9 @@ const clusterLivePhotos = async ( index += 1; } } - if (index === files.length - 1) { + if (index === items.length - 1) { result.push({ - ...files[index], + ...items[index], isLivePhoto: false, }); } @@ -994,7 +990,7 @@ const areLivePhotoAssets = async ( const gSize = await uploadItemSize(g.uploadItem); if (fSize > maxAssetSize || gSize > maxAssetSize) { log.info( - `Not classifying assets with too large sizes ${[fSize, gSize]} as a live photo`, + `Not classifying files with too large sizes (${fSize} and ${gSize} bytes) as a live photo`, ); return false; } @@ -1027,3 +1023,15 @@ const removePotentialLivePhotoSuffix = (name: string, suffix?: string) => { return foundSuffix ? name.slice(0, foundSuffix.length * -1) : name; }; + +/** + * Return the size of the given {@link uploadItem}. + */ +const uploadItemSize = async (uploadItem: UploadItem): Promise => { + if (uploadItem instanceof File) return uploadItem.size; + if (typeof uploadItem == "string") + return ensureElectron().pathOrZipEntrySize(uploadItem); + if (Array.isArray(uploadItem)) + return ensureElectron().pathOrZipEntrySize(uploadItem); + return uploadItem.file.size; +}; diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index f5ae6b650b..275c001648 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -190,25 +190,12 @@ export const uploadItemFileName = (uploadItem: UploadItem) => { return uploadItem.file.name; }; -/** - * Return the size of the given {@link uploadItem}. - */ -export const uploadItemSize = async ( - uploadItem: UploadItem, -): Promise => { - if (uploadItem instanceof File) return uploadItem.size; - if (typeof uploadItem == "string") - return ensureElectron().pathOrZipEntrySize(uploadItem); - if (Array.isArray(uploadItem)) - return ensureElectron().pathOrZipEntrySize(uploadItem); - return uploadItem.file.size; -}; /* -- Various intermediate type used during upload -- */ interface UploadAsset { isLivePhoto?: boolean; - fileOrPath?: File | string; + uploadItem?: UploadItem; livePhotoAssets?: LivePhotoAssets; } @@ -606,7 +593,7 @@ interface ReadAssetDetailsResult { const readAssetDetails = async ({ isLivePhoto, livePhotoAssets, - fileOrPath, + uploadItem: fileOrPath, }: UploadAsset): Promise => isLivePhoto ? readLivePhotoDetails(livePhotoAssets) @@ -673,7 +660,7 @@ interface ExtractAssetMetadataResult { * {@link parsedMetadataJSONMap} for the assets. Return the resultant metadatum. */ const extractAssetMetadata = async ( - { isLivePhoto, fileOrPath, livePhotoAssets }: UploadAsset, + { isLivePhoto, uploadItem: fileOrPath, livePhotoAssets }: UploadAsset, fileTypeInfo: FileTypeInfo, lastModifiedMs: number, collectionID: number, @@ -914,7 +901,7 @@ const areFilesSameNoHash = (f: Metadata, g: Metadata) => { const readAsset = async ( fileTypeInfo: FileTypeInfo, - { isLivePhoto, fileOrPath, livePhotoAssets }: UploadAsset, + { isLivePhoto, uploadItem: fileOrPath, livePhotoAssets }: UploadAsset, ): Promise => isLivePhoto ? await readLivePhoto(livePhotoAssets, fileTypeInfo) From baf491c62445f9a25fae57cddc603a767ca7ae84 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 09:10:27 +0530 Subject: [PATCH 34/49] up --- web/apps/photos/src/services/ffmpeg.ts | 2 +- .../src/services/upload/uploadService.ts | 64 ++++++++++--------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/web/apps/photos/src/services/ffmpeg.ts b/web/apps/photos/src/services/ffmpeg.ts index 6383a8ce0d..a8b9bc3671 100644 --- a/web/apps/photos/src/services/ffmpeg.ts +++ b/web/apps/photos/src/services/ffmpeg.ts @@ -50,7 +50,7 @@ const _generateVideoThumbnail = async ( * for the new files that the user is adding. * * @param dataOrPath The input video's data or the path to the video on the - * user's local filesystem. See: [Note: Reading a fileOrPath]. + * user's local filesystem. See: [Note: Reading a UploadItem]. * * @returns JPEG data of the generated thumbnail. * diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index 275c001648..35404eb2b5 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -57,7 +57,7 @@ import type { UploadItem, UploadableUploadItem } from "./uploadManager"; * A readable stream for a file, and its associated size and last modified time. * * This is the in-memory representation of the `fileOrPath` type that we usually - * pass around. See: [Note: Reading a fileOrPath] + * pass around. See: [Note: Reading a UploadItem] */ interface FileStream { /** @@ -190,7 +190,6 @@ export const uploadItemFileName = (uploadItem: UploadItem) => { return uploadItem.file.name; }; - /* -- Various intermediate type used during upload -- */ interface UploadAsset { @@ -457,19 +456,21 @@ export const uploader = async ( }; /** - * Read the given file or path into an in-memory representation. + * Read the given file or path or zip entry into an in-memory representation. * - * [Note: Reading a fileOrPath] + * [Note: Reading a UploadItem] * * The file can be either a web - * [File](https://developer.mozilla.org/en-US/docs/Web/API/File) or the absolute - * path to a file on desk. + * [File](https://developer.mozilla.org/en-US/docs/Web/API/File), the absolute + * path to a file on desk, a combination of these two, or a entry in a zip file + * on the user's local filesystem. * - * tl;dr; There are three cases: + * tl;dr; There are four cases: * * 1. web / File - * 2. desktop / File + * 2. desktop / File (+ path) * 3. desktop / path + * 4. desktop / ZipEntry * * For the when and why, read on. * @@ -488,10 +489,13 @@ export const uploader = async ( * So in the web context, this will always be a File we get as a result of an * explicit user interaction (e.g. drag and drop). * - * In the desktop context, this can be either a File or a path. + * In the desktop context, this can be either a File (+ path), or a path, or an + * entry within a zip file. * * 2. If the user provided us this file via some user interaction (say a drag - * and a drop), this'll still be a File. + * and a drop), this'll still be a File. Note that unlike in the web context, + * such File objects also have the full path. See: [Note: File paths when + * running under Electron]. * * 3. However, when running in the desktop app we have the ability to access * absolute paths on the user's file system. For example, if the user asks us @@ -500,17 +504,17 @@ export const uploader = async ( * path. Another example is when resuming an previously interrupted upload - * we'll only have the path at hand in such cases, not the File object. * - * Case 2, when we're provided a path, is simple. We don't have a choice, since - * we cannot still programmatically construct a File object (we can construct it - * on the Node.js layer, but it can't then be transferred over the IPC - * boundary). So all our operations use the path itself. + * 4. The user might've also initiated an upload of a zip file. In this case we + * will get a tuple (path to the zip file on the local file system, and the + * name of the entry within that zip file). * - * Case 3 involves a choice on a use-case basis, since + * Case 3 and 4, when we're provided a path, are simple. We don't have a choice, + * since we cannot still programmatically construct a File object (we can + * construct it on the Node.js layer, but it can't then be transferred over the + * IPC boundary). So all our operations use the path itself. * - * (a) unlike in the web context, such File objects also have the full path. - * See: [Note: File paths when running under Electron]. - * - * (b) neither File nor the path is a better choice for all use cases. + * Case 2 involves a choice on a use-case basis as neither File nor the path is + * a better choice for all use cases. * * The advantage of the File object is that the browser has already read it into * memory for us. The disadvantage comes in the case where we need to @@ -518,7 +522,7 @@ export const uploader = async ( * communication happens over IPC, the File's contents need to be serialized and * copied, which is a bummer for large videos etc. */ -const readFileOrPath = async ( +const readUploadItem = async ( fileOrPath: File | string, ): Promise => { let underlyingStream: ReadableStream; @@ -623,18 +627,18 @@ const readLivePhotoDetails = async ({ image, video }: LivePhotoAssets) => { * While we're at it, also return the size of the file, and its last modified * time (expressed as epoch milliseconds). * - * @param fileOrPath See: [Note: Reading a fileOrPath] + * @param uploadItem See: [Note: Reading a UploadItem] */ -const readImageOrVideoDetails = async (fileOrPath: File | string) => { +const readImageOrVideoDetails = async (uploadItem: UploadItem) => { const { stream, fileSize, lastModifiedMs } = - await readFileOrPath(fileOrPath); + await readUploadItem(uploadItem); const fileTypeInfo = await detectFileTypeInfoFromChunk(async () => { const reader = stream.getReader(); const chunk = ensure((await reader.read()).value); await reader.cancel(); return chunk; - }, uploadItemFileName(fileOrPath)); + }, uploadItemFileName(uploadItem)); return { fileTypeInfo, fileSize, lastModifiedMs }; }; @@ -832,7 +836,7 @@ const computeHash = async ( fileOrPath: File | string, worker: Remote, ) => { - const { stream, chunkCount } = await readFileOrPath(fileOrPath); + const { stream, chunkCount } = await readUploadItem(fileOrPath); const hashState = await worker.initChunkHashing(); const streamReader = stream.getReader(); @@ -921,9 +925,9 @@ const readLivePhoto = async ( extension: fileTypeInfo.imageType, fileType: FILE_TYPE.IMAGE, }, - await readFileOrPath(livePhotoAssets.image), + await readUploadItem(livePhotoAssets.image), ); - const videoFileStreamOrData = await readFileOrPath(livePhotoAssets.video); + const videoFileStreamOrData = await readUploadItem(livePhotoAssets.video); // The JS zip library that encodeLivePhoto uses does not support // ReadableStreams, so pass the file (blob) if we have one, otherwise read @@ -954,7 +958,7 @@ const readImageOrVideo = async ( fileOrPath: File | string, fileTypeInfo: FileTypeInfo, ) => { - const fileStream = await readFileOrPath(fileOrPath); + const fileStream = await readUploadItem(fileOrPath); return withThumbnail(fileOrPath, fileTypeInfo, fileStream); }; @@ -978,8 +982,8 @@ const moduleState = new ModuleState(); /** * Augment the given {@link dataOrStream} with thumbnail information. * - * This is a companion method for {@link readFileOrPath}, and can be used to - * convert the result of {@link readFileOrPath} into an {@link ThumbnailedFile}. + * This is a companion method for {@link readUploadItem}, and can be used to + * convert the result of {@link readUploadItem} into an {@link ThumbnailedFile}. * * Note: The `fileStream` in the returned ThumbnailedFile may be different from * the one passed to the function. From 93991c3a7f9120c62029b9e42e26daaefe4d2f54 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 09:16:10 +0530 Subject: [PATCH 35/49] up --- .../src/services/upload/uploadService.ts | 96 ++++++++++--------- 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index 35404eb2b5..97848eeac2 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -522,29 +522,30 @@ export const uploader = async ( * communication happens over IPC, the File's contents need to be serialized and * copied, which is a bummer for large videos etc. */ -const readUploadItem = async ( - fileOrPath: File | string, -): Promise => { +const readUploadItem = async (uploadItem: UploadItem): Promise => { let underlyingStream: ReadableStream; let file: File | undefined; let fileSize: number; let lastModifiedMs: number; - if (fileOrPath instanceof File) { - file = fileOrPath; - underlyingStream = file.stream(); - fileSize = file.size; - lastModifiedMs = file.lastModified; - } else { - const path = fileOrPath; + if (typeof uploadItem == "string" || Array.isArray(uploadItem)) { const { response, size, lastModifiedMs: lm, - } = await readStream(ensureElectron(), path); + } = await readStream(ensureElectron(), uploadItem); underlyingStream = response.body; fileSize = size; lastModifiedMs = lm; + } else { + if (uploadItem instanceof File) { + file = uploadItem; + } else { + file = uploadItem.file; + } + underlyingStream = file.stream(); + fileSize = file.size; + lastModifiedMs = file.lastModified; } const N = ENCRYPTION_CHUNK_SIZE; @@ -591,8 +592,8 @@ interface ReadAssetDetailsResult { } /** - * Read the file(s) to determine the type, size and last modified time of the - * given {@link asset}. + * Read the associated file(s) to determine the type, size and last modified + * time of the given {@link asset}. */ const readAssetDetails = async ({ isLivePhoto, @@ -664,7 +665,7 @@ interface ExtractAssetMetadataResult { * {@link parsedMetadataJSONMap} for the assets. Return the resultant metadatum. */ const extractAssetMetadata = async ( - { isLivePhoto, uploadItem: fileOrPath, livePhotoAssets }: UploadAsset, + { isLivePhoto, uploadItem, livePhotoAssets }: UploadAsset, fileTypeInfo: FileTypeInfo, lastModifiedMs: number, collectionID: number, @@ -681,7 +682,7 @@ const extractAssetMetadata = async ( worker, ) : await extractImageOrVideoMetadata( - fileOrPath, + uploadItem, fileTypeInfo, lastModifiedMs, collectionID, @@ -727,33 +728,33 @@ const extractLivePhotoMetadata = async ( }; const extractImageOrVideoMetadata = async ( - fileOrPath: File | string, + uploadItem: UploadItem, fileTypeInfo: FileTypeInfo, lastModifiedMs: number, collectionID: number, parsedMetadataJSONMap: Map, worker: Remote, ) => { - const fileName = uploadItemFileName(fileOrPath); + const fileName = uploadItemFileName(uploadItem); const { fileType } = fileTypeInfo; let extractedMetadata: ParsedExtractedMetadata; if (fileType === FILE_TYPE.IMAGE) { extractedMetadata = (await tryExtractImageMetadata( - fileOrPath, + uploadItem, fileTypeInfo, lastModifiedMs, )) ?? NULL_EXTRACTED_METADATA; } else if (fileType === FILE_TYPE.VIDEO) { extractedMetadata = - (await tryExtractVideoMetadata(fileOrPath)) ?? + (await tryExtractVideoMetadata(uploadItem)) ?? NULL_EXTRACTED_METADATA; } else { - throw new Error(`Unexpected file type ${fileType} for ${fileOrPath}`); + throw new Error(`Unexpected file type ${fileType} for ${uploadItem}`); } - const hash = await computeHash(fileOrPath, worker); + const hash = await computeHash(uploadItem, worker); const modificationTime = lastModifiedMs * 1000; const creationTime = @@ -797,46 +798,48 @@ const NULL_EXTRACTED_METADATA: ParsedExtractedMetadata = { }; async function tryExtractImageMetadata( - fileOrPath: File | string, + uploadItem: UploadItem, fileTypeInfo: FileTypeInfo, lastModifiedMs: number, ): Promise { let file: File; - if (fileOrPath instanceof File) { - file = fileOrPath; - } else { - const path = fileOrPath; + if (typeof uploadItem == "string" || Array.isArray(uploadItem)) { // The library we use for extracting EXIF from images, exifr, doesn't // support streams. But unlike videos, for images it is reasonable to // read the entire stream into memory here. - const { response } = await readStream(ensureElectron(), path); + const { response } = await readStream(ensureElectron(), uploadItem); + const path = typeof uploadItem == "string" ? uploadItem : uploadItem[1]; file = new File([await response.arrayBuffer()], basename(path), { lastModified: lastModifiedMs, }); + } else if (uploadItem instanceof File) { + file = uploadItem; + } else { + file = uploadItem.file; } try { return await parseImageMetadata(file, fileTypeInfo); } catch (e) { - log.error(`Failed to extract image metadata for ${fileOrPath}`, e); + log.error(`Failed to extract image metadata for ${uploadItem}`, e); return undefined; } } -const tryExtractVideoMetadata = async (fileOrPath: File | string) => { +const tryExtractVideoMetadata = async (uploadItem: UploadItem) => { try { - return await ffmpeg.extractVideoMetadata(fileOrPath); + return await ffmpeg.extractVideoMetadata(uploadItem); } catch (e) { - log.error(`Failed to extract video metadata for ${fileOrPath}`, e); + log.error(`Failed to extract video metadata for ${uploadItem}`, e); return undefined; } }; const computeHash = async ( - fileOrPath: File | string, + uploadItem: UploadItem, worker: Remote, ) => { - const { stream, chunkCount } = await readUploadItem(fileOrPath); + const { stream, chunkCount } = await readUploadItem(uploadItem); const hashState = await worker.initChunkHashing(); const streamReader = stream.getReader(); @@ -905,11 +908,11 @@ const areFilesSameNoHash = (f: Metadata, g: Metadata) => { const readAsset = async ( fileTypeInfo: FileTypeInfo, - { isLivePhoto, uploadItem: fileOrPath, livePhotoAssets }: UploadAsset, + { isLivePhoto, uploadItem, livePhotoAssets }: UploadAsset, ): Promise => isLivePhoto ? await readLivePhoto(livePhotoAssets, fileTypeInfo) - : await readImageOrVideo(fileOrPath, fileTypeInfo); + : await readImageOrVideo(uploadItem, fileTypeInfo); const readLivePhoto = async ( livePhotoAssets: LivePhotoAssets, @@ -955,11 +958,11 @@ const readLivePhoto = async ( }; const readImageOrVideo = async ( - fileOrPath: File | string, + uploadItem: UploadItem, fileTypeInfo: FileTypeInfo, ) => { - const fileStream = await readUploadItem(fileOrPath); - return withThumbnail(fileOrPath, fileTypeInfo, fileStream); + const fileStream = await readUploadItem(uploadItem); + return withThumbnail(uploadItem, fileTypeInfo, fileStream); }; // TODO(MR): Merge with the uploader @@ -985,11 +988,14 @@ const moduleState = new ModuleState(); * This is a companion method for {@link readUploadItem}, and can be used to * convert the result of {@link readUploadItem} into an {@link ThumbnailedFile}. * - * Note: The `fileStream` in the returned ThumbnailedFile may be different from - * the one passed to the function. + * @param uploadItem The {@link UploadItem} where the given {@link fileStream} + * came from. + * + * Note: The `fileStream` in the returned {@link ThumbnailedFile} may be + * different from the one passed to the function. */ const withThumbnail = async ( - fileOrPath: File | string, + uploadItem: UploadItem, fileTypeInfo: FileTypeInfo, fileStream: FileStream, ): Promise => { @@ -1009,7 +1015,7 @@ const withThumbnail = async ( // be absolute. See: [Note: File paths when running under Electron]. thumbnail = await generateThumbnailNative( electron, - fileOrPath instanceof File ? fileOrPath["path"] : fileOrPath, + uploadItem instanceof File ? uploadItem["path"] : uploadItem, fileTypeInfo, ); } catch (e) { @@ -1023,9 +1029,9 @@ const withThumbnail = async ( if (!thumbnail) { let blob: Blob | undefined; - if (fileOrPath instanceof File) { + if (uploadItem instanceof File) { // 2. Browser based thumbnail generation for File (blobs). - blob = fileOrPath; + blob = uploadItem; } else { // 3. Browser based thumbnail generation for paths. // @@ -1057,7 +1063,7 @@ const withThumbnail = async ( fileData = data; } else { log.warn( - `Not using browser based thumbnail generation fallback for video at path ${fileOrPath}`, + `Not using browser based thumbnail generation fallback for video at path ${uploadItem}`, ); } } From 77fe4f9f03aa4615c4f42a3b7dd8773e4b9a241e Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 09:39:38 +0530 Subject: [PATCH 36/49] wip ze ipc --- desktop/src/main/ipc.ts | 17 ++++++++++++----- desktop/src/main/services/image.ts | 29 +++++++++++++++++++++-------- desktop/src/main/stream.ts | 4 +--- desktop/src/preload.ts | 8 ++++---- web/packages/next/types/ipc.ts | 24 +++++++++++++++--------- 5 files changed, 53 insertions(+), 29 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 01f481f8eb..1a95828627 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -54,9 +54,9 @@ import { import { clearPendingUploads, listZipEntries, - pathOrZipEntrySize, markUploadedFiles, markUploadedZipEntries, + pathOrZipEntrySize, pendingUploads, setPendingUploads, } from "./services/upload"; @@ -152,10 +152,11 @@ export const attachIPCHandlers = () => { "generateImageThumbnail", ( _, - dataOrPath: Uint8Array | string, + dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, maxDimension: number, maxSize: number, - ) => generateImageThumbnail(dataOrPath, maxDimension, maxSize), + ) => + generateImageThumbnail(dataOrPathOrZipEntry, maxDimension, maxSize), ); ipcMain.handle( @@ -163,10 +164,16 @@ export const attachIPCHandlers = () => { ( _, command: string[], - dataOrPath: Uint8Array | string, + dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, outputFileExtension: string, timeoutMS: number, - ) => ffmpegExec(command, dataOrPath, outputFileExtension, timeoutMS), + ) => + ffmpegExec( + command, + dataOrPathOrZipEntry, + outputFileExtension, + timeoutMS, + ), ); // - ML diff --git a/desktop/src/main/services/image.ts b/desktop/src/main/services/image.ts index 26b4b351e5..894ff34049 100644 --- a/desktop/src/main/services/image.ts +++ b/desktop/src/main/services/image.ts @@ -1,8 +1,9 @@ /** @file Image format conversions and thumbnail generation */ +import StreamZip from "node-stream-zip"; import fs from "node:fs/promises"; import path from "path"; -import { CustomErrorMessage } from "../../types/ipc"; +import { CustomErrorMessage, type ZipEntry } from "../../types/ipc"; import log from "../log"; import { execAsync, isDev } from "../utils-electron"; import { deleteTempFile, makeTempFilePath } from "../utils-temp"; @@ -63,18 +64,31 @@ const imageMagickPath = () => path.join(isDev ? "build" : process.resourcesPath, "image-magick"); export const generateImageThumbnail = async ( - dataOrPath: Uint8Array | string, + dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, maxDimension: number, maxSize: number, ): Promise => { let inputFilePath: string; let isInputFileTemporary: boolean; - if (dataOrPath instanceof Uint8Array) { + let writeToTemporaryInputFile = async () => {}; + if (typeof dataOrPathOrZipEntry == "string") { + inputFilePath = dataOrPathOrZipEntry; + isInputFileTemporary = false; + } else { inputFilePath = await makeTempFilePath(); isInputFileTemporary = true; - } else { - inputFilePath = dataOrPath; - isInputFileTemporary = false; + if (dataOrPathOrZipEntry instanceof Uint8Array) { + writeToTemporaryInputFile = async () => { + await fs.writeFile(inputFilePath, dataOrPathOrZipEntry); + }; + } else { + writeToTemporaryInputFile = async () => { + const [zipPath, entryName] = dataOrPathOrZipEntry; + const zip = new StreamZip.async({ file: zipPath }); + await zip.extract(entryName, inputFilePath); + zip.close(); + }; + } } const outputFilePath = await makeTempFilePath("jpeg"); @@ -89,8 +103,7 @@ export const generateImageThumbnail = async ( ); try { - if (dataOrPath instanceof Uint8Array) - await fs.writeFile(inputFilePath, dataOrPath); + writeToTemporaryInputFile(); let thumbnail: Uint8Array; do { diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index ddd639c30b..c518874494 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -97,9 +97,7 @@ const handleRead = async (path: string) => { const handleReadZip = async (zipPath: string, zipEntryPath: string) => { try { - const zip = new StreamZip.async({ - file: zipPath, - }); + const zip = new StreamZip.async({ file: zipPath }); const entry = await zip.entry(zipEntryPath); const stream = await zip.stream(entry); diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 226a80767a..76d44591e0 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -129,27 +129,27 @@ const convertToJPEG = (imageData: Uint8Array): Promise => ipcRenderer.invoke("convertToJPEG", imageData); const generateImageThumbnail = ( - dataOrPath: Uint8Array | string, + dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, maxDimension: number, maxSize: number, ): Promise => ipcRenderer.invoke( "generateImageThumbnail", - dataOrPath, + dataOrPathOrZipEntry, maxDimension, maxSize, ); const ffmpegExec = ( command: string[], - dataOrPath: Uint8Array | string, + dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, outputFileExtension: string, timeoutMS: number, ): Promise => ipcRenderer.invoke( "ffmpegExec", command, - dataOrPath, + dataOrPathOrZipEntry, outputFileExtension, timeoutMS, ); diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index 34bb9196a2..4b3d97dd32 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -221,22 +221,27 @@ export interface Electron { * not yet possible, this function will throw an error with the * {@link CustomErrorMessage.NotAvailable} message. * - * @param dataOrPath The raw image data (the contents of the image file), or - * the path to the image file, whose thumbnail we want to generate. + * @param dataOrPathOrZipEntry The file whose thumbnail we want to generate. + * It can be provided as raw image data (the contents of the image file), or + * the path to the image file, or a tuple containing the path of the zip + * file along with the name of an entry in it. + * * @param maxDimension The maximum width or height of the generated * thumbnail. + * * @param maxSize Maximum size (in bytes) of the generated thumbnail. * * @returns JPEG data of the generated thumbnail. */ generateImageThumbnail: ( - dataOrPath: Uint8Array | string, + dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, maxDimension: number, maxSize: number, ) => Promise; /** - * Execute a FFmpeg {@link command} on the given {@link dataOrPath}. + * Execute a FFmpeg {@link command} on the given + * {@link dataOrPathOrZipEntry}. * * This executes the command using a FFmpeg executable we bundle with our * desktop app. We also have a wasm FFmpeg wasm implementation that we use @@ -249,10 +254,11 @@ export interface Electron { * (respectively {@link inputPathPlaceholder}, * {@link outputPathPlaceholder}, {@link ffmpegPathPlaceholder}). * - * @param dataOrPath The bytes of the input file, or the path to the input - * file on the user's local disk. In both cases, the data gets serialized to - * a temporary file, and then that path gets substituted in the FFmpeg - * {@link command} in lieu of {@link inputPathPlaceholder}. + * @param dataOrPathOrZipEntry The bytes of the input file, or the path to + * the input file on the user's local disk, or the path to a zip file on the + * user's disk and the name of an entry in it. In all three cases, the data + * gets serialized to a temporary file, and then that path gets substituted + * in the FFmpeg {@link command} in lieu of {@link inputPathPlaceholder}. * * @param outputFileExtension The extension (without the dot, e.g. "jpeg") * to use for the output file that we ask FFmpeg to create in @@ -268,7 +274,7 @@ export interface Electron { */ ffmpegExec: ( command: string[], - dataOrPath: Uint8Array | string, + dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, outputFileExtension: string, timeoutMS: number, ) => Promise; From e9bf26e421921ff8ae859f3d892938530ccf6ae7 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 09:55:45 +0530 Subject: [PATCH 37/49] Extract --- desktop/src/main/services/ffmpeg.ts | 33 ++++++++-------- desktop/src/main/services/image.ts | 36 ++++++----------- desktop/src/main/services/upload.ts | 8 +++- desktop/src/main/stream.ts | 1 + desktop/src/main/utils-temp.ts | 61 +++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 42 deletions(-) diff --git a/desktop/src/main/services/ffmpeg.ts b/desktop/src/main/services/ffmpeg.ts index ed3542f6ad..ea9ceaf761 100644 --- a/desktop/src/main/services/ffmpeg.ts +++ b/desktop/src/main/services/ffmpeg.ts @@ -1,9 +1,14 @@ import pathToFfmpeg from "ffmpeg-static"; import fs from "node:fs/promises"; +import type { ZipEntry } from "../../types/ipc"; import log from "../log"; import { withTimeout } from "../utils"; import { execAsync } from "../utils-electron"; -import { deleteTempFile, makeTempFilePath } from "../utils-temp"; +import { + deleteTempFile, + makeFileForDataOrPathOrZipEntry, + makeTempFilePath, +} from "../utils-temp"; /* Duplicated in the web app's code (used by the WASM FFmpeg implementation). */ const ffmpegPathPlaceholder = "FFMPEG"; @@ -39,28 +44,24 @@ const outputPathPlaceholder = "OUTPUT"; */ export const ffmpegExec = async ( command: string[], - dataOrPath: Uint8Array | string, + dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, outputFileExtension: string, timeoutMS: number, ): Promise => { - // TODO (MR): This currently copies files for both input and output. This - // needs to be tested extremely large video files when invoked downstream of - // `convertToMP4` in the web code. + // TODO (MR): This currently copies files for both input (when + // dataOrPathOrZipEntry is data) and output. This needs to be tested + // extremely large video files when invoked downstream of `convertToMP4` in + // the web code. - let inputFilePath: string; - let isInputFileTemporary: boolean; - if (dataOrPath instanceof Uint8Array) { - inputFilePath = await makeTempFilePath(); - isInputFileTemporary = true; - } else { - inputFilePath = dataOrPath; - isInputFileTemporary = false; - } + const { + path: inputFilePath, + isFileTemporary: isInputFileTemporary, + writeToTemporaryFile: writeToTemporaryInputFile, + } = await makeFileForDataOrPathOrZipEntry(dataOrPathOrZipEntry); const outputFilePath = await makeTempFilePath(outputFileExtension); try { - if (dataOrPath instanceof Uint8Array) - await fs.writeFile(inputFilePath, dataOrPath); + await writeToTemporaryInputFile(); const cmd = substitutePlaceholders( command, diff --git a/desktop/src/main/services/image.ts b/desktop/src/main/services/image.ts index 894ff34049..c55bacdffe 100644 --- a/desktop/src/main/services/image.ts +++ b/desktop/src/main/services/image.ts @@ -1,12 +1,15 @@ /** @file Image format conversions and thumbnail generation */ -import StreamZip from "node-stream-zip"; import fs from "node:fs/promises"; import path from "path"; import { CustomErrorMessage, type ZipEntry } from "../../types/ipc"; import log from "../log"; import { execAsync, isDev } from "../utils-electron"; -import { deleteTempFile, makeTempFilePath } from "../utils-temp"; +import { + deleteTempFile, + makeFileForDataOrPathOrZipEntry, + makeTempFilePath, +} from "../utils-temp"; export const convertToJPEG = async (imageData: Uint8Array) => { const inputFilePath = await makeTempFilePath(); @@ -68,28 +71,11 @@ export const generateImageThumbnail = async ( maxDimension: number, maxSize: number, ): Promise => { - let inputFilePath: string; - let isInputFileTemporary: boolean; - let writeToTemporaryInputFile = async () => {}; - if (typeof dataOrPathOrZipEntry == "string") { - inputFilePath = dataOrPathOrZipEntry; - isInputFileTemporary = false; - } else { - inputFilePath = await makeTempFilePath(); - isInputFileTemporary = true; - if (dataOrPathOrZipEntry instanceof Uint8Array) { - writeToTemporaryInputFile = async () => { - await fs.writeFile(inputFilePath, dataOrPathOrZipEntry); - }; - } else { - writeToTemporaryInputFile = async () => { - const [zipPath, entryName] = dataOrPathOrZipEntry; - const zip = new StreamZip.async({ file: zipPath }); - await zip.extract(entryName, inputFilePath); - zip.close(); - }; - } - } + const { + path: inputFilePath, + isFileTemporary: isInputFileTemporary, + writeToTemporaryFile: writeToTemporaryInputFile, + } = await makeFileForDataOrPathOrZipEntry(dataOrPathOrZipEntry); const outputFilePath = await makeTempFilePath("jpeg"); @@ -103,7 +89,7 @@ export const generateImageThumbnail = async ( ); try { - writeToTemporaryInputFile(); + await writeToTemporaryInputFile(); let thumbnail: Uint8Array; do { diff --git a/desktop/src/main/services/upload.ts b/desktop/src/main/services/upload.ts index a26722cb80..804a84736f 100644 --- a/desktop/src/main/services/upload.ts +++ b/desktop/src/main/services/upload.ts @@ -21,6 +21,8 @@ export const listZipEntries = async (zipPath: string): Promise => { } } + zip.close(); + return entryNames.map((entryName) => [zipPath, entryName]); }; @@ -34,7 +36,9 @@ export const pathOrZipEntrySize = async ( const [zipPath, entryName] = pathOrZipEntry; const zip = new StreamZip.async({ file: zipPath }); const entry = await zip.entry(entryName); - return entry.size; + const size = entry.size; + zip.close(); + return size; } }; @@ -110,6 +114,8 @@ export const getElectronFilesFromGoogleZip = async (filePath: string) => { } } + zip.close(); + return files; }; diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index c518874494..bcffe2cc50 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -100,6 +100,7 @@ const handleReadZip = async (zipPath: string, zipEntryPath: string) => { const zip = new StreamZip.async({ file: zipPath }); const entry = await zip.entry(zipEntryPath); const stream = await zip.stream(entry); + // TODO(MR): when to call zip.close() return new Response(Readable.toWeb(new Readable(stream)), { headers: { diff --git a/desktop/src/main/utils-temp.ts b/desktop/src/main/utils-temp.ts index a52daf619d..2e416bd652 100644 --- a/desktop/src/main/utils-temp.ts +++ b/desktop/src/main/utils-temp.ts @@ -1,7 +1,9 @@ import { app } from "electron/main"; +import StreamZip from "node-stream-zip"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "path"; +import type { ZipEntry } from "../types/ipc"; /** * Our very own directory within the system temp directory. Go crazy, but @@ -61,3 +63,62 @@ export const deleteTempFile = async (tempFilePath: string) => { throw new Error(`Attempting to delete a non-temp file ${tempFilePath}`); await fs.rm(tempFilePath, { force: true }); }; + +/** The result of {@link makeFileForDataOrPathOrZipEntry}. */ +interface FileForDataOrPathOrZipEntry { + /** The path to the file (possibly temporary) */ + path: string; + /** + * `true` if {@link path} points to a temporary file which should be deleted + * once we are done processing. + */ + isFileTemporary: boolean; + /** + * If set, this'll be a function that can be called to actually write the + * contents of the source `Uint8Array | string | ZipEntry` into the file at + * {@link path}. + * + * It will be undefined if the source is already a path since nothing needs + * to be written in that case. In the other two cases this function will + * write the data or zip entry into the file at {@link path}. + */ + writeToTemporaryFile?: () => Promise; +} + +/** + * Return the path to a file, a boolean indicating if this is a temporary path + * that needs to be deleted after processing, and a function to write the given + * {@link dataOrPathOrZipEntry} into that temporary file if needed. + * + * @param dataOrPathOrZipEntry The contents of the file, or the path to an + * existing file, or a (path to a zip file, name of an entry within that zip + * file) tuple. + */ +export const makeFileForDataOrPathOrZipEntry = async ( + dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, +): Promise => { + let path: string; + let isFileTemporary: boolean; + let writeToTemporaryFile: () => Promise | undefined; + + if (typeof dataOrPathOrZipEntry == "string") { + path = dataOrPathOrZipEntry; + isFileTemporary = false; + } else { + path = await makeTempFilePath(); + isFileTemporary = true; + if (dataOrPathOrZipEntry instanceof Uint8Array) { + writeToTemporaryFile = () => + fs.writeFile(path, dataOrPathOrZipEntry); + } else { + writeToTemporaryFile = async () => { + const [zipPath, entryName] = dataOrPathOrZipEntry; + const zip = new StreamZip.async({ file: zipPath }); + await zip.extract(entryName, path); + zip.close(); + }; + } + } + + return { path, isFileTemporary, writeToTemporaryFile }; +}; From 73baf5a375272d9387c76084553e19f27885e612 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 10:08:31 +0530 Subject: [PATCH 38/49] Uncollide with ZipEntry from StreamZip --- desktop/src/main/ipc.ts | 21 +++++++-------- desktop/src/main/services/ffmpeg.ts | 10 +++---- desktop/src/main/services/image.ts | 8 +++--- desktop/src/main/services/upload.ts | 16 +++++------ desktop/src/main/stores/upload-status.ts | 21 +++++++++++---- desktop/src/main/stream.ts | 6 ++--- desktop/src/main/utils-temp.ts | 34 +++++++++++++----------- desktop/src/preload.ts | 21 +++++++-------- desktop/src/types/ipc.ts | 4 +-- 9 files changed, 75 insertions(+), 66 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 1a95828627..abbe54705d 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -14,7 +14,7 @@ import type { CollectionMapping, FolderWatch, PendingUploads, - ZipEntry, + ZipItem, } from "../types/ipc"; import { selectDirectory, @@ -56,7 +56,7 @@ import { listZipEntries, markUploadedFiles, markUploadedZipEntries, - pathOrZipEntrySize, + pathOrZipItemSize, pendingUploads, setPendingUploads, } from "./services/upload"; @@ -152,11 +152,10 @@ export const attachIPCHandlers = () => { "generateImageThumbnail", ( _, - dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, + dataOrPathOrZipItem: Uint8Array | string | ZipItem, maxDimension: number, maxSize: number, - ) => - generateImageThumbnail(dataOrPathOrZipEntry, maxDimension, maxSize), + ) => generateImageThumbnail(dataOrPathOrZipItem, maxDimension, maxSize), ); ipcMain.handle( @@ -164,13 +163,13 @@ export const attachIPCHandlers = () => { ( _, command: string[], - dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, + dataOrPathOrZipItem: Uint8Array | string | ZipItem, outputFileExtension: string, timeoutMS: number, ) => ffmpegExec( command, - dataOrPathOrZipEntry, + dataOrPathOrZipItem, outputFileExtension, timeoutMS, ), @@ -210,10 +209,8 @@ export const attachIPCHandlers = () => { listZipEntries(zipPath), ); - ipcMain.handle( - "pathOrZipEntrySize", - (_, pathOrZipEntry: string | ZipEntry) => - pathOrZipEntrySize(pathOrZipEntry), + ipcMain.handle("pathOrZipItemSize", (_, pathOrZipItem: string | ZipItem) => + pathOrZipItemSize(pathOrZipItem), ); ipcMain.handle("pendingUploads", () => pendingUploads()); @@ -229,7 +226,7 @@ export const attachIPCHandlers = () => { ipcMain.handle( "markUploadedZipEntries", - (_, zipEntries: PendingUploads["zipEntries"]) => + (_, zipEntries: PendingUploads["zipItems"]) => markUploadedZipEntries(zipEntries), ); diff --git a/desktop/src/main/services/ffmpeg.ts b/desktop/src/main/services/ffmpeg.ts index ea9ceaf761..35977409ae 100644 --- a/desktop/src/main/services/ffmpeg.ts +++ b/desktop/src/main/services/ffmpeg.ts @@ -1,12 +1,12 @@ import pathToFfmpeg from "ffmpeg-static"; import fs from "node:fs/promises"; -import type { ZipEntry } from "../../types/ipc"; +import type { ZipItem } from "../../types/ipc"; import log from "../log"; import { withTimeout } from "../utils"; import { execAsync } from "../utils-electron"; import { deleteTempFile, - makeFileForDataOrPathOrZipEntry, + makeFileForDataOrPathOrZipItem, makeTempFilePath, } from "../utils-temp"; @@ -44,12 +44,12 @@ const outputPathPlaceholder = "OUTPUT"; */ export const ffmpegExec = async ( command: string[], - dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, + dataOrPathOrZipItem: Uint8Array | string | ZipItem, outputFileExtension: string, timeoutMS: number, ): Promise => { // TODO (MR): This currently copies files for both input (when - // dataOrPathOrZipEntry is data) and output. This needs to be tested + // dataOrPathOrZipItem is data) and output. This needs to be tested // extremely large video files when invoked downstream of `convertToMP4` in // the web code. @@ -57,7 +57,7 @@ export const ffmpegExec = async ( path: inputFilePath, isFileTemporary: isInputFileTemporary, writeToTemporaryFile: writeToTemporaryInputFile, - } = await makeFileForDataOrPathOrZipEntry(dataOrPathOrZipEntry); + } = await makeFileForDataOrPathOrZipItem(dataOrPathOrZipItem); const outputFilePath = await makeTempFilePath(outputFileExtension); try { diff --git a/desktop/src/main/services/image.ts b/desktop/src/main/services/image.ts index c55bacdffe..c48e87c5bf 100644 --- a/desktop/src/main/services/image.ts +++ b/desktop/src/main/services/image.ts @@ -2,12 +2,12 @@ import fs from "node:fs/promises"; import path from "path"; -import { CustomErrorMessage, type ZipEntry } from "../../types/ipc"; +import { CustomErrorMessage, type ZipItem } from "../../types/ipc"; import log from "../log"; import { execAsync, isDev } from "../utils-electron"; import { deleteTempFile, - makeFileForDataOrPathOrZipEntry, + makeFileForDataOrPathOrZipItem, makeTempFilePath, } from "../utils-temp"; @@ -67,7 +67,7 @@ const imageMagickPath = () => path.join(isDev ? "build" : process.resourcesPath, "image-magick"); export const generateImageThumbnail = async ( - dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, + dataOrPathOrZipItem: Uint8Array | string | ZipItem, maxDimension: number, maxSize: number, ): Promise => { @@ -75,7 +75,7 @@ export const generateImageThumbnail = async ( path: inputFilePath, isFileTemporary: isInputFileTemporary, writeToTemporaryFile: writeToTemporaryInputFile, - } = await makeFileForDataOrPathOrZipEntry(dataOrPathOrZipEntry); + } = await makeFileForDataOrPathOrZipItem(dataOrPathOrZipItem); const outputFilePath = await makeTempFilePath("jpeg"); diff --git a/desktop/src/main/services/upload.ts b/desktop/src/main/services/upload.ts index 804a84736f..1a1d343c5c 100644 --- a/desktop/src/main/services/upload.ts +++ b/desktop/src/main/services/upload.ts @@ -2,11 +2,11 @@ import StreamZip from "node-stream-zip"; import fs from "node:fs/promises"; import { existsSync } from "original-fs"; import path from "path"; -import type { ElectronFile, PendingUploads, ZipEntry } from "../../types/ipc"; +import type { ElectronFile, PendingUploads, ZipItem } from "../../types/ipc"; import { uploadStatusStore } from "../stores/upload-status"; import { getZipFileStream } from "./fs"; -export const listZipEntries = async (zipPath: string): Promise => { +export const listZipEntries = async (zipPath: string): Promise => { const zip = new StreamZip.async({ file: zipPath }); const entries = await zip.entries(); @@ -26,14 +26,14 @@ export const listZipEntries = async (zipPath: string): Promise => { return entryNames.map((entryName) => [zipPath, entryName]); }; -export const pathOrZipEntrySize = async ( - pathOrZipEntry: string | ZipEntry, +export const pathOrZipItemSize = async ( + pathOrZipItem: string | ZipItem, ): Promise => { - if (typeof pathOrZipEntry == "string") { - const stat = await fs.stat(pathOrZipEntry); + if (typeof pathOrZipItem == "string") { + const stat = await fs.stat(pathOrZipItem); return stat.size; } else { - const [zipPath, entryName] = pathOrZipEntry; + const [zipPath, entryName] = pathOrZipItem; const zip = new StreamZip.async({ file: zipPath }); const entry = await zip.entry(entryName); const size = entry.size; @@ -73,7 +73,7 @@ export const pendingUploads = async (): Promise => { return { collectionName, filePaths, - zipEntries, + zipItems: zipEntries, }; }; diff --git a/desktop/src/main/stores/upload-status.ts b/desktop/src/main/stores/upload-status.ts index 20a431fd9b..4fd6c9860e 100644 --- a/desktop/src/main/stores/upload-status.ts +++ b/desktop/src/main/stores/upload-status.ts @@ -1,17 +1,28 @@ import Store, { Schema } from "electron-store"; export interface UploadStatusStore { - /* The collection to which we're uploading, or the root collection. */ + /** + * The collection to which we're uploading, or the root collection. + * + * Not all pending uploads will have an associated collection. + */ collectionName?: string; - /** Paths to regular files that are pending upload */ + /** + * Paths to regular files that are pending upload. + * + * This should generally be present, albeit empty, but it is marked optional + * in sympathy with its siblings. + */ filePaths?: string[]; /** * Each item is the path to a zip file and the name of an entry within it. * * This is marked optional since legacy stores will not have it. */ - zipEntries?: [zipPath: string, entryName: string][]; - /** Legacy paths to zip files, now subsumed into zipEntries */ + zipItems?: [zipPath: string, entryName: string][]; + /** + * @deprecated Legacy paths to zip files, now subsumed into zipEntries. + */ zipPaths?: string[]; } @@ -25,7 +36,7 @@ const uploadStatusSchema: Schema = { type: "string", }, }, - zipEntries: { + zipItems: { type: "array", items: { type: "array", diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index bcffe2cc50..b37970cfae 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -95,10 +95,10 @@ const handleRead = async (path: string) => { } }; -const handleReadZip = async (zipPath: string, zipEntryPath: string) => { +const handleReadZip = async (zipPath: string, entryName: string) => { try { const zip = new StreamZip.async({ file: zipPath }); - const entry = await zip.entry(zipEntryPath); + const entry = await zip.entry(entryName); const stream = await zip.stream(entry); // TODO(MR): when to call zip.close() @@ -119,7 +119,7 @@ const handleReadZip = async (zipPath: string, zipEntryPath: string) => { }); } catch (e) { log.error( - `Failed to read entry ${zipEntryPath} from zip file at ${zipPath}`, + `Failed to read entry ${entryName} from zip file at ${zipPath}`, e, ); return new Response(`Failed to read stream: ${e.message}`, { diff --git a/desktop/src/main/utils-temp.ts b/desktop/src/main/utils-temp.ts index 2e416bd652..3f3a6081e4 100644 --- a/desktop/src/main/utils-temp.ts +++ b/desktop/src/main/utils-temp.ts @@ -3,7 +3,7 @@ import StreamZip from "node-stream-zip"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "path"; -import type { ZipEntry } from "../types/ipc"; +import type { ZipItem } from "../types/ipc"; /** * Our very own directory within the system temp directory. Go crazy, but @@ -64,9 +64,11 @@ export const deleteTempFile = async (tempFilePath: string) => { await fs.rm(tempFilePath, { force: true }); }; -/** The result of {@link makeFileForDataOrPathOrZipEntry}. */ -interface FileForDataOrPathOrZipEntry { - /** The path to the file (possibly temporary) */ +/** The result of {@link makeFileForDataOrPathOrZipItem}. */ +interface FileForDataOrPathOrZipItem { + /** + * The path to the file (possibly temporary). + */ path: string; /** * `true` if {@link path} points to a temporary file which should be deleted @@ -75,12 +77,12 @@ interface FileForDataOrPathOrZipEntry { isFileTemporary: boolean; /** * If set, this'll be a function that can be called to actually write the - * contents of the source `Uint8Array | string | ZipEntry` into the file at + * contents of the source `Uint8Array | string | ZipItem` into the file at * {@link path}. * * It will be undefined if the source is already a path since nothing needs * to be written in that case. In the other two cases this function will - * write the data or zip entry into the file at {@link path}. + * write the data or zip item into the file at {@link path}. */ writeToTemporaryFile?: () => Promise; } @@ -88,31 +90,31 @@ interface FileForDataOrPathOrZipEntry { /** * Return the path to a file, a boolean indicating if this is a temporary path * that needs to be deleted after processing, and a function to write the given - * {@link dataOrPathOrZipEntry} into that temporary file if needed. + * {@link dataOrPathOrZipItem} into that temporary file if needed. * - * @param dataOrPathOrZipEntry The contents of the file, or the path to an + * @param dataOrPathOrZipItem The contents of the file, or the path to an * existing file, or a (path to a zip file, name of an entry within that zip * file) tuple. */ -export const makeFileForDataOrPathOrZipEntry = async ( - dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, -): Promise => { +export const makeFileForDataOrPathOrZipItem = async ( + dataOrPathOrZipItem: Uint8Array | string | ZipItem, +): Promise => { let path: string; let isFileTemporary: boolean; let writeToTemporaryFile: () => Promise | undefined; - if (typeof dataOrPathOrZipEntry == "string") { - path = dataOrPathOrZipEntry; + if (typeof dataOrPathOrZipItem == "string") { + path = dataOrPathOrZipItem; isFileTemporary = false; } else { path = await makeTempFilePath(); isFileTemporary = true; - if (dataOrPathOrZipEntry instanceof Uint8Array) { + if (dataOrPathOrZipItem instanceof Uint8Array) { writeToTemporaryFile = () => - fs.writeFile(path, dataOrPathOrZipEntry); + fs.writeFile(path, dataOrPathOrZipItem); } else { writeToTemporaryFile = async () => { - const [zipPath, entryName] = dataOrPathOrZipEntry; + const [zipPath, entryName] = dataOrPathOrZipItem; const zip = new StreamZip.async({ file: zipPath }); await zip.extract(entryName, path); zip.close(); diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 76d44591e0..48e4d1448c 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -47,7 +47,7 @@ import type { ElectronFile, FolderWatch, PendingUploads, - ZipEntry, + ZipItem, } from "./types/ipc"; // - General @@ -129,27 +129,27 @@ const convertToJPEG = (imageData: Uint8Array): Promise => ipcRenderer.invoke("convertToJPEG", imageData); const generateImageThumbnail = ( - dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, + dataOrPathOrZipItem: Uint8Array | string | ZipItem, maxDimension: number, maxSize: number, ): Promise => ipcRenderer.invoke( "generateImageThumbnail", - dataOrPathOrZipEntry, + dataOrPathOrZipItem, maxDimension, maxSize, ); const ffmpegExec = ( command: string[], - dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, + dataOrPathOrZipItem: Uint8Array | string | ZipItem, outputFileExtension: string, timeoutMS: number, ): Promise => ipcRenderer.invoke( "ffmpegExec", command, - dataOrPathOrZipEntry, + dataOrPathOrZipItem, outputFileExtension, timeoutMS, ); @@ -241,12 +241,11 @@ const watchFindFiles = (folderPath: string): Promise => const pathForFile = (file: File) => webUtils.getPathForFile(file); -const listZipEntries = (zipPath: string): Promise => +const listZipEntries = (zipPath: string): Promise => ipcRenderer.invoke("listZipEntries", zipPath); -const pathOrZipEntrySize = ( - pathOrZipEntry: string | ZipEntry, -): Promise => ipcRenderer.invoke("pathOrZipEntrySize", pathOrZipEntry); +const pathOrZipItemSize = (pathOrZipItem: string | ZipItem): Promise => + ipcRenderer.invoke("pathOrZipItemSize", pathOrZipItem); const pendingUploads = (): Promise => ipcRenderer.invoke("pendingUploads"); @@ -258,7 +257,7 @@ const markUploadedFiles = (paths: PendingUploads["filePaths"]): Promise => ipcRenderer.invoke("markUploadedFiles", paths); const markUploadedZipEntries = ( - zipEntries: PendingUploads["zipEntries"], + zipEntries: PendingUploads["zipItems"], ): Promise => ipcRenderer.invoke("markUploadedZipEntries", zipEntries); const clearPendingUploads = (): Promise => @@ -374,7 +373,7 @@ contextBridge.exposeInMainWorld("electron", { pathForFile, listZipEntries, - pathOrZipEntrySize, + pathOrZipItemSize, pendingUploads, setPendingUploads, markUploadedFiles, diff --git a/desktop/src/types/ipc.ts b/desktop/src/types/ipc.ts index 307fb7de32..6e47b7a3a6 100644 --- a/desktop/src/types/ipc.ts +++ b/desktop/src/types/ipc.ts @@ -25,12 +25,12 @@ export interface FolderWatchSyncedFile { collectionID: number; } -export type ZipEntry = [zipPath: string, entryName: string]; +export type ZipItem = [zipPath: string, entryName: string]; export interface PendingUploads { collectionName: string; filePaths: string[]; - zipEntries: ZipEntry[]; + zipItems: ZipItem[]; } /** From afb0e1aff3e0a2544fbf88eab50cfcad559b7472 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 10:11:27 +0530 Subject: [PATCH 39/49] web --- desktop/src/main/ipc.ts | 13 ++-- desktop/src/main/services/upload.ts | 38 +++++------ desktop/src/main/stores/upload-status.ts | 2 +- desktop/src/preload.ts | 14 ++--- .../photos/src/components/Upload/Uploader.tsx | 63 ++++++++++--------- .../src/services/upload/uploadManager.ts | 14 ++--- web/apps/photos/src/utils/native-stream.ts | 4 +- web/packages/next/types/ipc.ts | 36 +++++------ 8 files changed, 95 insertions(+), 89 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index abbe54705d..df6ab7c8ea 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -53,9 +53,9 @@ import { } from "./services/store"; import { clearPendingUploads, - listZipEntries, + listZipItems, markUploadedFiles, - markUploadedZipEntries, + markUploadedZipItems, pathOrZipItemSize, pendingUploads, setPendingUploads, @@ -205,8 +205,8 @@ export const attachIPCHandlers = () => { // - Upload - ipcMain.handle("listZipEntries", (_, zipPath: string) => - listZipEntries(zipPath), + ipcMain.handle("listZipItems", (_, zipPath: string) => + listZipItems(zipPath), ); ipcMain.handle("pathOrZipItemSize", (_, pathOrZipItem: string | ZipItem) => @@ -225,9 +225,8 @@ export const attachIPCHandlers = () => { ); ipcMain.handle( - "markUploadedZipEntries", - (_, zipEntries: PendingUploads["zipItems"]) => - markUploadedZipEntries(zipEntries), + "markUploadedZipItems", + (_, items: PendingUploads["zipItems"]) => markUploadedZipItems(items), ); ipcMain.handle("clearPendingUploads", () => clearPendingUploads()); diff --git a/desktop/src/main/services/upload.ts b/desktop/src/main/services/upload.ts index 1a1d343c5c..9b24cc0ead 100644 --- a/desktop/src/main/services/upload.ts +++ b/desktop/src/main/services/upload.ts @@ -6,7 +6,7 @@ import type { ElectronFile, PendingUploads, ZipItem } from "../../types/ipc"; import { uploadStatusStore } from "../stores/upload-status"; import { getZipFileStream } from "./fs"; -export const listZipEntries = async (zipPath: string): Promise => { +export const listZipItems = async (zipPath: string): Promise => { const zip = new StreamZip.async({ file: zipPath }); const entries = await zip.entries(); @@ -48,32 +48,34 @@ export const pendingUploads = async (): Promise => { const allFilePaths = uploadStatusStore.get("filePaths") ?? []; const filePaths = allFilePaths.filter((f) => existsSync(f)); - const allZipEntries = uploadStatusStore.get("zipEntries"); - let zipEntries: typeof allZipEntries; + const allZipItems = uploadStatusStore.get("zipItems"); + let zipItems: typeof allZipItems; // Migration code - May 2024. Remove after a bit. // - // The older store formats will not have zipEntries and instead will have + // The older store formats will not have zipItems and instead will have // zipPaths. If we find such a case, read the zipPaths and enqueue all of - // their files as zipEntries in the result. This potentially can be cause us - // to try reuploading an already uploaded file, but the dedup logic will - // kick in at that point so no harm will come off it. - if (allZipEntries === undefined) { + // their files as zipItems in the result. + // + // This potentially can be cause us to try reuploading an already uploaded + // file, but the dedup logic will kick in at that point so no harm will come + // off it. + if (allZipItems === undefined) { const allZipPaths = uploadStatusStore.get("filePaths"); const zipPaths = allZipPaths.filter((f) => existsSync(f)); - zipEntries = []; + zipItems = []; for (const zip of zipPaths) - zipEntries = zipEntries.concat(await listZipEntries(zip)); + zipItems = zipItems.concat(await listZipItems(zip)); } else { - zipEntries = allZipEntries.filter(([z]) => existsSync(z)); + zipItems = allZipItems.filter(([z]) => existsSync(z)); } - if (filePaths.length == 0 && zipEntries.length == 0) return undefined; + if (filePaths.length == 0 && zipItems.length == 0) return undefined; return { collectionName, filePaths, - zipItems: zipEntries, + zipItems, }; }; @@ -86,14 +88,14 @@ export const markUploadedFiles = async (paths: string[]) => { uploadStatusStore.set("filePaths", updated); }; -export const markUploadedZipEntries = async ( - entries: [zipPath: string, entryName: string][], +export const markUploadedZipItems = async ( + items: [zipPath: string, entryName: string][], ) => { - const existing = uploadStatusStore.get("zipEntries"); + const existing = uploadStatusStore.get("zipItems"); const updated = existing.filter( - (z) => !entries.some((e) => z[0] == e[0] && z[1] == e[1]), + (z) => !items.some((e) => z[0] == e[0] && z[1] == e[1]), ); - uploadStatusStore.set("zipEntries", updated); + uploadStatusStore.set("zipItems", updated); }; export const clearPendingUploads = () => uploadStatusStore.clear(); diff --git a/desktop/src/main/stores/upload-status.ts b/desktop/src/main/stores/upload-status.ts index 4fd6c9860e..472f38a7f9 100644 --- a/desktop/src/main/stores/upload-status.ts +++ b/desktop/src/main/stores/upload-status.ts @@ -21,7 +21,7 @@ export interface UploadStatusStore { */ zipItems?: [zipPath: string, entryName: string][]; /** - * @deprecated Legacy paths to zip files, now subsumed into zipEntries. + * @deprecated Legacy paths to zip files, now subsumed into zipItems. */ zipPaths?: string[]; } diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 48e4d1448c..61955b5240 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -241,8 +241,8 @@ const watchFindFiles = (folderPath: string): Promise => const pathForFile = (file: File) => webUtils.getPathForFile(file); -const listZipEntries = (zipPath: string): Promise => - ipcRenderer.invoke("listZipEntries", zipPath); +const listZipItems = (zipPath: string): Promise => + ipcRenderer.invoke("listZipItems", zipPath); const pathOrZipItemSize = (pathOrZipItem: string | ZipItem): Promise => ipcRenderer.invoke("pathOrZipItemSize", pathOrZipItem); @@ -256,9 +256,9 @@ const setPendingUploads = (pendingUploads: PendingUploads): Promise => const markUploadedFiles = (paths: PendingUploads["filePaths"]): Promise => ipcRenderer.invoke("markUploadedFiles", paths); -const markUploadedZipEntries = ( - zipEntries: PendingUploads["zipItems"], -): Promise => ipcRenderer.invoke("markUploadedZipEntries", zipEntries); +const markUploadedZipItems = ( + items: PendingUploads["zipItems"], +): Promise => ipcRenderer.invoke("markUploadedZipItems", items); const clearPendingUploads = (): Promise => ipcRenderer.invoke("clearPendingUploads"); @@ -372,11 +372,11 @@ contextBridge.exposeInMainWorld("electron", { // - Upload pathForFile, - listZipEntries, + listZipItems, pathOrZipItemSize, pendingUploads, setPendingUploads, markUploadedFiles, - markUploadedZipEntries, + markUploadedZipItems, clearPendingUploads, }); diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index c895894ccc..cd31d2d382 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -1,7 +1,7 @@ import { basename } from "@/next/file"; import log from "@/next/log"; import { type FileAndPath } from "@/next/types/file"; -import type { CollectionMapping, Electron, ZipEntry } from "@/next/types/ipc"; +import type { CollectionMapping, Electron, ZipItem } from "@/next/types/ipc"; import { CustomError } from "@ente/shared/error"; import { isPromise } from "@ente/shared/utils"; import DiscFullIcon from "@mui/icons-material/DiscFull"; @@ -127,15 +127,18 @@ export default function Uploader({ ); /** - * {@link File}s that the user drag-dropped or selected for uploads. This is - * the only type of selection that is possible when we're running in the - * browser. + * {@link File}s that the user drag-dropped or selected for uploads (web). + * + * This is the only type of selection that is possible when we're running in + * the browser. */ const [webFiles, setWebFiles] = useState([]); /** * {@link File}s that the user drag-dropped or selected for uploads, - * augmented with their paths. These siblings of {@link webFiles} come into - * play when we are running in the context of our desktop app. + * augmented with their paths (desktop). + * + * These siblings of {@link webFiles} come into play when we are running in + * the context of our desktop app. */ const [desktopFiles, setDesktopFiles] = useState([]); /** @@ -151,22 +154,24 @@ export default function Uploader({ const [desktopFilePaths, setDesktopFilePaths] = useState([]); /** * (zip file path, entry within zip file) tuples for zip files that the user - * is trying to upload. These are only set when we are running in the - * context of our desktop app. They may be set either on a user action (when - * the user selects or drag-drops zip files) or programmatically (when the - * app is trying to resume pending uploads from a previous session). + * is trying to upload. + * + * These are only set when we are running in the context of our desktop app. + * They may be set either on a user action (when the user selects or + * drag-drops zip files) or programmatically (when the app is trying to + * resume pending uploads from a previous session). */ - const [desktopZipEntries, setDesktopZipEntries] = useState([]); + const [desktopZipItems, setDesktopZipItems] = useState([]); /** * Consolidated and cleaned list obtained from {@link webFiles}, * {@link desktopFiles}, {@link desktopFilePaths} and - * {@link desktopZipEntries}. + * {@link desktopZipItems}. * * Augment each {@link UploadItem} with its "path" (relative path or name in * the case of {@link webFiles}, absolute path in the case of * {@link desktopFiles}, {@link desktopFilePaths}, and the path within the - * zip file for {@link desktopZipEntries}). + * zip file for {@link desktopZipItems}). * * See the documentation of {@link UploadItem} for more details. */ @@ -254,13 +259,13 @@ export default function Uploader({ electron.pendingUploads().then((pending) => { if (!pending) return; - const { collectionName, filePaths, zipEntries } = pending; + const { collectionName, filePaths, zipItems } = pending; log.info("Resuming pending upload", pending); isPendingDesktopUpload.current = true; pendingDesktopUploadCollectionName.current = collectionName; setDesktopFilePaths(filePaths); - setDesktopZipEntries(zipEntries); + setDesktopZipItems(zipItems); }); } }, [ @@ -286,10 +291,10 @@ export default function Uploader({ fileSelectorZipFiles, ].flat(); if (electron) { - desktopFilesAndZipEntries(electron, files).then( - ({ fileAndPaths, zipEntries }) => { + desktopFilesAndZipItems(electron, files).then( + ({ fileAndPaths, zipItems }) => { setDesktopFiles(fileAndPaths); - setDesktopZipEntries(zipEntries); + setDesktopZipItems(zipItems); }, ); } else { @@ -309,7 +314,7 @@ export default function Uploader({ webFiles.map((f) => [f, f["path"] ?? f.name]), desktopFiles.map((fp) => [fp, fp.path]), desktopFilePaths.map((p) => [p, p]), - desktopZipEntries.map((ze) => [ze, ze[1]]), + desktopZipItems.map((ze) => [ze, ze[1]]), ].flat() as [UploadItem, string][]; if (allItemAndPaths.length == 0) return; @@ -333,7 +338,7 @@ export default function Uploader({ setWebFiles([]); setDesktopFiles([]); setDesktopFilePaths([]); - setDesktopZipEntries([]); + setDesktopZipItems([]); // Remove hidden files (files whose names begins with a "."). const prunedItemAndPaths = allItemAndPaths.filter( @@ -423,7 +428,7 @@ export default function Uploader({ intent: CollectionSelectorIntent.upload, }); })(); - }, [webFiles, desktopFiles, desktopFilePaths, desktopZipEntries]); + }, [webFiles, desktopFiles, desktopFilePaths, desktopZipItems]); const preCollectionCreationAction = async () => { props.closeCollectionSelector?.(); @@ -764,23 +769,23 @@ async function waitAndRun( await task(); } -const desktopFilesAndZipEntries = async ( +const desktopFilesAndZipItems = async ( electron: Electron, files: File[], -): Promise<{ fileAndPaths: FileAndPath[]; zipEntries: ZipEntry[] }> => { +): Promise<{ fileAndPaths: FileAndPath[]; zipItems: ZipItem[] }> => { const fileAndPaths: FileAndPath[] = []; - let zipEntries: ZipEntry[] = []; + let zipItems: ZipItem[] = []; for (const file of files) { const path = electron.pathForFile(file); if (file.name.endsWith(".zip")) { - zipEntries = zipEntries.concat(await electron.listZipEntries(path)); + zipItems = zipItems.concat(await electron.listZipItems(path)); } else { fileAndPaths.push({ file, path }); } } - return { fileAndPaths, zipEntries }; + return { fileAndPaths, zipItems }; }; // This is used to prompt the user the make upload strategy choice @@ -891,14 +896,14 @@ export const setPendingUploads = async ( } const filePaths: string[] = []; - const zipEntries: ZipEntry[] = []; + const zipItems: ZipItem[] = []; for (const item of uploadItems) { if (item instanceof File) { throw new Error("Unexpected web file for a desktop pending upload"); } else if (typeof item == "string") { filePaths.push(item); } else if (Array.isArray(item)) { - zipEntries.push(item); + zipItems.push(item); } else { filePaths.push(item.path); } @@ -907,6 +912,6 @@ export const setPendingUploads = async ( await electron.setPendingUploads({ collectionName, filePaths, - zipEntries, + zipItems: zipItems, }); }; diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index 06772b2d29..44bfef92f5 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -4,7 +4,7 @@ import { ensureElectron } from "@/next/electron"; import { lowercaseExtension, nameAndExtension } from "@/next/file"; import log from "@/next/log"; import { type FileAndPath } from "@/next/types/file"; -import type { Electron, ZipEntry } from "@/next/types/ipc"; +import type { Electron, ZipItem } from "@/next/types/ipc"; import { ComlinkWorker } from "@/next/worker/comlink-worker"; import { ensure } from "@/utils/ensure"; import { getDedicatedCryptoWorker } from "@ente/shared/crypto"; @@ -105,9 +105,9 @@ const maxConcurrentUploads = 4; * selected the zip file, or it might be a zip file that they'd previously * selected but we now are resuming an interrupted upload. Either ways, what * we have is a path to zip file, and the name of an entry within that zip - * file. This is the {@link ZipEntry} case. + * file. This is the {@link ZipItem} case. */ -export type UploadItem = File | FileAndPath | string | ZipEntry; +export type UploadItem = File | FileAndPath | string | ZipItem; export interface UploadItemWithCollection { localID: number; @@ -840,7 +840,7 @@ const markUploaded = async (electron: Electron, item: ClusteredUploadItem) => { item.livePhotoAssets.video, ]; if (Array.isArray(p0) && Array.isArray(p1)) { - electron.markUploadedZipEntries([p0, p1]); + electron.markUploadedZipItems([p0, p1]); } else if (typeof p0 == "string" && typeof p1 == "string") { electron.markUploadedFiles([p0, p1]); } else if ( @@ -860,7 +860,7 @@ const markUploaded = async (electron: Electron, item: ClusteredUploadItem) => { } else { const p = ensure(item.uploadItem); if (Array.isArray(p)) { - electron.markUploadedZipEntries([p]); + electron.markUploadedZipItems([p]); } else if (typeof p == "string") { electron.markUploadedFiles([p]); } else if (p && typeof p == "object" && "path" in p) { @@ -1030,8 +1030,8 @@ const removePotentialLivePhotoSuffix = (name: string, suffix?: string) => { const uploadItemSize = async (uploadItem: UploadItem): Promise => { if (uploadItem instanceof File) return uploadItem.size; if (typeof uploadItem == "string") - return ensureElectron().pathOrZipEntrySize(uploadItem); + return ensureElectron().pathOrZipItemSize(uploadItem); if (Array.isArray(uploadItem)) - return ensureElectron().pathOrZipEntrySize(uploadItem); + return ensureElectron().pathOrZipItemSize(uploadItem); return uploadItem.file.size; }; diff --git a/web/apps/photos/src/utils/native-stream.ts b/web/apps/photos/src/utils/native-stream.ts index c882d5031a..d33d41e2fe 100644 --- a/web/apps/photos/src/utils/native-stream.ts +++ b/web/apps/photos/src/utils/native-stream.ts @@ -6,7 +6,7 @@ * See: [Note: IPC streams]. */ -import type { Electron, ZipEntry } from "@/next/types/ipc"; +import type { Electron, ZipItem } from "@/next/types/ipc"; /** * Stream the given file or zip entry from the user's local filesystem. @@ -35,7 +35,7 @@ import type { Electron, ZipEntry } from "@/next/types/ipc"; */ export const readStream = async ( _: Electron, - pathOrZipEntry: string | ZipEntry, + pathOrZipEntry: string | ZipItem, ): Promise<{ response: Response; size: number; lastModifiedMs: number }> => { let url: URL; if (typeof pathOrZipEntry == "string") { diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index 4b3d97dd32..6efa32f4c6 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -221,7 +221,7 @@ export interface Electron { * not yet possible, this function will throw an error with the * {@link CustomErrorMessage.NotAvailable} message. * - * @param dataOrPathOrZipEntry The file whose thumbnail we want to generate. + * @param dataOrPathOrZipItem The file whose thumbnail we want to generate. * It can be provided as raw image data (the contents of the image file), or * the path to the image file, or a tuple containing the path of the zip * file along with the name of an entry in it. @@ -234,14 +234,14 @@ export interface Electron { * @returns JPEG data of the generated thumbnail. */ generateImageThumbnail: ( - dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, + dataOrPathOrZipItem: Uint8Array | string | ZipItem, maxDimension: number, maxSize: number, ) => Promise; /** * Execute a FFmpeg {@link command} on the given - * {@link dataOrPathOrZipEntry}. + * {@link dataOrPathOrZipItem}. * * This executes the command using a FFmpeg executable we bundle with our * desktop app. We also have a wasm FFmpeg wasm implementation that we use @@ -254,7 +254,7 @@ export interface Electron { * (respectively {@link inputPathPlaceholder}, * {@link outputPathPlaceholder}, {@link ffmpegPathPlaceholder}). * - * @param dataOrPathOrZipEntry The bytes of the input file, or the path to + * @param dataOrPathOrZipItem The bytes of the input file, or the path to * the input file on the user's local disk, or the path to a zip file on the * user's disk and the name of an entry in it. In all three cases, the data * gets serialized to a temporary file, and then that path gets substituted @@ -274,7 +274,7 @@ export interface Electron { */ ffmpegExec: ( command: string[], - dataOrPathOrZipEntry: Uint8Array | string | ZipEntry, + dataOrPathOrZipItem: Uint8Array | string | ZipItem, outputFileExtension: string, timeoutMS: number, ) => Promise; @@ -491,13 +491,13 @@ export interface Electron { * * To read the contents of the files themselves, see [Note: IPC streams]. */ - listZipEntries: (zipPath: string) => Promise; + listZipItems: (zipPath: string) => Promise; /** * Return the size in bytes of the file at the given path or of a particular * entry within a zip file. */ - pathOrZipEntrySize: (pathOrZipEntry: string | ZipEntry) => Promise; + pathOrZipItemSize: (pathOrZipItem: string | ZipItem) => Promise; /** * Return any pending uploads that were previously enqueued but haven't yet @@ -518,7 +518,7 @@ export interface Electron { * - Typically, this would be called at the start of an upload. * * - Thereafter, as each item gets uploaded one by one, we'd call - * {@link markUploadedFiles} or {@link markUploadedZipEntries}. + * {@link markUploadedFiles} or {@link markUploadedZipItems}. * * - Finally, once the upload completes (or gets cancelled), we'd call * {@link clearPendingUploads} to complete the circle. @@ -532,11 +532,9 @@ export interface Electron { markUploadedFiles: (paths: PendingUploads["filePaths"]) => Promise; /** - * Mark the given zip file entries as having been uploaded. + * Mark the given {@link ZipItem}s as having been uploaded. */ - markUploadedZipEntries: ( - entries: PendingUploads["zipEntries"], - ) => Promise; + markUploadedZipItems: (items: PendingUploads["zipItems"]) => Promise; /** * Clear any pending uploads. @@ -627,15 +625,17 @@ export interface FolderWatchSyncedFile { } /** - * When the user uploads a zip file, we create a "zip entry" for each entry - * within that zip file. Such an entry is a tuple containin the path to a zip - * file itself, and the name of an entry within it. + * A particular file within a zip file. + * + * When the user uploads a zip file, we create a "zip item" for each entry + * within the zip file. Each such entry is a tuple containing the (path to a zip + * file itself, and the name of an entry within it). * * The name of the entry is not just the file name, but rather is the full path * of the file within the zip. That is, each entry name uniquely identifies a * particular file within the given zip. */ -export type ZipEntry = [zipPath: string, entryName: string]; +export type ZipItem = [zipPath: string, entryName: string]; /** * State about pending and in-progress uploads. @@ -659,7 +659,7 @@ export interface PendingUploads { */ filePaths: string[]; /** - * {@link ZipEntry} (zip path and entry name) that need to be uploaded. + * {@link ZipItem} (zip path and entry name) that need to be uploaded. */ - zipEntries: ZipEntry[]; + zipItems: ZipItem[]; } From 7ad4069b999e9cdc16491bc5f2ae967be4610a02 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 10:23:33 +0530 Subject: [PATCH 40/49] hobgoblins --- web/apps/photos/src/components/Upload/Uploader.tsx | 4 ++-- web/apps/photos/src/services/export/index.ts | 8 ++++---- web/apps/photos/src/services/ffmpeg.ts | 2 +- web/apps/photos/src/services/upload/thumbnail.ts | 2 +- web/apps/photos/src/services/upload/uploadManager.ts | 2 +- web/apps/photos/src/services/upload/uploadService.ts | 10 +++++----- web/apps/photos/src/utils/native-fs.ts | 2 +- web/apps/photos/src/utils/native-stream.ts | 8 ++++---- web/docs/storage.md | 2 +- web/packages/next/types/ipc.ts | 8 ++++---- 10 files changed, 24 insertions(+), 24 deletions(-) diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index cd31d2d382..f5cb19e8c1 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -148,8 +148,8 @@ export default function Uploader({ * Unlike {@link filesWithPaths} which are still user initiated, * {@link desktopFilePaths} can be set via programmatic action. For example, * if the user has setup a folder watch, and a new file is added on their - * local filesystem in one of the watched folders, then the relevant path of - * the new file would get added to {@link desktopFilePaths}. + * local file system in one of the watched folders, then the relevant path + * of the new file would get added to {@link desktopFilePaths}. */ const [desktopFilePaths, setDesktopFilePaths] = useState([]); /** diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index 5a732658a6..82dfdbf8bf 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -46,13 +46,13 @@ const exportRecordFileName = "export_status.json"; /** * Name of the top level directory which we create underneath the selected - * directory when the user starts an export to the filesystem. + * directory when the user starts an export to the file system. */ const exportDirectoryName = "Ente Photos"; /** - * Name of the directory in which we put our metadata when exporting to the - * filesystem. + * Name of the directory in which we put our metadata when exporting to the file + * system. */ export const exportMetadataDirectoryName = "metadata"; @@ -1378,7 +1378,7 @@ const isExportInProgress = (exportStage: ExportStage) => * * Also move its associated metadata JSON to Trash. * - * @param exportDir The root directory on the user's filesystem where we are + * @param exportDir The root directory on the user's file system where we are * exporting to. * */ const moveToTrash = async ( diff --git a/web/apps/photos/src/services/ffmpeg.ts b/web/apps/photos/src/services/ffmpeg.ts index a8b9bc3671..00d9a97351 100644 --- a/web/apps/photos/src/services/ffmpeg.ts +++ b/web/apps/photos/src/services/ffmpeg.ts @@ -50,7 +50,7 @@ const _generateVideoThumbnail = async ( * for the new files that the user is adding. * * @param dataOrPath The input video's data or the path to the video on the - * user's local filesystem. See: [Note: Reading a UploadItem]. + * user's local file system. See: [Note: Reading a UploadItem]. * * @returns JPEG data of the generated thumbnail. * diff --git a/web/apps/photos/src/services/upload/thumbnail.ts b/web/apps/photos/src/services/upload/thumbnail.ts index a44c941f16..9cd9a339ca 100644 --- a/web/apps/photos/src/services/upload/thumbnail.ts +++ b/web/apps/photos/src/services/upload/thumbnail.ts @@ -178,7 +178,7 @@ const percentageSizeDiff = ( * object which we use to perform IPC with the Node.js side of our desktop app. * * @param dataOrPath Contents of an image or video file, or the path to the - * image or video file on the user's local filesystem, whose thumbnail we want + * image or video file on the user's local file system, whose thumbnail we want * to generate. * * @param fileTypeInfo The type information for {@link dataOrPath}. diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index 44bfef92f5..be24d7da72 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -91,7 +91,7 @@ const maxConcurrentUploads = 4; * * 2. A file drag-and-dropped or selected by the user when we are running in the * context of our desktop app. In such cases, we also have the absolute path - * of the file in the user's local filesystem. this is the + * of the file in the user's local file system. this is the * {@link FileAndPath} case. * * 3. A file path programmatically requested by the desktop app. For example, we diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index 97848eeac2..a88e5c3aad 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -456,21 +456,21 @@ export const uploader = async ( }; /** - * Read the given file or path or zip entry into an in-memory representation. + * Read the given file or path or zip item into an in-memory representation. * * [Note: Reading a UploadItem] * * The file can be either a web * [File](https://developer.mozilla.org/en-US/docs/Web/API/File), the absolute * path to a file on desk, a combination of these two, or a entry in a zip file - * on the user's local filesystem. + * on the user's local file system. * * tl;dr; There are four cases: * * 1. web / File * 2. desktop / File (+ path) * 3. desktop / path - * 4. desktop / ZipEntry + * 4. desktop / ZipItem * * For the when and why, read on. * @@ -482,9 +482,9 @@ export const uploader = async ( * * In the web context, we'll always get a File, since within the browser we * cannot programmatically construct paths to or arbitrarily access files on the - * user's filesystem. Note that even if we were to have an absolute path at + * user's file system. Note that even if we were to have an absolute path at * hand, we cannot programmatically create such File objects to arbitrary - * absolute paths on user's local filesystem for security reasons. + * absolute paths on user's local file system for security reasons. * * So in the web context, this will always be a File we get as a result of an * explicit user interaction (e.g. drag and drop). diff --git a/web/apps/photos/src/utils/native-fs.ts b/web/apps/photos/src/utils/native-fs.ts index 2ef8963022..27ebdd1c12 100644 --- a/web/apps/photos/src/utils/native-fs.ts +++ b/web/apps/photos/src/utils/native-fs.ts @@ -1,5 +1,5 @@ /** - * @file Utilities for native filesystem access. + * @file Utilities for native file system access. * * While they don't have any direct dependencies to our desktop app, they were * written for use by the code that runs in our desktop app. diff --git a/web/apps/photos/src/utils/native-stream.ts b/web/apps/photos/src/utils/native-stream.ts index d33d41e2fe..1ecae06241 100644 --- a/web/apps/photos/src/utils/native-stream.ts +++ b/web/apps/photos/src/utils/native-stream.ts @@ -9,7 +9,7 @@ import type { Electron, ZipItem } from "@/next/types/ipc"; /** - * Stream the given file or zip entry from the user's local filesystem. + * Stream the given file or zip entry from the user's local file system. * * This only works when we're running in our desktop app since it uses the * "stream://" protocol handler exposed by our custom code in the Node.js layer. @@ -18,9 +18,9 @@ import type { Electron, ZipItem } from "@/next/types/ipc"; * To avoid accidentally invoking it in a non-desktop app context, it requires * the {@link Electron} object as a parameter (even though it doesn't use it). * - * @param pathOrZipEntry Either the path on the file on the user's local - * filesystem whose contents we want to stream. Or a tuple containing the path - * to a zip file and the name of the entry within it. + * @param pathOrZipEntry Either the path on the file on the user's local file + * system whose contents we want to stream. Or a tuple containing the path to a + * zip file and the name of the entry within it. * * @return A ({@link Response}, size, lastModifiedMs) triple. * diff --git a/web/docs/storage.md b/web/docs/storage.md index d01654b234..9f19a6a46d 100644 --- a/web/docs/storage.md +++ b/web/docs/storage.md @@ -34,6 +34,6 @@ meant for larger, tabular data. OPFS is used for caching entire files when we're running under Electron (the Web Cache API is used in the browser). -As it name suggests, it is an entire filesystem, private for us ("origin"). In +As it name suggests, it is an entire file system, private for us ("origin"). In is not undbounded though, and the storage is not guaranteed to be persistent (at least with the APIs we use), hence the cache designation. diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index 6efa32f4c6..173b12b17c 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -123,17 +123,17 @@ export interface Electron { skipAppUpdate: (version: string) => void; /** - * A subset of filesystem access APIs. + * A subset of file system access APIs. * * The renderer process, being a web process, does not have full access to - * the local filesystem apart from files explicitly dragged and dropped (or + * the local file system apart from files explicitly dragged and dropped (or * selected by the user in a native file open dialog). * - * The main process, however, has full filesystem access (limited only be an + * The main process, however, has full fil system access (limited only be an * OS level sandbox on the entire process). * * When we're running in the desktop app, we want to better utilize the - * local filesystem access to provide more integrated features to the user - + * local file system access to provide more integrated features to the user; * things that are not currently possible using web technologies. For * example, continuous exports to an arbitrary user chosen location on disk, * or watching some folders for changes and syncing them automatically. From 5f0103682b91dc0d966aa38bd1e56f0b9442b96a Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 10:24:41 +0530 Subject: [PATCH 41/49] entries --- web/apps/photos/src/utils/native-stream.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/web/apps/photos/src/utils/native-stream.ts b/web/apps/photos/src/utils/native-stream.ts index 1ecae06241..8ada6070cd 100644 --- a/web/apps/photos/src/utils/native-stream.ts +++ b/web/apps/photos/src/utils/native-stream.ts @@ -18,7 +18,7 @@ import type { Electron, ZipItem } from "@/next/types/ipc"; * To avoid accidentally invoking it in a non-desktop app context, it requires * the {@link Electron} object as a parameter (even though it doesn't use it). * - * @param pathOrZipEntry Either the path on the file on the user's local file + * @param pathOrZipItem Either the path on the file on the user's local file * system whose contents we want to stream. Or a tuple containing the path to a * zip file and the name of the entry within it. * @@ -35,20 +35,18 @@ import type { Electron, ZipItem } from "@/next/types/ipc"; */ export const readStream = async ( _: Electron, - pathOrZipEntry: string | ZipItem, + pathOrZipItem: string | ZipItem, ): Promise<{ response: Response; size: number; lastModifiedMs: number }> => { let url: URL; - if (typeof pathOrZipEntry == "string") { - url = new URL(`stream://read${pathOrZipEntry}`); + if (typeof pathOrZipItem == "string") { + url = new URL(`stream://read${pathOrZipItem}`); } else { - const [zipPath, entryName] = pathOrZipEntry; + const [zipPath, entryName] = pathOrZipItem; url = new URL(`stream://read${zipPath}`); url.hash = entryName; } - const req = new Request(url, { - method: "GET", - }); + const req = new Request(url, { method: "GET" }); const res = await fetch(req); if (!res.ok) From c1a3fb489621579f8cf2a9937a9d87f61a52121b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 10:31:33 +0530 Subject: [PATCH 42/49] docs --- .../src/services/upload/uploadManager.ts | 19 ++++--- .../src/services/upload/uploadService.ts | 49 ++++++++++--------- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index be24d7da72..bbf0f827ad 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -91,21 +91,24 @@ const maxConcurrentUploads = 4; * * 2. A file drag-and-dropped or selected by the user when we are running in the * context of our desktop app. In such cases, we also have the absolute path - * of the file in the user's local file system. this is the + * of the file in the user's local file system. This is the * {@link FileAndPath} case. * * 3. A file path programmatically requested by the desktop app. For example, we * might be resuming a previously interrupted upload after an app restart * (thus we no longer have access to the {@link File} from case 2). Or we * could be uploading a file this is in one of the folders the user has asked - * us to watch for changes. This is the {@link string} case. + * us to watch for changes. This is the `string` case. * - * 4. A file within a zip file. This too is only possible when we are running in - * the context of our desktop app. The user might have drag-and-dropped or - * selected the zip file, or it might be a zip file that they'd previously - * selected but we now are resuming an interrupted upload. Either ways, what - * we have is a path to zip file, and the name of an entry within that zip - * file. This is the {@link ZipItem} case. + * 4. A file within a zip file on the user's local file system. This too is only + * possible when we are running in the context of our desktop app. The user + * might have drag-and-dropped or selected a zip file, or it might be a zip + * file that they'd previously selected but we now are resuming an + * interrupted upload of. Either ways, what we have is a tuple containing the + * (path to zip file, and the name of an entry within that zip file). This is + * the {@link ZipItem} case. + * + * Also see: [Note: Reading a UploadItem]. */ export type UploadItem = File | FileAndPath | string | ZipItem; diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index a88e5c3aad..5aadb25644 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -482,45 +482,48 @@ export const uploader = async ( * * In the web context, we'll always get a File, since within the browser we * cannot programmatically construct paths to or arbitrarily access files on the - * user's file system. Note that even if we were to have an absolute path at - * hand, we cannot programmatically create such File objects to arbitrary - * absolute paths on user's local file system for security reasons. + * user's file system. + * + * > Note that even if we were to somehow have an absolute path at hand, we + * cannot programmatically create such File objects to arbitrary absolute + * paths on user's local file system for security reasons. * * So in the web context, this will always be a File we get as a result of an - * explicit user interaction (e.g. drag and drop). + * explicit user interaction (e.g. drag and drop or using a file selector). * * In the desktop context, this can be either a File (+ path), or a path, or an * entry within a zip file. * * 2. If the user provided us this file via some user interaction (say a drag - * and a drop), this'll still be a File. Note that unlike in the web context, - * such File objects also have the full path. See: [Note: File paths when - * running under Electron]. + * and a drop), this'll still be a File. But unlike in the web context, we + * also have access to the full path of this file. * - * 3. However, when running in the desktop app we have the ability to access - * absolute paths on the user's file system. For example, if the user asks us - * to watch certain folders on their disk for changes, we'll be able to pick - * up new images being added, and in such cases, the parameter here will be a - * path. Another example is when resuming an previously interrupted upload - - * we'll only have the path at hand in such cases, not the File object. + * 3. In addition, when running in the desktop app we have the ability to + * initate programmatic access absolute paths on the user's file system. For + * example, if the user asks us to watch certain folders on their disk for + * changes, we'll be able to pick up new images being added, and in such + * cases, the parameter here will be a path. Another example is when resuming + * an previously interrupted upload - we'll only have the path at hand in + * such cases, not the original File object since the app subsequently + * restarted. * - * 4. The user might've also initiated an upload of a zip file. In this case we - * will get a tuple (path to the zip file on the local file system, and the - * name of the entry within that zip file). + * 4. The user might've also initiated an upload of a zip file (or we might be + * resuming one). In such cases we will get a tuple (path to the zip file on + * the local file system, and the name of the entry within that zip file). * * Case 3 and 4, when we're provided a path, are simple. We don't have a choice, * since we cannot still programmatically construct a File object (we can * construct it on the Node.js layer, but it can't then be transferred over the * IPC boundary). So all our operations use the path itself. * - * Case 2 involves a choice on a use-case basis as neither File nor the path is - * a better choice for all use cases. + * Case 2 involves a choice on a use-case basis. Neither File nor the path is a + * better choice for all use cases. * - * The advantage of the File object is that the browser has already read it into - * memory for us. The disadvantage comes in the case where we need to - * communicate with the native Node.js layer of our desktop app. Since this - * communication happens over IPC, the File's contents need to be serialized and - * copied, which is a bummer for large videos etc. + * > The advantage of the File object is that the browser has already read it + * into memory for us. The disadvantage comes in the case where we need to + * communicate with the native Node.js layer of our desktop app. Since this + * communication happens over IPC, the File's contents need to be serialized + * and copied, which is a bummer for large videos etc. */ const readUploadItem = async (uploadItem: UploadItem): Promise => { let underlyingStream: ReadableStream; From 761fd560a18932e1ec5c5500d43ab22622ff17f1 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 10:43:12 +0530 Subject: [PATCH 43/49] Separate file --- web/apps/photos/src/services/ffmpeg.ts | 11 ++++--- .../photos/src/services/upload/takeout.ts | 2 +- web/apps/photos/src/services/upload/types.ts | 31 +++++++++++++++++ .../src/services/upload/uploadManager.ts | 33 ++----------------- .../src/services/upload/uploadService.ts | 3 +- 5 files changed, 42 insertions(+), 38 deletions(-) create mode 100644 web/apps/photos/src/services/upload/types.ts diff --git a/web/apps/photos/src/services/ffmpeg.ts b/web/apps/photos/src/services/ffmpeg.ts index 00d9a97351..f637b5bd2f 100644 --- a/web/apps/photos/src/services/ffmpeg.ts +++ b/web/apps/photos/src/services/ffmpeg.ts @@ -10,6 +10,7 @@ import { import { NULL_LOCATION } from "constants/upload"; import type { ParsedExtractedMetadata } from "types/metadata"; import type { DedicatedFFmpegWorker } from "worker/ffmpeg.worker"; +import type { UploadItem } from "./upload/types"; /** * Generate a thumbnail for the given video using a wasm FFmpeg running in a web @@ -92,18 +93,18 @@ const makeGenThumbnailCommand = (seekTime: number) => [ * This function is called during upload, when we need to extract the metadata * of videos that the user is uploading. * - * @param fileOrPath A {@link File}, or the absolute path to a file on the + * @param uploadItem A {@link File}, or the absolute path to a file on the * user's local filesytem. A path can only be provided when we're running in the * context of our desktop app. */ export const extractVideoMetadata = async ( - fileOrPath: File | string, + uploadItem: UploadItem, ): Promise => { const command = extractVideoMetadataCommand; const outputData = - fileOrPath instanceof File - ? await ffmpegExecWeb(command, fileOrPath, "txt", 0) - : await electron.ffmpegExec(command, fileOrPath, "txt", 0); + uploadItem instanceof File + ? await ffmpegExecWeb(command, uploadItem, "txt", 0) + : await electron.ffmpegExec(command, uploadItem, "txt", 0); return parseFFmpegExtractedMetadata(outputData); }; diff --git a/web/apps/photos/src/services/upload/takeout.ts b/web/apps/photos/src/services/upload/takeout.ts index 2a71e420a0..24c0a9d267 100644 --- a/web/apps/photos/src/services/upload/takeout.ts +++ b/web/apps/photos/src/services/upload/takeout.ts @@ -6,7 +6,7 @@ import log from "@/next/log"; import { NULL_LOCATION } from "constants/upload"; import type { Location } from "types/metadata"; import { readStream } from "utils/native-stream"; -import type { UploadItem } from "./uploadManager"; +import type { UploadItem } from "./types"; export interface ParsedMetadataJSON { creationTime: number; diff --git a/web/apps/photos/src/services/upload/types.ts b/web/apps/photos/src/services/upload/types.ts new file mode 100644 index 0000000000..25a79de1a2 --- /dev/null +++ b/web/apps/photos/src/services/upload/types.ts @@ -0,0 +1,31 @@ +import type { FileAndPath } from "@/next/types/file"; +import type { ZipItem } from "@/next/types/ipc"; + +/** + * An item to upload is one of the following: + * + * 1. A file drag-and-dropped or selected by the user when we are running in the + * web browser. These is the {@link File} case. + * + * 2. A file drag-and-dropped or selected by the user when we are running in the + * context of our desktop app. In such cases, we also have the absolute path + * of the file in the user's local file system. This is the + * {@link FileAndPath} case. + * + * 3. A file path programmatically requested by the desktop app. For example, we + * might be resuming a previously interrupted upload after an app restart + * (thus we no longer have access to the {@link File} from case 2). Or we + * could be uploading a file this is in one of the folders the user has asked + * us to watch for changes. This is the `string` case. + * + * 4. A file within a zip file on the user's local file system. This too is only + * possible when we are running in the context of our desktop app. The user + * might have drag-and-dropped or selected a zip file, or it might be a zip + * file that they'd previously selected but we now are resuming an + * interrupted upload of. Either ways, what we have is a tuple containing the + * (path to zip file, and the name of an entry within that zip file). This is + * the {@link ZipItem} case. + * + * Also see: [Note: Reading a UploadItem]. + */ +export type UploadItem = File | FileAndPath | string | ZipItem; diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index bbf0f827ad..99fe6ced39 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -3,8 +3,7 @@ import { potentialFileTypeFromExtension } from "@/media/live-photo"; import { ensureElectron } from "@/next/electron"; import { lowercaseExtension, nameAndExtension } from "@/next/file"; import log from "@/next/log"; -import { type FileAndPath } from "@/next/types/file"; -import type { Electron, ZipItem } from "@/next/types/ipc"; +import type { Electron } from "@/next/types/ipc"; import { ComlinkWorker } from "@/next/worker/comlink-worker"; import { ensure } from "@/utils/ensure"; import { getDedicatedCryptoWorker } from "@ente/shared/crypto"; @@ -36,6 +35,7 @@ import { tryParseTakeoutMetadataJSON, type ParsedMetadataJSON, } from "./takeout"; +import type { UploadItem } from "./types"; import UploadService, { uploadItemFileName, uploader } from "./uploadService"; export type FileID = number; @@ -83,35 +83,6 @@ export interface ProgressUpdater { /** The number of uploads to process in parallel. */ const maxConcurrentUploads = 4; -/** - * An item to upload is one of the following: - * - * 1. A file drag-and-dropped or selected by the user when we are running in the - * web browser. These is the {@link File} case. - * - * 2. A file drag-and-dropped or selected by the user when we are running in the - * context of our desktop app. In such cases, we also have the absolute path - * of the file in the user's local file system. This is the - * {@link FileAndPath} case. - * - * 3. A file path programmatically requested by the desktop app. For example, we - * might be resuming a previously interrupted upload after an app restart - * (thus we no longer have access to the {@link File} from case 2). Or we - * could be uploading a file this is in one of the folders the user has asked - * us to watch for changes. This is the `string` case. - * - * 4. A file within a zip file on the user's local file system. This too is only - * possible when we are running in the context of our desktop app. The user - * might have drag-and-dropped or selected a zip file, or it might be a zip - * file that they'd previously selected but we now are resuming an - * interrupted upload of. Either ways, what we have is a tuple containing the - * (path to zip file, and the name of an entry within that zip file). This is - * the {@link ZipItem} case. - * - * Also see: [Note: Reading a UploadItem]. - */ -export type UploadItem = File | FileAndPath | string | ZipItem; - export interface UploadItemWithCollection { localID: number; collectionID: number; diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index 5aadb25644..8c042ccaf3 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -50,8 +50,9 @@ import { generateThumbnailNative, generateThumbnailWeb, } from "./thumbnail"; +import type { UploadItem } from "./types"; import UploadHttpClient from "./uploadHttpClient"; -import type { UploadItem, UploadableUploadItem } from "./uploadManager"; +import type { UploadableUploadItem } from "./uploadManager"; /** * A readable stream for a file, and its associated size and last modified time. From a5177a37423c51441dd6b9071746014b85acf3c4 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 10:51:20 +0530 Subject: [PATCH 44/49] fore --- web/apps/photos/src/services/ffmpeg.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/apps/photos/src/services/ffmpeg.ts b/web/apps/photos/src/services/ffmpeg.ts index f637b5bd2f..dbdc53a3c5 100644 --- a/web/apps/photos/src/services/ffmpeg.ts +++ b/web/apps/photos/src/services/ffmpeg.ts @@ -104,11 +104,21 @@ export const extractVideoMetadata = async ( const outputData = uploadItem instanceof File ? await ffmpegExecWeb(command, uploadItem, "txt", 0) - : await electron.ffmpegExec(command, uploadItem, "txt", 0); + : await electron.ffmpegExec(command, forE(uploadItem), "txt", 0); return parseFFmpegExtractedMetadata(outputData); }; +/** + * For each of cases of {@link UploadItem} that apply when we're running in the + * context of our desktop app, return a value that can be passed to + * {@link Electron}'s {@link ffmpegExec} over IPC. + */ +const forE = (desktopUploadItem: Exclude) => + typeof desktopUploadItem == "string" || Array.isArray(desktopUploadItem) + ? desktopUploadItem + : desktopUploadItem.path; + // Options: // // - `-c [short for codex] copy` From 68f3f1e714e2c508aedcdd64a54fa8050c409427 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 11:01:50 +0530 Subject: [PATCH 45/49] more --- web/apps/photos/src/services/ffmpeg.ts | 27 +++++++++---------- .../photos/src/services/upload/thumbnail.ts | 7 ++--- web/apps/photos/src/services/upload/types.ts | 16 +++++++++++ .../src/services/upload/uploadService.ts | 22 +++++++-------- 4 files changed, 44 insertions(+), 28 deletions(-) diff --git a/web/apps/photos/src/services/ffmpeg.ts b/web/apps/photos/src/services/ffmpeg.ts index dbdc53a3c5..4dfdb3f641 100644 --- a/web/apps/photos/src/services/ffmpeg.ts +++ b/web/apps/photos/src/services/ffmpeg.ts @@ -10,7 +10,11 @@ import { import { NULL_LOCATION } from "constants/upload"; import type { ParsedExtractedMetadata } from "types/metadata"; import type { DedicatedFFmpegWorker } from "worker/ffmpeg.worker"; -import type { UploadItem } from "./upload/types"; +import { + toDataOrPathOrZipEntry, + type DesktopUploadItem, + type UploadItem, +} from "./upload/types"; /** * Generate a thumbnail for the given video using a wasm FFmpeg running in a web @@ -59,12 +63,12 @@ const _generateVideoThumbnail = async ( */ export const generateVideoThumbnailNative = async ( electron: Electron, - dataOrPath: Uint8Array | string, + desktopUploadItem: DesktopUploadItem, ) => _generateVideoThumbnail((seekTime: number) => electron.ffmpegExec( makeGenThumbnailCommand(seekTime), - dataOrPath, + toDataOrPathOrZipEntry(desktopUploadItem), "jpeg", 0, ), @@ -104,21 +108,16 @@ export const extractVideoMetadata = async ( const outputData = uploadItem instanceof File ? await ffmpegExecWeb(command, uploadItem, "txt", 0) - : await electron.ffmpegExec(command, forE(uploadItem), "txt", 0); + : await electron.ffmpegExec( + command, + toDataOrPathOrZipEntry(uploadItem), + "txt", + 0, + ); return parseFFmpegExtractedMetadata(outputData); }; -/** - * For each of cases of {@link UploadItem} that apply when we're running in the - * context of our desktop app, return a value that can be passed to - * {@link Electron}'s {@link ffmpegExec} over IPC. - */ -const forE = (desktopUploadItem: Exclude) => - typeof desktopUploadItem == "string" || Array.isArray(desktopUploadItem) - ? desktopUploadItem - : desktopUploadItem.path; - // Options: // // - `-c [short for codex] copy` diff --git a/web/apps/photos/src/services/upload/thumbnail.ts b/web/apps/photos/src/services/upload/thumbnail.ts index 9cd9a339ca..1dd448376e 100644 --- a/web/apps/photos/src/services/upload/thumbnail.ts +++ b/web/apps/photos/src/services/upload/thumbnail.ts @@ -4,6 +4,7 @@ import { type Electron } from "@/next/types/ipc"; import { withTimeout } from "@ente/shared/utils"; import * as ffmpeg from "services/ffmpeg"; import { heicToJPEG } from "services/heic-convert"; +import { toDataOrPathOrZipEntry, type DesktopUploadItem } from "./types"; /** Maximum width or height of the generated thumbnail */ const maxThumbnailDimension = 720; @@ -189,16 +190,16 @@ const percentageSizeDiff = ( */ export const generateThumbnailNative = async ( electron: Electron, - dataOrPath: Uint8Array | string, + desktopUploadItem: DesktopUploadItem, fileTypeInfo: FileTypeInfo, ): Promise => fileTypeInfo.fileType === FILE_TYPE.IMAGE ? await electron.generateImageThumbnail( - dataOrPath, + toDataOrPathOrZipEntry(desktopUploadItem), maxThumbnailDimension, maxThumbnailSize, ) - : ffmpeg.generateVideoThumbnailNative(electron, dataOrPath); + : ffmpeg.generateVideoThumbnailNative(electron, desktopUploadItem); /** * A fallback, black, thumbnail for use in cases where thumbnail generation diff --git a/web/apps/photos/src/services/upload/types.ts b/web/apps/photos/src/services/upload/types.ts index 25a79de1a2..05ad332d4a 100644 --- a/web/apps/photos/src/services/upload/types.ts +++ b/web/apps/photos/src/services/upload/types.ts @@ -29,3 +29,19 @@ import type { ZipItem } from "@/next/types/ipc"; * Also see: [Note: Reading a UploadItem]. */ export type UploadItem = File | FileAndPath | string | ZipItem; + +/** + * The of cases of {@link UploadItem} that apply when we're running in the + * context of our desktop app. + */ +export type DesktopUploadItem = Exclude; + +/** + * For each of cases of {@link UploadItem} that apply when we're running in the + * context of our desktop app, return a value that can be passed to + * {@link Electron} functions over IPC. + */ +export const toDataOrPathOrZipEntry = (desktopUploadItem: DesktopUploadItem) => + typeof desktopUploadItem == "string" || Array.isArray(desktopUploadItem) + ? desktopUploadItem + : desktopUploadItem.path; diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index 8c042ccaf3..6dc1bbd493 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -1012,14 +1012,12 @@ const withThumbnail = async ( fileTypeInfo.fileType == FILE_TYPE.IMAGE && moduleState.isNativeImageThumbnailGenerationNotAvailable; - // 1. Native thumbnail generation using file's path. - if (electron && !notAvailable) { + // 1. Native thumbnail generation using items's (effective) path. + if (electron && !notAvailable && !(uploadItem instanceof File)) { try { - // When running in the context of our desktop app, File paths will - // be absolute. See: [Note: File paths when running under Electron]. thumbnail = await generateThumbnailNative( electron, - uploadItem instanceof File ? uploadItem["path"] : uploadItem, + uploadItem, fileTypeInfo, ); } catch (e) { @@ -1051,12 +1049,14 @@ const withThumbnail = async ( // The fallback in this case involves reading the entire stream into // memory, and passing that data across the IPC boundary in a single // go (i.e. not in a streaming manner). This is risky for videos of - // unbounded sizes, plus that isn't the expected scenario. So - // instead of trying to cater for arbitrary exceptions, we only run - // this fallback to cover for the case where thumbnail generation - // was not available for an image file on Windows. If/when we add - // support of native thumbnailing on Windows too, this entire branch - // can be removed. + // unbounded sizes, plus we shouldn't even be getting here unless + // something went wrong. + // + // So instead of trying to cater for arbitrary exceptions, we only + // run this fallback to cover for the case where thumbnail + // generation was not available for an image file on Windows. + // If/when we add support of native thumbnailing on Windows too, + // this entire branch can be removed. if (fileTypeInfo.fileType == FILE_TYPE.IMAGE) { const data = await readEntireStream(fileStream.stream); From 1f110929b2d7d439d801476ece34a1e253333566 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 11:05:53 +0530 Subject: [PATCH 46/49] connect --- .../PhotoViewer/ImageEditorOverlay/index.tsx | 2 +- .../photos/src/components/Upload/Uploader.tsx | 2 +- .../photos/src/services/upload/uploadService.ts | 16 ++++++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx index 3c7b6a9cab..42edddbf11 100644 --- a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx @@ -507,7 +507,7 @@ const ImageEditorOverlay = (props: IProps) => { const editedFile = await getEditedFile(); const file = { - fileOrPath: editedFile, + uploadItem: editedFile, localID: 1, collectionID: props.file.collectionID, }; diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index f5cb19e8c1..eb7595050b 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -20,12 +20,12 @@ import { getPublicCollectionUploaderName, savePublicCollectionUploaderName, } from "services/publicCollectionService"; +import type { UploadItem } from "services/upload/types"; import type { InProgressUpload, SegregatedFinishedUploads, UploadCounter, UploadFileNames, - UploadItem, UploadItemWithCollection, } from "services/upload/uploadManager"; import uploadManager from "services/upload/uploadManager"; diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index 6dc1bbd493..7d33038842 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -57,8 +57,8 @@ import type { UploadableUploadItem } from "./uploadManager"; /** * A readable stream for a file, and its associated size and last modified time. * - * This is the in-memory representation of the `fileOrPath` type that we usually - * pass around. See: [Note: Reading a UploadItem] + * This is the in-memory representation of the {@link UploadItem} type that we + * usually pass around. See: [Note: Reading a UploadItem] */ interface FileStream { /** @@ -602,11 +602,11 @@ interface ReadAssetDetailsResult { const readAssetDetails = async ({ isLivePhoto, livePhotoAssets, - uploadItem: fileOrPath, + uploadItem, }: UploadAsset): Promise => isLivePhoto ? readLivePhotoDetails(livePhotoAssets) - : readImageOrVideoDetails(fileOrPath); + : readImageOrVideoDetails(uploadItem); const readLivePhotoDetails = async ({ image, video }: LivePhotoAssets) => { const img = await readImageOrVideoDetails(image); @@ -941,12 +941,12 @@ const readLivePhoto = async ( // the entire stream into memory and pass the resultant data. // // This is a reasonable behaviour since the videos corresponding to live - // photos are only a couple of seconds long (we have already done a - // pre-flight check to ensure their size is small in `areLivePhotoAssets`). + // photos are only a couple of seconds long (we've already done a pre-flight + // check during areLivePhotoAssets to ensure their size is small). const fileOrData = async (sd: FileStream | Uint8Array) => { - const _fs = async ({ file, stream }: FileStream) => + const fos = async ({ file, stream }: FileStream) => file ? file : await readEntireStream(stream); - return sd instanceof Uint8Array ? sd : _fs(sd); + return sd instanceof Uint8Array ? sd : fos(sd); }; return { From 8ee9b2be32734d0dda8e398867270262f3fcf625 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 11:32:25 +0530 Subject: [PATCH 47/49] Use only the currently uploaded items --- .../photos/src/components/Upload/Uploader.tsx | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index eb7595050b..62d06971e3 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -284,12 +284,26 @@ export default function Uploader({ return; } - const files = [ - dragAndDropFiles, - fileSelectorFiles, - folderSelectorFiles, - fileSelectorZipFiles, - ].flat(); + let files: File[]; + + switch (pickedUploadType.current) { + case PICKED_UPLOAD_TYPE.FILES: + files = fileSelectorFiles; + break; + + case PICKED_UPLOAD_TYPE.FOLDERS: + files = folderSelectorFiles; + break; + + case PICKED_UPLOAD_TYPE.ZIPS: + files = fileSelectorZipFiles; + break; + + default: + files = dragAndDropFiles; + break; + } + if (electron) { desktopFilesAndZipItems(electron, files).then( ({ fileAndPaths, zipItems }) => { From ab95b4daeea46c1df323ae38fec7d2cc1ca74324 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 11:50:15 +0530 Subject: [PATCH 48/49] Inline --- .../photos/src/components/Upload/Uploader.tsx | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index 62d06971e3..f99b768ca9 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -209,6 +209,7 @@ export default function Uploader({ setChoiceModalView(false); uploadRunning.current = false; }; + const handleCollectionSelectorCancel = () => { uploadRunning.current = false; }; @@ -234,6 +235,7 @@ export default function Uploader({ publicCollectionGalleryContext, appContext.isCFProxyDisabled, ); + if (uploadManager.isUploadRunning()) { setUploadProgressView(true); } @@ -709,28 +711,19 @@ export default function Uploader({ } }; - const handleUploadToSingleCollection = () => { - uploadToSingleNewCollection(importSuggestion.rootFolderName); - }; - - const handleUploadToMultipleCollections = () => { - if (importSuggestion.hasRootLevelFileWithFolder) { - appContext.setDialogMessage( - getRootLevelFileWithFolderNotAllowMessage(), - ); - return; - } - uploadFilesToNewCollections("parent"); - }; - const didSelectCollectionMapping = (mapping: CollectionMapping) => { switch (mapping) { case "root": - handleUploadToSingleCollection(); + uploadToSingleNewCollection(importSuggestion.rootFolderName); break; case "parent": - handleUploadToMultipleCollections(); - break; + if (importSuggestion.hasRootLevelFileWithFolder) { + appContext.setDialogMessage( + getRootLevelFileWithFolderNotAllowMessage(), + ); + } else { + uploadFilesToNewCollections("parent"); + } } }; From e2cd1ea380299a593a20bc30d94a36069a4c5ef3 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Apr 2024 12:03:05 +0530 Subject: [PATCH 49/49] Fallback --- .../photos/src/components/Upload/Uploader.tsx | 22 +++++++++++++++---- .../next/locales/en-US/translation.json | 4 +++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index f99b768ca9..fdc6ee9329 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -51,8 +51,6 @@ import { CollectionMappingChoiceModal } from "./CollectionMappingChoiceModal"; import UploadProgress from "./UploadProgress"; import UploadTypeSelector from "./UploadTypeSelector"; -const FIRST_ALBUM_NAME = "My First Album"; - enum PICKED_UPLOAD_TYPE { FILES = "files", FOLDERS = "folders", @@ -415,7 +413,9 @@ export default function Uploader({ } if (isFirstUpload && !importSuggestion.rootFolderName) { - importSuggestion.rootFolderName = FIRST_ALBUM_NAME; + importSuggestion.rootFolderName = t( + "autogenerated_first_album_name", + ); } if (isDragAndDrop.current) { @@ -714,7 +714,21 @@ export default function Uploader({ const didSelectCollectionMapping = (mapping: CollectionMapping) => { switch (mapping) { case "root": - uploadToSingleNewCollection(importSuggestion.rootFolderName); + uploadToSingleNewCollection( + // rootFolderName would be empty here if one edge case: + // - User drags and drops a mixture of files and folders + // - They select the "upload to multiple albums" option + // - The see the error, close the error + // - Then they select the "upload to single album" option + // + // In such a flow, we'll reach here with an empty + // rootFolderName. The proper fix for this would be + // rearrange the flow and ask them to name the album here, + // but we currently don't have support for chaining modals. + // So in the meanwhile, keep a fallback album name at hand. + importSuggestion.rootFolderName ?? + t("autogenerated_default_album_name"), + ); break; case "parent": if (importSuggestion.hasRootLevelFileWithFolder) { diff --git a/web/packages/next/locales/en-US/translation.json b/web/packages/next/locales/en-US/translation.json index 5fdb380d5b..b3debe5aa0 100644 --- a/web/packages/next/locales/en-US/translation.json +++ b/web/packages/next/locales/en-US/translation.json @@ -621,5 +621,7 @@ "PASSKEY_LOGIN_ERRORED": "An error occurred while logging in with passkey.", "TRY_AGAIN": "Try again", "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "Follow the steps from your browser to continue logging in.", - "LOGIN_WITH_PASSKEY": "Login with passkey" + "LOGIN_WITH_PASSKEY": "Login with passkey", + "autogenerated_first_album_name": "My First Album", + "autogenerated_default_album_name": "New Album" }