diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 8526e2363..467d9c881 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -24,9 +24,10 @@ import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc"; import log, { initLogging } from "./main/log"; import { createApplicationMenu, createTrayContextMenu } from "./main/menu"; import { setupAutoUpdater } from "./main/services/app-update"; -import autoLauncher from "./main/services/autoLauncher"; -import { initWatcher } from "./main/services/chokidar"; +import autoLauncher from "./main/services/auto-launcher"; +import { createWatcher } from "./main/services/watch"; import { userPreferences } from "./main/stores/user-preferences"; +import { migrateLegacyWatchStoreIfNeeded } from "./main/stores/watch"; import { registerStreamProtocol } from "./main/stream"; import { isDev } from "./main/util"; @@ -196,8 +197,6 @@ const createMainWindow = async () => { window.maximize(); } - window.loadURL(rendererURL); - // Open the DevTools automatically when running in dev mode if (isDev) window.webContents.openDevTools(); @@ -296,6 +295,21 @@ const deleteLegacyDiskCacheDirIfExists = async () => { } }; +/** + * Older versions of our app used to keep a keys.json. It is not needed anymore, + * remove it if it exists. + * + * This code was added March 2024, and can be removed after some time once most + * people have upgraded to newer versions. + */ +const deleteLegacyKeysStoreIfExists = async () => { + const keysStore = path.join(app.getPath("userData"), "keys.json"); + if (existsSync(keysStore)) { + log.info(`Removing legacy keys store at ${keysStore}`); + await fs.rm(keysStore); + } +}; + const main = () => { const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { @@ -311,6 +325,7 @@ const main = () => { setupRendererServer(); registerPrivilegedSchemes(); increaseDiskCache(); + migrateLegacyWatchStoreIfNeeded(); app.on("second-instance", () => { // Someone tried to run a second instance, we should focus our window. @@ -325,19 +340,26 @@ const main = () => { // // Note that some Electron APIs can only be used after this event occurs. app.on("ready", async () => { + // Create window and prepare for renderer mainWindow = await createMainWindow(); - Menu.setApplicationMenu(await createApplicationMenu(mainWindow)); - setupTrayItem(mainWindow); attachIPCHandlers(); - attachFSWatchIPCHandlers(initWatcher(mainWindow)); + attachFSWatchIPCHandlers(createWatcher(mainWindow)); registerStreamProtocol(); - if (!isDev) setupAutoUpdater(mainWindow); handleDownloads(mainWindow); handleExternalLinks(mainWindow); addAllowOriginHeader(mainWindow); + // Start loading the renderer + mainWindow.loadURL(rendererURL); + + // Continue on with the rest of the startup sequence + Menu.setApplicationMenu(await createApplicationMenu(mainWindow)); + setupTrayItem(mainWindow); + if (!isDev) setupAutoUpdater(mainWindow); + try { deleteLegacyDiskCacheDirIfExists(); + deleteLegacyKeysStoreIfExists(); } catch (e) { // Log but otherwise ignore errors during non-critical startup // actions. diff --git a/desktop/src/main/fs.ts b/desktop/src/main/fs.ts index 36de710c3..2428d3a80 100644 --- a/desktop/src/main/fs.ts +++ b/desktop/src/main/fs.ts @@ -22,10 +22,8 @@ export const fsReadTextFile = async (filePath: string) => export const fsWriteFile = (path: string, contents: string) => fs.writeFile(path, contents); -/* TODO: Audit below this */ - -export const isFolder = async (dirPath: string) => { +export const fsIsDir = async (dirPath: string) => { if (!existsSync(dirPath)) return false; - const stats = await fs.stat(dirPath); - return stats.isDirectory(); + const stat = await fs.stat(dirPath); + return stat.isDirectory(); }; diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index bd29057da..eab2e8b59 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -10,7 +10,12 @@ import type { FSWatcher } from "chokidar"; import { ipcMain } from "electron/main"; -import type { ElectronFile, FILE_PATH_TYPE, WatchMapping } from "../types/ipc"; +import type { + CollectionMapping, + ElectronFile, + FolderWatch, + PendingUploads, +} from "../types/ipc"; import { selectDirectory, showUploadDirsDialog, @@ -19,13 +24,13 @@ import { } from "./dialogs"; import { fsExists, + fsIsDir, fsMkdirIfNeeded, fsReadTextFile, fsRename, fsRm, fsRmdir, fsWriteFile, - isFolder, } from "./fs"; import { logToDisk } from "./log"; import { @@ -49,16 +54,17 @@ import { } from "./services/store"; import { getElectronFilesFromGoogleZip, - getPendingUploads, - setToUploadCollection, - setToUploadFiles, + pendingUploads, + setPendingUploadCollection, + setPendingUploadFiles, } from "./services/upload"; import { - addWatchMapping, - getWatchMappings, - removeWatchMapping, - updateWatchMappingIgnoredFiles, - updateWatchMappingSyncedFiles, + watchAdd, + watchFindFiles, + watchGet, + watchRemove, + watchUpdateIgnoredFiles, + watchUpdateSyncedFiles, } from "./services/watch"; import { openDirectory, openLogDirectory } from "./util"; @@ -132,6 +138,8 @@ export const attachIPCHandlers = () => { fsWriteFile(path, contents), ); + ipcMain.handle("fsIsDir", (_, dirPath: string) => fsIsDir(dirPath)); + // - Conversion ipcMain.handle("convertToJPEG", (_, fileData, filename) => @@ -183,28 +191,26 @@ export const attachIPCHandlers = () => { ipcMain.handle("showUploadZipDialog", () => showUploadZipDialog()); - // - FS Legacy - - ipcMain.handle("isFolder", (_, dirPath: string) => isFolder(dirPath)); - // - Upload - ipcMain.handle("getPendingUploads", () => getPendingUploads()); + ipcMain.handle("pendingUploads", () => pendingUploads()); + + ipcMain.handle("setPendingUploadCollection", (_, collectionName: string) => + setPendingUploadCollection(collectionName), + ); ipcMain.handle( - "setToUploadFiles", - (_, type: FILE_PATH_TYPE, filePaths: string[]) => - setToUploadFiles(type, filePaths), + "setPendingUploadFiles", + (_, type: PendingUploads["type"], filePaths: string[]) => + setPendingUploadFiles(type, filePaths), ); + // - + ipcMain.handle("getElectronFilesFromGoogleZip", (_, filePath: string) => getElectronFilesFromGoogleZip(filePath), ); - ipcMain.handle("setToUploadCollection", (_, collectionName: string) => - setToUploadCollection(collectionName), - ); - ipcMain.handle("getDirFiles", (_, dirPath: string) => getDirFiles(dirPath)); }; @@ -213,42 +219,36 @@ export const attachIPCHandlers = () => { * watch folder functionality. * * It gets passed a {@link FSWatcher} instance which it can then forward to the - * actual handlers. + * actual handlers if they need access to it to do their thing. */ export const attachFSWatchIPCHandlers = (watcher: FSWatcher) => { // - Watch - ipcMain.handle( - "addWatchMapping", - ( - _, - collectionName: string, - folderPath: string, - uploadStrategy: number, - ) => - addWatchMapping( - watcher, - collectionName, - folderPath, - uploadStrategy, - ), - ); - - ipcMain.handle("removeWatchMapping", (_, folderPath: string) => - removeWatchMapping(watcher, folderPath), - ); - - ipcMain.handle("getWatchMappings", () => getWatchMappings()); + ipcMain.handle("watchGet", () => watchGet(watcher)); ipcMain.handle( - "updateWatchMappingSyncedFiles", - (_, folderPath: string, files: WatchMapping["syncedFiles"]) => - updateWatchMappingSyncedFiles(folderPath, files), + "watchAdd", + (_, folderPath: string, collectionMapping: CollectionMapping) => + watchAdd(watcher, folderPath, collectionMapping), + ); + + ipcMain.handle("watchRemove", (_, folderPath: string) => + watchRemove(watcher, folderPath), ); ipcMain.handle( - "updateWatchMappingIgnoredFiles", - (_, folderPath: string, files: WatchMapping["ignoredFiles"]) => - updateWatchMappingIgnoredFiles(folderPath, files), + "watchUpdateSyncedFiles", + (_, syncedFiles: FolderWatch["syncedFiles"], folderPath: string) => + watchUpdateSyncedFiles(syncedFiles, folderPath), + ); + + ipcMain.handle( + "watchUpdateIgnoredFiles", + (_, ignoredFiles: FolderWatch["ignoredFiles"], folderPath: string) => + watchUpdateIgnoredFiles(ignoredFiles, folderPath), + ); + + ipcMain.handle("watchFindFiles", (_, folderPath: string) => + watchFindFiles(folderPath), ); }; diff --git a/desktop/src/main/menu.ts b/desktop/src/main/menu.ts index 3441f3f2a..bd8810428 100644 --- a/desktop/src/main/menu.ts +++ b/desktop/src/main/menu.ts @@ -7,7 +7,7 @@ import { } from "electron"; import { allowWindowClose } from "../main"; import { forceCheckForAppUpdates } from "./services/app-update"; -import autoLauncher from "./services/autoLauncher"; +import autoLauncher from "./services/auto-launcher"; import { userPreferences } from "./stores/user-preferences"; import { openLogDirectory } from "./util"; diff --git a/desktop/src/main/platform.ts b/desktop/src/main/platform.ts deleted file mode 100644 index 1c3bb4584..000000000 --- a/desktop/src/main/platform.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function isPlatform(platform: "mac" | "windows" | "linux") { - return getPlatform() === platform; -} - -export function getPlatform(): "mac" | "windows" | "linux" { - switch (process.platform) { - case "aix": - case "freebsd": - case "linux": - case "openbsd": - case "android": - return "linux"; - case "darwin": - case "sunos": - return "mac"; - case "win32": - return "windows"; - } -} diff --git a/desktop/src/main/services/app-update.ts b/desktop/src/main/services/app-update.ts index b47448501..a3f4d3bed 100644 --- a/desktop/src/main/services/app-update.ts +++ b/desktop/src/main/services/app-update.ts @@ -1,9 +1,9 @@ import { compareVersions } from "compare-versions"; -import { app, BrowserWindow } from "electron"; import { default as electronLog } from "electron-log"; import { autoUpdater } from "electron-updater"; +import { app, BrowserWindow } from "electron/main"; import { allowWindowClose } from "../../main"; -import { AppUpdateInfo } from "../../types/ipc"; +import { AppUpdate } from "../../types/ipc"; import log from "../log"; import { userPreferences } from "../stores/user-preferences"; @@ -52,8 +52,8 @@ const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => { return; } - const showUpdateDialog = (updateInfo: AppUpdateInfo) => - mainWindow.webContents.send("appUpdateAvailable", updateInfo); + const showUpdateDialog = (update: AppUpdate) => + mainWindow.webContents.send("appUpdateAvailable", update); log.debug(() => "Attempting auto update"); autoUpdater.downloadUpdate(); diff --git a/desktop/src/main/services/auto-launcher.ts b/desktop/src/main/services/auto-launcher.ts new file mode 100644 index 000000000..c704f7399 --- /dev/null +++ b/desktop/src/main/services/auto-launcher.ts @@ -0,0 +1,51 @@ +import AutoLaunch from "auto-launch"; +import { app } from "electron/main"; + +class AutoLauncher { + /** + * This property will be set and used on Linux and Windows. On macOS, + * there's a separate API + */ + private autoLaunch?: AutoLaunch; + + constructor() { + if (process.platform != "darwin") { + this.autoLaunch = new AutoLaunch({ + name: "ente", + isHidden: true, + }); + } + } + + async isEnabled() { + const autoLaunch = this.autoLaunch; + if (autoLaunch) { + return await autoLaunch.isEnabled(); + } else { + return app.getLoginItemSettings().openAtLogin; + } + } + + async toggleAutoLaunch() { + const isEnabled = await this.isEnabled(); + const autoLaunch = this.autoLaunch; + if (autoLaunch) { + if (isEnabled) await autoLaunch.disable(); + else await autoLaunch.enable(); + } else { + if (isEnabled) app.setLoginItemSettings({ openAtLogin: false }); + else app.setLoginItemSettings({ openAtLogin: true }); + } + } + + async wasAutoLaunched() { + if (this.autoLaunch) { + return app.commandLine.hasSwitch("hidden"); + } else { + // TODO(MR): This apparently doesn't work anymore. + return app.getLoginItemSettings().wasOpenedAtLogin; + } + } +} + +export default new AutoLauncher(); diff --git a/desktop/src/main/services/autoLauncher.ts b/desktop/src/main/services/autoLauncher.ts deleted file mode 100644 index 614c151ba..000000000 --- a/desktop/src/main/services/autoLauncher.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { AutoLauncherClient } from "../../types/main"; -import { isPlatform } from "../platform"; -import linuxAndWinAutoLauncher from "./autoLauncherClients/linuxAndWinAutoLauncher"; -import macAutoLauncher from "./autoLauncherClients/macAutoLauncher"; - -class AutoLauncher { - private client: AutoLauncherClient; - async init() { - if (isPlatform("linux") || isPlatform("windows")) { - this.client = linuxAndWinAutoLauncher; - } else { - this.client = macAutoLauncher; - } - // migrate old auto launch settings for windows from mac auto launcher to linux and windows auto launcher - if (isPlatform("windows") && (await macAutoLauncher.isEnabled())) { - await macAutoLauncher.toggleAutoLaunch(); - await linuxAndWinAutoLauncher.toggleAutoLaunch(); - } - } - async isEnabled() { - if (!this.client) { - await this.init(); - } - return await this.client.isEnabled(); - } - async toggleAutoLaunch() { - if (!this.client) { - await this.init(); - } - await this.client.toggleAutoLaunch(); - } - - async wasAutoLaunched() { - if (!this.client) { - await this.init(); - } - return this.client.wasAutoLaunched(); - } -} - -export default new AutoLauncher(); diff --git a/desktop/src/main/services/autoLauncherClients/linuxAndWinAutoLauncher.ts b/desktop/src/main/services/autoLauncherClients/linuxAndWinAutoLauncher.ts deleted file mode 100644 index 0d2c1bb42..000000000 --- a/desktop/src/main/services/autoLauncherClients/linuxAndWinAutoLauncher.ts +++ /dev/null @@ -1,39 +0,0 @@ -import AutoLaunch from "auto-launch"; -import { app } from "electron"; -import { AutoLauncherClient } from "../../../types/main"; - -const LAUNCHED_AS_HIDDEN_FLAG = "hidden"; - -class LinuxAndWinAutoLauncher implements AutoLauncherClient { - private instance: AutoLaunch; - constructor() { - const autoLauncher = new AutoLaunch({ - name: "ente", - isHidden: true, - }); - this.instance = autoLauncher; - } - async isEnabled() { - return await this.instance.isEnabled(); - } - async toggleAutoLaunch() { - if (await this.isEnabled()) { - await this.disableAutoLaunch(); - } else { - await this.enableAutoLaunch(); - } - } - - async wasAutoLaunched() { - return app.commandLine.hasSwitch(LAUNCHED_AS_HIDDEN_FLAG); - } - - private async disableAutoLaunch() { - await this.instance.disable(); - } - private async enableAutoLaunch() { - await this.instance.enable(); - } -} - -export default new LinuxAndWinAutoLauncher(); diff --git a/desktop/src/main/services/autoLauncherClients/macAutoLauncher.ts b/desktop/src/main/services/autoLauncherClients/macAutoLauncher.ts deleted file mode 100644 index 00320e870..000000000 --- a/desktop/src/main/services/autoLauncherClients/macAutoLauncher.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { app } from "electron"; -import { AutoLauncherClient } from "../../../types/main"; - -class MacAutoLauncher implements AutoLauncherClient { - async isEnabled() { - return app.getLoginItemSettings().openAtLogin; - } - async toggleAutoLaunch() { - if (await this.isEnabled()) { - this.disableAutoLaunch(); - } else { - this.enableAutoLaunch(); - } - } - - async wasAutoLaunched() { - return app.getLoginItemSettings().wasOpenedAtLogin; - } - - private disableAutoLaunch() { - app.setLoginItemSettings({ openAtLogin: false }); - } - private enableAutoLaunch() { - app.setLoginItemSettings({ openAtLogin: true }); - } -} - -export default new MacAutoLauncher(); diff --git a/desktop/src/main/services/chokidar.ts b/desktop/src/main/services/chokidar.ts deleted file mode 100644 index 5d7284d2a..000000000 --- a/desktop/src/main/services/chokidar.ts +++ /dev/null @@ -1,45 +0,0 @@ -import chokidar from "chokidar"; -import { BrowserWindow } from "electron"; -import path from "path"; -import log from "../log"; -import { getElectronFile } from "./fs"; -import { getWatchMappings } from "./watch"; - -/** - * Convert a file system {@link filePath} that uses the local system specific - * path separators into a path that uses POSIX file separators. - */ -const normalizeToPOSIX = (filePath: string) => - filePath.split(path.sep).join(path.posix.sep); - -export function initWatcher(mainWindow: BrowserWindow) { - const mappings = getWatchMappings(); - const folderPaths = mappings.map((mapping) => { - return mapping.folderPath; - }); - - const watcher = chokidar.watch(folderPaths, { - awaitWriteFinish: true, - }); - watcher - .on("add", async (path) => { - mainWindow.webContents.send( - "watch-add", - await getElectronFile(normalizeToPOSIX(path)), - ); - }) - .on("unlink", (path) => { - mainWindow.webContents.send("watch-unlink", normalizeToPOSIX(path)); - }) - .on("unlinkDir", (path) => { - mainWindow.webContents.send( - "watch-unlink-dir", - normalizeToPOSIX(path), - ); - }) - .on("error", (error) => { - log.error("Error while watching files", error); - }); - - return watcher; -} diff --git a/desktop/src/main/services/fs.ts b/desktop/src/main/services/fs.ts index 7a29d581b..30ccf146b 100644 --- a/desktop/src/main/services/fs.ts +++ b/desktop/src/main/services/fs.ts @@ -91,19 +91,6 @@ export async function getElectronFile(filePath: string): Promise { }; } -export const getValidPaths = (paths: string[]) => { - if (!paths) { - return [] as string[]; - } - return paths.filter(async (path) => { - try { - await fs.stat(path).then((stat) => stat.isFile()); - } catch (e) { - return false; - } - }); -}; - export const getZipFileStream = async ( zip: StreamZip.StreamZipAsync, filePath: string, diff --git a/desktop/src/main/services/imageProcessor.ts b/desktop/src/main/services/imageProcessor.ts index 696119d80..f636c153a 100644 --- a/desktop/src/main/services/imageProcessor.ts +++ b/desktop/src/main/services/imageProcessor.ts @@ -3,7 +3,6 @@ import fs from "node:fs/promises"; import path from "path"; import { CustomErrors, ElectronFile } from "../../types/ipc"; import log from "../log"; -import { isPlatform } from "../platform"; import { writeStream } from "../stream"; import { generateTempFilePath } from "../temp"; import { execAsync, isDev } from "../util"; @@ -67,19 +66,15 @@ const IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [ OUTPUT_PATH_PLACEHOLDER, ]; -function getImageMagickStaticPath() { - return isDev - ? "resources/image-magick" - : path.join(process.resourcesPath, "image-magick"); -} +const imageMagickStaticPath = () => + path.join(isDev ? "build" : process.resourcesPath, "image-magick"); export async function convertToJPEG( fileData: Uint8Array, filename: string, ): Promise { - if (isPlatform("windows")) { + if (process.platform == "win32") throw Error(CustomErrors.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED); - } const convertedFileData = await convertToJPEG_(fileData, filename); return convertedFileData; } @@ -126,7 +121,7 @@ function constructConvertCommand( tempOutputFilePath: string, ) { let convertCmd: string[]; - if (isPlatform("mac")) { + if (process.platform == "darwin") { convertCmd = SIPS_HEIC_CONVERT_COMMAND_TEMPLATE.map((cmdPart) => { if (cmdPart === INPUT_PATH_PLACEHOLDER) { return tempInputFilePath; @@ -136,11 +131,11 @@ function constructConvertCommand( } return cmdPart; }); - } else if (isPlatform("linux")) { + } else if (process.platform == "linux") { convertCmd = IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE.map( (cmdPart) => { if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) { - return getImageMagickStaticPath(); + return imageMagickStaticPath(); } if (cmdPart === INPUT_PATH_PLACEHOLDER) { return tempInputFilePath; @@ -165,11 +160,10 @@ export async function generateImageThumbnail( let inputFilePath = null; let createdTempInputFile = null; try { - if (isPlatform("windows")) { + if (process.platform == "win32") throw Error( CustomErrors.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED, ); - } if (!existsSync(inputFile.path)) { const tempFilePath = await generateTempFilePath(inputFile.name); await writeStream(tempFilePath, await inputFile.stream()); @@ -240,7 +234,7 @@ function constructThumbnailGenerationCommand( quality: number, ) { let thumbnailGenerationCmd: string[]; - if (isPlatform("mac")) { + if (process.platform == "darwin") { thumbnailGenerationCmd = SIPS_THUMBNAIL_GENERATE_COMMAND_TEMPLATE.map( (cmdPart) => { if (cmdPart === INPUT_PATH_PLACEHOLDER) { @@ -258,11 +252,11 @@ function constructThumbnailGenerationCommand( return cmdPart; }, ); - } else if (isPlatform("linux")) { + } else if (process.platform == "linux") { thumbnailGenerationCmd = IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE.map((cmdPart) => { if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) { - return getImageMagickStaticPath(); + return imageMagickStaticPath(); } if (cmdPart === INPUT_PATH_PLACEHOLDER) { return inputFilePath; diff --git a/desktop/src/main/services/store.ts b/desktop/src/main/services/store.ts index a484080f5..9ec65c8c3 100644 --- a/desktop/src/main/services/store.ts +++ b/desktop/src/main/services/store.ts @@ -1,12 +1,15 @@ import { safeStorage } from "electron/main"; -import { keysStore } from "../stores/keys.store"; -import { safeStorageStore } from "../stores/safeStorage.store"; -import { uploadStatusStore } from "../stores/upload.store"; -import { watchStore } from "../stores/watch.store"; +import { safeStorageStore } from "../stores/safe-storage"; +import { uploadStatusStore } from "../stores/upload-status"; +import { watchStore } from "../stores/watch"; +/** + * Clear all stores except user preferences. + * + * This is useful to reset state when the user logs out. + */ export const clearStores = () => { uploadStatusStore.clear(); - keysStore.clear(); safeStorageStore.clear(); watchStore.clear(); }; diff --git a/desktop/src/main/services/upload.ts b/desktop/src/main/services/upload.ts index e3fbc16e6..88c2d88d1 100644 --- a/desktop/src/main/services/upload.ts +++ b/desktop/src/main/services/upload.ts @@ -1,19 +1,23 @@ import StreamZip from "node-stream-zip"; +import { existsSync } from "original-fs"; import path from "path"; -import { ElectronFile, FILE_PATH_TYPE } from "../../types/ipc"; -import { FILE_PATH_KEYS } from "../../types/main"; -import { uploadStatusStore } from "../stores/upload.store"; -import { getElectronFile, getValidPaths, getZipFileStream } from "./fs"; +import { ElectronFile, type PendingUploads } from "../../types/ipc"; +import { + uploadStatusStore, + type UploadStatusStore, +} from "../stores/upload-status"; +import { getElectronFile, getZipFileStream } from "./fs"; -export const getPendingUploads = async () => { - const filePaths = getSavedFilePaths(FILE_PATH_TYPE.FILES); - const zipPaths = getSavedFilePaths(FILE_PATH_TYPE.ZIPS); +export const pendingUploads = async () => { const collectionName = uploadStatusStore.get("collectionName"); + const filePaths = validSavedPaths("files"); + const zipPaths = validSavedPaths("zips"); let files: ElectronFile[] = []; - let type: FILE_PATH_TYPE; + let type: PendingUploads["type"]; + if (zipPaths.length) { - type = FILE_PATH_TYPE.ZIPS; + type = "zips"; for (const zipPath of zipPaths) { files = [ ...files, @@ -23,9 +27,10 @@ export const getPendingUploads = async () => { const pendingFilePaths = new Set(filePaths); files = files.filter((file) => pendingFilePaths.has(file.path)); } else if (filePaths.length) { - type = FILE_PATH_TYPE.FILES; + type = "files"; files = await Promise.all(filePaths.map(getElectronFile)); } + return { files, collectionName, @@ -33,16 +38,56 @@ export const getPendingUploads = async () => { }; }; -export const getSavedFilePaths = (type: FILE_PATH_TYPE) => { - const paths = - getValidPaths( - uploadStatusStore.get(FILE_PATH_KEYS[type]) as string[], - ) ?? []; - - setToUploadFiles(type, paths); +export 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; }; +export const setPendingUploadCollection = (collectionName: string) => { + if (collectionName) uploadStatusStore.set("collectionName", collectionName); + else uploadStatusStore.delete("collectionName"); +}; + +export const setPendingUploadFiles = ( + type: PendingUploads["type"], + filePaths: string[], +) => { + const key = storeKey(type); + if (filePaths) uploadStatusStore.set(key, filePaths); + else uploadStatusStore.delete(key); +}; + +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, + }); + const zipName = path.basename(filePath, ".zip"); + + const entries = await zip.entries(); + const files: ElectronFile[] = []; + + for (const entry of Object.values(entries)) { + const basename = path.basename(entry.name); + if (entry.isFile && basename.length > 0 && basename[0] !== ".") { + files.push(await getZipEntryAsElectronFile(zipName, zip, entry)); + } + } + + return files; +}; + export async function getZipEntryAsElectronFile( zipName: string, zip: StreamZip.StreamZipAsync, @@ -69,39 +114,3 @@ export async function getZipEntryAsElectronFile( }, }; } - -export const setToUploadFiles = (type: FILE_PATH_TYPE, filePaths: string[]) => { - const key = FILE_PATH_KEYS[type]; - if (filePaths) { - uploadStatusStore.set(key, filePaths); - } else { - uploadStatusStore.delete(key); - } -}; - -export const setToUploadCollection = (collectionName: string) => { - if (collectionName) { - uploadStatusStore.set("collectionName", collectionName); - } else { - uploadStatusStore.delete("collectionName"); - } -}; - -export const getElectronFilesFromGoogleZip = async (filePath: string) => { - const zip = new StreamZip.async({ - file: filePath, - }); - const zipName = path.basename(filePath, ".zip"); - - const entries = await zip.entries(); - const files: ElectronFile[] = []; - - for (const entry of Object.values(entries)) { - const basename = path.basename(entry.name); - if (entry.isFile && basename.length > 0 && basename[0] !== ".") { - files.push(await getZipEntryAsElectronFile(zipName, zip, entry)); - } - } - - return files; -}; diff --git a/desktop/src/main/services/watch.ts b/desktop/src/main/services/watch.ts index 8a3414c58..73a13c545 100644 --- a/desktop/src/main/services/watch.ts +++ b/desktop/src/main/services/watch.ts @@ -1,101 +1,159 @@ -import type { FSWatcher } from "chokidar"; -import ElectronLog from "electron-log"; -import { WatchMapping, WatchStoreType } from "../../types/ipc"; -import { watchStore } from "../stores/watch.store"; +import chokidar, { type FSWatcher } from "chokidar"; +import { BrowserWindow } from "electron/main"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { FolderWatch, type CollectionMapping } from "../../types/ipc"; +import { fsIsDir } from "../fs"; +import log from "../log"; +import { watchStore } from "../stores/watch"; -export const addWatchMapping = async ( - watcher: FSWatcher, - rootFolderName: string, - folderPath: string, - uploadStrategy: number, -) => { - ElectronLog.log(`Adding watch mapping: ${folderPath}`); - const watchMappings = getWatchMappings(); - if (isMappingPresent(watchMappings, folderPath)) { - throw new Error(`Watch mapping already exists`); +/** + * Create and return a new file system watcher. + * + * Internally this uses the watcher from the chokidar package. + * + * @param mainWindow The window handle is used to notify the renderer process of + * pertinent file system events. + */ +export const createWatcher = (mainWindow: BrowserWindow) => { + const send = (eventName: string) => (path: string) => + mainWindow.webContents.send(eventName, ...eventData(path)); + + const folderPaths = folderWatches().map((watch) => watch.folderPath); + + const watcher = chokidar.watch(folderPaths, { + awaitWriteFinish: true, + }); + + watcher + .on("add", send("watchAddFile")) + .on("unlink", send("watchRemoveFile")) + .on("unlinkDir", send("watchRemoveDir")) + .on("error", (error) => log.error("Error while watching files", error)); + + return watcher; +}; + +const eventData = (path: string): [string, FolderWatch] => { + path = posixPath(path); + + const watch = folderWatches().find((watch) => + path.startsWith(watch.folderPath + "/"), + ); + + if (!watch) throw new Error(`No folder watch was found for path ${path}`); + + return [path, watch]; +}; + +/** + * Convert a file system {@link filePath} that uses the local system specific + * path separators into a path that uses POSIX file separators. + */ +const posixPath = (filePath: string) => + filePath.split(path.sep).join(path.posix.sep); + +export const watchGet = (watcher: FSWatcher) => { + const [valid, deleted] = folderWatches().reduce( + ([valid, deleted], watch) => { + (fsIsDir(watch.folderPath) ? valid : deleted).push(watch); + return [valid, deleted]; + }, + [[], []], + ); + if (deleted.length) { + for (const watch of deleted) watchRemove(watcher, watch.folderPath); + setFolderWatches(valid); } + return valid; +}; - watcher.add(folderPath); +const folderWatches = (): FolderWatch[] => watchStore.get("mappings") ?? []; - watchMappings.push({ - rootFolderName, - uploadStrategy, +const setFolderWatches = (watches: FolderWatch[]) => + watchStore.set("mappings", watches); + +export const watchAdd = async ( + watcher: FSWatcher, + folderPath: string, + collectionMapping: CollectionMapping, +) => { + const watches = folderWatches(); + + if (!fsIsDir(folderPath)) + throw new Error( + `Attempting to add a folder watch for a folder path ${folderPath} that is not an existing directory`, + ); + + if (watches.find((watch) => watch.folderPath == folderPath)) + throw new Error( + `A folder watch with the given folder path ${folderPath} already exists`, + ); + + watches.push({ folderPath, + collectionMapping, syncedFiles: [], ignoredFiles: [], }); - setWatchMappings(watchMappings); + setFolderWatches(watches); + + watcher.add(folderPath); + + return watches; }; -function isMappingPresent(watchMappings: WatchMapping[], folderPath: string) { - const watchMapping = watchMappings?.find( - (mapping) => mapping.folderPath === folderPath, - ); - return !!watchMapping; -} +export const watchRemove = async (watcher: FSWatcher, folderPath: string) => { + const watches = folderWatches(); + const filtered = watches.filter((watch) => watch.folderPath != folderPath); + if (watches.length == filtered.length) + throw new Error( + `Attempting to remove a non-existing folder watch for folder path ${folderPath}`, + ); + setFolderWatches(filtered); + watcher.unwatch(folderPath); + return filtered; +}; -export const removeWatchMapping = async ( - watcher: FSWatcher, +export const watchUpdateSyncedFiles = ( + syncedFiles: FolderWatch["syncedFiles"], folderPath: string, ) => { - let watchMappings = getWatchMappings(); - const watchMapping = watchMappings.find( - (mapping) => mapping.folderPath === folderPath, + setFolderWatches( + folderWatches().map((watch) => { + if (watch.folderPath == folderPath) { + watch.syncedFiles = syncedFiles; + } + return watch; + }), ); - - if (!watchMapping) { - throw new Error(`Watch mapping does not exist`); - } - - watcher.unwatch(watchMapping.folderPath); - - watchMappings = watchMappings.filter( - (mapping) => mapping.folderPath !== watchMapping.folderPath, - ); - - setWatchMappings(watchMappings); }; -export function updateWatchMappingSyncedFiles( +export const watchUpdateIgnoredFiles = ( + ignoredFiles: FolderWatch["ignoredFiles"], folderPath: string, - files: WatchMapping["syncedFiles"], -): void { - const watchMappings = getWatchMappings(); - const watchMapping = watchMappings.find( - (mapping) => mapping.folderPath === folderPath, +) => { + setFolderWatches( + folderWatches().map((watch) => { + if (watch.folderPath == folderPath) { + watch.ignoredFiles = ignoredFiles; + } + return watch; + }), ); +}; - if (!watchMapping) { - throw Error(`Watch mapping not found`); +export const watchFindFiles = async (dirPath: string) => { + const items = await fs.readdir(dirPath, { withFileTypes: true }); + let paths: string[] = []; + for (const item of items) { + const itemPath = path.posix.join(dirPath, item.name); + if (item.isFile()) { + paths.push(itemPath); + } else if (item.isDirectory()) { + paths = [...paths, ...(await watchFindFiles(itemPath))]; + } } - - watchMapping.syncedFiles = files; - setWatchMappings(watchMappings); -} - -export function updateWatchMappingIgnoredFiles( - folderPath: string, - files: WatchMapping["ignoredFiles"], -): void { - const watchMappings = getWatchMappings(); - const watchMapping = watchMappings.find( - (mapping) => mapping.folderPath === folderPath, - ); - - if (!watchMapping) { - throw Error(`Watch mapping not found`); - } - - watchMapping.ignoredFiles = files; - setWatchMappings(watchMappings); -} - -export function getWatchMappings() { - const mappings = watchStore.get("mappings") ?? []; - return mappings; -} - -function setWatchMappings(watchMappings: WatchStoreType["mappings"]) { - watchStore.set("mappings", watchMappings); -} + return paths; +}; diff --git a/desktop/src/main/stores/keys.store.ts b/desktop/src/main/stores/keys.store.ts deleted file mode 100644 index 4f8618cea..000000000 --- a/desktop/src/main/stores/keys.store.ts +++ /dev/null @@ -1,18 +0,0 @@ -import Store, { Schema } from "electron-store"; -import type { KeysStoreType } from "../../types/main"; - -const keysStoreSchema: Schema = { - AnonymizeUserID: { - type: "object", - properties: { - id: { - type: "string", - }, - }, - }, -}; - -export const keysStore = new Store({ - name: "keys", - schema: keysStoreSchema, -}); diff --git a/desktop/src/main/stores/safeStorage.store.ts b/desktop/src/main/stores/safe-storage.ts similarity index 63% rename from desktop/src/main/stores/safeStorage.store.ts rename to desktop/src/main/stores/safe-storage.ts index da95df3be..1e1369db8 100644 --- a/desktop/src/main/stores/safeStorage.store.ts +++ b/desktop/src/main/stores/safe-storage.ts @@ -1,7 +1,10 @@ import Store, { Schema } from "electron-store"; -import type { SafeStorageStoreType } from "../../types/main"; -const safeStorageSchema: Schema = { +interface SafeStorageStore { + encryptionKey: string; +} + +const safeStorageSchema: Schema = { encryptionKey: { type: "string", }, diff --git a/desktop/src/main/stores/upload.store.ts b/desktop/src/main/stores/upload-status.ts similarity index 65% rename from desktop/src/main/stores/upload.store.ts rename to desktop/src/main/stores/upload-status.ts index 20b1f419d..25af7a49e 100644 --- a/desktop/src/main/stores/upload.store.ts +++ b/desktop/src/main/stores/upload-status.ts @@ -1,7 +1,12 @@ import Store, { Schema } from "electron-store"; -import type { UploadStoreType } from "../../types/main"; -const uploadStoreSchema: Schema = { +export interface UploadStatusStore { + filePaths: string[]; + zipPaths: string[]; + collectionName: string; +} + +const uploadStatusSchema: Schema = { filePaths: { type: "array", items: { @@ -21,5 +26,5 @@ const uploadStoreSchema: Schema = { export const uploadStatusStore = new Store({ name: "upload-status", - schema: uploadStoreSchema, + schema: uploadStatusSchema, }); diff --git a/desktop/src/main/stores/user-preferences.ts b/desktop/src/main/stores/user-preferences.ts index a305f1a99..b4a02bc5b 100644 --- a/desktop/src/main/stores/user-preferences.ts +++ b/desktop/src/main/stores/user-preferences.ts @@ -1,12 +1,12 @@ import Store, { Schema } from "electron-store"; -interface UserPreferencesSchema { +interface UserPreferences { hideDockIcon: boolean; skipAppVersion?: string; muteUpdateNotificationVersion?: string; } -const userPreferencesSchema: Schema = { +const userPreferencesSchema: Schema = { hideDockIcon: { type: "boolean", }, diff --git a/desktop/src/main/stores/watch.store.ts b/desktop/src/main/stores/watch.store.ts deleted file mode 100644 index 55470ce86..000000000 --- a/desktop/src/main/stores/watch.store.ts +++ /dev/null @@ -1,47 +0,0 @@ -import Store, { Schema } from "electron-store"; -import { WatchStoreType } from "../../types/ipc"; - -const watchStoreSchema: Schema = { - mappings: { - type: "array", - items: { - type: "object", - properties: { - rootFolderName: { - type: "string", - }, - uploadStrategy: { - type: "number", - }, - folderPath: { - type: "string", - }, - syncedFiles: { - type: "array", - items: { - type: "object", - properties: { - path: { - type: "string", - }, - id: { - type: "number", - }, - }, - }, - }, - ignoredFiles: { - type: "array", - items: { - type: "string", - }, - }, - }, - }, - }, -}; - -export const watchStore = new Store({ - name: "watch-status", - schema: watchStoreSchema, -}); diff --git a/desktop/src/main/stores/watch.ts b/desktop/src/main/stores/watch.ts new file mode 100644 index 000000000..7ee383038 --- /dev/null +++ b/desktop/src/main/stores/watch.ts @@ -0,0 +1,73 @@ +import Store, { Schema } from "electron-store"; +import { type FolderWatch } from "../../types/ipc"; +import log from "../log"; + +interface WatchStore { + mappings: FolderWatchWithLegacyFields[]; +} + +type FolderWatchWithLegacyFields = FolderWatch & { + /** @deprecated Only retained for migration, do not use in other code */ + rootFolderName?: string; + /** @deprecated Only retained for migration, do not use in other code */ + uploadStrategy?: number; +}; + +const watchStoreSchema: Schema = { + mappings: { + type: "array", + items: { + type: "object", + properties: { + rootFolderName: { type: "string" }, + collectionMapping: { type: "string" }, + uploadStrategy: { type: "number" }, + folderPath: { type: "string" }, + syncedFiles: { + type: "array", + items: { + type: "object", + properties: { + path: { type: "string" }, + uploadedFileID: { type: "number" }, + collectionID: { type: "number" }, + }, + }, + }, + ignoredFiles: { + type: "array", + items: { type: "string" }, + }, + }, + }, + }, +}; + +export const watchStore = new Store({ + name: "watch-status", + schema: watchStoreSchema, +}); + +/** + * Previous versions of the store used to store an integer to indicate the + * collection mapping, migrate these to the new schema if we encounter them. + */ +export const migrateLegacyWatchStoreIfNeeded = () => { + let needsUpdate = false; + const watches = watchStore.get("mappings")?.map((watch) => { + let collectionMapping = watch.collectionMapping; + if (!collectionMapping) { + collectionMapping = watch.uploadStrategy == 1 ? "parent" : "root"; + needsUpdate = true; + } + if (watch.rootFolderName) { + delete watch.rootFolderName; + needsUpdate = true; + } + return { ...watch, collectionMapping }; + }); + if (needsUpdate) { + watchStore.set("mappings", watches); + log.info("Migrated legacy watch store data to new schema"); + } +}; diff --git a/desktop/src/main/util.ts b/desktop/src/main/util.ts index d0c6699e9..b997d738e 100644 --- a/desktop/src/main/util.ts +++ b/desktop/src/main/util.ts @@ -56,6 +56,13 @@ export const openDirectory = async (dirPath: string) => { if (res) throw new Error(`Failed to open directory ${dirPath}: res`); }; +/** + * Open the app's log directory in the system's folder viewer. + * + * @see {@link openDirectory} + */ +export const openLogDirectory = () => openDirectory(logDirectoryPath()); + /** * Return the path where the logs for the app are saved. * @@ -72,10 +79,3 @@ export const openDirectory = async (dirPath: string) => { * */ const logDirectoryPath = () => app.getPath("logs"); - -/** - * Open the app's log directory in the system's folder viewer. - * - * @see {@link openDirectory} - */ -export const openLogDirectory = () => openDirectory(logDirectoryPath()); diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index ff2cf505a..7d0df41d5 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -40,12 +40,13 @@ import { contextBridge, ipcRenderer } 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 / looked around for at runtime. +// needed when compiling and will not be needed or looked around for at runtime. import type { - AppUpdateInfo, + AppUpdate, + CollectionMapping, ElectronFile, - FILE_PATH_TYPE, - WatchMapping, + FolderWatch, + PendingUploads, } from "./types/ipc"; // - General @@ -77,12 +78,12 @@ const onMainWindowFocus = (cb?: () => void) => { // - App update const onAppUpdateAvailable = ( - cb?: ((updateInfo: AppUpdateInfo) => void) | undefined, + cb?: ((update: AppUpdate) => void) | undefined, ) => { ipcRenderer.removeAllListeners("appUpdateAvailable"); if (cb) { - ipcRenderer.on("appUpdateAvailable", (_, updateInfo: AppUpdateInfo) => - cb(updateInfo), + ipcRenderer.on("appUpdateAvailable", (_, update: AppUpdate) => + cb(update), ); } }; @@ -118,6 +119,9 @@ const fsReadTextFile = (path: string): Promise => const fsWriteFile = (path: string, contents: string): Promise => ipcRenderer.invoke("fsWriteFile", path, contents); +const fsIsDir = (dirPath: string): Promise => + ipcRenderer.invoke("fsIsDir", dirPath); + // - AUDIT below this // - Conversion @@ -188,82 +192,78 @@ const showUploadZipDialog = (): Promise<{ // - Watch -const registerWatcherFunctions = ( - addFile: (file: ElectronFile) => Promise, - removeFile: (path: string) => Promise, - removeFolder: (folderPath: string) => Promise, -) => { - ipcRenderer.removeAllListeners("watch-add"); - ipcRenderer.removeAllListeners("watch-unlink"); - ipcRenderer.removeAllListeners("watch-unlink-dir"); - ipcRenderer.on("watch-add", (_, file: ElectronFile) => addFile(file)); - ipcRenderer.on("watch-unlink", (_, filePath: string) => - removeFile(filePath), - ); - ipcRenderer.on("watch-unlink-dir", (_, folderPath: string) => - removeFolder(folderPath), +const watchGet = (): Promise => ipcRenderer.invoke("watchGet"); + +const watchAdd = ( + folderPath: string, + collectionMapping: CollectionMapping, +): Promise => + ipcRenderer.invoke("watchAdd", folderPath, collectionMapping); + +const watchRemove = (folderPath: string): Promise => + ipcRenderer.invoke("watchRemove", folderPath); + +const watchUpdateSyncedFiles = ( + syncedFiles: FolderWatch["syncedFiles"], + folderPath: string, +): Promise => + ipcRenderer.invoke("watchUpdateSyncedFiles", syncedFiles, folderPath); + +const watchUpdateIgnoredFiles = ( + ignoredFiles: FolderWatch["ignoredFiles"], + folderPath: string, +): Promise => + ipcRenderer.invoke("watchUpdateIgnoredFiles", ignoredFiles, folderPath); + +const watchOnAddFile = (f: (path: string, watch: FolderWatch) => void) => { + ipcRenderer.removeAllListeners("watchAddFile"); + ipcRenderer.on("watchAddFile", (_, path: string, watch: FolderWatch) => + f(path, watch), ); }; -const addWatchMapping = ( - collectionName: string, - folderPath: string, - uploadStrategy: number, -): Promise => - ipcRenderer.invoke( - "addWatchMapping", - collectionName, - folderPath, - uploadStrategy, +const watchOnRemoveFile = (f: (path: string, watch: FolderWatch) => void) => { + ipcRenderer.removeAllListeners("watchRemoveFile"); + ipcRenderer.on("watchRemoveFile", (_, path: string, watch: FolderWatch) => + f(path, watch), ); +}; -const removeWatchMapping = (folderPath: string): Promise => - ipcRenderer.invoke("removeWatchMapping", folderPath); +const watchOnRemoveDir = (f: (path: string, watch: FolderWatch) => void) => { + ipcRenderer.removeAllListeners("watchRemoveDir"); + ipcRenderer.on("watchRemoveDir", (_, path: string, watch: FolderWatch) => + f(path, watch), + ); +}; -const getWatchMappings = (): Promise => - ipcRenderer.invoke("getWatchMappings"); - -const updateWatchMappingSyncedFiles = ( - folderPath: string, - files: WatchMapping["syncedFiles"], -): Promise => - ipcRenderer.invoke("updateWatchMappingSyncedFiles", folderPath, files); - -const updateWatchMappingIgnoredFiles = ( - folderPath: string, - files: WatchMapping["ignoredFiles"], -): Promise => - ipcRenderer.invoke("updateWatchMappingIgnoredFiles", folderPath, files); - -// - FS Legacy - -const isFolder = (dirPath: string): Promise => - ipcRenderer.invoke("isFolder", dirPath); +const watchFindFiles = (folderPath: string): Promise => + ipcRenderer.invoke("watchFindFiles", folderPath); // - Upload -const getPendingUploads = (): Promise<{ - files: ElectronFile[]; - collectionName: string; - type: string; -}> => ipcRenderer.invoke("getPendingUploads"); +const pendingUploads = (): Promise => + ipcRenderer.invoke("pendingUploads"); -const setToUploadFiles = ( - type: FILE_PATH_TYPE, +const setPendingUploadCollection = (collectionName: string): Promise => + ipcRenderer.invoke("setPendingUploadCollection", collectionName); + +const setPendingUploadFiles = ( + type: PendingUploads["type"], filePaths: string[], -): Promise => ipcRenderer.invoke("setToUploadFiles", type, filePaths); +): Promise => + ipcRenderer.invoke("setPendingUploadFiles", type, filePaths); + +// - const getElectronFilesFromGoogleZip = ( filePath: string, ): Promise => ipcRenderer.invoke("getElectronFilesFromGoogleZip", filePath); -const setToUploadCollection = (collectionName: string): Promise => - ipcRenderer.invoke("setToUploadCollection", collectionName); - const getDirFiles = (dirPath: string): Promise => ipcRenderer.invoke("getDirFiles", dirPath); +// // These objects exposed here will become available to the JS code in our // renderer (the web/ code) as `window.ElectronAPIs.*` // @@ -295,10 +295,13 @@ const getDirFiles = (dirPath: string): Promise => // https://www.electronjs.org/docs/latest/api/context-bridge#methods // // The copy itself is relatively fast, but the problem with transfering large -// amounts of data is potentially running out of memory during the copy. For an -// alternative, see [Note: IPC streams]. +// amounts of data is potentially running out of memory during the copy. +// +// For an alternative, see [Note: IPC streams]. +// contextBridge.exposeInMainWorld("electron", { // - General + appVersion, logToDisk, openDirectory, @@ -309,12 +312,14 @@ contextBridge.exposeInMainWorld("electron", { onMainWindowFocus, // - App update + onAppUpdateAvailable, updateAndRestart, updateOnNextRestart, skipAppUpdate, // - FS + fs: { exists: fsExists, rename: fsRename, @@ -323,42 +328,51 @@ contextBridge.exposeInMainWorld("electron", { rm: fsRm, readTextFile: fsReadTextFile, writeFile: fsWriteFile, + isDir: fsIsDir, }, // - Conversion + convertToJPEG, generateImageThumbnail, runFFmpegCmd, // - ML + clipImageEmbedding, clipTextEmbedding, detectFaces, faceEmbedding, // - File selection + selectDirectory, showUploadFilesDialog, showUploadDirsDialog, showUploadZipDialog, // - Watch - registerWatcherFunctions, - addWatchMapping, - removeWatchMapping, - getWatchMappings, - updateWatchMappingSyncedFiles, - updateWatchMappingIgnoredFiles, - // - FS legacy - // TODO: Move these into fs + document + rename if needed - isFolder, + watch: { + get: watchGet, + add: watchAdd, + remove: watchRemove, + onAddFile: watchOnAddFile, + onRemoveFile: watchOnRemoveFile, + onRemoveDir: watchOnRemoveDir, + findFiles: watchFindFiles, + updateSyncedFiles: watchUpdateSyncedFiles, + updateIgnoredFiles: watchUpdateIgnoredFiles, + }, // - Upload - getPendingUploads, - setToUploadFiles, + pendingUploads, + setPendingUploadCollection, + setPendingUploadFiles, + + // - + getElectronFilesFromGoogleZip, - setToUploadCollection, getDirFiles, }); diff --git a/desktop/src/types/ipc.ts b/desktop/src/types/ipc.ts index 3dba231f2..d96341982 100644 --- a/desktop/src/types/ipc.ts +++ b/desktop/src/types/ipc.ts @@ -5,6 +5,32 @@ * See [Note: types.ts <-> preload.ts <-> ipc.ts] */ +export interface AppUpdate { + autoUpdatable: boolean; + version: string; +} + +export interface FolderWatch { + collectionMapping: CollectionMapping; + folderPath: string; + syncedFiles: FolderWatchSyncedFile[]; + ignoredFiles: string[]; +} + +export type CollectionMapping = "root" | "parent"; + +export interface FolderWatchSyncedFile { + path: string; + uploadedFileID: number; + collectionID: number; +} + +export interface PendingUploads { + collectionName: string; + type: "files" | "zips"; + files: ElectronFile[]; +} + /** * Errors that have special semantics on the web side. * @@ -51,32 +77,3 @@ export interface ElectronFile { blob: () => Promise; arrayBuffer: () => Promise; } - -interface WatchMappingSyncedFile { - path: string; - uploadedFileID: number; - collectionID: number; -} - -export interface WatchMapping { - rootFolderName: string; - uploadStrategy: number; - folderPath: string; - syncedFiles: WatchMappingSyncedFile[]; - ignoredFiles: string[]; -} - -export interface WatchStoreType { - mappings: WatchMapping[]; -} - -export enum FILE_PATH_TYPE { - /* eslint-disable no-unused-vars */ - FILES = "files", - ZIPS = "zips", -} - -export interface AppUpdateInfo { - autoUpdatable: boolean; - version: string; -} diff --git a/desktop/src/types/main.ts b/desktop/src/types/main.ts deleted file mode 100644 index 546749c54..000000000 --- a/desktop/src/types/main.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { FILE_PATH_TYPE } from "./ipc"; - -export interface AutoLauncherClient { - isEnabled: () => Promise; - toggleAutoLaunch: () => Promise; - wasAutoLaunched: () => Promise; -} - -export interface UploadStoreType { - filePaths: string[]; - zipPaths: string[]; - collectionName: string; -} - -export interface KeysStoreType { - AnonymizeUserID: { - id: string; - }; -} - -/* eslint-disable no-unused-vars */ -export const FILE_PATH_KEYS: { - [k in FILE_PATH_TYPE]: keyof UploadStoreType; -} = { - [FILE_PATH_TYPE.ZIPS]: "zipPaths", - [FILE_PATH_TYPE.FILES]: "filePaths", -}; - -export interface SafeStorageStoreType { - encryptionKey: string; -} diff --git a/docs/docs/photos/faq/general.md b/docs/docs/photos/faq/general.md index c20bebbc4..b95b7c1d9 100644 --- a/docs/docs/photos/faq/general.md +++ b/docs/docs/photos/faq/general.md @@ -110,10 +110,10 @@ or "dog playing at the beach". Check the sections within the upload progress bar for "Failed Uploads," "Ignored Uploads," and "Unsuccessful Uploads." -## How do i keep NAS and Ente photos synced? +## How do I keep NAS and Ente photos synced? Please try using our CLI to pull data into your NAS -https://github.com/ente-io/ente/tree/main/cli#readme . +https://github.com/ente-io/ente/tree/main/cli#readme. ## Is there a way to view all albums on the map view? diff --git a/mobile/lib/db/files_db.dart b/mobile/lib/db/files_db.dart index 3569f9b44..0dc28b612 100644 --- a/mobile/lib/db/files_db.dart +++ b/mobile/lib/db/files_db.dart @@ -478,11 +478,10 @@ class FilesDB { } Future getFile(int generatedID) async { - final db = await instance.database; - final results = await db.query( - filesTable, - where: '$columnGeneratedID = ?', - whereArgs: [generatedID], + final db = await instance.ffiDB; + final results = db.select( + 'SELECT * FROM $filesTable WHERE $columnGeneratedID = ?', + [generatedID], ); if (results.isEmpty) { return null; @@ -491,11 +490,10 @@ class FilesDB { } Future getUploadedFile(int uploadedID, int collectionID) async { - final db = await instance.database; - final results = await db.query( - filesTable, - where: '$columnUploadedFileID = ? AND $columnCollectionID = ?', - whereArgs: [ + final db = await instance.ffiDB; + final results = db.select( + 'SELECT * FROM $filesTable WHERE $columnUploadedFileID = ? AND $columnCollectionID = ?', + [ uploadedID, collectionID, ], @@ -506,29 +504,12 @@ class FilesDB { return convertToFiles(results)[0]; } - Future getAnyUploadedFile(int uploadedID) async { - final db = await instance.database; - final results = await db.query( - filesTable, - where: '$columnUploadedFileID = ?', - whereArgs: [ - uploadedID, - ], - ); - if (results.isEmpty) { - return null; - } - return convertToFiles(results)[0]; - } - Future> getUploadedFileIDs(int collectionID) async { - final db = await instance.database; - final results = await db.query( - filesTable, - columns: [columnUploadedFileID], - where: - '$columnCollectionID = ? AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)', - whereArgs: [ + final db = await instance.ffiDB; + final results = db.select( + 'SELECT $columnUploadedFileID FROM $filesTable' + ' WHERE $columnCollectionID = ? AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)', + [ collectionID, ], ); @@ -564,12 +545,10 @@ class FilesDB { } Future getBackedUpIDs() async { - final db = await instance.database; - final results = await db.query( - filesTable, - columns: [columnLocalID, columnUploadedFileID, columnFileSize], - where: - '$columnLocalID IS NOT NULL AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)', + final db = await instance.sqliteAsyncDB; + final results = await db.getAll( + 'SELECT $columnLocalID, $columnUploadedFileID, $columnFileSize FROM $filesTable' + ' WHERE $columnLocalID IS NOT NULL AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)', ); final Set localIDs = {}; final Set uploadedIDs = {}; @@ -705,13 +684,12 @@ class FilesDB { } Future> getAllFilesCollection(int collectionID) async { - final db = await instance.database; + final db = await instance.sqliteAsyncDB; const String whereClause = '$columnCollectionID = ?'; final List whereArgs = [collectionID]; - final results = await db.query( - filesTable, - where: whereClause, - whereArgs: whereArgs, + final results = await db.getAll( + 'SELECT * FROM $filesTable WHERE $whereClause', + whereArgs, ); final files = convertToFiles(results); return files; @@ -721,14 +699,13 @@ class FilesDB { int collectionID, int addedTime, ) async { - final db = await instance.database; + final db = await instance.sqliteAsyncDB; const String whereClause = '$columnCollectionID = ? AND $columnAddedTime > ?'; final List whereArgs = [collectionID, addedTime]; - final results = await db.query( - filesTable, - where: whereClause, - whereArgs: whereArgs, + final results = await db.getAll( + 'SELECT * FROM $filesTable WHERE $whereClause', + whereArgs, ); final files = convertToFiles(results); return files; @@ -750,20 +727,22 @@ class FilesDB { inParam += "'" + id.toString() + "',"; } inParam = inParam.substring(0, inParam.length - 1); - final db = await instance.database; + final db = await instance.sqliteAsyncDB; final order = (asc ?? false ? 'ASC' : 'DESC'); final String whereClause = '$columnCollectionID IN ($inParam) AND $columnCreationTime >= ? AND ' '$columnCreationTime <= ? AND $columnOwnerID = ?'; final List whereArgs = [startTime, endTime, userID]; - final results = await db.query( - filesTable, - where: whereClause, - whereArgs: whereArgs, - orderBy: - '$columnCreationTime ' + order + ', $columnModificationTime ' + order, - limit: limit, + String query = 'SELECT * FROM $filesTable WHERE $whereClause ORDER BY ' + '$columnCreationTime $order, $columnModificationTime $order'; + if (limit != null) { + query += ' LIMIT ?'; + whereArgs.add(limit); + } + final results = await db.getAll( + query, + whereArgs, ); final files = convertToFiles(results); final dedupeResult = @@ -781,7 +760,7 @@ class FilesDB { if (durations.isEmpty) { return []; } - final db = await instance.database; + final db = await instance.sqliteAsyncDB; String whereClause = "( "; for (int index = 0; index < durations.length; index++) { whereClause += "($columnCreationTime >= " + @@ -796,11 +775,12 @@ class FilesDB { } } whereClause += ")"; - final results = await db.query( - filesTable, - where: whereClause, - orderBy: '$columnCreationTime ' + order, + final query = + 'SELECT * FROM $filesTable WHERE $whereClause ORDER BY $columnCreationTime $order'; + final results = await db.getAll( + query, ); + final files = convertToFiles(results); return applyDBFilters( files, diff --git a/mobile/lib/generated/intl/messages_cs.dart b/mobile/lib/generated/intl/messages_cs.dart index 86ecd6893..8db8489d3 100644 --- a/mobile/lib/generated/intl/messages_cs.dart +++ b/mobile/lib/generated/intl/messages_cs.dart @@ -55,6 +55,7 @@ class MessageLookup extends MessageLookupByLibrary { "Modify your query, or try searching for"), "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage("Move to hidden album"), + "search": MessageLookupByLibrary.simpleMessage("Search"), "selectALocation": MessageLookupByLibrary.simpleMessage("Select a location"), "selectALocationFirst": diff --git a/mobile/lib/generated/intl/messages_de.dart b/mobile/lib/generated/intl/messages_de.dart index 9005de2dc..442cae919 100644 --- a/mobile/lib/generated/intl/messages_de.dart +++ b/mobile/lib/generated/intl/messages_de.dart @@ -1213,6 +1213,7 @@ class MessageLookup extends MessageLookupByLibrary { "scanThisBarcodeWithnyourAuthenticatorApp": MessageLookupByLibrary.simpleMessage( "Scanne diesen Code mit \ndeiner Authentifizierungs-App"), + "search": MessageLookupByLibrary.simpleMessage("Search"), "searchAlbumsEmptySection": MessageLookupByLibrary.simpleMessage("Alben"), "searchByAlbumNameHint": diff --git a/mobile/lib/generated/intl/messages_en.dart b/mobile/lib/generated/intl/messages_en.dart index 59180d26c..eef309aa5 100644 --- a/mobile/lib/generated/intl/messages_en.dart +++ b/mobile/lib/generated/intl/messages_en.dart @@ -1175,6 +1175,7 @@ class MessageLookup extends MessageLookupByLibrary { "scanThisBarcodeWithnyourAuthenticatorApp": MessageLookupByLibrary.simpleMessage( "Scan this barcode with\nyour authenticator app"), + "search": MessageLookupByLibrary.simpleMessage("Search"), "searchAlbumsEmptySection": MessageLookupByLibrary.simpleMessage("Albums"), "searchByAlbumNameHint": diff --git a/mobile/lib/generated/intl/messages_es.dart b/mobile/lib/generated/intl/messages_es.dart index 5bba2d9a0..a6294d4a4 100644 --- a/mobile/lib/generated/intl/messages_es.dart +++ b/mobile/lib/generated/intl/messages_es.dart @@ -1044,6 +1044,7 @@ class MessageLookup extends MessageLookupByLibrary { "scanThisBarcodeWithnyourAuthenticatorApp": MessageLookupByLibrary.simpleMessage( "Escanea este código QR con tu aplicación de autenticación"), + "search": MessageLookupByLibrary.simpleMessage("Search"), "searchByAlbumNameHint": MessageLookupByLibrary.simpleMessage("Nombre del álbum"), "searchByExamples": MessageLookupByLibrary.simpleMessage( diff --git a/mobile/lib/generated/intl/messages_fr.dart b/mobile/lib/generated/intl/messages_fr.dart index 5f21ec77b..82125afcc 100644 --- a/mobile/lib/generated/intl/messages_fr.dart +++ b/mobile/lib/generated/intl/messages_fr.dart @@ -1182,6 +1182,7 @@ class MessageLookup extends MessageLookupByLibrary { "scanThisBarcodeWithnyourAuthenticatorApp": MessageLookupByLibrary.simpleMessage( "Scannez ce code-barres avec\nvotre application d\'authentification"), + "search": MessageLookupByLibrary.simpleMessage("Search"), "searchAlbumsEmptySection": MessageLookupByLibrary.simpleMessage("Albums"), "searchByAlbumNameHint": diff --git a/mobile/lib/generated/intl/messages_it.dart b/mobile/lib/generated/intl/messages_it.dart index c20931418..e6db5b380 100644 --- a/mobile/lib/generated/intl/messages_it.dart +++ b/mobile/lib/generated/intl/messages_it.dart @@ -1137,6 +1137,7 @@ class MessageLookup extends MessageLookupByLibrary { "scanThisBarcodeWithnyourAuthenticatorApp": MessageLookupByLibrary.simpleMessage( "Scansione questo codice QR\ncon la tua app di autenticazione"), + "search": MessageLookupByLibrary.simpleMessage("Search"), "searchByAlbumNameHint": MessageLookupByLibrary.simpleMessage("Nome album"), "searchByExamples": MessageLookupByLibrary.simpleMessage( diff --git a/mobile/lib/generated/intl/messages_ko.dart b/mobile/lib/generated/intl/messages_ko.dart index 15b4acf26..c91d849f6 100644 --- a/mobile/lib/generated/intl/messages_ko.dart +++ b/mobile/lib/generated/intl/messages_ko.dart @@ -55,6 +55,7 @@ class MessageLookup extends MessageLookupByLibrary { "Modify your query, or try searching for"), "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage("Move to hidden album"), + "search": MessageLookupByLibrary.simpleMessage("Search"), "selectALocation": MessageLookupByLibrary.simpleMessage("Select a location"), "selectALocationFirst": diff --git a/mobile/lib/generated/intl/messages_nl.dart b/mobile/lib/generated/intl/messages_nl.dart index a86943e50..af7502d90 100644 --- a/mobile/lib/generated/intl/messages_nl.dart +++ b/mobile/lib/generated/intl/messages_nl.dart @@ -21,7 +21,7 @@ class MessageLookup extends MessageLookupByLibrary { String get localeName => 'nl'; static String m0(count) => - "${Intl.plural(count, zero: 'Add collaborator', one: 'Add collaborator', other: 'Add collaborators')}"; + "${Intl.plural(count, zero: 'Voeg samenwerker toe', one: 'Voeg samenwerker toe', other: 'Voeg samenwerkers toe')}"; static String m2(count) => "${Intl.plural(count, one: 'Bestand toevoegen', other: 'Bestanden toevoegen')}"; @@ -30,7 +30,7 @@ class MessageLookup extends MessageLookupByLibrary { "Jouw ${storageAmount} add-on is geldig tot ${endDate}"; static String m1(count) => - "${Intl.plural(count, zero: 'Add viewer', one: 'Add viewer', other: 'Add viewers')}"; + "${Intl.plural(count, one: 'Voeg kijker toe', other: 'Voeg kijkers toe')}"; static String m4(emailOrName) => "Toegevoegd door ${emailOrName}"; @@ -64,6 +64,8 @@ class MessageLookup extends MessageLookupByLibrary { static String m13(provider) => "Neem contact met ons op via support@ente.io om uw ${provider} abonnement te beheren."; + static String m69(endpoint) => "Verbonden met ${endpoint}"; + static String m14(count) => "${Intl.plural(count, one: 'Verwijder ${count} bestand', other: 'Verwijder ${count} bestanden')}"; @@ -85,7 +87,7 @@ class MessageLookup extends MessageLookupByLibrary { static String m20(newEmail) => "E-mailadres gewijzigd naar ${newEmail}"; static String m21(email) => - "${email} heeft geen ente account.\n\nStuur ze een uitnodiging om foto\'s te delen."; + "${email} heeft geen Ente account.\n\nStuur ze een uitnodiging om foto\'s te delen."; static String m22(count, formattedNumber) => "${Intl.plural(count, one: '1 bestand', other: '${formattedNumber} bestanden')} in dit album zijn veilig geback-upt"; @@ -102,7 +104,7 @@ class MessageLookup extends MessageLookupByLibrary { static String m26(endDate) => "Gratis proefversie geldig tot ${endDate}"; static String m27(count) => - "U heeft nog steeds toegang tot ${Intl.plural(count, one: 'het', other: 'ze')} op ente zolang u een actief abonnement heeft"; + "Je hebt nog steeds toegang tot ${Intl.plural(count, one: 'het', other: 'ze')} op Ente zolang je een actief abonnement hebt"; static String m28(sizeInMBorGB) => "Maak ${sizeInMBorGB} vrij"; @@ -164,7 +166,7 @@ class MessageLookup extends MessageLookupByLibrary { "Hey, kunt u bevestigen dat dit uw ente.io verificatie-ID is: ${verificationID}"; static String m50(referralCode, referralStorageInGB) => - "ente verwijzingscode: ${referralCode} \n\nPas het toe bij Instellingen → Algemeen → Verwijzingen om ${referralStorageInGB} GB gratis te krijgen nadat je je hebt aangemeld voor een betaald abonnement\n\nhttps://ente.io"; + "Ente verwijzingscode: ${referralCode} \n\nPas het toe bij Instellingen → Algemeen → Verwijzingen om ${referralStorageInGB} GB gratis te krijgen nadat je je hebt aangemeld voor een betaald abonnement\n\nhttps://ente.io"; static String m51(numberOfPeople) => "${Intl.plural(numberOfPeople, zero: 'Deel met specifieke mensen', one: 'Gedeeld met 1 persoon', other: 'Gedeeld met ${numberOfPeople} mensen')}"; @@ -175,10 +177,10 @@ class MessageLookup extends MessageLookupByLibrary { "Deze ${fileType} zal worden verwijderd van jouw apparaat."; static String m54(fileType) => - "Deze ${fileType} staat zowel in ente als op jouw apparaat."; + "Deze ${fileType} staat zowel in Ente als op jouw apparaat."; static String m55(fileType) => - "Deze ${fileType} zal worden verwijderd uit ente."; + "Deze ${fileType} zal worden verwijderd uit Ente."; static String m56(storageAmountInGB) => "${storageAmountInGB} GB"; @@ -187,7 +189,7 @@ class MessageLookup extends MessageLookupByLibrary { "${usedAmount} ${usedStorageUnit} van ${totalAmount} ${totalStorageUnit} gebruikt"; static String m58(id) => - "Uw ${id} is al aan een ander ente account gekoppeld.\nAls u uw ${id} wilt gebruiken met dit account, neem dan contact op met onze klantenservice"; + "Jouw ${id} is al aan een ander Ente account gekoppeld.\nAls je jouw ${id} wilt gebruiken met dit account, neem dan contact op met onze klantenservice"; static String m59(endDate) => "Uw abonnement loopt af op ${endDate}"; @@ -218,7 +220,7 @@ class MessageLookup extends MessageLookupByLibrary { final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { "aNewVersionOfEnteIsAvailable": MessageLookupByLibrary.simpleMessage( - "Er is een nieuwe versie van ente beschikbaar."), + "Er is een nieuwe versie van Ente beschikbaar."), "about": MessageLookupByLibrary.simpleMessage("Over"), "account": MessageLookupByLibrary.simpleMessage("Account"), "accountWelcomeBack": @@ -249,7 +251,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Voeg geselecteerde toe"), "addToAlbum": MessageLookupByLibrary.simpleMessage("Toevoegen aan album"), - "addToEnte": MessageLookupByLibrary.simpleMessage("Toevoegen aan ente"), + "addToEnte": MessageLookupByLibrary.simpleMessage("Toevoegen aan Ente"), "addToHiddenAlbum": MessageLookupByLibrary.simpleMessage( "Toevoegen aan verborgen album"), "addViewer": MessageLookupByLibrary.simpleMessage("Voeg kijker toe"), @@ -421,6 +423,8 @@ class MessageLookup extends MessageLookupByLibrary { "claimedStorageSoFar": m10, "cleanUncategorized": MessageLookupByLibrary.simpleMessage("Ongecategoriseerd opschonen"), + "cleanUncategorizedDescription": MessageLookupByLibrary.simpleMessage( + "Verwijder alle bestanden van Ongecategoriseerd die aanwezig zijn in andere albums"), "clearCaches": MessageLookupByLibrary.simpleMessage("Cache legen"), "clearIndexes": MessageLookupByLibrary.simpleMessage("Index wissen"), "click": MessageLookupByLibrary.simpleMessage("• Click"), @@ -438,7 +442,7 @@ class MessageLookup extends MessageLookupByLibrary { "codeUsedByYou": MessageLookupByLibrary.simpleMessage("Code gebruikt door jou"), "collabLinkSectionDescription": MessageLookupByLibrary.simpleMessage( - "Maak een link waarmee mensen foto\'s in jouw gedeelde album kunnen toevoegen en bekijken zonder dat ze daarvoor een ente app of account nodig hebben. Handig voor het verzamelen van foto\'s van evenementen."), + "Maak een link waarmee mensen foto\'s in jouw gedeelde album kunnen toevoegen en bekijken zonder dat ze daarvoor een Ente app of account nodig hebben. Handig voor het verzamelen van foto\'s van evenementen."), "collaborativeLink": MessageLookupByLibrary.simpleMessage("Gezamenlijke link"), "collaborativeLinkCreatedFor": m11, @@ -501,7 +505,7 @@ class MessageLookup extends MessageLookupByLibrary { "createAlbumActionHint": MessageLookupByLibrary.simpleMessage( "Lang indrukken om foto\'s te selecteren en klik + om een album te maken"), "createCollaborativeLink": - MessageLookupByLibrary.simpleMessage("Create collaborative link"), + MessageLookupByLibrary.simpleMessage("Maak een gezamenlijke link"), "createCollage": MessageLookupByLibrary.simpleMessage("Creëer collage"), "createNewAccount": MessageLookupByLibrary.simpleMessage("Nieuw account aanmaken"), @@ -516,6 +520,7 @@ class MessageLookup extends MessageLookupByLibrary { "currentUsageIs": MessageLookupByLibrary.simpleMessage("Huidig gebruik is "), "custom": MessageLookupByLibrary.simpleMessage("Aangepast"), + "customEndpoint": m69, "darkTheme": MessageLookupByLibrary.simpleMessage("Donker"), "dayToday": MessageLookupByLibrary.simpleMessage("Vandaag"), "dayYesterday": MessageLookupByLibrary.simpleMessage("Gisteren"), @@ -538,7 +543,7 @@ class MessageLookup extends MessageLookupByLibrary { "Hiermee worden alle lege albums verwijderd. Dit is handig wanneer je rommel in je albumlijst wilt verminderen."), "deleteAll": MessageLookupByLibrary.simpleMessage("Alles Verwijderen"), "deleteConfirmDialogBody": MessageLookupByLibrary.simpleMessage( - "Dit account is gekoppeld aan andere ente apps, als je er gebruik van maakt.\\n\\nJe geüploade gegevens worden in alle ente apps gepland voor verwijdering, en je account wordt permanent verwijderd voor alle ente diensten."), + "Dit account is gekoppeld aan andere Ente apps, als je er gebruik van maakt. Je geüploade gegevens worden in alle Ente apps gepland voor verwijdering, en je account wordt permanent verwijderd voor alle Ente diensten."), "deleteEmailRequest": MessageLookupByLibrary.simpleMessage( "Stuur een e-mail naar account-deletion@ente.io vanaf het door jou geregistreerde e-mailadres."), "deleteEmptyAlbums": @@ -550,7 +555,7 @@ class MessageLookup extends MessageLookupByLibrary { "deleteFromDevice": MessageLookupByLibrary.simpleMessage("Verwijder van apparaat"), "deleteFromEnte": - MessageLookupByLibrary.simpleMessage("Verwijder van ente"), + MessageLookupByLibrary.simpleMessage("Verwijder van Ente"), "deleteItemCount": m14, "deleteLocation": MessageLookupByLibrary.simpleMessage("Verwijder locatie"), @@ -571,7 +576,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Gedeeld album verwijderen?"), "deleteSharedAlbumDialogBody": MessageLookupByLibrary.simpleMessage( "Het album wordt verwijderd voor iedereen\n\nJe verliest de toegang tot gedeelde foto\'s in dit album die eigendom zijn van anderen"), - "descriptions": MessageLookupByLibrary.simpleMessage("Descriptions"), + "descriptions": MessageLookupByLibrary.simpleMessage("Beschrijvingen"), "deselectAll": MessageLookupByLibrary.simpleMessage("Alles deselecteren"), "designedToOutlive": MessageLookupByLibrary.simpleMessage( @@ -579,12 +584,16 @@ class MessageLookup extends MessageLookupByLibrary { "details": MessageLookupByLibrary.simpleMessage("Details"), "devAccountChanged": MessageLookupByLibrary.simpleMessage( "Het ontwikkelaarsaccount dat we gebruiken om te publiceren in de App Store is veranderd. Daarom moet je opnieuw inloggen.\n\nOnze excuses voor het ongemak, helaas was dit onvermijdelijk."), + "developerSettings": + MessageLookupByLibrary.simpleMessage("Ontwikkelaarsinstellingen"), + "developerSettingsWarning": MessageLookupByLibrary.simpleMessage( + "Weet je zeker dat je de ontwikkelaarsinstellingen wilt wijzigen?"), "deviceCodeHint": MessageLookupByLibrary.simpleMessage("Voer de code in"), "deviceFilesAutoUploading": MessageLookupByLibrary.simpleMessage( - "Bestanden toegevoegd aan dit album van dit apparaat zullen automatisch geüpload worden naar ente."), + "Bestanden toegevoegd aan dit album van dit apparaat zullen automatisch geüpload worden naar Ente."), "deviceLockExplanation": MessageLookupByLibrary.simpleMessage( - "Schakel de schermvergrendeling van het apparaat uit wanneer ente op de voorgrond is en er een back-up aan de gang is. Dit is normaal gesproken niet nodig, maar kan grote uploads en initiële imports van grote mappen sneller laten verlopen."), + "Schakel de schermvergrendeling van het apparaat uit wanneer Ente op de voorgrond is en er een back-up aan de gang is. Dit is normaal gesproken niet nodig, maar kan grote uploads en initiële imports van grote mappen sneller laten verlopen."), "deviceNotFound": MessageLookupByLibrary.simpleMessage("Apparaat niet gevonden"), "didYouKnow": MessageLookupByLibrary.simpleMessage("Wist u dat?"), @@ -648,15 +657,17 @@ class MessageLookup extends MessageLookupByLibrary { "encryption": MessageLookupByLibrary.simpleMessage("Encryptie"), "encryptionKeys": MessageLookupByLibrary.simpleMessage("Encryptiesleutels"), + "endpointUpdatedMessage": MessageLookupByLibrary.simpleMessage( + "Eindpunt met succes bijgewerkt"), "endtoendEncryptedByDefault": MessageLookupByLibrary.simpleMessage( "Standaard end-to-end versleuteld"), "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": MessageLookupByLibrary.simpleMessage( - "ente kan bestanden alleen versleutelen en bewaren als u toegang tot ze geeft"), + "Ente kan bestanden alleen versleutelen en bewaren als u toegang tot ze geeft"), "entePhotosPerm": MessageLookupByLibrary.simpleMessage( - "ente heeft toestemming nodig om je foto\'s te bewaren"), + "Ente heeft toestemming nodig om je foto\'s te bewaren"), "enteSubscriptionPitch": MessageLookupByLibrary.simpleMessage( - "ente bewaart uw herinneringen, zodat ze altijd beschikbaar voor u zijn, zelfs als u uw apparaat verliest."), + "Ente bewaart uw herinneringen, zodat ze altijd beschikbaar voor u zijn, zelfs als u uw apparaat verliest."), "enteSubscriptionShareWithFamily": MessageLookupByLibrary.simpleMessage( "Je familie kan ook aan je abonnement worden toegevoegd."), "enterAlbumName": @@ -716,7 +727,7 @@ class MessageLookup extends MessageLookupByLibrary { "failedToVerifyPaymentStatus": MessageLookupByLibrary.simpleMessage( "Betalingsstatus verifiëren mislukt"), "familyPlanOverview": MessageLookupByLibrary.simpleMessage( - "Voeg 5 gezinsleden toe aan uw bestaande abonnement zonder extra te betalen.\n\nElk lid krijgt zijn eigen privé ruimte en kan elkaars bestanden niet zien, tenzij ze zijn gedeeld.\n\nFamilieplannen zijn beschikbaar voor klanten die een betaald ente abonnement hebben.\n\nAbonneer u nu om aan de slag te gaan!"), + "Voeg 5 gezinsleden toe aan je bestaande abonnement zonder extra te betalen.\n\nElk lid krijgt zijn eigen privé ruimte en kan elkaars bestanden niet zien tenzij ze zijn gedeeld.\n\nFamilieplannen zijn beschikbaar voor klanten die een betaald Ente abonnement hebben.\n\nAbonneer nu om aan de slag te gaan!"), "familyPlanPortalTitle": MessageLookupByLibrary.simpleMessage("Familie"), "familyPlans": @@ -777,6 +788,7 @@ class MessageLookup extends MessageLookupByLibrary { "Wij gebruiken geen tracking. Het zou helpen als je ons vertelt waar je ons gevonden hebt!"), "hearUsWhereTitle": MessageLookupByLibrary.simpleMessage( "Hoe hoorde je over Ente? (optioneel)"), + "help": MessageLookupByLibrary.simpleMessage("Hulp"), "hidden": MessageLookupByLibrary.simpleMessage("Verborgen"), "hide": MessageLookupByLibrary.simpleMessage("Verbergen"), "hiding": MessageLookupByLibrary.simpleMessage("Verbergen..."), @@ -792,7 +804,7 @@ class MessageLookup extends MessageLookupByLibrary { "iOSOkButton": MessageLookupByLibrary.simpleMessage("Oké"), "ignoreUpdate": MessageLookupByLibrary.simpleMessage("Negeren"), "ignoredFolderUploadReason": MessageLookupByLibrary.simpleMessage( - "Sommige bestanden in dit album worden genegeerd voor de upload omdat ze eerder van ente zijn verwijderd."), + "Sommige bestanden in dit album worden genegeerd voor uploaden omdat ze eerder van Ente zijn verwijderd."), "importing": MessageLookupByLibrary.simpleMessage("Importeren...."), "incorrectCode": MessageLookupByLibrary.simpleMessage("Onjuiste code"), "incorrectPasswordTitle": @@ -811,16 +823,20 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Installeer handmatig"), "invalidEmailAddress": MessageLookupByLibrary.simpleMessage("Ongeldig e-mailadres"), + "invalidEndpoint": + MessageLookupByLibrary.simpleMessage("Ongeldig eindpunt"), + "invalidEndpointMessage": MessageLookupByLibrary.simpleMessage( + "Sorry, het eindpunt dat je hebt ingevoerd is ongeldig. Voer een geldig eindpunt in en probeer het opnieuw."), "invalidKey": MessageLookupByLibrary.simpleMessage("Ongeldige sleutel"), "invalidRecoveryKey": MessageLookupByLibrary.simpleMessage( "De herstelsleutel die je hebt ingevoerd is niet geldig. Zorg ervoor dat deze 24 woorden bevat en controleer de spelling van elk van deze woorden.\n\nAls je een oudere herstelcode hebt ingevoerd, zorg ervoor dat deze 64 tekens lang is, en controleer ze allemaal."), "invite": MessageLookupByLibrary.simpleMessage("Uitnodigen"), "inviteToEnte": - MessageLookupByLibrary.simpleMessage("Uitnodigen voor ente"), + MessageLookupByLibrary.simpleMessage("Uitnodigen voor Ente"), "inviteYourFriends": MessageLookupByLibrary.simpleMessage("Vrienden uitnodigen"), "inviteYourFriendsToEnte": MessageLookupByLibrary.simpleMessage( - "Vrienden uitnodigen voor ente"), + "Vrienden uitnodigen voor Ente"), "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": MessageLookupByLibrary.simpleMessage( "Het lijkt erop dat er iets fout is gegaan. Probeer het later opnieuw. Als de fout zich blijft voordoen, neem dan contact op met ons supportteam."), @@ -830,7 +846,7 @@ class MessageLookup extends MessageLookupByLibrary { "Bestanden tonen het aantal resterende dagen voordat ze permanent worden verwijderd"), "itemsWillBeRemovedFromAlbum": MessageLookupByLibrary.simpleMessage( "Geselecteerde items zullen worden verwijderd uit dit album"), - "joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"), + "joinDiscord": MessageLookupByLibrary.simpleMessage("Join de Discord"), "keepPhotos": MessageLookupByLibrary.simpleMessage("Foto\'s behouden"), "kiloMeterUnit": MessageLookupByLibrary.simpleMessage("km"), "kindlyHelpUsWithThisInformation": MessageLookupByLibrary.simpleMessage( @@ -888,7 +904,7 @@ class MessageLookup extends MessageLookupByLibrary { "locationName": MessageLookupByLibrary.simpleMessage("Locatie naam"), "locationTagFeatureDescription": MessageLookupByLibrary.simpleMessage( "Een locatie tag groept alle foto\'s die binnen een bepaalde straal van een foto zijn genomen"), - "locations": MessageLookupByLibrary.simpleMessage("Locations"), + "locations": MessageLookupByLibrary.simpleMessage("Locaties"), "lockButtonLabel": MessageLookupByLibrary.simpleMessage("Vergrendel"), "lockScreenEnablePreSteps": MessageLookupByLibrary.simpleMessage( "Om vergrendelscherm in te schakelen, moet u een toegangscode of schermvergrendeling instellen in uw systeeminstellingen."), @@ -902,7 +918,7 @@ class MessageLookup extends MessageLookupByLibrary { "Dit zal logboeken verzenden om ons te helpen uw probleem op te lossen. Houd er rekening mee dat bestandsnamen zullen worden meegenomen om problemen met specifieke bestanden bij te houden."), "longPressAnEmailToVerifyEndToEndEncryption": MessageLookupByLibrary.simpleMessage( - "Long press an email to verify end to end encryption."), + "Druk lang op een e-mail om de versleuteling te verifiëren."), "longpressOnAnItemToViewInFullscreen": MessageLookupByLibrary.simpleMessage( "Houd een bestand lang ingedrukt om te bekijken op volledig scherm"), "lostDevice": @@ -953,7 +969,7 @@ class MessageLookup extends MessageLookupByLibrary { "Kan geen verbinding maken met Ente, controleer uw netwerkinstellingen en neem contact op met ondersteuning als de fout zich blijft voordoen."), "never": MessageLookupByLibrary.simpleMessage("Nooit"), "newAlbum": MessageLookupByLibrary.simpleMessage("Nieuw album"), - "newToEnte": MessageLookupByLibrary.simpleMessage("Nieuw bij ente"), + "newToEnte": MessageLookupByLibrary.simpleMessage("Nieuw bij Ente"), "newest": MessageLookupByLibrary.simpleMessage("Nieuwste"), "no": MessageLookupByLibrary.simpleMessage("Nee"), "noAlbumsSharedByYouYet": MessageLookupByLibrary.simpleMessage( @@ -1007,6 +1023,9 @@ class MessageLookup extends MessageLookupByLibrary { "orPickAnExistingOne": MessageLookupByLibrary.simpleMessage("Of kies een bestaande"), "pair": MessageLookupByLibrary.simpleMessage("Koppelen"), + "passkey": MessageLookupByLibrary.simpleMessage("Passkey"), + "passkeyAuthTitle": + MessageLookupByLibrary.simpleMessage("Passkey verificatie"), "password": MessageLookupByLibrary.simpleMessage("Wachtwoord"), "passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage( "Wachtwoord succesvol aangepast"), @@ -1018,6 +1037,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Betaalgegevens"), "paymentFailed": MessageLookupByLibrary.simpleMessage("Betaling mislukt"), + "paymentFailedMessage": MessageLookupByLibrary.simpleMessage( + "Helaas is je betaling mislukt. Neem contact op met support zodat we je kunnen helpen!"), "paymentFailedTalkToProvider": m37, "pendingItems": MessageLookupByLibrary.simpleMessage("Bestanden in behandeling"), @@ -1206,6 +1227,7 @@ class MessageLookup extends MessageLookupByLibrary { "scanThisBarcodeWithnyourAuthenticatorApp": MessageLookupByLibrary.simpleMessage( "Scan deze barcode met\nje authenticator app"), + "search": MessageLookupByLibrary.simpleMessage("Zoeken"), "searchAlbumsEmptySection": MessageLookupByLibrary.simpleMessage("Albums"), "searchByAlbumNameHint": @@ -1253,7 +1275,7 @@ class MessageLookup extends MessageLookupByLibrary { "selectYourPlan": MessageLookupByLibrary.simpleMessage("Kies uw abonnement"), "selectedFilesAreNotOnEnte": MessageLookupByLibrary.simpleMessage( - "Geselecteerde bestanden staan niet op ente"), + "Geselecteerde bestanden staan niet op Ente"), "selectedFoldersWillBeEncryptedAndBackedUp": MessageLookupByLibrary.simpleMessage( "Geselecteerde mappen worden versleuteld en geback-upt"), @@ -1267,6 +1289,8 @@ class MessageLookup extends MessageLookupByLibrary { "sendInvite": MessageLookupByLibrary.simpleMessage("Stuur een uitnodiging"), "sendLink": MessageLookupByLibrary.simpleMessage("Stuur link"), + "serverEndpoint": + MessageLookupByLibrary.simpleMessage("Server eindpunt"), "sessionExpired": MessageLookupByLibrary.simpleMessage("Sessie verlopen"), "setAPassword": @@ -1290,15 +1314,15 @@ class MessageLookup extends MessageLookupByLibrary { "Deel alleen met de mensen die u wilt"), "shareTextConfirmOthersVerificationID": m49, "shareTextRecommendUsingEnte": MessageLookupByLibrary.simpleMessage( - "Download ente zodat we gemakkelijk foto\'s en video\'s van originele kwaliteit kunnen delen\n\nhttps://ente.io"), + "Download Ente zodat we gemakkelijk foto\'s en video\'s in originele kwaliteit kunnen delen\n\nhttps://ente.io"), "shareTextReferralCode": m50, "shareWithNonenteUsers": MessageLookupByLibrary.simpleMessage( - "Delen met niet-ente gebruikers"), + "Delen met niet-Ente gebruikers"), "shareWithPeopleSectionTitle": m51, "shareYourFirstAlbum": MessageLookupByLibrary.simpleMessage("Deel jouw eerste album"), "sharedAlbumSectionDescription": MessageLookupByLibrary.simpleMessage( - "Maak gedeelde en collaboratieve albums met andere ente gebruikers, inclusief gebruikers met gratis abonnementen."), + "Maak gedeelde en collaboratieve albums met andere Ente gebruikers, inclusief gebruikers met gratis abonnementen."), "sharedByMe": MessageLookupByLibrary.simpleMessage("Gedeeld door mij"), "sharedByYou": MessageLookupByLibrary.simpleMessage("Gedeeld door jou"), "sharedPhotoNotifications": @@ -1328,7 +1352,7 @@ class MessageLookup extends MessageLookupByLibrary { "skip": MessageLookupByLibrary.simpleMessage("Overslaan"), "social": MessageLookupByLibrary.simpleMessage("Sociale media"), "someItemsAreInBothEnteAndYourDevice": MessageLookupByLibrary.simpleMessage( - "Sommige bestanden bevinden zich in zowel ente als op uw apparaat."), + "Sommige bestanden bevinden zich zowel in Ente als op jouw apparaat."), "someOfTheFilesYouAreTryingToDeleteAre": MessageLookupByLibrary.simpleMessage( "Sommige bestanden die u probeert te verwijderen zijn alleen beschikbaar op uw apparaat en kunnen niet hersteld worden als deze verwijderd worden"), @@ -1494,9 +1518,8 @@ class MessageLookup extends MessageLookupByLibrary { "Tot 50% korting, tot 4 december."), "usableReferralStorageInfo": MessageLookupByLibrary.simpleMessage( "Bruikbare opslag is beperkt door je huidige abonnement. Buitensporige geclaimde opslag zal automatisch bruikbaar worden wanneer je je abonnement upgrade."), - "usePublicLinksForPeopleNotOnEnte": - MessageLookupByLibrary.simpleMessage( - "Gebruik publieke links voor mensen die niet op ente zitten"), + "usePublicLinksForPeopleNotOnEnte": MessageLookupByLibrary.simpleMessage( + "Gebruik publieke links voor mensen die geen Ente account hebben"), "useRecoveryKey": MessageLookupByLibrary.simpleMessage("Herstelcode gebruiken"), "useSelectedPhoto": @@ -1512,6 +1535,8 @@ class MessageLookup extends MessageLookupByLibrary { "verifyEmail": MessageLookupByLibrary.simpleMessage("Bevestig e-mail"), "verifyEmailID": m65, "verifyIDLabel": MessageLookupByLibrary.simpleMessage("Verifiëren"), + "verifyPasskey": + MessageLookupByLibrary.simpleMessage("Bevestig passkey"), "verifyPassword": MessageLookupByLibrary.simpleMessage("Bevestig wachtwoord"), "verifying": MessageLookupByLibrary.simpleMessage("Verifiëren..."), @@ -1532,6 +1557,8 @@ class MessageLookup extends MessageLookupByLibrary { "viewer": MessageLookupByLibrary.simpleMessage("Kijker"), "visitWebToManage": MessageLookupByLibrary.simpleMessage( "Bezoek alstublieft web.ente.io om uw abonnement te beheren"), + "waitingForVerification": + MessageLookupByLibrary.simpleMessage("Wachten op verificatie..."), "waitingForWifi": MessageLookupByLibrary.simpleMessage("Wachten op WiFi..."), "weAreOpenSource": diff --git a/mobile/lib/generated/intl/messages_no.dart b/mobile/lib/generated/intl/messages_no.dart index 294292a3d..0e5bd97b2 100644 --- a/mobile/lib/generated/intl/messages_no.dart +++ b/mobile/lib/generated/intl/messages_no.dart @@ -77,6 +77,7 @@ class MessageLookup extends MessageLookupByLibrary { "Modify your query, or try searching for"), "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage("Move to hidden album"), + "search": MessageLookupByLibrary.simpleMessage("Search"), "selectALocation": MessageLookupByLibrary.simpleMessage("Select a location"), "selectALocationFirst": diff --git a/mobile/lib/generated/intl/messages_pl.dart b/mobile/lib/generated/intl/messages_pl.dart index fea153d71..b3a922b0a 100644 --- a/mobile/lib/generated/intl/messages_pl.dart +++ b/mobile/lib/generated/intl/messages_pl.dart @@ -171,6 +171,7 @@ class MessageLookup extends MessageLookupByLibrary { "resetPasswordTitle": MessageLookupByLibrary.simpleMessage("Zresetuj hasło"), "saveKey": MessageLookupByLibrary.simpleMessage("Zapisz klucz"), + "search": MessageLookupByLibrary.simpleMessage("Search"), "selectALocation": MessageLookupByLibrary.simpleMessage("Select a location"), "selectALocationFirst": diff --git a/mobile/lib/generated/intl/messages_pt.dart b/mobile/lib/generated/intl/messages_pt.dart index 3168451df..50552dc66 100644 --- a/mobile/lib/generated/intl/messages_pt.dart +++ b/mobile/lib/generated/intl/messages_pt.dart @@ -1217,6 +1217,7 @@ class MessageLookup extends MessageLookupByLibrary { "scanThisBarcodeWithnyourAuthenticatorApp": MessageLookupByLibrary.simpleMessage( "Escaneie este código de barras com\nseu aplicativo autenticador"), + "search": MessageLookupByLibrary.simpleMessage("Pesquisar"), "searchAlbumsEmptySection": MessageLookupByLibrary.simpleMessage("Álbuns"), "searchByAlbumNameHint": diff --git a/mobile/lib/generated/intl/messages_zh.dart b/mobile/lib/generated/intl/messages_zh.dart index 73d2ae0f5..7be447f89 100644 --- a/mobile/lib/generated/intl/messages_zh.dart +++ b/mobile/lib/generated/intl/messages_zh.dart @@ -988,6 +988,7 @@ class MessageLookup extends MessageLookupByLibrary { "scanCode": MessageLookupByLibrary.simpleMessage("扫描二维码/条码"), "scanThisBarcodeWithnyourAuthenticatorApp": MessageLookupByLibrary.simpleMessage("用您的身份验证器应用\n扫描此条码"), + "search": MessageLookupByLibrary.simpleMessage("搜索"), "searchAlbumsEmptySection": MessageLookupByLibrary.simpleMessage("相册"), "searchByAlbumNameHint": MessageLookupByLibrary.simpleMessage("相册名称"), "searchByExamples": MessageLookupByLibrary.simpleMessage( diff --git a/mobile/lib/generated/l10n.dart b/mobile/lib/generated/l10n.dart index 89b71a76a..3fa9c2209 100644 --- a/mobile/lib/generated/l10n.dart +++ b/mobile/lib/generated/l10n.dart @@ -8553,6 +8553,16 @@ class S { args: [], ); } + + /// `Search` + String get search { + return Intl.message( + 'Search', + name: 'search', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/mobile/lib/l10n/intl_cs.arb b/mobile/lib/l10n/intl_cs.arb index 6b7a4933b..e7d374725 100644 --- a/mobile/lib/l10n/intl_cs.arb +++ b/mobile/lib/l10n/intl_cs.arb @@ -17,5 +17,6 @@ "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}", "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}", "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.", - "createCollaborativeLink": "Create collaborative link" + "createCollaborativeLink": "Create collaborative link", + "search": "Search" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_de.arb b/mobile/lib/l10n/intl_de.arb index 8bb844df3..0e5807e1e 100644 --- a/mobile/lib/l10n/intl_de.arb +++ b/mobile/lib/l10n/intl_de.arb @@ -1203,5 +1203,6 @@ "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}", "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}", "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.", - "createCollaborativeLink": "Create collaborative link" + "createCollaborativeLink": "Create collaborative link", + "search": "Search" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_en.arb b/mobile/lib/l10n/intl_en.arb index 9d1c7bcf9..7115c6950 100644 --- a/mobile/lib/l10n/intl_en.arb +++ b/mobile/lib/l10n/intl_en.arb @@ -1211,5 +1211,6 @@ "invalidEndpointMessage": "Sorry, the endpoint you entered is invalid. Please enter a valid endpoint and try again.", "endpointUpdatedMessage": "Endpoint updated successfully", "customEndpoint": "Connected to {endpoint}", - "createCollaborativeLink": "Create collaborative link" + "createCollaborativeLink": "Create collaborative link", + "search": "Search" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_es.arb b/mobile/lib/l10n/intl_es.arb index 7dff21036..6515371fa 100644 --- a/mobile/lib/l10n/intl_es.arb +++ b/mobile/lib/l10n/intl_es.arb @@ -979,5 +979,6 @@ "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}", "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}", "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.", - "createCollaborativeLink": "Create collaborative link" + "createCollaborativeLink": "Create collaborative link", + "search": "Search" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_fr.arb b/mobile/lib/l10n/intl_fr.arb index d44d093c1..1d8e5f6d3 100644 --- a/mobile/lib/l10n/intl_fr.arb +++ b/mobile/lib/l10n/intl_fr.arb @@ -1160,5 +1160,6 @@ "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}", "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}", "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.", - "createCollaborativeLink": "Create collaborative link" + "createCollaborativeLink": "Create collaborative link", + "search": "Search" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_it.arb b/mobile/lib/l10n/intl_it.arb index 9e884ed9e..c9655dd06 100644 --- a/mobile/lib/l10n/intl_it.arb +++ b/mobile/lib/l10n/intl_it.arb @@ -1122,5 +1122,6 @@ "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}", "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}", "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.", - "createCollaborativeLink": "Create collaborative link" + "createCollaborativeLink": "Create collaborative link", + "search": "Search" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_ko.arb b/mobile/lib/l10n/intl_ko.arb index 6b7a4933b..e7d374725 100644 --- a/mobile/lib/l10n/intl_ko.arb +++ b/mobile/lib/l10n/intl_ko.arb @@ -17,5 +17,6 @@ "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}", "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}", "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.", - "createCollaborativeLink": "Create collaborative link" + "createCollaborativeLink": "Create collaborative link", + "search": "Search" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_nl.arb b/mobile/lib/l10n/intl_nl.arb index 120e4a207..0ba9bd10c 100644 --- a/mobile/lib/l10n/intl_nl.arb +++ b/mobile/lib/l10n/intl_nl.arb @@ -23,7 +23,7 @@ "sendEmail": "E-mail versturen", "deleteRequestSLAText": "Je verzoek wordt binnen 72 uur verwerkt.", "deleteEmailRequest": "Stuur een e-mail naar account-deletion@ente.io vanaf het door jou geregistreerde e-mailadres.", - "entePhotosPerm": "ente heeft toestemming nodig om je foto's te bewaren", + "entePhotosPerm": "Ente heeft toestemming nodig om je foto's te bewaren", "ok": "Oké", "createAccount": "Account aanmaken", "createNewAccount": "Nieuw account aanmaken", @@ -225,17 +225,17 @@ }, "description": "Number of participants in an album, including the album owner." }, - "collabLinkSectionDescription": "Maak een link waarmee mensen foto's in jouw gedeelde album kunnen toevoegen en bekijken zonder dat ze daarvoor een ente app of account nodig hebben. Handig voor het verzamelen van foto's van evenementen.", + "collabLinkSectionDescription": "Maak een link waarmee mensen foto's in jouw gedeelde album kunnen toevoegen en bekijken zonder dat ze daarvoor een Ente app of account nodig hebben. Handig voor het verzamelen van foto's van evenementen.", "collectPhotos": "Foto's verzamelen", "collaborativeLink": "Gezamenlijke link", - "shareWithNonenteUsers": "Delen met niet-ente gebruikers", + "shareWithNonenteUsers": "Delen met niet-Ente gebruikers", "createPublicLink": "Maak publieke link", "sendLink": "Stuur link", "copyLink": "Kopieer link", "linkHasExpired": "Link is vervallen", "publicLinkEnabled": "Publieke link ingeschakeld", "shareALink": "Deel een link", - "sharedAlbumSectionDescription": "Maak gedeelde en collaboratieve albums met andere ente gebruikers, inclusief gebruikers met gratis abonnementen.", + "sharedAlbumSectionDescription": "Maak gedeelde en collaboratieve albums met andere Ente gebruikers, inclusief gebruikers met gratis abonnementen.", "shareWithPeopleSectionTitle": "{numberOfPeople, plural, =0 {Deel met specifieke mensen} =1 {Gedeeld met 1 persoon} other {Gedeeld met {numberOfPeople} mensen}}", "@shareWithPeopleSectionTitle": { "placeholders": { @@ -259,12 +259,12 @@ }, "verificationId": "Verificatie ID", "verifyEmailID": "Verifieer {email}", - "emailNoEnteAccount": "{email} heeft geen ente account.\n\nStuur ze een uitnodiging om foto's te delen.", + "emailNoEnteAccount": "{email} heeft geen Ente account.\n\nStuur ze een uitnodiging om foto's te delen.", "shareMyVerificationID": "Hier is mijn verificatie-ID: {verificationID} voor ente.io.", "shareTextConfirmOthersVerificationID": "Hey, kunt u bevestigen dat dit uw ente.io verificatie-ID is: {verificationID}", "somethingWentWrong": "Er ging iets mis", "sendInvite": "Stuur een uitnodiging", - "shareTextRecommendUsingEnte": "Download ente zodat we gemakkelijk foto's en video's van originele kwaliteit kunnen delen\n\nhttps://ente.io", + "shareTextRecommendUsingEnte": "Download Ente zodat we gemakkelijk foto's en video's in originele kwaliteit kunnen delen\n\nhttps://ente.io", "done": "Voltooid", "applyCodeTitle": "Code toepassen", "enterCodeDescription": "Voer de code van de vriend in om gratis opslag voor jullie beiden te claimen", @@ -281,7 +281,7 @@ "claimMore": "Claim meer!", "theyAlsoGetXGb": "Zij krijgen ook {storageAmountInGB} GB", "freeStorageOnReferralSuccess": "{storageAmountInGB} GB telkens als iemand zich aanmeldt voor een betaald abonnement en je code toepast", - "shareTextReferralCode": "ente verwijzingscode: {referralCode} \n\nPas het toe bij Instellingen → Algemeen → Verwijzingen om {referralStorageInGB} GB gratis te krijgen nadat je je hebt aangemeld voor een betaald abonnement\n\nhttps://ente.io", + "shareTextReferralCode": "Ente verwijzingscode: {referralCode} \n\nPas het toe bij Instellingen → Algemeen → Verwijzingen om {referralStorageInGB} GB gratis te krijgen nadat je je hebt aangemeld voor een betaald abonnement\n\nhttps://ente.io", "claimFreeStorage": "Claim gratis opslag", "inviteYourFriends": "Vrienden uitnodigen", "failedToFetchReferralDetails": "Kan geen verwijzingsgegevens ophalen. Probeer het later nog eens.", @@ -304,6 +304,7 @@ } }, "faq": "Veelgestelde vragen", + "help": "Hulp", "oopsSomethingWentWrong": "Oeps, er is iets misgegaan", "peopleUsingYourCode": "Mensen die jouw code gebruiken", "eligible": "gerechtigd", @@ -333,7 +334,7 @@ "removeParticipantBody": "{userEmail} zal worden verwijderd uit dit gedeelde album\n\nAlle door hen toegevoegde foto's worden ook uit het album verwijderd", "keepPhotos": "Foto's behouden", "deletePhotos": "Foto's verwijderen", - "inviteToEnte": "Uitnodigen voor ente", + "inviteToEnte": "Uitnodigen voor Ente", "removePublicLink": "Verwijder publieke link", "disableLinkMessage": "Dit verwijdert de openbare link voor toegang tot \"{albumName}\".", "sharing": "Delen...", @@ -349,10 +350,10 @@ "videoSmallCase": "video", "photoSmallCase": "foto", "singleFileDeleteHighlight": "Het wordt uit alle albums verwijderd.", - "singleFileInBothLocalAndRemote": "Deze {fileType} staat zowel in ente als op jouw apparaat.", - "singleFileInRemoteOnly": "Deze {fileType} zal worden verwijderd uit ente.", + "singleFileInBothLocalAndRemote": "Deze {fileType} staat zowel in Ente als op jouw apparaat.", + "singleFileInRemoteOnly": "Deze {fileType} zal worden verwijderd uit Ente.", "singleFileDeleteFromDevice": "Deze {fileType} zal worden verwijderd van jouw apparaat.", - "deleteFromEnte": "Verwijder van ente", + "deleteFromEnte": "Verwijder van Ente", "yesDelete": "Ja, verwijderen", "movedToTrash": "Naar prullenbak verplaatst", "deleteFromDevice": "Verwijder van apparaat", @@ -444,7 +445,7 @@ "backupOverMobileData": "Back-up maken via mobiele data", "backupVideos": "Back-up video's", "disableAutoLock": "Automatisch vergrendelen uitschakelen", - "deviceLockExplanation": "Schakel de schermvergrendeling van het apparaat uit wanneer ente op de voorgrond is en er een back-up aan de gang is. Dit is normaal gesproken niet nodig, maar kan grote uploads en initiële imports van grote mappen sneller laten verlopen.", + "deviceLockExplanation": "Schakel de schermvergrendeling van het apparaat uit wanneer Ente op de voorgrond is en er een back-up aan de gang is. Dit is normaal gesproken niet nodig, maar kan grote uploads en initiële imports van grote mappen sneller laten verlopen.", "about": "Over", "weAreOpenSource": "We zijn open source!", "privacy": "Privacy", @@ -464,7 +465,7 @@ "authToInitiateAccountDeletion": "Gelieve te verifiëren om het verwijderen van je account te starten", "areYouSureYouWantToLogout": "Weet je zeker dat je wilt uitloggen?", "yesLogout": "Ja, log uit", - "aNewVersionOfEnteIsAvailable": "Er is een nieuwe versie van ente beschikbaar.", + "aNewVersionOfEnteIsAvailable": "Er is een nieuwe versie van Ente beschikbaar.", "update": "Update", "installManually": "Installeer handmatig", "criticalUpdateAvailable": "Belangrijke update beschikbaar", @@ -553,11 +554,11 @@ "systemTheme": "Systeem", "freeTrial": "Gratis proefversie", "selectYourPlan": "Kies uw abonnement", - "enteSubscriptionPitch": "ente bewaart uw herinneringen, zodat ze altijd beschikbaar voor u zijn, zelfs als u uw apparaat verliest.", + "enteSubscriptionPitch": "Ente bewaart uw herinneringen, zodat ze altijd beschikbaar voor u zijn, zelfs als u uw apparaat verliest.", "enteSubscriptionShareWithFamily": "Je familie kan ook aan je abonnement worden toegevoegd.", "currentUsageIs": "Huidig gebruik is ", "@currentUsageIs": { - "description": "This text is followed by storage usaged", + "description": "This text is followed by storage usage", "examples": { "0": "Current usage is 1.2 GB" }, @@ -619,7 +620,7 @@ "appleId": "Apple ID", "playstoreSubscription": "PlayStore abonnement", "appstoreSubscription": "PlayStore abonnement", - "subAlreadyLinkedErrMessage": "Uw {id} is al aan een ander ente account gekoppeld.\nAls u uw {id} wilt gebruiken met dit account, neem dan contact op met onze klantenservice", + "subAlreadyLinkedErrMessage": "Jouw {id} is al aan een ander Ente account gekoppeld.\nAls je jouw {id} wilt gebruiken met dit account, neem dan contact op met onze klantenservice", "visitWebToManage": "Bezoek alstublieft web.ente.io om uw abonnement te beheren", "couldNotUpdateSubscription": "Kon abonnement niet wijzigen", "pleaseContactSupportAndWeWillBeHappyToHelp": "Neem alstublieft contact op met support@ente.io en we helpen u graag!", @@ -640,7 +641,7 @@ "thankYou": "Bedankt", "failedToVerifyPaymentStatus": "Betalingsstatus verifiëren mislukt", "pleaseWaitForSometimeBeforeRetrying": "Gelieve even te wachten voordat u opnieuw probeert", - "paymentFailedWithReason": "Helaas is uw betaling mislukt vanwege {reason}", + "paymentFailedMessage": "Helaas is je betaling mislukt. Neem contact op met support zodat we je kunnen helpen!", "youAreOnAFamilyPlan": "U bent onderdeel van een familie abonnement!", "contactFamilyAdmin": "Neem contact op met {familyAdminEmail} om uw abonnement te beheren", "leaveFamily": "Familie abonnement verlaten", @@ -664,7 +665,7 @@ "everywhere": "overal", "androidIosWebDesktop": "Android, iOS, Web, Desktop", "mobileWebDesktop": "Mobiel, Web, Desktop", - "newToEnte": "Nieuw bij ente", + "newToEnte": "Nieuw bij Ente", "pleaseLoginAgain": "Log opnieuw in", "devAccountChanged": "Het ontwikkelaarsaccount dat we gebruiken om te publiceren in de App Store is veranderd. Daarom moet je opnieuw inloggen.\n\nOnze excuses voor het ongemak, helaas was dit onvermijdelijk.", "yourSubscriptionHasExpired": "Uw abonnement is verlopen", @@ -677,12 +678,12 @@ }, "backupFailed": "Back-up mislukt", "couldNotBackUpTryLater": "We konden uw gegevens niet back-uppen.\nWe zullen het later opnieuw proberen.", - "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "ente kan bestanden alleen versleutelen en bewaren als u toegang tot ze geeft", + "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "Ente kan bestanden alleen versleutelen en bewaren als u toegang tot ze geeft", "pleaseGrantPermissions": "Geef alstublieft toestemming", "grantPermission": "Toestemming verlenen", "privateSharing": "Privé delen", "shareOnlyWithThePeopleYouWant": "Deel alleen met de mensen die u wilt", - "usePublicLinksForPeopleNotOnEnte": "Gebruik publieke links voor mensen die niet op ente zitten", + "usePublicLinksForPeopleNotOnEnte": "Gebruik publieke links voor mensen die geen Ente account hebben", "allowPeopleToAddPhotos": "Mensen toestaan foto's toe te voegen", "shareAnAlbumNow": "Deel nu een album", "collectEventPhotos": "Foto's van gebeurtenissen verzamelen", @@ -694,7 +695,7 @@ }, "onDevice": "Op het apparaat", "@onEnte": { - "description": "The text displayed above albums backed up to ente", + "description": "The text displayed above albums backed up to Ente", "type": "text" }, "onEnte": "Op ente", @@ -740,7 +741,7 @@ "saveCollage": "Sla collage op", "collageSaved": "Collage opgeslagen in gallerij", "collageLayout": "Layout", - "addToEnte": "Toevoegen aan ente", + "addToEnte": "Toevoegen aan Ente", "addToAlbum": "Toevoegen aan album", "delete": "Verwijderen", "hide": "Verbergen", @@ -805,9 +806,9 @@ "photosAddedByYouWillBeRemovedFromTheAlbum": "Foto's toegevoegd door u zullen worden verwijderd uit het album", "youveNoFilesInThisAlbumThatCanBeDeleted": "Je hebt geen bestanden in dit album die verwijderd kunnen worden", "youDontHaveAnyArchivedItems": "U heeft geen gearchiveerde bestanden.", - "ignoredFolderUploadReason": "Sommige bestanden in dit album worden genegeerd voor de upload omdat ze eerder van ente zijn verwijderd.", + "ignoredFolderUploadReason": "Sommige bestanden in dit album worden genegeerd voor uploaden omdat ze eerder van Ente zijn verwijderd.", "resetIgnoredFiles": "Reset genegeerde bestanden", - "deviceFilesAutoUploading": "Bestanden toegevoegd aan dit album van dit apparaat zullen automatisch geüpload worden naar ente.", + "deviceFilesAutoUploading": "Bestanden toegevoegd aan dit album van dit apparaat zullen automatisch geüpload worden naar Ente.", "turnOnBackupForAutoUpload": "Schakel back-up in om bestanden die toegevoegd zijn aan deze map op dit apparaat automatisch te uploaden.", "noHiddenPhotosOrVideos": "Geen verborgen foto's of video's", "toHideAPhotoOrVideo": "Om een foto of video te verbergen", @@ -885,7 +886,7 @@ "@freeUpSpaceSaving": { "description": "Text to tell user how much space they can free up by deleting items from the device" }, - "freeUpAccessPostDelete": "U heeft nog steeds toegang tot {count, plural, one {het} other {ze}} op ente zolang u een actief abonnement heeft", + "freeUpAccessPostDelete": "Je hebt nog steeds toegang tot {count, plural, one {het} other {ze}} op Ente zolang je een actief abonnement hebt", "@freeUpAccessPostDelete": { "placeholders": { "count": { @@ -936,7 +937,7 @@ "renameFile": "Bestandsnaam wijzigen", "enterFileName": "Geef bestandsnaam op", "filesDeleted": "Bestanden verwijderd", - "selectedFilesAreNotOnEnte": "Geselecteerde bestanden staan niet op ente", + "selectedFilesAreNotOnEnte": "Geselecteerde bestanden staan niet op Ente", "thisActionCannotBeUndone": "Deze actie kan niet ongedaan gemaakt worden", "emptyTrash": "Prullenbak leegmaken?", "permDeleteWarning": "Alle bestanden in de prullenbak zullen permanent worden verwijderd\n\nDeze actie kan niet ongedaan worden gemaakt", @@ -945,7 +946,7 @@ "permanentlyDeleteFromDevice": "Permanent verwijderen van apparaat?", "someOfTheFilesYouAreTryingToDeleteAre": "Sommige bestanden die u probeert te verwijderen zijn alleen beschikbaar op uw apparaat en kunnen niet hersteld worden als deze verwijderd worden", "theyWillBeDeletedFromAllAlbums": "Ze zullen uit alle albums worden verwijderd.", - "someItemsAreInBothEnteAndYourDevice": "Sommige bestanden bevinden zich in zowel ente als op uw apparaat.", + "someItemsAreInBothEnteAndYourDevice": "Sommige bestanden bevinden zich zowel in Ente als op jouw apparaat.", "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": "Geselecteerde bestanden worden verwijderd uit alle albums en verplaatst naar de prullenbak.", "theseItemsWillBeDeletedFromYourDevice": "Deze bestanden zullen worden verwijderd van uw apparaat.", "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Het lijkt erop dat er iets fout is gegaan. Probeer het later opnieuw. Als de fout zich blijft voordoen, neem dan contact op met ons supportteam.", @@ -1051,7 +1052,7 @@ }, "setRadius": "Radius instellen", "familyPlanPortalTitle": "Familie", - "familyPlanOverview": "Voeg 5 gezinsleden toe aan uw bestaande abonnement zonder extra te betalen.\n\nElk lid krijgt zijn eigen privé ruimte en kan elkaars bestanden niet zien, tenzij ze zijn gedeeld.\n\nFamilieplannen zijn beschikbaar voor klanten die een betaald ente abonnement hebben.\n\nAbonneer u nu om aan de slag te gaan!", + "familyPlanOverview": "Voeg 5 gezinsleden toe aan je bestaande abonnement zonder extra te betalen.\n\nElk lid krijgt zijn eigen privé ruimte en kan elkaars bestanden niet zien tenzij ze zijn gedeeld.\n\nFamilieplannen zijn beschikbaar voor klanten die een betaald Ente abonnement hebben.\n\nAbonneer nu om aan de slag te gaan!", "androidBiometricHint": "Identiteit verifiëren", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -1129,7 +1130,7 @@ "noAlbumsSharedByYouYet": "Nog geen albums gedeeld door jou", "sharedWithYou": "Gedeeld met jou", "sharedByYou": "Gedeeld door jou", - "inviteYourFriendsToEnte": "Vrienden uitnodigen voor ente", + "inviteYourFriendsToEnte": "Vrienden uitnodigen voor Ente", "failedToDownloadVideo": "Downloaden van video mislukt", "hiding": "Verbergen...", "unhiding": "Zichtbaar maken...", @@ -1139,7 +1140,7 @@ "addToHiddenAlbum": "Toevoegen aan verborgen album", "moveToHiddenAlbum": "Verplaatsen naar verborgen album", "fileTypes": "Bestandstype", - "deleteConfirmDialogBody": "Dit account is gekoppeld aan andere ente apps, als je er gebruik van maakt.\\n\\nJe geüploade gegevens worden in alle ente apps gepland voor verwijdering, en je account wordt permanent verwijderd voor alle ente diensten.", + "deleteConfirmDialogBody": "Dit account is gekoppeld aan andere Ente apps, als je er gebruik van maakt. Je geüploade gegevens worden in alle Ente apps gepland voor verwijdering, en je account wordt permanent verwijderd voor alle Ente diensten.", "hearUsWhereTitle": "Hoe hoorde je over Ente? (optioneel)", "hearUsExplanation": "Wij gebruiken geen tracking. Het zou helpen als je ons vertelt waar je ons gevonden hebt!", "viewAddOnButton": "Add-ons bekijken", @@ -1187,16 +1188,29 @@ "changeLocationOfSelectedItems": "Locatie van geselecteerde items wijzigen?", "editsToLocationWillOnlyBeSeenWithinEnte": "Bewerkte locatie wordt alleen gezien binnen Ente", "cleanUncategorized": "Ongecategoriseerd opschonen", + "cleanUncategorizedDescription": "Verwijder alle bestanden van Ongecategoriseerd die aanwezig zijn in andere albums", + "waitingForVerification": "Wachten op verificatie...", + "passkey": "Passkey", + "passkeyAuthTitle": "Passkey verificatie", + "verifyPasskey": "Bevestig passkey", "playOnTv": "Album afspelen op TV", "pair": "Koppelen", "deviceNotFound": "Apparaat niet gevonden", "castInstruction": "Bezoek cast.ente.io op het apparaat dat u wilt koppelen.\n\nVoer de code hieronder in om het album op uw TV af te spelen.", "deviceCodeHint": "Voer de code in", - "joinDiscord": "Join Discord", - "locations": "Locations", - "descriptions": "Descriptions", - "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}", - "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}", - "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.", - "createCollaborativeLink": "Create collaborative link" + "joinDiscord": "Join de Discord", + "locations": "Locaties", + "descriptions": "Beschrijvingen", + "addViewers": "{count, plural, one {Voeg kijker toe} other {Voeg kijkers toe}}", + "addCollaborators": "{count, plural, zero {Voeg samenwerker toe} one {Voeg samenwerker toe} other {Voeg samenwerkers toe}}", + "longPressAnEmailToVerifyEndToEndEncryption": "Druk lang op een e-mail om de versleuteling te verifiëren.", + "developerSettingsWarning": "Weet je zeker dat je de ontwikkelaarsinstellingen wilt wijzigen?", + "developerSettings": "Ontwikkelaarsinstellingen", + "serverEndpoint": "Server eindpunt", + "invalidEndpoint": "Ongeldig eindpunt", + "invalidEndpointMessage": "Sorry, het eindpunt dat je hebt ingevoerd is ongeldig. Voer een geldig eindpunt in en probeer het opnieuw.", + "endpointUpdatedMessage": "Eindpunt met succes bijgewerkt", + "customEndpoint": "Verbonden met {endpoint}", + "createCollaborativeLink": "Maak een gezamenlijke link", + "search": "Zoeken" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_no.arb b/mobile/lib/l10n/intl_no.arb index 0b777b353..8908eadb0 100644 --- a/mobile/lib/l10n/intl_no.arb +++ b/mobile/lib/l10n/intl_no.arb @@ -31,5 +31,6 @@ "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}", "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}", "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.", - "createCollaborativeLink": "Create collaborative link" + "createCollaborativeLink": "Create collaborative link", + "search": "Search" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_pl.arb b/mobile/lib/l10n/intl_pl.arb index d358d4d2c..13d740614 100644 --- a/mobile/lib/l10n/intl_pl.arb +++ b/mobile/lib/l10n/intl_pl.arb @@ -118,5 +118,6 @@ "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}", "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}", "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.", - "createCollaborativeLink": "Create collaborative link" + "createCollaborativeLink": "Create collaborative link", + "search": "Search" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_pt.arb b/mobile/lib/l10n/intl_pt.arb index 37b1041a9..4185ea901 100644 --- a/mobile/lib/l10n/intl_pt.arb +++ b/mobile/lib/l10n/intl_pt.arb @@ -1211,5 +1211,6 @@ "invalidEndpointMessage": "Desculpe, o endpoint que você inseriu é inválido. Por favor, insira um endpoint válido e tente novamente.", "endpointUpdatedMessage": "Endpoint atualizado com sucesso", "customEndpoint": "Conectado a {endpoint}", - "createCollaborativeLink": "Criar link colaborativo" + "createCollaborativeLink": "Criar link colaborativo", + "search": "Pesquisar" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_zh.arb b/mobile/lib/l10n/intl_zh.arb index 439643162..54fed47df 100644 --- a/mobile/lib/l10n/intl_zh.arb +++ b/mobile/lib/l10n/intl_zh.arb @@ -1211,5 +1211,6 @@ "invalidEndpointMessage": "抱歉,您输入的端点无效。请输入有效的端点,然后重试。", "endpointUpdatedMessage": "端点更新成功", "customEndpoint": "已连接至 {endpoint}", - "createCollaborativeLink": "创建协作链接" + "createCollaborativeLink": "创建协作链接", + "search": "搜索" } \ No newline at end of file diff --git a/mobile/lib/ui/account/recovery_page.dart b/mobile/lib/ui/account/recovery_page.dart index 4b3d49995..881b0792d 100644 --- a/mobile/lib/ui/account/recovery_page.dart +++ b/mobile/lib/ui/account/recovery_page.dart @@ -59,9 +59,9 @@ class _RecoveryPageState extends State { Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (BuildContext context) { - return WillPopScope( - onWillPop: () async => false, - child: const PasswordEntryPage( + return const PopScope( + canPop: false, + child: PasswordEntryPage( mode: PasswordEntryMode.reset, ), ); diff --git a/mobile/lib/ui/common/linear_progress_dialog.dart b/mobile/lib/ui/common/linear_progress_dialog.dart index 3bd2f70fe..375eebe48 100644 --- a/mobile/lib/ui/common/linear_progress_dialog.dart +++ b/mobile/lib/ui/common/linear_progress_dialog.dart @@ -27,8 +27,8 @@ class LinearProgressDialogState extends State { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async => false, + return PopScope( + canPop: false, child: AlertDialog( title: Text( widget.message, diff --git a/mobile/lib/ui/common/progress_dialog.dart b/mobile/lib/ui/common/progress_dialog.dart index 61f8d4ca1..f08d7cdbc 100644 --- a/mobile/lib/ui/common/progress_dialog.dart +++ b/mobile/lib/ui/common/progress_dialog.dart @@ -155,8 +155,8 @@ class ProgressDialog { barrierColor: _barrierColor, builder: (BuildContext context) { _dismissingContext = context; - return WillPopScope( - onWillPop: () async => _barrierDismissible, + return PopScope( + canPop: _barrierDismissible, child: Dialog( backgroundColor: _backgroundColor, insetAnimationCurve: _insetAnimCurve, diff --git a/mobile/lib/ui/payment/payment_web_page.dart b/mobile/lib/ui/payment/payment_web_page.dart index cbe55f671..c6c0c83d0 100644 --- a/mobile/lib/ui/payment/payment_web_page.dart +++ b/mobile/lib/ui/payment/payment_web_page.dart @@ -52,8 +52,15 @@ class _PaymentWebPageState extends State { if (initPaymentUrl == null) { return const EnteLoadingWidget(); } - return WillPopScope( - onWillPop: (() async => _buildPageExitWidget(context)), + return PopScope( + canPop: false, + onPopInvoked: (didPop) async { + if (didPop) return; + final shouldPop = await _buildPageExitWidget(context); + if (shouldPop) { + Navigator.of(context).pop(); + } + }, child: Scaffold( appBar: AppBar( title: Text(S.of(context).subscription), diff --git a/mobile/lib/ui/settings/app_update_dialog.dart b/mobile/lib/ui/settings/app_update_dialog.dart index 8038b7fa5..c9e612201 100644 --- a/mobile/lib/ui/settings/app_update_dialog.dart +++ b/mobile/lib/ui/settings/app_update_dialog.dart @@ -83,8 +83,8 @@ class _AppUpdateDialogState extends State { ); final shouldForceUpdate = UpdateService.instance.shouldForceUpdate(widget.latestVersionInfo!); - return WillPopScope( - onWillPop: () async => !shouldForceUpdate, + return PopScope( + canPop: !shouldForceUpdate, child: AlertDialog( key: const ValueKey("updateAppDialog"), title: Column( diff --git a/mobile/lib/ui/tabs/home_widget.dart b/mobile/lib/ui/tabs/home_widget.dart index 6745aaaa6..4b2c38ce5 100644 --- a/mobile/lib/ui/tabs/home_widget.dart +++ b/mobile/lib/ui/tabs/home_widget.dart @@ -315,7 +315,23 @@ class _HomeWidgetState extends State { final enableDrawer = LocalSyncService.instance.hasCompletedFirstImport(); final action = AppLifecycleService.instance.mediaExtensionAction.action; return UserDetailsStateWidget( - child: WillPopScope( + child: PopScope( + canPop: false, + onPopInvoked: (didPop) async { + if (didPop) return; + if (_selectedTabIndex == 0) { + if (isSettingsOpen) { + Navigator.pop(context); + } else if (Platform.isAndroid && action == IntentAction.main) { + unawaited(MoveToBackground.moveTaskToBack()); + } else { + Navigator.pop(context); + } + } else { + Bus.instance + .fire(TabChangedEvent(0, TabChangedEventSource.backButton)); + } + }, child: Scaffold( drawerScrimColor: getEnteColorScheme(context).strokeFainter, drawerEnableOpenDragGesture: false, @@ -341,24 +357,6 @@ class _HomeWidgetState extends State { ), resizeToAvoidBottomInset: false, ), - onWillPop: () async { - if (_selectedTabIndex == 0) { - if (isSettingsOpen) { - Navigator.pop(context); - return false; - } - if (Platform.isAndroid && action == IntentAction.main) { - unawaited(MoveToBackground.moveTaskToBack()); - return false; - } else { - return true; - } - } else { - Bus.instance - .fire(TabChangedEvent(0, TabChangedEventSource.backButton)); - return false; - } - }, ), ); } diff --git a/mobile/lib/ui/tools/app_lock.dart b/mobile/lib/ui/tools/app_lock.dart index 1fbc1678e..c27555df0 100644 --- a/mobile/lib/ui/tools/app_lock.dart +++ b/mobile/lib/ui/tools/app_lock.dart @@ -137,9 +137,9 @@ class _AppLockState extends State with WidgetsBindingObserver { } Widget get _lockScreen { - return WillPopScope( + return PopScope( + canPop: false, child: this.widget.lockScreen, - onWillPop: () => Future.value(false), ); } diff --git a/mobile/lib/ui/tools/editor/image_editor_page.dart b/mobile/lib/ui/tools/editor/image_editor_page.dart index ca36db002..4830df952 100644 --- a/mobile/lib/ui/tools/editor/image_editor_page.dart +++ b/mobile/lib/ui/tools/editor/image_editor_page.dart @@ -63,14 +63,14 @@ class _ImageEditorPageState extends State { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async { + return PopScope( + canPop: false, + onPopInvoked: (didPop) async { if (_hasBeenEdited()) { await _showExitConfirmationDialog(context); } else { replacePage(context, DetailPage(widget.detailPageConfig)); } - return false; }, child: Scaffold( appBar: AppBar( diff --git a/mobile/lib/ui/viewer/file/file_app_bar.dart b/mobile/lib/ui/viewer/file/file_app_bar.dart index b2d230ab6..8eff94b98 100644 --- a/mobile/lib/ui/viewer/file/file_app_bar.dart +++ b/mobile/lib/ui/viewer/file/file_app_bar.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; +import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:media_extension/media_extension.dart'; @@ -53,47 +54,70 @@ class FileAppBar extends StatefulWidget { class FileAppBarState extends State { final _logger = Logger("FadingAppBar"); + final List _actions = []; + + @override + void didUpdateWidget(FileAppBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.file.generatedID != widget.file.generatedID) { + _getActions(); + } + } @override Widget build(BuildContext context) { + _logger.fine("building app bar ${widget.file.generatedID?.toString()}"); + + //When the widget is initialized, the actions are not available. + //Cannot call _getActions() in initState. + if (_actions.isEmpty) { + _getActions(); + } + + final isTrashedFile = widget.file is TrashFile; + final shouldShowActions = widget.shouldShowActions && !isTrashedFile; return CustomAppBar( ValueListenableBuilder( valueListenable: widget.enableFullScreenNotifier, - builder: (context, bool isFullScreen, _) { + builder: (context, bool isFullScreen, child) { return IgnorePointer( ignoring: isFullScreen, child: AnimatedOpacity( opacity: isFullScreen ? 0 : 1, duration: const Duration(milliseconds: 150), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withOpacity(0.72), - Colors.black.withOpacity(0.6), - Colors.transparent, - ], - stops: const [0, 0.2, 1], - ), - ), - child: _buildAppBar(), - ), + child: child, ), ); }, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.72), + Colors.black.withOpacity(0.6), + Colors.transparent, + ], + stops: const [0, 0.2, 1], + ), + ), + child: AppBar( + iconTheme: const IconThemeData( + color: Colors.white, + ), //same for both themes + actions: shouldShowActions ? _actions : [], + elevation: 0, + backgroundColor: const Color(0x00000000), + ), + ), ), Size.fromHeight(Platform.isAndroid ? 84 : 96), ); } - AppBar _buildAppBar() { - _logger.fine("building app bar ${widget.file.generatedID?.toString()}"); - - final List actions = []; - final isTrashedFile = widget.file is TrashFile; - final shouldShowActions = widget.shouldShowActions && !isTrashedFile; + List _getActions() { + _actions.clear(); final bool isOwnedByUser = widget.file.isOwner; final bool isFileUploaded = widget.file.isUploaded; bool isFileHidden = false; @@ -104,7 +128,7 @@ class FileAppBarState extends State { false; } if (widget.file.isLiveOrMotionPhoto) { - actions.add( + _actions.add( IconButton( icon: const Icon(Icons.album_outlined), onPressed: () { @@ -117,8 +141,8 @@ class FileAppBarState extends State { ); } // only show fav option for files owned by the user - if (!isFileHidden && isFileUploaded) { - actions.add( + if ((isOwnedByUser || kDebugMode) && !isFileHidden && isFileUploaded) { + _actions.add( Padding( padding: const EdgeInsets.all(8), child: FavoriteWidget(widget.file), @@ -126,7 +150,7 @@ class FileAppBarState extends State { ); } if (!isFileUploaded) { - actions.add( + _actions.add( UploadIconWidget( file: widget.file, key: ValueKey(widget.file.tag), @@ -241,7 +265,7 @@ class FileAppBarState extends State { } } if (items.isNotEmpty) { - actions.add( + _actions.add( PopupMenuButton( itemBuilder: (context) { return items; @@ -262,13 +286,7 @@ class FileAppBarState extends State { ), ); } - return AppBar( - iconTheme: - const IconThemeData(color: Colors.white), //same for both themes - actions: shouldShowActions ? actions : [], - elevation: 0, - backgroundColor: const Color(0x00000000), - ); + return _actions; } Future _handleHideRequest(BuildContext context) async { diff --git a/mobile/lib/ui/viewer/search/search_widget.dart b/mobile/lib/ui/viewer/search/search_widget.dart index 2beaa1ec1..1c6c7b693 100644 --- a/mobile/lib/ui/viewer/search/search_widget.dart +++ b/mobile/lib/ui/viewer/search/search_widget.dart @@ -5,6 +5,7 @@ import "package:flutter/scheduler.dart"; import "package:photos/core/event_bus.dart"; import "package:photos/events/clear_and_unfocus_search_bar_event.dart"; import "package:photos/events/tab_changed_event.dart"; +import "package:photos/generated/l10n.dart"; import "package:photos/models/search/index_of_indexed_stack.dart"; import "package:photos/models/search/search_result.dart"; import "package:photos/services/search_service.dart"; @@ -130,17 +131,14 @@ class SearchWidgetState extends State { color: colorScheme.backgroundBase, child: Container( color: colorScheme.fillFaint, - child: TextFormField( + child: TextField( controller: textController, focusNode: focusNode, style: Theme.of(context).textTheme.titleMedium, // Below parameters are to disable auto-suggestion - enableSuggestions: false, - autocorrect: false, // Above parameters are to disable auto-suggestion decoration: InputDecoration( - //TODO: Extract string - hintText: "Search", + hintText: S.of(context).search, filled: true, fillColor: getEnteColorScheme(context).fillFaint, border: const UnderlineInputBorder( @@ -161,6 +159,9 @@ class SearchWidgetState extends State { minHeight: 44, minWidth: 44, ), + contentPadding: const EdgeInsets.symmetric( + vertical: 8, + ), prefixIcon: Hero( tag: "search_icon", child: Icon( @@ -168,6 +169,7 @@ class SearchWidgetState extends State { color: colorScheme.strokeFaint, ), ), + /*Using valueListenableBuilder inside a stateful widget because this widget is only rebuild when setState is called when deboucncing is over and the spinner needs to be shown while debouncing */ suffixIcon: ValueListenableBuilder( diff --git a/server/pkg/controller/file.go b/server/pkg/controller/file.go index 12d173e25..a4ac4b1b7 100644 --- a/server/pkg/controller/file.go +++ b/server/pkg/controller/file.go @@ -59,25 +59,41 @@ const ( DeletedObjectQueueLock = "deleted_objects_queue_lock" ) -// Create adds an entry for a file in the respective tables -func (c *FileController) Create(ctx context.Context, userID int64, file ente.File, userAgent string, app ente.App) (ente.File, error) { +func (c *FileController) validateFileCreateOrUpdateReq(userID int64, file ente.File) error { objectPathPrefix := strconv.FormatInt(userID, 10) + "/" if !strings.HasPrefix(file.File.ObjectKey, objectPathPrefix) || !strings.HasPrefix(file.Thumbnail.ObjectKey, objectPathPrefix) { - return file, stacktrace.Propagate(ente.ErrBadRequest, "Incorrect object key reported") + return stacktrace.Propagate(ente.ErrBadRequest, "Incorrect object key reported") + } + if file.EncryptedKey == "" || file.KeyDecryptionNonce == "" { + return stacktrace.Propagate(ente.ErrBadRequest, "EncryptedKey and KeyDecryptionNonce are required") + } + if file.File.DecryptionHeader == "" || file.Thumbnail.DecryptionHeader == "" { + return stacktrace.Propagate(ente.ErrBadRequest, "DecryptionHeader for file & thumb is required") + } + if file.UpdationTime == 0 { + return stacktrace.Propagate(ente.ErrBadRequest, "UpdationTime is required") } collection, err := c.CollectionRepo.Get(file.CollectionID) if err != nil { - return file, stacktrace.Propagate(err, "") + return stacktrace.Propagate(err, "") } // Verify that user owns the collection. // Warning: Do not remove this check if collection.Owner.ID != userID || file.OwnerID != userID { - return file, stacktrace.Propagate(ente.ErrPermissionDenied, "") + return stacktrace.Propagate(ente.ErrPermissionDenied, "") } if collection.IsDeleted { - return file, stacktrace.Propagate(ente.ErrNotFound, "collection has been deleted") + return stacktrace.Propagate(ente.ErrNotFound, "collection has been deleted") } + return nil +} +// Create adds an entry for a file in the respective tables +func (c *FileController) Create(ctx context.Context, userID int64, file ente.File, userAgent string, app ente.App) (ente.File, error) { + err := c.validateFileCreateOrUpdateReq(userID, file) + if err != nil { + return file, stacktrace.Propagate(err, "") + } hotDC := c.S3Config.GetHotDataCenter() // sizeOf will do also HEAD check to ensure that the object exists in the // current hot DC @@ -115,7 +131,7 @@ func (c *FileController) Create(ctx context.Context, userID int64, file ente.Fil // all iz well var usage int64 - file, usage, err = c.FileRepo.Create(file, fileSize, thumbnailSize, fileSize+thumbnailSize, collection.Owner.ID, app) + file, usage, err = c.FileRepo.Create(file, fileSize, thumbnailSize, fileSize+thumbnailSize, userID, app) if err != nil { if err == ente.ErrDuplicateFileObjectFound || err == ente.ErrDuplicateThumbnailObjectFound { var existing ente.File @@ -144,9 +160,9 @@ func (c *FileController) Create(ctx context.Context, userID int64, file ente.Fil // Update verifies permissions and updates the specified file func (c *FileController) Update(ctx context.Context, userID int64, file ente.File, app ente.App) (ente.UpdateFileResponse, error) { var response ente.UpdateFileResponse - objectPathPrefix := strconv.FormatInt(userID, 10) + "/" - if !strings.HasPrefix(file.File.ObjectKey, objectPathPrefix) || !strings.HasPrefix(file.Thumbnail.ObjectKey, objectPathPrefix) { - return response, stacktrace.Propagate(ente.ErrBadRequest, "Incorrect object key reported") + err := c.validateFileCreateOrUpdateReq(userID, file) + if err != nil { + return response, stacktrace.Propagate(err, "") } ownerID, err := c.FileRepo.GetOwnerID(file.ID) if err != nil { diff --git a/web/apps/cast/package.json b/web/apps/cast/package.json index ee318ef61..2437c6c14 100644 --- a/web/apps/cast/package.json +++ b/web/apps/cast/package.json @@ -3,11 +3,11 @@ "version": "0.0.0", "private": true, "dependencies": { + "@/media": "*", "@/next": "*", "@ente/accounts": "*", "@ente/eslint-config": "*", "@ente/shared": "*", - "jszip": "3.10.1", "mime-types": "^2.1.35" } } diff --git a/web/apps/cast/src/components/PhotoAuditorium.tsx b/web/apps/cast/src/components/PhotoAuditorium.tsx index 0042dfe95..6aa2c3990 100644 --- a/web/apps/cast/src/components/PhotoAuditorium.tsx +++ b/web/apps/cast/src/components/PhotoAuditorium.tsx @@ -1,50 +1,24 @@ -import { SlideshowContext } from "pages/slideshow"; -import { useContext, useEffect, useState } from "react"; +import { useEffect } from "react"; -export default function PhotoAuditorium({ - url, - nextSlideUrl, -}: { +interface PhotoAuditoriumProps { url: string; nextSlideUrl: string; -}) { - const { showNextSlide } = useContext(SlideshowContext); - - const [showPreloadedNextSlide, setShowPreloadedNextSlide] = useState(false); - const [nextSlidePrerendered, setNextSlidePrerendered] = useState(false); - const [prerenderTime, setPrerenderTime] = useState(null); - + showNextSlide: () => void; +} +export const PhotoAuditorium: React.FC = ({ + url, + nextSlideUrl, + showNextSlide, +}) => { useEffect(() => { - let timeout: NodeJS.Timeout; - let timeout2: NodeJS.Timeout; - - if (nextSlidePrerendered) { - const elapsedTime = prerenderTime ? Date.now() - prerenderTime : 0; - const delayTime = Math.max(10000 - elapsedTime, 0); - - if (elapsedTime >= 10000) { - setShowPreloadedNextSlide(true); - } else { - timeout = setTimeout(() => { - setShowPreloadedNextSlide(true); - }, delayTime); - } - - if (showNextSlide) { - timeout2 = setTimeout(() => { - showNextSlide(); - setNextSlidePrerendered(false); - setPrerenderTime(null); - setShowPreloadedNextSlide(false); - }, delayTime); - } - } + const timeoutId = window.setTimeout(() => { + showNextSlide(); + }, 10000); return () => { - if (timeout) clearTimeout(timeout); - if (timeout2) clearTimeout(timeout2); + if (timeoutId) clearTimeout(timeoutId); }; - }, [nextSlidePrerendered, showNextSlide, prerenderTime]); + }, [showNextSlide]); return (
- { - setNextSlidePrerendered(true); - setPrerenderTime(Date.now()); + /> +
); -} +}; diff --git a/web/apps/cast/src/components/Theatre/PhotoAuditorium.tsx b/web/apps/cast/src/components/Theatre/PhotoAuditorium.tsx deleted file mode 100644 index 0042dfe95..000000000 --- a/web/apps/cast/src/components/Theatre/PhotoAuditorium.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { SlideshowContext } from "pages/slideshow"; -import { useContext, useEffect, useState } from "react"; - -export default function PhotoAuditorium({ - url, - nextSlideUrl, -}: { - url: string; - nextSlideUrl: string; -}) { - const { showNextSlide } = useContext(SlideshowContext); - - const [showPreloadedNextSlide, setShowPreloadedNextSlide] = useState(false); - const [nextSlidePrerendered, setNextSlidePrerendered] = useState(false); - const [prerenderTime, setPrerenderTime] = useState(null); - - useEffect(() => { - let timeout: NodeJS.Timeout; - let timeout2: NodeJS.Timeout; - - if (nextSlidePrerendered) { - const elapsedTime = prerenderTime ? Date.now() - prerenderTime : 0; - const delayTime = Math.max(10000 - elapsedTime, 0); - - if (elapsedTime >= 10000) { - setShowPreloadedNextSlide(true); - } else { - timeout = setTimeout(() => { - setShowPreloadedNextSlide(true); - }, delayTime); - } - - if (showNextSlide) { - timeout2 = setTimeout(() => { - showNextSlide(); - setNextSlidePrerendered(false); - setPrerenderTime(null); - setShowPreloadedNextSlide(false); - }, delayTime); - } - } - - return () => { - if (timeout) clearTimeout(timeout); - if (timeout2) clearTimeout(timeout2); - }; - }, [nextSlidePrerendered, showNextSlide, prerenderTime]); - - return ( -
-
- - { - setNextSlidePrerendered(true); - setPrerenderTime(Date.now()); - }} - /> -
-
- ); -} diff --git a/web/apps/cast/src/components/Theatre/VideoAuditorium.tsx b/web/apps/cast/src/components/Theatre/VideoAuditorium.tsx deleted file mode 100644 index 2bf5ed490..000000000 --- a/web/apps/cast/src/components/Theatre/VideoAuditorium.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import mime from "mime-types"; -import { SlideshowContext } from "pages/slideshow"; -import { useContext, useEffect, useRef } from "react"; - -export default function VideoAuditorium({ - name, - url, -}: { - name: string; - url: string; -}) { - const { showNextSlide } = useContext(SlideshowContext); - - const videoRef = useRef(null); - - useEffect(() => { - attemptPlay(); - }, [url, videoRef]); - - const attemptPlay = async () => { - if (videoRef.current) { - try { - await videoRef.current.play(); - } catch { - showNextSlide(); - } - } - }; - - return ( -
- -
- ); -} diff --git a/web/apps/cast/src/components/Theatre/index.tsx b/web/apps/cast/src/components/Theatre/index.tsx deleted file mode 100644 index f7cac9c54..000000000 --- a/web/apps/cast/src/components/Theatre/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { FILE_TYPE } from "constants/file"; -import PhotoAuditorium from "./PhotoAuditorium"; -// import VideoAuditorium from './VideoAuditorium'; - -interface fileProp { - fileName: string; - fileURL: string; - type: FILE_TYPE; -} - -interface IProps { - file1: fileProp; - file2: fileProp; -} - -export default function Theatre(props: IProps) { - switch (props.file1.type && props.file2.type) { - case FILE_TYPE.IMAGE: - return ( - - ); - // case FILE_TYPE.VIDEO: - // return ( - // - // ); - } -} diff --git a/web/apps/cast/src/pages/slideshow.tsx b/web/apps/cast/src/pages/slideshow.tsx index 692e61154..774bbd4da 100644 --- a/web/apps/cast/src/pages/slideshow.tsx +++ b/web/apps/cast/src/pages/slideshow.tsx @@ -1,9 +1,9 @@ import log from "@/next/log"; import PairedSuccessfullyOverlay from "components/PairedSuccessfullyOverlay"; -import Theatre from "components/Theatre"; +import { PhotoAuditorium } from "components/PhotoAuditorium"; import { FILE_TYPE } from "constants/file"; import { useRouter } from "next/router"; -import { createContext, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { getCastCollection, getLocalFiles, @@ -13,25 +13,20 @@ import { Collection } from "types/collection"; import { EnteFile } from "types/file"; import { getPreviewableImage, isRawFileFromFileName } from "utils/file"; -export const SlideshowContext = createContext<{ - showNextSlide: () => void; -}>(null); - const renderableFileURLCache = new Map(); export default function Slideshow() { - const [collectionFiles, setCollectionFiles] = useState([]); - - const [currentFile, setCurrentFile] = useState( - undefined, - ); - const [nextFile, setNextFile] = useState(undefined); - const [loading, setLoading] = useState(true); const [castToken, setCastToken] = useState(""); const [castCollection, setCastCollection] = useState< Collection | undefined - >(undefined); + >(); + const [collectionFiles, setCollectionFiles] = useState([]); + const [currentFileId, setCurrentFileId] = useState(); + const [currentFileURL, setCurrentFileURL] = useState(); + const [nextFileURL, setNextFileURL] = useState(); + + const router = useRouter(); const syncCastFiles = async (token: string) => { try { @@ -72,29 +67,16 @@ export default function Slideshow() { const isFileEligibleForCast = (file: EnteFile) => { const fileType = file.metadata.fileType; - if (fileType !== FILE_TYPE.IMAGE && fileType !== FILE_TYPE.LIVE_PHOTO) { + if (fileType !== FILE_TYPE.IMAGE && fileType !== FILE_TYPE.LIVE_PHOTO) return false; - } - const fileSizeLimit = 100 * 1024 * 1024; + if (file.info.fileSize > 100 * 1024 * 1024) return false; - if (file.info.fileSize > fileSizeLimit) { - return false; - } - - const name = file.metadata.title; - - if (fileType === FILE_TYPE.IMAGE) { - if (isRawFileFromFileName(name)) { - return false; - } - } + if (isRawFileFromFileName(file.metadata.title)) return false; return true; }; - const router = useRouter(); - useEffect(() => { try { const castToken = window.localStorage.getItem("castToken"); @@ -117,9 +99,9 @@ export default function Slideshow() { showNextSlide(); }, [collectionFiles]); - const showNextSlide = () => { + const showNextSlide = async () => { const currentIndex = collectionFiles.findIndex( - (file) => file.id === currentFile?.id, + (file) => file.id === currentFileId, ); const nextIndex = (currentIndex + 1) % collectionFiles.length; @@ -128,63 +110,44 @@ export default function Slideshow() { const nextFile = collectionFiles[nextIndex]; const nextNextFile = collectionFiles[nextNextIndex]; - setCurrentFile(nextFile); - setNextFile(nextNextFile); + let nextURL = renderableFileURLCache.get(nextFile.id); + let nextNextURL = renderableFileURLCache.get(nextNextFile.id); + + if (!nextURL) { + try { + const blob = await getPreviewableImage(nextFile, castToken); + const url = URL.createObjectURL(blob); + renderableFileURLCache.set(nextFile.id, url); + nextURL = url; + } catch (e) { + return; + } + } + + if (!nextNextURL) { + try { + const blob = await getPreviewableImage(nextNextFile, castToken); + const url = URL.createObjectURL(blob); + renderableFileURLCache.set(nextNextFile.id, url); + nextNextURL = url; + } catch (e) { + return; + } + } + + setLoading(false); + setCurrentFileId(nextFile.id); + setCurrentFileURL(nextURL); + setNextFileURL(nextNextURL); }; - const [renderableFileURL, setRenderableFileURL] = useState(""); - - const getRenderableFileURL = async () => { - if (!currentFile) return; - - const cacheValue = renderableFileURLCache.get(currentFile.id); - if (cacheValue) { - setRenderableFileURL(cacheValue); - setLoading(false); - return; - } - - try { - const blob = await getPreviewableImage( - currentFile as EnteFile, - castToken, - ); - - const url = URL.createObjectURL(blob); - - renderableFileURLCache.set(currentFile?.id, url); - - setRenderableFileURL(url); - } catch (e) { - return; - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (currentFile) { - getRenderableFileURL(); - } - }, [currentFile]); + if (loading) return ; return ( - <> - - - - {loading && } - + ); } diff --git a/web/apps/cast/src/services/livePhotoService.ts b/web/apps/cast/src/services/livePhotoService.ts deleted file mode 100644 index 789234bd3..000000000 --- a/web/apps/cast/src/services/livePhotoService.ts +++ /dev/null @@ -1,32 +0,0 @@ -import JSZip from "jszip"; -import { EnteFile } from "types/file"; -import { - getFileExtensionWithDot, - getFileNameWithoutExtension, -} from "utils/file"; - -class LivePhoto { - image: Uint8Array; - video: Uint8Array; - imageNameTitle: string; - videoNameTitle: string; -} - -export const decodeLivePhoto = async (file: EnteFile, zipBlob: Blob) => { - const originalName = getFileNameWithoutExtension(file.metadata.title); - const zip = await JSZip.loadAsync(zipBlob, { createFolders: true }); - - const livePhoto = new LivePhoto(); - for (const zipFilename in zip.files) { - if (zipFilename.startsWith("image")) { - livePhoto.imageNameTitle = - originalName + getFileExtensionWithDot(zipFilename); - livePhoto.image = await zip.files[zipFilename].async("uint8array"); - } else if (zipFilename.startsWith("video")) { - livePhoto.videoNameTitle = - originalName + getFileExtensionWithDot(zipFilename); - livePhoto.video = await zip.files[zipFilename].async("uint8array"); - } - } - return livePhoto; -}; diff --git a/web/apps/cast/src/types/upload/index.ts b/web/apps/cast/src/types/upload/index.ts index ef44b4a23..0e249846a 100644 --- a/web/apps/cast/src/types/upload/index.ts +++ b/web/apps/cast/src/types/upload/index.ts @@ -95,13 +95,6 @@ export interface ParsedExtractedMetadata { height: number; } -// This is used to prompt the user the make upload strategy choice -export interface ImportSuggestion { - rootFolderName: string; - hasNestedFolders: boolean; - hasRootLevelFileWithFolder: boolean; -} - export interface PublicUploadProps { token: string; passwordToken: string; diff --git a/web/apps/cast/src/utils/file/index.ts b/web/apps/cast/src/utils/file/index.ts index 4f6311cbd..60ec0e56e 100644 --- a/web/apps/cast/src/utils/file/index.ts +++ b/web/apps/cast/src/utils/file/index.ts @@ -1,8 +1,8 @@ +import { decodeLivePhoto } from "@/media/live-photo"; import log from "@/next/log"; import ComlinkCryptoWorker from "@ente/shared/crypto"; import { FILE_TYPE, RAW_FORMATS } from "constants/file"; import CastDownloadManager from "services/castDownloadManager"; -import { decodeLivePhoto } from "services/livePhotoService"; import { getFileType } from "services/typeDetectionService"; import { EncryptedEnteFile, @@ -85,18 +85,6 @@ export async function decryptFile( } } -export function getFileNameWithoutExtension(filename: string) { - const lastDotPosition = filename.lastIndexOf("."); - if (lastDotPosition === -1) return filename; - else return filename.slice(0, lastDotPosition); -} - -export function getFileExtensionWithDot(filename: string) { - const lastDotPosition = filename.lastIndexOf("."); - if (lastDotPosition === -1) return ""; - else return filename.slice(lastDotPosition); -} - export function generateStreamFromArrayBuffer(data: Uint8Array) { return new ReadableStream({ async start(controller: ReadableStreamDefaultController) { @@ -115,6 +103,18 @@ export function isRawFileFromFileName(fileName: string) { return false; } +/** + * [Note: File name for local EnteFile objects] + * + * The title property in a file's metadata is the original file's name. The + * metadata of a file cannot be edited. So if later on the file's name is + * changed, then the edit is stored in the `editedName` property of the public + * metadata of the file. + * + * This function merges these edits onto the file object that we use locally. + * Effectively, post this step, the file's metadata.title can be used in lieu of + * its filename. + */ export function mergeMetadata(files: EnteFile[]): EnteFile[] { return files.map((file) => { if (file.pubMagicMetadata?.data.editedTime) { @@ -137,8 +137,11 @@ export const getPreviewableImage = async ( await CastDownloadManager.downloadFile(castToken, file), ).blob(); if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { - const livePhoto = await decodeLivePhoto(file, fileBlob); - fileBlob = new Blob([livePhoto.image]); + const { imageData } = await decodeLivePhoto( + file.metadata.title, + fileBlob, + ); + fileBlob = new Blob([imageData]); } const fileType = await getFileType( new File([fileBlob], file.metadata.title), diff --git a/web/apps/photos/package.json b/web/apps/photos/package.json index 6ae109af1..4ade92263 100644 --- a/web/apps/photos/package.json +++ b/web/apps/photos/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { + "@/media": "*", "@/next": "*", "@date-io/date-fns": "^2.14.0", "@ente/accounts": "*", @@ -25,7 +26,6 @@ "hdbscan": "0.0.1-alpha.5", "heic-convert": "^2.0.0", "idb": "^7.1.1", - "jszip": "3.10.1", "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.1", "localforage": "^1.9.0", diff --git a/web/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx b/web/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx index 8a5cb2c90..fdabffe84 100644 --- a/web/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx +++ b/web/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx @@ -50,7 +50,7 @@ export default function AlbumCastDialog(props: Props) { setFieldError, ) => { try { - await doCast(value); + await doCast(value.trim()); props.onHide(); } catch (e) { const error = e as Error; diff --git a/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx b/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx index 74ae87380..1bee86c25 100644 --- a/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx +++ b/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx @@ -1,3 +1,4 @@ +import { nameAndExtension } from "@/next/file"; import log from "@/next/log"; import { FlexWrapper } from "@ente/shared/components/Container"; import PhotoOutlined from "@mui/icons-material/PhotoOutlined"; @@ -7,11 +8,7 @@ import { FILE_TYPE } from "constants/file"; import { useEffect, useState } from "react"; import { EnteFile } from "types/file"; import { makeHumanReadableStorage } from "utils/billing"; -import { - changeFileName, - splitFilenameAndExtension, - updateExistingFilePubMetadata, -} from "utils/file"; +import { changeFileName, updateExistingFilePubMetadata } from "utils/file"; import { FileNameEditDialog } from "./FileNameEditDialog"; import InfoItem from "./InfoItem"; @@ -65,9 +62,7 @@ export function RenderFileName({ const [extension, setExtension] = useState(); useEffect(() => { - const [filename, extension] = splitFilenameAndExtension( - file.metadata.title, - ); + const [filename, extension] = nameAndExtension(file.metadata.title); setFilename(filename); setExtension(extension); }, [file]); diff --git a/web/apps/photos/src/components/Sidebar/UtilitySection.tsx b/web/apps/photos/src/components/Sidebar/UtilitySection.tsx index 904eab747..6b4a6f43d 100644 --- a/web/apps/photos/src/components/Sidebar/UtilitySection.tsx +++ b/web/apps/photos/src/components/Sidebar/UtilitySection.tsx @@ -24,7 +24,7 @@ import { import { getAccountsURL } from "@ente/shared/network/api"; import { THEME_COLOR } from "@ente/shared/themes/constants"; import { EnteMenuItem } from "components/Menu/EnteMenuItem"; -import WatchFolder from "components/WatchFolder"; +import { WatchFolder } from "components/WatchFolder"; import isElectron from "is-electron"; import { getAccountsToken } from "services/userService"; import { getDownloadAppMessage } from "utils/ui"; @@ -206,7 +206,12 @@ export default function UtilitySection({ closeSidebar }) { closeSidebar={closeSidebar} setLoading={startLoading} /> - + {isElectron() && ( + + )} void; +interface CollectionMappingChoiceModalProps { open: boolean; onClose: () => void; - uploadToSingleCollection: () => void; + didSelect: (mapping: CollectionMapping) => void; } -function UploadStrategyChoiceModal({ - uploadToMultipleCollection, - uploadToSingleCollection, - ...props -}: Props) { - const handleClose = dialogCloseHandler({ - onClose: props.onClose, - }); + +export const CollectionMappingChoiceModal: React.FC< + CollectionMappingChoiceModalProps +> = ({ open, onClose, didSelect }) => { + const handleClose = dialogCloseHandler({ onClose }); return ( - + {t("MULTI_FOLDER_UPLOAD")} @@ -39,8 +36,8 @@ function UploadStrategyChoiceModal({ size="medium" color="accent" onClick={() => { - props.onClose(); - uploadToSingleCollection(); + onClose(); + didSelect("root"); }} > {t("UPLOAD_STRATEGY_SINGLE_COLLECTION")} @@ -52,8 +49,8 @@ function UploadStrategyChoiceModal({ size="medium" color="accent" onClick={() => { - props.onClose(); - uploadToMultipleCollection(); + onClose(); + didSelect("parent"); }} > {t("UPLOAD_STRATEGY_COLLECTION_PER_FOLDER")} @@ -62,5 +59,4 @@ function UploadStrategyChoiceModal({ ); -} -export default UploadStrategyChoiceModal; +}; diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index 4d81b1612..a622953b3 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -1,15 +1,11 @@ +import { ensureElectron } from "@/next/electron"; import log from "@/next/log"; -import type { Electron } from "@/next/types/ipc"; +import type { CollectionMapping, Electron } from "@/next/types/ipc"; import { CustomError } from "@ente/shared/error"; import { isPromise } from "@ente/shared/utils"; import DiscFullIcon from "@mui/icons-material/DiscFull"; import UserNameInputDialog from "components/UserNameInputDialog"; -import { - DEFAULT_IMPORT_SUGGESTION, - PICKED_UPLOAD_TYPE, - UPLOAD_STAGES, - UPLOAD_STRATEGY, -} from "constants/upload"; +import { PICKED_UPLOAD_TYPE, UPLOAD_STAGES } from "constants/upload"; import { t } from "i18next"; import isElectron from "is-electron"; import { AppContext } from "pages/_app"; @@ -17,14 +13,14 @@ import { GalleryContext } from "pages/gallery"; import { useContext, useEffect, useRef, useState } from "react"; import billingService from "services/billingService"; import { getLatestCollections } from "services/collectionService"; -import ImportService from "services/importService"; +import { setToUploadCollection } from "services/pending-uploads"; import { getPublicCollectionUID, getPublicCollectionUploaderName, savePublicCollectionUploaderName, } from "services/publicCollectionService"; import uploadManager from "services/upload/uploadManager"; -import watchFolderService from "services/watchFolder/watchFolderService"; +import watcher from "services/watch"; import { NotificationAttributes } from "types/Notification"; import { Collection } from "types/collection"; import { @@ -35,11 +31,7 @@ import { SetLoading, UploadTypeSelectorIntent, } from "types/gallery"; -import { - ElectronFile, - FileWithCollection, - ImportSuggestion, -} from "types/upload"; +import { ElectronFile, FileWithCollection } from "types/upload"; import { InProgressUpload, SegregatedFinishedUploads, @@ -53,13 +45,15 @@ import { getRootLevelFileWithFolderNotAllowMessage, } from "utils/ui"; import { + DEFAULT_IMPORT_SUGGESTION, filterOutSystemFiles, getImportSuggestion, groupFilesBasedOnParentFolder, + type ImportSuggestion, } from "utils/upload"; import { SetCollectionNamerAttributes } from "../Collections/CollectionNamer"; +import { CollectionMappingChoiceModal } from "./CollectionMappingChoiceModal"; import UploadProgress from "./UploadProgress"; -import UploadStrategyChoiceModal from "./UploadStrategyChoiceModal"; import UploadTypeSelector from "./UploadTypeSelector"; const FIRST_ALBUM_NAME = "My First Album"; @@ -137,6 +131,7 @@ export default function Uploader(props: Props) { const closeUploadProgress = () => setUploadProgressView(false); const showUserNameInputDialog = () => setUserNameInputDialogView(true); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const setCollectionName = (collectionName: string) => { isPendingDesktopUpload.current = true; pendingDesktopUploadCollectionName.current = collectionName; @@ -177,18 +172,27 @@ export default function Uploader(props: Props) { } if (isElectron()) { - ImportService.getPendingUploads().then( - ({ files: electronFiles, collectionName, type }) => { - log.info(`found pending desktop upload, resuming uploads`); - resumeDesktopUpload(type, electronFiles, collectionName); - }, - ); - watchFolderService.init( + ensureElectron() + .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, + ); + } + }); + /* TODO(MR): This is the connection point, implement + watcher.init( setElectronFiles, setCollectionName, props.syncWithRemote, - appContext.setIsFolderSyncRunning, ); + */ } }, [ publicCollectionGalleryContext.accessedThroughSharedURL, @@ -291,18 +295,16 @@ export default function Uploader(props: Props) { }`, ); if (uploadManager.isUploadRunning()) { - if (watchFolderService.isUploadRunning()) { + if (watcher.isUploadRunning()) { + // Pause watch folder sync on user upload log.info( - "watchFolder upload was running, pausing it to run user upload", + "Folder watcher was uploading, pausing it to first run user upload", ); - // pause watch folder service on user upload - watchFolderService.pauseRunningSync(); + watcher.pauseRunningSync(); } else { log.info( - "an upload is already running, rejecting new upload request", + "Ignoring new upload request because an upload is already running", ); - // no-op - // a user upload is already in progress return; } } @@ -330,7 +332,7 @@ export default function Uploader(props: Props) { const importSuggestion = getImportSuggestion( pickedUploadType.current, - toUploadFiles.current, + toUploadFiles.current.map((file) => file["path"]), ); setImportSuggestion(importSuggestion); @@ -391,7 +393,7 @@ export default function Uploader(props: Props) { }; const uploadFilesToNewCollections = async ( - strategy: UPLOAD_STRATEGY, + strategy: CollectionMapping, collectionName?: string, ) => { try { @@ -405,7 +407,7 @@ export default function Uploader(props: Props) { string, (File | ElectronFile)[] >(); - if (strategy === UPLOAD_STRATEGY.SINGLE_COLLECTION) { + if (strategy == "root") { collectionNameToFilesMap.set( collectionName, toUploadFiles.current, @@ -505,18 +507,19 @@ export default function Uploader(props: Props) { if ( electron && !isPendingDesktopUpload.current && - !watchFolderService.isUploadRunning() + !watcher.isUploadRunning() ) { - await ImportService.setToUploadCollection(collections); + await setToUploadCollection(collections); + // TODO (MR): What happens when we have both? if (zipPaths.current) { - await electron.setToUploadFiles( - PICKED_UPLOAD_TYPE.ZIPS, + await electron.setPendingUploadFiles( + "zips", zipPaths.current, ); zipPaths.current = null; } - await electron.setToUploadFiles( - PICKED_UPLOAD_TYPE.FILES, + await electron.setPendingUploadFiles( + "files", filesWithCollectionToUploadIn.map( ({ file }) => (file as ElectronFile).path, ), @@ -532,14 +535,14 @@ export default function Uploader(props: Props) { closeUploadProgress(); } if (isElectron()) { - if (watchFolderService.isUploadRunning()) { - await watchFolderService.allFileUploadsDone( + if (watcher.isUploadRunning()) { + await watcher.allFileUploadsDone( filesWithCollectionToUploadIn, collections, ); - } else if (watchFolderService.isSyncPaused()) { + } else if (watcher.isSyncPaused()) { // resume the service after user upload is done - watchFolderService.resumePausedSync(); + watcher.resumePausedSync(); } } } catch (e) { @@ -605,10 +608,7 @@ export default function Uploader(props: Props) { } const uploadToSingleNewCollection = (collectionName: string) => { - uploadFilesToNewCollections( - UPLOAD_STRATEGY.SINGLE_COLLECTION, - collectionName, - ); + uploadFilesToNewCollections("root", collectionName); }; const showCollectionCreateModal = (suggestedName: string) => { @@ -647,7 +647,7 @@ export default function Uploader(props: Props) { `upload pending files to collection - ${pendingDesktopUploadCollectionName.current}`, ); uploadFilesToNewCollections( - UPLOAD_STRATEGY.SINGLE_COLLECTION, + "root", pendingDesktopUploadCollectionName.current, ); pendingDesktopUploadCollectionName.current = null; @@ -655,17 +655,13 @@ export default function Uploader(props: Props) { log.info( `pending upload - strategy - "multiple collections" `, ); - uploadFilesToNewCollections( - UPLOAD_STRATEGY.COLLECTION_PER_FOLDER, - ); + uploadFilesToNewCollections("parent"); } return; } if (isElectron() && pickedUploadType === PICKED_UPLOAD_TYPE.ZIPS) { log.info("uploading zip files"); - uploadFilesToNewCollections( - UPLOAD_STRATEGY.COLLECTION_PER_FOLDER, - ); + uploadFilesToNewCollections("parent"); return; } if (isFirstUpload && !importSuggestion.rootFolderName) { @@ -784,16 +780,26 @@ export default function Uploader(props: Props) { ); return; } - uploadFilesToNewCollections(UPLOAD_STRATEGY.COLLECTION_PER_FOLDER); + uploadFilesToNewCollections("parent"); + }; + + const didSelectCollectionMapping = (mapping: CollectionMapping) => { + switch (mapping) { + case "root": + handleUploadToSingleCollection(); + break; + case "parent": + handleUploadToMultipleCollections(); + break; + } }; return ( <> - void; +} + +/** + * View the state of and manage folder watches. + * + * This is the screen that controls that "watch folder" feature in the app. + */ +export const WatchFolder: React.FC = ({ open, onClose }) => { + // The folders we are watching + const [watches, setWatches] = useState(); + // Temporarily stash the folder path while we show a choice dialog to the + // user to select the collection mapping. + const [savedFolderPath, setSavedFolderPath] = useState< + string | undefined + >(); + // True when we're showing the choice dialog to ask the user to set the + // collection mapping. + const [choiceModalOpen, setChoiceModalOpen] = useState(false); + + const appContext = useContext(AppContext); + + useEffect(() => { + watcher.getWatches().then((ws) => setWatches(ws)); + }, []); + + useEffect(() => { + if ( + appContext.watchFolderFiles && + appContext.watchFolderFiles.length > 0 + ) { + handleFolderDrop(appContext.watchFolderFiles); + appContext.setWatchFolderFiles(null); + } + }, [appContext.watchFolderFiles]); + + const handleFolderDrop = async (folders: FileList) => { + for (let i = 0; i < folders.length; i++) { + const folder: any = folders[i]; + const path = (folder.path as string).replace(/\\/g, "/"); + if (await ensureElectron().fs.isDir(path)) { + await selectCollectionMappingAndAddWatch(path); + } + } + }; + + const selectCollectionMappingAndAddWatch = async (path: string) => { + const filePaths = await ensureElectron().watch.findFiles(path); + if (areAllInSameDirectory(filePaths)) { + addWatch(path, "root"); + } else { + setSavedFolderPath(path); + setChoiceModalOpen(true); + } + }; + + const addWatch = (folderPath: string, mapping: CollectionMapping) => + watcher.addWatch(folderPath, mapping).then((ws) => setWatches(ws)); + + const addNewWatch = async () => { + const dirPath = await ensureElectron().selectDirectory(); + if (dirPath) { + await selectCollectionMappingAndAddWatch(dirPath); + } + }; + + const removeWatch = async (watch: FolderWatch) => + watcher.removeWatch(watch.folderPath).then((ws) => setWatches(ws)); + + const closeChoiceModal = () => setChoiceModalOpen(false); + + const addWatchWithMapping = (mapping: CollectionMapping) => { + closeChoiceModal(); + setSavedFolderPath(undefined); + addWatch(ensure(savedFolderPath), mapping); + }; + + return ( + <> + + + {t("WATCHED_FOLDERS")} + + + + + + + + + + + ); +}; + +interface WatchList { + watches: FolderWatch[]; + removeWatch: (watch: FolderWatch) => void; +} + +const WatchList: React.FC = ({ watches, removeWatch }) => { + return watches.length === 0 ? ( + + ) : ( + + {watches.map((watch) => { + return ( + + ); + })} + + ); +}; + +const WatchesContainer = styled(Box)(() => ({ + height: "278px", + overflow: "auto", + "&::-webkit-scrollbar": { + width: "4px", + }, +})); + +const NoWatches: React.FC = () => { + return ( + + + + {t("NO_FOLDERS_ADDED")} + + + {t("FOLDERS_AUTOMATICALLY_MONITORED")} + + + + + {t("UPLOAD_NEW_FILES_TO_ENTE")} + + + + + + {t("REMOVE_DELETED_FILES_FROM_ENTE")} + + + + + ); +}; + +const NoWatchesContainer = styled(VerticallyCentered)({ + textAlign: "left", + alignItems: "flex-start", + marginBottom: "32px", +}); + +const CheckmarkIcon: React.FC = () => { + return ( + theme.palette.secondary.main, + }} + /> + ); +}; + +interface WatchEntryProps { + watch: FolderWatch; + removeWatch: (watch: FolderWatch) => void; +} + +const WatchEntry: React.FC = ({ watch, removeWatch }) => { + const appContext = React.useContext(AppContext); + + const confirmStopWatching = () => { + appContext.setDialogMessage({ + title: t("STOP_WATCHING_FOLDER"), + content: t("STOP_WATCHING_DIALOG_MESSAGE"), + close: { + text: t("CANCEL"), + variant: "secondary", + }, + proceed: { + action: () => removeWatch(watch), + text: t("YES_STOP"), + variant: "critical", + }, + }); + }; + + return ( + + + {watch.collectionMapping === "root" ? ( + + + + ) : ( + + + + )} + + + + {watch.folderPath} + + + + + + ); +}; + +const EntryContainer = styled(Box)({ + marginLeft: "12px", + marginRight: "6px", + marginBottom: "12px", +}); + +interface EntryHeadingProps { + watch: FolderWatch; +} + +const EntryHeading: React.FC = ({ watch }) => { + const folderPath = watch.folderPath; + + return ( + + {basename(folderPath)} + {watcher.isSyncingFolder(folderPath) && ( + + )} + + ); +}; + +interface EntryOptionsProps { + confirmStopWatching: () => void; +} + +const EntryOptions: React.FC = ({ confirmStopWatching }) => { + return ( + + theme.colors.background.elevated2, + }, + }} + ariaControls={"watch-mapping-option"} + triggerButtonIcon={} + > + } + > + {t("STOP_WATCHING")} + + + ); +}; diff --git a/web/apps/photos/src/components/WatchFolder/index.tsx b/web/apps/photos/src/components/WatchFolder/index.tsx deleted file mode 100644 index 4ccfd4138..000000000 --- a/web/apps/photos/src/components/WatchFolder/index.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import DialogTitleWithCloseButton from "@ente/shared/components/DialogBox/TitleWithCloseButton"; -import { Button, Dialog, DialogContent, Stack } from "@mui/material"; -import UploadStrategyChoiceModal from "components/Upload/UploadStrategyChoiceModal"; -import { PICKED_UPLOAD_TYPE, UPLOAD_STRATEGY } from "constants/upload"; -import { t } from "i18next"; -import { AppContext } from "pages/_app"; -import { useContext, useEffect, useState } from "react"; -import watchFolderService from "services/watchFolder/watchFolderService"; -import { WatchMapping } from "types/watchFolder"; -import { getImportSuggestion } from "utils/upload"; -import { MappingList } from "./mappingList"; - -interface Iprops { - open: boolean; - onClose: () => void; -} - -export default function WatchFolder({ open, onClose }: Iprops) { - const [mappings, setMappings] = useState([]); - const [inputFolderPath, setInputFolderPath] = useState(""); - const [choiceModalOpen, setChoiceModalOpen] = useState(false); - const appContext = useContext(AppContext); - - const electron = globalThis.electron; - - useEffect(() => { - if (!electron) return; - watchFolderService.getWatchMappings().then((m) => setMappings(m)); - }, []); - - useEffect(() => { - if ( - appContext.watchFolderFiles && - appContext.watchFolderFiles.length > 0 - ) { - handleFolderDrop(appContext.watchFolderFiles); - appContext.setWatchFolderFiles(null); - } - }, [appContext.watchFolderFiles]); - - const handleFolderDrop = async (folders: FileList) => { - for (let i = 0; i < folders.length; i++) { - const folder: any = folders[i]; - const path = (folder.path as string).replace(/\\/g, "/"); - if (await watchFolderService.isFolder(path)) { - await addFolderForWatching(path); - } - } - }; - - const addFolderForWatching = async (path: string) => { - if (!electron) return; - - setInputFolderPath(path); - const files = await electron.getDirFiles(path); - const analysisResult = getImportSuggestion( - PICKED_UPLOAD_TYPE.FOLDERS, - files, - ); - if (analysisResult.hasNestedFolders) { - setChoiceModalOpen(true); - } else { - handleAddWatchMapping(UPLOAD_STRATEGY.SINGLE_COLLECTION, path); - } - }; - - const handleAddFolderClick = async () => { - await handleFolderSelection(); - }; - - const handleFolderSelection = async () => { - const folderPath = await watchFolderService.selectFolder(); - if (folderPath) { - await addFolderForWatching(folderPath); - } - }; - - const handleAddWatchMapping = async ( - uploadStrategy: UPLOAD_STRATEGY, - folderPath?: string, - ) => { - folderPath = folderPath || inputFolderPath; - await watchFolderService.addWatchMapping( - folderPath.substring(folderPath.lastIndexOf("/") + 1), - folderPath, - uploadStrategy, - ); - setInputFolderPath(""); - setMappings(await watchFolderService.getWatchMappings()); - }; - - const handleRemoveWatchMapping = async (mapping: WatchMapping) => { - await watchFolderService.removeWatchMapping(mapping.folderPath); - setMappings(await watchFolderService.getWatchMappings()); - }; - - const closeChoiceModal = () => setChoiceModalOpen(false); - - const uploadToSingleCollection = () => { - closeChoiceModal(); - handleAddWatchMapping(UPLOAD_STRATEGY.SINGLE_COLLECTION); - }; - - const uploadToMultipleCollection = () => { - closeChoiceModal(); - handleAddWatchMapping(UPLOAD_STRATEGY.COLLECTION_PER_FOLDER); - }; - - return ( - <> - - - {t("WATCHED_FOLDERS")} - - - - - - - - - - - ); -} diff --git a/web/apps/photos/src/components/WatchFolder/mappingEntry/entryHeading.tsx b/web/apps/photos/src/components/WatchFolder/mappingEntry/entryHeading.tsx deleted file mode 100644 index b34e4277f..000000000 --- a/web/apps/photos/src/components/WatchFolder/mappingEntry/entryHeading.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { FlexWrapper } from "@ente/shared/components/Container"; -import { CircularProgress, Typography } from "@mui/material"; -import { AppContext } from "pages/_app"; -import { useContext } from "react"; -import watchFolderService from "services/watchFolder/watchFolderService"; -import { WatchMapping } from "types/watchFolder"; - -interface Iprops { - mapping: WatchMapping; -} - -export function EntryHeading({ mapping }: Iprops) { - const appContext = useContext(AppContext); - return ( - - {mapping.rootFolderName} - {appContext.isFolderSyncRunning && - watchFolderService.isMappingSyncInProgress(mapping) && ( - - )} - - ); -} diff --git a/web/apps/photos/src/components/WatchFolder/mappingEntry/index.tsx b/web/apps/photos/src/components/WatchFolder/mappingEntry/index.tsx deleted file mode 100644 index 819394699..000000000 --- a/web/apps/photos/src/components/WatchFolder/mappingEntry/index.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { - HorizontalFlex, - SpaceBetweenFlex, -} from "@ente/shared/components/Container"; -import FolderCopyOutlinedIcon from "@mui/icons-material/FolderCopyOutlined"; -import FolderOpenIcon from "@mui/icons-material/FolderOpen"; -import { Tooltip, Typography } from "@mui/material"; -import { t } from "i18next"; -import { AppContext } from "pages/_app"; -import React from "react"; -import { WatchMapping } from "types/watchFolder"; -import { EntryContainer } from "../styledComponents"; - -import { UPLOAD_STRATEGY } from "constants/upload"; -import { EntryHeading } from "./entryHeading"; -import MappingEntryOptions from "./mappingEntryOptions"; - -interface Iprops { - mapping: WatchMapping; - handleRemoveMapping: (mapping: WatchMapping) => void; -} - -export function MappingEntry({ mapping, handleRemoveMapping }: Iprops) { - const appContext = React.useContext(AppContext); - - const stopWatching = () => { - handleRemoveMapping(mapping); - }; - - const confirmStopWatching = () => { - appContext.setDialogMessage({ - title: t("STOP_WATCHING_FOLDER"), - content: t("STOP_WATCHING_DIALOG_MESSAGE"), - close: { - text: t("CANCEL"), - variant: "secondary", - }, - proceed: { - action: stopWatching, - text: t("YES_STOP"), - variant: "critical", - }, - }); - }; - - return ( - - - {mapping && - mapping.uploadStrategy === UPLOAD_STRATEGY.SINGLE_COLLECTION ? ( - - - - ) : ( - - - - )} - - - - {mapping.folderPath} - - - - - - ); -} diff --git a/web/apps/photos/src/components/WatchFolder/mappingEntry/mappingEntryOptions.tsx b/web/apps/photos/src/components/WatchFolder/mappingEntry/mappingEntryOptions.tsx deleted file mode 100644 index 4f3cdc56d..000000000 --- a/web/apps/photos/src/components/WatchFolder/mappingEntry/mappingEntryOptions.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { t } from "i18next"; - -import OverflowMenu from "@ente/shared/components/OverflowMenu/menu"; -import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option"; -import DoNotDisturbOutlinedIcon from "@mui/icons-material/DoNotDisturbOutlined"; -import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; - -interface Iprops { - confirmStopWatching: () => void; -} - -export default function MappingEntryOptions({ confirmStopWatching }: Iprops) { - return ( - - theme.colors.background.elevated2, - }, - }} - ariaControls={"watch-mapping-option"} - triggerButtonIcon={} - > - } - > - {t("STOP_WATCHING")} - - - ); -} diff --git a/web/apps/photos/src/components/WatchFolder/mappingList/index.tsx b/web/apps/photos/src/components/WatchFolder/mappingList/index.tsx deleted file mode 100644 index f2c7b781c..000000000 --- a/web/apps/photos/src/components/WatchFolder/mappingList/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { WatchMapping } from "types/watchFolder"; -import { MappingEntry } from "../mappingEntry"; -import { MappingsContainer } from "../styledComponents"; -import { NoMappingsContent } from "./noMappingsContent/noMappingsContent"; -interface Iprops { - mappings: WatchMapping[]; - handleRemoveWatchMapping: (value: WatchMapping) => void; -} - -export function MappingList({ mappings, handleRemoveWatchMapping }: Iprops) { - return mappings.length === 0 ? ( - - ) : ( - - {mappings.map((mapping) => { - return ( - - ); - })} - - ); -} diff --git a/web/apps/photos/src/components/WatchFolder/mappingList/noMappingsContent/checkmarkIcon.tsx b/web/apps/photos/src/components/WatchFolder/mappingList/noMappingsContent/checkmarkIcon.tsx deleted file mode 100644 index aedd79404..000000000 --- a/web/apps/photos/src/components/WatchFolder/mappingList/noMappingsContent/checkmarkIcon.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import CheckIcon from "@mui/icons-material/Check"; - -export function CheckmarkIcon() { - return ( - theme.palette.secondary.main, - }} - /> - ); -} diff --git a/web/apps/photos/src/components/WatchFolder/mappingList/noMappingsContent/noMappingsContent.tsx b/web/apps/photos/src/components/WatchFolder/mappingList/noMappingsContent/noMappingsContent.tsx deleted file mode 100644 index a5af6aff9..000000000 --- a/web/apps/photos/src/components/WatchFolder/mappingList/noMappingsContent/noMappingsContent.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Stack, Typography } from "@mui/material"; -import { t } from "i18next"; - -import { FlexWrapper } from "@ente/shared/components/Container"; -import { NoMappingsContainer } from "../../styledComponents"; -import { CheckmarkIcon } from "./checkmarkIcon"; - -export function NoMappingsContent() { - return ( - - - - {t("NO_FOLDERS_ADDED")} - - - {t("FOLDERS_AUTOMATICALLY_MONITORED")} - - - - - {t("UPLOAD_NEW_FILES_TO_ENTE")} - - - - - - {t("REMOVE_DELETED_FILES_FROM_ENTE")} - - - - - ); -} diff --git a/web/apps/photos/src/components/WatchFolder/styledComponents.tsx b/web/apps/photos/src/components/WatchFolder/styledComponents.tsx deleted file mode 100644 index d507bbaa8..000000000 --- a/web/apps/photos/src/components/WatchFolder/styledComponents.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { VerticallyCentered } from "@ente/shared/components/Container"; -import { Box } from "@mui/material"; -import { styled } from "@mui/material/styles"; - -export const MappingsContainer = styled(Box)(() => ({ - height: "278px", - overflow: "auto", - "&::-webkit-scrollbar": { - width: "4px", - }, -})); - -export const NoMappingsContainer = styled(VerticallyCentered)({ - textAlign: "left", - alignItems: "flex-start", - marginBottom: "32px", -}); - -export const EntryContainer = styled(Box)({ - marginLeft: "12px", - marginRight: "6px", - marginBottom: "12px", -}); diff --git a/web/apps/photos/src/constants/upload.ts b/web/apps/photos/src/constants/upload.ts index 6d9f63d78..1f8858bc3 100644 --- a/web/apps/photos/src/constants/upload.ts +++ b/web/apps/photos/src/constants/upload.ts @@ -1,11 +1,6 @@ import { ENCRYPTION_CHUNK_SIZE } from "@ente/shared/crypto/constants"; import { FILE_TYPE } from "constants/file"; -import { - FileTypeInfo, - ImportSuggestion, - Location, - ParsedExtractedMetadata, -} from "types/upload"; +import { FileTypeInfo, Location, ParsedExtractedMetadata } from "types/upload"; // list of format that were missed by type-detection for some files. export const WHITELISTED_FILE_FORMATS: FileTypeInfo[] = [ @@ -111,12 +106,6 @@ export const NULL_EXTRACTED_METADATA: ParsedExtractedMetadata = { export const A_SEC_IN_MICROSECONDS = 1e6; -export const DEFAULT_IMPORT_SUGGESTION: ImportSuggestion = { - rootFolderName: "", - hasNestedFolders: false, - hasRootLevelFileWithFolder: false, -}; - export const BLACK_THUMBNAIL_BASE64 = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB" + "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQ" + diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index c31256f13..4b5fe3107 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -5,7 +5,7 @@ import { logStartupBanner, logUnhandledErrorsAndRejections, } from "@/next/log-web"; -import { AppUpdateInfo } from "@/next/types/ipc"; +import { AppUpdate } from "@/next/types/ipc"; import { APPS, APP_TITLES, @@ -91,8 +91,6 @@ type AppContextType = { closeMessageDialog: () => void; setDialogMessage: SetDialogBoxAttributes; setNotificationAttributes: SetNotificationAttributes; - isFolderSyncRunning: boolean; - setIsFolderSyncRunning: (isRunning: boolean) => void; watchFolderView: boolean; setWatchFolderView: (isOpen: boolean) => void; watchFolderFiles: FileList; @@ -128,7 +126,6 @@ export default function App({ Component, pageProps }: AppProps) { useState(null); const [messageDialogView, setMessageDialogView] = useState(false); const [dialogBoxV2View, setDialogBoxV2View] = useState(false); - const [isFolderSyncRunning, setIsFolderSyncRunning] = useState(false); const [watchFolderView, setWatchFolderView] = useState(false); const [watchFolderFiles, setWatchFolderFiles] = useState(null); const isMobile = useMediaQuery("(max-width:428px)"); @@ -160,9 +157,9 @@ export default function App({ Component, pageProps }: AppProps) { const electron = globalThis.electron; if (!electron) return; - const showUpdateDialog = (updateInfo: AppUpdateInfo) => { - if (updateInfo.autoUpdatable) { - setDialogMessage(getUpdateReadyToInstallMessage(updateInfo)); + const showUpdateDialog = (update: AppUpdate) => { + if (update.autoUpdatable) { + setDialogMessage(getUpdateReadyToInstallMessage(update)); } else { setNotificationAttributes({ endIcon: , @@ -170,7 +167,7 @@ export default function App({ Component, pageProps }: AppProps) { message: t("UPDATE_AVAILABLE"), onClick: () => setDialogMessage( - getUpdateAvailableForDownloadMessage(updateInfo), + getUpdateAvailableForDownloadMessage(update), ), }); } @@ -403,8 +400,6 @@ export default function App({ Component, pageProps }: AppProps) { finishLoading, closeMessageDialog, setDialogMessage, - isFolderSyncRunning, - setIsFolderSyncRunning, watchFolderView, setWatchFolderView, watchFolderFiles, diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index 7d6279882..882c36f9b 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -1,3 +1,4 @@ +import { decodeLivePhoto } from "@/media/live-photo"; import { ensureElectron } from "@/next/electron"; import log from "@/next/log"; import { CustomError } from "@ente/shared/error"; @@ -38,7 +39,6 @@ import { writeStream } from "utils/native-stream"; import { getAllLocalCollections } from "../collectionService"; import downloadManager from "../download"; import { getAllLocalFiles } from "../fileService"; -import { decodeLivePhoto } from "../livePhotoService"; import { migrateExport } from "./migration"; /** Name of the JSON file in which we keep the state of the export. */ @@ -1015,18 +1015,18 @@ class ExportService { fileStream: ReadableStream, file: EnteFile, ) { - const electron = ensureElectron(); + const fs = ensureElectron().fs; const fileBlob = await new Response(fileStream).blob(); - const livePhoto = await decodeLivePhoto(file, fileBlob); + const livePhoto = await decodeLivePhoto(file.metadata.title, fileBlob); const imageExportName = await safeFileName( collectionExportPath, - livePhoto.imageNameTitle, - electron.fs.exists, + livePhoto.imageFileName, + fs.exists, ); const videoExportName = await safeFileName( collectionExportPath, - livePhoto.videoNameTitle, - electron.fs.exists, + livePhoto.videoFileName, + fs.exists, ); const livePhotoExportName = getLivePhotoExportName( imageExportName, @@ -1038,7 +1038,9 @@ class ExportService { livePhotoExportName, ); try { - const imageStream = generateStreamFromArrayBuffer(livePhoto.image); + const imageStream = generateStreamFromArrayBuffer( + livePhoto.imageData, + ); await this.saveMetadataFile( collectionExportPath, imageExportName, @@ -1049,7 +1051,9 @@ class ExportService { imageStream, ); - const videoStream = generateStreamFromArrayBuffer(livePhoto.video); + const videoStream = generateStreamFromArrayBuffer( + livePhoto.videoData, + ); await this.saveMetadataFile( collectionExportPath, videoExportName, @@ -1061,9 +1065,7 @@ class ExportService { videoStream, ); } catch (e) { - await electron.fs.rm( - `${collectionExportPath}/${imageExportName}`, - ); + await fs.rm(`${collectionExportPath}/${imageExportName}`); throw e; } } catch (e) { diff --git a/web/apps/photos/src/services/export/migration.ts b/web/apps/photos/src/services/export/migration.ts index b90c12e1c..3f471b539 100644 --- a/web/apps/photos/src/services/export/migration.ts +++ b/web/apps/photos/src/services/export/migration.ts @@ -1,3 +1,4 @@ +import { decodeLivePhoto } from "@/media/live-photo"; import { ensureElectron } from "@/next/electron"; import log from "@/next/log"; import { LS_KEYS, getData } from "@ente/shared/storage/localStorage"; @@ -7,7 +8,6 @@ import { FILE_TYPE } from "constants/file"; import { getLocalCollections } from "services/collectionService"; import downloadManager from "services/download"; import { getAllLocalFiles } from "services/fileService"; -import { decodeLivePhoto } from "services/livePhotoService"; import { Collection } from "types/collection"; import { CollectionExportNames, @@ -21,11 +21,11 @@ import { } from "types/export"; import { EnteFile } from "types/file"; import { getNonEmptyPersonalCollections } from "utils/collection"; -import { splitFilenameAndExtension } from "utils/ffmpeg"; import { getIDBasedSortedFiles, getPersonalFiles, mergeMetadata, + splitFilenameAndExtension, } from "utils/file"; import { safeDirectoryName, @@ -318,15 +318,18 @@ async function getFileExportNamesFromExportedFiles( if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { const fileStream = await downloadManager.getFile(file); const fileBlob = await new Response(fileStream).blob(); - const livePhoto = await decodeLivePhoto(file, fileBlob); + const { imageFileName, videoFileName } = await decodeLivePhoto( + file.metadata.title, + fileBlob, + ); const imageExportName = getUniqueFileExportNameForMigration( collectionPath, - livePhoto.imageNameTitle, + imageFileName, usedFilePaths, ); const videoExportName = getUniqueFileExportNameForMigration( collectionPath, - livePhoto.videoNameTitle, + videoFileName, usedFilePaths, ); fileExportName = getLivePhotoExportName( diff --git a/web/apps/photos/src/services/importService.ts b/web/apps/photos/src/services/importService.ts deleted file mode 100644 index 6d2c46a85..000000000 --- a/web/apps/photos/src/services/importService.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { ensureElectron } from "@/next/electron"; -import log from "@/next/log"; -import { PICKED_UPLOAD_TYPE } from "constants/upload"; -import { Collection } from "types/collection"; -import { ElectronFile, FileWithCollection } from "types/upload"; - -interface PendingUploads { - files: ElectronFile[]; - collectionName: string; - type: PICKED_UPLOAD_TYPE; -} - -class ImportService { - async getPendingUploads(): Promise { - try { - const pendingUploads = - (await ensureElectron().getPendingUploads()) as PendingUploads; - return pendingUploads; - } catch (e) { - if (e?.message?.includes("ENOENT: no such file or directory")) { - // ignore - } else { - log.error("failed to getPendingUploads ", e); - } - return { files: [], collectionName: null, type: null }; - } - } - - async setToUploadCollection(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().setToUploadCollection(collectionName); - } - - async updatePendingUploads(files: FileWithCollection[]) { - const filePaths = []; - for (const fileWithCollection of files) { - if (fileWithCollection.isLivePhoto) { - filePaths.push( - (fileWithCollection.livePhotoAssets.image as ElectronFile) - .path, - (fileWithCollection.livePhotoAssets.video as ElectronFile) - .path, - ); - } else { - filePaths.push((fileWithCollection.file as ElectronFile).path); - } - } - await ensureElectron().setToUploadFiles( - PICKED_UPLOAD_TYPE.FILES, - filePaths, - ); - } - - async cancelRemainingUploads() { - const electron = ensureElectron(); - await electron.setToUploadCollection(null); - await electron.setToUploadFiles(PICKED_UPLOAD_TYPE.ZIPS, []); - await electron.setToUploadFiles(PICKED_UPLOAD_TYPE.FILES, []); - } -} - -export default new ImportService(); diff --git a/web/apps/photos/src/services/livePhotoService.ts b/web/apps/photos/src/services/livePhotoService.ts deleted file mode 100644 index 4d96e812c..000000000 --- a/web/apps/photos/src/services/livePhotoService.ts +++ /dev/null @@ -1,45 +0,0 @@ -import JSZip from "jszip"; -import { EnteFile } from "types/file"; -import { - getFileExtensionWithDot, - getFileNameWithoutExtension, -} from "utils/file"; - -class LivePhoto { - image: Uint8Array; - video: Uint8Array; - imageNameTitle: string; - videoNameTitle: string; -} - -export const decodeLivePhoto = async (file: EnteFile, zipBlob: Blob) => { - const originalName = getFileNameWithoutExtension(file.metadata.title); - const zip = await JSZip.loadAsync(zipBlob, { createFolders: true }); - - const livePhoto = new LivePhoto(); - for (const zipFilename in zip.files) { - if (zipFilename.startsWith("image")) { - livePhoto.imageNameTitle = - originalName + getFileExtensionWithDot(zipFilename); - livePhoto.image = await zip.files[zipFilename].async("uint8array"); - } else if (zipFilename.startsWith("video")) { - livePhoto.videoNameTitle = - originalName + getFileExtensionWithDot(zipFilename); - livePhoto.video = await zip.files[zipFilename].async("uint8array"); - } - } - return livePhoto; -}; - -export const encodeLivePhoto = async (livePhoto: LivePhoto) => { - const zip = new JSZip(); - zip.file( - "image" + getFileExtensionWithDot(livePhoto.imageNameTitle), - livePhoto.image, - ); - zip.file( - "video" + getFileExtensionWithDot(livePhoto.videoNameTitle), - livePhoto.video, - ); - return await zip.generateAsync({ type: "uint8array" }); -}; diff --git a/web/apps/photos/src/services/machineLearning/faceService.ts b/web/apps/photos/src/services/machineLearning/faceService.ts index 052ed020d..4d8a41745 100644 --- a/web/apps/photos/src/services/machineLearning/faceService.ts +++ b/web/apps/photos/src/services/machineLearning/faceService.ts @@ -145,7 +145,7 @@ class FaceService { imageBitmap, ); const blurValues = - syncContext.blurDetectionService.detectBlur(faceImages); + syncContext.blurDetectionService.detectBlur(faceImages, newMlFile.faces); newMlFile.faces.forEach((f, i) => (f.blurValue = blurValues[i])); imageBitmap.close(); diff --git a/web/apps/photos/src/services/machineLearning/laplacianBlurDetectionService.ts b/web/apps/photos/src/services/machineLearning/laplacianBlurDetectionService.ts index 14178a535..b9bc49441 100644 --- a/web/apps/photos/src/services/machineLearning/laplacianBlurDetectionService.ts +++ b/web/apps/photos/src/services/machineLearning/laplacianBlurDetectionService.ts @@ -1,6 +1,7 @@ import { BlurDetectionMethod, BlurDetectionService, + Face, Versioned, } from "types/machineLearning"; import { createGrayscaleIntMatrixFromNormalized2List } from "utils/image"; @@ -16,18 +17,20 @@ class LaplacianBlurDetectionService implements BlurDetectionService { }; } - public detectBlur(alignedFaces: Float32Array): number[] { + public detectBlur(alignedFaces: Float32Array, faces: Face[]): number[] { const numFaces = Math.round( alignedFaces.length / (mobileFaceNetFaceSize * mobileFaceNetFaceSize * 3), ); const blurValues: number[] = []; for (let i = 0; i < numFaces; i++) { + const face = faces[i]; + const direction = getFaceDirection(face); const faceImage = createGrayscaleIntMatrixFromNormalized2List( alignedFaces, i, ); - const laplacian = this.applyLaplacian(faceImage); + const laplacian = this.applyLaplacian(faceImage, direction); const variance = this.calculateVariance(laplacian); blurValues.push(variance); } @@ -61,42 +64,77 @@ class LaplacianBlurDetectionService implements BlurDetectionService { return variance; } - private padImage(image: number[][]): number[][] { + private padImage( + image: number[][], + removeSideColumns: number = 56, + direction: FaceDirection = "straight", + ): number[][] { + // Exception is removeSideColumns is not even + if (removeSideColumns % 2 != 0) { + throw new Error("removeSideColumns must be even"); + } const numRows = image.length; const numCols = image[0].length; + const paddedNumCols = numCols + 2 - removeSideColumns; + const paddedNumRows = numRows + 2; // Create a new matrix with extra padding const paddedImage: number[][] = Array.from( - { length: numRows + 2 }, - () => new Array(numCols + 2).fill(0), + { length: paddedNumRows}, + () => new Array(paddedNumCols).fill(0), ); // Copy original image into the center of the padded image - for (let i = 0; i < numRows; i++) { - for (let j = 0; j < numCols; j++) { - paddedImage[i + 1][j + 1] = image[i][j]; + if (direction === "straight") { + for (let i = 0; i < numRows; i++) { + for (let j = 0; j < paddedNumCols - 2; j++) { + paddedImage[i + 1][j + 1] = + image[i][j + Math.round(removeSideColumns / 2)]; + } + } + } // If the face is facing left, we only take the right side of the face image + else if (direction === "left") { + for (let i = 0; i < numRows; i++) { + for (let j = 0; j < paddedNumCols - 2; j++) { + paddedImage[i + 1][j + 1] = image[i][j + removeSideColumns]; + } + } + } // If the face is facing right, we only take the left side of the face image + else if (direction === "right") { + for (let i = 0; i < numRows; i++) { + for (let j = 0; j < paddedNumCols - 2; j++) { + paddedImage[i + 1][j + 1] = image[i][j]; + } } } // Reflect padding // Top and bottom rows - for (let j = 1; j <= numCols; j++) { + for (let j = 1; j <= paddedNumCols - 2; j++) { paddedImage[0][j] = paddedImage[2][j]; // Top row paddedImage[numRows + 1][j] = paddedImage[numRows - 1][j]; // Bottom row } // Left and right columns for (let i = 0; i < numRows + 2; i++) { paddedImage[i][0] = paddedImage[i][2]; // Left column - paddedImage[i][numCols + 1] = paddedImage[i][numCols - 1]; // Right column + paddedImage[i][paddedNumCols - 1] = + paddedImage[i][paddedNumCols - 3]; // Right column } return paddedImage; } - private applyLaplacian(image: number[][]): number[][] { - const paddedImage: number[][] = this.padImage(image); - const numRows = image.length; - const numCols = image[0].length; + private applyLaplacian( + image: number[][], + direction: FaceDirection = "straight", + ): number[][] { + const paddedImage: number[][] = this.padImage( + image, + undefined, + direction, + ); + const numRows = paddedImage.length - 2; + const numCols = paddedImage[0].length - 2; // Create an output image initialized to 0 const outputImage: number[][] = Array.from({ length: numRows }, () => @@ -129,3 +167,45 @@ class LaplacianBlurDetectionService implements BlurDetectionService { } export default new LaplacianBlurDetectionService(); + +type FaceDirection = "left" | "right" | "straight"; + +const getFaceDirection = (face: Face): FaceDirection => { + const landmarks = face.detection.landmarks; + const leftEye = landmarks[0]; + const rightEye = landmarks[1]; + const nose = landmarks[2]; + const leftMouth = landmarks[3]; + const rightMouth = landmarks[4]; + + const eyeDistanceX = Math.abs(rightEye.x - leftEye.x); + const eyeDistanceY = Math.abs(rightEye.y - leftEye.y); + const mouthDistanceY = Math.abs(rightMouth.y - leftMouth.y); + + const faceIsUpright = + Math.max(leftEye.y, rightEye.y) + 0.5 * eyeDistanceY < nose.y && + nose.y + 0.5 * mouthDistanceY < Math.min(leftMouth.y, rightMouth.y); + + const noseStickingOutLeft = + nose.x < Math.min(leftEye.x, rightEye.x) && + nose.x < Math.min(leftMouth.x, rightMouth.x); + + const noseStickingOutRight = + nose.x > Math.max(leftEye.x, rightEye.x) && + nose.x > Math.max(leftMouth.x, rightMouth.x); + + const noseCloseToLeftEye = + Math.abs(nose.x - leftEye.x) < 0.2 * eyeDistanceX; + const noseCloseToRightEye = + Math.abs(nose.x - rightEye.x) < 0.2 * eyeDistanceX; + + // if (faceIsUpright && (noseStickingOutLeft || noseCloseToLeftEye)) { + if (noseStickingOutLeft || (faceIsUpright && noseCloseToLeftEye)) { + return "left"; + // } else if (faceIsUpright && (noseStickingOutRight || noseCloseToRightEye)) { + } else if (noseStickingOutRight || (faceIsUpright && noseCloseToRightEye)) { + return "right"; + } + + return "straight"; +}; diff --git a/web/apps/photos/src/services/pending-uploads.ts b/web/apps/photos/src/services/pending-uploads.ts new file mode 100644 index 000000000..3b219f5b0 --- /dev/null +++ b/web/apps/photos/src/services/pending-uploads.ts @@ -0,0 +1,42 @@ +import { ensureElectron } from "@/next/electron"; +import { Collection } from "types/collection"; +import { ElectronFile, FileWithCollection } from "types/upload"; + +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); +}; + +export const updatePendingUploads = async (files: FileWithCollection[]) => { + const filePaths = []; + for (const fileWithCollection of files) { + if (fileWithCollection.isLivePhoto) { + filePaths.push( + (fileWithCollection.livePhotoAssets.image as ElectronFile).path, + (fileWithCollection.livePhotoAssets.video as ElectronFile).path, + ); + } else { + filePaths.push((fileWithCollection.file as ElectronFile).path); + } + } + await ensureElectron().setPendingUploadFiles("files", filePaths); +}; + +export const cancelRemainingUploads = async () => { + const electron = ensureElectron(); + await electron.setPendingUploadCollection(undefined); + await electron.setPendingUploadFiles("zips", []); + await electron.setPendingUploadFiles("files", []); +}; diff --git a/web/apps/photos/src/services/upload/livePhotoService.ts b/web/apps/photos/src/services/upload/livePhotoService.ts index 392b5b9c8..c203c4d5f 100644 --- a/web/apps/photos/src/services/upload/livePhotoService.ts +++ b/web/apps/photos/src/services/upload/livePhotoService.ts @@ -1,10 +1,10 @@ +import { encodeLivePhoto } from "@/media/live-photo"; import log from "@/next/log"; import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker"; import { CustomError } from "@ente/shared/error"; import { Remote } from "comlink"; import { FILE_TYPE } from "constants/file"; import { LIVE_PHOTO_ASSET_SIZE_LIMIT } from "constants/upload"; -import { encodeLivePhoto } from "services/livePhotoService"; import { getFileType } from "services/typeDetectionService"; import { ElectronFile, @@ -14,12 +14,6 @@ import { LivePhotoAssets, ParsedMetadataJSONMap, } from "types/upload"; -import { - getFileExtensionWithDot, - getFileNameWithoutExtension, - isImageOrVideo, - splitFilenameAndExtension, -} from "utils/file"; import { getFileTypeFromExtensionForLivePhotoClustering } from "utils/file/livePhoto"; import { getUint8ArrayView } from "../readerService"; import { extractFileMetadata } from "./fileService"; @@ -107,16 +101,16 @@ export async function readLivePhoto( }, ); - const image = await getUint8ArrayView(livePhotoAssets.image); + const imageData = await getUint8ArrayView(livePhotoAssets.image); - const video = await getUint8ArrayView(livePhotoAssets.video); + const videoData = await getUint8ArrayView(livePhotoAssets.video); return { filedata: await encodeLivePhoto({ - image, - video, - imageNameTitle: livePhotoAssets.image.name, - videoNameTitle: livePhotoAssets.video.name, + imageFileName: livePhotoAssets.image.name, + imageData, + videoFileName: livePhotoAssets.video.name, + videoData, }), thumbnail, hasStaticThumbnail, @@ -304,3 +298,28 @@ function removePotentialLivePhotoSuffix( return filenameWithoutExtension; } } + +function getFileNameWithoutExtension(filename: string) { + const lastDotPosition = filename.lastIndexOf("."); + if (lastDotPosition === -1) return filename; + else return filename.slice(0, lastDotPosition); +} + +function getFileExtensionWithDot(filename: string) { + const lastDotPosition = filename.lastIndexOf("."); + if (lastDotPosition === -1) return ""; + else return filename.slice(lastDotPosition); +} + +function splitFilenameAndExtension(filename: string): [string, string] { + const lastDotPosition = filename.lastIndexOf("."); + if (lastDotPosition === -1) return [filename, null]; + else + return [ + filename.slice(0, lastDotPosition), + filename.slice(lastDotPosition + 1), + ]; +} + +const isImageOrVideo = (fileType: FILE_TYPE) => + [FILE_TYPE.IMAGE, FILE_TYPE.VIDEO].includes(fileType); diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index 82b761091..e927d7b08 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -8,13 +8,16 @@ import { Events, eventBus } from "@ente/shared/events"; import { Remote } from "comlink"; import { UPLOAD_RESULT, UPLOAD_STAGES } from "constants/upload"; import isElectron from "is-electron"; -import ImportService from "services/importService"; +import { + cancelRemainingUploads, + updatePendingUploads, +} from "services/pending-uploads"; import { getLocalPublicFiles, getPublicCollectionUID, } from "services/publicCollectionService"; import { getDisableCFUploadProxyFlag } from "services/userService"; -import watchFolderService from "services/watchFolder/watchFolderService"; +import watcher from "services/watch"; import { Collection } from "types/collection"; import { EncryptedEnteFile, EnteFile } from "types/file"; import { SetFiles } from "types/gallery"; @@ -177,7 +180,7 @@ class UploadManager { if (e.message === CustomError.UPLOAD_CANCELLED) { if (isElectron()) { this.remainingFiles = []; - await ImportService.cancelRemainingUploads(); + await cancelRemainingUploads(); } } else { log.error("uploading failed with error", e); @@ -387,11 +390,13 @@ class UploadManager { uploadedFile: EncryptedEnteFile, ) { if (isElectron()) { - await watchFolderService.onFileUpload( - fileUploadResult, - fileWithCollection, - uploadedFile, - ); + if (watcher.isUploadRunning()) { + await watcher.onFileUpload( + fileUploadResult, + fileWithCollection, + uploadedFile, + ); + } } } @@ -431,12 +436,12 @@ class UploadManager { this.remainingFiles = this.remainingFiles.filter( (file) => !areFileWithCollectionsSame(file, fileWithCollection), ); - await ImportService.updatePendingUploads(this.remainingFiles); + await updatePendingUploads(this.remainingFiles); } } public shouldAllowNewUpload = () => { - return !this.uploadInProgress || watchFolderService.isUploadRunning(); + return !this.uploadInProgress || watcher.isUploadRunning(); }; } diff --git a/web/apps/photos/src/services/watch.ts b/web/apps/photos/src/services/watch.ts new file mode 100644 index 000000000..703dd87ad --- /dev/null +++ b/web/apps/photos/src/services/watch.ts @@ -0,0 +1,647 @@ +/** + * @file Interface with the Node.js layer of our desktop app to provide the + * watch folders functionality. + */ + +import { ensureElectron } from "@/next/electron"; +import { basename, dirname } from "@/next/file"; +import log from "@/next/log"; +import type { + CollectionMapping, + FolderWatch, + FolderWatchSyncedFile, +} from "@/next/types/ipc"; +import { UPLOAD_RESULT } from "constants/upload"; +import debounce from "debounce"; +import uploadManager from "services/upload/uploadManager"; +import { Collection } from "types/collection"; +import { EncryptedEnteFile } from "types/file"; +import { ElectronFile, FileWithCollection } from "types/upload"; +import { groupFilesBasedOnCollectionID } from "utils/file"; +import { isHiddenFile } from "utils/upload"; +import { removeFromCollection } from "./collectionService"; +import { getLocalFiles } from "./fileService"; + +/** + * Watch for file system folders and automatically update the corresponding Ente + * collections. + * + * This class relies on APIs exposed over the Electron IPC layer, and thus only + * works when we're running inside our desktop app. + */ +class FolderWatcher { + /** Pending file system events that we need to process. */ + private eventQueue: WatchEvent[] = []; + /** The folder watch whose event we're currently processing */ + private activeWatch: FolderWatch | undefined; + /** + * If the file system directory corresponding to the (root) folder path of a + * folder watch is deleted on disk, we note down that in this queue so that + * we can ignore any file system events that come for it next. + * + * TODO: is this really needed? the mappings are pre-checked first. + */ + private deletedFolderPaths: string[] = []; + /** `true` if we are using the uploader. */ + private uploadRunning = false; + /** `true` if we are temporarily paused to let a user upload go through. */ + private isPaused = false; + private filePathToUploadedFileIDMap = new Map(); + private unUploadableFilePaths = new Set(); + + /** + * A function to call when we want to enqueue a new upload of the given list + * of file paths to the given Ente collection. + * + * This is passed as a param to {@link init}. + */ + private upload: (collectionName: string, filePaths: string[]) => void; + /** + * A function to call when we want to sync with the backend. + * + * This is passed as a param to {@link init}. + */ + private syncWithRemote: () => void; + + /** A helper function that debounces invocations of {@link runNextEvent}. */ + private debouncedRunNextEvent: () => void; + + constructor() { + this.debouncedRunNextEvent = debounce(() => this.runNextEvent(), 1000); + } + + /** + * Initialize the watcher and start processing file system events. + * + * This is only called when we're running in the context of our desktop app. + * + * The caller provides us with the hooks we can use to actually upload the + * files, and to sync with remote (say after deletion). + */ + init( + upload: (collectionName: string, filePaths: string[]) => void, + syncWithRemote: () => void, + ) { + this.upload = upload; + this.syncWithRemote = syncWithRemote; + this.registerListeners(); + this.syncWithDisk(); + } + + /** `true` if we are currently using the uploader */ + isUploadRunning() { + return this.uploadRunning; + } + + /** `true` if syncing has been temporarily paused */ + isSyncPaused() { + return this.isPaused; + } + + /** + * Temporarily pause syncing and cancel any running uploads. + * + * This frees up the uploader for handling user initated uploads. + */ + pauseRunningSync() { + this.isPaused = true; + uploadManager.cancelRunningUpload(); + } + + /** + * Resume from a temporary pause, resyncing from disk. + * + * Sibling of {@link pauseRunningSync}. + */ + resumePausedSync() { + this.isPaused = false; + this.syncWithDisk(); + } + + /** Return the list of folders we are watching for changes. */ + async getWatches(): Promise { + return await ensureElectron().watch.get(); + } + + /** + * Return true if we are currently syncing files that belong to the given + * {@link folderPath}. + */ + isSyncingFolder(folderPath: string) { + return this.activeWatch?.folderPath == folderPath; + } + + /** + * Add a new folder watch for the given root {@link folderPath} + * + * @param mapping The {@link CollectionMapping} to use to decide which + * collection do files belonging to nested directories go to. + * + * @returns The updated list of watches. + */ + async addWatch(folderPath: string, mapping: CollectionMapping) { + const watches = await ensureElectron().watch.add(folderPath, mapping); + this.syncWithDisk(); + return watches; + } + + /** + * Remove the folder watch for the given root {@link folderPath}. + * + * @returns The updated list of watches. + */ + async removeWatch(folderPath: string) { + return await ensureElectron().watch.remove(folderPath); + } + + private async syncWithDisk() { + try { + const watches = await this.getWatches(); + if (!watches) return; + + this.eventQueue = []; + const events = await deduceEvents(watches); + log.info(`Folder watch deduced ${events.length} events`); + this.eventQueue = this.eventQueue.concat(events); + + this.debouncedRunNextEvent(); + } catch (e) { + log.error("Ignoring error while syncing watched folders", e); + } + } + + pushEvent(event: WatchEvent) { + this.eventQueue.push(event); + log.info("Folder watch event", event); + this.debouncedRunNextEvent(); + } + + private registerListeners() { + const watch = ensureElectron().watch; + + // [Note: File renames during folder watch] + // + // Renames come as two file system events - an `onAddFile` + an + // `onRemoveFile` - in an arbitrary order. + + watch.onAddFile((path: string, watch: FolderWatch) => { + this.pushEvent({ + action: "upload", + collectionName: collectionNameForPath(path, watch), + folderPath: watch.folderPath, + filePath: path, + }); + }); + + watch.onRemoveFile((path: string, watch: FolderWatch) => { + this.pushEvent({ + action: "trash", + collectionName: collectionNameForPath(path, watch), + folderPath: watch.folderPath, + filePath: path, + }); + }); + + watch.onRemoveDir((path: string, watch: FolderWatch) => { + if (path == watch.folderPath) { + log.info( + `Received file system delete event for a watched folder at ${path}`, + ); + this.deletedFolderPaths.push(path); + } + }); + } + + private async runNextEvent() { + if (this.eventQueue.length == 0 || this.activeWatch || this.isPaused) + return; + + const skip = (reason: string) => { + log.info(`Ignoring event since ${reason}`); + this.debouncedRunNextEvent(); + }; + + const event = this.dequeueClubbedEvent(); + log.info( + `Processing ${event.action} event for folder watch ${event.folderPath} (collectionName ${event.collectionName}, ${event.filePaths.length} files)`, + ); + + const watch = (await this.getWatches()).find( + (watch) => watch.folderPath == event.folderPath, + ); + if (!watch) { + // Possibly stale + skip(`no folder watch for found for ${event.folderPath}`); + return; + } + + if (event.action === "upload") { + const paths = pathsToUpload(event.filePaths, watch); + if (paths.length == 0) { + skip("none of the files need uploading"); + return; + } + + // Here we pass control to the uploader. When the upload is done, + // the uploader will notify us by calling allFileUploadsDone. + + this.activeWatch = watch; + this.uploadRunning = true; + + const collectionName = event.collectionName; + log.info( + `Folder watch requested upload of ${paths.length} files to collection ${collectionName}`, + ); + + this.upload(collectionName, paths); + } else { + if (this.pruneFileEventsFromDeletedFolderPaths()) { + skip("event was from a deleted folder path"); + return; + } + + const [removed, rest] = watch.syncedFiles.reduce( + ([removed, rest], { path }) => { + (event.filePaths.includes(path) ? rest : removed).push( + watch, + ); + return [removed, rest]; + }, + [[], []], + ); + + this.activeWatch = watch; + + await this.moveToTrash(removed); + + await ensureElectron().watch.updateSyncedFiles( + rest, + watch.folderPath, + ); + + this.activeWatch = undefined; + + this.debouncedRunNextEvent(); + } + } + + /** + * Batch the next run of events with the same action, collection and folder + * path into a single clubbed event that contains the list of all effected + * file paths from the individual events. + */ + private dequeueClubbedEvent(): ClubbedWatchEvent | undefined { + const event = this.eventQueue.shift(); + if (!event) return undefined; + + const filePaths = [event.filePath]; + while ( + this.eventQueue.length > 0 && + event.action === this.eventQueue[0].action && + event.folderPath === this.eventQueue[0].folderPath && + event.collectionName === this.eventQueue[0].collectionName + ) { + filePaths.push(this.eventQueue[0].filePath); + this.eventQueue.shift(); + } + return { ...event, filePaths }; + } + + /** + * Callback invoked by the uploader whenever a file we requested to + * {@link upload} gets uploaded. + */ + async onFileUpload( + fileUploadResult: UPLOAD_RESULT, + fileWithCollection: FileWithCollection, + file: EncryptedEnteFile, + ) { + if ( + [ + UPLOAD_RESULT.ADDED_SYMLINK, + UPLOAD_RESULT.UPLOADED, + UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL, + UPLOAD_RESULT.ALREADY_UPLOADED, + ].includes(fileUploadResult) + ) { + if (fileWithCollection.isLivePhoto) { + this.filePathToUploadedFileIDMap.set( + (fileWithCollection.livePhotoAssets.image as ElectronFile) + .path, + file, + ); + this.filePathToUploadedFileIDMap.set( + (fileWithCollection.livePhotoAssets.video as ElectronFile) + .path, + file, + ); + } else { + this.filePathToUploadedFileIDMap.set( + (fileWithCollection.file as ElectronFile).path, + file, + ); + } + } else if ( + [UPLOAD_RESULT.UNSUPPORTED, UPLOAD_RESULT.TOO_LARGE].includes( + fileUploadResult, + ) + ) { + if (fileWithCollection.isLivePhoto) { + this.unUploadableFilePaths.add( + (fileWithCollection.livePhotoAssets.image as ElectronFile) + .path, + ); + this.unUploadableFilePaths.add( + (fileWithCollection.livePhotoAssets.video as ElectronFile) + .path, + ); + } else { + this.unUploadableFilePaths.add( + (fileWithCollection.file as ElectronFile).path, + ); + } + } + } + + /** + * Callback invoked by the uploader whenever all the files we requested to + * {@link upload} get uploaded. + */ + async allFileUploadsDone( + filesWithCollection: FileWithCollection[], + collections: Collection[], + ) { + const electron = ensureElectron(); + const watch = this.activeWatch; + + log.debug(() => + JSON.stringify({ + f: "watch/allFileUploadsDone", + filesWithCollection, + collections, + watch, + }), + ); + + const { syncedFiles, ignoredFiles } = + this.parseAllFileUploadsDone(filesWithCollection); + + log.debug(() => + JSON.stringify({ + f: "watch/allFileUploadsDone", + syncedFiles, + ignoredFiles, + }), + ); + + if (syncedFiles.length > 0) + await electron.watch.updateSyncedFiles( + watch.syncedFiles.concat(syncedFiles), + watch.folderPath, + ); + + if (ignoredFiles.length > 0) + await electron.watch.updateIgnoredFiles( + watch.ignoredFiles.concat(ignoredFiles), + watch.folderPath, + ); + + this.activeWatch = undefined; + this.uploadRunning = false; + + this.debouncedRunNextEvent(); + } + + private parseAllFileUploadsDone(filesWithCollection: FileWithCollection[]) { + const syncedFiles: FolderWatch["syncedFiles"] = []; + const ignoredFiles: FolderWatch["ignoredFiles"] = []; + + for (const fileWithCollection of filesWithCollection) { + if (fileWithCollection.isLivePhoto) { + const imagePath = ( + fileWithCollection.livePhotoAssets.image as ElectronFile + ).path; + const videoPath = ( + fileWithCollection.livePhotoAssets.video as ElectronFile + ).path; + + if ( + this.filePathToUploadedFileIDMap.has(imagePath) && + this.filePathToUploadedFileIDMap.has(videoPath) + ) { + const imageFile = { + path: imagePath, + uploadedFileID: + this.filePathToUploadedFileIDMap.get(imagePath).id, + collectionID: + this.filePathToUploadedFileIDMap.get(imagePath) + .collectionID, + }; + const videoFile = { + path: videoPath, + uploadedFileID: + this.filePathToUploadedFileIDMap.get(videoPath).id, + collectionID: + this.filePathToUploadedFileIDMap.get(videoPath) + .collectionID, + }; + syncedFiles.push(imageFile); + syncedFiles.push(videoFile); + log.debug( + () => + `added image ${JSON.stringify( + imageFile, + )} and video file ${JSON.stringify( + videoFile, + )} to uploadedFiles`, + ); + } else if ( + this.unUploadableFilePaths.has(imagePath) && + this.unUploadableFilePaths.has(videoPath) + ) { + ignoredFiles.push(imagePath); + ignoredFiles.push(videoPath); + log.debug( + () => + `added image ${imagePath} and video file ${videoPath} to rejectedFiles`, + ); + } + this.filePathToUploadedFileIDMap.delete(imagePath); + this.filePathToUploadedFileIDMap.delete(videoPath); + } else { + const filePath = (fileWithCollection.file as ElectronFile).path; + + if (this.filePathToUploadedFileIDMap.has(filePath)) { + const file = { + path: filePath, + uploadedFileID: + this.filePathToUploadedFileIDMap.get(filePath).id, + collectionID: + this.filePathToUploadedFileIDMap.get(filePath) + .collectionID, + }; + syncedFiles.push(file); + log.debug(() => `added file ${JSON.stringify(file)}`); + } else if (this.unUploadableFilePaths.has(filePath)) { + ignoredFiles.push(filePath); + log.debug(() => `added file ${filePath} to rejectedFiles`); + } + this.filePathToUploadedFileIDMap.delete(filePath); + } + } + + return { syncedFiles, ignoredFiles }; + } + + private pruneFileEventsFromDeletedFolderPaths() { + const deletedFolderPath = this.deletedFolderPaths.shift(); + if (!deletedFolderPath) return false; + + this.eventQueue = this.eventQueue.filter( + (event) => !event.filePath.startsWith(deletedFolderPath), + ); + return true; + } + + private async moveToTrash(syncedFiles: FolderWatch["syncedFiles"]) { + try { + const files = await getLocalFiles(); + const toTrashFilesMap = new Map(); + for (const file of syncedFiles) { + toTrashFilesMap.set(file.uploadedFileID, file); + } + const filesToTrash = files.filter((file) => { + if (toTrashFilesMap.has(file.id)) { + const fileToTrash = toTrashFilesMap.get(file.id); + if (fileToTrash.collectionID === file.collectionID) { + return true; + } + } + }); + const groupFilesByCollectionId = + groupFilesBasedOnCollectionID(filesToTrash); + + for (const [ + collectionID, + filesToTrash, + ] of groupFilesByCollectionId.entries()) { + await removeFromCollection(collectionID, filesToTrash); + } + this.syncWithRemote(); + } catch (e) { + log.error("error while trashing by IDs", e); + } + } +} + +/** The singleton instance of the {@link FolderWatcher}. */ +const watcher = new FolderWatcher(); + +export default watcher; + +/** + * A file system watch event encapsulates a change that has occurred on disk + * that needs us to take some action within Ente to synchronize with the user's + * Ente collections. + * + * Events get added in two ways: + * + * - When the app starts, it reads the current state of files on disk and + * compares that with its last known state to determine what all events it + * missed. This is easier than it sounds as we have only two events: add and + * remove. + * + * - When the app is running, it gets live notifications from our file system + * watcher (from the Node.js layer) about changes that have happened on disk, + * which the app then enqueues onto the event queue if they pertain to the + * files we're interested in. + */ +interface WatchEvent { + /** The action to take */ + action: "upload" | "trash"; + /** The path of the root folder corresponding to the {@link FolderWatch}. */ + folderPath: string; + /** The name of the Ente collection the file belongs to. */ + collectionName: string; + /** The absolute path to the file under consideration. */ + filePath: string; +} + +/** + * A composite of multiple {@link WatchEvent}s that only differ in their + * {@link filePath}. + * + * When processing events, we combine a run of events with the same + * {@link action}, {@link folderPath} and {@link collectionName}. This allows us + * to process all the affected {@link filePaths} in one shot. + */ +type ClubbedWatchEvent = Omit & { + filePaths: string[]; +}; + +/** + * Determine which events we need to process to synchronize the watched on-disk + * folders to their corresponding collections. + */ +const deduceEvents = async (watches: FolderWatch[]): Promise => { + const electron = ensureElectron(); + const events: WatchEvent[] = []; + + for (const watch of watches) { + const folderPath = watch.folderPath; + + const filePaths = await electron.watch.findFiles(folderPath); + + // Files that are on disk but not yet synced. + for (const filePath of pathsToUpload(filePaths, watch)) + events.push({ + action: "upload", + folderPath, + collectionName: collectionNameForPath(filePath, watch), + filePath, + }); + + // Previously synced files that are no longer on disk. + for (const filePath of pathsToRemove(filePaths, watch)) + events.push({ + action: "trash", + folderPath, + collectionName: collectionNameForPath(filePath, watch), + filePath, + }); + } + + return events; +}; + +/** + * Filter out hidden files and previously synced or ignored paths from + * {@link paths} to get the list of paths that need to be uploaded to the Ente + * collection. + */ +const pathsToUpload = (paths: string[], watch: FolderWatch) => + paths + // Filter out hidden files (files whose names begins with a dot) + .filter((path) => !isHiddenFile(path)) + // Files that are on disk but not yet synced or ignored. + .filter((path) => !isSyncedOrIgnoredPath(path, watch)); + +/** + * Return the paths to previously synced files that are no longer on disk and so + * must be removed from the Ente collection. + */ +const pathsToRemove = (paths: string[], watch: FolderWatch) => + watch.syncedFiles + .map((f) => f.path) + .filter((path) => !paths.includes(path)); + +const isSyncedOrIgnoredPath = (path: string, watch: FolderWatch) => + watch.ignoredFiles.includes(path) || + watch.syncedFiles.find((f) => f.path === path); + +const collectionNameForPath = (path: string, watch: FolderWatch) => + watch.collectionMapping == "root" + ? dirname(watch.folderPath) + : parentDirectoryName(path); + +const parentDirectoryName = (path: string) => basename(dirname(path)); diff --git a/web/apps/photos/src/services/watchFolder/utils.ts b/web/apps/photos/src/services/watchFolder/utils.ts deleted file mode 100644 index bd6ceb853..000000000 --- a/web/apps/photos/src/services/watchFolder/utils.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const getParentFolderName = (filePath: string) => { - const folderPath = filePath.substring(0, filePath.lastIndexOf("/")); - const folderName = folderPath.substring(folderPath.lastIndexOf("/") + 1); - return folderName; -}; diff --git a/web/apps/photos/src/services/watchFolder/watchFolderEventHandlers.ts b/web/apps/photos/src/services/watchFolder/watchFolderEventHandlers.ts deleted file mode 100644 index ba4ad62ee..000000000 --- a/web/apps/photos/src/services/watchFolder/watchFolderEventHandlers.ts +++ /dev/null @@ -1,73 +0,0 @@ -import log from "@/next/log"; -import { ElectronFile } from "types/upload"; -import { EventQueueItem } from "types/watchFolder"; -import watchFolderService from "./watchFolderService"; - -export async function diskFileAddedCallback(file: ElectronFile) { - try { - const collectionNameAndFolderPath = - await watchFolderService.getCollectionNameAndFolderPath(file.path); - - if (!collectionNameAndFolderPath) { - return; - } - - const { collectionName, folderPath } = collectionNameAndFolderPath; - - const event: EventQueueItem = { - type: "upload", - collectionName, - folderPath, - files: [file], - }; - watchFolderService.pushEvent(event); - log.info( - `added (upload) to event queue, collectionName:${event.collectionName} folderPath:${event.folderPath}, filesCount: ${event.files.length}`, - ); - } catch (e) { - log.error("error while calling diskFileAddedCallback", e); - } -} - -export async function diskFileRemovedCallback(filePath: string) { - try { - const collectionNameAndFolderPath = - await watchFolderService.getCollectionNameAndFolderPath(filePath); - - if (!collectionNameAndFolderPath) { - return; - } - - const { collectionName, folderPath } = collectionNameAndFolderPath; - - const event: EventQueueItem = { - type: "trash", - collectionName, - folderPath, - paths: [filePath], - }; - watchFolderService.pushEvent(event); - log.info( - `added (trash) to event queue collectionName:${event.collectionName} folderPath:${event.folderPath} , pathsCount: ${event.paths.length}`, - ); - } catch (e) { - log.error("error while calling diskFileRemovedCallback", e); - } -} - -export async function diskFolderRemovedCallback(folderPath: string) { - try { - const mappings = await watchFolderService.getWatchMappings(); - const mapping = mappings.find( - (mapping) => mapping.folderPath === folderPath, - ); - if (!mapping) { - log.info(`folder not found in mappings, ${folderPath}`); - throw Error(`Watch mapping not found`); - } - watchFolderService.pushTrashedDir(folderPath); - log.info(`added trashedDir, ${folderPath}`); - } catch (e) { - log.error("error while calling diskFolderRemovedCallback", e); - } -} diff --git a/web/apps/photos/src/services/watchFolder/watchFolderService.ts b/web/apps/photos/src/services/watchFolder/watchFolderService.ts deleted file mode 100644 index 791aed445..000000000 --- a/web/apps/photos/src/services/watchFolder/watchFolderService.ts +++ /dev/null @@ -1,644 +0,0 @@ -import { ensureElectron } from "@/next/electron"; -import log from "@/next/log"; -import { UPLOAD_RESULT, UPLOAD_STRATEGY } from "constants/upload"; -import debounce from "debounce"; -import uploadManager from "services/upload/uploadManager"; -import { Collection } from "types/collection"; -import { EncryptedEnteFile } from "types/file"; -import { ElectronFile, FileWithCollection } from "types/upload"; -import { - EventQueueItem, - WatchMapping, - WatchMappingSyncedFile, -} from "types/watchFolder"; -import { groupFilesBasedOnCollectionID } from "utils/file"; -import { getValidFilesToUpload } from "utils/watch"; -import { removeFromCollection } from "../collectionService"; -import { getLocalFiles } from "../fileService"; -import { getParentFolderName } from "./utils"; -import { - diskFileAddedCallback, - diskFileRemovedCallback, - diskFolderRemovedCallback, -} from "./watchFolderEventHandlers"; - -class watchFolderService { - private eventQueue: EventQueueItem[] = []; - private currentEvent: EventQueueItem; - private currentlySyncedMapping: WatchMapping; - private trashingDirQueue: string[] = []; - private isEventRunning: boolean = false; - private uploadRunning: boolean = false; - private filePathToUploadedFileIDMap = new Map(); - private unUploadableFilePaths = new Set(); - private isPaused = false; - private setElectronFiles: (files: ElectronFile[]) => void; - private setCollectionName: (collectionName: string) => void; - private syncWithRemote: () => void; - private setWatchFolderServiceIsRunning: (isRunning: boolean) => void; - private debouncedRunNextEvent: () => void; - - constructor() { - this.debouncedRunNextEvent = debounce(() => this.runNextEvent(), 1000); - } - - isUploadRunning() { - return this.uploadRunning; - } - - isSyncPaused() { - return this.isPaused; - } - - async init( - setElectronFiles: (files: ElectronFile[]) => void, - setCollectionName: (collectionName: string) => void, - syncWithRemote: () => void, - setWatchFolderServiceIsRunning: (isRunning: boolean) => void, - ) { - try { - this.setElectronFiles = setElectronFiles; - this.setCollectionName = setCollectionName; - this.syncWithRemote = syncWithRemote; - this.setWatchFolderServiceIsRunning = - setWatchFolderServiceIsRunning; - this.setupWatcherFunctions(); - await this.getAndSyncDiffOfFiles(); - } catch (e) { - log.error("error while initializing watch service", e); - } - } - - async getAndSyncDiffOfFiles() { - try { - let mappings = await this.getWatchMappings(); - - if (!mappings?.length) { - return; - } - - mappings = await this.filterOutDeletedMappings(mappings); - - this.eventQueue = []; - - for (const mapping of mappings) { - const filesOnDisk: ElectronFile[] = - await ensureElectron().getDirFiles(mapping.folderPath); - - this.uploadDiffOfFiles(mapping, filesOnDisk); - this.trashDiffOfFiles(mapping, filesOnDisk); - } - } catch (e) { - log.error("error while getting and syncing diff of files", e); - } - } - - isMappingSyncInProgress(mapping: WatchMapping) { - return this.currentEvent?.folderPath === mapping.folderPath; - } - - private uploadDiffOfFiles( - mapping: WatchMapping, - filesOnDisk: ElectronFile[], - ) { - const filesToUpload = getValidFilesToUpload(filesOnDisk, mapping); - - if (filesToUpload.length > 0) { - for (const file of filesToUpload) { - const event: EventQueueItem = { - type: "upload", - collectionName: this.getCollectionNameForMapping( - mapping, - file.path, - ), - folderPath: mapping.folderPath, - files: [file], - }; - this.pushEvent(event); - } - } - } - - private trashDiffOfFiles( - mapping: WatchMapping, - filesOnDisk: ElectronFile[], - ) { - const filesToRemove = mapping.syncedFiles.filter((file) => { - return !filesOnDisk.find( - (electronFile) => electronFile.path === file.path, - ); - }); - - if (filesToRemove.length > 0) { - for (const file of filesToRemove) { - const event: EventQueueItem = { - type: "trash", - collectionName: this.getCollectionNameForMapping( - mapping, - file.path, - ), - folderPath: mapping.folderPath, - paths: [file.path], - }; - this.pushEvent(event); - } - } - } - - private async filterOutDeletedMappings( - mappings: WatchMapping[], - ): Promise { - const notDeletedMappings = []; - for (const mapping of mappings) { - const mappingExists = await ensureElectron().isFolder( - mapping.folderPath, - ); - if (!mappingExists) { - ensureElectron().removeWatchMapping(mapping.folderPath); - } else { - notDeletedMappings.push(mapping); - } - } - return notDeletedMappings; - } - - pushEvent(event: EventQueueItem) { - this.eventQueue.push(event); - this.debouncedRunNextEvent(); - } - - async pushTrashedDir(path: string) { - this.trashingDirQueue.push(path); - } - - private setupWatcherFunctions() { - ensureElectron().registerWatcherFunctions( - diskFileAddedCallback, - diskFileRemovedCallback, - diskFolderRemovedCallback, - ); - } - - async addWatchMapping( - rootFolderName: string, - folderPath: string, - uploadStrategy: UPLOAD_STRATEGY, - ) { - try { - await ensureElectron().addWatchMapping( - rootFolderName, - folderPath, - uploadStrategy, - ); - this.getAndSyncDiffOfFiles(); - } catch (e) { - log.error("error while adding watch mapping", e); - } - } - - async removeWatchMapping(folderPath: string) { - try { - await ensureElectron().removeWatchMapping(folderPath); - } catch (e) { - log.error("error while removing watch mapping", e); - } - } - - async getWatchMappings(): Promise { - try { - return (await ensureElectron().getWatchMappings()) ?? []; - } catch (e) { - log.error("error while getting watch mappings", e); - return []; - } - } - - private setIsEventRunning(isEventRunning: boolean) { - this.isEventRunning = isEventRunning; - this.setWatchFolderServiceIsRunning(isEventRunning); - } - - private async runNextEvent() { - try { - if ( - this.eventQueue.length === 0 || - this.isEventRunning || - this.isPaused - ) { - return; - } - - const event = this.clubSameCollectionEvents(); - log.info( - `running event type:${event.type} collectionName:${event.collectionName} folderPath:${event.folderPath} , fileCount:${event.files?.length} pathsCount: ${event.paths?.length}`, - ); - const mappings = await this.getWatchMappings(); - const mapping = mappings.find( - (mapping) => mapping.folderPath === event.folderPath, - ); - if (!mapping) { - throw Error("no Mapping found for event"); - } - log.info( - `mapping for event rootFolder: ${mapping.rootFolderName} folderPath: ${mapping.folderPath} uploadStrategy: ${mapping.uploadStrategy} syncedFilesCount: ${mapping.syncedFiles.length} ignoredFilesCount ${mapping.ignoredFiles.length}`, - ); - if (event.type === "upload") { - event.files = getValidFilesToUpload(event.files, mapping); - log.info(`valid files count: ${event.files?.length}`); - if (event.files.length === 0) { - return; - } - } - this.currentEvent = event; - this.currentlySyncedMapping = mapping; - - this.setIsEventRunning(true); - if (event.type === "upload") { - this.processUploadEvent(); - } else { - await this.processTrashEvent(); - this.setIsEventRunning(false); - setTimeout(() => this.runNextEvent(), 0); - } - } catch (e) { - log.error("runNextEvent failed", e); - } - } - - private async processUploadEvent() { - try { - this.uploadRunning = true; - - this.setCollectionName(this.currentEvent.collectionName); - this.setElectronFiles(this.currentEvent.files); - } catch (e) { - log.error("error while running next upload", e); - } - } - - async onFileUpload( - fileUploadResult: UPLOAD_RESULT, - fileWithCollection: FileWithCollection, - file: EncryptedEnteFile, - ) { - log.debug(() => `onFileUpload called`); - if (!this.isUploadRunning()) { - return; - } - if ( - [ - UPLOAD_RESULT.ADDED_SYMLINK, - UPLOAD_RESULT.UPLOADED, - UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL, - UPLOAD_RESULT.ALREADY_UPLOADED, - ].includes(fileUploadResult) - ) { - if (fileWithCollection.isLivePhoto) { - this.filePathToUploadedFileIDMap.set( - (fileWithCollection.livePhotoAssets.image as ElectronFile) - .path, - file, - ); - this.filePathToUploadedFileIDMap.set( - (fileWithCollection.livePhotoAssets.video as ElectronFile) - .path, - file, - ); - } else { - this.filePathToUploadedFileIDMap.set( - (fileWithCollection.file as ElectronFile).path, - file, - ); - } - } else if ( - [UPLOAD_RESULT.UNSUPPORTED, UPLOAD_RESULT.TOO_LARGE].includes( - fileUploadResult, - ) - ) { - if (fileWithCollection.isLivePhoto) { - this.unUploadableFilePaths.add( - (fileWithCollection.livePhotoAssets.image as ElectronFile) - .path, - ); - this.unUploadableFilePaths.add( - (fileWithCollection.livePhotoAssets.video as ElectronFile) - .path, - ); - } else { - this.unUploadableFilePaths.add( - (fileWithCollection.file as ElectronFile).path, - ); - } - } - } - - async allFileUploadsDone( - filesWithCollection: FileWithCollection[], - collections: Collection[], - ) { - try { - log.debug( - () => - `allFileUploadsDone,${JSON.stringify( - filesWithCollection, - )} ${JSON.stringify(collections)}`, - ); - const collection = collections.find( - (collection) => - collection.id === filesWithCollection[0].collectionID, - ); - log.debug(() => `got collection ${!!collection}`); - log.debug( - () => - `${this.isEventRunning} ${this.currentEvent.collectionName} ${collection?.name}`, - ); - if ( - !this.isEventRunning || - this.currentEvent.collectionName !== collection?.name - ) { - return; - } - - const syncedFiles: WatchMapping["syncedFiles"] = []; - const ignoredFiles: WatchMapping["ignoredFiles"] = []; - - for (const fileWithCollection of filesWithCollection) { - this.handleUploadedFile( - fileWithCollection, - syncedFiles, - ignoredFiles, - ); - } - - log.debug(() => `syncedFiles ${JSON.stringify(syncedFiles)}`); - log.debug(() => `ignoredFiles ${JSON.stringify(ignoredFiles)}`); - - if (syncedFiles.length > 0) { - this.currentlySyncedMapping.syncedFiles = [ - ...this.currentlySyncedMapping.syncedFiles, - ...syncedFiles, - ]; - await ensureElectron().updateWatchMappingSyncedFiles( - this.currentlySyncedMapping.folderPath, - this.currentlySyncedMapping.syncedFiles, - ); - } - if (ignoredFiles.length > 0) { - this.currentlySyncedMapping.ignoredFiles = [ - ...this.currentlySyncedMapping.ignoredFiles, - ...ignoredFiles, - ]; - await ensureElectron().updateWatchMappingIgnoredFiles( - this.currentlySyncedMapping.folderPath, - this.currentlySyncedMapping.ignoredFiles, - ); - } - - this.runPostUploadsAction(); - } catch (e) { - log.error("error while running all file uploads done", e); - } - } - - private runPostUploadsAction() { - this.setIsEventRunning(false); - this.uploadRunning = false; - this.runNextEvent(); - } - - private handleUploadedFile( - fileWithCollection: FileWithCollection, - syncedFiles: WatchMapping["syncedFiles"], - ignoredFiles: WatchMapping["ignoredFiles"], - ) { - if (fileWithCollection.isLivePhoto) { - const imagePath = ( - fileWithCollection.livePhotoAssets.image as ElectronFile - ).path; - const videoPath = ( - fileWithCollection.livePhotoAssets.video as ElectronFile - ).path; - - if ( - this.filePathToUploadedFileIDMap.has(imagePath) && - this.filePathToUploadedFileIDMap.has(videoPath) - ) { - const imageFile = { - path: imagePath, - uploadedFileID: - this.filePathToUploadedFileIDMap.get(imagePath).id, - collectionID: - this.filePathToUploadedFileIDMap.get(imagePath) - .collectionID, - }; - const videoFile = { - path: videoPath, - uploadedFileID: - this.filePathToUploadedFileIDMap.get(videoPath).id, - collectionID: - this.filePathToUploadedFileIDMap.get(videoPath) - .collectionID, - }; - syncedFiles.push(imageFile); - syncedFiles.push(videoFile); - log.debug( - () => - `added image ${JSON.stringify( - imageFile, - )} and video file ${JSON.stringify( - videoFile, - )} to uploadedFiles`, - ); - } else if ( - this.unUploadableFilePaths.has(imagePath) && - this.unUploadableFilePaths.has(videoPath) - ) { - ignoredFiles.push(imagePath); - ignoredFiles.push(videoPath); - log.debug( - () => - `added image ${imagePath} and video file ${videoPath} to rejectedFiles`, - ); - } - this.filePathToUploadedFileIDMap.delete(imagePath); - this.filePathToUploadedFileIDMap.delete(videoPath); - } else { - const filePath = (fileWithCollection.file as ElectronFile).path; - - if (this.filePathToUploadedFileIDMap.has(filePath)) { - const file = { - path: filePath, - uploadedFileID: - this.filePathToUploadedFileIDMap.get(filePath).id, - collectionID: - this.filePathToUploadedFileIDMap.get(filePath) - .collectionID, - }; - syncedFiles.push(file); - log.debug(() => `added file ${JSON.stringify(file)}`); - } else if (this.unUploadableFilePaths.has(filePath)) { - ignoredFiles.push(filePath); - log.debug(() => `added file ${filePath} to rejectedFiles`); - } - this.filePathToUploadedFileIDMap.delete(filePath); - } - } - - private async processTrashEvent() { - try { - if (this.checkAndIgnoreIfFileEventsFromTrashedDir()) { - return; - } - - const { paths } = this.currentEvent; - const filePathsToRemove = new Set(paths); - - const files = this.currentlySyncedMapping.syncedFiles.filter( - (file) => filePathsToRemove.has(file.path), - ); - - await this.trashByIDs(files); - - this.currentlySyncedMapping.syncedFiles = - this.currentlySyncedMapping.syncedFiles.filter( - (file) => !filePathsToRemove.has(file.path), - ); - await ensureElectron().updateWatchMappingSyncedFiles( - this.currentlySyncedMapping.folderPath, - this.currentlySyncedMapping.syncedFiles, - ); - } catch (e) { - log.error("error while running next trash", e); - } - } - - private async trashByIDs(toTrashFiles: WatchMapping["syncedFiles"]) { - try { - const files = await getLocalFiles(); - const toTrashFilesMap = new Map(); - for (const file of toTrashFiles) { - toTrashFilesMap.set(file.uploadedFileID, file); - } - const filesToTrash = files.filter((file) => { - if (toTrashFilesMap.has(file.id)) { - const fileToTrash = toTrashFilesMap.get(file.id); - if (fileToTrash.collectionID === file.collectionID) { - return true; - } - } - }); - const groupFilesByCollectionId = - groupFilesBasedOnCollectionID(filesToTrash); - - for (const [ - collectionID, - filesToTrash, - ] of groupFilesByCollectionId.entries()) { - await removeFromCollection(collectionID, filesToTrash); - } - this.syncWithRemote(); - } catch (e) { - log.error("error while trashing by IDs", e); - } - } - - private checkAndIgnoreIfFileEventsFromTrashedDir() { - if (this.trashingDirQueue.length !== 0) { - this.ignoreFileEventsFromTrashedDir(this.trashingDirQueue[0]); - this.trashingDirQueue.shift(); - return true; - } - return false; - } - - private ignoreFileEventsFromTrashedDir(trashingDir: string) { - this.eventQueue = this.eventQueue.filter((event) => - event.paths.every((path) => !path.startsWith(trashingDir)), - ); - } - - async getCollectionNameAndFolderPath(filePath: string) { - try { - const mappings = await this.getWatchMappings(); - - const mapping = mappings.find( - (mapping) => - filePath.length > mapping.folderPath.length && - filePath.startsWith(mapping.folderPath) && - filePath[mapping.folderPath.length] === "/", - ); - - if (!mapping) { - throw Error(`no mapping found`); - } - - return { - collectionName: this.getCollectionNameForMapping( - mapping, - filePath, - ), - folderPath: mapping.folderPath, - }; - } catch (e) { - log.error("error while getting collection name", e); - } - } - - private getCollectionNameForMapping( - mapping: WatchMapping, - filePath: string, - ) { - return mapping.uploadStrategy === UPLOAD_STRATEGY.COLLECTION_PER_FOLDER - ? getParentFolderName(filePath) - : mapping.rootFolderName; - } - - async selectFolder(): Promise { - try { - const folderPath = await ensureElectron().selectDirectory(); - return folderPath; - } catch (e) { - log.error("error while selecting folder", e); - } - } - - // Batches all the files to be uploaded (or trashed) from the - // event queue of same collection as the next event - private clubSameCollectionEvents(): EventQueueItem { - const event = this.eventQueue.shift(); - while ( - this.eventQueue.length > 0 && - event.collectionName === this.eventQueue[0].collectionName && - event.type === this.eventQueue[0].type - ) { - if (event.type === "trash") { - event.paths = [...event.paths, ...this.eventQueue[0].paths]; - } else { - event.files = [...event.files, ...this.eventQueue[0].files]; - } - this.eventQueue.shift(); - } - return event; - } - - async isFolder(folderPath: string) { - try { - const isFolder = await ensureElectron().isFolder(folderPath); - return isFolder; - } catch (e) { - log.error("error while checking if folder exists", e); - } - } - - pauseRunningSync() { - this.isPaused = true; - uploadManager.cancelRunningUpload(); - } - - resumePausedSync() { - this.isPaused = false; - this.getAndSyncDiffOfFiles(); - } -} - -export default new watchFolderService(); diff --git a/web/apps/photos/src/types/machineLearning/index.ts b/web/apps/photos/src/types/machineLearning/index.ts index 7fee94815..2c3961cdf 100644 --- a/web/apps/photos/src/types/machineLearning/index.ts +++ b/web/apps/photos/src/types/machineLearning/index.ts @@ -290,7 +290,7 @@ export interface FaceEmbeddingService { export interface BlurDetectionService { method: Versioned; - detectBlur(alignedFaces: Float32Array): number[]; + detectBlur(alignedFaces: Float32Array, faces: Face[]): number[]; } export interface ClusteringService { diff --git a/web/apps/photos/src/types/upload/index.ts b/web/apps/photos/src/types/upload/index.ts index 0d38f6190..72eef39f6 100644 --- a/web/apps/photos/src/types/upload/index.ts +++ b/web/apps/photos/src/types/upload/index.ts @@ -24,6 +24,11 @@ export function isDataStream(object: any): object is DataStream { export type Logger = (message: string) => void; export interface Metadata { + /** + * The file name. + * + * See: [Note: File name for local EnteFile objects] + */ title: string; creationTime: number; modificationTime: number; @@ -151,13 +156,6 @@ export interface ParsedExtractedMetadata { height: number; } -// This is used to prompt the user the make upload strategy choice -export interface ImportSuggestion { - rootFolderName: string; - hasNestedFolders: boolean; - hasRootLevelFileWithFolder: boolean; -} - export interface PublicUploadProps { token: string; passwordToken: string; diff --git a/web/apps/photos/src/types/watchFolder/index.ts b/web/apps/photos/src/types/watchFolder/index.ts deleted file mode 100644 index bd55704de..000000000 --- a/web/apps/photos/src/types/watchFolder/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { UPLOAD_STRATEGY } from "constants/upload"; -import { ElectronFile } from "types/upload"; - -export interface WatchMappingSyncedFile { - path: string; - uploadedFileID: number; - collectionID: number; -} - -export interface WatchMapping { - rootFolderName: string; - folderPath: string; - uploadStrategy: UPLOAD_STRATEGY; - syncedFiles: WatchMappingSyncedFile[]; - ignoredFiles: string[]; -} - -export interface EventQueueItem { - type: "upload" | "trash"; - folderPath: string; - collectionName?: string; - paths?: string[]; - files?: ElectronFile[]; -} diff --git a/web/apps/photos/src/utils/ffmpeg/index.ts b/web/apps/photos/src/utils/ffmpeg/index.ts index 1b3445976..8a4332a7f 100644 --- a/web/apps/photos/src/utils/ffmpeg/index.ts +++ b/web/apps/photos/src/utils/ffmpeg/index.ts @@ -65,13 +65,3 @@ function parseCreationTime(creationTime: string) { } return dateTime; } - -export function splitFilenameAndExtension(filename: string): [string, string] { - const lastDotPosition = filename.lastIndexOf("."); - if (lastDotPosition === -1) return [filename, null]; - else - return [ - filename.slice(0, lastDotPosition), - filename.slice(lastDotPosition + 1), - ]; -} diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 785921cc9..f65d36bd9 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -1,3 +1,4 @@ +import { decodeLivePhoto } from "@/media/live-photo"; import { convertBytesToHumanReadable } from "@/next/file"; import log from "@/next/log"; import type { Electron } from "@/next/types/ipc"; @@ -32,7 +33,6 @@ import { updateFilePublicMagicMetadata, } from "services/fileService"; import heicConversionService from "services/heicConversionService"; -import { decodeLivePhoto } from "services/livePhotoService"; import { getFileType } from "services/typeDetectionService"; import { updateFileCreationDateInEXIF } from "services/upload/exifService"; import { @@ -97,19 +97,20 @@ export async function downloadFile(file: EnteFile) { await DownloadManager.getFile(file), ).blob(); if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { - const livePhoto = await decodeLivePhoto(file, fileBlob); - const image = new File([livePhoto.image], livePhoto.imageNameTitle); + const { imageFileName, imageData, videoFileName, videoData } = + await decodeLivePhoto(file.metadata.title, fileBlob); + const image = new File([imageData], imageFileName); const imageType = await getFileType(image); const tempImageURL = URL.createObjectURL( - new Blob([livePhoto.image], { type: imageType.mimeType }), + new Blob([imageData], { type: imageType.mimeType }), ); - const video = new File([livePhoto.video], livePhoto.videoNameTitle); + const video = new File([videoData], videoFileName); const videoType = await getFileType(video); const tempVideoURL = URL.createObjectURL( - new Blob([livePhoto.video], { type: videoType.mimeType }), + new Blob([videoData], { type: videoType.mimeType }), ); - downloadUsingAnchor(tempImageURL, livePhoto.imageNameTitle); - downloadUsingAnchor(tempVideoURL, livePhoto.videoNameTitle); + downloadUsingAnchor(tempImageURL, imageFileName); + downloadUsingAnchor(tempVideoURL, videoFileName); } else { const fileType = await getFileType( new File([fileBlob], file.metadata.title), @@ -247,18 +248,6 @@ export async function decryptFile( } } -export function getFileNameWithoutExtension(filename: string) { - const lastDotPosition = filename.lastIndexOf("."); - if (lastDotPosition === -1) return filename; - else return filename.slice(0, lastDotPosition); -} - -export function getFileExtensionWithDot(filename: string) { - const lastDotPosition = filename.lastIndexOf("."); - if (lastDotPosition === -1) return ""; - else return filename.slice(lastDotPosition); -} - export function splitFilenameAndExtension(filename: string): [string, string] { const lastDotPosition = filename.lastIndexOf("."); if (lastDotPosition === -1) return [filename, null]; @@ -355,13 +344,13 @@ async function getRenderableLivePhotoURL( fileBlob: Blob, forceConvert: boolean, ): Promise { - const livePhoto = await decodeLivePhoto(file, fileBlob); + const livePhoto = await decodeLivePhoto(file.metadata.title, fileBlob); const getRenderableLivePhotoImageURL = async () => { try { - const imageBlob = new Blob([livePhoto.image]); + const imageBlob = new Blob([livePhoto.imageData]); const convertedImageBlob = await getRenderableImage( - livePhoto.imageNameTitle, + livePhoto.imageFileName, imageBlob, ); @@ -374,10 +363,9 @@ async function getRenderableLivePhotoURL( const getRenderableLivePhotoVideoURL = async () => { try { - const videoBlob = new Blob([livePhoto.video]); - + const videoBlob = new Blob([livePhoto.videoData]); const convertedVideoBlob = await getPlayableVideo( - livePhoto.videoNameTitle, + livePhoto.videoFileName, videoBlob, forceConvert, true, @@ -813,21 +801,22 @@ async function downloadFileDesktop( if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { const fileBlob = await new Response(updatedStream).blob(); - const livePhoto = await decodeLivePhoto(file, fileBlob); + const { imageFileName, imageData, videoFileName, videoData } = + await decodeLivePhoto(file.metadata.title, fileBlob); const imageExportName = await safeFileName( downloadDir, - livePhoto.imageNameTitle, + imageFileName, fs.exists, ); - const imageStream = generateStreamFromArrayBuffer(livePhoto.image); + const imageStream = generateStreamFromArrayBuffer(imageData); await writeStream(`${downloadDir}/${imageExportName}`, imageStream); try { const videoExportName = await safeFileName( downloadDir, - livePhoto.videoNameTitle, + videoFileName, fs.exists, ); - const videoStream = generateStreamFromArrayBuffer(livePhoto.video); + const videoStream = generateStreamFromArrayBuffer(videoData); await writeStream(`${downloadDir}/${videoExportName}`, videoStream); } catch (e) { await fs.rm(`${downloadDir}/${imageExportName}`); diff --git a/web/apps/photos/src/utils/machineLearning/index.ts b/web/apps/photos/src/utils/machineLearning/index.ts index 2c199981a..a89bccc4c 100644 --- a/web/apps/photos/src/utils/machineLearning/index.ts +++ b/web/apps/photos/src/utils/machineLearning/index.ts @@ -1,9 +1,9 @@ +import { decodeLivePhoto } from "@/media/live-photo"; import log from "@/next/log"; import { FILE_TYPE } from "constants/file"; import PQueue from "p-queue"; import DownloadManager from "services/download"; import { getLocalFiles } from "services/fileService"; -import { decodeLivePhoto } from "services/livePhotoService"; import { EnteFile } from "types/file"; import { Dimensions } from "types/image"; import { @@ -134,11 +134,11 @@ async function getOriginalConvertedFile(file: EnteFile, queue?: PQueue) { if (file.metadata.fileType === FILE_TYPE.IMAGE) { return await getRenderableImage(file.metadata.title, fileBlob); } else { - const livePhoto = await decodeLivePhoto(file, fileBlob); - return await getRenderableImage( - livePhoto.imageNameTitle, - new Blob([livePhoto.image]), + const { imageFileName, imageData } = await decodeLivePhoto( + file.metadata.title, + fileBlob, ); + return await getRenderableImage(imageFileName, new Blob([imageData])); } } diff --git a/web/apps/photos/src/utils/storage/mlIDbStorage.ts b/web/apps/photos/src/utils/storage/mlIDbStorage.ts index 6dccbb89d..40e6dad66 100644 --- a/web/apps/photos/src/utils/storage/mlIDbStorage.ts +++ b/web/apps/photos/src/utils/storage/mlIDbStorage.ts @@ -97,10 +97,8 @@ class MLIDbStorage { wasMLSearchEnabled = searchConfig.enabled; } } catch (e) { - log.info( - "Ignoring likely harmless error while trying to determine ML search status during migration", - e, - ); + // The configs store might not exist (e.g. during logout). + // Ignore. } log.info( `Previous ML database v${oldVersion} had ML search ${wasMLSearchEnabled ? "enabled" : "disabled"}`, diff --git a/web/apps/photos/src/utils/ui/index.tsx b/web/apps/photos/src/utils/ui/index.tsx index 1b01116d3..8f4895ead 100644 --- a/web/apps/photos/src/utils/ui/index.tsx +++ b/web/apps/photos/src/utils/ui/index.tsx @@ -1,5 +1,5 @@ import { ensureElectron } from "@/next/electron"; -import { AppUpdateInfo } from "@/next/types/ipc"; +import { AppUpdate } from "@/next/types/ipc"; import { logoutUser } from "@ente/accounts/services/user"; import { DialogBoxAttributes } from "@ente/shared/components/DialogBox/types"; import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined"; @@ -55,7 +55,7 @@ export const getTrashFileMessage = (deleteFileHelper): DialogBoxAttributes => ({ export const getUpdateReadyToInstallMessage = ({ version, -}: AppUpdateInfo): DialogBoxAttributes => ({ +}: AppUpdate): DialogBoxAttributes => ({ icon: , title: t("UPDATE_AVAILABLE"), content: t("UPDATE_INSTALLABLE_MESSAGE"), @@ -73,7 +73,7 @@ export const getUpdateReadyToInstallMessage = ({ export const getUpdateAvailableForDownloadMessage = ({ version, -}: AppUpdateInfo): DialogBoxAttributes => ({ +}: AppUpdate): DialogBoxAttributes => ({ icon: , title: t("UPDATE_AVAILABLE"), content: t("UPDATE_AVAILABLE_MESSAGE"), diff --git a/web/apps/photos/src/utils/upload/index.ts b/web/apps/photos/src/utils/upload/index.ts index 643c931fe..4e6d216cf 100644 --- a/web/apps/photos/src/utils/upload/index.ts +++ b/web/apps/photos/src/utils/upload/index.ts @@ -1,18 +1,10 @@ +import { basename, dirname } from "@/next/file"; import { FILE_TYPE } from "constants/file"; -import { - A_SEC_IN_MICROSECONDS, - DEFAULT_IMPORT_SUGGESTION, - PICKED_UPLOAD_TYPE, -} from "constants/upload"; +import { A_SEC_IN_MICROSECONDS, PICKED_UPLOAD_TYPE } from "constants/upload"; import isElectron from "is-electron"; import { exportMetadataDirectoryName } from "services/export"; import { EnteFile } from "types/file"; -import { - ElectronFile, - FileWithCollection, - ImportSuggestion, - Metadata, -} from "types/upload"; +import { ElectronFile, FileWithCollection, Metadata } from "types/upload"; const TYPE_JSON = "json"; const DEDUPE_COLLECTION = new Set(["icloud library", "icloudlibrary"]); @@ -110,15 +102,36 @@ export function areFileWithCollectionsSame( return firstFile.localID === secondFile.localID; } +/** + * Return true if all the paths in the given list are items that belong to the + * same (arbitrary) directory. + * + * Empty list of paths is considered to be in the same directory. + */ +export const areAllInSameDirectory = (paths: string[]) => + new Set(paths.map(dirname)).size == 1; + +// This is used to prompt the user the make upload strategy choice +export interface ImportSuggestion { + rootFolderName: string; + hasNestedFolders: boolean; + hasRootLevelFileWithFolder: boolean; +} + +export const DEFAULT_IMPORT_SUGGESTION: ImportSuggestion = { + rootFolderName: "", + hasNestedFolders: false, + hasRootLevelFileWithFolder: false, +}; + export function getImportSuggestion( uploadType: PICKED_UPLOAD_TYPE, - toUploadFiles: File[] | ElectronFile[], + paths: string[], ): ImportSuggestion { if (isElectron() && uploadType === PICKED_UPLOAD_TYPE.FILES) { return DEFAULT_IMPORT_SUGGESTION; } - const paths: string[] = toUploadFiles.map((file) => file["path"]); const getCharCount = (str: string) => (str.match(/\//g) ?? []).length; paths.sort((path1, path2) => getCharCount(path1) - getCharCount(path2)); const firstPath = paths[0]; @@ -209,3 +222,10 @@ export function filterOutSystemFiles(files: File[] | ElectronFile[]) { export function isSystemFile(file: File | ElectronFile) { return file.name.startsWith("."); } + +/** + * Return true if the file at the given {@link path} is hidden. + * + * Hidden files are those whose names begin with a "." (dot). + */ +export const isHiddenFile = (path: string) => basename(path).startsWith("."); diff --git a/web/apps/photos/src/utils/watch/index.ts b/web/apps/photos/src/utils/watch/index.ts deleted file mode 100644 index eb16780dd..000000000 --- a/web/apps/photos/src/utils/watch/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ElectronFile } from "types/upload"; -import { WatchMapping } from "types/watchFolder"; -import { isSystemFile } from "utils/upload"; - -function isSyncedOrIgnoredFile(file: ElectronFile, mapping: WatchMapping) { - return ( - mapping.ignoredFiles.includes(file.path) || - mapping.syncedFiles.find((f) => f.path === file.path) - ); -} - -export function getValidFilesToUpload( - files: ElectronFile[], - mapping: WatchMapping, -) { - const uniqueFilePaths = new Set(); - return files.filter((file) => { - if (!isSystemFile(file) && !isSyncedOrIgnoredFile(file, mapping)) { - if (!uniqueFilePaths.has(file.path)) { - uniqueFilePaths.add(file.path); - return true; - } - } - return false; - }); -} diff --git a/web/apps/photos/tests/zip-file-reading.test.ts b/web/apps/photos/tests/zip-file-reading.test.ts index 6ac20bfee..07d70f067 100644 --- a/web/apps/photos/tests/zip-file-reading.test.ts +++ b/web/apps/photos/tests/zip-file-reading.test.ts @@ -96,7 +96,7 @@ export const testZipWithRootFileReadingTest = async () => { const importSuggestion = getImportSuggestion( PICKED_UPLOAD_TYPE.ZIPS, - files, + files.map((file) => file["path"]), ); if (!importSuggestion.rootFolderName) { throw Error( diff --git a/web/docs/dependencies.md b/web/docs/dependencies.md index d0660bb3e..7dece3a37 100644 --- a/web/docs/dependencies.md +++ b/web/docs/dependencies.md @@ -110,7 +110,7 @@ with Next.js. For more details, see [translations.md](translations.md). -## Meta Frameworks +## Meta frameworks ### Next.js @@ -131,7 +131,12 @@ It is more lower level than Next, but the bells and whistles it doesn't have are the bells and whistles (and the accompanying complexity) that we don't need in some cases. -## Photos +## Media + +- "jszip" is used for reading zip files in JavaScript. Live photos are zip + files under the hood. + +## Photos app specific ### Misc diff --git a/web/packages/media/.eslintrc.js b/web/packages/media/.eslintrc.js new file mode 100644 index 000000000..348075cd4 --- /dev/null +++ b/web/packages/media/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ["@/build-config/eslintrc-next"], +}; diff --git a/web/packages/media/README.md b/web/packages/media/README.md new file mode 100644 index 000000000..70d6424f2 --- /dev/null +++ b/web/packages/media/README.md @@ -0,0 +1,11 @@ +## @/media + +A package for sharing code between our apps that show media (photos, videos). + +Specifically, this is the intersection of code required by both the photos and +cast apps. + +### Packaging + +This (internal) package exports a React TypeScript library. We rely on the +importing project to transpile and bundle it. diff --git a/web/packages/media/live-photo.ts b/web/packages/media/live-photo.ts new file mode 100644 index 000000000..16143ca13 --- /dev/null +++ b/web/packages/media/live-photo.ts @@ -0,0 +1,87 @@ +import { fileNameFromComponents, nameAndExtension } from "@/next/file"; +import JSZip from "jszip"; + +/** + * An in-memory representation of a live photo. + */ +interface LivePhoto { + imageFileName: string; + imageData: Uint8Array; + videoFileName: string; + videoData: Uint8Array; +} + +/** + * Convert a binary serialized representation of a live photo to an in-memory + * {@link LivePhoto}. + * + * A live photo is a zip file containing two files - an image and a video. This + * functions reads that zip file (blob), and return separate bytes (and + * filenames) for the image and video parts. + * + * @param fileName The name of the overall live photo. Both the image and video + * parts of the decompressed live photo use this as their name, combined with + * their original extensions. + * + * @param zipBlob A blob contained the zipped data (i.e. the binary serialized + * live photo). + */ +export const decodeLivePhoto = async ( + fileName: string, + zipBlob: Blob, +): Promise => { + let imageFileName, videoFileName: string | undefined; + let imageData, videoData: Uint8Array | undefined; + + const [name] = nameAndExtension(fileName); + const zip = await JSZip.loadAsync(zipBlob, { createFolders: true }); + + for (const zipFileName in zip.files) { + if (zipFileName.startsWith("image")) { + const [, imageExt] = nameAndExtension(zipFileName); + imageFileName = fileNameFromComponents([name, imageExt]); + imageData = await zip.files[zipFileName]?.async("uint8array"); + } else if (zipFileName.startsWith("video")) { + const [, videoExt] = nameAndExtension(zipFileName); + videoFileName = fileNameFromComponents([name, videoExt]); + videoData = await zip.files[zipFileName]?.async("uint8array"); + } + } + + if (!imageFileName || !imageData) + throw new Error( + `Decoded live photo ${fileName} does not have an image`, + ); + + if (!videoFileName || !videoData) + throw new Error( + `Decoded live photo ${fileName} does not have an image`, + ); + + return { imageFileName, imageData, videoFileName, videoData }; +}; + +/** + * Return a binary serialized representation of a live photo. + * + * This function takes the (in-memory) image and video data from the + * {@link livePhoto} object, writes them to a zip file (using the respective + * filenames), and returns the {@link Uint8Array} that represent the bytes of + * this zip file. + * + * @param livePhoto The in-mem photo to serialized. + */ +export const encodeLivePhoto = async ({ + imageFileName, + imageData, + videoFileName, + videoData, +}: LivePhoto) => { + const [, imageExt] = nameAndExtension(imageFileName); + const [, videoExt] = nameAndExtension(videoFileName); + + const zip = new JSZip(); + zip.file(fileNameFromComponents(["image", imageExt]), imageData); + zip.file(fileNameFromComponents(["video", videoExt]), videoData); + return await zip.generateAsync({ type: "uint8array" }); +}; diff --git a/web/packages/media/package.json b/web/packages/media/package.json new file mode 100644 index 000000000..7ab047317 --- /dev/null +++ b/web/packages/media/package.json @@ -0,0 +1,9 @@ +{ + "name": "@/media", + "version": "0.0.0", + "private": true, + "dependencies": { + "@/next": "*", + "jszip": "^3.10" + } +} diff --git a/web/packages/media/tsconfig.json b/web/packages/media/tsconfig.json new file mode 100644 index 000000000..f29c34811 --- /dev/null +++ b/web/packages/media/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@/build-config/tsconfig-typecheck.json", + /* Typecheck all files with the given extensions (here or in subfolders) */ + "include": ["**/*.ts", "**/*.tsx"] +} diff --git a/web/packages/next/file.ts b/web/packages/next/file.ts index b69fece50..83b20f2ec 100644 --- a/web/packages/next/file.ts +++ b/web/packages/next/file.ts @@ -1,17 +1,69 @@ import type { ElectronFile } from "./types/file"; +/** + * The two parts of a file name - the name itself, and an (optional) extension. + * + * The extension does not include the dot. + */ +type FileNameComponents = [name: string, extension: string | undefined]; + /** * Split a filename into its components - the name itself, and the extension (if * any) - returning both. The dot is not included in either. * * For example, `foo-bar.png` will be split into ["foo-bar", "png"]. + * + * See {@link fileNameFromComponents} for the inverse operation. */ -export const nameAndExtension = ( - fileName: string, -): [string, string | undefined] => { +export const nameAndExtension = (fileName: string): FileNameComponents => { const i = fileName.lastIndexOf("."); + // No extension if (i == -1) return [fileName, undefined]; - else return [fileName.slice(0, i), fileName.slice(i + 1)]; + // A hidden file without an extension, e.g. ".gitignore" + if (i == 0) return [fileName, undefined]; + // Both components present, just omit the dot. + return [fileName.slice(0, i), fileName.slice(i + 1)]; +}; + +/** + * Construct a file name from its components (name and extension). + * + * Inverse of {@link nameAndExtension}. + */ +export const fileNameFromComponents = (components: FileNameComponents) => + components.filter((x) => !!x).join("."); + +/** + * Return the file name portion from the given {@link path}. + * + * This tries to emulate the UNIX `basename` command. In particular, any + * trailing slashes on the path are trimmed, so this function can be used to get + * the name of the directory too. + * + * The path is assumed to use POSIX separators ("/"). + */ +export const basename = (path: string) => { + const pathComponents = path.split("/"); + for (let i = pathComponents.length - 1; i >= 0; i--) + if (pathComponents[i] !== "") return pathComponents[i]; + return path; +}; + +/** + * Return the directory portion from the given {@link path}. + * + * This tries to emulate the UNIX `dirname` command. In particular, any trailing + * slashes on the path are trimmed, so this function can be used to get the path + * leading up to a directory too. + * + * The path is assumed to use POSIX separators ("/"). + */ +export const dirname = (path: string) => { + const pathComponents = path.split("/"); + while (pathComponents.pop() == "") { + /* no-op */ + } + return pathComponents.join("/"); }; export function getFileNameSize(file: File | ElectronFile) { diff --git a/web/packages/next/types/file.ts b/web/packages/next/types/file.ts index e7d3ced5a..dc8a148e9 100644 --- a/web/packages/next/types/file.ts +++ b/web/packages/next/types/file.ts @@ -26,20 +26,6 @@ export interface DataStream { chunkCount: number; } -export interface WatchMappingSyncedFile { - path: string; - uploadedFileID: number; - collectionID: number; -} - -export interface WatchMapping { - rootFolderName: string; - folderPath: string; - uploadStrategy: UPLOAD_STRATEGY; - syncedFiles: WatchMappingSyncedFile[]; - ignoredFiles: string[]; -} - export interface EventQueueItem { type: "upload" | "trash"; folderPath: string; diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index 3477d745e..0628bb0ca 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -3,23 +3,7 @@ // // See [Note: types.ts <-> preload.ts <-> ipc.ts] -import type { ElectronFile, WatchMapping } from "./file"; - -export interface AppUpdateInfo { - autoUpdatable: boolean; - version: string; -} - -export enum FILE_PATH_TYPE { - FILES = "files", - ZIPS = "zips", -} - -export enum PICKED_UPLOAD_TYPE { - FILES = "files", - FOLDERS = "folders", - ZIPS = "zips", -} +import type { ElectronFile } from "./file"; /** * Extra APIs provided by our Node.js layer when our code is running inside our @@ -111,7 +95,7 @@ export interface Electron { * Note: Setting a callback clears any previous callbacks. */ onAppUpdateAvailable: ( - cb?: ((updateInfo: AppUpdateInfo) => void) | undefined, + cb?: ((update: AppUpdate) => void) | undefined, ) => void; /** @@ -199,6 +183,12 @@ export interface Electron { * @param contents The string contents to write. */ writeFile: (path: string, contents: string) => Promise; + + /** + * Return true if there is an item at {@link dirPath}, and it is as + * directory. + */ + isDir: (dirPath: string) => Promise; }; /* @@ -284,51 +274,229 @@ export interface Electron { // - Watch - registerWatcherFunctions: ( - addFile: (file: ElectronFile) => Promise, - removeFile: (path: string) => Promise, - removeFolder: (folderPath: string) => Promise, - ) => void; + /** + * Interface with the file system watcher running in our Node.js layer. + * + * [Note: Folder vs Directory in the context of FolderWatch-es] + * + * A note on terminology: The word "folder" is used to the top level root + * folder for which a {@link FolderWatch} has been added. This folder is + * also in 1-1 correspondence to be a directory on the user's disk. It can + * have other, nested directories too (which may or may not be getting + * mapped to separate Ente collections), but we'll not refer to these nested + * directories as folders - only the root of the tree, which the user + * dragged/dropped or selected to set up the folder watch, will be referred + * to as a folder when naming things. + */ + watch: { + /** + * Return the list of folder watches, pruning non-existing directories. + * + * The list of folder paths (and auxillary details) is persisted in the + * Node.js layer. The implementation of this function goes through the + * list, permanently removes any watches whose on-disk directory is no + * longer present, and returns this pruned list of watches. + */ + get: () => Promise; - addWatchMapping: ( - collectionName: string, - folderPath: string, - uploadStrategy: number, - ) => Promise; + /** + * Add a new folder watch for the given {@link folderPath}. + * + * This adds a new entry in the list of watches (persisting them on + * disk), and also starts immediately observing for file system events + * that happen within {@link folderPath}. + * + * @param collectionMapping Determines how nested directories (if any) + * get mapped to Ente collections. + * + * @returns The updated list of watches. + */ + add: ( + folderPath: string, + collectionMapping: CollectionMapping, + ) => Promise; - removeWatchMapping: (folderPath: string) => Promise; + /** + * Remove the pre-existing watch for the given {@link folderPath}. + * + * Persist this removal, and also stop listening for file system events + * that happen within the {@link folderPath}. + * + * @returns The updated list of watches. + */ + remove: (folderPath: string) => Promise; - getWatchMappings: () => Promise; + /** + * Update the list of synced files for the folder watch associated + * with the given {@link folderPath}. + */ + updateSyncedFiles: ( + syncedFiles: FolderWatch["syncedFiles"], + folderPath: string, + ) => Promise; - updateWatchMappingSyncedFiles: ( - folderPath: string, - files: WatchMapping["syncedFiles"], - ) => Promise; + /** + * Update the list of ignored file paths for the folder watch + * associated with the given {@link folderPath}. + */ + updateIgnoredFiles: ( + ignoredFiles: FolderWatch["ignoredFiles"], + folderPath: string, + ) => Promise; - updateWatchMappingIgnoredFiles: ( - folderPath: string, - files: WatchMapping["ignoredFiles"], - ) => Promise; + /** + * Register the function to invoke when a file is added in one of the + * folders we are watching. + * + * The callback function is passed the path to the file that was added, + * and the folder watch it was associated with. + * + * The path is guaranteed to use POSIX separators ('/'). + */ + onAddFile: (f: (path: string, watch: FolderWatch) => void) => void; - // - FS legacy - isFolder: (dirPath: string) => Promise; + /** + * Register the function to invoke when a file is removed in one of the + * folders we are watching. + * + * The callback function is passed the path to the file that was + * removed, and the folder watch it was associated with. + * + * The path is guaranteed to use POSIX separators ('/'). + */ + onRemoveFile: (f: (path: string, watch: FolderWatch) => void) => void; + + /** + * Register the function to invoke when a directory is removed in one of + * the folders we are watching. + * + * The callback function is passed the path to the directory that was + * removed, and the folder watch it was associated with. + * + * The path is guaranteed to use POSIX separators ('/'). + */ + onRemoveDir: (f: (path: string, watch: FolderWatch) => void) => void; + + /** + * Return the paths of all the files under the given folder. + * + * This function walks the directory tree starting at {@link folderPath} + * and returns a list of the absolute paths of all the files that exist + * therein. It will recursively traverse into nested directories, and + * return the absolute paths of the files there too. + * + * The returned paths are guaranteed to use POSIX separators ('/'). + */ + findFiles: (folderPath: string) => Promise; + }; // - Upload - getPendingUploads: () => Promise<{ - files: ElectronFile[]; - collectionName: string; - type: string; - }>; - setToUploadFiles: ( - /** TODO(MR): This is the actual type */ - // type: FILE_PATH_TYPE, - type: PICKED_UPLOAD_TYPE, + /** + * 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; + + // - + getElectronFilesFromGoogleZip: ( filePath: string, ) => Promise; - setToUploadCollection: (collectionName: string) => Promise; getDirFiles: (dirPath: string) => Promise; } + +/** + * Data passed across the IPC bridge when an app update is available. + */ +export interface AppUpdate { + /** `true` if the user automatically update to this (new) version */ + autoUpdatable: boolean; + /** The new version that is available */ + version: string; +} + +/** + * A top level folder that was selected by the user for watching. + * + * The user can set up multiple such watches. Each of these can in turn be + * syncing multiple on disk folders to one or more Ente collections (depending + * on the value of {@link collectionMapping}). + * + * This type is passed across the IPC boundary. It is persisted on the Node.js + * side. + */ +export interface FolderWatch { + /** + * Specify if nested files should all be mapped to the same single root + * collection, or if there should be a collection per directory that has + * files. @see {@link CollectionMapping}. + */ + collectionMapping: CollectionMapping; + /** + * The path to the (root) folder we are watching. + */ + folderPath: string; + /** + * Files that have already been uploaded. + */ + syncedFiles: FolderWatchSyncedFile[]; + /** + * Files (paths) that should be ignored when uploading. + */ + ignoredFiles: string[]; +} + +/** + * The ways in which directories are mapped to collection. + * + * This comes into play when we have nested directories that we are trying to + * upload or watch on the user's local file system. + */ +export type CollectionMapping = + /** All files go into a single collection named after the root directory. */ + | "root" + /** Each file goes to a collection named after its parent directory. */ + | "parent"; + +/** + * An on-disk file that was synced as part of a folder watch. + */ +export interface FolderWatchSyncedFile { + path: string; + uploadedFileID: number; + collectionID: number; +} + +/** + * 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. + */ +export interface PendingUploads { + /** The collection to which we're uploading */ + collectionName: string; + /* The upload can be either of a Google Takeout zip, or regular files */ + type: "files" | "zips"; + files: ElectronFile[]; +} diff --git a/web/packages/utils/ensure.ts b/web/packages/utils/ensure.ts new file mode 100644 index 000000000..2e8f9a213 --- /dev/null +++ b/web/packages/utils/ensure.ts @@ -0,0 +1,7 @@ +/** + * Throw an exception if the given value is undefined. + */ +export const ensure = (v: T | undefined): T => { + if (v === undefined) throw new Error("Required value was not found"); + return v; +}; diff --git a/web/yarn.lock b/web/yarn.lock index 11cc8b8e1..61d2cfeae 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -3252,7 +3252,7 @@ jssha@~3.3.1: object.assign "^4.1.4" object.values "^1.1.6" -jszip@3.10.1: +jszip@^3.10: version "3.10.1" resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==