diff --git a/.github/workflows/mobile-internal-release.yml b/.github/workflows/mobile-internal-release.yml new file mode 100644 index 000000000..9779a5d7a --- /dev/null +++ b/.github/workflows/mobile-internal-release.yml @@ -0,0 +1,56 @@ +name: "Internal Release - Photos" + +on: + workflow_dispatch: # Allow manually running the action + +env: + FLUTTER_VERSION: "3.19.3" + +jobs: + build: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: mobile + + steps: + - name: Checkout code and submodules + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup JDK 17 + uses: actions/setup-java@v1 + with: + java-version: 17 + + - name: Install Flutter ${{ env.FLUTTER_VERSION }} + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - name: Setup keys + uses: timheuer/base64-to-file@v1 + with: + fileName: "keystore/ente_photos_key.jks" + encodedString: ${{ secrets.SIGNING_KEY_PHOTOS }} + + - name: Build PlayStore AAB + run: | + flutter build appbundle --release --flavor playstore + env: + SIGNING_KEY_PATH: "/home/runner/work/_temp/keystore/ente_photos_key.jks" + SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS_PHOTOS }} + SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD_PHOTOS }} + SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD_PHOTOS }} + + - name: Upload AAB to PlayStore + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} + packageName: io.ente.photos + releaseFiles: mobile/build/app/outputs/bundle/playstoreRelease/app-playstore-release.aab + track: internal diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index 0f45df751..6211f2c26 100644 --- a/.github/workflows/mobile-release.yml +++ b/.github/workflows/mobile-release.yml @@ -9,7 +9,7 @@ on: - "photos-v*" env: - FLUTTER_VERSION: "3.19.5" + FLUTTER_VERSION: "3.19.3" jobs: build: @@ -25,6 +25,11 @@ jobs: with: submodules: recursive + - name: Setup JDK 17 + uses: actions/setup-java@v1 + with: + java-version: 17 + - name: Install Flutter ${{ env.FLUTTER_VERSION }} uses: subosito/flutter-action@v2 with: diff --git a/auth/lib/l10n/arb/app_de.arb b/auth/lib/l10n/arb/app_de.arb index a7cd56043..be769ecd5 100644 --- a/auth/lib/l10n/arb/app_de.arb +++ b/auth/lib/l10n/arb/app_de.arb @@ -78,12 +78,14 @@ "data": "Datei", "importCodes": "Codes importieren", "importTypePlainText": "Klartext", + "importTypeEnteEncrypted": "Verschlüsselter Ente-Export", "passwordForDecryptingExport": "Passwort um den Export zu entschlüsseln", "passwordEmptyError": "Passwort kann nicht leer sein", "importFromApp": "Importiere Codes von {appName}", "importGoogleAuthGuide": "Exportiere deine Accounts von Google Authenticator zu einem QR-Code, durch die \"Konten übertragen\" Option. Scanne den QR-Code danach mit einem anderen Gerät.\n\nTipp: Du kannst die Kamera eines Laptops verwenden, um ein Foto den dem QR-Code zu erstellen.", "importSelectJsonFile": "Wähle eine JSON-Datei", "importSelectAppExport": "{appName} Exportdatei auswählen", + "importEnteEncGuide": "Wähle die von Ente exportierte, verschlüsselte JSON-Datei", "importRaivoGuide": "Verwenden Sie die Option \"Export OTPs to Zip archive\" in den Raivo-Einstellungen.\n\nEntpacken Sie die Zip-Datei und importieren Sie die JSON-Datei.", "importBitwardenGuide": "Verwenden Sie die Option \"Tresor exportieren\" innerhalb der Bitwarden Tools und importieren Sie die unverschlüsselte JSON-Datei.", "importAegisGuide": "Verwenden Sie die Option \"Tresor exportieren\" in den Aegis-Einstellungen.\n\nFalls Ihr Tresor verschlüsselt ist, müssen Sie das Passwort für den Tresor eingeben, um ihn zu entschlüsseln.", @@ -113,18 +115,22 @@ "copied": "Kopiert", "pleaseTryAgain": "Bitte versuchen Sie es erneut", "existingUser": "Bestehender Benutzer", + "newUser": "Neu bei Ente", "delete": "Löschen", "enterYourPasswordHint": "Geben Sie Ihr Passwort ein", "forgotPassword": "Passwort vergessen", "oops": "Hopla", "suggestFeatures": "Features vorschlagen", "faq": "FAQ", + "faq_q_1": "Wie sicher ist Auth?", + "faq_a_1": "Alle Codes, die du über Auth sicherst, werden Ende-zu-Ende-verschlüsselt gespeichert. Das bedeutet, dass nur du auf deine Codes zugreifen kannst. Unsere Anwendungen sind quelloffen und unsere Kryptografie wurde extern geprüft.", "faq_q_2": "Kann ich auf meine Codes auf dem Desktop zugreifen?", "faq_a_2": "Sie können auf Ihre Codes im Web via auth.ente.io zugreifen.", "faq_q_3": "Wie kann ich Codes löschen?", "faq_a_3": "Sie können einen Code löschen, indem Sie auf dem Code nach links wischen.", "faq_q_4": "Wie kann ich das Projekt unterstützen?", "faq_a_4": "Sie können die Entwicklung dieses Projekts unterstützen, indem Sie unsere Fotos-App auf ente.io abonnieren.", + "faq_q_5": "Wie kann ich die FaceID-Sperre in Auth aktivieren", "faq_a_5": "Sie können FaceID unter Einstellungen → Sicherheit → Sperrbildschirm aktivieren.", "somethingWentWrongMessage": "Ein Fehler ist aufgetreten, bitte versuchen Sie es erneut", "leaveFamily": "Familie verlassen", @@ -193,6 +199,10 @@ "recoveryKeySaveDescription": "Wir speichern diesen Schlüssel nicht. Sichern sie dieses diesen Schlüssel bestehend aus 24 Wörtern an einem sicheren Platz.", "doThisLater": "Auf später verschieben", "saveKey": "Schlüssel speichern", + "save": "Speichern", + "send": "Senden", + "saveOrSendDescription": "Möchtest du dies in deinem Speicher (standardmäßig im Ordner Downloads) oder an andere Apps senden?", + "saveOnlyDescription": "Möchtest du dies in deinem Speicher (standardmäßig im Ordner Downloads) speichern?", "back": "Zurück", "createAccount": "Account erstellen", "passwordStrength": "Passwortstärke: {passwordStrengthValue}", @@ -340,6 +350,7 @@ "deleteCodeAuthMessage": "Authentifizieren, um Code zu löschen", "showQRAuthMessage": "Authentifizieren, um QR-Code anzuzeigen", "confirmAccountDeleteTitle": "Kontolöschung bestätigen", + "confirmAccountDeleteMessage": "Dieses Konto ist mit anderen Ente-Apps verknüpft, falls du welche verwendest.\n\nDeine hochgeladenen Daten werden in allen Ente-Apps zur Löschung vorgemerkt und dein Konto wird endgültig gelöscht.", "androidBiometricHint": "Identität bestätigen", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -400,6 +411,7 @@ "doNotSignOut": "Nicht abmelden", "hearUsWhereTitle": "Wie hast du von Ente erfahren? (optional)", "hearUsExplanation": "Wir tracken keine App-Installationen. Es würde uns jedoch helfen, wenn du uns mitteilst, wie du von uns erfahren hast!", + "recoveryKeySaved": "Wiederherstellungsschlüssel im Downloads-Ordner gespeichert!", "waitingForBrowserRequest": "Warten auf Browseranfrage...", "waitingForVerification": "Warte auf Bestätigung...", "passkey": "Passkey", diff --git a/auth/lib/l10n/arb/app_it.arb b/auth/lib/l10n/arb/app_it.arb index 473b3a2b3..e35fd11dc 100644 --- a/auth/lib/l10n/arb/app_it.arb +++ b/auth/lib/l10n/arb/app_it.arb @@ -190,6 +190,7 @@ "recoveryKeySaveDescription": "Non memorizziamo questa chiave, per favore salva questa chiave di 24 parole in un posto sicuro.", "doThisLater": "Fallo più tardi", "saveKey": "Salva chiave", + "save": "Salva", "back": "Indietro", "createAccount": "Crea account", "passwordStrength": "Forza password: {passwordStrengthValue}", @@ -396,5 +397,6 @@ "signOutOtherDevices": "Esci dagli altri dispositivi", "doNotSignOut": "Non uscire", "hearUsWhereTitle": "Dove hai sentito parlare di Ente? (opzionale)", - "hearUsExplanation": "Non teniamo traccia delle installazioni dell'app. Sarebbe utile se ci dicessi dove ci hai trovato!" + "hearUsExplanation": "Non teniamo traccia delle installazioni dell'app. Sarebbe utile se ci dicessi dove ci hai trovato!", + "passkey": "Passkey" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_pt.arb b/auth/lib/l10n/arb/app_pt.arb index 06e2c0bb4..b27a018fb 100644 --- a/auth/lib/l10n/arb/app_pt.arb +++ b/auth/lib/l10n/arb/app_pt.arb @@ -78,12 +78,14 @@ "data": "Dados", "importCodes": "Importar códigos", "importTypePlainText": "Texto simples", + "importTypeEnteEncrypted": "Exportação Ente criptografada", "passwordForDecryptingExport": "Senha para descriptografar a exportação", "passwordEmptyError": "O campo senha não pode estar vazio", "importFromApp": "Importar códigos do {appName}", "importGoogleAuthGuide": "Exporte suas contas do Google Authenticator para um QR code usando a opção \"Transferir contas\". Então, usando outro dispositivo, escaneie o QR code.\n\nDica: Você pode usar a câmera do seu notebook para fotografar o QR code.", "importSelectJsonFile": "Selecione o arquivo JSON", "importSelectAppExport": "Selecione o arquivo de exportação do aplicativo {appName}", + "importEnteEncGuide": "Selecione o arquivo JSON criptografado exportado do Ente", "importRaivoGuide": "Use a opção \"Exportar OTPs para arquivo Zip\" nas configurações do Raivo.\n\nExtraia o arquivo zip e importe o arquivo JSON.", "importBitwardenGuide": "Use a opção \"Exportar cofre\" nas configurações do Bitwarden e importe o arquivo JSON não criptografado.", "importAegisGuide": "Use a opção \"Exportar cofre\" nas Configurações do Aegis.\n\nSe o seu cofre estiver criptografado, você precisará inserir a senha do cofre para descriptografá-lo.", @@ -113,18 +115,22 @@ "copied": "Copiado", "pleaseTryAgain": "Por favor, tente novamente", "existingUser": "Usuário Existente", + "newUser": "Novo no Ente", "delete": "Excluir", "enterYourPasswordHint": "Insira sua senha", "forgotPassword": "Esqueci a senha", "oops": "Oops", "suggestFeatures": "Sugerir funcionalidades", "faq": "Perguntas frequentes", + "faq_q_1": "Quão seguro é o Auth?", + "faq_a_1": "Todos os códigos que você faz backup via Auth são armazenados criptografados de ponta a ponta. Isso significa que somente você pode acessar seus códigos. Nossos aplicativos são de código aberto e nossa criptografia foi auditada externamente.", "faq_q_2": "Eu posso acessar meus códigos no computador?", "faq_a_2": "Você pode acessar seus códigos na web em auth.ente.io.", "faq_q_3": "Como faço para excluir códigos?", "faq_a_3": "Você pode excluir um código deslizando para a esquerda sobre esse item.", "faq_q_4": "Como posso apoiar este projeto?", "faq_a_4": "Você pode apoiar o desenvolvimento deste projeto assinando nosso aplicativo de Fotos em ente.io.", + "faq_q_5": "Como posso ativar o bloqueio facial no Auth", "faq_a_5": "Você pode ativar o bloqueio facial em Configurações → Segurança → Tela de bloqueio.", "somethingWentWrongMessage": "Algo deu errado. Por favor, tente outra vez", "leaveFamily": "Sair da família", @@ -344,6 +350,7 @@ "deleteCodeAuthMessage": "Autenticar para excluir o código", "showQRAuthMessage": "Autenticar para mostrar o QR code", "confirmAccountDeleteTitle": "Confirmar exclusão de conta", + "confirmAccountDeleteMessage": "Esta conta está vinculada a outros aplicativos Ente, se você usa algum.\n\nSeus dados enviados, em todos os aplicativos Ente, serão agendados para exclusão, e sua conta será excluída permanentemente.", "androidBiometricHint": "Verificar identidade", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." diff --git a/auth/lib/l10n/arb/app_sv.arb b/auth/lib/l10n/arb/app_sv.arb index d1dc39e05..cfb41d7bd 100644 --- a/auth/lib/l10n/arb/app_sv.arb +++ b/auth/lib/l10n/arb/app_sv.arb @@ -61,6 +61,7 @@ "incorrectPasswordTitle": "Felaktigt lösenord", "welcomeBack": "Välkommen tillbaka!", "changePassword": "Ändra lösenord", + "importCodes": "Importera koder", "cancel": "Avbryt", "yes": "Ja", "no": "Nej", diff --git a/desktop/docs/dependencies.md b/desktop/docs/dependencies.md index 62f70e8e4..b159b13eb 100644 --- a/desktop/docs/dependencies.md +++ b/desktop/docs/dependencies.md @@ -94,12 +94,12 @@ Some extra ones specific to the code here are: ### Format conversion -The main tool we use is for arbitrary conversions is FFMPEG. To bundle a +The main tool we use is for arbitrary conversions is ffmpeg. To bundle a (platform specific) static binary of ffmpeg with our app, we use [ffmpeg-static](https://github.com/eugeneware/ffmpeg-static). > There is a significant (~20x) speed difference between using the compiled -> FFMPEG binary and using the WASM one (that our renderer process already has). +> ffmpeg binary and using the wasm one (that our renderer process already has). > Which is why we bundle it to speed up operations on the desktop app. In addition, we also bundle a static Linux binary of imagemagick in our extra diff --git a/desktop/package.json b/desktop/package.json index 032953d8d..69d54f75b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -44,8 +44,8 @@ "electron-builder-notarize": "^1.5", "eslint": "^8", "prettier": "^3", - "prettier-plugin-organize-imports": "^3.2", - "prettier-plugin-packagejson": "^2.4", + "prettier-plugin-organize-imports": "^3", + "prettier-plugin-packagejson": "^2", "shx": "^0.3", "typescript": "^5" }, diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 8526e2363..a8a8a5610 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -24,11 +24,12 @@ 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"; +import { isDev } from "./main/utils-electron"; /** * The URL where the renderer HTML is being served from. @@ -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(); @@ -206,6 +205,8 @@ const createMainWindow = async () => { window.webContents.reload(); }); + // "The unresponsive event is fired when Chromium detects that your + // webContents is not responding to input messages for > 30 seconds." window.webContents.on("unresponsive", () => { log.error( "Main window's webContents are unresponsive, will restart the renderer process", @@ -296,6 +297,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 +327,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 +342,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/init.ts b/desktop/src/main/init.ts index d3e9b28b4..1b078dc98 100644 --- a/desktop/src/main/init.ts +++ b/desktop/src/main/init.ts @@ -11,18 +11,7 @@ export function handleDownloads(mainWindow: BrowserWindow) { }); } -export function handleExternalLinks(mainWindow: BrowserWindow) { - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - if (!url.startsWith(rendererURL)) { - shell.openExternal(url); - return { action: "deny" }; - } else { - return { action: "allow" }; - } - }); -} - -export function getUniqueSavePath(filename: string, directory: string): string { +function getUniqueSavePath(filename: string, directory: string): string { let uniqueFileSavePath = path.join(directory, filename); const { name: filenameWithoutExtension, ext: extension } = path.parse(filename); @@ -42,12 +31,15 @@ export function getUniqueSavePath(filename: string, directory: string): string { return uniqueFileSavePath; } -function lowerCaseHeaders(responseHeaders: Record) { - const headers: Record = {}; - for (const key of Object.keys(responseHeaders)) { - headers[key.toLowerCase()] = responseHeaders[key]; - } - return headers; +export function handleExternalLinks(mainWindow: BrowserWindow) { + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (!url.startsWith(rendererURL)) { + shell.openExternal(url); + return { action: "deny" }; + } else { + return { action: "allow" }; + } + }); } export function addAllowOriginHeader(mainWindow: BrowserWindow) { @@ -61,3 +53,11 @@ export function addAllowOriginHeader(mainWindow: BrowserWindow) { }, ); } + +function lowerCaseHeaders(responseHeaders: Record) { + const headers: Record = {}; + for (const key of Object.keys(responseHeaders)) { + headers[key.toLowerCase()] = responseHeaders[key]; + } + return headers; +} diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index bd29057da..9ea4d802f 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -10,7 +10,11 @@ import type { FSWatcher } from "chokidar"; import { ipcMain } from "electron/main"; -import type { ElectronFile, FILE_PATH_TYPE, WatchMapping } from "../types/ipc"; +import type { + CollectionMapping, + FolderWatch, + PendingUploads, +} from "../types/ipc"; import { selectDirectory, showUploadDirsDialog, @@ -19,13 +23,13 @@ import { } from "./dialogs"; import { fsExists, + fsIsDir, fsMkdirIfNeeded, fsReadTextFile, fsRename, fsRm, fsRmdir, fsWriteFile, - isFolder, } from "./fs"; import { logToDisk } from "./log"; import { @@ -34,13 +38,13 @@ import { updateAndRestart, updateOnNextRestart, } from "./services/app-update"; -import { runFFmpegCmd } from "./services/ffmpeg"; +import { convertToJPEG, generateImageThumbnail } from "./services/convert"; +import { ffmpegExec } from "./services/ffmpeg"; import { getDirFiles } from "./services/fs"; import { - convertToJPEG, - generateImageThumbnail, -} from "./services/imageProcessor"; -import { clipImageEmbedding, clipTextEmbedding } from "./services/ml-clip"; + clipImageEmbedding, + clipTextEmbeddingIfAvailable, +} from "./services/ml-clip"; import { detectFaces, faceEmbedding } from "./services/ml-face"; import { clearStores, @@ -49,18 +53,19 @@ 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"; +import { openDirectory, openLogDirectory } from "./utils-electron"; /** * Listen for IPC events sent/invoked by the renderer process, and route them to @@ -132,10 +137,12 @@ export const attachIPCHandlers = () => { fsWriteFile(path, contents), ); + ipcMain.handle("fsIsDir", (_, dirPath: string) => fsIsDir(dirPath)); + // - Conversion - ipcMain.handle("convertToJPEG", (_, fileData, filename) => - convertToJPEG(fileData, filename), + ipcMain.handle("convertToJPEG", (_, fileName, imageData) => + convertToJPEG(fileName, imageData), ); ipcMain.handle( @@ -145,14 +152,14 @@ export const attachIPCHandlers = () => { ); ipcMain.handle( - "runFFmpegCmd", + "ffmpegExec", ( _, - cmd: string[], - inputFile: File | ElectronFile, + command: string[], + inputDataOrPath: Uint8Array | string, outputFileName: string, - dontTimeout?: boolean, - ) => runFFmpegCmd(cmd, inputFile, outputFileName, dontTimeout), + timeoutMS: number, + ) => ffmpegExec(command, inputDataOrPath, outputFileName, timeoutMS), ); // - ML @@ -161,8 +168,8 @@ export const attachIPCHandlers = () => { clipImageEmbedding(jpegImageData), ); - ipcMain.handle("clipTextEmbedding", (_, text: string) => - clipTextEmbedding(text), + ipcMain.handle("clipTextEmbeddingIfAvailable", (_, text: string) => + clipTextEmbeddingIfAvailable(text), ); ipcMain.handle("detectFaces", (_, input: Float32Array) => @@ -183,28 +190,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 +218,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/log.ts b/desktop/src/main/log.ts index d43161fea..22ebb5300 100644 --- a/desktop/src/main/log.ts +++ b/desktop/src/main/log.ts @@ -1,6 +1,6 @@ import log from "electron-log"; import util from "node:util"; -import { isDev } from "./util"; +import { isDev } from "./utils-electron"; /** * Initialize logging in the main process. diff --git a/desktop/src/main/menu.ts b/desktop/src/main/menu.ts index 3441f3f2a..12b1ee17d 100644 --- a/desktop/src/main/menu.ts +++ b/desktop/src/main/menu.ts @@ -7,9 +7,9 @@ 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"; +import { isDev, openLogDirectory } from "./utils-electron"; /** Create and return the entries in the app's main menu bar */ export const createApplicationMenu = async (mainWindow: BrowserWindow) => { @@ -23,6 +23,9 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => { const macOSOnly = (options: MenuItemConstructorOptions[]) => process.platform == "darwin" ? options : []; + const devOnly = (options: MenuItemConstructorOptions[]) => + isDev ? options : []; + const handleCheckForUpdates = () => forceCheckForAppUpdates(mainWindow); const handleViewChangelog = () => @@ -139,7 +142,9 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => { label: "View", submenu: [ { label: "Reload", role: "reload" }, - { label: "Toggle Dev Tools", role: "toggleDevTools" }, + ...devOnly([ + { label: "Toggle Dev Tools", role: "toggleDevTools" }, + ]), { type: "separator" }, { label: "Toggle Full Screen", role: "togglefullscreen" }, ], 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..e20d42fb7 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,23 +52,23 @@ 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(); - let timeout: NodeJS.Timeout; + let timeoutId: ReturnType; const fiveMinutes = 5 * 60 * 1000; autoUpdater.on("update-downloaded", () => { - timeout = setTimeout( + timeoutId = setTimeout( () => showUpdateDialog({ autoUpdatable: true, version }), fiveMinutes, ); }); autoUpdater.on("error", (error) => { - clearTimeout(timeout); + clearTimeout(timeoutId); log.error("Auto update failed", error); showUpdateDialog({ autoUpdatable: false, version }); }); 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/imageProcessor.ts b/desktop/src/main/services/convert.ts similarity index 60% rename from desktop/src/main/services/imageProcessor.ts rename to desktop/src/main/services/convert.ts index d87fb0c5f..7f38a86ea 100644 --- a/desktop/src/main/services/imageProcessor.ts +++ b/desktop/src/main/services/convert.ts @@ -1,13 +1,69 @@ +/** @file Image conversions */ + import { existsSync } from "fs"; import fs from "node:fs/promises"; import path from "path"; -import { CustomErrors, ElectronFile } from "../../types/ipc"; -import { writeStream } from "../stream"; +import { CustomErrorMessage, ElectronFile } from "../../types/ipc"; import log from "../log"; -import { isPlatform } from "../platform"; -import { generateTempFilePath } from "../temp"; -import { execAsync, isDev } from "../util"; -import { deleteTempFile } from "./ffmpeg"; +import { writeStream } from "../stream"; +import { execAsync, isDev } from "../utils-electron"; +import { deleteTempFile, makeTempFilePath } from "../utils-temp"; + +export const convertToJPEG = async ( + fileName: string, + imageData: Uint8Array, +): Promise => { + const inputFilePath = await makeTempFilePath(fileName); + const outputFilePath = await makeTempFilePath("output.jpeg"); + + // Construct the command first, it may throw on NotAvailable on win32. + const command = convertToJPEGCommand(inputFilePath, outputFilePath); + + try { + await fs.writeFile(inputFilePath, imageData); + await execAsync(command); + return new Uint8Array(await fs.readFile(outputFilePath)); + } finally { + try { + deleteTempFile(outputFilePath); + deleteTempFile(inputFilePath); + } catch (e) { + log.error("Ignoring error when cleaning up temp files", e); + } + } +}; + +const convertToJPEGCommand = ( + inputFilePath: string, + outputFilePath: string, +) => { + switch (process.platform) { + case "darwin": + return [ + "sips", + "-s", + "format", + "jpeg", + inputFilePath, + "--out", + outputFilePath, + ]; + case "linux": + return [ + imageMagickPath(), + inputFilePath, + "-quality", + "100%", + outputFilePath, + ]; + default: // "win32" + throw new Error(CustomErrorMessage.NotAvailable); + } +}; + +/** Path to the Linux image-magick executable bundled with our app */ +const imageMagickPath = () => + path.join(isDev ? "build" : process.resourcesPath, "image-magick"); const IMAGE_MAGICK_PLACEHOLDER = "IMAGE_MAGICK"; const MAX_DIMENSION_PLACEHOLDER = "MAX_DIMENSION"; @@ -19,16 +75,6 @@ const QUALITY_PLACEHOLDER = "QUALITY"; const MAX_QUALITY = 70; const MIN_QUALITY = 50; -const SIPS_HEIC_CONVERT_COMMAND_TEMPLATE = [ - "sips", - "-s", - "format", - "jpeg", - INPUT_PATH_PLACEHOLDER, - "--out", - OUTPUT_PATH_PLACEHOLDER, -]; - const SIPS_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [ "sips", "-s", @@ -44,14 +90,6 @@ const SIPS_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [ OUTPUT_PATH_PLACEHOLDER, ]; -const IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE = [ - IMAGE_MAGICK_PLACEHOLDER, - INPUT_PATH_PLACEHOLDER, - "-quality", - "100%", - OUTPUT_PATH_PLACEHOLDER, -]; - const IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [ IMAGE_MAGICK_PLACEHOLDER, INPUT_PATH_PLACEHOLDER, @@ -67,96 +105,6 @@ const IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [ OUTPUT_PATH_PLACEHOLDER, ]; -function getImageMagickStaticPath() { - return isDev - ? "resources/image-magick" - : path.join(process.resourcesPath, "image-magick"); -} - -export async function convertToJPEG( - fileData: Uint8Array, - filename: string, -): Promise { - if (isPlatform("windows")) { - throw Error(CustomErrors.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED); - } - const convertedFileData = await convertToJPEG_(fileData, filename); - return convertedFileData; -} - -async function convertToJPEG_( - fileData: Uint8Array, - filename: string, -): Promise { - let tempInputFilePath: string; - let tempOutputFilePath: string; - try { - tempInputFilePath = await generateTempFilePath(filename); - tempOutputFilePath = await generateTempFilePath("output.jpeg"); - - await fs.writeFile(tempInputFilePath, fileData); - - await execAsync( - constructConvertCommand(tempInputFilePath, tempOutputFilePath), - ); - - return new Uint8Array(await fs.readFile(tempOutputFilePath)); - } catch (e) { - log.error("Failed to convert HEIC", e); - throw e; - } finally { - try { - await fs.rm(tempInputFilePath, { force: true }); - } catch (e) { - log.error(`Failed to remove tempInputFile ${tempInputFilePath}`, e); - } - try { - await fs.rm(tempOutputFilePath, { force: true }); - } catch (e) { - log.error( - `Failed to remove tempOutputFile ${tempOutputFilePath}`, - e, - ); - } - } -} - -function constructConvertCommand( - tempInputFilePath: string, - tempOutputFilePath: string, -) { - let convertCmd: string[]; - if (isPlatform("mac")) { - convertCmd = SIPS_HEIC_CONVERT_COMMAND_TEMPLATE.map((cmdPart) => { - if (cmdPart === INPUT_PATH_PLACEHOLDER) { - return tempInputFilePath; - } - if (cmdPart === OUTPUT_PATH_PLACEHOLDER) { - return tempOutputFilePath; - } - return cmdPart; - }); - } else if (isPlatform("linux")) { - convertCmd = IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE.map( - (cmdPart) => { - if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) { - return getImageMagickStaticPath(); - } - if (cmdPart === INPUT_PATH_PLACEHOLDER) { - return tempInputFilePath; - } - if (cmdPart === OUTPUT_PATH_PLACEHOLDER) { - return tempOutputFilePath; - } - return cmdPart; - }, - ); - } else { - throw new Error(`Unsupported OS ${process.platform}`); - } - return convertCmd; -} - export async function generateImageThumbnail( inputFile: File | ElectronFile, maxDimension: number, @@ -165,13 +113,12 @@ 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); + const tempFilePath = await makeTempFilePath(inputFile.name); await writeStream(tempFilePath, await inputFile.stream()); inputFilePath = tempFilePath; createdTempInputFile = true; @@ -203,7 +150,7 @@ async function generateImageThumbnail_( let tempOutputFilePath: string; let quality = MAX_QUALITY; try { - tempOutputFilePath = await generateTempFilePath("thumb.jpeg"); + tempOutputFilePath = await makeTempFilePath("thumb.jpeg"); let thumbnail: Uint8Array; do { await execAsync( @@ -240,7 +187,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 +205,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 imageMagickPath(); } if (cmdPart === INPUT_PATH_PLACEHOLDER) { return inputFilePath; diff --git a/desktop/src/main/services/ffmpeg.ts b/desktop/src/main/services/ffmpeg.ts index 3072d5ee7..c49ac6700 100644 --- a/desktop/src/main/services/ffmpeg.ts +++ b/desktop/src/main/services/ffmpeg.ts @@ -1,22 +1,19 @@ import pathToFfmpeg from "ffmpeg-static"; -import { existsSync } from "node:fs"; import fs from "node:fs/promises"; -import { ElectronFile } from "../../types/ipc"; -import { writeStream } from "../stream"; -import log from "../log"; -import { generateTempFilePath, getTempDirPath } from "../temp"; -import { execAsync } from "../util"; +import { withTimeout } from "../utils"; +import { execAsync } from "../utils-electron"; +import { deleteTempFile, makeTempFilePath } from "../utils-temp"; -const INPUT_PATH_PLACEHOLDER = "INPUT"; -const FFMPEG_PLACEHOLDER = "FFMPEG"; -const OUTPUT_PATH_PLACEHOLDER = "OUTPUT"; +const ffmpegPathPlaceholder = "FFMPEG"; +const inputPathPlaceholder = "INPUT"; +const outputPathPlaceholder = "OUTPUT"; /** * Run a ffmpeg command * - * [Note: FFMPEG in Electron] + * [Note: ffmpeg in Electron] * - * There is a wasm build of FFMPEG, but that is currently 10-20 times slower + * There is a wasm build of ffmpeg, but that is currently 10-20 times slower * that the native build. That is slow enough to be unusable for our purposes. * https://ffmpegwasm.netlify.app/docs/performance * @@ -36,79 +33,65 @@ const OUTPUT_PATH_PLACEHOLDER = "OUTPUT"; * $ file ente.app/Contents/Frameworks/Electron\ Framework.framework/Versions/Current/Libraries/libffmpeg.dylib * .../libffmpeg.dylib: Mach-O 64-bit dynamically linked shared library arm64 * - * I'm not sure if our code is supposed to be able to use it, and how. + * But I'm not sure if our code is supposed to be able to use it, and how. */ -export async function runFFmpegCmd( - cmd: string[], - inputFile: File | ElectronFile, +export const ffmpegExec = async ( + command: string[], + inputDataOrPath: Uint8Array | string, outputFileName: string, - dontTimeout?: boolean, -) { - let inputFilePath = null; - let createdTempInputFile = null; + timeoutMS: number, +): Promise => { + // TODO (MR): This currently copies files for both input and output. This + // needs to be tested extremely large video files when invoked downstream of + // `convertToMP4` in the web code. + + let inputFilePath: string; + let isInputFileTemporary: boolean; + if (typeof inputDataOrPath == "string") { + inputFilePath = inputDataOrPath; + isInputFileTemporary = false; + } else { + inputFilePath = await makeTempFilePath("input" /* arbitrary */); + isInputFileTemporary = true; + await fs.writeFile(inputFilePath, inputDataOrPath); + } + + let outputFilePath: string | undefined; try { - if (!existsSync(inputFile.path)) { - const tempFilePath = await generateTempFilePath(inputFile.name); - await writeStream(tempFilePath, await inputFile.stream()); - inputFilePath = tempFilePath; - createdTempInputFile = true; - } else { - inputFilePath = inputFile.path; - } - const outputFileData = await runFFmpegCmd_( - cmd, + outputFilePath = await makeTempFilePath(outputFileName); + + const cmd = substitutePlaceholders( + command, inputFilePath, - outputFileName, - dontTimeout, + outputFilePath, ); - return new File([outputFileData], outputFileName); - } finally { - if (createdTempInputFile) { - await deleteTempFile(inputFilePath); - } - } -} -export async function runFFmpegCmd_( - cmd: string[], + if (timeoutMS) await withTimeout(execAsync(cmd), 30 * 1000); + else await execAsync(cmd); + + return fs.readFile(outputFilePath); + } finally { + if (isInputFileTemporary) await deleteTempFile(inputFilePath); + if (outputFilePath) await deleteTempFile(outputFilePath); + } +}; + +const substitutePlaceholders = ( + command: string[], inputFilePath: string, - outputFileName: string, - dontTimeout = false, -) { - let tempOutputFilePath: string; - try { - tempOutputFilePath = await generateTempFilePath(outputFileName); - - cmd = cmd.map((cmdPart) => { - if (cmdPart === FFMPEG_PLACEHOLDER) { - return ffmpegBinaryPath(); - } else if (cmdPart === INPUT_PATH_PLACEHOLDER) { - return inputFilePath; - } else if (cmdPart === OUTPUT_PATH_PLACEHOLDER) { - return tempOutputFilePath; - } else { - return cmdPart; - } - }); - - if (dontTimeout) { - await execAsync(cmd); + outputFilePath: string, +) => + command.map((segment) => { + if (segment == ffmpegPathPlaceholder) { + return ffmpegBinaryPath(); + } else if (segment == inputPathPlaceholder) { + return inputFilePath; + } else if (segment == outputPathPlaceholder) { + return outputFilePath; } else { - await promiseWithTimeout(execAsync(cmd), 30 * 1000); + return segment; } - - if (!existsSync(tempOutputFilePath)) { - throw new Error("ffmpeg output file not found"); - } - const outputFile = await fs.readFile(tempOutputFilePath); - return new Uint8Array(outputFile); - } catch (e) { - log.error("FFMPEG command failed", e); - throw e; - } finally { - await deleteTempFile(tempOutputFilePath); - } -} + }); /** * Return the path to the `ffmpeg` binary. @@ -122,40 +105,3 @@ const ffmpegBinaryPath = () => { // https://github.com/eugeneware/ffmpeg-static/issues/16 return pathToFfmpeg.replace("app.asar", "app.asar.unpacked"); }; - -export async function writeTempFile(fileStream: Uint8Array, fileName: string) { - const tempFilePath = await generateTempFilePath(fileName); - await fs.writeFile(tempFilePath, fileStream); - return tempFilePath; -} - -export async function deleteTempFile(tempFilePath: string) { - const tempDirPath = await getTempDirPath(); - if (!tempFilePath.startsWith(tempDirPath)) - log.error("Attempting to delete a non-temp file ${tempFilePath}"); - await fs.rm(tempFilePath, { force: true }); -} - -const promiseWithTimeout = async ( - request: Promise, - timeout: number, -): Promise => { - const timeoutRef: { - current: NodeJS.Timeout; - } = { current: null }; - const rejectOnTimeout = new Promise((_, reject) => { - timeoutRef.current = setTimeout( - () => reject(new Error("Operation timed out")), - timeout, - ); - }); - const requestWithTimeOutCancellation = async () => { - const resp = await request; - clearTimeout(timeoutRef.current); - return resp; - }; - return await Promise.race([ - requestWithTimeOutCancellation(), - rejectOnTimeout, - ]); -}; 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/ml-clip.ts b/desktop/src/main/services/ml-clip.ts index af8198a3c..0c466b9f6 100644 --- a/desktop/src/main/services/ml-clip.ts +++ b/desktop/src/main/services/ml-clip.ts @@ -5,115 +5,22 @@ * * @see `web/apps/photos/src/services/clip-service.ts` for more details. */ -import { existsSync } from "fs"; import jpeg from "jpeg-js"; import fs from "node:fs/promises"; import * as ort from "onnxruntime-node"; import Tokenizer from "../../thirdparty/clip-bpe-ts/mod"; -import { CustomErrors } from "../../types/ipc"; -import { writeStream } from "../stream"; import log from "../log"; -import { generateTempFilePath } from "../temp"; -import { deleteTempFile } from "./ffmpeg"; -import { - createInferenceSession, - downloadModel, - modelPathDownloadingIfNeeded, - modelSavePath, -} from "./ml"; +import { writeStream } from "../stream"; +import { deleteTempFile, makeTempFilePath } from "../utils-temp"; +import { makeCachedInferenceSession } from "./ml"; -const textModelName = "clip-text-vit-32-uint8.onnx"; -const textModelByteSize = 64173509; // 61.2 MB - -const imageModelName = "clip-image-vit-32-float32.onnx"; -const imageModelByteSize = 351468764; // 335.2 MB - -let activeImageModelDownload: Promise | undefined; - -const imageModelPathDownloadingIfNeeded = async () => { - try { - if (activeImageModelDownload) { - log.info("Waiting for CLIP image model download to finish"); - await activeImageModelDownload; - } else { - activeImageModelDownload = modelPathDownloadingIfNeeded( - imageModelName, - imageModelByteSize, - ); - return await activeImageModelDownload; - } - } finally { - activeImageModelDownload = undefined; - } -}; - -let textModelDownloadInProgress = false; - -/* TODO(MR): use the generic method. Then we can remove the exports for the - internal details functions that we use here */ -const textModelPathDownloadingIfNeeded = async () => { - if (textModelDownloadInProgress) - throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING); - - const modelPath = modelSavePath(textModelName); - if (!existsSync(modelPath)) { - log.info("CLIP text model not found, downloading"); - textModelDownloadInProgress = true; - downloadModel(modelPath, textModelName) - .catch((e) => { - // log but otherwise ignore - log.error("CLIP text model download failed", e); - }) - .finally(() => { - textModelDownloadInProgress = false; - }); - throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING); - } else { - const localFileSize = (await fs.stat(modelPath)).size; - if (localFileSize !== textModelByteSize) { - log.error( - `CLIP text model size ${localFileSize} does not match the expected size, downloading again`, - ); - textModelDownloadInProgress = true; - downloadModel(modelPath, textModelName) - .catch((e) => { - // log but otherwise ignore - log.error("CLIP text model download failed", e); - }) - .finally(() => { - textModelDownloadInProgress = false; - }); - throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING); - } - } - - return modelPath; -}; - -let imageSessionPromise: Promise | undefined; - -const onnxImageSession = async () => { - if (!imageSessionPromise) { - imageSessionPromise = (async () => { - const modelPath = await imageModelPathDownloadingIfNeeded(); - return createInferenceSession(modelPath); - })(); - } - return imageSessionPromise; -}; - -let _textSession: any = null; - -const onnxTextSession = async () => { - if (!_textSession) { - const modelPath = await textModelPathDownloadingIfNeeded(); - _textSession = await createInferenceSession(modelPath); - } - return _textSession; -}; +const cachedCLIPImageSession = makeCachedInferenceSession( + "clip-image-vit-32-float32.onnx", + 351468764 /* 335.2 MB */, +); export const clipImageEmbedding = async (jpegImageData: Uint8Array) => { - const tempFilePath = await generateTempFilePath(""); + const tempFilePath = await makeTempFilePath(""); const imageStream = new Response(jpegImageData.buffer).body; await writeStream(tempFilePath, imageStream); try { @@ -124,19 +31,20 @@ export const clipImageEmbedding = async (jpegImageData: Uint8Array) => { }; const clipImageEmbedding_ = async (jpegFilePath: string) => { - const imageSession = await onnxImageSession(); + const session = await cachedCLIPImageSession(); const t1 = Date.now(); const rgbData = await getRGBData(jpegFilePath); const feeds = { input: new ort.Tensor("float32", rgbData, [1, 3, 224, 224]), }; const t2 = Date.now(); - const results = await imageSession.run(feeds); + const results = await session.run(feeds); log.debug( () => `onnx/clip image embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`, ); - const imageEmbedding = results["output"].data; // Float32Array + /* Need these model specific casts to type the result */ + const imageEmbedding = results["output"].data as Float32Array; return normalizeEmbedding(imageEmbedding); }; @@ -221,6 +129,11 @@ const normalizeEmbedding = (embedding: Float32Array) => { return embedding; }; +const cachedCLIPTextSession = makeCachedInferenceSession( + "clip-text-vit-32-uint8.onnx", + 64173509 /* 61.2 MB */, +); + let _tokenizer: Tokenizer = null; const getTokenizer = () => { if (!_tokenizer) { @@ -229,8 +142,21 @@ const getTokenizer = () => { return _tokenizer; }; -export const clipTextEmbedding = async (text: string) => { - const imageSession = await onnxTextSession(); +export const clipTextEmbeddingIfAvailable = async (text: string) => { + const sessionOrStatus = await Promise.race([ + cachedCLIPTextSession(), + "downloading-model", + ]); + + // Don't wait for the download to complete + if (typeof sessionOrStatus == "string") { + console.log( + "Ignoring CLIP text embedding request because model download is pending", + ); + return undefined; + } + + const session = sessionOrStatus; const t1 = Date.now(); const tokenizer = getTokenizer(); const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text)); @@ -238,11 +164,11 @@ export const clipTextEmbedding = async (text: string) => { input: new ort.Tensor("int32", tokenizedText, [1, 77]), }; const t2 = Date.now(); - const results = await imageSession.run(feeds); + const results = await session.run(feeds); log.debug( () => `onnx/clip text embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`, ); - const textEmbedding = results["output"].data; + const textEmbedding = results["output"].data as Float32Array; return normalizeEmbedding(textEmbedding); }; diff --git a/desktop/src/main/services/ml-face.ts b/desktop/src/main/services/ml-face.ts index 1f007c5fd..2309d193c 100644 --- a/desktop/src/main/services/ml-face.ts +++ b/desktop/src/main/services/ml-face.ts @@ -8,78 +8,15 @@ */ import * as ort from "onnxruntime-node"; import log from "../log"; -import { createInferenceSession, modelPathDownloadingIfNeeded } from "./ml"; +import { makeCachedInferenceSession } from "./ml"; -const faceDetectionModelName = "yolov5s_face_640_640_dynamic.onnx"; -const faceDetectionModelByteSize = 30762872; // 29.3 MB - -const faceEmbeddingModelName = "mobilefacenet_opset15.onnx"; -const faceEmbeddingModelByteSize = 5286998; // 5 MB - -let activeFaceDetectionModelDownload: Promise | undefined; - -const faceDetectionModelPathDownloadingIfNeeded = async () => { - try { - if (activeFaceDetectionModelDownload) { - log.info("Waiting for face detection model download to finish"); - await activeFaceDetectionModelDownload; - } else { - activeFaceDetectionModelDownload = modelPathDownloadingIfNeeded( - faceDetectionModelName, - faceDetectionModelByteSize, - ); - return await activeFaceDetectionModelDownload; - } - } finally { - activeFaceDetectionModelDownload = undefined; - } -}; - -let _faceDetectionSession: Promise | undefined; - -const faceDetectionSession = async () => { - if (!_faceDetectionSession) { - _faceDetectionSession = - faceDetectionModelPathDownloadingIfNeeded().then((modelPath) => - createInferenceSession(modelPath), - ); - } - return _faceDetectionSession; -}; - -let activeFaceEmbeddingModelDownload: Promise | undefined; - -const faceEmbeddingModelPathDownloadingIfNeeded = async () => { - try { - if (activeFaceEmbeddingModelDownload) { - log.info("Waiting for face embedding model download to finish"); - await activeFaceEmbeddingModelDownload; - } else { - activeFaceEmbeddingModelDownload = modelPathDownloadingIfNeeded( - faceEmbeddingModelName, - faceEmbeddingModelByteSize, - ); - return await activeFaceEmbeddingModelDownload; - } - } finally { - activeFaceEmbeddingModelDownload = undefined; - } -}; - -let _faceEmbeddingSession: Promise | undefined; - -const faceEmbeddingSession = async () => { - if (!_faceEmbeddingSession) { - _faceEmbeddingSession = - faceEmbeddingModelPathDownloadingIfNeeded().then((modelPath) => - createInferenceSession(modelPath), - ); - } - return _faceEmbeddingSession; -}; +const cachedFaceDetectionSession = makeCachedInferenceSession( + "yolov5s_face_640_640_dynamic.onnx", + 30762872 /* 29.3 MB */, +); export const detectFaces = async (input: Float32Array) => { - const session = await faceDetectionSession(); + const session = await cachedFaceDetectionSession(); const t = Date.now(); const feeds = { input: new ort.Tensor("float32", input, [1, 3, 640, 640]), @@ -89,6 +26,11 @@ export const detectFaces = async (input: Float32Array) => { return results["output"].data; }; +const cachedFaceEmbeddingSession = makeCachedInferenceSession( + "mobilefacenet_opset15.onnx", + 5286998 /* 5 MB */, +); + export const faceEmbedding = async (input: Float32Array) => { // Dimension of each face (alias) const mobileFaceNetFaceSize = 112; @@ -98,11 +40,11 @@ export const faceEmbedding = async (input: Float32Array) => { const n = Math.round(input.length / (z * z * 3)); const inputTensor = new ort.Tensor("float32", input, [n, z, z, 3]); - const session = await faceEmbeddingSession(); + const session = await cachedFaceEmbeddingSession(); const t = Date.now(); const feeds = { img_inputs: inputTensor }; const results = await session.run(feeds); log.debug(() => `onnx/yolo face embedding took ${Date.now() - t} ms`); - // TODO: What's with this type? It works in practice, but double check. - return (results.embeddings as unknown as any)["cpuData"]; // as Float32Array; + /* Need these model specific casts to extract and type the result */ + return (results.embeddings as unknown as any)["cpuData"] as Float32Array; }; diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index e1d68e2dd..8292596a2 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -1,5 +1,5 @@ /** - * @file AI/ML related functionality. + * @file AI/ML related functionality, generic layer. * * @see also `ml-clip.ts`, `ml-face.ts`. * @@ -15,8 +15,51 @@ import { existsSync } from "fs"; import fs from "node:fs/promises"; import path from "node:path"; import * as ort from "onnxruntime-node"; -import { writeStream } from "../stream"; import log from "../log"; +import { writeStream } from "../stream"; + +/** + * Return a function that can be used to trigger a download of the specified + * model, and the creating of an ONNX inference session initialized using it. + * + * Multiple parallel calls to the returned function are fine, it ensures that + * the the model will be downloaded and the session created using it only once. + * All pending calls to it meanwhile will just await on the same promise. + * + * And once the promise is resolved, the create ONNX inference session will be + * cached, so subsequent calls to the returned function will just reuse the same + * session. + * + * {@link makeCachedInferenceSession} can itself be called anytime, it doesn't + * actively trigger a download until the returned function is called. + * + * @param modelName The name of the model to download. + * @param modelByteSize The size in bytes that we expect the model to have. If + * the size of the downloaded model does not match the expected size, then we + * will redownload it. + * + * @returns a function. calling that function returns a promise to an ONNX + * session. + */ +export const makeCachedInferenceSession = ( + modelName: string, + modelByteSize: number, +) => { + let session: Promise | undefined; + + const download = () => + modelPathDownloadingIfNeeded(modelName, modelByteSize); + + const createSession = (modelPath: string) => + createInferenceSession(modelPath); + + const cachedInferenceSession = () => { + if (!session) session = download().then(createSession); + return session; + }; + + return cachedInferenceSession; +}; /** * Download the model named {@link modelName} if we don't already have it. @@ -26,7 +69,7 @@ import log from "../log"; * * @returns the path to the model on the local machine. */ -export const modelPathDownloadingIfNeeded = async ( +const modelPathDownloadingIfNeeded = async ( modelName: string, expectedByteSize: number, ) => { @@ -49,10 +92,10 @@ export const modelPathDownloadingIfNeeded = async ( }; /** Return the path where the given {@link modelName} is meant to be saved */ -export const modelSavePath = (modelName: string) => +const modelSavePath = (modelName: string) => path.join(app.getPath("userData"), "models", modelName); -export const downloadModel = async (saveLocation: string, name: string) => { +const downloadModel = async (saveLocation: string, name: string) => { // `mkdir -p` the directory where we want to save the model. const saveDir = path.dirname(saveLocation); await fs.mkdir(saveDir, { recursive: true }); @@ -69,7 +112,7 @@ export const downloadModel = async (saveLocation: string, name: string) => { /** * Crete an ONNX {@link InferenceSession} with some defaults. */ -export const createInferenceSession = async (modelPath: string) => { +const createInferenceSession = async (modelPath: string) => { return await ort.InferenceSession.create(modelPath, { // Restrict the number of threads to 1 intraOpNumThreads: 1, 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/temp.ts b/desktop/src/main/temp.ts deleted file mode 100644 index 489e5cbd4..000000000 --- a/desktop/src/main/temp.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { app } from "electron/main"; -import { existsSync } from "node:fs"; -import fs from "node:fs/promises"; -import path from "path"; - -const CHARACTERS = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - -export async function getTempDirPath() { - const tempDirPath = path.join(app.getPath("temp"), "ente"); - await fs.mkdir(tempDirPath, { recursive: true }); - return tempDirPath; -} - -function generateTempName(length: number) { - let result = ""; - - const charactersLength = CHARACTERS.length; - for (let i = 0; i < length; i++) { - result += CHARACTERS.charAt( - Math.floor(Math.random() * charactersLength), - ); - } - return result; -} - -export async function generateTempFilePath(formatSuffix: string) { - let tempFilePath: string; - do { - const tempDirPath = await getTempDirPath(); - const namePrefix = generateTempName(10); - tempFilePath = path.join(tempDirPath, namePrefix + "-" + formatSuffix); - } while (existsSync(tempFilePath)); - return tempFilePath; -} diff --git a/desktop/src/main/util.ts b/desktop/src/main/utils-electron.ts similarity index 100% rename from desktop/src/main/util.ts rename to desktop/src/main/utils-electron.ts index d0c6699e9..b997d738e 100644 --- a/desktop/src/main/util.ts +++ b/desktop/src/main/utils-electron.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/main/utils-temp.ts b/desktop/src/main/utils-temp.ts new file mode 100644 index 000000000..35455e85e --- /dev/null +++ b/desktop/src/main/utils-temp.ts @@ -0,0 +1,64 @@ +import { app } from "electron/main"; +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "path"; + +/** + * Our very own directory within the system temp directory. Go crazy, but + * remember to clean up, especially in exception handlers. + */ +const enteTempDirPath = async () => { + const result = path.join(app.getPath("temp"), "ente"); + await fs.mkdir(result, { recursive: true }); + return result; +}; + +const randomPrefix = (length: number) => { + const CHARACTERS = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + let result = ""; + const charactersLength = CHARACTERS.length; + for (let i = 0; i < length; i++) { + result += CHARACTERS.charAt( + Math.floor(Math.random() * charactersLength), + ); + } + return result; +}; + +/** + * Return the path to a temporary file with the given {@link formatSuffix}. + * + * The function returns the path to a file in the system temp directory (in an + * Ente specific folder therin) with a random prefix and the given + * {@link formatSuffix}. It ensures that there is no existing file with the same + * name already. + * + * Use {@link deleteTempFile} to remove this file when you're done. + */ +export const makeTempFilePath = async (formatSuffix: string) => { + const tempDir = await enteTempDirPath(); + let result: string; + do { + result = path.join(tempDir, randomPrefix(10) + "-" + formatSuffix); + } while (existsSync(result)); + return result; +}; + +/** + * Delete a temporary file at the given path if it exists. + * + * This is the same as a vanilla {@link fs.rm}, except it first checks that the + * given path is within the Ente specific directory in the system temp + * directory. This acts as an additional safety check. + * + * @param tempFilePath The path to the temporary file to delete. This path + * should've been previously created using {@link makeTempFilePath}. + */ +export const deleteTempFile = async (tempFilePath: string) => { + const tempDir = await enteTempDirPath(); + if (!tempFilePath.startsWith(tempDir)) + throw new Error(`Attempting to delete a non-temp file ${tempFilePath}`); + await fs.rm(tempFilePath, { force: true }); +}; diff --git a/desktop/src/main/utils.ts b/desktop/src/main/utils.ts new file mode 100644 index 000000000..132859a43 --- /dev/null +++ b/desktop/src/main/utils.ts @@ -0,0 +1,35 @@ +/** + * @file grab bag of utitity functions. + * + * Many of these are verbatim copies of functions from web code since there + * isn't currently a common package that both of them share. + */ + +/** + * Wait for {@link ms} milliseconds + * + * This function is a promisified `setTimeout`. It returns a promise that + * resolves after {@link ms} milliseconds. + */ +export const wait = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Await the given {@link promise} for {@link timeoutMS} milliseconds. If it + * does not resolve within {@link timeoutMS}, then reject with a timeout error. + */ +export const withTimeout = async (promise: Promise, ms: number) => { + let timeoutId: ReturnType; + const rejectOnTimeout = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error("Operation timed out")), + ms, + ); + }); + const promiseAndCancelTimeout = async () => { + const result = await promise; + clearTimeout(timeoutId); + return result; + }; + return Promise.race([promiseAndCancelTimeout(), rejectOnTimeout]); +}; diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index ff2cf505a..c3f964e17 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,15 +119,16 @@ const fsReadTextFile = (path: string): Promise => const fsWriteFile = (path: string, contents: string): Promise => ipcRenderer.invoke("fsWriteFile", path, contents); -// - AUDIT below this +const fsIsDir = (dirPath: string): Promise => + ipcRenderer.invoke("fsIsDir", dirPath); // - Conversion const convertToJPEG = ( - fileData: Uint8Array, - filename: string, + fileName: string, + imageData: Uint8Array, ): Promise => - ipcRenderer.invoke("convertToJPEG", fileData, filename); + ipcRenderer.invoke("convertToJPEG", fileName, imageData); const generateImageThumbnail = ( inputFile: File | ElectronFile, @@ -140,18 +142,18 @@ const generateImageThumbnail = ( maxSize, ); -const runFFmpegCmd = ( - cmd: string[], - inputFile: File | ElectronFile, +const ffmpegExec = ( + command: string[], + inputDataOrPath: Uint8Array | string, outputFileName: string, - dontTimeout?: boolean, -): Promise => + timeoutMS: number, +): Promise => ipcRenderer.invoke( - "runFFmpegCmd", - cmd, - inputFile, + "ffmpegExec", + command, + inputDataOrPath, outputFileName, - dontTimeout, + timeoutMS, ); // - ML @@ -159,8 +161,10 @@ const runFFmpegCmd = ( const clipImageEmbedding = (jpegImageData: Uint8Array): Promise => ipcRenderer.invoke("clipImageEmbedding", jpegImageData); -const clipTextEmbedding = (text: string): Promise => - ipcRenderer.invoke("clipTextEmbedding", text); +const clipTextEmbeddingIfAvailable = ( + text: string, +): Promise => + ipcRenderer.invoke("clipTextEmbeddingIfAvailable", text); const detectFaces = (input: Float32Array): Promise => ipcRenderer.invoke("detectFaces", input); @@ -188,117 +192,121 @@ 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); + +// - TODO: AUDIT below this +// - 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.*` -// -// There are a few related concepts at play here, and it might be worthwhile to -// read their (excellent) documentation to get an understanding; -//` -// - ContextIsolation: -// https://www.electronjs.org/docs/latest/tutorial/context-isolation -// -// - IPC https://www.electronjs.org/docs/latest/tutorial/ipc -// -// [Note: Transferring large amount of data over IPC] -// -// Electron's IPC implementation uses the HTML standard Structured Clone -// Algorithm to serialize objects passed between processes. -// https://www.electronjs.org/docs/latest/tutorial/ipc#object-serialization -// -// In particular, ArrayBuffer is eligible for structured cloning. -// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm -// -// Also, ArrayBuffer is "transferable", which means it is a zero-copy operation -// operation when it happens across threads. -// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects -// -// In our case though, we're not dealing with threads but separate processes. So -// the ArrayBuffer will be copied: -// > "parameters, errors and return values are **copied** when they're sent over -// the bridge". -// 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]. +/** + * These objects exposed here will become available to the JS code in our + * renderer (the web/ code) as `window.ElectronAPIs.*` + * + * There are a few related concepts at play here, and it might be worthwhile to + * read their (excellent) documentation to get an understanding; + *` + * - ContextIsolation: + * https://www.electronjs.org/docs/latest/tutorial/context-isolation + * + * - IPC https://www.electronjs.org/docs/latest/tutorial/ipc + * + * --- + * + * [Note: Transferring large amount of data over IPC] + * + * Electron's IPC implementation uses the HTML standard Structured Clone + * Algorithm to serialize objects passed between processes. + * https://www.electronjs.org/docs/latest/tutorial/ipc#object-serialization + * + * In particular, ArrayBuffer is eligible for structured cloning. + * https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm + * + * Also, ArrayBuffer is "transferable", which means it is a zero-copy operation + * operation when it happens across threads. + * https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects + * + * In our case though, we're not dealing with threads but separate processes. So + * the ArrayBuffer will be copied: + * + * > "parameters, errors and return values are **copied** when they're sent over + * > the bridge". + * > + * > 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]. + */ contextBridge.exposeInMainWorld("electron", { // - General + appVersion, logToDisk, openDirectory, @@ -309,12 +317,14 @@ contextBridge.exposeInMainWorld("electron", { onMainWindowFocus, // - App update + onAppUpdateAvailable, updateAndRestart, updateOnNextRestart, skipAppUpdate, // - FS + fs: { exists: fsExists, rename: fsRename, @@ -323,42 +333,51 @@ contextBridge.exposeInMainWorld("electron", { rm: fsRm, readTextFile: fsReadTextFile, writeFile: fsWriteFile, + isDir: fsIsDir, }, // - Conversion + convertToJPEG, generateImageThumbnail, - runFFmpegCmd, + ffmpegExec, // - ML + clipImageEmbedding, - clipTextEmbedding, + clipTextEmbeddingIfAvailable, 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..3fa375eab 100644 --- a/desktop/src/types/ipc.ts +++ b/desktop/src/types/ipc.ts @@ -5,29 +5,40 @@ * 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. + * See: [Note: Custom errors across Electron/Renderer boundary] * - * [Note: Custom errors across Electron/Renderer boundary] - * - * We need to use the `message` field to disambiguate between errors thrown by - * the main process when invoked from the renderer process. This is because: - * - * > Errors thrown throw `handle` in the main process are not transparent as - * > they are serialized and only the `message` property from the original error - * > is provided to the renderer process. - * > - * > - https://www.electronjs.org/docs/latest/tutorial/ipc - * > - * > Ref: https://github.com/electron/electron/issues/24427 + * Note: this is not a type, and cannot be used in preload.js; it is only meant + * for use in the main process code. */ -export const CustomErrors = { - WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED: - "Windows native image processing is not supported", - UNSUPPORTED_PLATFORM: (platform: string, arch: string) => - `Unsupported platform - ${platform} ${arch}`, - MODEL_DOWNLOAD_PENDING: - "Model download pending, skipping clip search request", +export const CustomErrorMessage = { + NotAvailable: "This feature in not available on the current OS/arch", }; /** @@ -51,32 +62,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/desktop/yarn.lock b/desktop/yarn.lock index a4cc12cfe..a5b86f1eb 100644 --- a/desktop/yarn.lock +++ b/desktop/yarn.lock @@ -125,7 +125,7 @@ dependencies: eslint-visitor-keys "^3.3.0" -"@eslint-community/regexpp@^4.5.1", "@eslint-community/regexpp@^4.6.1": +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.6.1": version "4.10.0" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63" integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA== @@ -285,7 +285,7 @@ resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz#b979ebad3919799c979b17c72621c0bc0a31c6c4" integrity sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA== -"@types/json-schema@^7.0.12": +"@types/json-schema@^7.0.15": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -303,9 +303,9 @@ integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== "@types/node@*", "@types/node@^20.9.0": - version "20.11.30" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.30.tgz#9c33467fc23167a347e73834f788f4b9f399d66f" - integrity sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw== + version "20.12.7" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.7.tgz#04080362fa3dd6c5822061aa3124f5c152cff384" + integrity sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg== dependencies: undici-types "~5.26.4" @@ -334,7 +334,7 @@ dependencies: "@types/node" "*" -"@types/semver@^7.5.0": +"@types/semver@^7.5.8": version "7.5.8" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ== @@ -352,90 +352,90 @@ "@types/node" "*" "@typescript-eslint/eslint-plugin@^7": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.4.0.tgz#de61c3083842fc6ac889d2fc83c9a96b55ab8328" - integrity sha512-yHMQ/oFaM7HZdVrVm/M2WHaNPgyuJH4WelkSVEWSSsir34kxW2kDJCxlXRhhGWEsMN0WAW/vLpKfKVcm8k+MPw== + version "7.6.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.6.0.tgz#1f5df5cda490a0bcb6fbdd3382e19f1241024242" + integrity sha512-gKmTNwZnblUdnTIJu3e9kmeRRzV2j1a/LUO27KNNAnIC5zjy1aSvXSRp4rVNlmAoHlQ7HzX42NbKpcSr4jF80A== dependencies: - "@eslint-community/regexpp" "^4.5.1" - "@typescript-eslint/scope-manager" "7.4.0" - "@typescript-eslint/type-utils" "7.4.0" - "@typescript-eslint/utils" "7.4.0" - "@typescript-eslint/visitor-keys" "7.4.0" + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "7.6.0" + "@typescript-eslint/type-utils" "7.6.0" + "@typescript-eslint/utils" "7.6.0" + "@typescript-eslint/visitor-keys" "7.6.0" debug "^4.3.4" graphemer "^1.4.0" - ignore "^5.2.4" + ignore "^5.3.1" natural-compare "^1.4.0" - semver "^7.5.4" - ts-api-utils "^1.0.1" + semver "^7.6.0" + ts-api-utils "^1.3.0" "@typescript-eslint/parser@^7": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.4.0.tgz#540f4321de1e52b886c0fa68628af1459954c1f1" - integrity sha512-ZvKHxHLusweEUVwrGRXXUVzFgnWhigo4JurEj0dGF1tbcGh6buL+ejDdjxOQxv6ytcY1uhun1p2sm8iWStlgLQ== + version "7.6.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.6.0.tgz#0aca5de3045d68b36e88903d15addaf13d040a95" + integrity sha512-usPMPHcwX3ZoPWnBnhhorc14NJw9J4HpSXQX4urF2TPKG0au0XhJoZyX62fmvdHONUkmyUe74Hzm1//XA+BoYg== dependencies: - "@typescript-eslint/scope-manager" "7.4.0" - "@typescript-eslint/types" "7.4.0" - "@typescript-eslint/typescript-estree" "7.4.0" - "@typescript-eslint/visitor-keys" "7.4.0" + "@typescript-eslint/scope-manager" "7.6.0" + "@typescript-eslint/types" "7.6.0" + "@typescript-eslint/typescript-estree" "7.6.0" + "@typescript-eslint/visitor-keys" "7.6.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.4.0.tgz#acfc69261f10ece7bf7ece1734f1713392c3655f" - integrity sha512-68VqENG5HK27ypafqLVs8qO+RkNc7TezCduYrx8YJpXq2QGZ30vmNZGJJJC48+MVn4G2dCV8m5ZTVnzRexTVtw== +"@typescript-eslint/scope-manager@7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.6.0.tgz#1e9972f654210bd7500b31feadb61a233f5b5e9d" + integrity sha512-ngttyfExA5PsHSx0rdFgnADMYQi+Zkeiv4/ZxGYUWd0nLs63Ha0ksmp8VMxAIC0wtCFxMos7Lt3PszJssG/E6w== dependencies: - "@typescript-eslint/types" "7.4.0" - "@typescript-eslint/visitor-keys" "7.4.0" + "@typescript-eslint/types" "7.6.0" + "@typescript-eslint/visitor-keys" "7.6.0" -"@typescript-eslint/type-utils@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.4.0.tgz#cfcaab21bcca441c57da5d3a1153555e39028cbd" - integrity sha512-247ETeHgr9WTRMqHbbQdzwzhuyaJ8dPTuyuUEMANqzMRB1rj/9qFIuIXK7l0FX9i9FXbHeBQl/4uz6mYuCE7Aw== +"@typescript-eslint/type-utils@7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.6.0.tgz#644f75075f379827d25fe0713e252ccd4e4a428c" + integrity sha512-NxAfqAPNLG6LTmy7uZgpK8KcuiS2NZD/HlThPXQRGwz6u7MDBWRVliEEl1Gj6U7++kVJTpehkhZzCJLMK66Scw== dependencies: - "@typescript-eslint/typescript-estree" "7.4.0" - "@typescript-eslint/utils" "7.4.0" + "@typescript-eslint/typescript-estree" "7.6.0" + "@typescript-eslint/utils" "7.6.0" debug "^4.3.4" - ts-api-utils "^1.0.1" + ts-api-utils "^1.3.0" -"@typescript-eslint/types@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.4.0.tgz#ee9dafa75c99eaee49de6dcc9348b45d354419b6" - integrity sha512-mjQopsbffzJskos5B4HmbsadSJQWaRK0UxqQ7GuNA9Ga4bEKeiO6b2DnB6cM6bpc8lemaPseh0H9B/wyg+J7rw== +"@typescript-eslint/types@7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.6.0.tgz#53dba7c30c87e5f10a731054266dd905f1fbae38" + integrity sha512-h02rYQn8J+MureCvHVVzhl69/GAfQGPQZmOMjG1KfCl7o3HtMSlPaPUAPu6lLctXI5ySRGIYk94clD/AUMCUgQ== -"@typescript-eslint/typescript-estree@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.4.0.tgz#12dbcb4624d952f72c10a9f4431284fca24624f4" - integrity sha512-A99j5AYoME/UBQ1ucEbbMEmGkN7SE0BvZFreSnTd1luq7yulcHdyGamZKizU7canpGDWGJ+Q6ZA9SyQobipePg== +"@typescript-eslint/typescript-estree@7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.6.0.tgz#112a3775563799fd3f011890ac8322f80830ac17" + integrity sha512-+7Y/GP9VuYibecrCQWSKgl3GvUM5cILRttpWtnAu8GNL9j11e4tbuGZmZjJ8ejnKYyBRb2ddGQ3rEFCq3QjMJw== dependencies: - "@typescript-eslint/types" "7.4.0" - "@typescript-eslint/visitor-keys" "7.4.0" + "@typescript-eslint/types" "7.6.0" + "@typescript-eslint/visitor-keys" "7.6.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" - minimatch "9.0.3" - semver "^7.5.4" - ts-api-utils "^1.0.1" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^1.3.0" -"@typescript-eslint/utils@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.4.0.tgz#d889a0630cab88bddedaf7c845c64a00576257bd" - integrity sha512-NQt9QLM4Tt8qrlBVY9lkMYzfYtNz8/6qwZg8pI3cMGlPnj6mOpRxxAm7BMJN9K0AiY+1BwJ5lVC650YJqYOuNg== +"@typescript-eslint/utils@7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.6.0.tgz#e400d782280b6f724c8a1204269d984c79202282" + integrity sha512-x54gaSsRRI+Nwz59TXpCsr6harB98qjXYzsRxGqvA5Ue3kQH+FxS7FYU81g/omn22ML2pZJkisy6Q+ElK8pBCA== dependencies: "@eslint-community/eslint-utils" "^4.4.0" - "@types/json-schema" "^7.0.12" - "@types/semver" "^7.5.0" - "@typescript-eslint/scope-manager" "7.4.0" - "@typescript-eslint/types" "7.4.0" - "@typescript-eslint/typescript-estree" "7.4.0" - semver "^7.5.4" + "@types/json-schema" "^7.0.15" + "@types/semver" "^7.5.8" + "@typescript-eslint/scope-manager" "7.6.0" + "@typescript-eslint/types" "7.6.0" + "@typescript-eslint/typescript-estree" "7.6.0" + semver "^7.6.0" -"@typescript-eslint/visitor-keys@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.4.0.tgz#0c8ff2c1f8a6fe8d7d1a57ebbd4a638e86a60a94" - integrity sha512-0zkC7YM0iX5Y41homUUeW1CHtZR01K3ybjM1l6QczoMuay0XKtrb93kv95AxUGwdjGr64nNqnOCwmEl616N8CA== +"@typescript-eslint/visitor-keys@7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.6.0.tgz#d1ce13145844379021e1f9bd102c1d78946f4e76" + integrity sha512-4eLB7t+LlNUmXzfOu1VAIAdkjbu5xNSerURS9X/S5TUKWFRpXRQZbmtPqgKmYx8bj3J0irtQXSiWAOY82v+cgw== dependencies: - "@typescript-eslint/types" "7.4.0" - eslint-visitor-keys "^3.4.1" + "@typescript-eslint/types" "7.6.0" + eslint-visitor-keys "^3.4.3" "@ungap/structured-clone@^1.2.0": version "1.2.0" @@ -1140,9 +1140,9 @@ ejs@^3.1.8: jake "^10.8.5" electron-builder-notarize@^1.5: - version "1.5.1" - resolved "https://registry.yarnpkg.com/electron-builder-notarize/-/electron-builder-notarize-1.5.1.tgz#e00b868a67ef20a77f00017606626f24fdbdc445" - integrity sha512-xS7s9gE+1AcJIuJ4DU/LqCrmRypE1zOR/6b66egKzgP/UVh9YSa7rINos34gF/KcueNDQU39HcXcCEKiEI5wPQ== + version "1.5.2" + resolved "https://registry.yarnpkg.com/electron-builder-notarize/-/electron-builder-notarize-1.5.2.tgz#540185b57a336fc6eec01bfe092a3b4764459255" + integrity sha512-vo6RGgIFYxMk2yp59N4NsvmAYfB7ncYi6gV9Fcq2TVKxEn2tPXrSjIKB2e/pu+5iXIY6BHNZNXa75F3DHgOOLA== dependencies: dotenv "^8.2.0" electron-notarize "^1.1.1" @@ -1215,9 +1215,9 @@ electron-updater@^6.1: tiny-typed-emitter "^2.1.0" electron@^29: - version "29.1.5" - resolved "https://registry.yarnpkg.com/electron/-/electron-29.1.5.tgz#b745b4d201c1ac9f84d6aa034126288dde34d5a1" - integrity sha512-1uWGRw/ffA62lcrklxGUgVxVtOHojsg/nwsYr+/F9cVjipZJn8iPv/ABGIIexhmUqWcho8BqfTJ4osCBa29gBg== + version "29.3.0" + resolved "https://registry.yarnpkg.com/electron/-/electron-29.3.0.tgz#8e65cb08e9c0952c66d3196e1b5c811c43b8c5b0" + integrity sha512-ZxFKm0/v48GSoBuO3DdnMlCYXefEUKUHLMsKxyXY4nZGgzbBKpF/X8haZa2paNj23CLfsCKBOtfc2vsEQiOOsA== dependencies: "@electron/get" "^2.0.0" "@types/node" "^20.9.0" @@ -1835,7 +1835,7 @@ ieee754@^1.1.13: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== -ignore@^5.2.0, ignore@^5.2.4: +ignore@^5.2.0, ignore@^5.2.4, ignore@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== @@ -2190,13 +2190,6 @@ mimic-response@^3.1.0: resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== -minimatch@9.0.3, minimatch@^9.0.1: - version "9.0.3" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" - integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== - dependencies: - brace-expansion "^2.0.1" - minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -2211,6 +2204,20 @@ minimatch@^5.0.1, minimatch@^5.1.1: dependencies: brace-expansion "^2.0.1" +minimatch@^9.0.1: + version "9.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" + integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^9.0.4: + version "9.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51" + integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.2.3, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" @@ -2482,17 +2489,17 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prettier-plugin-organize-imports@^3.2: +prettier-plugin-organize-imports@^3: version "3.2.4" resolved "https://registry.yarnpkg.com/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.4.tgz#77967f69d335e9c8e6e5d224074609309c62845e" integrity sha512-6m8WBhIp0dfwu0SkgfOxJqh+HpdyfqSSLfKKRZSFbDuEQXDDndb8fTpRWkUrX/uBenkex3MgnVk0J3b3Y5byog== -prettier-plugin-packagejson@^2.4: - version "2.4.12" - resolved "https://registry.yarnpkg.com/prettier-plugin-packagejson/-/prettier-plugin-packagejson-2.4.12.tgz#eeb917dad83ae42d0caccc9f26d3728b5c4f2434" - integrity sha512-hifuuOgw5rHHTdouw9VrhT8+Nd7UwxtL1qco8dUfd4XUFQL6ia3xyjSxhPQTsGnSYFraTWy5Omb+MZm/OWDTpQ== +prettier-plugin-packagejson@^2: + version "2.5.0" + resolved "https://registry.yarnpkg.com/prettier-plugin-packagejson/-/prettier-plugin-packagejson-2.5.0.tgz#23d2cb8b1f7840702d35e3a5078e564ea0bc63e0" + integrity sha512-6XkH3rpin5QEQodBSVNg+rBo4r91g/1mCaRwS1YGdQJZ6jwqrg2UchBsIG9tpS1yK1kNBvOt84OILsX8uHzBGg== dependencies: - sort-package-json "2.8.0" + sort-package-json "2.10.0" synckit "0.9.0" prettier@^3: @@ -2711,7 +2718,7 @@ semver@^6.2.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.2, semver@^7.3.5, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4: +semver@^7.3.2, semver@^7.3.5, semver@^7.3.8, semver@^7.5.3, semver@^7.6.0: version "7.6.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== @@ -2800,10 +2807,10 @@ sort-object-keys@^1.1.3: resolved "https://registry.yarnpkg.com/sort-object-keys/-/sort-object-keys-1.1.3.tgz#bff833fe85cab147b34742e45863453c1e190b45" integrity sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg== -sort-package-json@2.8.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/sort-package-json/-/sort-package-json-2.8.0.tgz#6a46439ad0fef77f091e678e103f03ecbea575c8" - integrity sha512-PxeNg93bTJWmDGnu0HADDucoxfFiKkIr73Kv85EBThlI1YQPdc0XovBgg2llD0iABZbu2SlKo8ntGmOP9wOj/g== +sort-package-json@2.10.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/sort-package-json/-/sort-package-json-2.10.0.tgz#6be07424bf3b7db9fbb1bdd69e7945f301026d8a" + integrity sha512-MYecfvObMwJjjJskhxYfuOADkXp1ZMMnCFC8yhp+9HDsk7HhR336hd7eiBs96lTXfiqmUNI+WQCeCMRBhl251g== dependencies: detect-indent "^7.0.1" detect-newline "^4.0.0" @@ -2811,6 +2818,7 @@ sort-package-json@2.8.0: git-hooks-list "^3.0.0" globby "^13.1.2" is-plain-obj "^4.1.0" + semver "^7.6.0" sort-object-keys "^1.1.3" source-map-support@^0.5.19: @@ -3018,7 +3026,7 @@ truncate-utf8-bytes@^1.0.0: dependencies: utf8-byte-length "^1.0.1" -ts-api-utils@^1.0.1: +ts-api-utils@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== 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/README.md b/mobile/README.md index 662e71403..fc17f6b26 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -46,7 +46,7 @@ You can alternatively install the build from PlayStore or F-Droid. ## 🧑‍💻 Building from source -1. [Install Flutter v3.19.5](https://flutter.dev/docs/get-started/install). +1. [Install Flutter v3.19.3](https://flutter.dev/docs/get-started/install). 2. Pull in all submodules with `git submodule update --init --recursive` diff --git a/mobile/fastlane/metadata/ios/ru/name.txt b/mobile/fastlane/metadata/ios/ru/name.txt index 44e95b9fc..45bf4920f 100644 --- a/mobile/fastlane/metadata/ios/ru/name.txt +++ b/mobile/fastlane/metadata/ios/ru/name.txt @@ -1 +1 @@ -ente фотографии +ente Фото diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 102c04e6a..88bc70bf7 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -10,19 +10,19 @@ PODS: - Flutter - file_saver (0.0.1): - Flutter - - Firebase/CoreOnly (10.22.0): - - FirebaseCore (= 10.22.0) - - Firebase/Messaging (10.22.0): + - Firebase/CoreOnly (10.24.0): + - FirebaseCore (= 10.24.0) + - Firebase/Messaging (10.24.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 10.22.0) - - firebase_core (2.29.0): - - Firebase/CoreOnly (= 10.22.0) + - FirebaseMessaging (~> 10.24.0) + - firebase_core (2.30.0): + - Firebase/CoreOnly (= 10.24.0) - Flutter - - firebase_messaging (14.7.19): - - Firebase/Messaging (= 10.22.0) + - firebase_messaging (14.8.1): + - Firebase/Messaging (= 10.24.0) - firebase_core - Flutter - - FirebaseCore (10.22.0): + - FirebaseCore (10.24.0): - FirebaseCoreInternal (~> 10.0) - GoogleUtilities/Environment (~> 7.12) - GoogleUtilities/Logger (~> 7.12) @@ -33,7 +33,7 @@ PODS: - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8) - PromisesObjC (~> 2.1) - - FirebaseMessaging (10.22.0): + - FirebaseMessaging (10.24.0): - FirebaseCore (~> 10.0) - FirebaseInstallations (~> 10.0) - GoogleDataTransport (~> 9.3) @@ -175,7 +175,7 @@ PODS: - SDWebImage (5.19.1): - SDWebImage/Core (= 5.19.1) - SDWebImage/Core (5.19.1) - - SDWebImageWebPCoder (0.14.5): + - SDWebImageWebPCoder (0.14.6): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - Sentry/HybridSDK (8.21.0): @@ -193,14 +193,14 @@ PODS: - sqflite (0.0.3): - Flutter - FlutterMacOS - - sqlite3 (3.45.1): - - sqlite3/common (= 3.45.1) - - sqlite3/common (3.45.1) - - sqlite3/fts5 (3.45.1): + - "sqlite3 (3.45.3+1)": + - "sqlite3/common (= 3.45.3+1)" + - "sqlite3/common (3.45.3+1)" + - "sqlite3/fts5 (3.45.3+1)": - sqlite3/common - - sqlite3/perf-threadsafe (3.45.1): + - "sqlite3/perf-threadsafe (3.45.3+1)": - sqlite3/common - - sqlite3/rtree (3.45.1): + - "sqlite3/rtree (3.45.3+1)": - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter @@ -404,13 +404,13 @@ SPEC CHECKSUMS: connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 - Firebase: 797fd7297b7e1be954432743a0b3f90038e45a71 - firebase_core: aaadbddb3cb2ee3792b9804f9dbb63e5f6f7b55c - firebase_messaging: e65050bf9b187511d80ea3a4de7cf5573d2c7543 - FirebaseCore: 0326ec9b05fbed8f8716cddbf0e36894a13837f7 + Firebase: 91fefd38712feb9186ea8996af6cbdef41473442 + firebase_core: 66b99b4fb4e5d7cc4e88d4c195fe986681f3466a + firebase_messaging: 0eb0425d28b4f4af147cdd4adcaf7c0100df28ed + FirebaseCore: 11dc8a16dfb7c5e3c3f45ba0e191a33ac4f50894 FirebaseCoreInternal: bcb5acffd4ea05e12a783ecf835f2210ce3dc6af FirebaseInstallations: 8f581fca6478a50705d2bd2abd66d306e0f5736e - FirebaseMessaging: 9f71037fd9db3376a4caa54e5a3949d1027b4b6e + FirebaseMessaging: 4d52717dd820707cc4eadec5eb981b4832ec8d5d fk_user_agent: 1f47ec39291e8372b1d692b50084b0d54103c545 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b @@ -452,14 +452,14 @@ SPEC CHECKSUMS: receive_sharing_intent: 6837b01768e567fe8562182397bf43d63d8c6437 screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 SDWebImage: 40b0b4053e36c660a764958bff99eed16610acbb - SDWebImageWebPCoder: c94f09adbca681822edad9e532ac752db713eabf + SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 Sentry: ebc12276bd17613a114ab359074096b6b3725203 sentry_flutter: 88ebea3f595b0bc16acc5bedacafe6d60c12dcd5 SentryPrivate: d651efb234cf385ec9a1cdd3eff94b5e78a0e0fe share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec - sqlite3: 73b7fc691fdc43277614250e04d183740cb15078 + sqlite3: 02d1f07eaaa01f80a1c16b4b31dfcbb3345ee01a sqlite3_flutter_libs: af0e8fe9bce48abddd1ffdbbf839db0302d72d80 Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e uni_links: d97da20c7701486ba192624d99bffaaffcfc298a diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index d4b852540..bc406d7c3 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -13,18 +13,13 @@ import 'package:media_extension/media_extension_action_types.dart'; import 'package:photos/ente_theme_data.dart'; import "package:photos/generated/l10n.dart"; import "package:photos/l10n/l10n.dart"; -import "package:photos/models/collection/collection_items.dart"; import 'package:photos/services/app_lifecycle_service.dart'; -import "package:photos/services/collections_service.dart"; -import "package:photos/services/favorites_service.dart"; import "package:photos/services/home_widget_service.dart"; import "package:photos/services/machine_learning/machine_learning_controller.dart"; import 'package:photos/services/sync_service.dart'; import 'package:photos/ui/tabs/home_widget.dart'; import "package:photos/ui/viewer/actions/file_viewer.dart"; -import "package:photos/ui/viewer/gallery/collection_page.dart"; import "package:photos/utils/intent_util.dart"; -import "package:photos/utils/navigation_util.dart"; class EnteApp extends StatefulWidget { final Future Function(String) runBackgroundTask; @@ -66,39 +61,14 @@ class _EnteAppState extends State with WidgetsBindingObserver { void didChangeDependencies() { super.didChangeDependencies(); _checkForWidgetLaunch(); - hw.HomeWidget.widgetClicked.listen(_launchedFromWidget); } void _checkForWidgetLaunch() { - hw.HomeWidget.initiallyLaunchedFromHomeWidget().then(_launchedFromWidget); - } - - Future _launchedFromWidget(Uri? uri) async { - if (uri == null) return; - final collectionID = - await FavoritesService.instance.getFavoriteCollectionID(); - if (collectionID == null) { - return; - } - final collection = CollectionsService.instance.getCollectionByID( - collectionID, + hw.HomeWidget.initiallyLaunchedFromHomeWidget().then( + (uri) => HomeWidgetService.instance.onLaunchFromWidget(uri, context), ); - if (collection == null) { - return; - } - unawaited(HomeWidgetService.instance.initHomeWidget()); - - final thumbnail = await CollectionsService.instance.getCover(collection); - unawaited( - routeToPage( - context, - CollectionPage( - CollectionWithThumbnail( - collection, - thumbnail, - ), - ), - ), + hw.HomeWidget.widgetClicked.listen( + (uri) => HomeWidgetService.instance.onLaunchFromWidget(uri, context), ); } diff --git a/mobile/lib/core/constants.dart b/mobile/lib/core/constants.dart index 1cd9da1dd..c2d08d903 100644 --- a/mobile/lib/core/constants.dart +++ b/mobile/lib/core/constants.dart @@ -16,6 +16,7 @@ const int jan011981Time = 347155200000000; const int galleryLoadStartTime = -8000000000000000; // Wednesday, March 6, 1748 const int galleryLoadEndTime = 9223372036854775807; // 2^63 -1 const int batchSize = 1000; +const int batchSizeCopy = 100; const photoGridSizeDefault = 4; const photoGridSizeMin = 2; const photoGridSizeMax = 6; @@ -38,13 +39,6 @@ const dragSensitivity = 8; const supportEmail = 'support@ente.io'; -// Default values for various feature flags -class FFDefault { - static const bool enableStripe = true; - static const bool disableCFWorker = false; - static const bool enablePasskey = false; -} - // this is the chunk size of the un-encrypted file which is read and encrypted before uploading it as a single part. const multipartPartSize = 20 * 1024 * 1024; diff --git a/mobile/lib/db/files_db.dart b/mobile/lib/db/files_db.dart index 202faaaad..fce650086 100644 --- a/mobile/lib/db/files_db.dart +++ b/mobile/lib/db/files_db.dart @@ -16,7 +16,6 @@ import "package:photos/services/filter/db_filters.dart"; import 'package:photos/utils/file_uploader_util.dart'; import 'package:sqflite/sqflite.dart'; import 'package:sqflite_migration/sqflite_migration.dart'; -import 'package:sqlite3/sqlite3.dart' as sqlite3; import 'package:sqlite_async/sqlite_async.dart' as sqlite_async; class FilesDB { @@ -103,20 +102,15 @@ class FilesDB { // only have a single app-wide reference to the database static Future? _dbFuture; - static Future? _ffiDBFuture; static Future? _sqliteAsyncDBFuture; + @Deprecated("Use sqliteAsyncDB instead (sqlite_async)") Future get database async { // lazily instantiate the db the first time it is accessed _dbFuture ??= _initDatabase(); return _dbFuture!; } - Future get ffiDB async { - _ffiDBFuture ??= _initFFIDatabase(); - return _ffiDBFuture!; - } - Future get sqliteAsyncDB async { _sqliteAsyncDBFuture ??= _initSqliteAsyncDatabase(); return _sqliteAsyncDBFuture!; @@ -131,14 +125,6 @@ class FilesDB { return await openDatabaseWithMigration(path, dbConfig); } - Future _initFFIDatabase() async { - final Directory documentsDirectory = - await getApplicationDocumentsDirectory(); - final String path = join(documentsDirectory.path, _databaseName); - _logger.info("DB path " + path); - return sqlite3.sqlite3.open(path); - } - Future _initSqliteAsyncDatabase() async { final Directory documentsDirectory = await getApplicationDocumentsDirectory(); @@ -478,11 +464,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.sqliteAsyncDB; + final results = await db.getAll( + 'SELECT * FROM $filesTable WHERE $columnGeneratedID = ?', + [generatedID], ); if (results.isEmpty) { return null; @@ -491,11 +476,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.sqliteAsyncDB; + final results = await db.getAll( + 'SELECT * FROM $filesTable WHERE $columnUploadedFileID = ? AND $columnCollectionID = ?', + [ uploadedID, collectionID, ], @@ -506,29 +490,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.sqliteAsyncDB; + final results = await db.getAll( + 'SELECT $columnUploadedFileID FROM $filesTable' + ' WHERE $columnCollectionID = ? AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)', + [ collectionID, ], ); @@ -539,13 +506,36 @@ class FilesDB { return ids; } - Future getBackedUpIDs() async { + Future<(Set, Map)> getUploadAndHash( + int collectionID, + ) async { final db = await instance.database; final results = await db.query( filesTable, - columns: [columnLocalID, columnUploadedFileID, columnFileSize], + columns: [columnUploadedFileID, columnHash], where: - '$columnLocalID IS NOT NULL AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)', + '$columnCollectionID = ? AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)', + whereArgs: [ + collectionID, + ], + ); + final ids = {}; + final hash = {}; + for (final result in results) { + ids.add(result[columnUploadedFileID] as int); + if (result[columnHash] != null) { + hash[result[columnHash] as String] = + result[columnUploadedFileID] as int; + } + } + return (ids, hash); + } + + Future getBackedUpIDs() async { + 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 = {}; @@ -681,13 +671,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; @@ -697,14 +686,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; @@ -726,20 +714,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 = @@ -757,7 +747,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 >= " + @@ -772,44 +762,10 @@ class FilesDB { } } whereClause += ")"; - final results = await db.query( - filesTable, - where: whereClause, - orderBy: '$columnCreationTime ' + order, - ); - final files = convertToFiles(results); - return applyDBFilters( - files, - DBFilterOptions(ignoredCollectionIDs: ignoredCollectionIDs), - ); - } - - Future> getFilesCreatedWithinDurationsSync( - List> durations, - Set ignoredCollectionIDs, { - int? visibility, - String order = 'ASC', - }) async { - if (durations.isEmpty) { - return []; - } - final db = await instance.ffiDB; - String whereClause = "( "; - for (int index = 0; index < durations.length; index++) { - whereClause += "($columnCreationTime >= " + - durations[index][0].toString() + - " AND $columnCreationTime < " + - durations[index][1].toString() + - ")"; - if (index != durations.length - 1) { - whereClause += " OR "; - } else if (visibility != null) { - whereClause += ' AND $columnMMdVisibility = $visibility'; - } - } - whereClause += ")"; - final results = db.select( - 'select * from $filesTable where $whereClause order by $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( @@ -1041,6 +997,29 @@ class FilesDB { return convertToFiles(rows); } + Future> + getUserOwnedFilesWithSameHashForGivenListOfFiles( + List files, + int userID, + ) async { + final db = await sqliteAsyncDB; + final List hashes = []; + for (final file in files) { + if (file.hash != null && file.hash != '') { + hashes.add(file.hash!); + } + } + if (hashes.isEmpty) { + return {}; + } + final inParam = hashes.map((e) => "'$e'").join(','); + final rows = await db.execute(''' + SELECT * FROM $filesTable WHERE $columnHash IN ($inParam) AND $columnOwnerID = $userID; + '''); + final matchedFiles = convertToFiles(rows); + return Map.fromIterable(matchedFiles, key: (e) => e.hash); + } + Future> getUploadedFilesWithHashes( FileHashData hashData, FileType fileType, 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/main.dart b/mobile/lib/main.dart index 97338f55f..04d6c1b1b 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -21,12 +21,12 @@ import 'package:photos/core/network/network.dart'; import 'package:photos/db/upload_locks_db.dart'; import 'package:photos/ente_theme_data.dart'; import "package:photos/l10n/l10n.dart"; +import "package:photos/service_locator.dart"; import 'package:photos/services/app_lifecycle_service.dart'; import 'package:photos/services/billing_service.dart'; import 'package:photos/services/collections_service.dart'; import "package:photos/services/entity_service.dart"; import 'package:photos/services/favorites_service.dart'; -import 'package:photos/services/feature_flag_service.dart'; import 'package:photos/services/home_widget_service.dart'; import 'package:photos/services/local_file_update_service.dart'; import 'package:photos/services/local_sync_service.dart'; @@ -178,6 +178,7 @@ Future _init(bool isBackground, {String via = ''}) async { _isProcessRunning = true; _logger.info("Initializing... inBG =$isBackground via: $via"); final SharedPreferences preferences = await SharedPreferences.getInstance(); + await _logFGHeartBeatInfo(); unawaited(_scheduleHeartBeat(preferences, isBackground)); AppLifecycleService.instance.init(preferences); @@ -191,6 +192,7 @@ Future _init(bool isBackground, {String via = ''}) async { CryptoUtil.init(); await Configuration.instance.init(); await NetworkClient.instance.init(); + ServiceLocator.instance.init(preferences, NetworkClient.instance.enteDio); await UserService.instance.init(); await EntityService.instance.init(); LocationService.instance.init(preferences); @@ -224,7 +226,7 @@ Future _init(bool isBackground, {String via = ''}) async { ); }); } - unawaited(FeatureFlagService.instance.init()); + unawaited(SemanticSearchService.instance.init()); MachineLearningController.instance.init(); // Can not including existing tf/ml binaries as they are not being built diff --git a/mobile/lib/models/file/file.dart b/mobile/lib/models/file/file.dart index 75a40c99b..2aa5a4558 100644 --- a/mobile/lib/models/file/file.dart +++ b/mobile/lib/models/file/file.dart @@ -9,7 +9,7 @@ import 'package:photos/core/constants.dart'; import 'package:photos/models/file/file_type.dart'; import 'package:photos/models/location/location.dart'; import "package:photos/models/metadata/file_magic.dart"; -import 'package:photos/services/feature_flag_service.dart'; +import "package:photos/service_locator.dart"; import 'package:photos/utils/date_time_util.dart'; import 'package:photos/utils/exif_util.dart'; import 'package:photos/utils/file_uploader_util.dart'; @@ -244,8 +244,7 @@ class EnteFile { String get downloadUrl { final endpoint = Configuration.instance.getHttpEndpoint(); - if (endpoint != kDefaultProductionEndpoint || - FeatureFlagService.instance.disableCFWorker()) { + if (endpoint != kDefaultProductionEndpoint || flagService.disableCFWorker) { return endpoint + "/files/download/" + uploadedFileID.toString(); } else { return "https://files.ente.io/?fileID=" + uploadedFileID.toString(); @@ -258,8 +257,7 @@ class EnteFile { String get thumbnailUrl { final endpoint = Configuration.instance.getHttpEndpoint(); - if (endpoint != kDefaultProductionEndpoint || - FeatureFlagService.instance.disableCFWorker()) { + if (endpoint != kDefaultProductionEndpoint || flagService.disableCFWorker) { return endpoint + "/files/preview/" + uploadedFileID.toString(); } else { return "https://thumbnails.ente.io/?fileID=" + uploadedFileID.toString(); diff --git a/mobile/lib/service_locator.dart b/mobile/lib/service_locator.dart new file mode 100644 index 000000000..0fec75b46 --- /dev/null +++ b/mobile/lib/service_locator.dart @@ -0,0 +1,28 @@ +import "package:dio/dio.dart"; +import "package:ente_feature_flag/ente_feature_flag.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +class ServiceLocator { + late final SharedPreferences prefs; + late final Dio enteDio; + + // instance + ServiceLocator._privateConstructor(); + + static final ServiceLocator instance = ServiceLocator._privateConstructor(); + + init(SharedPreferences prefs, Dio enteDio) { + this.prefs = prefs; + this.enteDio = enteDio; + } +} + +FlagService? _flagService; + +FlagService get flagService { + _flagService ??= FlagService( + ServiceLocator.instance.prefs, + ServiceLocator.instance.enteDio, + ); + return _flagService!; +} diff --git a/mobile/lib/services/collections_service.dart b/mobile/lib/services/collections_service.dart index ae8ae150f..0981eb767 100644 --- a/mobile/lib/services/collections_service.dart +++ b/mobile/lib/services/collections_service.dart @@ -28,7 +28,9 @@ import 'package:photos/models/collection/collection.dart'; import 'package:photos/models/collection/collection_file_item.dart'; import 'package:photos/models/collection/collection_items.dart'; import 'package:photos/models/file/file.dart'; +import "package:photos/models/files_split.dart"; import "package:photos/models/metadata/collection_magic.dart"; +import "package:photos/service_locator.dart"; import 'package:photos/services/app_lifecycle_service.dart'; import "package:photos/services/favorites_service.dart"; import 'package:photos/services/file_magic_service.dart'; @@ -187,6 +189,23 @@ class CollectionsService { return result; } + bool allowUpload(int collectionID) { + final Collection? c = _collectionIDToCollections[collectionID]; + if (c == null) { + _logger.info('discardUpload: collectionMissing $collectionID'); + return false; + } + if (c.isDeleted) { + _logger.info('discardUpload: collectionDeleted $collectionID'); + return false; + } + if (!c.isOwner(_config.getUserID()!)) { + _logger.info('discardUpload: notOwner $collectionID'); + return false; + } + return true; + } + Future> getArchivedCollection() async { final allCollections = getCollectionsForUI(); return allCollections @@ -1148,11 +1167,56 @@ class CollectionsService { return collection; } - Future addToCollection(int collectionID, List files) async { - final containsUploadedFile = files.firstWhereOrNull( - (element) => element.uploadedFileID != null, - ) != - null; + Future addOrCopyToCollection( + int dstCollectionID, + List files, + ) async { + final splitResult = FilesSplit.split(files, _config.getUserID()!); + if (splitResult.pendingUploads.isNotEmpty) { + throw ArgumentError('File should be already uploaded'); + } + if (splitResult.ownedByCurrentUser.isNotEmpty) { + await _addToCollection(dstCollectionID, splitResult.ownedByCurrentUser); + } + if (splitResult.ownedByOtherUsers.isNotEmpty) { + if (!flagService.internalUser) { + throw ArgumentError('Cannot add files owned by other users'); + } + late final List filesToCopy; + late final List filesToAdd; + (filesToAdd, filesToCopy) = (await _splitFilesToAddAndCopy( + splitResult.ownedByOtherUsers, + )); + + if (filesToAdd.isNotEmpty) { + _logger.info( + "found existing ${filesToAdd.length} files with same hash, adding symlinks", + ); + await _addToCollection(dstCollectionID, filesToAdd); + } + // group files by collectionID + final Map> filesByCollection = {}; + for (final file in filesToCopy) { + if (filesByCollection.containsKey(file.collectionID!)) { + filesByCollection[file.collectionID!]!.add(file.copyWith()); + } else { + filesByCollection[file.collectionID!] = [file.copyWith()]; + } + } + for (final entry in filesByCollection.entries) { + final srcCollectionID = entry.key; + final files = entry.value; + await _copyToCollection( + files, + dstCollectionID: dstCollectionID, + srcCollectionID: srcCollectionID, + ); + } + } + } + + Future _addToCollection(int collectionID, List files) async { + final containsUploadedFile = files.any((e) => e.isUploaded); if (containsUploadedFile) { final existingFileIDsInCollection = await FilesDB.instance.getUploadedFileIDs(collectionID); @@ -1166,6 +1230,13 @@ class CollectionsService { _logger.info("nothing to add to the collection"); return; } + final anyFileOwnedByOther = + files.any((e) => e.ownerID != null && e.ownerID != _config.getUserID()); + if (anyFileOwnedByOther) { + throw ArgumentError( + 'Cannot add files owned by other users, they should be copied', + ); + } final params = {}; params["collectionID"] = collectionID; @@ -1263,6 +1334,126 @@ class CollectionsService { } } + Future _copyToCollection( + List files, { + required int dstCollectionID, + required int srcCollectionID, + }) async { + _validateCopyInput(dstCollectionID, srcCollectionID, files); + final batchedFiles = files.chunks(batchSizeCopy); + final params = {}; + params["dstCollectionID"] = dstCollectionID; + params["srcCollectionID"] = srcCollectionID; + for (final batch in batchedFiles) { + params["files"] = []; + for (final batchFile in batch) { + final fileKey = getFileKey(batchFile); + _logger.info( + "srcCollection : $srcCollectionID file: ${batchFile.uploadedFileID} key: ${CryptoUtil.bin2base64(fileKey)} ", + ); + final encryptedKeyData = + CryptoUtil.encryptSync(fileKey, getCollectionKey(dstCollectionID)); + batchFile.encryptedKey = + CryptoUtil.bin2base64(encryptedKeyData.encryptedData!); + batchFile.keyDecryptionNonce = + CryptoUtil.bin2base64(encryptedKeyData.nonce!); + params["files"].add( + CollectionFileItem( + batchFile.uploadedFileID!, + batchFile.encryptedKey!, + batchFile.keyDecryptionNonce!, + ).toMap(), + ); + } + + try { + final res = await _enteDio.post( + "/files/copy", + data: params, + ); + final oldToCopiedFileIDMap = Map.from( + (res.data["oldToNewFileIDMap"] as Map).map( + (key, value) => MapEntry(int.parse(key), value as int), + ), + ); + for (final file in batch) { + final int uploadIDForOriginalFIle = file.uploadedFileID!; + if (oldToCopiedFileIDMap.containsKey(uploadIDForOriginalFIle)) { + file.generatedID = null; + file.collectionID = dstCollectionID; + file.uploadedFileID = oldToCopiedFileIDMap[uploadIDForOriginalFIle]; + file.ownerID = _config.getUserID(); + oldToCopiedFileIDMap.remove(uploadIDForOriginalFIle); + } else { + throw Exception("Failed to copy file ${file.uploadedFileID}"); + } + } + if (oldToCopiedFileIDMap.isNotEmpty) { + throw Exception( + "Failed to map following uploadKey ${oldToCopiedFileIDMap.keys}", + ); + } + await _filesDB.insertMultiple(batch); + Bus.instance + .fire(CollectionUpdatedEvent(dstCollectionID, batch, "copiedTo")); + } catch (e) { + rethrow; + } + } + } + + Future<(List, List)> _splitFilesToAddAndCopy( + List othersFile, + ) async { + final hashToUserFile = + await _filesDB.getUserOwnedFilesWithSameHashForGivenListOfFiles( + othersFile, + _config.getUserID()!, + ); + final List filesToCopy = []; + final List filesToAdd = []; + for (final EnteFile file in othersFile) { + if (hashToUserFile.containsKey(file.hash ?? '')) { + final userFile = hashToUserFile[file.hash]!; + if (userFile.fileType == file.fileType) { + filesToAdd.add(userFile); + } else { + filesToCopy.add(file); + } + } else { + filesToCopy.add(file); + } + } + return (filesToAdd, filesToCopy); + } + + void _validateCopyInput( + int destCollectionID, + int srcCollectionID, + List files, + ) { + final dstCollection = _collectionIDToCollections[destCollectionID]; + final srcCollection = _collectionIDToCollections[srcCollectionID]; + if (dstCollection == null || !dstCollection.isOwner(_config.getUserID()!)) { + throw ArgumentError( + 'Destination collection not found ${dstCollection == null} or not owned by user ', + ); + } + if (srcCollection == null) { + throw ArgumentError('Source collection not found'); + } + // verify that all fileIds belong to srcCollection and isn't owned by current user + for (final f in files) { + if (f.collectionID != srcCollectionID || + f.ownerID == _config.getUserID()) { + _logger.warning( + 'file $f does not belong to srcCollection $srcCollection or is owned by current user ${f.ownerID}', + ); + throw ArgumentError(''); + } + } + } + Future linkLocalFileToExistingUploadedFileInAnotherCollection( int destCollectionID, { required EnteFile localFileToUpload, @@ -1481,10 +1672,13 @@ class CollectionsService { for (final file in batch) { params["fileIDs"].add(file.uploadedFileID); } - await _enteDio.post( + final resp = await _enteDio.post( "/collections/v3/remove-files", data: params, ); + if (resp.statusCode != 200) { + throw Exception("Failed to remove files from collection"); + } await _filesDB.removeFromCollection(collectionID, params["fileIDs"]); Bus.instance diff --git a/mobile/lib/services/favorites_service.dart b/mobile/lib/services/favorites_service.dart index 5388a6e67..fef4a323a 100644 --- a/mobile/lib/services/favorites_service.dart +++ b/mobile/lib/services/favorites_service.dart @@ -24,6 +24,7 @@ class FavoritesService { late FilesDB _filesDB; int? _cachedFavoritesCollectionID; final Set _cachedFavUploadedIDs = {}; + final Map _cachedFavFileHases = {}; final Set _cachedPendingLocalIDs = {}; late StreamSubscription _collectionUpdatesSubscription; @@ -60,9 +61,12 @@ class FavoritesService { Future _warmUpCache() async { final favCollection = await _getFavoritesCollection(); if (favCollection != null) { - final uploadedIDs = - await FilesDB.instance.getUploadedFileIDs(favCollection.id); + Set uploadedIDs; + Map fileHashes; + (uploadedIDs, fileHashes) = + await FilesDB.instance.getUploadAndHash(favCollection.id); _cachedFavUploadedIDs.addAll(uploadedIDs); + _cachedFavFileHases.addAll(fileHashes); } } @@ -87,6 +91,9 @@ class FavoritesService { return false; } if (file.uploadedFileID != null) { + if (file.ownerID != _config.getUserID() && file.hash != null) { + return _cachedFavFileHases.containsKey(file.hash!); + } return _cachedFavUploadedIDs.contains(file.uploadedFileID); } else if (file.localID != null) { return _cachedPendingLocalIDs.contains(file.localID); @@ -99,6 +106,9 @@ class FavoritesService { if (collection == null || file.uploadedFileID == null) { return false; } + if (file.ownerID != _config.getUserID() && file.hash != null) { + return _cachedFavFileHases.containsKey(file.hash!); + } return _filesDB.doesFileExistInCollection( file.uploadedFileID!, collection.id, @@ -110,10 +120,14 @@ class FavoritesService { required bool favFlag, }) { final Set updatedIDs = {}; + final Map hashes = {}; final Set localIDs = {}; for (var file in files) { if (file.uploadedFileID != null) { updatedIDs.add(file.uploadedFileID!); + if (file.hash != null) { + hashes[file.hash!] = file.uploadedFileID!; + } } else if (file.localID != null || file.localID != "") { /* Note: Favorite un-uploaded files For such files, as we don't have uploaded IDs yet, we will cache @@ -124,8 +138,12 @@ class FavoritesService { } if (favFlag) { _cachedFavUploadedIDs.addAll(updatedIDs); + _cachedFavFileHases.addAll(hashes); } else { _cachedFavUploadedIDs.removeAll(updatedIDs); + for (var hash in hashes.keys) { + _cachedFavFileHases.remove(hash); + } } } @@ -137,7 +155,7 @@ class FavoritesService { await _filesDB.insert(file); Bus.instance.fire(CollectionUpdatedEvent(collectionID, files, "addTFav")); } else { - await _collectionsService.addToCollection(collectionID, files); + await _collectionsService.addOrCopyToCollection(collectionID, files); } _updateFavoriteFilesCache(files, favFlag: true); RemoteSyncService.instance.sync(silently: true).ignore(); @@ -153,11 +171,11 @@ class FavoritesService { throw AssertionError("Can only favorite uploaded items"); } if (files.any((f) => f.ownerID != currentUserID)) { - throw AssertionError("Can not favortie files owned by others"); + throw AssertionError("Can not favorite files owned by others"); } final collectionID = await _getOrCreateFavoriteCollectionID(); if (favFlag) { - await _collectionsService.addToCollection(collectionID, files); + await _collectionsService.addOrCopyToCollection(collectionID, files); } else { final Collection? favCollection = await _getFavoritesCollection(); await _collectionActions.moveFilesFromCurrentCollection( @@ -169,17 +187,30 @@ class FavoritesService { _updateFavoriteFilesCache(files, favFlag: favFlag); } - Future removeFromFavorites(BuildContext context, EnteFile file) async { - final fileID = file.uploadedFileID; - if (fileID == null) { + Future removeFromFavorites( + BuildContext context, + EnteFile file, + ) async { + final inUploadID = file.uploadedFileID; + if (inUploadID == null) { // Do nothing, ignore } else { final Collection? favCollection = await _getFavoritesCollection(); // The file might be part of another collection. For unfav, we need to // move file from the fav collection to the . + if (file.ownerID != _config.getUserID() && + _cachedFavFileHases.containsKey(file.hash!)) { + final EnteFile? favFile = await FilesDB.instance.getUploadedFile( + _cachedFavFileHases[file.hash!]!, + favCollection!.id, + ); + if (favFile != null) { + file = favFile; + } + } if (file.collectionID != favCollection!.id) { final EnteFile? favFile = await FilesDB.instance.getUploadedFile( - fileID, + file.uploadedFileID!, favCollection.id, ); if (favFile != null) { diff --git a/mobile/lib/services/feature_flag_service.dart b/mobile/lib/services/feature_flag_service.dart deleted file mode 100644 index 2891b03f6..000000000 --- a/mobile/lib/services/feature_flag_service.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:logging/logging.dart'; -import 'package:photos/core/configuration.dart'; -import 'package:photos/core/constants.dart'; -import 'package:photos/core/network/network.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class FeatureFlagService { - FeatureFlagService._privateConstructor(); - - static final FeatureFlagService instance = - FeatureFlagService._privateConstructor(); - static const _featureFlagsKey = "feature_flags_key"; - static final _internalUserIDs = const String.fromEnvironment( - "internal_user_ids", - defaultValue: "1,2,3,4,191,125,1580559962388044,1580559962392434,10000025", - ).split(",").map((element) { - return int.parse(element); - }).toSet(); - - final _logger = Logger("FeatureFlagService"); - FeatureFlags? _featureFlags; - late SharedPreferences _prefs; - - Future init() async { - _prefs = await SharedPreferences.getInstance(); - // Fetch feature flags from network in async manner. - // Intention of delay is to give more CPU cycles to other tasks - Future.delayed( - const Duration(seconds: 5), - () { - fetchFeatureFlags(); - }, - ); - } - - FeatureFlags _getFeatureFlags() { - _featureFlags ??= - FeatureFlags.fromJson(_prefs.getString(_featureFlagsKey)!); - // if nothing is cached, use defaults as temporary fallback - if (_featureFlags == null) { - return FeatureFlags.defaultFlags; - } - return _featureFlags!; - } - - bool disableCFWorker() { - try { - return _getFeatureFlags().disableCFWorker; - } catch (e) { - _logger.severe(e); - return FFDefault.disableCFWorker; - } - } - - bool enableStripe() { - if (Platform.isIOS) { - return false; - } - try { - return _getFeatureFlags().enableStripe; - } catch (e) { - _logger.severe(e); - return FFDefault.enableStripe; - } - } - - bool enablePasskey() { - try { - if (isInternalUserOrDebugBuild()) { - return true; - } - return _getFeatureFlags().enablePasskey; - } catch (e) { - _logger.info('error in enablePasskey check', e); - return FFDefault.enablePasskey; - } - } - - bool isInternalUserOrDebugBuild() { - final String? email = Configuration.instance.getEmail(); - final userID = Configuration.instance.getUserID(); - return (email != null && email.endsWith("@ente.io")) || - _internalUserIDs.contains(userID) || - kDebugMode; - } - - Future fetchFeatureFlags() async { - try { - final response = await NetworkClient.instance - .getDio() - .get("https://static.ente.io/feature_flags.json"); - final flagsResponse = FeatureFlags.fromMap(response.data); - await _prefs.setString(_featureFlagsKey, flagsResponse.toJson()); - _featureFlags = flagsResponse; - } catch (e) { - _logger.severe("Failed to sync feature flags ", e); - } - } -} - -class FeatureFlags { - static FeatureFlags defaultFlags = FeatureFlags( - disableCFWorker: FFDefault.disableCFWorker, - enableStripe: FFDefault.enableStripe, - enablePasskey: FFDefault.enablePasskey, - ); - - final bool disableCFWorker; - final bool enableStripe; - final bool enablePasskey; - - FeatureFlags({ - required this.disableCFWorker, - required this.enableStripe, - required this.enablePasskey, - }); - - Map toMap() { - return { - "disableCFWorker": disableCFWorker, - "enableStripe": enableStripe, - "enablePasskey": enablePasskey, - }; - } - - String toJson() => json.encode(toMap()); - - factory FeatureFlags.fromJson(String source) => - FeatureFlags.fromMap(json.decode(source)); - - factory FeatureFlags.fromMap(Map json) { - return FeatureFlags( - disableCFWorker: json["disableCFWorker"] ?? FFDefault.disableCFWorker, - enableStripe: json["enableStripe"] ?? FFDefault.enableStripe, - enablePasskey: json["enablePasskey"] ?? FFDefault.enablePasskey, - ); - } -} diff --git a/mobile/lib/services/filter/collection_ignore.dart b/mobile/lib/services/filter/collection_ignore.dart index d9e27f094..f7f50a7df 100644 --- a/mobile/lib/services/filter/collection_ignore.dart +++ b/mobile/lib/services/filter/collection_ignore.dart @@ -1,26 +1,38 @@ import 'package:photos/models/file/file.dart'; import "package:photos/services/filter/filter.dart"; -// CollectionsIgnoreFilter will filter out files that are in present in the -// given collections. This is useful for filtering out files that are in archive -// or hidden collections from home page and other places -class CollectionsIgnoreFilter extends Filter { +// CollectionsOrHashIgnoreFilter will filter out all files that are in present in the +// given collections collectionIDs. This is useful for filtering out files that are in archive +// or hidden collections from home page and other places. Based on flag, it will also filter out +// shared files if the user already as another file with the same hash. +class CollectionsAndSavedFileFilter extends Filter { final Set collectionIDs; + final bool ignoreSavedFiles; + final int ownerID; Set? _ignoredUploadIDs; + Set ownedFileHashes = {}; - CollectionsIgnoreFilter(this.collectionIDs, List files) : super() { + CollectionsAndSavedFileFilter( + this.collectionIDs, + this.ownerID, + List files, + this.ignoreSavedFiles, + ) : super() { init(files); } void init(List files) { _ignoredUploadIDs = {}; - if (collectionIDs.isEmpty) return; for (var file in files) { - if (file.collectionID != null && - file.isUploaded && - collectionIDs.contains(file.collectionID!)) { - _ignoredUploadIDs!.add(file.uploadedFileID!); + if (file.collectionID != null && file.isUploaded) { + if (collectionIDs.contains(file.collectionID!)) { + _ignoredUploadIDs!.add(file.uploadedFileID!); + } else if (ignoreSavedFiles && + file.ownerID == ownerID && + (file.hash ?? '').isNotEmpty) { + ownedFileHashes.add(file.hash!); + } } } } @@ -37,6 +49,16 @@ class CollectionsIgnoreFilter extends Filter { } return true; } - return !_ignoredUploadIDs!.contains(file.uploadedFileID!); + if (_ignoredUploadIDs!.contains(file.uploadedFileID!)) { + return false; // this file should be filtered out + } + if (ignoreSavedFiles && + file.ownerID != ownerID && + (file.hash ?? '').isNotEmpty) { + // if the file is shared and the user already has a file with the same hash + // then filter it out by returning false + return !ownedFileHashes.contains(file.hash!); + } + return true; } } diff --git a/mobile/lib/services/filter/db_filters.dart b/mobile/lib/services/filter/db_filters.dart index ad062fe1c..0c969dc52 100644 --- a/mobile/lib/services/filter/db_filters.dart +++ b/mobile/lib/services/filter/db_filters.dart @@ -1,3 +1,4 @@ +import "package:photos/core/configuration.dart"; import 'package:photos/models/file/file.dart'; import "package:photos/services/filter/collection_ignore.dart"; import "package:photos/services/filter/dedupe_by_upload_id.dart"; @@ -12,11 +13,14 @@ class DBFilterOptions { Set? ignoredCollectionIDs; bool dedupeUploadID; bool hideIgnoredForUpload; + // If true, shared files that are already saved in the users account will be ignored. + bool ignoreSavedFiles; DBFilterOptions({ this.ignoredCollectionIDs, this.hideIgnoredForUpload = false, this.dedupeUploadID = true, + this.ignoreSavedFiles = false, }); static DBFilterOptions dedupeOption = DBFilterOptions( @@ -42,12 +46,18 @@ Future> applyDBFilters( if (options.dedupeUploadID) { filters.add(DedupeUploadIDFilter()); } - if (options.ignoredCollectionIDs != null && - options.ignoredCollectionIDs!.isNotEmpty) { - final collectionIgnoreFilter = - CollectionsIgnoreFilter(options.ignoredCollectionIDs!, files); + + if ((options.ignoredCollectionIDs ?? {}).isNotEmpty || + options.ignoreSavedFiles) { + final collectionIgnoreFilter = CollectionsAndSavedFileFilter( + options.ignoredCollectionIDs ?? {}, + Configuration.instance.getUserID() ?? 0, + files, + options.ignoreSavedFiles, + ); filters.add(collectionIgnoreFilter); } + final List filterFiles = []; for (final file in files) { if (filters.every((f) => f.filter(file))) { diff --git a/mobile/lib/services/home_widget_service.dart b/mobile/lib/services/home_widget_service.dart index 33ef5d2bb..7b44310c2 100644 --- a/mobile/lib/services/home_widget_service.dart +++ b/mobile/lib/services/home_widget_service.dart @@ -8,9 +8,14 @@ import "package:logging/logging.dart"; import "package:photos/core/configuration.dart"; import "package:photos/core/constants.dart"; import "package:photos/db/files_db.dart"; +import "package:photos/models/collection/collection_items.dart"; import "package:photos/models/file/file_type.dart"; +import "package:photos/services/collections_service.dart"; import "package:photos/services/favorites_service.dart"; +import "package:photos/ui/viewer/file/detail_page.dart"; +import "package:photos/ui/viewer/gallery/collection_page.dart"; import "package:photos/utils/file_util.dart"; +import "package:photos/utils/navigation_util.dart"; import "package:photos/utils/preload_util.dart"; class HomeWidgetService { @@ -171,4 +176,49 @@ class HomeWidgetService { ); _logger.info(">>> SlideshowWidget cleared"); } + + Future onLaunchFromWidget(Uri? uri, BuildContext context) async { + if (uri == null) return; + + final collectionID = + await FavoritesService.instance.getFavoriteCollectionID(); + if (collectionID == null) { + return; + } + + final collection = CollectionsService.instance.getCollectionByID( + collectionID, + ); + if (collection == null) { + return; + } + + final thumbnail = await CollectionsService.instance.getCover(collection); + + final previousGeneratedId = + await hw.HomeWidget.getWidgetData("home_widget_last_img"); + + final res = previousGeneratedId != null + ? await FilesDB.instance.getFile( + previousGeneratedId, + ) + : null; + + routeToPage( + context, + CollectionPage( + CollectionWithThumbnail( + collection, + thumbnail, + ), + ), + ).ignore(); + + if (res == null) return; + + final page = DetailPage( + DetailPageConfiguration(List.unmodifiable([res]), null, 0, "collection"), + ); + routeToPage(context, page, forceCustomPageRoute: true).ignore(); + } } diff --git a/mobile/lib/services/memories_service.dart b/mobile/lib/services/memories_service.dart index de68e2dab..646113128 100644 --- a/mobile/lib/services/memories_service.dart +++ b/mobile/lib/services/memories_service.dart @@ -107,7 +107,7 @@ class MemoriesService extends ChangeNotifier { } final ignoredCollections = CollectionsService.instance.archivedOrHiddenCollectionIds(); - final files = await _filesDB.getFilesCreatedWithinDurationsSync( + final files = await _filesDB.getFilesCreatedWithinDurations( durations, ignoredCollections, visibility: visibleVisibility, diff --git a/mobile/lib/services/remote_sync_service.dart b/mobile/lib/services/remote_sync_service.dart index 4c5222758..eab8478a6 100644 --- a/mobile/lib/services/remote_sync_service.dart +++ b/mobile/lib/services/remote_sync_service.dart @@ -23,9 +23,9 @@ import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; import 'package:photos/models/file/file_type.dart'; import 'package:photos/models/upload_strategy.dart'; +import "package:photos/service_locator.dart"; import 'package:photos/services/app_lifecycle_service.dart'; import 'package:photos/services/collections_service.dart'; -import "package:photos/services/feature_flag_service.dart"; import 'package:photos/services/ignored_files_service.dart'; import 'package:photos/services/local_file_update_service.dart'; import "package:photos/services/notification_service.dart"; @@ -185,7 +185,7 @@ class RemoteSyncService { rethrow; } else { _logger.severe("Error executing remote sync ", e, s); - if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) { + if (flagService.internalUser) { rethrow; } } 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/actions/collection/collection_file_actions.dart b/mobile/lib/ui/actions/collection/collection_file_actions.dart index 8315c235b..81b79825a 100644 --- a/mobile/lib/ui/actions/collection/collection_file_actions.dart +++ b/mobile/lib/ui/actions/collection/collection_file_actions.dart @@ -184,7 +184,8 @@ extension CollectionFileActions on CollectionActions { } } if (files.isNotEmpty) { - await CollectionsService.instance.addToCollection(collectionID, files); + await CollectionsService.instance + .addOrCopyToCollection(collectionID, files); } unawaited(RemoteSyncService.instance.sync(silently: true)); await dialog?.hide(); diff --git a/mobile/lib/ui/actions/collection/collection_sharing_actions.dart b/mobile/lib/ui/actions/collection/collection_sharing_actions.dart index dc28197bd..7993c4342 100644 --- a/mobile/lib/ui/actions/collection/collection_sharing_actions.dart +++ b/mobile/lib/ui/actions/collection/collection_sharing_actions.dart @@ -140,7 +140,7 @@ class CollectionActions { req, ); logger.finest("adding files to share to new album"); - await collectionsService.addToCollection(collection.id, files); + await collectionsService.addOrCopyToCollection(collection.id, files); logger.finest("creating public link for the newly created album"); await CollectionsService.instance.createShareUrl(collection); await dialog.hide(); 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/home/home_gallery_widget.dart b/mobile/lib/ui/home/home_gallery_widget.dart index 195eb7b95..5d9f9c09d 100644 --- a/mobile/lib/ui/home/home_gallery_widget.dart +++ b/mobile/lib/ui/home/home_gallery_widget.dart @@ -41,6 +41,7 @@ class HomeGalleryWidget extends StatelessWidget { hideIgnoredForUpload: true, dedupeUploadID: true, ignoredCollectionIDs: collectionsToHide, + ignoreSavedFiles: true, ); if (hasSelectedAllForBackup) { result = await FilesDB.instance.getAllLocalAndUploadedFiles( 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/payment/subscription.dart b/mobile/lib/ui/payment/subscription.dart index 0327c3ab5..c30a1c67d 100644 --- a/mobile/lib/ui/payment/subscription.dart +++ b/mobile/lib/ui/payment/subscription.dart @@ -1,6 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:photos/core/configuration.dart'; -import 'package:photos/services/feature_flag_service.dart'; +import "package:photos/service_locator.dart"; import 'package:photos/services/update_service.dart'; import "package:photos/ui/payment/store_subscription_page.dart"; import 'package:photos/ui/payment/stripe_subscription_page.dart'; @@ -9,8 +9,7 @@ StatefulWidget getSubscriptionPage({bool isOnBoarding = false}) { if (UpdateService.instance.isIndependentFlavor()) { return StripeSubscriptionPage(isOnboarding: isOnBoarding); } - if (FeatureFlagService.instance.enableStripe() && - _isUserCreatedPostStripeSupport()) { + if (flagService.enableStripe && _isUserCreatedPostStripeSupport()) { return StripeSubscriptionPage(isOnboarding: isOnBoarding); } else { return StoreSubscriptionPage(isOnboarding: isOnBoarding); 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/settings/machine_learning_settings_page.dart b/mobile/lib/ui/settings/machine_learning_settings_page.dart index 0ad5bce31..3306ea36f 100644 --- a/mobile/lib/ui/settings/machine_learning_settings_page.dart +++ b/mobile/lib/ui/settings/machine_learning_settings_page.dart @@ -5,7 +5,7 @@ import "package:intl/intl.dart"; import "package:photos/core/event_bus.dart"; import 'package:photos/events/embedding_updated_event.dart'; import "package:photos/generated/l10n.dart"; -import "package:photos/services/feature_flag_service.dart"; +import "package:photos/service_locator.dart"; import 'package:photos/services/machine_learning/semantic_search/frameworks/ml_framework.dart'; import 'package:photos/services/machine_learning/semantic_search/semantic_search_service.dart'; import "package:photos/theme/ente_theme.dart"; @@ -151,7 +151,7 @@ class _MachineLearningSettingsPageState const SizedBox( height: 12, ), - FeatureFlagService.instance.isInternalUserOrDebugBuild() + flagService.internalUser ? MenuItemWidget( leadingIcon: Icons.delete_sweep_outlined, captionedTextWidget: CaptionedTextWidget( diff --git a/mobile/lib/ui/settings/security_section_widget.dart b/mobile/lib/ui/settings/security_section_widget.dart index dce7e97ec..eb93d85f6 100644 --- a/mobile/lib/ui/settings/security_section_widget.dart +++ b/mobile/lib/ui/settings/security_section_widget.dart @@ -10,7 +10,7 @@ import 'package:photos/events/two_factor_status_change_event.dart'; import "package:photos/generated/l10n.dart"; import "package:photos/l10n/l10n.dart"; import "package:photos/models/user_details.dart"; -import "package:photos/services/feature_flag_service.dart"; +import 'package:photos/service_locator.dart'; import 'package:photos/services/local_authentication_service.dart'; import "package:photos/services/passkey_service.dart"; import 'package:photos/services/user_service.dart'; @@ -70,8 +70,6 @@ class _SecuritySectionWidgetState extends State { final Completer completer = Completer(); final List children = []; if (_config.hasConfiguredAccount()) { - final bool isInternalUser = - FeatureFlagService.instance.isInternalUserOrDebugBuild(); children.addAll( [ sectionOptionSpacing, @@ -103,8 +101,8 @@ class _SecuritySectionWidgetState extends State { }, ), ), - if (isInternalUser) sectionOptionSpacing, - if (isInternalUser) + if (flagService.passKeyEnabled) sectionOptionSpacing, + if (flagService.passKeyEnabled) MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: context.l10n.passkey, diff --git a/mobile/lib/ui/settings_page.dart b/mobile/lib/ui/settings_page.dart index 51db27595..d5ba1254f 100644 --- a/mobile/lib/ui/settings_page.dart +++ b/mobile/lib/ui/settings_page.dart @@ -7,7 +7,7 @@ import 'package:photos/core/configuration.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/events/opened_settings_event.dart'; import "package:photos/generated/l10n.dart"; -import 'package:photos/services/feature_flag_service.dart'; +import "package:photos/service_locator.dart"; import "package:photos/services/storage_bonus_service.dart"; import 'package:photos/theme/colors.dart'; import 'package:photos/theme/ente_theme.dart'; @@ -140,8 +140,7 @@ class SettingsPage extends StatelessWidget { const AboutSectionWidget(), ]); - if (hasLoggedIn && - FeatureFlagService.instance.isInternalUserOrDebugBuild()) { + if (hasLoggedIn && flagService.internalUser) { contents.addAll([sectionSpacing, const DebugSectionWidget()]); } contents.add(const AppVersionWidget()); 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/debug/app_storage_viewer.dart b/mobile/lib/ui/tools/debug/app_storage_viewer.dart index 055457e08..50ec16c25 100644 --- a/mobile/lib/ui/tools/debug/app_storage_viewer.dart +++ b/mobile/lib/ui/tools/debug/app_storage_viewer.dart @@ -7,7 +7,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:photos/core/cache/video_cache_manager.dart'; import 'package:photos/core/configuration.dart'; import "package:photos/generated/l10n.dart"; -import 'package:photos/services/feature_flag_service.dart'; +import "package:photos/service_locator.dart"; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/buttons/icon_button_widget.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; @@ -34,7 +34,7 @@ class _AppStorageViewerState extends State { @override void initState() { - internalUser = FeatureFlagService.instance.isInternalUserOrDebugBuild(); + internalUser = flagService.internalUser; addPath(); super.initState(); } 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/actions/file_selection_actions_widget.dart b/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart index dff39ef60..e2e29e021 100644 --- a/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart +++ b/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart @@ -14,6 +14,7 @@ import 'package:photos/models/files_split.dart'; import 'package:photos/models/gallery_type.dart'; import "package:photos/models/metadata/common_keys.dart"; import 'package:photos/models/selected_files.dart'; +import "package:photos/service_locator.dart"; import 'package:photos/services/collections_service.dart'; import 'package:photos/services/hidden_service.dart'; import "package:photos/theme/colors.dart"; @@ -59,6 +60,7 @@ class _FileSelectionActionsWidgetState late FilesSplit split; late CollectionActions collectionActions; late bool isCollectionOwner; + bool _isInternalUser = false; // _cachedCollectionForSharedLink is primarily used to avoid creating duplicate // links if user keeps on creating Create link button after selecting @@ -69,6 +71,7 @@ class _FileSelectionActionsWidgetState @override void initState() { currentUserID = Configuration.instance.getUserID()!; + split = FilesSplit.split([], currentUserID); widget.selectedFiles.addListener(_selectFileChangeListener); collectionActions = CollectionActions(CollectionsService.instance); @@ -95,6 +98,7 @@ class _FileSelectionActionsWidgetState @override Widget build(BuildContext context) { + _isInternalUser = flagService.internalUser; final ownedFilesCount = split.ownedByCurrentUser.length; final ownedAndPendingUploadFilesCount = ownedFilesCount + split.pendingUploads.length; @@ -140,13 +144,14 @@ class _FileSelectionActionsWidgetState final showUploadIcon = widget.type == GalleryType.localFolder && split.ownedByCurrentUser.isEmpty; - if (widget.type.showAddToAlbum()) { + if (widget.type.showAddToAlbum() || + (_isInternalUser && widget.type == GalleryType.sharedCollection)) { if (showUploadIcon) { items.add( SelectionActionButton( icon: Icons.cloud_upload_outlined, labelText: S.of(context).addToEnte, - onTap: anyOwnedFiles ? _addToAlbum : null, + onTap: (anyOwnedFiles || _isInternalUser) ? _addToAlbum : null, ), ); } else { @@ -154,8 +159,8 @@ class _FileSelectionActionsWidgetState SelectionActionButton( icon: Icons.add_outlined, labelText: S.of(context).addToAlbum, - onTap: anyOwnedFiles ? _addToAlbum : null, - shouldShow: ownedAndPendingUploadFilesCount > 0, + onTap: (anyOwnedFiles || _isInternalUser) ? _addToAlbum : null, + shouldShow: ownedAndPendingUploadFilesCount > 0 || _isInternalUser, ), ); } @@ -434,7 +439,7 @@ class _FileSelectionActionsWidgetState } Future _addToAlbum() async { - if (split.ownedByOtherUsers.isNotEmpty) { + if (split.ownedByOtherUsers.isNotEmpty && !_isInternalUser) { widget.selectedFiles .unSelectAll(split.ownedByOtherUsers.toSet(), skipNotify: true); } diff --git a/mobile/lib/ui/viewer/file/file_app_bar.dart b/mobile/lib/ui/viewer/file/file_app_bar.dart index 126f3093d..444afdbe6 100644 --- a/mobile/lib/ui/viewer/file/file_app_bar.dart +++ b/mobile/lib/ui/viewer/file/file_app_bar.dart @@ -18,6 +18,7 @@ import 'package:photos/models/file/trash_file.dart'; import 'package:photos/models/ignored_file.dart'; import "package:photos/models/metadata/common_keys.dart"; import 'package:photos/models/selected_files.dart'; +import "package:photos/service_locator.dart"; import 'package:photos/services/collections_service.dart'; import 'package:photos/services/hidden_service.dart'; import 'package:photos/services/ignored_files_service.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,10 @@ class FileAppBarState extends State { ); } // only show fav option for files owned by the user - if (isOwnedByUser && !isFileHidden && isFileUploaded) { - actions.add( + if ((isOwnedByUser || flagService.internalUser) && + !isFileHidden && + isFileUploaded) { + _actions.add( Padding( padding: const EdgeInsets.all(8), child: FavoriteWidget(widget.file), @@ -126,7 +152,7 @@ class FileAppBarState extends State { ); } if (!isFileUploaded) { - actions.add( + _actions.add( UploadIconWidget( file: widget.file, key: ValueKey(widget.file.tag), @@ -241,7 +267,7 @@ class FileAppBarState extends State { } } if (items.isNotEmpty) { - actions.add( + _actions.add( PopupMenuButton( itemBuilder: (context) { return items; @@ -262,13 +288,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/file/video_widget.dart b/mobile/lib/ui/viewer/file/video_widget.dart index c9c07df5c..7f9218e9a 100644 --- a/mobile/lib/ui/viewer/file/video_widget.dart +++ b/mobile/lib/ui/viewer/file/video_widget.dart @@ -9,7 +9,7 @@ import 'package:photos/core/constants.dart'; import "package:photos/generated/l10n.dart"; import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; -import "package:photos/services/feature_flag_service.dart"; +import "package:photos/service_locator.dart"; import 'package:photos/services/files_service.dart'; import "package:photos/ui/actions/file/file_actions.dart"; import 'package:photos/ui/viewer/file/thumbnail_widget.dart'; @@ -161,8 +161,7 @@ class _VideoWidgetState extends State { } }).onError( (error, stackTrace) { - if (mounted && - FeatureFlagService.instance.isInternalUserOrDebugBuild()) { + if (mounted && flagService.internalUser) { if (error is Exception) { showErrorDialogForException( context: context, diff --git a/mobile/lib/ui/viewer/file_details/favorite_widget.dart b/mobile/lib/ui/viewer/file_details/favorite_widget.dart index 15fb7397c..3371b1442 100644 --- a/mobile/lib/ui/viewer/file_details/favorite_widget.dart +++ b/mobile/lib/ui/viewer/file_details/favorite_widget.dart @@ -3,11 +3,11 @@ import "dart:async"; import "package:flutter/material.dart"; import "package:like_button/like_button.dart"; import "package:logging/logging.dart"; +import "package:photos/core/configuration.dart"; import "package:photos/generated/l10n.dart"; import 'package:photos/models/file/file.dart'; import "package:photos/services/favorites_service.dart"; -import "package:photos/ui/common/progress_dialog.dart"; -import "package:photos/utils/dialog_util.dart"; +import "package:photos/ui/common/loading_widget.dart"; import "package:photos/utils/toast_util.dart"; class FavoriteWidget extends StatefulWidget { @@ -18,13 +18,13 @@ class FavoriteWidget extends StatefulWidget { super.key, }); - // State createState() => _ShareCollectionPageState(); @override State createState() => _FavoriteWidgetState(); } class _FavoriteWidgetState extends State { late Logger _logger; + bool _isLoading = false; @override void initState() { @@ -42,61 +42,71 @@ class _FavoriteWidgetState extends State { future: _fetchData(), builder: (context, snapshot) { final bool isLiked = snapshot.data ?? false; - return LikeButton( - size: 24, - isLiked: isLiked, - onTap: (oldValue) async { - final isLiked = !oldValue; - bool hasError = false; - if (isLiked) { - final shouldBlockUser = widget.file.uploadedFileID == null; - late ProgressDialog dialog; - if (shouldBlockUser) { - dialog = createProgressDialog( - context, - S.of(context).addingToFavorites, - ); - await dialog.show(); - } - try { - await FavoritesService.instance.addToFavorites( - context, - widget.file, - ); - } catch (e, s) { - _logger.severe(e, s); - hasError = true; - showToast(context, S.of(context).sorryCouldNotAddToFavorites); - } finally { - if (shouldBlockUser) { - await dialog.hide(); - } - } - } else { - try { - await FavoritesService.instance - .removeFromFavorites(context, widget.file); - } catch (e, s) { - _logger.severe(e, s); - hasError = true; - showToast( - context, - S.of(context).sorryCouldNotRemoveFromFavorites, - ); - } - } - return hasError ? oldValue : isLiked; - }, - likeBuilder: (isLiked) { - return Icon( - isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - color: isLiked - ? Colors.pinkAccent - : Colors.white, //same for both themes - size: 24, - ); - }, - ); + return _isLoading + ? const EnteLoadingWidget( + size: 14, + padding: 2, + ) // Add this line + : LikeButton( + size: 24, + isLiked: isLiked, + onTap: (oldValue) async { + if (widget.file.uploadedFileID == null || + widget.file.ownerID != + Configuration.instance.getUserID()!) { + setState(() { + _isLoading = true; // Add this line + }); + } + final isLiked = !oldValue; + bool hasError = false; + if (isLiked) { + try { + await FavoritesService.instance.addToFavorites( + context, + widget.file.copyWith(), + ); + } catch (e, s) { + _logger.severe(e, s); + hasError = true; + showToast( + context, + S.of(context).sorryCouldNotAddToFavorites, + ); + } + } else { + try { + await FavoritesService.instance + .removeFromFavorites(context, widget.file.copyWith()); + } catch (e, s) { + _logger.severe(e, s); + hasError = true; + showToast( + context, + S.of(context).sorryCouldNotRemoveFromFavorites, + ); + } + } + setState(() { + _isLoading = false; // Add this line + }); + return hasError ? oldValue : isLiked; + }, + likeBuilder: (isLiked) { + debugPrint( + "File Upload ID ${widget.file.uploadedFileID} & collection ${widget.file.collectionID}", + ); + return Icon( + isLiked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + color: isLiked + ? Colors.pinkAccent + : Colors.white, //same for both themes + size: 24, + ); + }, + ); }, ); } diff --git a/mobile/lib/ui/viewer/gallery/gallery_app_bar_widget.dart b/mobile/lib/ui/viewer/gallery/gallery_app_bar_widget.dart index 5431e15a2..6c6c1e2ad 100644 --- a/mobile/lib/ui/viewer/gallery/gallery_app_bar_widget.dart +++ b/mobile/lib/ui/viewer/gallery/gallery_app_bar_widget.dart @@ -23,8 +23,8 @@ import 'package:photos/models/device_collection.dart'; import 'package:photos/models/gallery_type.dart'; import "package:photos/models/metadata/common_keys.dart"; import 'package:photos/models/selected_files.dart'; +import 'package:photos/service_locator.dart'; import 'package:photos/services/collections_service.dart'; -import "package:photos/services/feature_flag_service.dart"; import 'package:photos/services/sync_service.dart'; import 'package:photos/services/update_service.dart'; import 'package:photos/ui/actions/collection/collection_sharing_actions.dart'; @@ -100,7 +100,7 @@ class _GalleryAppBarWidgetState extends State { _selectedFilesListener = () { setState(() {}); }; - isInternalUser = FeatureFlagService.instance.isInternalUserOrDebugBuild(); + isInternalUser = flagService.internalUser; collectionActions = CollectionActions(CollectionsService.instance); widget.selectedFiles.addListener(_selectedFilesListener); _userAuthEventSubscription = 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/mobile/lib/utils/dialog_util.dart b/mobile/lib/utils/dialog_util.dart index f9bd733ae..f6e9eb021 100644 --- a/mobile/lib/utils/dialog_util.dart +++ b/mobile/lib/utils/dialog_util.dart @@ -5,7 +5,7 @@ import "package:flutter/services.dart"; import "package:photos/generated/l10n.dart"; import 'package:photos/models/button_result.dart'; import 'package:photos/models/typedefs.dart'; -import "package:photos/services/feature_flag_service.dart"; +import "package:photos/service_locator.dart"; import 'package:photos/theme/colors.dart'; import 'package:photos/ui/common/loading_widget.dart'; import 'package:photos/ui/common/progress_dialog.dart'; @@ -91,8 +91,7 @@ String parseErrorForUI( } } // return generic error if the user is not internal and the error is not in debug mode - if (!(FeatureFlagService.instance.isInternalUserOrDebugBuild() && - kDebugMode)) { + if (!(flagService.internalUser && kDebugMode)) { return genericError; } String errorInfo = ""; diff --git a/mobile/lib/utils/diff_fetcher.dart b/mobile/lib/utils/diff_fetcher.dart index 63a25099d..e48c1e19a 100644 --- a/mobile/lib/utils/diff_fetcher.dart +++ b/mobile/lib/utils/diff_fetcher.dart @@ -27,8 +27,9 @@ class DiffFetcher { final bool hasMore = response.data["hasMore"] as bool; final startTime = DateTime.now(); late Set existingUploadIDs; - if(diff.isNotEmpty) { - existingUploadIDs = await FilesDB.instance.getUploadedFileIDs(collectionID); + if (diff.isNotEmpty) { + existingUploadIDs = + await FilesDB.instance.getUploadedFileIDs(collectionID); } final deletedFiles = []; final updatedFiles = []; @@ -96,8 +97,7 @@ class DiffFetcher { updatedFiles.add(file); } _logger.info('[Collection-$collectionID] parsed ${diff.length} ' - 'diff items ( ${updatedFiles.length} updated) in ${DateTime.now() - .difference(startTime).inMilliseconds}ms'); + 'diff items ( ${updatedFiles.length} updated) in ${DateTime.now().difference(startTime).inMilliseconds}ms'); return Diff(updatedFiles, deletedFiles, hasMore, latestUpdatedAtTime); } catch (e, s) { _logger.severe(e, s); diff --git a/mobile/lib/utils/file_uploader.dart b/mobile/lib/utils/file_uploader.dart index d877ce522..d77bc95d7 100644 --- a/mobile/lib/utils/file_uploader.dart +++ b/mobile/lib/utils/file_uploader.dart @@ -29,7 +29,6 @@ import "package:photos/models/metadata/file_magic.dart"; import 'package:photos/models/upload_url.dart'; import "package:photos/models/user_details.dart"; import 'package:photos/services/collections_service.dart'; -import "package:photos/services/feature_flag_service.dart"; import "package:photos/services/file_magic_service.dart"; import 'package:photos/services/local_sync_service.dart'; import 'package:photos/services/sync_service.dart'; @@ -172,7 +171,7 @@ class FileUploader { ); return CollectionsService.instance - .addToCollection(collectionID, [uploadedFile]).then((aVoid) { + .addOrCopyToCollection(collectionID, [uploadedFile]).then((aVoid) { return uploadedFile; }); }); @@ -402,6 +401,16 @@ class FileUploader { _logger.severe('Trying to upload file with missing localID'); return file; } + if (!CollectionsService.instance.allowUpload(collectionID)) { + _logger.warning( + 'Upload not allowed for collection $collectionID', + ); + if (!file.isUploaded && file.generatedID != null) { + _logger.info("Deleting file entry for " + file.toString()); + await FilesDB.instance.deleteByGeneratedID(file.generatedID!); + } + return file; + } final String lockKey = file.localID!; @@ -497,7 +506,7 @@ class FileUploader { // Calculate the number of parts for the file. Multiple part upload // is only enabled for internal users and debug builds till it's battle tested. - final count = FeatureFlagService.instance.isInternalUserOrDebugBuild() + final count = kDebugMode ? await calculatePartCount( await encryptedFile.length(), ) diff --git a/mobile/lib/utils/multipart_upload_util.dart b/mobile/lib/utils/multipart_upload_util.dart index 6e0eda8ca..102c08d8d 100644 --- a/mobile/lib/utils/multipart_upload_util.dart +++ b/mobile/lib/utils/multipart_upload_util.dart @@ -6,7 +6,7 @@ import "package:dio/dio.dart"; import "package:logging/logging.dart"; import "package:photos/core/constants.dart"; import "package:photos/core/network/network.dart"; -import "package:photos/services/feature_flag_service.dart"; +import "package:photos/service_locator.dart"; import "package:photos/utils/xml_parser_util.dart"; final _enteDio = NetworkClient.instance.enteDio; @@ -58,7 +58,7 @@ Future calculatePartCount(int fileSize) async { Future getMultipartUploadURLs(int count) async { try { assert( - FeatureFlagService.instance.isInternalUserOrDebugBuild(), + flagService.internalUser, "Multipart upload should not be enabled for external users.", ); final response = await _enteDio.get( diff --git a/mobile/plugins/ente_feature_flag/.metadata b/mobile/plugins/ente_feature_flag/.metadata new file mode 100644 index 000000000..9fc7ede54 --- /dev/null +++ b/mobile/plugins/ente_feature_flag/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 0b8abb4724aa590dd0f429683339b1e045a1594d + channel: stable + +project_type: plugin diff --git a/mobile/plugins/ente_feature_flag/analysis_options.yaml b/mobile/plugins/ente_feature_flag/analysis_options.yaml new file mode 100644 index 000000000..fac60e247 --- /dev/null +++ b/mobile/plugins/ente_feature_flag/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml \ No newline at end of file diff --git a/mobile/plugins/ente_feature_flag/lib/ente_feature_flag.dart b/mobile/plugins/ente_feature_flag/lib/ente_feature_flag.dart new file mode 100644 index 000000000..66a7132d8 --- /dev/null +++ b/mobile/plugins/ente_feature_flag/lib/ente_feature_flag.dart @@ -0,0 +1 @@ +export 'src/service.dart'; diff --git a/mobile/plugins/ente_feature_flag/lib/src/model.dart b/mobile/plugins/ente_feature_flag/lib/src/model.dart new file mode 100644 index 000000000..49b292148 --- /dev/null +++ b/mobile/plugins/ente_feature_flag/lib/src/model.dart @@ -0,0 +1,66 @@ +import "dart:convert"; +import "dart:io"; + +import "package:flutter/foundation.dart"; + +class RemoteFlags { + final bool enableStripe; + final bool disableCFWorker; + final bool mapEnabled; + final bool faceSearchEnabled; + final bool passKeyEnabled; + final bool recoveryKeyVerified; + final bool internalUser; + final bool betaUser; + + RemoteFlags({ + required this.enableStripe, + required this.disableCFWorker, + required this.mapEnabled, + required this.faceSearchEnabled, + required this.passKeyEnabled, + required this.recoveryKeyVerified, + required this.internalUser, + required this.betaUser, + }); + + static RemoteFlags defaultValue = RemoteFlags( + enableStripe: Platform.isAndroid, + disableCFWorker: false, + mapEnabled: false, + faceSearchEnabled: false, + passKeyEnabled: false, + recoveryKeyVerified: false, + internalUser: kDebugMode, + betaUser: kDebugMode, + ); + + String toJson() => json.encode(toMap()); + Map toMap() { + return { + 'enableStripe': enableStripe, + 'disableCFWorker': disableCFWorker, + 'mapEnabled': mapEnabled, + 'faceSearchEnabled': faceSearchEnabled, + 'passKeyEnabled': passKeyEnabled, + 'recoveryKeyVerified': recoveryKeyVerified, + 'internalUser': internalUser, + 'betaUser': betaUser, + }; + } + + factory RemoteFlags.fromMap(Map map) { + return RemoteFlags( + enableStripe: map['enableStripe'] ?? defaultValue.enableStripe, + disableCFWorker: map['disableCFWorker'] ?? defaultValue.disableCFWorker, + mapEnabled: map['mapEnabled'] ?? defaultValue.mapEnabled, + faceSearchEnabled: + map['faceSearchEnabled'] ?? defaultValue.faceSearchEnabled, + passKeyEnabled: map['passKeyEnabled'] ?? defaultValue.passKeyEnabled, + recoveryKeyVerified: + map['recoveryKeyVerified'] ?? defaultValue.recoveryKeyVerified, + internalUser: map['internalUser'] ?? defaultValue.internalUser, + betaUser: map['betaUser'] ?? defaultValue.betaUser, + ); + } +} diff --git a/mobile/plugins/ente_feature_flag/lib/src/service.dart b/mobile/plugins/ente_feature_flag/lib/src/service.dart new file mode 100644 index 000000000..47539eeb5 --- /dev/null +++ b/mobile/plugins/ente_feature_flag/lib/src/service.dart @@ -0,0 +1,75 @@ +// ignore_for_file: always_use_package_imports + +import "dart:convert"; +import "dart:developer"; +import "dart:io"; + +import "package:dio/dio.dart"; +import "package:flutter/foundation.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +import "model.dart"; + +class FlagService { + final SharedPreferences _prefs; + final Dio _enteDio; + late final bool _usingEnteEmail; + + FlagService(this._prefs, this._enteDio) { + _usingEnteEmail = _prefs.getString("email")?.endsWith("@ente.io") ?? false; + Future.delayed(const Duration(seconds: 5), () { + _fetch(); + }); + } + + RemoteFlags? _flags; + + RemoteFlags get flags { + try { + if (!_prefs.containsKey("remote_flags")) { + _fetch().ignore(); + } + _flags ??= RemoteFlags.fromMap( + jsonDecode(_prefs.getString("remote_flags") ?? "{}"), + ); + return _flags!; + } catch (e) { + debugPrint("Failed to get feature flags $e"); + return RemoteFlags.defaultValue; + } + } + + Future _fetch() async { + try { + if (!_prefs.containsKey("token")) { + log("token not found, skip", name: "FlagService"); + return; + } + log("fetching feature flags", name: "FlagService"); + final response = await _enteDio.get("/remote-store/feature-flags"); + final remoteFlags = RemoteFlags.fromMap(response.data); + await _prefs.setString("remote_flags", remoteFlags.toJson()); + _flags = remoteFlags; + } catch (e) { + debugPrint("Failed to sync feature flags $e"); + } + } + + bool get disableCFWorker => flags.disableCFWorker; + + bool get internalUser => flags.internalUser || _usingEnteEmail || kDebugMode; + + bool get betaUser => flags.betaUser; + + bool get internalOrBetaUser => internalUser || betaUser; + + bool get enableStripe => Platform.isIOS ? false : flags.enableStripe; + + bool get mapEnabled => flags.mapEnabled; + + bool get faceSearchEnabled => flags.faceSearchEnabled; + + bool get passKeyEnabled => flags.passKeyEnabled || internalOrBetaUser; + + bool get recoveryKeyVerified => flags.recoveryKeyVerified; +} diff --git a/mobile/plugins/ente_feature_flag/pubspec.yaml b/mobile/plugins/ente_feature_flag/pubspec.yaml new file mode 100644 index 000000000..7507d61f1 --- /dev/null +++ b/mobile/plugins/ente_feature_flag/pubspec.yaml @@ -0,0 +1,19 @@ +name: ente_feature_flag +version: 0.0.1 +publish_to: none + +environment: + sdk: '>=3.3.0 <4.0.0' + +dependencies: + collection: + dio: ^4.0.6 + flutter: + sdk: flutter + shared_preferences: ^2.0.5 + stack_trace: + +dev_dependencies: + flutter_lints: + +flutter: \ No newline at end of file diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 3c9ff792c..882967ff0 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -443,6 +443,13 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.17" + ente_feature_flag: + dependency: "direct main" + description: + path: "plugins/ente_feature_flag" + relative: true + source: path + version: "0.0.1" equatable: dependency: "direct main" description: @@ -560,10 +567,10 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: a864d1b6afd25497a3b57b016886d1763df52baaa69758a46723164de8d187fe + sha256: "6b1152a5af3b1cfe7e45309e96fc1aa14873f410f7aadb3878aa7812acfa7531" url: "https://pub.dev" source: hosted - version: "2.29.0" + version: "2.30.0" firebase_core_platform_interface: dependency: transitive description: @@ -584,10 +591,10 @@ packages: dependency: "direct main" description: name: firebase_messaging - sha256: e41586e0fd04fe9a40424f8b0053d0832e6d04f49e020cdaf9919209a28497e9 + sha256: "87e3eda0ecdfeadb5fd1cf0dc5153aea5307a0cfca751c4b1ac97bfdd805660e" url: "https://pub.dev" source: hosted - version: "14.7.19" + version: "14.8.1" firebase_messaging_platform_interface: dependency: transitive description: @@ -714,10 +721,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: a701df4866f9a38bb8e4450a54c143bbeeb0ce2381e7df5a36e1006f3b43bb28 + sha256: f9a05409385b77b06c18f200a41c7c2711ebf7415669350bb0f8474c07bd40d1 url: "https://pub.dev" source: hosted - version: "17.0.1" + version: "17.0.0" flutter_local_notifications_linux: dependency: transitive description: @@ -2064,7 +2071,7 @@ packages: source: hosted version: "0.3.0" sqlite3: - dependency: "direct main" + dependency: transitive description: name: sqlite3 sha256: "072128763f1547e3e9b4735ce846bfd226d68019ccda54db4cd427b12dfdedc9" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index bc0d4ba2d..a641211ea 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,7 @@ description: ente photos application # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.8.81+601 +version: 0.8.84+604 publish_to: none environment: @@ -51,6 +51,8 @@ dependencies: dotted_border: ^2.1.0 dropdown_button2: ^2.0.0 email_validator: ^2.0.1 + ente_feature_flag: + path: plugins/ente_feature_flag equatable: ^2.0.5 event_bus: ^2.0.0 exif: ^3.0.0 @@ -63,8 +65,8 @@ dependencies: file_saver: # Use forked version till this PR is merged: https://github.com/incrediblezayed/file_saver/pull/87 git: https://github.com/jesims/file_saver.git - firebase_core: ^2.13.1 - firebase_messaging: ^14.6.2 + firebase_core: ^2.30.0 + firebase_messaging: ^14.8.0 fk_user_agent: ^2.0.1 flutter: sdk: flutter @@ -144,7 +146,6 @@ dependencies: shared_preferences: ^2.0.5 sqflite: ^2.3.0 sqflite_migration: ^0.3.0 - sqlite3: ^2.1.0 sqlite3_flutter_libs: ^0.5.20 sqlite_async: ^0.6.1 step_progress_indicator: ^1.0.2 diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index c451b8b9c..fc2300d93 100644 --- a/server/cmd/museum/main.go +++ b/server/cmd/museum/main.go @@ -5,6 +5,7 @@ import ( "database/sql" b64 "encoding/base64" "fmt" + "github.com/ente-io/museum/pkg/controller/file_copy" "net/http" "os" "os/signal" @@ -193,7 +194,7 @@ func main() { commonBillController := commonbilling.NewController(storagBonusRepo, userRepo, usageRepo) appStoreController := controller.NewAppStoreController(defaultPlan, billingRepo, fileRepo, userRepo, commonBillController) - + remoteStoreController := &remoteStoreCtrl.Controller{Repo: remoteStoreRepository} playStoreController := controller.NewPlayStoreController(defaultPlan, billingRepo, fileRepo, userRepo, storagBonusRepo, commonBillController) stripeController := controller.NewStripeController(plans, stripeClients, @@ -389,9 +390,17 @@ func main() { timeout.WithHandler(healthCheckHandler.PingDBStats), timeout.WithResponse(timeOutResponse), )) + fileCopyCtrl := &file_copy.FileCopyController{ + FileController: fileController, + CollectionCtrl: collectionController, + S3Config: s3Config, + ObjectRepo: objectRepo, + FileRepo: fileRepo, + } fileHandler := &api.FileHandler{ - Controller: fileController, + Controller: fileController, + FileCopyCtrl: fileCopyCtrl, } privateAPI.GET("/files/upload-urls", fileHandler.GetUploadURLs) privateAPI.GET("/files/multipart-upload-urls", fileHandler.GetMultipartUploadURLs) @@ -400,6 +409,7 @@ func main() { privateAPI.GET("/files/preview/:fileID", fileHandler.GetThumbnail) privateAPI.GET("/files/preview/v2/:fileID", fileHandler.GetThumbnail) privateAPI.POST("/files", fileHandler.CreateOrUpdate) + privateAPI.POST("/files/copy", fileHandler.CopyFiles) privateAPI.PUT("/files/update", fileHandler.Update) privateAPI.POST("/files/trash", fileHandler.Trash) privateAPI.POST("/files/size", fileHandler.GetSize) @@ -600,6 +610,7 @@ func main() { UserAuthRepo: userAuthRepo, UserController: userController, FamilyController: familyController, + RemoteStoreController: remoteStoreController, FileRepo: fileRepo, StorageBonusRepo: storagBonusRepo, BillingRepo: billingRepo, @@ -621,6 +632,7 @@ func main() { adminAPI.PUT("/user/change-email", adminHandler.ChangeEmail) adminAPI.DELETE("/user/delete", adminHandler.DeleteUser) adminAPI.POST("/user/recover", adminHandler.RecoverAccount) + adminAPI.POST("/user/update-flag", adminHandler.UpdateFeatureFlag) adminAPI.GET("/email-hash", adminHandler.GetEmailHash) adminAPI.POST("/emails-from-hashes", adminHandler.GetEmailsFromHashes) adminAPI.PUT("/user/subscription", adminHandler.UpdateSubscription) @@ -648,7 +660,6 @@ func main() { privateAPI.DELETE("/authenticator/entity", authenticatorHandler.DeleteEntity) privateAPI.GET("/authenticator/entity/diff", authenticatorHandler.GetDiff) - remoteStoreController := &remoteStoreCtrl.Controller{Repo: remoteStoreRepository} dataCleanupController := &dataCleanupCtrl.DeleteUserCleanupController{ Repo: dataCleanupRepository, UserRepo: userRepo, @@ -662,6 +673,7 @@ func main() { privateAPI.POST("/remote-store/update", remoteStoreHandler.InsertOrUpdate) privateAPI.GET("/remote-store", remoteStoreHandler.GetKey) + privateAPI.GET("/remote-store/feature-flags", remoteStoreHandler.GetFeatureFlags) pushHandler := &api.PushHandler{PushController: pushController} privateAPI.POST("/push/token", pushHandler.AddToken) diff --git a/server/configurations/local.yaml b/server/configurations/local.yaml index 0e456a53a..7785f5601 100644 --- a/server/configurations/local.yaml +++ b/server/configurations/local.yaml @@ -73,8 +73,11 @@ http: # Specify the base endpoints for various apps apps: - public-albums: "https://albums.ente.io" - + # Default is https://albums.ente.io + # + # If you're running a self hosted instance and wish to serve public links, + # set this to the URL where your albums web app is running. + public-albums: # Database connection parameters db: diff --git a/server/ente/collection.go b/server/ente/collection.go index 763d07b9b..71b4c50ac 100644 --- a/server/ente/collection.go +++ b/server/ente/collection.go @@ -103,6 +103,17 @@ type AddFilesRequest struct { Files []CollectionFileItem `json:"files" binding:"required"` } +// CopyFileSyncRequest is request object for creating copy of CollectionFileItems, and those copy to the destination collection +type CopyFileSyncRequest struct { + SrcCollectionID int64 `json:"srcCollectionID" binding:"required"` + DstCollection int64 `json:"dstCollectionID" binding:"required"` + CollectionFileItems []CollectionFileItem `json:"files" binding:"required"` +} + +type CopyResponse struct { + OldToNewFileIDMap map[int64]int64 `json:"oldToNewFileIDMap"` +} + // RemoveFilesRequest represents a request to remove files from a collection type RemoveFilesRequest struct { CollectionID int64 `json:"collectionID" binding:"required"` diff --git a/server/ente/remotestore.go b/server/ente/remotestore.go index 02eb93232..8f518f2a1 100644 --- a/server/ente/remotestore.go +++ b/server/ente/remotestore.go @@ -13,3 +13,66 @@ type UpdateKeyValueRequest struct { Key string `json:"key" binding:"required"` Value string `json:"value" binding:"required"` } + +type AdminUpdateKeyValueRequest struct { + UserID int64 `json:"userID" binding:"required"` + Key string `json:"key" binding:"required"` + Value string `json:"value" binding:"required"` +} + +type FeatureFlagResponse struct { + EnableStripe bool `json:"enableStripe"` + // If true, the mobile client will stop using CF worker to download files + DisableCFWorker bool `json:"disableCFWorker"` + MapEnabled bool `json:"mapEnabled"` + FaceSearchEnabled bool `json:"faceSearchEnabled"` + PassKeyEnabled bool `json:"passKeyEnabled"` + RecoveryKeyVerified bool `json:"recoveryKeyVerified"` + InternalUser bool `json:"internalUser"` + BetaUser bool `json:"betaUser"` +} + +type FlagKey string + +const ( + RecoveryKeyVerified FlagKey = "recoveryKeyVerified" + MapEnabled FlagKey = "mapEnabled" + FaceSearchEnabled FlagKey = "faceSearchEnabled" + PassKeyEnabled FlagKey = "passKeyEnabled" + IsInternalUser FlagKey = "internalUser" + IsBetaUser FlagKey = "betaUser" +) + +func (k FlagKey) String() string { + return string(k) +} + +// UserEditable returns true if the key is user editable +func (k FlagKey) UserEditable() bool { + switch k { + case RecoveryKeyVerified, MapEnabled, FaceSearchEnabled, PassKeyEnabled: + return true + default: + return false + } +} + +func (k FlagKey) IsAdminEditable() bool { + switch k { + case RecoveryKeyVerified, MapEnabled, FaceSearchEnabled: + return false + case IsInternalUser, IsBetaUser, PassKeyEnabled: + return true + default: + return true + } +} + +func (k FlagKey) IsBoolType() bool { + switch k { + case RecoveryKeyVerified, MapEnabled, FaceSearchEnabled, PassKeyEnabled, IsInternalUser, IsBetaUser: + return true + default: + return false + } +} diff --git a/server/pkg/api/admin.go b/server/pkg/api/admin.go index b153e19bb..0b6ac18ef 100644 --- a/server/pkg/api/admin.go +++ b/server/pkg/api/admin.go @@ -3,6 +3,7 @@ package api import ( "errors" "fmt" + "github.com/ente-io/museum/pkg/controller/remotestore" "net/http" "strconv" "strings" @@ -43,6 +44,7 @@ type AdminHandler struct { BillingController *controller.BillingController UserController *user.UserController FamilyController *family.Controller + RemoteStoreController *remotestore.Controller ObjectCleanupController *controller.ObjectCleanupController MailingListsController *controller.MailingListsController DiscordController *discord.DiscordController @@ -260,6 +262,32 @@ func (h *AdminHandler) RemovePasskeys(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) } +func (h *AdminHandler) UpdateFeatureFlag(c *gin.Context) { + var request ente.AdminUpdateKeyValueRequest + if err := c.ShouldBindJSON(&request); err != nil { + handler.Error(c, stacktrace.Propagate(ente.ErrBadRequest, "Bad request")) + return + } + go h.DiscordController.NotifyAdminAction( + fmt.Sprintf("Admin (%d) updating flag:%s to val:%s for %d", auth.GetUserID(c.Request.Header), request.Key, request.Value, request.UserID)) + + logger := logrus.WithFields(logrus.Fields{ + "user_id": request.UserID, + "admin_id": auth.GetUserID(c.Request.Header), + "req_id": requestid.Get(c), + "req_ctx": "update_feature_flag", + }) + logger.Info("Start update") + err := h.RemoteStoreController.AdminInsertOrUpdate(c, request) + if err != nil { + logger.WithError(err).Error("Failed to update flag") + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + logger.Info("successfully updated flag") + c.JSON(http.StatusOK, gin.H{}) +} + func (h *AdminHandler) CloseFamily(c *gin.Context) { var request ente.AdminOpsForUserRequest diff --git a/server/pkg/api/file.go b/server/pkg/api/file.go index a65b9e383..a253c71c2 100644 --- a/server/pkg/api/file.go +++ b/server/pkg/api/file.go @@ -1,6 +1,8 @@ package api import ( + "fmt" + "github.com/ente-io/museum/pkg/controller/file_copy" "net/http" "os" "strconv" @@ -20,11 +22,13 @@ import ( // FileHandler exposes request handlers for all encrypted file related requests type FileHandler struct { - Controller *controller.FileController + Controller *controller.FileController + FileCopyCtrl *file_copy.FileCopyController } // DefaultMaxBatchSize is the default maximum API batch size unless specified otherwise const DefaultMaxBatchSize = 1000 +const DefaultCopyBatchSize = 100 // CreateOrUpdate creates an entry for a file func (h *FileHandler) CreateOrUpdate(c *gin.Context) { @@ -58,6 +62,25 @@ func (h *FileHandler) CreateOrUpdate(c *gin.Context) { c.JSON(http.StatusOK, response) } +// CopyFiles copies files that are owned by another user +func (h *FileHandler) CopyFiles(c *gin.Context) { + var req ente.CopyFileSyncRequest + if err := c.ShouldBindJSON(&req); err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + if len(req.CollectionFileItems) > DefaultCopyBatchSize { + handler.Error(c, stacktrace.Propagate(ente.NewBadRequestWithMessage(fmt.Sprintf("more than %d items", DefaultCopyBatchSize)), "")) + return + } + response, err := h.FileCopyCtrl.CopyFiles(c, req) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, response) +} + // Update updates already existing file func (h *FileHandler) Update(c *gin.Context) { enteApp := auth.GetApp(c) diff --git a/server/pkg/api/remotestore.go b/server/pkg/api/remotestore.go index ea6e621a3..9f03554de 100644 --- a/server/pkg/api/remotestore.go +++ b/server/pkg/api/remotestore.go @@ -49,3 +49,13 @@ func (h *RemoteStoreHandler) GetKey(c *gin.Context) { } c.JSON(http.StatusOK, resp) } + +// GetFeatureFlags returns all the feature flags and value for given user +func (h *RemoteStoreHandler) GetFeatureFlags(c *gin.Context) { + resp, err := h.Controller.GetFeatureFlags(c) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "failed to get feature flags")) + return + } + c.JSON(http.StatusOK, resp) +} diff --git a/server/pkg/controller/collection.go b/server/pkg/controller/collection.go index 15c06fa33..911afc6d7 100644 --- a/server/pkg/controller/collection.go +++ b/server/pkg/controller/collection.go @@ -464,6 +464,41 @@ func (c *CollectionController) isRemoveAllowed(ctx *gin.Context, actorUserID int return nil } +func (c *CollectionController) IsCopyAllowed(ctx *gin.Context, actorUserID int64, req ente.CopyFileSyncRequest) error { + // verify that srcCollectionID is accessible by actorUserID + if _, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{ + CollectionID: req.SrcCollectionID, + ActorUserID: actorUserID, + }); err != nil { + return stacktrace.Propagate(err, "failed to verify srcCollection access") + } + // verify that dstCollectionID is owned by actorUserID + if _, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{ + CollectionID: req.DstCollection, + ActorUserID: actorUserID, + VerifyOwner: true, + }); err != nil { + return stacktrace.Propagate(err, "failed to ownership of the dstCollection access") + } + // verify that all FileIDs exists in the srcCollection + fileIDs := make([]int64, len(req.CollectionFileItems)) + for idx, file := range req.CollectionFileItems { + fileIDs[idx] = file.ID + } + if err := c.CollectionRepo.VerifyAllFileIDsExistsInCollection(ctx, req.SrcCollectionID, fileIDs); err != nil { + return stacktrace.Propagate(err, "failed to verify fileIDs in srcCollection") + } + dsMap, err := c.FileRepo.GetOwnerToFileIDsMap(ctx, fileIDs) + if err != nil { + return err + } + // verify that none of the file belongs to actorUserID + if _, ok := dsMap[actorUserID]; ok { + return ente.NewBadRequestWithMessage("can not copy files owned by actor") + } + return nil +} + // GetDiffV2 returns the changes in user's collections since a timestamp, along with hasMore bool flag. func (c *CollectionController) GetDiffV2(ctx *gin.Context, cID int64, userID int64, sinceTime int64) ([]ente.File, bool, error) { reqContextLogger := log.WithFields(log.Fields{ diff --git a/server/pkg/controller/file.go b/server/pkg/controller/file.go index 12d173e25..e91d299f1 100644 --- a/server/pkg/controller/file.go +++ b/server/pkg/controller/file.go @@ -59,25 +59,51 @@ 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") } - collection, err := c.CollectionRepo.Get(file.CollectionID) + isCreateFileReq := file.ID == 0 + // Check for attributes for fileCreation. We don't send key details on update + if isCreateFileReq { + 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") + } + if isCreateFileReq { + collection, err := c.CollectionRepo.Get(file.CollectionID) + if err != nil { + return stacktrace.Propagate(err, "") + } + // Verify that user owns the collection. + // Warning: Do not remove this check + if collection.Owner.ID != userID { + return stacktrace.Propagate(ente.ErrPermissionDenied, "collection doesn't belong to user") + } + if collection.IsDeleted { + return stacktrace.Propagate(ente.ErrNotFound, "collection has been deleted") + } + if file.OwnerID != userID { + return stacktrace.Propagate(ente.ErrPermissionDenied, "file ownerID doesn't match with userID") + } + } + + 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, "") } - // 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, "") - } - if collection.IsDeleted { - return file, stacktrace.Propagate(ente.ErrNotFound, "collection has been deleted") - } - hotDC := c.S3Config.GetHotDataCenter() // sizeOf will do also HEAD check to ensure that the object exists in the // current hot DC @@ -115,7 +141,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 +170,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/server/pkg/controller/file_copy/file_copy.go b/server/pkg/controller/file_copy/file_copy.go new file mode 100644 index 000000000..afab10efe --- /dev/null +++ b/server/pkg/controller/file_copy/file_copy.go @@ -0,0 +1,206 @@ +package file_copy + +import ( + "fmt" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/ente-io/museum/ente" + "github.com/ente-io/museum/pkg/controller" + "github.com/ente-io/museum/pkg/repo" + "github.com/ente-io/museum/pkg/utils/auth" + "github.com/ente-io/museum/pkg/utils/s3config" + enteTime "github.com/ente-io/museum/pkg/utils/time" + "github.com/gin-contrib/requestid" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" + "sync" + "time" +) + +const () + +type FileCopyController struct { + S3Config *s3config.S3Config + FileController *controller.FileController + FileRepo *repo.FileRepository + CollectionCtrl *controller.CollectionController + ObjectRepo *repo.ObjectRepository +} + +type copyS3ObjectReq struct { + SourceS3Object ente.S3ObjectKey + DestObjectKey string +} + +type fileCopyInternal struct { + SourceFile ente.File + DestCollectionID int64 + // The FileKey is encrypted with the destination collection's key + EncryptedFileKey string + EncryptedFileKeyNonce string + FileCopyReq *copyS3ObjectReq + ThumbCopyReq *copyS3ObjectReq +} + +func (fci fileCopyInternal) newFile(ownedID int64) ente.File { + newFileAttributes := fci.SourceFile.File + newFileAttributes.ObjectKey = fci.FileCopyReq.DestObjectKey + newThumbAttributes := fci.SourceFile.Thumbnail + newThumbAttributes.ObjectKey = fci.ThumbCopyReq.DestObjectKey + return ente.File{ + OwnerID: ownedID, + CollectionID: fci.DestCollectionID, + EncryptedKey: fci.EncryptedFileKey, + KeyDecryptionNonce: fci.EncryptedFileKeyNonce, + File: newFileAttributes, + Thumbnail: newThumbAttributes, + Metadata: fci.SourceFile.Metadata, + UpdationTime: enteTime.Microseconds(), + IsDeleted: false, + } +} + +func (fc *FileCopyController) CopyFiles(c *gin.Context, req ente.CopyFileSyncRequest) (*ente.CopyResponse, error) { + userID := auth.GetUserID(c.Request.Header) + app := auth.GetApp(c) + logger := logrus.WithFields(logrus.Fields{"req_id": requestid.Get(c), "user_id": userID}) + err := fc.CollectionCtrl.IsCopyAllowed(c, userID, req) + if err != nil { + return nil, err + } + fileIDs := make([]int64, 0, len(req.CollectionFileItems)) + fileToCollectionFileMap := make(map[int64]*ente.CollectionFileItem, len(req.CollectionFileItems)) + for i := range req.CollectionFileItems { + item := &req.CollectionFileItems[i] + fileToCollectionFileMap[item.ID] = item + fileIDs = append(fileIDs, item.ID) + } + s3ObjectsToCopy, err := fc.ObjectRepo.GetObjectsForFileIDs(fileIDs) + if err != nil { + return nil, err + } + // note: this assumes that preview existingFilesToCopy for videos are not tracked inside the object_keys table + if len(s3ObjectsToCopy) != 2*len(fileIDs) { + return nil, ente.NewInternalError(fmt.Sprintf("expected %d objects, got %d", 2*len(fileIDs), len(s3ObjectsToCopy))) + } + // todo:(neeraj) if the total size is greater than 1GB, do an early check if the user can upload the existingFilesToCopy + var totalSize int64 + for _, obj := range s3ObjectsToCopy { + totalSize += obj.FileSize + } + logger.WithField("totalSize", totalSize).Info("total size of existingFilesToCopy to copy") + + // request the uploadUrls using existing method. This is to ensure that orphan objects are automatically cleaned up + // todo:(neeraj) optimize this method by removing the need for getting a signed url for each object + uploadUrls, err := fc.FileController.GetUploadURLs(c, userID, len(s3ObjectsToCopy), app) + if err != nil { + return nil, err + } + existingFilesToCopy, err := fc.FileRepo.GetFileAttributesForCopy(fileIDs) + if err != nil { + return nil, err + } + if len(existingFilesToCopy) != len(fileIDs) { + return nil, ente.NewInternalError(fmt.Sprintf("expected %d existingFilesToCopy, got %d", len(fileIDs), len(existingFilesToCopy))) + } + fileOGS3Object := make(map[int64]*copyS3ObjectReq) + fileThumbS3Object := make(map[int64]*copyS3ObjectReq) + for i, s3Obj := range s3ObjectsToCopy { + if s3Obj.Type == ente.FILE { + fileOGS3Object[s3Obj.FileID] = ©S3ObjectReq{ + SourceS3Object: s3Obj, + DestObjectKey: uploadUrls[i].ObjectKey, + } + } else if s3Obj.Type == ente.THUMBNAIL { + fileThumbS3Object[s3Obj.FileID] = ©S3ObjectReq{ + SourceS3Object: s3Obj, + DestObjectKey: uploadUrls[i].ObjectKey, + } + } else { + return nil, ente.NewInternalError(fmt.Sprintf("unexpected object type %s", s3Obj.Type)) + } + } + fileCopyList := make([]fileCopyInternal, 0, len(existingFilesToCopy)) + for i := range existingFilesToCopy { + file := existingFilesToCopy[i] + collectionItem := fileToCollectionFileMap[file.ID] + if collectionItem.ID != file.ID { + return nil, ente.NewInternalError(fmt.Sprintf("expected collectionItem.ID %d, got %d", file.ID, collectionItem.ID)) + } + fileCopy := fileCopyInternal{ + SourceFile: file, + DestCollectionID: req.DstCollection, + EncryptedFileKey: fileToCollectionFileMap[file.ID].EncryptedKey, + EncryptedFileKeyNonce: fileToCollectionFileMap[file.ID].KeyDecryptionNonce, + FileCopyReq: fileOGS3Object[file.ID], + ThumbCopyReq: fileThumbS3Object[file.ID], + } + fileCopyList = append(fileCopyList, fileCopy) + } + oldToNewFileIDMap := make(map[int64]int64) + var wg sync.WaitGroup + errChan := make(chan error, len(fileCopyList)) + + for _, fileCopy := range fileCopyList { + wg.Add(1) + go func(fileCopy fileCopyInternal) { + defer wg.Done() + newFile, err := fc.createCopy(c, fileCopy, userID, app) + if err != nil { + errChan <- err + return + } + oldToNewFileIDMap[fileCopy.SourceFile.ID] = newFile.ID + }(fileCopy) + } + + // Wait for all goroutines to finish + wg.Wait() + + // Close the error channel and check if there were any errors + close(errChan) + if err, ok := <-errChan; ok { + return nil, err + } + return &ente.CopyResponse{OldToNewFileIDMap: oldToNewFileIDMap}, nil +} + +func (fc *FileCopyController) createCopy(c *gin.Context, fcInternal fileCopyInternal, userID int64, app ente.App) (*ente.File, error) { + // using HotS3Client copy the File and Thumbnail + s3Client := fc.S3Config.GetHotS3Client() + hotBucket := fc.S3Config.GetHotBucket() + g := new(errgroup.Group) + g.Go(func() error { + return copyS3Object(s3Client, hotBucket, fcInternal.FileCopyReq) + }) + g.Go(func() error { + return copyS3Object(s3Client, hotBucket, fcInternal.ThumbCopyReq) + }) + if err := g.Wait(); err != nil { + return nil, err + } + file := fcInternal.newFile(userID) + newFile, err := fc.FileController.Create(c, userID, file, "", app) + if err != nil { + return nil, err + } + return &newFile, nil +} + +// Helper function for S3 object copying. +func copyS3Object(s3Client *s3.S3, bucket *string, req *copyS3ObjectReq) error { + copySource := fmt.Sprintf("%s/%s", *bucket, req.SourceS3Object.ObjectKey) + copyInput := &s3.CopyObjectInput{ + Bucket: bucket, + CopySource: ©Source, + Key: &req.DestObjectKey, + } + start := time.Now() + _, err := s3Client.CopyObject(copyInput) + elapsed := time.Since(start) + if err != nil { + return fmt.Errorf("failed to copy (%s) from %s to %s: %w", req.SourceS3Object.Type, copySource, req.DestObjectKey, err) + } + logrus.WithField("duration", elapsed).WithField("size", req.SourceS3Object.FileSize).Infof("copied (%s) from %s to %s", req.SourceS3Object.Type, copySource, req.DestObjectKey) + return nil +} diff --git a/server/pkg/controller/remotestore/controller.go b/server/pkg/controller/remotestore/controller.go index d41bf7e5f..bf8e4acfc 100644 --- a/server/pkg/controller/remotestore/controller.go +++ b/server/pkg/controller/remotestore/controller.go @@ -3,6 +3,7 @@ package remotestore import ( "database/sql" "errors" + "fmt" "github.com/ente-io/museum/ente" "github.com/ente-io/museum/pkg/repo/remotestore" @@ -16,12 +17,22 @@ type Controller struct { Repo *remotestore.Repository } -// Insert of update the key's value +// InsertOrUpdate the key's value func (c *Controller) InsertOrUpdate(ctx *gin.Context, request ente.UpdateKeyValueRequest) error { + if err := _validateRequest(request.Key, request.Value, false); err != nil { + return err + } userID := auth.GetUserID(ctx.Request.Header) return c.Repo.InsertOrUpdate(ctx, userID, request.Key, request.Value) } +func (c *Controller) AdminInsertOrUpdate(ctx *gin.Context, request ente.AdminUpdateKeyValueRequest) error { + if err := _validateRequest(request.Key, request.Value, true); err != nil { + return err + } + return c.Repo.InsertOrUpdate(ctx, request.UserID, request.Key, request.Value) +} + func (c *Controller) Get(ctx *gin.Context, req ente.GetValueRequest) (*ente.GetValueResponse, error) { userID := auth.GetUserID(ctx.Request.Header) value, err := c.Repo.GetValue(ctx, userID, req.Key) @@ -34,3 +45,50 @@ func (c *Controller) Get(ctx *gin.Context, req ente.GetValueRequest) (*ente.GetV } return &ente.GetValueResponse{Value: value}, nil } + +func (c *Controller) GetFeatureFlags(ctx *gin.Context) (*ente.FeatureFlagResponse, error) { + userID := auth.GetUserID(ctx.Request.Header) + values, err := c.Repo.GetAllValues(ctx, userID) + if err != nil { + return nil, stacktrace.Propagate(err, "") + } + response := &ente.FeatureFlagResponse{ + EnableStripe: true, // enable stripe for all + DisableCFWorker: false, + } + for key, value := range values { + flag := ente.FlagKey(key) + if !flag.IsBoolType() { + continue + } + switch flag { + case ente.RecoveryKeyVerified: + response.RecoveryKeyVerified = value == "true" + case ente.MapEnabled: + response.MapEnabled = value == "true" + case ente.FaceSearchEnabled: + response.FaceSearchEnabled = value == "true" + case ente.PassKeyEnabled: + response.PassKeyEnabled = value == "true" + case ente.IsInternalUser: + response.InternalUser = value == "true" + case ente.IsBetaUser: + response.BetaUser = value == "true" + } + } + return response, nil +} + +func _validateRequest(key, value string, byAdmin bool) error { + flag := ente.FlagKey(key) + if !flag.UserEditable() && !byAdmin { + return stacktrace.Propagate(ente.NewBadRequestWithMessage(fmt.Sprintf("key %s is not user editable", key)), "key not user editable") + } + if byAdmin && !flag.IsAdminEditable() { + return stacktrace.Propagate(ente.NewBadRequestWithMessage(fmt.Sprintf("key %s is not admin editable", key)), "key not admin editable") + } + if flag.IsBoolType() && value != "true" && value != "false" { + return stacktrace.Propagate(ente.NewBadRequestWithMessage(fmt.Sprintf("value %s is not allowed", value)), "value not allowed") + } + return nil +} diff --git a/server/pkg/repo/collection.go b/server/pkg/repo/collection.go index 16ae85324..9310f33d4 100644 --- a/server/pkg/repo/collection.go +++ b/server/pkg/repo/collection.go @@ -374,6 +374,30 @@ func (repo *CollectionRepository) DoesFileExistInCollections(fileID int64, cIDs return exists, stacktrace.Propagate(err, "") } +// VerifyAllFileIDsExistsInCollection returns error if the fileIDs don't exist in the collection +func (repo *CollectionRepository) VerifyAllFileIDsExistsInCollection(ctx context.Context, cID int64, fileIDs []int64) error { + fileIdMap := make(map[int64]bool) + rows, err := repo.DB.QueryContext(ctx, `SELECT file_id FROM collection_files WHERE collection_id = $1 AND is_deleted = $2 AND file_id = ANY ($3)`, + cID, false, pq.Array(fileIDs)) + if err != nil { + return stacktrace.Propagate(err, "") + } + for rows.Next() { + var fileID int64 + if err := rows.Scan(&fileID); err != nil { + return stacktrace.Propagate(err, "") + } + fileIdMap[fileID] = true + } + // find fileIds that are not present in the collection + for _, fileID := range fileIDs { + if _, ok := fileIdMap[fileID]; !ok { + return stacktrace.Propagate(fmt.Errorf("fileID %d not found in collection %d", fileID, cID), "") + } + } + return nil +} + // GetCollectionShareeRole returns true if the collection is shared with the user func (repo *CollectionRepository) GetCollectionShareeRole(cID int64, userID int64) (*ente.CollectionParticipantRole, error) { var role *ente.CollectionParticipantRole diff --git a/server/pkg/repo/file.go b/server/pkg/repo/file.go index ffa7ea048..eafc7b570 100644 --- a/server/pkg/repo/file.go +++ b/server/pkg/repo/file.go @@ -612,6 +612,24 @@ func (repo *FileRepository) GetFileAttributesFromObjectKey(objectKey string) (en return file, nil } +func (repo *FileRepository) GetFileAttributesForCopy(fileIDs []int64) ([]ente.File, error) { + result := make([]ente.File, 0) + rows, err := repo.DB.Query(`SELECT file_id, owner_id, file_decryption_header, thumbnail_decryption_header, metadata_decryption_header, encrypted_metadata, pub_magic_metadata FROM files WHERE file_id = ANY($1)`, pq.Array(fileIDs)) + if err != nil { + return nil, stacktrace.Propagate(err, "") + } + defer rows.Close() + for rows.Next() { + var file ente.File + err := rows.Scan(&file.ID, &file.OwnerID, &file.File.DecryptionHeader, &file.Thumbnail.DecryptionHeader, &file.Metadata.DecryptionHeader, &file.Metadata.EncryptedData, &file.PubicMagicMetadata) + if err != nil { + return nil, stacktrace.Propagate(err, "") + } + result = append(result, file) + } + return result, nil +} + // GetUsage gets the Storage usage of a user // Deprecated: GetUsage is deprecated, use UsageRepository.GetUsage func (repo *FileRepository) GetUsage(userID int64) (int64, error) { diff --git a/server/pkg/repo/object.go b/server/pkg/repo/object.go index f0cc5c6cf..fdbbbf52c 100644 --- a/server/pkg/repo/object.go +++ b/server/pkg/repo/object.go @@ -44,6 +44,15 @@ func (repo *ObjectRepository) MarkObjectReplicated(objectKey string, datacenter return result.RowsAffected() } +func (repo *ObjectRepository) GetObjectsForFileIDs(fileIDs []int64) ([]ente.S3ObjectKey, error) { + rows, err := repo.DB.Query(`SELECT file_id, o_type, object_key, size FROM object_keys + WHERE file_id = ANY($1) AND is_deleted=false`, pq.Array(fileIDs)) + if err != nil { + return nil, stacktrace.Propagate(err, "") + } + return convertRowsToObjectKeys(rows) +} + // GetObject returns the ente.S3ObjectKey key for a file id and type func (repo *ObjectRepository) GetObject(fileID int64, objType ente.ObjectType) (ente.S3ObjectKey, error) { // todo: handling of deleted objects diff --git a/server/pkg/repo/public_collection.go b/server/pkg/repo/public_collection.go index d4aa81bf2..6c6106a77 100644 --- a/server/pkg/repo/public_collection.go +++ b/server/pkg/repo/public_collection.go @@ -23,7 +23,7 @@ type PublicCollectionRepository struct { // NewPublicCollectionRepository .. func NewPublicCollectionRepository(db *sql.DB, albumHost string) *PublicCollectionRepository { if albumHost == "" { - panic("albumHost can not be empty") + albumHost = "https://albums.ente.io" } return &PublicCollectionRepository{ DB: db, diff --git a/server/pkg/repo/remotestore/repository.go b/server/pkg/repo/remotestore/repository.go index dc54b0cfc..2548f4901 100644 --- a/server/pkg/repo/remotestore/repository.go +++ b/server/pkg/repo/remotestore/repository.go @@ -13,7 +13,6 @@ type Repository struct { DB *sql.DB } -// func (r *Repository) InsertOrUpdate(ctx context.Context, userID int64, key string, value string) error { _, err := r.DB.ExecContext(ctx, `INSERT INTO remote_store(user_id, key_name, key_value) VALUES ($1,$2,$3) ON CONFLICT (user_id, key_name) DO UPDATE SET key_value = $3; @@ -40,3 +39,25 @@ func (r *Repository) GetValue(ctx context.Context, userID int64, key string) (st } return keyValue, nil } + +// GetAllValues fetches and return all the key value pairs for given user_id +func (r *Repository) GetAllValues(ctx context.Context, userID int64) (map[string]string, error) { + rows, err := r.DB.QueryContext(ctx, `SELECT key_name, key_value FROM remote_store + WHERE user_id = $1`, + userID, // $1 + ) + if err != nil { + return nil, stacktrace.Propagate(err, "reading value failed") + } + defer rows.Close() + values := make(map[string]string) + for rows.Next() { + var key, value string + err := rows.Scan(&key, &value) + if err != nil { + return nil, stacktrace.Propagate(err, "reading value failed") + } + values[key] = value + } + return values, 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/index.tsx b/web/apps/cast/src/pages/index.tsx index ce3ec3763..b48da7e35 100644 --- a/web/apps/cast/src/pages/index.tsx +++ b/web/apps/cast/src/pages/index.tsx @@ -42,52 +42,96 @@ export default function PairingMode() { }, []); useEffect(() => { - if (!cast) return; - if (isCastReady) return; + if (!cast) { + console.log("cast not ready"); + return; + } + if (isCastReady) { + console.log("cast already ready"); + return; + } const context = cast.framework.CastReceiverContext.getInstance(); - + context.setLoggerLevel(cast.framework.LoggerLevel.DEBUG); + const castDebugLogger = cast.debug.CastDebugLogger.getInstance(); try { const options = new cast.framework.CastReceiverOptions(); + options.maxInactivity = 3600; + context.addEventListener( + cast.framework.system.EventType.READY, + () => { + if (!castDebugLogger.debugOverlayElement_) { + // Enable debug logger and show a 'DEBUG MODE' overlay at top left corner. + castDebugLogger.setEnabled(true); + } + }, + ); + context.addEventListener( + cast.framework.system.EventType.ERROR, + (event) => { + castDebugLogger.info( + "Context Error - ", + JSON.stringify(event), + ); + }, + ); options.customNamespaces = Object.assign({}); options.customNamespaces["urn:x-cast:pair-request"] = cast.framework.system.MessageType.JSON; options.disableIdleTimeout = true; + context.set; context.addCustomMessageListener( "urn:x-cast:pair-request", messageReceiveHandler, ); context.start(options); + setIsCastReady(true); } catch (e) { + console.log("failed to create cast context", e); log.error("failed to create cast context", e); } - setIsCastReady(true); + return () => { + console.log("stopping cast context"); context.stop(); }; - }, [cast, isCastReady]); + }, [cast]); const messageReceiveHandler = (message: { type: string; senderId: string; data: any; }) => { - cast.framework.CastReceiverContext.getInstance().sendCustomMessage( - "urn:x-cast:pair-request", - message.senderId, - { - code: digits.join(""), - }, - ); + console.log("received message", message); + try { + console.log("sending pair request response message"); + cast.framework.CastReceiverContext.getInstance().sendCustomMessage( + "urn:x-cast:pair-request", + message.senderId, + { + code: digits.join(""), + }, + ); + console.log("sent pair request response message"); + } catch (e) { + console.log("failed to pair request response message", e); + log.error("failed to send message", e); + } }; const init = async () => { - const data = generateSecureData(6); - setDigits(convertDataToDecimalString(data).split("")); - const keypair = await generateKeyPair(); - setPublicKeyB64(await toB64(keypair.publicKey)); - setPrivateKeyB64(await toB64(keypair.privateKey)); + try { + const data = generateSecureData(6); + setDigits(convertDataToDecimalString(data).split("")); + const keypair = await generateKeyPair(); + setPublicKeyB64(await toB64(keypair.publicKey)); + setPrivateKeyB64(await toB64(keypair.privateKey)); + } catch (e) { + console.log("failed to generate keypair", e); + log.error("failed to generate keypair", e); + throw e; + } }; const generateKeyPair = async () => { 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/cast/src/utils/useCastReceiver.tsx b/web/apps/cast/src/utils/useCastReceiver.tsx index 176b96882..55f8e1759 100644 --- a/web/apps/cast/src/utils/useCastReceiver.tsx +++ b/web/apps/cast/src/utils/useCastReceiver.tsx @@ -23,6 +23,13 @@ const load = (() => { }); document.body.appendChild(script); + const debugScript = document.createElement("script"); + debugScript.src = + "https://www.gstatic.com/cast/sdk/libs/devtools/debug_layer/caf_receiver_logger.js"; + debugScript.addEventListener("load", () => { + console.log("debug script loaded"); + }); + document.body.appendChild(debugScript); }); } return promise; 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..2ae077daf 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,15 @@ 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 { getPublicCollectionUID, getPublicCollectionUploaderName, savePublicCollectionUploaderName, } from "services/publicCollectionService"; -import uploadManager from "services/upload/uploadManager"; -import watchFolderService from "services/watchFolder/watchFolderService"; +import uploadManager, { + setToUploadCollection, +} from "services/upload/uploadManager"; +import watcher from "services/watch"; import { NotificationAttributes } from "types/Notification"; import { Collection } from "types/collection"; import { @@ -38,7 +35,7 @@ import { import { ElectronFile, FileWithCollection, - ImportSuggestion, + type FileWithCollection2, } from "types/upload"; import { InProgressUpload, @@ -53,13 +50,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"; @@ -118,11 +117,28 @@ export default function Uploader(props: Props) { const [importSuggestion, setImportSuggestion] = useState( DEFAULT_IMPORT_SUGGESTION, ); + /** + * Paths of file to upload that we've received over the IPC bridge from the + * code running in the Node.js layer of our desktop app. + */ + const [desktopFilePaths, setDesktopFilePaths] = useState< + string[] | undefined + >(); const [electronFiles, setElectronFiles] = useState(null); const [webFiles, setWebFiles] = useState([]); - const toUploadFiles = useRef(null); + const toUploadFiles = useRef< + File[] | ElectronFile[] | string[] | undefined | null + >(null); + /** + * If true, then the next upload we'll be processing was initiated by our + * desktop app. + */ const isPendingDesktopUpload = useRef(false); + /** + * If set, this will be the name of the collection that our desktop app + * wishes for us to upload into. + */ const pendingDesktopUploadCollectionName = useRef(""); // This is set when the user choses a type to upload from the upload type selector dialog const pickedUploadType = useRef(null); @@ -137,11 +153,6 @@ export default function Uploader(props: Props) { const closeUploadProgress = () => setUploadProgressView(false); const showUserNameInputDialog = () => setUserNameInputDialogView(true); - const setCollectionName = (collectionName: string) => { - isPendingDesktopUpload.current = true; - pendingDesktopUploadCollectionName.current = collectionName; - }; - const handleChoiceModalClose = () => { setChoiceModalView(false); uploadRunning.current = false; @@ -177,18 +188,37 @@ 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( - setElectronFiles, - setCollectionName, - props.syncWithRemote, - appContext.setIsFolderSyncRunning, - ); + 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, + ); + } + }); + + const upload = (collectionName: string, filePaths: string[]) => { + isPendingDesktopUpload.current = true; + pendingDesktopUploadCollectionName.current = collectionName; + setDesktopFilePaths(filePaths); + }; + + const requestSyncWithRemote = () => { + props.syncWithRemote().catch((e) => { + log.error( + "Ignoring error when syncing trash changes with remote", + e, + ); + }); + }; + + watcher.init(upload, requestSyncWithRemote); } }, [ publicCollectionGalleryContext.accessedThroughSharedURL, @@ -273,36 +303,38 @@ export default function Uploader(props: Props) { useEffect(() => { if ( + desktopFilePaths?.length > 0 || electronFiles?.length > 0 || webFiles?.length > 0 || appContext.sharedFiles?.length > 0 ) { log.info( - `upload request type:${ - electronFiles?.length > 0 - ? "electronFiles" - : webFiles?.length > 0 - ? "webFiles" - : "sharedFiles" + `upload request type: ${ + desktopFilePaths?.length > 0 + ? "desktopFilePaths" + : electronFiles?.length > 0 + ? "electronFiles" + : webFiles?.length > 0 + ? "webFiles" + : "sharedFiles" } count ${ + desktopFilePaths?.length ?? electronFiles?.length ?? webFiles?.length ?? appContext?.sharedFiles.length }`, ); 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; } } @@ -317,9 +349,13 @@ export default function Uploader(props: Props) { toUploadFiles.current = appContext.sharedFiles; appContext.resetSharedFiles(); } else if (electronFiles?.length > 0) { - // File selection from desktop app + // File selection from desktop app - deprecated toUploadFiles.current = electronFiles; setElectronFiles([]); + } else if (desktopFilePaths && desktopFilePaths.length > 0) { + // File selection from our desktop app + toUploadFiles.current = desktopFilePaths; + setDesktopFilePaths(undefined); } toUploadFiles.current = filterOutSystemFiles(toUploadFiles.current); @@ -330,7 +366,9 @@ export default function Uploader(props: Props) { const importSuggestion = getImportSuggestion( pickedUploadType.current, - toUploadFiles.current, + toUploadFiles.current.map((file) => + typeof file == "string" ? file : file["path"], + ), ); setImportSuggestion(importSuggestion); @@ -343,7 +381,7 @@ export default function Uploader(props: Props) { pickedUploadType.current = null; props.setLoading(false); } - }, [webFiles, appContext.sharedFiles, electronFiles]); + }, [webFiles, appContext.sharedFiles, electronFiles, desktopFilePaths]); const resumeDesktopUpload = async ( type: PICKED_UPLOAD_TYPE, @@ -391,7 +429,7 @@ export default function Uploader(props: Props) { }; const uploadFilesToNewCollections = async ( - strategy: UPLOAD_STRATEGY, + strategy: CollectionMapping, collectionName?: string, ) => { try { @@ -399,13 +437,13 @@ export default function Uploader(props: Props) { `upload file to an new collections strategy:${strategy} ,collectionName:${collectionName}`, ); await preCollectionCreationAction(); - let filesWithCollectionToUpload: FileWithCollection[] = []; + let filesWithCollectionToUpload: FileWithCollection2[] = []; const collections: Collection[] = []; let collectionNameToFilesMap = new Map< string, - (File | ElectronFile)[] + File[] | ElectronFile[] | string[] >(); - if (strategy === UPLOAD_STRATEGY.SINGLE_COLLECTION) { + if (strategy == "root") { collectionNameToFilesMap.set( collectionName, toUploadFiles.current, @@ -454,7 +492,7 @@ export default function Uploader(props: Props) { }); throw e; } - await waitInQueueAndUploadFiles( + await waitInQueueAndUploadFiles2( filesWithCollectionToUpload, collections, ); @@ -482,6 +520,24 @@ export default function Uploader(props: Props) { await currentUploadPromise.current; }; + const waitInQueueAndUploadFiles2 = async ( + filesWithCollectionToUploadIn: FileWithCollection2[], + collections: Collection[], + uploaderName?: string, + ) => { + const currentPromise = currentUploadPromise.current; + currentUploadPromise.current = waitAndRun( + currentPromise, + async () => + await uploadFiles2( + filesWithCollectionToUploadIn, + collections, + uploaderName, + ), + ); + await currentUploadPromise.current; + }; + const preUploadAction = async () => { uploadManager.prepareForNewUpload(); setUploadProgressView(true); @@ -505,18 +561,18 @@ export default function Uploader(props: Props) { if ( electron && !isPendingDesktopUpload.current && - !watchFolderService.isUploadRunning() + !watcher.isUploadRunning() ) { - await ImportService.setToUploadCollection(collections); + await setToUploadCollection(collections); 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 +588,71 @@ 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) { + log.error("failed to upload files", e); + showUserFacingError(e.message); + closeUploadProgress(); + } finally { + postUploadAction(); + } + }; + + const uploadFiles2 = async ( + filesWithCollectionToUploadIn: FileWithCollection2[], + collections: Collection[], + uploaderName?: string, + ) => { + try { + log.info("uploadFiles called"); + preUploadAction(); + if ( + electron && + !isPendingDesktopUpload.current && + !watcher.isUploadRunning() + ) { + await setToUploadCollection(collections); + if (zipPaths.current) { + await electron.setPendingUploadFiles( + "zips", + zipPaths.current, + ); + zipPaths.current = null; + } + await electron.setPendingUploadFiles( + "files", + filesWithCollectionToUploadIn.map( + ({ file }) => (file as ElectronFile).path, + ), + ); + } + const shouldCloseUploadProgress = + await uploadManager.queueFilesForUpload2( + filesWithCollectionToUploadIn, + collections, + uploaderName, + ); + if (shouldCloseUploadProgress) { + closeUploadProgress(); + } + if (isElectron()) { + if (watcher.isUploadRunning()) { + await watcher.allFileUploadsDone( + filesWithCollectionToUploadIn, + collections, + ); + } else if (watcher.isSyncPaused()) { + // resume the service after user upload is done + watcher.resumePausedSync(); } } } catch (e) { @@ -559,7 +672,8 @@ export default function Uploader(props: Props) { const uploaderName = uploadManager.getUploaderName(); await preUploadAction(); await uploadManager.queueFilesForUpload( - filesWithCollections.files, + /* TODO(MR): ElectronFile changes */ + filesWithCollections.files as FileWithCollection[], filesWithCollections.collections, uploaderName, ); @@ -605,10 +719,7 @@ export default function Uploader(props: Props) { } const uploadToSingleNewCollection = (collectionName: string) => { - uploadFilesToNewCollections( - UPLOAD_STRATEGY.SINGLE_COLLECTION, - collectionName, - ); + uploadFilesToNewCollections("root", collectionName); }; const showCollectionCreateModal = (suggestedName: string) => { @@ -629,7 +740,7 @@ export default function Uploader(props: Props) { try { if (accessedThroughSharedURL) { log.info( - `uploading files to pulbic collection - ${props.uploadCollection.name} - ${props.uploadCollection.id}`, + `uploading files to public collection - ${props.uploadCollection.name} - ${props.uploadCollection.id}`, ); const uploaderName = await getPublicCollectionUploaderName( getPublicCollectionUID( @@ -647,7 +758,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 +766,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 +891,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/ffmpeg.ts b/web/apps/photos/src/constants/ffmpeg.ts index 9ecc41eb5..fb0d762e5 100644 --- a/web/apps/photos/src/constants/ffmpeg.ts +++ b/web/apps/photos/src/constants/ffmpeg.ts @@ -1,3 +1,3 @@ -export const INPUT_PATH_PLACEHOLDER = "INPUT"; -export const FFMPEG_PLACEHOLDER = "FFMPEG"; -export const OUTPUT_PATH_PLACEHOLDER = "OUTPUT"; +export const ffmpegPathPlaceholder = "FFMPEG"; +export const inputPathPlaceholder = "INPUT"; +export const outputPathPlaceholder = "OUTPUT"; diff --git a/web/apps/photos/src/constants/upload.ts b/web/apps/photos/src/constants/upload.ts index 6d9f63d78..e1ee197bc 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 } from "types/upload"; // list of format that were missed by type-detection for some files. export const WHITELISTED_FILE_FORMATS: FileTypeInfo[] = [ @@ -98,25 +93,6 @@ export enum PICKED_UPLOAD_TYPE { ZIPS = "zips", } -export const MAX_FILE_SIZE_SUPPORTED = 4 * 1024 * 1024 * 1024; // 4 GB - -export const LIVE_PHOTO_ASSET_SIZE_LIMIT = 20 * 1024 * 1024; // 20MB - -export const NULL_EXTRACTED_METADATA: ParsedExtractedMetadata = { - location: NULL_LOCATION, - creationTime: null, - width: null, - height: null, -}; - -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/clip-service.ts b/web/apps/photos/src/services/clip-service.ts index 253d8097e..eae9590fd 100644 --- a/web/apps/photos/src/services/clip-service.ts +++ b/web/apps/photos/src/services/clip-service.ts @@ -75,7 +75,6 @@ class CLIPService { private onFileUploadedHandler: | ((arg: { enteFile: EnteFile; localFile: globalThis.File }) => void) | null = null; - private unsupportedPlatform = false; constructor() { this.liveEmbeddingExtractionQueue = new PQueue({ @@ -85,7 +84,7 @@ class CLIPService { } isPlatformSupported = () => { - return isElectron() && !this.unsupportedPlatform; + return isElectron(); }; private logoutHandler = async () => { @@ -99,9 +98,6 @@ class CLIPService { setupOnFileUploadListener = async () => { try { - if (this.unsupportedPlatform) { - return; - } if (this.onFileUploadedHandler) { log.info("file upload listener already setup"); return; @@ -188,26 +184,12 @@ class CLIPService { } }; - getTextEmbedding = async (text: string): Promise => { - try { - return ensureElectron().clipTextEmbedding(text); - } catch (e) { - if (e?.message?.includes(CustomError.UNSUPPORTED_PLATFORM)) { - this.unsupportedPlatform = true; - } - log.error("Failed to compute CLIP text embedding", e); - throw e; - } + getTextEmbeddingIfAvailable = async (text: string) => { + return ensureElectron().clipTextEmbeddingIfAvailable(text); }; private runClipEmbeddingExtraction = async (canceller: AbortController) => { try { - if (this.unsupportedPlatform) { - log.info( - `skipping clip embedding extraction, platform unsupported`, - ); - return; - } const user = getData(LS_KEYS.USER); if (!user) { return; @@ -254,11 +236,6 @@ class CLIPService { e, ); } - if ( - e?.message?.includes(CustomError.UNSUPPORTED_PLATFORM) - ) { - this.unsupportedPlatform = true; - } if ( e?.message === CustomError.REQUEST_CANCELLED || e?.message?.includes(CustomError.UNSUPPORTED_PLATFORM) diff --git a/web/apps/photos/src/services/download/index.ts b/web/apps/photos/src/services/download/index.ts index 41af5c055..d2ad6b1f7 100644 --- a/web/apps/photos/src/services/download/index.ts +++ b/web/apps/photos/src/services/download/index.ts @@ -1,3 +1,4 @@ +import { decodeLivePhoto } from "@/media/live-photo"; import { openCache, type BlobCache } from "@/next/blob-cache"; import log from "@/next/log"; import { APPS } from "@ente/shared/apps/constants"; @@ -5,13 +6,13 @@ import ComlinkCryptoWorker from "@ente/shared/crypto"; import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker"; import { CustomError } from "@ente/shared/error"; import { Events, eventBus } from "@ente/shared/events"; +import { isPlaybackPossible } from "@ente/shared/media/video-playback"; import { Remote } from "comlink"; import { FILE_TYPE } from "constants/file"; +import isElectron from "is-electron"; +import * as ffmpegService from "services/ffmpeg"; import { EnteFile } from "types/file"; -import { - generateStreamFromArrayBuffer, - getRenderableFileURL, -} from "utils/file"; +import { generateStreamFromArrayBuffer, getRenderableImage } from "utils/file"; import { PhotosDownloadClient } from "./clients/photos"; import { PublicAlbumsDownloadClient } from "./clients/publicAlbums"; @@ -303,7 +304,7 @@ class DownloadManagerImpl { if (cachedBlob) res = new Response(cachedBlob); else { res = await this.downloadClient.downloadFileStream(file); - this?.fileCache.put(cacheKey, await res.blob()); + this.fileCache?.put(cacheKey, await res.blob()); } const reader = res.body.getReader(); @@ -467,3 +468,159 @@ function createDownloadClient( return new PhotosDownloadClient(token, timeout); } } + +async function getRenderableFileURL( + file: EnteFile, + fileBlob: Blob, + originalFileURL: string, + forceConvert: boolean, +): Promise { + let srcURLs: SourceURLs["url"]; + switch (file.metadata.fileType) { + case FILE_TYPE.IMAGE: { + const convertedBlob = await getRenderableImage( + file.metadata.title, + fileBlob, + ); + const convertedURL = getFileObjectURL( + originalFileURL, + fileBlob, + convertedBlob, + ); + srcURLs = convertedURL; + break; + } + case FILE_TYPE.LIVE_PHOTO: { + srcURLs = await getRenderableLivePhotoURL( + file, + fileBlob, + forceConvert, + ); + break; + } + case FILE_TYPE.VIDEO: { + const convertedBlob = await getPlayableVideo( + file.metadata.title, + fileBlob, + forceConvert, + ); + const convertedURL = getFileObjectURL( + originalFileURL, + fileBlob, + convertedBlob, + ); + srcURLs = convertedURL; + break; + } + default: { + srcURLs = originalFileURL; + break; + } + } + + let isOriginal: boolean; + if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { + isOriginal = false; + } else { + isOriginal = (srcURLs as string) === (originalFileURL as string); + } + + return { + url: srcURLs, + isOriginal, + isRenderable: + file.metadata.fileType !== FILE_TYPE.LIVE_PHOTO && !!srcURLs, + type: + file.metadata.fileType === FILE_TYPE.LIVE_PHOTO + ? "livePhoto" + : "normal", + }; +} + +const getFileObjectURL = ( + originalFileURL: string, + originalBlob: Blob, + convertedBlob: Blob, +) => { + const convertedURL = convertedBlob + ? convertedBlob === originalBlob + ? originalFileURL + : URL.createObjectURL(convertedBlob) + : null; + return convertedURL; +}; + +async function getRenderableLivePhotoURL( + file: EnteFile, + fileBlob: Blob, + forceConvert: boolean, +): Promise { + const livePhoto = await decodeLivePhoto(file.metadata.title, fileBlob); + + const getRenderableLivePhotoImageURL = async () => { + try { + const imageBlob = new Blob([livePhoto.imageData]); + const convertedImageBlob = await getRenderableImage( + livePhoto.imageFileName, + imageBlob, + ); + + return URL.createObjectURL(convertedImageBlob); + } catch (e) { + //ignore and return null + return null; + } + }; + + const getRenderableLivePhotoVideoURL = async () => { + try { + const videoBlob = new Blob([livePhoto.videoData]); + const convertedVideoBlob = await getPlayableVideo( + livePhoto.videoFileName, + videoBlob, + forceConvert, + true, + ); + return URL.createObjectURL(convertedVideoBlob); + } catch (e) { + //ignore and return null + return null; + } + }; + + return { + image: getRenderableLivePhotoImageURL, + video: getRenderableLivePhotoVideoURL, + }; +} + +async function getPlayableVideo( + videoNameTitle: string, + videoBlob: Blob, + forceConvert = false, + runOnWeb = false, +) { + try { + const isPlayable = await isPlaybackPossible( + URL.createObjectURL(videoBlob), + ); + if (isPlayable && !forceConvert) { + return videoBlob; + } else { + if (!forceConvert && !runOnWeb && !isElectron()) { + return null; + } + log.info( + `video format not supported, converting it name: ${videoNameTitle}`, + ); + const mp4ConvertedVideo = await ffmpegService.convertToMP4( + new File([videoBlob], videoNameTitle), + ); + log.info(`video successfully converted ${videoNameTitle}`); + return new Blob([await mp4ConvertedVideo.arrayBuffer()]); + } + } catch (e) { + log.error("video conversion failed", e); + return null; + } +} diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index 7d6279882..dc7d40c70 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"; @@ -5,7 +6,7 @@ import { Events, eventBus } from "@ente/shared/events"; import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; import { formatDateTimeShort } from "@ente/shared/time/format"; import { User } from "@ente/shared/user/types"; -import { sleep } from "@ente/shared/utils"; +import { wait } from "@ente/shared/utils"; import QueueProcessor, { CancellationStatus, RequestCanceller, @@ -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. */ @@ -919,7 +919,7 @@ class ExportService { e.message === CustomError.EXPORT_RECORD_JSON_PARSING_FAILED && retry ) { - await sleep(1000); + await wait(1000); return await this.getExportRecord(folder, false); } if (e.message !== CustomError.EXPORT_FOLDER_DOES_NOT_EXIST) { @@ -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..a8c4e5068 100644 --- a/web/apps/photos/src/services/export/migration.ts +++ b/web/apps/photos/src/services/export/migration.ts @@ -1,13 +1,13 @@ +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"; import { User } from "@ente/shared/user/types"; -import { sleep } from "@ente/shared/utils"; +import { wait } from "@ente/shared/utils"; 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, @@ -305,7 +305,7 @@ async function getFileExportNamesFromExportedFiles( ); let success = 0; for (const file of exportedFiles) { - await sleep(0); + await wait(0); const collectionPath = exportedCollectionPaths.get(file.collectionID); log.debug( () => @@ -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/ffmpeg.ts b/web/apps/photos/src/services/ffmpeg.ts new file mode 100644 index 000000000..30ab76323 --- /dev/null +++ b/web/apps/photos/src/services/ffmpeg.ts @@ -0,0 +1,201 @@ +import { ComlinkWorker } from "@/next/worker/comlink-worker"; +import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time"; +import { Remote } from "comlink"; +import { + ffmpegPathPlaceholder, + inputPathPlaceholder, + outputPathPlaceholder, +} from "constants/ffmpeg"; +import { NULL_LOCATION } from "constants/upload"; +import { ElectronFile, ParsedExtractedMetadata } from "types/upload"; +import { type DedicatedFFmpegWorker } from "worker/ffmpeg.worker"; + +/** Called during upload */ +export async function generateVideoThumbnail( + file: File | ElectronFile, +): Promise { + let seekTime = 1; + while (seekTime >= 0) { + try { + return await ffmpegExec( + [ + ffmpegPathPlaceholder, + "-i", + inputPathPlaceholder, + "-ss", + `00:00:0${seekTime}`, + "-vframes", + "1", + "-vf", + "scale=-1:720", + outputPathPlaceholder, + ], + file, + "thumb.jpeg", + ); + } catch (e) { + if (seekTime === 0) { + throw e; + } + } + seekTime--; + } +} + +/** Called during upload */ +export async function extractVideoMetadata(file: File | ElectronFile) { + // https://stackoverflow.com/questions/9464617/retrieving-and-saving-media-metadata-using-ffmpeg + // -c [short for codex] copy[(stream_specifier)[ffmpeg.org/ffmpeg.html#Stream-specifiers]] => copies all the stream without re-encoding + // -map_metadata [http://ffmpeg.org/ffmpeg.html#Advanced-options search for map_metadata] => copies all stream metadata to the out + // -f ffmetadata [https://ffmpeg.org/ffmpeg-formats.html#Metadata-1] => dump metadata from media files into a simple UTF-8-encoded INI-like text file + const metadata = await ffmpegExec( + [ + ffmpegPathPlaceholder, + "-i", + inputPathPlaceholder, + "-c", + "copy", + "-map_metadata", + "0", + "-f", + "ffmetadata", + outputPathPlaceholder, + ], + file, + `metadata.txt`, + ); + return parseFFmpegExtractedMetadata( + new Uint8Array(await metadata.arrayBuffer()), + ); +} + +enum MetadataTags { + CREATION_TIME = "creation_time", + APPLE_CONTENT_IDENTIFIER = "com.apple.quicktime.content.identifier", + APPLE_LIVE_PHOTO_IDENTIFIER = "com.apple.quicktime.live-photo.auto", + APPLE_CREATION_DATE = "com.apple.quicktime.creationdate", + APPLE_LOCATION_ISO = "com.apple.quicktime.location.ISO6709", + LOCATION = "location", +} + +function parseFFmpegExtractedMetadata(encodedMetadata: Uint8Array) { + const metadataString = new TextDecoder().decode(encodedMetadata); + const metadataPropertyArray = metadataString.split("\n"); + const metadataKeyValueArray = metadataPropertyArray.map((property) => + property.split("="), + ); + const validKeyValuePairs = metadataKeyValueArray.filter( + (keyValueArray) => keyValueArray.length === 2, + ) as Array<[string, string]>; + + const metadataMap = Object.fromEntries(validKeyValuePairs); + + const location = parseAppleISOLocation( + metadataMap[MetadataTags.APPLE_LOCATION_ISO] ?? + metadataMap[MetadataTags.LOCATION], + ); + + const creationTime = parseCreationTime( + metadataMap[MetadataTags.APPLE_CREATION_DATE] ?? + metadataMap[MetadataTags.CREATION_TIME], + ); + const parsedMetadata: ParsedExtractedMetadata = { + creationTime, + location: { + latitude: location.latitude, + longitude: location.longitude, + }, + width: null, + height: null, + }; + return parsedMetadata; +} + +function parseAppleISOLocation(isoLocation: string) { + let location = NULL_LOCATION; + if (isoLocation) { + const [latitude, longitude] = isoLocation + .match(/(\+|-)\d+\.*\d+/g) + .map((x) => parseFloat(x)); + + location = { latitude, longitude }; + } + return location; +} + +function parseCreationTime(creationTime: string) { + let dateTime = null; + if (creationTime) { + dateTime = validateAndGetCreationUnixTimeInMicroSeconds( + new Date(creationTime), + ); + } + return dateTime; +} + +/** Called when viewing a file */ +export async function convertToMP4(file: File) { + return await ffmpegExec( + [ + ffmpegPathPlaceholder, + "-i", + inputPathPlaceholder, + "-preset", + "ultrafast", + outputPathPlaceholder, + ], + file, + "output.mp4", + 30 * 1000, + ); +} + +/** + * Run the given ffmpeg command. + * + * If we're running in the context of our desktop app, use the ffmpeg binary we + * bundle with our desktop app to run the command. Otherwise fallback to using + * the wasm ffmpeg we link to from our web app in a web worker. + * + * As a rough ballpark, the native ffmpeg integration in the desktop app is + * 10-20x faster than the wasm one currently. See: [Note: ffmpeg in Electron]. + */ +const ffmpegExec = async ( + cmd: string[], + inputFile: File | ElectronFile, + outputFilename: string, + timeoutMS: number = 0, +): Promise => { + const electron = globalThis.electron; + if (electron || false) { + /* TODO(MR): ElectronFile changes */ + // return electron.runFFmpegCmd(cmd, inputFile, outputFilename, timeoutMS); + } else { + return workerFactory + .instance() + .then((worker) => + worker.run(cmd, inputFile, outputFilename, timeoutMS), + ); + } +}; + +/** Lazily create a singleton instance of our worker */ +class WorkerFactory { + private _instance: Promise>; + + async instance() { + if (!this._instance) { + const comlinkWorker = createComlinkWorker(); + this._instance = comlinkWorker.remote; + } + return this._instance; + } +} + +const workerFactory = new WorkerFactory(); + +const createComlinkWorker = () => + new ComlinkWorker( + "ffmpeg-worker", + new Worker(new URL("worker/ffmpeg.worker.ts", import.meta.url)), + ); diff --git a/web/apps/photos/src/services/ffmpeg/ffmpegFactory.ts b/web/apps/photos/src/services/ffmpeg/ffmpegFactory.ts deleted file mode 100644 index 49aee9868..000000000 --- a/web/apps/photos/src/services/ffmpeg/ffmpegFactory.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ElectronFile } from "types/upload"; -import ComlinkFFmpegWorker from "utils/comlink/ComlinkFFmpegWorker"; - -export interface IFFmpeg { - run: ( - cmd: string[], - inputFile: File | ElectronFile, - outputFilename: string, - dontTimeout?: boolean, - ) => Promise; -} - -class FFmpegFactory { - private client: IFFmpeg; - async getFFmpegClient() { - if (!this.client) { - const electron = globalThis.electron; - if (electron) { - this.client = { - run(cmd, inputFile, outputFilename, dontTimeout) { - return electron.runFFmpegCmd( - cmd, - inputFile, - outputFilename, - dontTimeout, - ); - }, - }; - } else { - this.client = await ComlinkFFmpegWorker.getInstance(); - } - } - return this.client; - } -} - -export default new FFmpegFactory(); diff --git a/web/apps/photos/src/services/ffmpeg/ffmpegService.ts b/web/apps/photos/src/services/ffmpeg/ffmpegService.ts deleted file mode 100644 index 0a6a66cb0..000000000 --- a/web/apps/photos/src/services/ffmpeg/ffmpegService.ts +++ /dev/null @@ -1,100 +0,0 @@ -import log from "@/next/log"; -import { - FFMPEG_PLACEHOLDER, - INPUT_PATH_PLACEHOLDER, - OUTPUT_PATH_PLACEHOLDER, -} from "constants/ffmpeg"; -import { ElectronFile } from "types/upload"; -import { parseFFmpegExtractedMetadata } from "utils/ffmpeg"; -import ffmpegFactory from "./ffmpegFactory"; - -export async function generateVideoThumbnail( - file: File | ElectronFile, -): Promise { - try { - let seekTime = 1; - const ffmpegClient = await ffmpegFactory.getFFmpegClient(); - while (seekTime >= 0) { - try { - return await ffmpegClient.run( - [ - FFMPEG_PLACEHOLDER, - "-i", - INPUT_PATH_PLACEHOLDER, - "-ss", - `00:00:0${seekTime}`, - "-vframes", - "1", - "-vf", - "scale=-1:720", - OUTPUT_PATH_PLACEHOLDER, - ], - file, - "thumb.jpeg", - ); - } catch (e) { - if (seekTime === 0) { - throw e; - } - } - seekTime--; - } - } catch (e) { - log.error("ffmpeg generateVideoThumbnail failed", e); - throw e; - } -} - -export async function extractVideoMetadata(file: File | ElectronFile) { - try { - const ffmpegClient = await ffmpegFactory.getFFmpegClient(); - // https://stackoverflow.com/questions/9464617/retrieving-and-saving-media-metadata-using-ffmpeg - // -c [short for codex] copy[(stream_specifier)[ffmpeg.org/ffmpeg.html#Stream-specifiers]] => copies all the stream without re-encoding - // -map_metadata [http://ffmpeg.org/ffmpeg.html#Advanced-options search for map_metadata] => copies all stream metadata to the out - // -f ffmetadata [https://ffmpeg.org/ffmpeg-formats.html#Metadata-1] => dump metadata from media files into a simple UTF-8-encoded INI-like text file - const metadata = await ffmpegClient.run( - [ - FFMPEG_PLACEHOLDER, - "-i", - INPUT_PATH_PLACEHOLDER, - "-c", - "copy", - "-map_metadata", - "0", - "-f", - "ffmetadata", - OUTPUT_PATH_PLACEHOLDER, - ], - file, - `metadata.txt`, - ); - return parseFFmpegExtractedMetadata( - new Uint8Array(await metadata.arrayBuffer()), - ); - } catch (e) { - log.error("ffmpeg extractVideoMetadata failed", e); - throw e; - } -} - -export async function convertToMP4(file: File | ElectronFile) { - try { - const ffmpegClient = await ffmpegFactory.getFFmpegClient(); - return await ffmpegClient.run( - [ - FFMPEG_PLACEHOLDER, - "-i", - INPUT_PATH_PLACEHOLDER, - "-preset", - "ultrafast", - OUTPUT_PATH_PLACEHOLDER, - ], - file, - "output.mp4", - true, - ); - } catch (e) { - log.error("ffmpeg convertToMP4 failed", e); - throw e; - } -} diff --git a/web/apps/photos/src/services/heic-convert/service.ts b/web/apps/photos/src/services/heic-convert.ts similarity index 78% rename from web/apps/photos/src/services/heic-convert/service.ts rename to web/apps/photos/src/services/heic-convert.ts index 0dc650612..478cce218 100644 --- a/web/apps/photos/src/services/heic-convert/service.ts +++ b/web/apps/photos/src/services/heic-convert.ts @@ -4,8 +4,18 @@ import { ComlinkWorker } from "@/next/worker/comlink-worker"; import { CustomError } from "@ente/shared/error"; import { retryAsyncFunction } from "@ente/shared/utils"; import QueueProcessor from "@ente/shared/utils/queueProcessor"; -import { getDedicatedConvertWorker } from "utils/comlink/ComlinkConvertWorker"; -import { DedicatedConvertWorker } from "worker/convert.worker"; +import { type DedicatedHEICConvertWorker } from "worker/heic-convert.worker"; + +/** + * Convert a HEIC image to a JPEG. + * + * Behind the scenes, it uses a web worker pool to do the conversion using a + * WASM HEIC conversion package. + * + * @param heicBlob The HEIC blob to convert. + * @returns The JPEG blob. + */ +export const heicToJPEG = (heicBlob: Blob) => converter.convert(heicBlob); const WORKER_POOL_SIZE = 2; const WAIT_TIME_BEFORE_NEXT_ATTEMPT_IN_MICROSECONDS = [100, 100]; @@ -14,20 +24,18 @@ const BREATH_TIME_IN_MICROSECONDS = 1000; class HEICConverter { private convertProcessor = new QueueProcessor(); - private workerPool: ComlinkWorker[] = []; - private ready: Promise; + private workerPool: ComlinkWorker[] = []; - constructor() { - this.ready = this.init(); - } - private async init() { + private initIfNeeded() { + if (this.workerPool.length > 0) return; this.workerPool = []; - for (let i = 0; i < WORKER_POOL_SIZE; i++) { - this.workerPool.push(getDedicatedConvertWorker()); - } + for (let i = 0; i < WORKER_POOL_SIZE; i++) + this.workerPool.push(createComlinkWorker()); } + async convert(fileBlob: Blob): Promise { - await this.ready; + this.initIfNeeded(); + const response = this.convertProcessor.queueUpRequest(() => retryAsyncFunction(async () => { const convertWorker = this.workerPool.shift(); @@ -42,9 +50,7 @@ class HEICConverter { }, WAIT_TIME_IN_MICROSECONDS); const startTime = Date.now(); const convertedHEIC = - await worker.convertHEICToJPEG( - fileBlob, - ); + await worker.heicToJPEG(fileBlob); log.info( `originalFileSize:${convertBytesToHumanReadable( fileBlob?.size, @@ -90,11 +96,12 @@ class HEICConverter { } catch (e) { log.error("heic conversion failed", e); convertWorker.terminate(); - this.workerPool.push(getDedicatedConvertWorker()); + this.workerPool.push(createComlinkWorker()); throw e; } }, WAIT_TIME_BEFORE_NEXT_ATTEMPT_IN_MICROSECONDS), ); + try { return await response.promise; } catch (e) { @@ -107,4 +114,11 @@ class HEICConverter { } } -export default new HEICConverter(); +/** The singleton instance of {@link HEICConverter}. */ +const converter = new HEICConverter(); + +const createComlinkWorker = () => + new ComlinkWorker( + "heic-convert-worker", + new Worker(new URL("worker/heic-convert.worker.ts", import.meta.url)), + ); diff --git a/web/apps/photos/src/services/heicConversionService.ts b/web/apps/photos/src/services/heicConversionService.ts deleted file mode 100644 index 189781494..000000000 --- a/web/apps/photos/src/services/heicConversionService.ts +++ /dev/null @@ -1,14 +0,0 @@ -import log from "@/next/log"; -import WasmHEICConverterService from "./heic-convert/service"; - -class HeicConversionService { - async convert(heicFileData: Blob): Promise { - try { - return await WasmHEICConverterService.convert(heicFileData); - } catch (e) { - log.error("failed to convert heic file", e); - throw e; - } - } -} -export default new HeicConversionService(); 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..1dedadf15 100644 --- a/web/apps/photos/src/services/machineLearning/faceService.ts +++ b/web/apps/photos/src/services/machineLearning/faceService.ts @@ -144,8 +144,10 @@ class FaceService { syncContext.faceEmbeddingService.faceSize, imageBitmap, ); - const blurValues = - syncContext.blurDetectionService.detectBlur(faceImages); + const blurValues = 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..3357e21cc 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/searchService.ts b/web/apps/photos/src/services/searchService.ts index 408c3daa5..dfe6f2006 100644 --- a/web/apps/photos/src/services/searchService.ts +++ b/web/apps/photos/src/services/searchService.ts @@ -1,5 +1,4 @@ import log from "@/next/log"; -import { CustomError } from "@ente/shared/error"; import * as chrono from "chrono-node"; import { FILE_TYPE } from "constants/file"; import { t } from "i18next"; @@ -287,24 +286,20 @@ async function getLocationSuggestions(searchPhrase: string) { return [...locationTagSuggestions, ...citySearchSuggestions]; } -async function getClipSuggestion(searchPhrase: string): Promise { - try { - if (!clipService.isPlatformSupported()) { - return null; - } - - const clipResults = await searchClip(searchPhrase); - return { - type: SuggestionType.CLIP, - value: clipResults, - label: searchPhrase, - }; - } catch (e) { - if (!e.message?.includes(CustomError.MODEL_DOWNLOAD_PENDING)) { - log.error("getClipSuggestion failed", e); - } +async function getClipSuggestion( + searchPhrase: string, +): Promise { + if (!clipService.isPlatformSupported()) { return null; } + + const clipResults = await searchClip(searchPhrase); + if (!clipResults) return undefined; + return { + type: SuggestionType.CLIP, + value: clipResults, + label: searchPhrase, + }; } function searchCollection( @@ -374,9 +369,14 @@ async function searchLocationTag(searchPhrase: string): Promise { return matchedLocationTags; } -async function searchClip(searchPhrase: string): Promise { +const searchClip = async ( + searchPhrase: string, +): Promise => { + const textEmbedding = + await clipService.getTextEmbeddingIfAvailable(searchPhrase); + if (!textEmbedding) return undefined; + const imageEmbeddings = await getLocalEmbeddings(); - const textEmbedding = await clipService.getTextEmbedding(searchPhrase); const clipSearchResult = new Map( ( await Promise.all( @@ -394,7 +394,7 @@ async function searchClip(searchPhrase: string): Promise { ); return clipSearchResult; -} +}; function convertSuggestionToSearchQuery(option: Suggestion): Search { switch (option.type) { diff --git a/web/apps/photos/src/services/upload/encryptionService.ts b/web/apps/photos/src/services/upload/encryptionService.ts deleted file mode 100644 index 90f100c9f..000000000 --- a/web/apps/photos/src/services/upload/encryptionService.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker"; -import { EncryptionResult } from "@ente/shared/crypto/types"; -import { Remote } from "comlink"; -import { DataStream, isDataStream } from "types/upload"; - -async function encryptFileStream( - worker: Remote, - fileData: DataStream, -) { - const { stream, chunkCount } = fileData; - const fileStreamReader = stream.getReader(); - const { key, decryptionHeader, pushState } = - await worker.initChunkEncryption(); - const ref = { pullCount: 1 }; - const encryptedFileStream = new ReadableStream({ - async pull(controller) { - const { value } = await fileStreamReader.read(); - const encryptedFileChunk = await worker.encryptFileChunk( - value, - pushState, - ref.pullCount === chunkCount, - ); - controller.enqueue(encryptedFileChunk); - if (ref.pullCount === chunkCount) { - controller.close(); - } - ref.pullCount++; - }, - }); - return { - key, - file: { - decryptionHeader, - encryptedData: { stream: encryptedFileStream, chunkCount }, - }, - }; -} - -export async function encryptFiledata( - worker: Remote, - filedata: Uint8Array | DataStream, -): Promise> { - return isDataStream(filedata) - ? await encryptFileStream(worker, filedata) - : await worker.encryptFile(filedata); -} diff --git a/web/apps/photos/src/services/upload/fileService.ts b/web/apps/photos/src/services/upload/fileService.ts deleted file mode 100644 index dacccdccb..000000000 --- a/web/apps/photos/src/services/upload/fileService.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { getFileNameSize } from "@/next/file"; -import log from "@/next/log"; -import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker"; -import { Remote } from "comlink"; -import { FILE_READER_CHUNK_SIZE, MULTIPART_PART_SIZE } from "constants/upload"; -import { EncryptedMagicMetadata } from "types/magicMetadata"; -import { - DataStream, - ElectronFile, - EncryptedFile, - ExtractMetadataResult, - FileInMemory, - FileTypeInfo, - FileWithMetadata, - ParsedMetadataJSON, - ParsedMetadataJSONMap, -} from "types/upload"; -import { - getElectronFileStream, - getFileStream, - getUint8ArrayView, -} from "../readerService"; -import { encryptFiledata } from "./encryptionService"; -import { - MAX_FILE_NAME_LENGTH_GOOGLE_EXPORT, - extractMetadata, - getClippedMetadataJSONMapKeyForFile, - getMetadataJSONMapKeyForFile, -} from "./metadataService"; -import { generateThumbnail } from "./thumbnailService"; - -export function getFileSize(file: File | ElectronFile) { - return file.size; -} - -export function getFilename(file: File | ElectronFile) { - return file.name; -} - -export async function readFile( - fileTypeInfo: FileTypeInfo, - rawFile: File | ElectronFile, -): Promise { - const { thumbnail, hasStaticThumbnail } = await generateThumbnail( - rawFile, - fileTypeInfo, - ); - log.info(`reading file data ${getFileNameSize(rawFile)} `); - let filedata: Uint8Array | DataStream; - if (!(rawFile instanceof File)) { - if (rawFile.size > MULTIPART_PART_SIZE) { - filedata = await getElectronFileStream( - rawFile, - FILE_READER_CHUNK_SIZE, - ); - } else { - filedata = await getUint8ArrayView(rawFile); - } - } else if (rawFile.size > MULTIPART_PART_SIZE) { - filedata = getFileStream(rawFile, FILE_READER_CHUNK_SIZE); - } else { - filedata = await getUint8ArrayView(rawFile); - } - - log.info(`read file data successfully ${getFileNameSize(rawFile)} `); - - return { - filedata, - thumbnail, - hasStaticThumbnail, - }; -} - -export async function extractFileMetadata( - worker: Remote, - parsedMetadataJSONMap: ParsedMetadataJSONMap, - collectionID: number, - fileTypeInfo: FileTypeInfo, - rawFile: File | ElectronFile, -): Promise { - let key = getMetadataJSONMapKeyForFile(collectionID, rawFile.name); - let googleMetadata: ParsedMetadataJSON = parsedMetadataJSONMap.get(key); - - if (!googleMetadata && key.length > MAX_FILE_NAME_LENGTH_GOOGLE_EXPORT) { - key = getClippedMetadataJSONMapKeyForFile(collectionID, rawFile.name); - googleMetadata = parsedMetadataJSONMap.get(key); - } - - const { metadata, publicMagicMetadata } = await extractMetadata( - worker, - rawFile, - fileTypeInfo, - ); - - for (const [key, value] of Object.entries(googleMetadata ?? {})) { - if (!value) { - continue; - } - metadata[key] = value; - } - return { metadata, publicMagicMetadata }; -} - -export async function encryptFile( - worker: Remote, - file: FileWithMetadata, - encryptionKey: string, -): Promise { - try { - const { key: fileKey, file: encryptedFiledata } = await encryptFiledata( - worker, - file.filedata, - ); - - const { file: encryptedThumbnail } = await worker.encryptThumbnail( - file.thumbnail, - fileKey, - ); - const { file: encryptedMetadata } = await worker.encryptMetadata( - file.metadata, - fileKey, - ); - - let encryptedPubMagicMetadata: EncryptedMagicMetadata; - if (file.pubMagicMetadata) { - const { file: encryptedPubMagicMetadataData } = - await worker.encryptMetadata( - file.pubMagicMetadata.data, - fileKey, - ); - encryptedPubMagicMetadata = { - version: file.pubMagicMetadata.version, - count: file.pubMagicMetadata.count, - data: encryptedPubMagicMetadataData.encryptedData, - header: encryptedPubMagicMetadataData.decryptionHeader, - }; - } - - const encryptedKey = await worker.encryptToB64(fileKey, encryptionKey); - - const result: EncryptedFile = { - file: { - file: encryptedFiledata, - thumbnail: encryptedThumbnail, - metadata: encryptedMetadata, - pubMagicMetadata: encryptedPubMagicMetadata, - localID: file.localID, - }, - fileKey: encryptedKey, - }; - return result; - } catch (e) { - log.error("Error encrypting files", e); - throw e; - } -} diff --git a/web/apps/photos/src/services/upload/hashService.tsx b/web/apps/photos/src/services/upload/hashService.tsx deleted file mode 100644 index aa275fb34..000000000 --- a/web/apps/photos/src/services/upload/hashService.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { getFileNameSize } from "@/next/file"; -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_READER_CHUNK_SIZE } from "constants/upload"; -import { getElectronFileStream, getFileStream } from "services/readerService"; -import { DataStream, ElectronFile } from "types/upload"; - -export async function getFileHash( - worker: Remote, - file: File | ElectronFile, -) { - try { - log.info(`getFileHash called for ${getFileNameSize(file)}`); - let filedata: DataStream; - if (file instanceof File) { - filedata = getFileStream(file, FILE_READER_CHUNK_SIZE); - } else { - filedata = await getElectronFileStream( - file, - FILE_READER_CHUNK_SIZE, - ); - } - const hashState = await worker.initChunkHashing(); - - const streamReader = filedata.stream.getReader(); - for (let i = 0; i < filedata.chunkCount; i++) { - const { done, value: chunk } = await streamReader.read(); - if (done) { - throw Error(CustomError.CHUNK_LESS_THAN_EXPECTED); - } - await worker.hashFileChunk(hashState, Uint8Array.from(chunk)); - } - const { done } = await streamReader.read(); - if (!done) { - throw Error(CustomError.CHUNK_MORE_THAN_EXPECTED); - } - const hash = await worker.completeChunkHashing(hashState); - log.info( - `file hashing completed successfully ${getFileNameSize(file)}`, - ); - return hash; - } catch (e) { - log.error("getFileHash failed", e); - log.info(`file hashing failed ${getFileNameSize(file)} ,${e.message} `); - } -} diff --git a/web/apps/photos/src/services/upload/livePhotoService.ts b/web/apps/photos/src/services/upload/livePhotoService.ts deleted file mode 100644 index 392b5b9c8..000000000 --- a/web/apps/photos/src/services/upload/livePhotoService.ts +++ /dev/null @@ -1,306 +0,0 @@ -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, - ExtractMetadataResult, - FileTypeInfo, - FileWithCollection, - 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"; -import { getFileHash } from "./hashService"; -import { generateThumbnail } from "./thumbnailService"; -import uploadCancelService from "./uploadCancelService"; - -interface LivePhotoIdentifier { - collectionID: number; - fileType: FILE_TYPE; - name: string; - size: number; -} - -const UNDERSCORE_THREE = "_3"; -// Note: The icloud-photos-downloader library appends _HVEC to the end of the filename in case of live photos -// https://github.com/icloud-photos-downloader/icloud_photos_downloader -const UNDERSCORE_HEVC = "_HVEC"; - -export async function getLivePhotoFileType( - livePhotoAssets: LivePhotoAssets, -): Promise { - const imageFileTypeInfo = await getFileType(livePhotoAssets.image); - const videoFileTypeInfo = await getFileType(livePhotoAssets.video); - return { - fileType: FILE_TYPE.LIVE_PHOTO, - exactType: `${imageFileTypeInfo.exactType}+${videoFileTypeInfo.exactType}`, - imageType: imageFileTypeInfo.exactType, - videoType: videoFileTypeInfo.exactType, - }; -} - -export async function extractLivePhotoMetadata( - worker: Remote, - parsedMetadataJSONMap: ParsedMetadataJSONMap, - collectionID: number, - fileTypeInfo: FileTypeInfo, - livePhotoAssets: LivePhotoAssets, -): Promise { - const imageFileTypeInfo: FileTypeInfo = { - fileType: FILE_TYPE.IMAGE, - exactType: fileTypeInfo.imageType, - }; - const { - metadata: imageMetadata, - publicMagicMetadata: imagePublicMagicMetadata, - } = await extractFileMetadata( - worker, - parsedMetadataJSONMap, - collectionID, - imageFileTypeInfo, - livePhotoAssets.image, - ); - const videoHash = await getFileHash(worker, livePhotoAssets.video); - return { - metadata: { - ...imageMetadata, - title: getLivePhotoName(livePhotoAssets), - fileType: FILE_TYPE.LIVE_PHOTO, - imageHash: imageMetadata.hash, - videoHash: videoHash, - hash: undefined, - }, - publicMagicMetadata: imagePublicMagicMetadata, - }; -} - -export function getLivePhotoSize(livePhotoAssets: LivePhotoAssets) { - return livePhotoAssets.image.size + livePhotoAssets.video.size; -} - -export function getLivePhotoName(livePhotoAssets: LivePhotoAssets) { - return livePhotoAssets.image.name; -} - -export async function readLivePhoto( - fileTypeInfo: FileTypeInfo, - livePhotoAssets: LivePhotoAssets, -) { - const { thumbnail, hasStaticThumbnail } = await generateThumbnail( - livePhotoAssets.image, - { - exactType: fileTypeInfo.imageType, - fileType: FILE_TYPE.IMAGE, - }, - ); - - const image = await getUint8ArrayView(livePhotoAssets.image); - - const video = await getUint8ArrayView(livePhotoAssets.video); - - return { - filedata: await encodeLivePhoto({ - image, - video, - imageNameTitle: livePhotoAssets.image.name, - videoNameTitle: livePhotoAssets.video.name, - }), - thumbnail, - hasStaticThumbnail, - }; -} - -export async function clusterLivePhotoFiles(mediaFiles: FileWithCollection[]) { - try { - const analysedMediaFiles: FileWithCollection[] = []; - mediaFiles - .sort((firstMediaFile, secondMediaFile) => - splitFilenameAndExtension( - firstMediaFile.file.name, - )[0].localeCompare( - splitFilenameAndExtension(secondMediaFile.file.name)[0], - ), - ) - .sort( - (firstMediaFile, secondMediaFile) => - firstMediaFile.collectionID - secondMediaFile.collectionID, - ); - let index = 0; - while (index < mediaFiles.length - 1) { - if (uploadCancelService.isUploadCancelationRequested()) { - throw Error(CustomError.UPLOAD_CANCELLED); - } - const firstMediaFile = mediaFiles[index]; - const secondMediaFile = mediaFiles[index + 1]; - const firstFileType = - getFileTypeFromExtensionForLivePhotoClustering( - firstMediaFile.file.name, - ); - const secondFileType = - getFileTypeFromExtensionForLivePhotoClustering( - secondMediaFile.file.name, - ); - const firstFileIdentifier: LivePhotoIdentifier = { - collectionID: firstMediaFile.collectionID, - fileType: firstFileType, - name: firstMediaFile.file.name, - size: firstMediaFile.file.size, - }; - const secondFileIdentifier: LivePhotoIdentifier = { - collectionID: secondMediaFile.collectionID, - fileType: secondFileType, - name: secondMediaFile.file.name, - size: secondMediaFile.file.size, - }; - if ( - areFilesLivePhotoAssets( - firstFileIdentifier, - secondFileIdentifier, - ) - ) { - let imageFile: File | ElectronFile; - let videoFile: File | ElectronFile; - if ( - firstFileType === FILE_TYPE.IMAGE && - secondFileType === FILE_TYPE.VIDEO - ) { - imageFile = firstMediaFile.file; - videoFile = secondMediaFile.file; - } else { - videoFile = firstMediaFile.file; - imageFile = secondMediaFile.file; - } - const livePhotoLocalID = firstMediaFile.localID; - analysedMediaFiles.push({ - localID: livePhotoLocalID, - collectionID: firstMediaFile.collectionID, - isLivePhoto: true, - livePhotoAssets: { - image: imageFile, - video: videoFile, - }, - }); - index += 2; - } else { - analysedMediaFiles.push({ - ...firstMediaFile, - isLivePhoto: false, - }); - index += 1; - } - } - if (index === mediaFiles.length - 1) { - analysedMediaFiles.push({ - ...mediaFiles[index], - isLivePhoto: false, - }); - } - return analysedMediaFiles; - } catch (e) { - if (e.message === CustomError.UPLOAD_CANCELLED) { - throw e; - } else { - log.error("failed to cluster live photo", e); - throw e; - } - } -} - -function areFilesLivePhotoAssets( - firstFileIdentifier: LivePhotoIdentifier, - secondFileIdentifier: LivePhotoIdentifier, -) { - const haveSameCollectionID = - firstFileIdentifier.collectionID === secondFileIdentifier.collectionID; - const areNotSameFileType = - firstFileIdentifier.fileType !== secondFileIdentifier.fileType; - - let firstFileNameWithoutSuffix: string; - let secondFileNameWithoutSuffix: string; - if (firstFileIdentifier.fileType === FILE_TYPE.IMAGE) { - firstFileNameWithoutSuffix = removePotentialLivePhotoSuffix( - getFileNameWithoutExtension(firstFileIdentifier.name), - // Note: The Google Live Photo image file can have video extension appended as suffix, passing that to removePotentialLivePhotoSuffix to remove it - // Example: IMG_20210630_0001.mp4.jpg (Google Live Photo image file) - getFileExtensionWithDot(secondFileIdentifier.name), - ); - secondFileNameWithoutSuffix = removePotentialLivePhotoSuffix( - getFileNameWithoutExtension(secondFileIdentifier.name), - ); - } else { - firstFileNameWithoutSuffix = removePotentialLivePhotoSuffix( - getFileNameWithoutExtension(firstFileIdentifier.name), - ); - secondFileNameWithoutSuffix = removePotentialLivePhotoSuffix( - getFileNameWithoutExtension(secondFileIdentifier.name), - getFileExtensionWithDot(firstFileIdentifier.name), - ); - } - if ( - haveSameCollectionID && - isImageOrVideo(firstFileIdentifier.fileType) && - isImageOrVideo(secondFileIdentifier.fileType) && - areNotSameFileType && - firstFileNameWithoutSuffix === secondFileNameWithoutSuffix - ) { - // checks size of live Photo assets are less than allowed limit - // I did that based on the assumption that live photo assets ideally would not be larger than LIVE_PHOTO_ASSET_SIZE_LIMIT - // also zipping library doesn't support stream as a input - if ( - firstFileIdentifier.size <= LIVE_PHOTO_ASSET_SIZE_LIMIT && - secondFileIdentifier.size <= LIVE_PHOTO_ASSET_SIZE_LIMIT - ) { - return true; - } else { - log.error( - `${CustomError.TOO_LARGE_LIVE_PHOTO_ASSETS} - ${JSON.stringify({ - fileSizes: [ - firstFileIdentifier.size, - secondFileIdentifier.size, - ], - })}`, - ); - } - } - return false; -} - -function removePotentialLivePhotoSuffix( - filenameWithoutExtension: string, - suffix?: string, -) { - let presentSuffix: string; - if (filenameWithoutExtension.endsWith(UNDERSCORE_THREE)) { - presentSuffix = UNDERSCORE_THREE; - } else if (filenameWithoutExtension.endsWith(UNDERSCORE_HEVC)) { - presentSuffix = UNDERSCORE_HEVC; - } else if ( - filenameWithoutExtension.endsWith(UNDERSCORE_HEVC.toLowerCase()) - ) { - presentSuffix = UNDERSCORE_HEVC.toLowerCase(); - } else if (suffix) { - if (filenameWithoutExtension.endsWith(suffix)) { - presentSuffix = suffix; - } else if (filenameWithoutExtension.endsWith(suffix.toLowerCase())) { - presentSuffix = suffix.toLowerCase(); - } - } - if (presentSuffix) { - return filenameWithoutExtension.slice(0, presentSuffix.length * -1); - } else { - return filenameWithoutExtension; - } -} diff --git a/web/apps/photos/src/services/upload/magicMetadataService.ts b/web/apps/photos/src/services/upload/magicMetadataService.ts deleted file mode 100644 index f56b31c43..000000000 --- a/web/apps/photos/src/services/upload/magicMetadataService.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - FilePublicMagicMetadata, - FilePublicMagicMetadataProps, -} from "types/file"; -import { - getNonEmptyMagicMetadataProps, - updateMagicMetadata, -} from "utils/magicMetadata"; - -export async function constructPublicMagicMetadata( - publicMagicMetadataProps: FilePublicMagicMetadataProps, -): Promise { - const nonEmptyPublicMagicMetadataProps = getNonEmptyMagicMetadataProps( - publicMagicMetadataProps, - ); - - if (Object.values(nonEmptyPublicMagicMetadataProps)?.length === 0) { - return null; - } - return await updateMagicMetadata(publicMagicMetadataProps); -} diff --git a/web/apps/photos/src/services/upload/metadataService.ts b/web/apps/photos/src/services/upload/metadataService.ts index 9bd2a63c0..d1c98ff69 100644 --- a/web/apps/photos/src/services/upload/metadataService.ts +++ b/web/apps/photos/src/services/upload/metadataService.ts @@ -1,5 +1,8 @@ +import { ensureElectron } from "@/next/electron"; +import { basename, getFileNameSize } from "@/next/file"; import log from "@/next/log"; import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker"; +import { CustomError } from "@ente/shared/error"; import { parseDateFromFusedDateString, tryToParseDateTime, @@ -7,21 +10,30 @@ import { } from "@ente/shared/time"; import { Remote } from "comlink"; import { FILE_TYPE } from "constants/file"; -import { NULL_EXTRACTED_METADATA, NULL_LOCATION } from "constants/upload"; +import { FILE_READER_CHUNK_SIZE, NULL_LOCATION } from "constants/upload"; +import * as ffmpegService from "services/ffmpeg"; +import { getElectronFileStream, getFileStream } from "services/readerService"; +import { getFileType } from "services/typeDetectionService"; import { FilePublicMagicMetadataProps } from "types/file"; import { + DataStream, ElectronFile, ExtractMetadataResult, FileTypeInfo, + LivePhotoAssets, Location, Metadata, ParsedExtractedMetadata, ParsedMetadataJSON, + ParsedMetadataJSONMap, + type FileWithCollection, + type FileWithCollection2, + type LivePhotoAssets2, } from "types/upload"; -import { splitFilenameAndExtension } from "utils/file"; +import { getFileTypeFromExtensionForLivePhotoClustering } from "utils/file/livePhoto"; import { getEXIFLocation, getEXIFTime, getParsedExifData } from "./exifService"; -import { getFileHash } from "./hashService"; -import { getVideoMetadata } from "./videoMetadataService"; +import uploadCancelService from "./uploadCancelService"; +import { extractFileMetadata, getFileName } from "./uploadService"; const NULL_PARSED_METADATA_JSON: ParsedMetadataJSON = { creationTime: null, @@ -49,6 +61,13 @@ const EXIF_TAGS_NEEDED = [ export const MAX_FILE_NAME_LENGTH_GOOGLE_EXPORT = 46; +export const NULL_EXTRACTED_METADATA: ParsedExtractedMetadata = { + location: NULL_LOCATION, + creationTime: null, + width: null, + height: null, +}; + export async function extractMetadata( worker: Remote, receivedFile: File | ElectronFile, @@ -150,67 +169,79 @@ export const getMetadataJSONMapKeyForFile = ( return `${collectionID}-${getFileOriginalName(fileName)}`; }; -export async function parseMetadataJSON(receivedFile: File | ElectronFile) { +export async function parseMetadataJSON( + receivedFile: File | ElectronFile | string, +) { try { - if (!(receivedFile instanceof File)) { - receivedFile = new File( - [await receivedFile.blob()], - receivedFile.name, - ); - } - const metadataJSON: object = JSON.parse(await receivedFile.text()); - - const parsedMetadataJSON: ParsedMetadataJSON = - NULL_PARSED_METADATA_JSON; - if (!metadataJSON) { - return; + let text: string; + if (typeof receivedFile == "string") { + text = await ensureElectron().fs.readTextFile(receivedFile); + } else { + if (!(receivedFile instanceof File)) { + receivedFile = new File( + [await receivedFile.blob()], + receivedFile.name, + ); + } + text = await receivedFile.text(); } - if ( - metadataJSON["photoTakenTime"] && - metadataJSON["photoTakenTime"]["timestamp"] - ) { - parsedMetadataJSON.creationTime = - metadataJSON["photoTakenTime"]["timestamp"] * 1000000; - } else if ( - metadataJSON["creationTime"] && - metadataJSON["creationTime"]["timestamp"] - ) { - parsedMetadataJSON.creationTime = - metadataJSON["creationTime"]["timestamp"] * 1000000; - } - if ( - metadataJSON["modificationTime"] && - metadataJSON["modificationTime"]["timestamp"] - ) { - parsedMetadataJSON.modificationTime = - metadataJSON["modificationTime"]["timestamp"] * 1000000; - } - let locationData: Location = NULL_LOCATION; - if ( - metadataJSON["geoData"] && - (metadataJSON["geoData"]["latitude"] !== 0.0 || - metadataJSON["geoData"]["longitude"] !== 0.0) - ) { - locationData = metadataJSON["geoData"]; - } else if ( - metadataJSON["geoDataExif"] && - (metadataJSON["geoDataExif"]["latitude"] !== 0.0 || - metadataJSON["geoDataExif"]["longitude"] !== 0.0) - ) { - locationData = metadataJSON["geoDataExif"]; - } - if (locationData !== null) { - parsedMetadataJSON.latitude = locationData.latitude; - parsedMetadataJSON.longitude = locationData.longitude; - } - return parsedMetadataJSON; + return parseMetadataJSONText(text); } catch (e) { log.error("parseMetadataJSON failed", e); // ignore } } +export async function parseMetadataJSONText(text: string) { + const metadataJSON: object = JSON.parse(text); + + const parsedMetadataJSON: ParsedMetadataJSON = NULL_PARSED_METADATA_JSON; + if (!metadataJSON) { + return; + } + + if ( + metadataJSON["photoTakenTime"] && + metadataJSON["photoTakenTime"]["timestamp"] + ) { + parsedMetadataJSON.creationTime = + metadataJSON["photoTakenTime"]["timestamp"] * 1000000; + } else if ( + metadataJSON["creationTime"] && + metadataJSON["creationTime"]["timestamp"] + ) { + parsedMetadataJSON.creationTime = + metadataJSON["creationTime"]["timestamp"] * 1000000; + } + if ( + metadataJSON["modificationTime"] && + metadataJSON["modificationTime"]["timestamp"] + ) { + parsedMetadataJSON.modificationTime = + metadataJSON["modificationTime"]["timestamp"] * 1000000; + } + let locationData: Location = NULL_LOCATION; + if ( + metadataJSON["geoData"] && + (metadataJSON["geoData"]["latitude"] !== 0.0 || + metadataJSON["geoData"]["longitude"] !== 0.0) + ) { + locationData = metadataJSON["geoData"]; + } else if ( + metadataJSON["geoDataExif"] && + (metadataJSON["geoDataExif"]["latitude"] !== 0.0 || + metadataJSON["geoDataExif"]["longitude"] !== 0.0) + ) { + locationData = metadataJSON["geoDataExif"]; + } + if (locationData !== null) { + parsedMetadataJSON.latitude = locationData.latitude; + parsedMetadataJSON.longitude = locationData.longitude; + } + return parsedMetadataJSON; +} + // tries to extract date from file name if available else returns null export function extractDateFromFileName(filename: string): number { try { @@ -272,3 +303,347 @@ function getFileOriginalName(fileName: string) { } return originalName; } + +async function getVideoMetadata(file: File | ElectronFile) { + let videoMetadata = NULL_EXTRACTED_METADATA; + try { + log.info(`getVideoMetadata called for ${getFileNameSize(file)}`); + videoMetadata = await ffmpegService.extractVideoMetadata(file); + log.info( + `videoMetadata successfully extracted ${getFileNameSize(file)}`, + ); + } catch (e) { + log.error("failed to get video metadata", e); + log.info( + `videoMetadata extracted failed ${getFileNameSize(file)} ,${ + e.message + } `, + ); + } + + return videoMetadata; +} + +interface LivePhotoIdentifier { + collectionID: number; + fileType: FILE_TYPE; + name: string; + size: number; +} + +const UNDERSCORE_THREE = "_3"; +// Note: The icloud-photos-downloader library appends _HVEC to the end of the filename in case of live photos +// https://github.com/icloud-photos-downloader/icloud_photos_downloader +const UNDERSCORE_HEVC = "_HVEC"; + +export async function getLivePhotoFileType( + livePhotoAssets: LivePhotoAssets, +): Promise { + const imageFileTypeInfo = await getFileType(livePhotoAssets.image); + const videoFileTypeInfo = await getFileType(livePhotoAssets.video); + return { + fileType: FILE_TYPE.LIVE_PHOTO, + exactType: `${imageFileTypeInfo.exactType}+${videoFileTypeInfo.exactType}`, + imageType: imageFileTypeInfo.exactType, + videoType: videoFileTypeInfo.exactType, + }; +} + +export async function extractLivePhotoMetadata( + worker: Remote, + parsedMetadataJSONMap: ParsedMetadataJSONMap, + collectionID: number, + fileTypeInfo: FileTypeInfo, + livePhotoAssets: LivePhotoAssets2, +): Promise { + const imageFileTypeInfo: FileTypeInfo = { + fileType: FILE_TYPE.IMAGE, + exactType: fileTypeInfo.imageType, + }; + const { + metadata: imageMetadata, + publicMagicMetadata: imagePublicMagicMetadata, + } = await extractFileMetadata( + worker, + parsedMetadataJSONMap, + collectionID, + imageFileTypeInfo, + livePhotoAssets.image, + ); + const videoHash = await getFileHash( + worker, + /* TODO(MR): ElectronFile changes */ + livePhotoAssets.video as File | ElectronFile, + ); + return { + metadata: { + ...imageMetadata, + title: getLivePhotoName(livePhotoAssets), + fileType: FILE_TYPE.LIVE_PHOTO, + imageHash: imageMetadata.hash, + videoHash: videoHash, + hash: undefined, + }, + publicMagicMetadata: imagePublicMagicMetadata, + }; +} + +export function getLivePhotoSize(livePhotoAssets: LivePhotoAssets) { + return livePhotoAssets.image.size + livePhotoAssets.video.size; +} + +export const getLivePhotoName = ({ image }: LivePhotoAssets2) => + typeof image == "string" ? basename(image) : image.name; + +export async function clusterLivePhotoFiles(mediaFiles: FileWithCollection2[]) { + try { + const analysedMediaFiles: FileWithCollection2[] = []; + mediaFiles + .sort((firstMediaFile, secondMediaFile) => + splitFilenameAndExtension( + getFileName(firstMediaFile.file), + )[0].localeCompare( + splitFilenameAndExtension( + getFileName(secondMediaFile.file), + )[0], + ), + ) + .sort( + (firstMediaFile, secondMediaFile) => + firstMediaFile.collectionID - secondMediaFile.collectionID, + ); + let index = 0; + while (index < mediaFiles.length - 1) { + if (uploadCancelService.isUploadCancelationRequested()) { + throw Error(CustomError.UPLOAD_CANCELLED); + } + const firstMediaFile = mediaFiles[index]; + const secondMediaFile = mediaFiles[index + 1]; + const firstFileType = + getFileTypeFromExtensionForLivePhotoClustering( + getFileName(firstMediaFile.file), + ); + const secondFileType = + getFileTypeFromExtensionForLivePhotoClustering( + getFileName(secondMediaFile.file), + ); + const firstFileIdentifier: LivePhotoIdentifier = { + collectionID: firstMediaFile.collectionID, + fileType: firstFileType, + name: getFileName(firstMediaFile.file), + /* TODO(MR): ElectronFile changes */ + size: (firstMediaFile as FileWithCollection).file.size, + }; + const secondFileIdentifier: LivePhotoIdentifier = { + collectionID: secondMediaFile.collectionID, + fileType: secondFileType, + name: getFileName(secondMediaFile.file), + /* TODO(MR): ElectronFile changes */ + size: (secondMediaFile as FileWithCollection).file.size, + }; + if ( + areFilesLivePhotoAssets( + firstFileIdentifier, + secondFileIdentifier, + ) + ) { + let imageFile: File | ElectronFile | string; + let videoFile: File | ElectronFile | string; + if ( + firstFileType === FILE_TYPE.IMAGE && + secondFileType === FILE_TYPE.VIDEO + ) { + imageFile = firstMediaFile.file; + videoFile = secondMediaFile.file; + } else { + videoFile = firstMediaFile.file; + imageFile = secondMediaFile.file; + } + const livePhotoLocalID = firstMediaFile.localID; + analysedMediaFiles.push({ + localID: livePhotoLocalID, + collectionID: firstMediaFile.collectionID, + isLivePhoto: true, + livePhotoAssets: { + image: imageFile, + video: videoFile, + }, + }); + index += 2; + } else { + analysedMediaFiles.push({ + ...firstMediaFile, + isLivePhoto: false, + }); + index += 1; + } + } + if (index === mediaFiles.length - 1) { + analysedMediaFiles.push({ + ...mediaFiles[index], + isLivePhoto: false, + }); + } + return analysedMediaFiles; + } catch (e) { + if (e.message === CustomError.UPLOAD_CANCELLED) { + throw e; + } else { + log.error("failed to cluster live photo", e); + throw e; + } + } +} + +function areFilesLivePhotoAssets( + firstFileIdentifier: LivePhotoIdentifier, + secondFileIdentifier: LivePhotoIdentifier, +) { + const haveSameCollectionID = + firstFileIdentifier.collectionID === secondFileIdentifier.collectionID; + const areNotSameFileType = + firstFileIdentifier.fileType !== secondFileIdentifier.fileType; + + let firstFileNameWithoutSuffix: string; + let secondFileNameWithoutSuffix: string; + if (firstFileIdentifier.fileType === FILE_TYPE.IMAGE) { + firstFileNameWithoutSuffix = removePotentialLivePhotoSuffix( + getFileNameWithoutExtension(firstFileIdentifier.name), + // Note: The Google Live Photo image file can have video extension appended as suffix, passing that to removePotentialLivePhotoSuffix to remove it + // Example: IMG_20210630_0001.mp4.jpg (Google Live Photo image file) + getFileExtensionWithDot(secondFileIdentifier.name), + ); + secondFileNameWithoutSuffix = removePotentialLivePhotoSuffix( + getFileNameWithoutExtension(secondFileIdentifier.name), + ); + } else { + firstFileNameWithoutSuffix = removePotentialLivePhotoSuffix( + getFileNameWithoutExtension(firstFileIdentifier.name), + ); + secondFileNameWithoutSuffix = removePotentialLivePhotoSuffix( + getFileNameWithoutExtension(secondFileIdentifier.name), + getFileExtensionWithDot(firstFileIdentifier.name), + ); + } + if ( + haveSameCollectionID && + isImageOrVideo(firstFileIdentifier.fileType) && + isImageOrVideo(secondFileIdentifier.fileType) && + areNotSameFileType && + firstFileNameWithoutSuffix === secondFileNameWithoutSuffix + ) { + const LIVE_PHOTO_ASSET_SIZE_LIMIT = 20 * 1024 * 1024; // 20MB + + // checks size of live Photo assets are less than allowed limit + // I did that based on the assumption that live photo assets ideally would not be larger than LIVE_PHOTO_ASSET_SIZE_LIMIT + // also zipping library doesn't support stream as a input + if ( + firstFileIdentifier.size <= LIVE_PHOTO_ASSET_SIZE_LIMIT && + secondFileIdentifier.size <= LIVE_PHOTO_ASSET_SIZE_LIMIT + ) { + return true; + } else { + log.error( + `${CustomError.TOO_LARGE_LIVE_PHOTO_ASSETS} - ${JSON.stringify({ + fileSizes: [ + firstFileIdentifier.size, + secondFileIdentifier.size, + ], + })}`, + ); + } + } + return false; +} + +function removePotentialLivePhotoSuffix( + filenameWithoutExtension: string, + suffix?: string, +) { + let presentSuffix: string; + if (filenameWithoutExtension.endsWith(UNDERSCORE_THREE)) { + presentSuffix = UNDERSCORE_THREE; + } else if (filenameWithoutExtension.endsWith(UNDERSCORE_HEVC)) { + presentSuffix = UNDERSCORE_HEVC; + } else if ( + filenameWithoutExtension.endsWith(UNDERSCORE_HEVC.toLowerCase()) + ) { + presentSuffix = UNDERSCORE_HEVC.toLowerCase(); + } else if (suffix) { + if (filenameWithoutExtension.endsWith(suffix)) { + presentSuffix = suffix; + } else if (filenameWithoutExtension.endsWith(suffix.toLowerCase())) { + presentSuffix = suffix.toLowerCase(); + } + } + if (presentSuffix) { + return filenameWithoutExtension.slice(0, presentSuffix.length * -1); + } else { + 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); + +async function getFileHash( + worker: Remote, + file: File | ElectronFile, +) { + try { + log.info(`getFileHash called for ${getFileNameSize(file)}`); + let filedata: DataStream; + if (file instanceof File) { + filedata = getFileStream(file, FILE_READER_CHUNK_SIZE); + } else { + filedata = await getElectronFileStream( + file, + FILE_READER_CHUNK_SIZE, + ); + } + const hashState = await worker.initChunkHashing(); + + const streamReader = filedata.stream.getReader(); + for (let i = 0; i < filedata.chunkCount; i++) { + const { done, value: chunk } = await streamReader.read(); + if (done) { + throw Error(CustomError.CHUNK_LESS_THAN_EXPECTED); + } + await worker.hashFileChunk(hashState, Uint8Array.from(chunk)); + } + const { done } = await streamReader.read(); + if (!done) { + throw Error(CustomError.CHUNK_MORE_THAN_EXPECTED); + } + const hash = await worker.completeChunkHashing(hashState); + log.info( + `file hashing completed successfully ${getFileNameSize(file)}`, + ); + return hash; + } catch (e) { + log.error("getFileHash failed", e); + log.info(`file hashing failed ${getFileNameSize(file)} ,${e.message} `); + } +} diff --git a/web/apps/photos/src/services/upload/thumbnail.ts b/web/apps/photos/src/services/upload/thumbnail.ts new file mode 100644 index 000000000..91b1ea9fb --- /dev/null +++ b/web/apps/photos/src/services/upload/thumbnail.ts @@ -0,0 +1,319 @@ +import { getFileNameSize } from "@/next/file"; +import log from "@/next/log"; +import { CustomErrorMessage, type Electron } from "@/next/types/ipc"; +import { CustomError } from "@ente/shared/error"; +import { FILE_TYPE } from "constants/file"; +import { BLACK_THUMBNAIL_BASE64 } from "constants/upload"; +import * as FFmpegService from "services/ffmpeg"; +import { heicToJPEG } from "services/heic-convert"; +import { ElectronFile, FileTypeInfo } from "types/upload"; +import { isFileHEIC } from "utils/file"; +import { getUint8ArrayView } from "../readerService"; +import { getFileName } from "./uploadService"; + +/** Maximum width or height of the generated thumbnail */ +const maxThumbnailDimension = 720; +/** Maximum size (in bytes) of the generated thumbnail */ +const maxThumbnailSize = 100 * 1024; // 100 KB +const MIN_COMPRESSION_PERCENTAGE_SIZE_DIFF = 10; +const MIN_QUALITY = 0.5; +const MAX_QUALITY = 0.7; + +const WAIT_TIME_THUMBNAIL_GENERATION = 30 * 1000; + +class ModuleState { + /** + * This will be set to true if we get an error from the Node.js side of our + * desktop app telling us that native JPEG conversion is not available for + * the current OS/arch combination. That way, we can stop pestering it again + * and again (saving an IPC round-trip). + * + * Note the double negative when it is used. + */ + isNativeThumbnailCreationNotAvailable = false; +} + +const moduleState = new ModuleState(); + +interface GeneratedThumbnail { + /** The JPEG data of the generated thumbnail */ + thumbnail: Uint8Array; + /** + * `true` if this is a fallback (all black) thumbnail we're returning since + * thumbnail generation failed for some reason. + */ + hasStaticThumbnail: boolean; +} + +/** + * Generate a JPEG thumbnail for the given {@link file}. + * + * The thumbnail has a smaller file size so that is quick to load. But more + * importantly, it uses a universal file format (JPEG in our case) so that the + * thumbnail itself can be opened in all clients, even those like the web client + * itself that might not yet have support for more exotic formats. + */ +export const generateThumbnail = async ( + file: File | ElectronFile, + fileTypeInfo: FileTypeInfo, +): Promise => { + try { + const thumbnail = + fileTypeInfo.fileType === FILE_TYPE.IMAGE + ? await generateImageThumbnail(file, fileTypeInfo) + : await generateVideoThumbnail(file, fileTypeInfo); + + if (thumbnail.length == 0) throw new Error("Empty thumbnail"); + log.debug(() => `Generated thumbnail for ${getFileName(file)}`); + return { thumbnail, hasStaticThumbnail: false }; + } catch (e) { + log.error( + `Failed to generate thumbnail for ${getFileName(file)} with format ${fileTypeInfo.exactType}`, + e, + ); + return { thumbnail: fallbackThumbnail(), hasStaticThumbnail: true }; + } +}; + +/** + * A fallback, black, thumbnail for use in cases where thumbnail generation + * fails. + */ +const fallbackThumbnail = () => + Uint8Array.from(atob(BLACK_THUMBNAIL_BASE64), (c) => c.charCodeAt(0)); + +const generateImageThumbnail = async ( + file: File | ElectronFile, + fileTypeInfo: FileTypeInfo, +) => { + let jpegData: Uint8Array | undefined; + + const electron = globalThis.electron; + const available = !moduleState.isNativeThumbnailCreationNotAvailable; + if (electron && available) { + // If we're running in our desktop app, try to make the thumbnail using + // the native tools available there-in, it'll be faster than doing it on + // the web layer. + try { + jpegData = await generateImageThumbnailInElectron(electron, file); + } catch (e) { + if (e.message == CustomErrorMessage.NotAvailable) { + moduleState.isNativeThumbnailCreationNotAvailable = true; + } else { + log.error("Native thumbnail creation failed", e); + } + } + } + + if (!jpegData) { + jpegData = await generateImageThumbnailUsingCanvas(file, fileTypeInfo); + } + return jpegData; +}; + +const generateImageThumbnailInElectron = async ( + electron: Electron, + inputFile: File | ElectronFile, +): Promise => { + const startTime = Date.now(); + const jpegData = await electron.generateImageThumbnail( + inputFile, + maxThumbnailDimension, + maxThumbnailSize, + ); + log.debug( + () => `Native thumbnail generation took ${Date.now() - startTime} ms`, + ); + return jpegData; +}; + +async function generateImageThumbnailUsingCanvas( + file: File | ElectronFile, + fileTypeInfo: FileTypeInfo, +) { + const canvas = document.createElement("canvas"); + const canvasCTX = canvas.getContext("2d"); + + let imageURL = null; + let timeout = null; + + if (isFileHEIC(fileTypeInfo.exactType)) { + log.debug(() => `Pre-converting ${getFileName(file)} to JPEG`); + const jpegBlob = await heicToJPEG(new Blob([await file.arrayBuffer()])); + file = new File([jpegBlob], file.name); + } + + let image = new Image(); + imageURL = URL.createObjectURL(new Blob([await file.arrayBuffer()])); + await new Promise((resolve, reject) => { + image.setAttribute("src", imageURL); + image.onload = () => { + try { + URL.revokeObjectURL(imageURL); + const { width, height } = scaledThumbnailDimensions( + image.width, + image.height, + maxThumbnailDimension, + ); + canvas.width = width; + canvas.height = height; + canvasCTX.drawImage(image, 0, 0, width, height); + image = null; + clearTimeout(timeout); + resolve(null); + } catch (e) { + const err = new Error(CustomError.THUMBNAIL_GENERATION_FAILED, { + cause: e, + }); + reject(err); + } + }; + timeout = setTimeout( + () => reject(new Error("Operation timed out")), + WAIT_TIME_THUMBNAIL_GENERATION, + ); + }); + const thumbnailBlob = await getCompressedThumbnailBlobFromCanvas(canvas); + return await getUint8ArrayView(thumbnailBlob); +} + +async function generateVideoThumbnail( + file: File | ElectronFile, + fileTypeInfo: FileTypeInfo, +) { + let thumbnail: Uint8Array; + try { + log.info( + `ffmpeg generateThumbnail called for ${getFileNameSize(file)}`, + ); + + const thumbnail = await FFmpegService.generateVideoThumbnail(file); + log.info( + `ffmpeg thumbnail successfully generated ${getFileNameSize(file)}`, + ); + return await getUint8ArrayView(thumbnail); + } catch (e) { + log.info( + `ffmpeg thumbnail generated failed ${getFileNameSize( + file, + )} error: ${e.message}`, + ); + log.error( + `failed to generate thumbnail using ffmpeg for format ${fileTypeInfo.exactType}`, + e, + ); + thumbnail = await generateVideoThumbnailUsingCanvas(file); + } + return thumbnail; +} + +async function generateVideoThumbnailUsingCanvas(file: File | ElectronFile) { + const canvas = document.createElement("canvas"); + const canvasCTX = canvas.getContext("2d"); + + let timeout = null; + let videoURL = null; + + let video = document.createElement("video"); + videoURL = URL.createObjectURL(new Blob([await file.arrayBuffer()])); + await new Promise((resolve, reject) => { + video.preload = "metadata"; + video.src = videoURL; + video.addEventListener("loadeddata", function () { + try { + URL.revokeObjectURL(videoURL); + if (!video) { + throw Error("video load failed"); + } + const { width, height } = scaledThumbnailDimensions( + video.videoWidth, + video.videoHeight, + maxThumbnailDimension, + ); + canvas.width = width; + canvas.height = height; + canvasCTX.drawImage(video, 0, 0, width, height); + video = null; + clearTimeout(timeout); + resolve(null); + } catch (e) { + const err = Error( + `${CustomError.THUMBNAIL_GENERATION_FAILED} err: ${e}`, + ); + log.error(CustomError.THUMBNAIL_GENERATION_FAILED, e); + reject(err); + } + }); + timeout = setTimeout( + () => reject(new Error("Operation timed out")), + WAIT_TIME_THUMBNAIL_GENERATION, + ); + }); + const thumbnailBlob = await getCompressedThumbnailBlobFromCanvas(canvas); + return await getUint8ArrayView(thumbnailBlob); +} + +async function getCompressedThumbnailBlobFromCanvas(canvas: HTMLCanvasElement) { + let thumbnailBlob: Blob = null; + let prevSize = Number.MAX_SAFE_INTEGER; + let quality = MAX_QUALITY; + + do { + if (thumbnailBlob) { + prevSize = thumbnailBlob.size; + } + thumbnailBlob = await new Promise((resolve) => { + canvas.toBlob( + function (blob) { + resolve(blob); + }, + "image/jpeg", + quality, + ); + }); + thumbnailBlob = thumbnailBlob ?? new Blob([]); + quality -= 0.1; + } while ( + quality >= MIN_QUALITY && + thumbnailBlob.size > maxThumbnailSize && + percentageSizeDiff(thumbnailBlob.size, prevSize) >= + MIN_COMPRESSION_PERCENTAGE_SIZE_DIFF + ); + + return thumbnailBlob; +} + +function percentageSizeDiff( + newThumbnailSize: number, + oldThumbnailSize: number, +) { + return ((oldThumbnailSize - newThumbnailSize) * 100) / oldThumbnailSize; +} + +/** + * Compute the size of the thumbnail to create for an image with the given + * {@link width} and {@link height}. + * + * This function calculates a new size of an image for limiting it to maximum + * width and height (both specified by {@link maxDimension}), while maintaining + * aspect ratio. + * + * It returns `{0, 0}` for invalid inputs. + */ +const scaledThumbnailDimensions = ( + width: number, + height: number, + maxDimension: number, +): { width: number; height: number } => { + if (width === 0 || height === 0) return { width: 0, height: 0 }; + const widthScaleFactor = maxDimension / width; + const heightScaleFactor = maxDimension / height; + const scaleFactor = Math.min(widthScaleFactor, heightScaleFactor); + const thumbnailDimensions = { + width: Math.round(width * scaleFactor), + height: Math.round(height * scaleFactor), + }; + if (thumbnailDimensions.width === 0 || thumbnailDimensions.height === 0) + return { width: 0, height: 0 }; + return thumbnailDimensions; +}; diff --git a/web/apps/photos/src/services/upload/thumbnailService.ts b/web/apps/photos/src/services/upload/thumbnailService.ts deleted file mode 100644 index 071ef3078..000000000 --- a/web/apps/photos/src/services/upload/thumbnailService.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { ensureElectron } from "@/next/electron"; -import { convertBytesToHumanReadable, getFileNameSize } from "@/next/file"; -import log from "@/next/log"; -import { CustomError } from "@ente/shared/error"; -import { FILE_TYPE } from "constants/file"; -import { BLACK_THUMBNAIL_BASE64 } from "constants/upload"; -import isElectron from "is-electron"; -import * as FFmpegService from "services/ffmpeg/ffmpegService"; -import HeicConversionService from "services/heicConversionService"; -import { ElectronFile, FileTypeInfo } from "types/upload"; -import { isFileHEIC } from "utils/file"; -import { getUint8ArrayView } from "../readerService"; - -const MAX_THUMBNAIL_DIMENSION = 720; -const MIN_COMPRESSION_PERCENTAGE_SIZE_DIFF = 10; -const MAX_THUMBNAIL_SIZE = 100 * 1024; -const MIN_QUALITY = 0.5; -const MAX_QUALITY = 0.7; - -const WAIT_TIME_THUMBNAIL_GENERATION = 30 * 1000; - -interface Dimension { - width: number; - height: number; -} - -export async function generateThumbnail( - file: File | ElectronFile, - fileTypeInfo: FileTypeInfo, -): Promise<{ thumbnail: Uint8Array; hasStaticThumbnail: boolean }> { - try { - log.info(`generating thumbnail for ${getFileNameSize(file)}`); - let hasStaticThumbnail = false; - let thumbnail: Uint8Array; - try { - if (fileTypeInfo.fileType === FILE_TYPE.IMAGE) { - thumbnail = await generateImageThumbnail(file, fileTypeInfo); - } else { - thumbnail = await generateVideoThumbnail(file, fileTypeInfo); - } - if (thumbnail.length > 1.5 * MAX_THUMBNAIL_SIZE) { - log.error( - `thumbnail greater than max limit - ${JSON.stringify({ - thumbnailSize: convertBytesToHumanReadable( - thumbnail.length, - ), - fileSize: convertBytesToHumanReadable(file.size), - fileType: fileTypeInfo.exactType, - })}`, - ); - } - if (thumbnail.length === 0) { - throw Error("EMPTY THUMBNAIL"); - } - log.info( - `thumbnail successfully generated ${getFileNameSize(file)}`, - ); - } catch (e) { - log.error( - `thumbnail generation failed ${getFileNameSize(file)} with format ${fileTypeInfo.exactType}`, - e, - ); - thumbnail = Uint8Array.from(atob(BLACK_THUMBNAIL_BASE64), (c) => - c.charCodeAt(0), - ); - hasStaticThumbnail = true; - } - return { thumbnail, hasStaticThumbnail }; - } catch (e) { - log.error("Error generating static thumbnail", e); - throw e; - } -} - -async function generateImageThumbnail( - file: File | ElectronFile, - fileTypeInfo: FileTypeInfo, -) { - if (isElectron()) { - try { - return await generateImageThumbnailInElectron( - file, - MAX_THUMBNAIL_DIMENSION, - MAX_THUMBNAIL_SIZE, - ); - } catch (e) { - return await generateImageThumbnailUsingCanvas(file, fileTypeInfo); - } - } else { - return await generateImageThumbnailUsingCanvas(file, fileTypeInfo); - } -} - -const generateImageThumbnailInElectron = async ( - inputFile: File | ElectronFile, - maxDimension: number, - maxSize: number, -): Promise => { - try { - const startTime = Date.now(); - const thumb = await ensureElectron().generateImageThumbnail( - inputFile, - maxDimension, - maxSize, - ); - log.info( - `originalFileSize:${convertBytesToHumanReadable( - inputFile?.size, - )},thumbFileSize:${convertBytesToHumanReadable( - thumb?.length, - )}, native thumbnail generation time: ${ - Date.now() - startTime - }ms `, - ); - return thumb; - } catch (e) { - if ( - e.message !== - CustomError.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED - ) { - log.error("failed to generate image thumbnail natively", e); - } - throw e; - } -}; - -export async function generateImageThumbnailUsingCanvas( - file: File | ElectronFile, - fileTypeInfo: FileTypeInfo, -) { - const canvas = document.createElement("canvas"); - const canvasCTX = canvas.getContext("2d"); - - let imageURL = null; - let timeout = null; - const isHEIC = isFileHEIC(fileTypeInfo.exactType); - if (isHEIC) { - log.info(`HEICConverter called for ${getFileNameSize(file)}`); - const convertedBlob = await HeicConversionService.convert( - new Blob([await file.arrayBuffer()]), - ); - file = new File([convertedBlob], file.name); - log.info(`${getFileNameSize(file)} successfully converted`); - } - let image = new Image(); - imageURL = URL.createObjectURL(new Blob([await file.arrayBuffer()])); - await new Promise((resolve, reject) => { - image.setAttribute("src", imageURL); - image.onload = () => { - try { - URL.revokeObjectURL(imageURL); - const imageDimension = { - width: image.width, - height: image.height, - }; - const thumbnailDimension = calculateThumbnailDimension( - imageDimension, - MAX_THUMBNAIL_DIMENSION, - ); - canvas.width = thumbnailDimension.width; - canvas.height = thumbnailDimension.height; - canvasCTX.drawImage( - image, - 0, - 0, - thumbnailDimension.width, - thumbnailDimension.height, - ); - image = null; - clearTimeout(timeout); - resolve(null); - } catch (e) { - const err = new Error(CustomError.THUMBNAIL_GENERATION_FAILED, { - cause: e, - }); - reject(err); - } - }; - timeout = setTimeout( - () => reject(new Error("Operation timed out")), - WAIT_TIME_THUMBNAIL_GENERATION, - ); - }); - const thumbnailBlob = await getCompressedThumbnailBlobFromCanvas(canvas); - return await getUint8ArrayView(thumbnailBlob); -} - -async function generateVideoThumbnail( - file: File | ElectronFile, - fileTypeInfo: FileTypeInfo, -) { - let thumbnail: Uint8Array; - try { - log.info( - `ffmpeg generateThumbnail called for ${getFileNameSize(file)}`, - ); - - const thumbnail = await FFmpegService.generateVideoThumbnail(file); - log.info( - `ffmpeg thumbnail successfully generated ${getFileNameSize(file)}`, - ); - return await getUint8ArrayView(thumbnail); - } catch (e) { - log.info( - `ffmpeg thumbnail generated failed ${getFileNameSize( - file, - )} error: ${e.message}`, - ); - log.error( - `failed to generate thumbnail using ffmpeg for format ${fileTypeInfo.exactType}`, - e, - ); - thumbnail = await generateVideoThumbnailUsingCanvas(file); - } - return thumbnail; -} - -export async function generateVideoThumbnailUsingCanvas( - file: File | ElectronFile, -) { - const canvas = document.createElement("canvas"); - const canvasCTX = canvas.getContext("2d"); - - let timeout = null; - let videoURL = null; - - let video = document.createElement("video"); - videoURL = URL.createObjectURL(new Blob([await file.arrayBuffer()])); - await new Promise((resolve, reject) => { - video.preload = "metadata"; - video.src = videoURL; - video.addEventListener("loadeddata", function () { - try { - URL.revokeObjectURL(videoURL); - if (!video) { - throw Error("video load failed"); - } - const videoDimension = { - width: video.videoWidth, - height: video.videoHeight, - }; - const thumbnailDimension = calculateThumbnailDimension( - videoDimension, - MAX_THUMBNAIL_DIMENSION, - ); - canvas.width = thumbnailDimension.width; - canvas.height = thumbnailDimension.height; - canvasCTX.drawImage( - video, - 0, - 0, - thumbnailDimension.width, - thumbnailDimension.height, - ); - video = null; - clearTimeout(timeout); - resolve(null); - } catch (e) { - const err = Error( - `${CustomError.THUMBNAIL_GENERATION_FAILED} err: ${e}`, - ); - log.error(CustomError.THUMBNAIL_GENERATION_FAILED, e); - reject(err); - } - }); - timeout = setTimeout( - () => reject(new Error("Operation timed out")), - WAIT_TIME_THUMBNAIL_GENERATION, - ); - }); - const thumbnailBlob = await getCompressedThumbnailBlobFromCanvas(canvas); - return await getUint8ArrayView(thumbnailBlob); -} - -async function getCompressedThumbnailBlobFromCanvas(canvas: HTMLCanvasElement) { - let thumbnailBlob: Blob = null; - let prevSize = Number.MAX_SAFE_INTEGER; - let quality = MAX_QUALITY; - - do { - if (thumbnailBlob) { - prevSize = thumbnailBlob.size; - } - thumbnailBlob = await new Promise((resolve) => { - canvas.toBlob( - function (blob) { - resolve(blob); - }, - "image/jpeg", - quality, - ); - }); - thumbnailBlob = thumbnailBlob ?? new Blob([]); - quality -= 0.1; - } while ( - quality >= MIN_QUALITY && - thumbnailBlob.size > MAX_THUMBNAIL_SIZE && - percentageSizeDiff(thumbnailBlob.size, prevSize) >= - MIN_COMPRESSION_PERCENTAGE_SIZE_DIFF - ); - - return thumbnailBlob; -} - -function percentageSizeDiff( - newThumbnailSize: number, - oldThumbnailSize: number, -) { - return ((oldThumbnailSize - newThumbnailSize) * 100) / oldThumbnailSize; -} - -// method to calculate new size of image for limiting it to maximum width and height, maintaining aspect ratio -// returns {0,0} for invalid inputs -function calculateThumbnailDimension( - originalDimension: Dimension, - maxDimension: number, -): Dimension { - if (originalDimension.height === 0 || originalDimension.width === 0) { - return { width: 0, height: 0 }; - } - const widthScaleFactor = maxDimension / originalDimension.width; - const heightScaleFactor = maxDimension / originalDimension.height; - const scaleFactor = Math.min(widthScaleFactor, heightScaleFactor); - const thumbnailDimension = { - width: Math.round(originalDimension.width * scaleFactor), - height: Math.round(originalDimension.height * scaleFactor), - }; - if (thumbnailDimension.width === 0 || thumbnailDimension.height === 0) { - return { width: 0, height: 0 }; - } - return thumbnailDimension; -} diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index 82b761091..05a336be5 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -1,4 +1,4 @@ -import { getFileNameSize } from "@/next/file"; +import { ensureElectron } from "@/next/electron"; import log from "@/next/log"; import { ComlinkWorker } from "@/next/worker/comlink-worker"; import { getDedicatedCryptoWorker } from "@ente/shared/crypto"; @@ -8,37 +8,39 @@ 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 { 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"; import { + ElectronFile, FileWithCollection, ParsedMetadataJSON, ParsedMetadataJSONMap, PublicUploadProps, + type FileWithCollection2, } from "types/upload"; import { ProgressUpdater } from "types/upload/ui"; import { decryptFile, getUserOwnedFiles, sortFiles } from "utils/file"; import { areFileWithCollectionsSame, segregateMetadataAndMediaFiles, + segregateMetadataAndMediaFiles2, } from "utils/upload"; import { getLocalFiles } from "../fileService"; import { + clusterLivePhotoFiles, getMetadataJSONMapKeyForJSON, parseMetadataJSON, } from "./metadataService"; import { default as UIService, default as uiService } from "./uiService"; import uploadCancelService from "./uploadCancelService"; -import UploadService from "./uploadService"; -import uploader from "./uploader"; +import UploadService, { getFileName, uploader } from "./uploadService"; const MAX_CONCURRENT_UPLOADS = 4; @@ -47,9 +49,9 @@ class UploadManager { ComlinkWorker >(MAX_CONCURRENT_UPLOADS); private parsedMetadataJSONMap: ParsedMetadataJSONMap; - private filesToBeUploaded: FileWithCollection[]; - private remainingFiles: FileWithCollection[] = []; - private failedFiles: FileWithCollection[]; + private filesToBeUploaded: FileWithCollection2[]; + private remainingFiles: FileWithCollection2[] = []; + private failedFiles: FileWithCollection2[]; private existingFiles: EnteFile[]; private setFiles: SetFiles; private collections: Map; @@ -151,7 +153,7 @@ class UploadManager { if (mediaFiles.length) { log.info(`clusterLivePhotoFiles started`); const analysedMediaFiles = - await UploadService.clusterLivePhotoFiles(mediaFiles); + await clusterLivePhotoFiles(mediaFiles); log.info(`clusterLivePhotoFiles ended`); log.info( `got live photos: ${ @@ -177,7 +179,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); @@ -202,37 +204,121 @@ class UploadManager { } } - private async parseMetadataJSONFiles(metadataFiles: FileWithCollection[]) { + public async queueFilesForUpload2( + filesWithCollectionToUploadIn: FileWithCollection2[], + collections: Collection[], + uploaderName?: string, + ) { + try { + if (this.uploadInProgress) { + throw Error("can't run multiple uploads at once"); + } + this.uploadInProgress = true; + await this.updateExistingFilesAndCollections(collections); + this.uploaderName = uploaderName; + log.info( + `received ${filesWithCollectionToUploadIn.length} files to upload`, + ); + uiService.setFilenames( + new Map( + filesWithCollectionToUploadIn.map((mediaFile) => [ + mediaFile.localID, + UploadService.getAssetName(mediaFile), + ]), + ), + ); + const { metadataJSONFiles, mediaFiles } = + segregateMetadataAndMediaFiles2(filesWithCollectionToUploadIn); + log.info(`has ${metadataJSONFiles.length} metadata json files`); + log.info(`has ${mediaFiles.length} media files`); + if (metadataJSONFiles.length) { + UIService.setUploadStage( + UPLOAD_STAGES.READING_GOOGLE_METADATA_FILES, + ); + await this.parseMetadataJSONFiles(metadataJSONFiles); + + UploadService.setParsedMetadataJSONMap( + this.parsedMetadataJSONMap, + ); + } + if (mediaFiles.length) { + log.info(`clusterLivePhotoFiles started`); + const analysedMediaFiles = + await clusterLivePhotoFiles(mediaFiles); + log.info(`clusterLivePhotoFiles ended`); + log.info( + `got live photos: ${ + mediaFiles.length !== analysedMediaFiles.length + }`, + ); + uiService.setFilenames( + new Map( + analysedMediaFiles.map((mediaFile) => [ + mediaFile.localID, + UploadService.getAssetName(mediaFile), + ]), + ), + ); + + UIService.setHasLivePhoto( + mediaFiles.length !== analysedMediaFiles.length, + ); + + await this.uploadMediaFiles(analysedMediaFiles); + } + } catch (e) { + if (e.message === CustomError.UPLOAD_CANCELLED) { + if (isElectron()) { + this.remainingFiles = []; + await cancelRemainingUploads(); + } + } else { + log.error("uploading failed with error", e); + throw e; + } + } finally { + UIService.setUploadStage(UPLOAD_STAGES.FINISH); + for (let i = 0; i < MAX_CONCURRENT_UPLOADS; i++) { + this.cryptoWorkers[i]?.terminate(); + } + this.uploadInProgress = false; + } + try { + if (!UIService.hasFilesInResultList()) { + return true; + } else { + return false; + } + } catch (e) { + log.error(" failed to return shouldCloseProgressBar", e); + return false; + } + } + + private async parseMetadataJSONFiles(metadataFiles: FileWithCollection2[]) { try { log.info(`parseMetadataJSONFiles function executed `); UIService.reset(metadataFiles.length); for (const { file, collectionID } of metadataFiles) { + const name = getFileName(file); try { if (uploadCancelService.isUploadCancelationRequested()) { throw Error(CustomError.UPLOAD_CANCELLED); } - log.info( - `parsing metadata json file ${getFileNameSize(file)}`, - ); + + log.info(`parsing metadata json file ${name}`); const parsedMetadataJSON = await parseMetadataJSON(file); if (parsedMetadataJSON) { this.parsedMetadataJSONMap.set( - getMetadataJSONMapKeyForJSON( - collectionID, - file.name, - ), + getMetadataJSONMapKeyForJSON(collectionID, name), parsedMetadataJSON && { ...parsedMetadataJSON }, ); UIService.increaseFileUploaded(); } - log.info( - `successfully parsed metadata json file ${getFileNameSize( - file, - )}`, - ); + log.info(`successfully parsed metadata json file ${name}`); } catch (e) { if (e.message === CustomError.UPLOAD_CANCELLED) { throw e; @@ -240,9 +326,7 @@ class UploadManager { // and don't break for subsequent files just log and move on log.error("parsing failed for a file", e); log.info( - `failed to parse metadata json file ${getFileNameSize( - file, - )} error: ${e.message}`, + `failed to parse metadata json file ${name} error: ${e.message}`, ); } } @@ -255,7 +339,7 @@ class UploadManager { } } - private async uploadMediaFiles(mediaFiles: FileWithCollection[]) { + private async uploadMediaFiles(mediaFiles: FileWithCollection2[]) { log.info(`uploadMediaFiles called`); this.filesToBeUploaded = [...this.filesToBeUploaded, ...mediaFiles]; @@ -316,7 +400,7 @@ class UploadManager { async postUploadTask( fileUploadResult: UPLOAD_RESULT, uploadedFile: EncryptedEnteFile | EnteFile | null, - fileWithCollection: FileWithCollection, + fileWithCollection: FileWithCollection2, ) { try { let decryptedFile: EnteFile; @@ -383,15 +467,17 @@ class UploadManager { private async watchFolderCallback( fileUploadResult: UPLOAD_RESULT, - fileWithCollection: FileWithCollection, + fileWithCollection: FileWithCollection2, uploadedFile: EncryptedEnteFile, ) { if (isElectron()) { - await watchFolderService.onFileUpload( - fileUploadResult, - fileWithCollection, - uploadedFile, - ); + if (watcher.isUploadRunning()) { + await watcher.onFileUpload( + fileUploadResult, + fileWithCollection, + uploadedFile, + ); + } } } @@ -425,19 +511,62 @@ class UploadManager { } private async updateElectronRemainingFiles( - fileWithCollection: FileWithCollection, + fileWithCollection: FileWithCollection2, ) { if (isElectron()) { 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(); }; } export default new UploadManager(); + +export const setToUploadCollection = async (collections: Collection[]) => { + let collectionName: string = null; + /* collection being one suggest one of two things + 1. Either the user has upload to a single existing collection + 2. Created a new single collection to upload to + may have had multiple folder, but chose to upload + to one album + hence saving the collection name when upload collection count is 1 + helps the info of user choosing this options + and on next upload we can directly start uploading to this collection + */ + if (collections.length === 1) { + collectionName = collections[0].name; + } + await ensureElectron().setPendingUploadCollection(collectionName); +}; + +const updatePendingUploads = async (files: FileWithCollection2[]) => { + const paths = files + .map((file) => + file.isLivePhoto + ? [file.livePhotoAssets.image, file.livePhotoAssets.video] + : [file.file], + ) + .flat() + .map((f) => getFilePathElectron(f)); + await ensureElectron().setPendingUploadFiles("files", paths); +}; + +/** + * NOTE: a stop gap measure, only meant to be called by code that is running in + * the context of a desktop app initiated upload + */ +export const getFilePathElectron = (file: File | ElectronFile | string) => + typeof file == "string" ? file : (file as ElectronFile).path; + +const cancelRemainingUploads = 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/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index 95e4752a7..78953bd24 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -1,16 +1,40 @@ +import { encodeLivePhoto } from "@/media/live-photo"; +import { + basename, + convertBytesToHumanReadable, + getFileNameSize, +} from "@/next/file"; import log from "@/next/log"; import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker"; -import { B64EncryptionResult } from "@ente/shared/crypto/types"; +import { + B64EncryptionResult, + EncryptionResult, +} from "@ente/shared/crypto/types"; import { CustomError, handleUploadError } from "@ente/shared/error"; +import { wait } from "@ente/shared/utils"; import { Remote } from "comlink"; +import { FILE_TYPE } from "constants/file"; +import { + FILE_READER_CHUNK_SIZE, + MULTIPART_PART_SIZE, + UPLOAD_RESULT, +} from "constants/upload"; +import { addToCollection } from "services/collectionService"; import { Collection } from "types/collection"; -import { FilePublicMagicMetadataProps } from "types/file"; +import { + EnteFile, + FilePublicMagicMetadata, + FilePublicMagicMetadataProps, +} from "types/file"; +import { EncryptedMagicMetadata } from "types/magicMetadata"; import { BackupedFile, + DataStream, + ElectronFile, EncryptedFile, ExtractMetadataResult, + FileInMemory, FileTypeInfo, - FileWithCollection, FileWithMetadata, Logger, ParsedMetadataJSON, @@ -21,29 +45,39 @@ import { UploadFile, UploadURL, isDataStream, + type FileWithCollection2, + type LivePhotoAssets, + type UploadAsset2, } from "types/upload"; +import { + getNonEmptyMagicMetadataProps, + updateMagicMetadata, +} from "utils/magicMetadata"; +import { findMatchingExistingFiles } from "utils/upload"; +import { + getElectronFileStream, + getFileStream, + getUint8ArrayView, +} from "../readerService"; import { getFileType } from "../typeDetectionService"; import { - encryptFile, - extractFileMetadata, - getFileSize, - getFilename, - readFile, -} from "./fileService"; -import { - clusterLivePhotoFiles, + MAX_FILE_NAME_LENGTH_GOOGLE_EXPORT, extractLivePhotoMetadata, + extractMetadata, + getClippedMetadataJSONMapKeyForFile, getLivePhotoFileType, getLivePhotoName, getLivePhotoSize, - readLivePhoto, -} from "./livePhotoService"; -import { constructPublicMagicMetadata } from "./magicMetadataService"; + getMetadataJSONMapKeyForFile, +} from "./metadataService"; import { uploadStreamUsingMultipart } from "./multiPartUploadService"; import publicUploadHttpClient from "./publicUploadHttpClient"; +import { generateThumbnail } from "./thumbnail"; import UIService from "./uiService"; +import uploadCancelService from "./uploadCancelService"; import UploadHttpClient from "./uploadHttpClient"; +/** Upload files to cloud storage */ class UploadService { private uploadURLs: UploadURL[] = []; private parsedMetadataJSONMap: ParsedMetadataJSONMap = new Map< @@ -98,10 +132,10 @@ class UploadService { : getFileSize(file); } - getAssetName({ isLivePhoto, file, livePhotoAssets }: UploadAsset) { + getAssetName({ isLivePhoto, file, livePhotoAssets }: UploadAsset2) { return isLivePhoto ? getLivePhotoName(livePhotoAssets) - : getFilename(file); + : getFileName(file); } getAssetFileType({ isLivePhoto, file, livePhotoAssets }: UploadAsset) { @@ -121,7 +155,7 @@ class UploadService { async extractAssetMetadata( worker: Remote, - { isLivePhoto, file, livePhotoAssets }: UploadAsset, + { isLivePhoto, file, livePhotoAssets }: UploadAsset2, collectionID: number, fileTypeInfo: FileTypeInfo, ): Promise { @@ -142,10 +176,6 @@ class UploadService { ); } - clusterLivePhotoFiles(mediaFiles: FileWithCollection[]) { - return clusterLivePhotoFiles(mediaFiles); - } - constructPublicMagicMetadata( publicMagicMetadataProps: FilePublicMagicMetadataProps, ) { @@ -310,4 +340,401 @@ class UploadService { } } -export default new UploadService(); +/** The singleton instance of {@link UploadService}. */ +const uploadService = new UploadService(); + +export default uploadService; + +export async function constructPublicMagicMetadata( + publicMagicMetadataProps: FilePublicMagicMetadataProps, +): Promise { + const nonEmptyPublicMagicMetadataProps = getNonEmptyMagicMetadataProps( + publicMagicMetadataProps, + ); + + if (Object.values(nonEmptyPublicMagicMetadataProps)?.length === 0) { + return null; + } + return await updateMagicMetadata(publicMagicMetadataProps); +} + +function getFileSize(file: File | ElectronFile) { + return file.size; +} + +export const getFileName = (file: File | ElectronFile | string) => + typeof file == "string" ? basename(file) : file.name; + +async function readFile( + fileTypeInfo: FileTypeInfo, + rawFile: File | ElectronFile, +): Promise { + const { thumbnail, hasStaticThumbnail } = await generateThumbnail( + rawFile, + fileTypeInfo, + ); + log.info(`reading file data ${getFileNameSize(rawFile)} `); + let filedata: Uint8Array | DataStream; + if (!(rawFile instanceof File)) { + if (rawFile.size > MULTIPART_PART_SIZE) { + filedata = await getElectronFileStream( + rawFile, + FILE_READER_CHUNK_SIZE, + ); + } else { + filedata = await getUint8ArrayView(rawFile); + } + } else if (rawFile.size > MULTIPART_PART_SIZE) { + filedata = getFileStream(rawFile, FILE_READER_CHUNK_SIZE); + } else { + filedata = await getUint8ArrayView(rawFile); + } + + log.info(`read file data successfully ${getFileNameSize(rawFile)} `); + + return { + filedata, + thumbnail, + hasStaticThumbnail, + }; +} + +async function readLivePhoto( + fileTypeInfo: FileTypeInfo, + livePhotoAssets: LivePhotoAssets, +) { + const { thumbnail, hasStaticThumbnail } = await generateThumbnail( + livePhotoAssets.image, + { + exactType: fileTypeInfo.imageType, + fileType: FILE_TYPE.IMAGE, + }, + ); + + const imageData = await getUint8ArrayView(livePhotoAssets.image); + + const videoData = await getUint8ArrayView(livePhotoAssets.video); + + return { + filedata: await encodeLivePhoto({ + imageFileName: livePhotoAssets.image.name, + imageData, + videoFileName: livePhotoAssets.video.name, + videoData, + }), + thumbnail, + hasStaticThumbnail, + }; +} + +export async function extractFileMetadata( + worker: Remote, + parsedMetadataJSONMap: ParsedMetadataJSONMap, + collectionID: number, + fileTypeInfo: FileTypeInfo, + rawFile: File | ElectronFile | string, +): Promise { + const rawFileName = getFileName(rawFile); + let key = getMetadataJSONMapKeyForFile(collectionID, rawFileName); + let googleMetadata: ParsedMetadataJSON = parsedMetadataJSONMap.get(key); + + if (!googleMetadata && key.length > MAX_FILE_NAME_LENGTH_GOOGLE_EXPORT) { + key = getClippedMetadataJSONMapKeyForFile(collectionID, rawFileName); + googleMetadata = parsedMetadataJSONMap.get(key); + } + + const { metadata, publicMagicMetadata } = await extractMetadata( + worker, + /* TODO(MR): ElectronFile changes */ + rawFile as File | ElectronFile, + fileTypeInfo, + ); + + for (const [key, value] of Object.entries(googleMetadata ?? {})) { + if (!value) { + continue; + } + metadata[key] = value; + } + return { metadata, publicMagicMetadata }; +} + +async function encryptFile( + worker: Remote, + file: FileWithMetadata, + encryptionKey: string, +): Promise { + try { + const { key: fileKey, file: encryptedFiledata } = await encryptFiledata( + worker, + file.filedata, + ); + + const { file: encryptedThumbnail } = await worker.encryptThumbnail( + file.thumbnail, + fileKey, + ); + const { file: encryptedMetadata } = await worker.encryptMetadata( + file.metadata, + fileKey, + ); + + let encryptedPubMagicMetadata: EncryptedMagicMetadata; + if (file.pubMagicMetadata) { + const { file: encryptedPubMagicMetadataData } = + await worker.encryptMetadata( + file.pubMagicMetadata.data, + fileKey, + ); + encryptedPubMagicMetadata = { + version: file.pubMagicMetadata.version, + count: file.pubMagicMetadata.count, + data: encryptedPubMagicMetadataData.encryptedData, + header: encryptedPubMagicMetadataData.decryptionHeader, + }; + } + + const encryptedKey = await worker.encryptToB64(fileKey, encryptionKey); + + const result: EncryptedFile = { + file: { + file: encryptedFiledata, + thumbnail: encryptedThumbnail, + metadata: encryptedMetadata, + pubMagicMetadata: encryptedPubMagicMetadata, + localID: file.localID, + }, + fileKey: encryptedKey, + }; + return result; + } catch (e) { + log.error("Error encrypting files", e); + throw e; + } +} + +async function encryptFiledata( + worker: Remote, + filedata: Uint8Array | DataStream, +): Promise> { + return isDataStream(filedata) + ? await encryptFileStream(worker, filedata) + : await worker.encryptFile(filedata); +} + +async function encryptFileStream( + worker: Remote, + fileData: DataStream, +) { + const { stream, chunkCount } = fileData; + const fileStreamReader = stream.getReader(); + const { key, decryptionHeader, pushState } = + await worker.initChunkEncryption(); + const ref = { pullCount: 1 }; + const encryptedFileStream = new ReadableStream({ + async pull(controller) { + const { value } = await fileStreamReader.read(); + const encryptedFileChunk = await worker.encryptFileChunk( + value, + pushState, + ref.pullCount === chunkCount, + ); + controller.enqueue(encryptedFileChunk); + if (ref.pullCount === chunkCount) { + controller.close(); + } + ref.pullCount++; + }, + }); + return { + key, + file: { + decryptionHeader, + encryptedData: { stream: encryptedFileStream, chunkCount }, + }, + }; +} + +interface UploadResponse { + fileUploadResult: UPLOAD_RESULT; + uploadedFile?: EnteFile; +} + +export async function uploader( + worker: Remote, + existingFiles: EnteFile[], + fileWithCollection: FileWithCollection2, + uploaderName: string, +): Promise { + const { collection, localID, ...uploadAsset2 } = fileWithCollection; + /* TODO(MR): ElectronFile changes */ + const uploadAsset = uploadAsset2 as UploadAsset; + const fileNameSize = `${uploadService.getAssetName( + fileWithCollection, + )}_${convertBytesToHumanReadable(uploadService.getAssetSize(uploadAsset))}`; + + log.info(`uploader called for ${fileNameSize}`); + UIService.setFileProgress(localID, 0); + await wait(0); + let fileTypeInfo: FileTypeInfo; + let fileSize: number; + try { + const maxFileSize = 4 * 1024 * 1024 * 1024; // 4 GB + + fileSize = uploadService.getAssetSize(uploadAsset); + if (fileSize >= maxFileSize) { + return { fileUploadResult: UPLOAD_RESULT.TOO_LARGE }; + } + log.info(`getting filetype for ${fileNameSize}`); + fileTypeInfo = await uploadService.getAssetFileType(uploadAsset); + log.info( + `got filetype for ${fileNameSize} - ${JSON.stringify(fileTypeInfo)}`, + ); + + log.info(`extracting metadata ${fileNameSize}`); + const { metadata, publicMagicMetadata } = + await uploadService.extractAssetMetadata( + worker, + uploadAsset, + collection.id, + fileTypeInfo, + ); + + const matchingExistingFiles = findMatchingExistingFiles( + existingFiles, + metadata, + ); + log.debug( + () => + `matchedFileList: ${matchingExistingFiles + .map((f) => `${f.id}-${f.metadata.title}`) + .join(",")}`, + ); + if (matchingExistingFiles?.length) { + const matchingExistingFilesCollectionIDs = + matchingExistingFiles.map((e) => e.collectionID); + log.debug( + () => + `matched file collectionIDs:${matchingExistingFilesCollectionIDs} + and collectionID:${collection.id}`, + ); + if (matchingExistingFilesCollectionIDs.includes(collection.id)) { + log.info( + `file already present in the collection , skipped upload for ${fileNameSize}`, + ); + const sameCollectionMatchingExistingFile = + matchingExistingFiles.find( + (f) => f.collectionID === collection.id, + ); + return { + fileUploadResult: UPLOAD_RESULT.ALREADY_UPLOADED, + uploadedFile: sameCollectionMatchingExistingFile, + }; + } else { + log.info( + `same file in ${matchingExistingFilesCollectionIDs.length} collection found for ${fileNameSize} ,adding symlink`, + ); + // any of the matching file can used to add a symlink + const resultFile = Object.assign({}, matchingExistingFiles[0]); + resultFile.collectionID = collection.id; + await addToCollection(collection, [resultFile]); + return { + fileUploadResult: UPLOAD_RESULT.ADDED_SYMLINK, + uploadedFile: resultFile, + }; + } + } + if (uploadCancelService.isUploadCancelationRequested()) { + throw Error(CustomError.UPLOAD_CANCELLED); + } + log.info(`reading asset ${fileNameSize}`); + + const file = await uploadService.readAsset(fileTypeInfo, uploadAsset); + + if (file.hasStaticThumbnail) { + metadata.hasStaticThumbnail = true; + } + + const pubMagicMetadata = + await uploadService.constructPublicMagicMetadata({ + ...publicMagicMetadata, + uploaderName, + }); + + const fileWithMetadata: FileWithMetadata = { + localID, + filedata: file.filedata, + thumbnail: file.thumbnail, + metadata, + pubMagicMetadata, + }; + + if (uploadCancelService.isUploadCancelationRequested()) { + throw Error(CustomError.UPLOAD_CANCELLED); + } + log.info(`encryptAsset ${fileNameSize}`); + const encryptedFile = await uploadService.encryptAsset( + worker, + fileWithMetadata, + collection.key, + ); + + if (uploadCancelService.isUploadCancelationRequested()) { + throw Error(CustomError.UPLOAD_CANCELLED); + } + log.info(`uploadToBucket ${fileNameSize}`); + const logger: Logger = (message: string) => { + log.info(message, `fileNameSize: ${fileNameSize}`); + }; + const backupedFile: BackupedFile = await uploadService.uploadToBucket( + logger, + encryptedFile.file, + ); + + const uploadFile: UploadFile = uploadService.getUploadFile( + collection, + backupedFile, + encryptedFile.fileKey, + ); + log.info(`uploading file to server ${fileNameSize}`); + + const uploadedFile = await uploadService.uploadFile(uploadFile); + + log.info(`${fileNameSize} successfully uploaded`); + + return { + fileUploadResult: metadata.hasStaticThumbnail + ? UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL + : UPLOAD_RESULT.UPLOADED, + uploadedFile: uploadedFile, + }; + } catch (e) { + log.info(`upload failed for ${fileNameSize} ,error: ${e.message}`); + if ( + e.message !== CustomError.UPLOAD_CANCELLED && + e.message !== CustomError.UNSUPPORTED_FILE_FORMAT + ) { + log.error( + `file upload failed - ${JSON.stringify({ + fileFormat: fileTypeInfo?.exactType, + fileSize: convertBytesToHumanReadable(fileSize), + })}`, + e, + ); + } + const error = handleUploadError(e); + switch (error.message) { + case CustomError.ETAG_MISSING: + return { fileUploadResult: UPLOAD_RESULT.BLOCKED }; + case CustomError.UNSUPPORTED_FILE_FORMAT: + return { fileUploadResult: UPLOAD_RESULT.UNSUPPORTED }; + case CustomError.FILE_TOO_LARGE: + return { + fileUploadResult: + UPLOAD_RESULT.LARGER_THAN_AVAILABLE_STORAGE, + }; + default: + return { fileUploadResult: UPLOAD_RESULT.FAILED }; + } + } +} diff --git a/web/apps/photos/src/services/upload/uploader.ts b/web/apps/photos/src/services/upload/uploader.ts deleted file mode 100644 index 5fb164c62..000000000 --- a/web/apps/photos/src/services/upload/uploader.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { convertBytesToHumanReadable } from "@/next/file"; -import log from "@/next/log"; -import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker"; -import { CustomError, handleUploadError } from "@ente/shared/error"; -import { sleep } from "@ente/shared/utils"; -import { Remote } from "comlink"; -import { MAX_FILE_SIZE_SUPPORTED, UPLOAD_RESULT } from "constants/upload"; -import { addToCollection } from "services/collectionService"; -import { EnteFile } from "types/file"; -import { - BackupedFile, - FileTypeInfo, - FileWithCollection, - FileWithMetadata, - Logger, - UploadFile, -} from "types/upload"; -import { findMatchingExistingFiles } from "utils/upload"; -import UIService from "./uiService"; -import uploadCancelService from "./uploadCancelService"; -import { - default as UploadService, - default as uploadService, -} from "./uploadService"; - -interface UploadResponse { - fileUploadResult: UPLOAD_RESULT; - uploadedFile?: EnteFile; -} - -export default async function uploader( - worker: Remote, - existingFiles: EnteFile[], - fileWithCollection: FileWithCollection, - uploaderName: string, -): Promise { - const { collection, localID, ...uploadAsset } = fileWithCollection; - const fileNameSize = `${UploadService.getAssetName( - fileWithCollection, - )}_${convertBytesToHumanReadable(UploadService.getAssetSize(uploadAsset))}`; - - log.info(`uploader called for ${fileNameSize}`); - UIService.setFileProgress(localID, 0); - await sleep(0); - let fileTypeInfo: FileTypeInfo; - let fileSize: number; - try { - fileSize = UploadService.getAssetSize(uploadAsset); - if (fileSize >= MAX_FILE_SIZE_SUPPORTED) { - return { fileUploadResult: UPLOAD_RESULT.TOO_LARGE }; - } - log.info(`getting filetype for ${fileNameSize}`); - fileTypeInfo = await UploadService.getAssetFileType(uploadAsset); - log.info( - `got filetype for ${fileNameSize} - ${JSON.stringify(fileTypeInfo)}`, - ); - - log.info(`extracting metadata ${fileNameSize}`); - const { metadata, publicMagicMetadata } = - await UploadService.extractAssetMetadata( - worker, - uploadAsset, - collection.id, - fileTypeInfo, - ); - - const matchingExistingFiles = findMatchingExistingFiles( - existingFiles, - metadata, - ); - log.debug( - () => - `matchedFileList: ${matchingExistingFiles - .map((f) => `${f.id}-${f.metadata.title}`) - .join(",")}`, - ); - if (matchingExistingFiles?.length) { - const matchingExistingFilesCollectionIDs = - matchingExistingFiles.map((e) => e.collectionID); - log.debug( - () => - `matched file collectionIDs:${matchingExistingFilesCollectionIDs} - and collectionID:${collection.id}`, - ); - if (matchingExistingFilesCollectionIDs.includes(collection.id)) { - log.info( - `file already present in the collection , skipped upload for ${fileNameSize}`, - ); - const sameCollectionMatchingExistingFile = - matchingExistingFiles.find( - (f) => f.collectionID === collection.id, - ); - return { - fileUploadResult: UPLOAD_RESULT.ALREADY_UPLOADED, - uploadedFile: sameCollectionMatchingExistingFile, - }; - } else { - log.info( - `same file in ${matchingExistingFilesCollectionIDs.length} collection found for ${fileNameSize} ,adding symlink`, - ); - // any of the matching file can used to add a symlink - const resultFile = Object.assign({}, matchingExistingFiles[0]); - resultFile.collectionID = collection.id; - await addToCollection(collection, [resultFile]); - return { - fileUploadResult: UPLOAD_RESULT.ADDED_SYMLINK, - uploadedFile: resultFile, - }; - } - } - if (uploadCancelService.isUploadCancelationRequested()) { - throw Error(CustomError.UPLOAD_CANCELLED); - } - log.info(`reading asset ${fileNameSize}`); - - const file = await UploadService.readAsset(fileTypeInfo, uploadAsset); - - if (file.hasStaticThumbnail) { - metadata.hasStaticThumbnail = true; - } - - const pubMagicMetadata = - await uploadService.constructPublicMagicMetadata({ - ...publicMagicMetadata, - uploaderName, - }); - - const fileWithMetadata: FileWithMetadata = { - localID, - filedata: file.filedata, - thumbnail: file.thumbnail, - metadata, - pubMagicMetadata, - }; - - if (uploadCancelService.isUploadCancelationRequested()) { - throw Error(CustomError.UPLOAD_CANCELLED); - } - log.info(`encryptAsset ${fileNameSize}`); - const encryptedFile = await UploadService.encryptAsset( - worker, - fileWithMetadata, - collection.key, - ); - - if (uploadCancelService.isUploadCancelationRequested()) { - throw Error(CustomError.UPLOAD_CANCELLED); - } - log.info(`uploadToBucket ${fileNameSize}`); - const logger: Logger = (message: string) => { - log.info(message, `fileNameSize: ${fileNameSize}`); - }; - const backupedFile: BackupedFile = await UploadService.uploadToBucket( - logger, - encryptedFile.file, - ); - - const uploadFile: UploadFile = UploadService.getUploadFile( - collection, - backupedFile, - encryptedFile.fileKey, - ); - log.info(`uploading file to server ${fileNameSize}`); - - const uploadedFile = await UploadService.uploadFile(uploadFile); - - log.info(`${fileNameSize} successfully uploaded`); - - return { - fileUploadResult: metadata.hasStaticThumbnail - ? UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL - : UPLOAD_RESULT.UPLOADED, - uploadedFile: uploadedFile, - }; - } catch (e) { - log.info(`upload failed for ${fileNameSize} ,error: ${e.message}`); - if ( - e.message !== CustomError.UPLOAD_CANCELLED && - e.message !== CustomError.UNSUPPORTED_FILE_FORMAT - ) { - log.error( - `file upload failed - ${JSON.stringify({ - fileFormat: fileTypeInfo?.exactType, - fileSize: convertBytesToHumanReadable(fileSize), - })}`, - e, - ); - } - const error = handleUploadError(e); - switch (error.message) { - case CustomError.ETAG_MISSING: - return { fileUploadResult: UPLOAD_RESULT.BLOCKED }; - case CustomError.UNSUPPORTED_FILE_FORMAT: - return { fileUploadResult: UPLOAD_RESULT.UNSUPPORTED }; - case CustomError.FILE_TOO_LARGE: - return { - fileUploadResult: - UPLOAD_RESULT.LARGER_THAN_AVAILABLE_STORAGE, - }; - default: - return { fileUploadResult: UPLOAD_RESULT.FAILED }; - } - } -} diff --git a/web/apps/photos/src/services/upload/videoMetadataService.ts b/web/apps/photos/src/services/upload/videoMetadataService.ts deleted file mode 100644 index 947bd538c..000000000 --- a/web/apps/photos/src/services/upload/videoMetadataService.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getFileNameSize } from "@/next/file"; -import log from "@/next/log"; -import { NULL_EXTRACTED_METADATA } from "constants/upload"; -import * as ffmpegService from "services/ffmpeg/ffmpegService"; -import { ElectronFile } from "types/upload"; - -export async function getVideoMetadata(file: File | ElectronFile) { - let videoMetadata = NULL_EXTRACTED_METADATA; - try { - log.info(`getVideoMetadata called for ${getFileNameSize(file)}`); - videoMetadata = await ffmpegService.extractVideoMetadata(file); - log.info( - `videoMetadata successfully extracted ${getFileNameSize(file)}`, - ); - } catch (e) { - log.error("failed to get video metadata", e); - log.info( - `videoMetadata extracted failed ${getFileNameSize(file)} ,${ - e.message - } `, - ); - } - - return videoMetadata; -} diff --git a/web/apps/photos/src/services/wasm/ffmpeg.ts b/web/apps/photos/src/services/wasm/ffmpeg.ts deleted file mode 100644 index 10c5a5c05..000000000 --- a/web/apps/photos/src/services/wasm/ffmpeg.ts +++ /dev/null @@ -1,115 +0,0 @@ -import log from "@/next/log"; -import { promiseWithTimeout } from "@ente/shared/utils"; -import QueueProcessor from "@ente/shared/utils/queueProcessor"; -import { generateTempName } from "@ente/shared/utils/temp"; -import { createFFmpeg, FFmpeg } from "ffmpeg-wasm"; -import { getUint8ArrayView } from "services/readerService"; - -const INPUT_PATH_PLACEHOLDER = "INPUT"; -const FFMPEG_PLACEHOLDER = "FFMPEG"; -const OUTPUT_PATH_PLACEHOLDER = "OUTPUT"; - -const FFMPEG_EXECUTION_WAIT_TIME = 30 * 1000; - -export class WasmFFmpeg { - private ffmpeg: FFmpeg; - private ready: Promise = null; - private ffmpegTaskQueue = new QueueProcessor(); - - constructor() { - this.ffmpeg = createFFmpeg({ - corePath: "/js/ffmpeg/ffmpeg-core.js", - mt: false, - }); - - this.ready = this.init(); - } - - private async init() { - if (!this.ffmpeg.isLoaded()) { - await this.ffmpeg.load(); - } - } - - async run( - cmd: string[], - inputFile: File, - outputFileName: string, - dontTimeout = false, - ) { - const response = this.ffmpegTaskQueue.queueUpRequest(() => { - if (dontTimeout) { - return this.execute(cmd, inputFile, outputFileName); - } else { - return promiseWithTimeout( - this.execute(cmd, inputFile, outputFileName), - FFMPEG_EXECUTION_WAIT_TIME, - ); - } - }); - try { - return await response.promise; - } catch (e) { - log.error("ffmpeg run failed", e); - throw e; - } - } - - private async execute( - cmd: string[], - inputFile: File, - outputFileName: string, - ) { - let tempInputFilePath: string; - let tempOutputFilePath: string; - try { - await this.ready; - const extension = getFileExtension(inputFile.name); - const tempNameSuffix = extension ? `input.${extension}` : "input"; - tempInputFilePath = `${generateTempName(10, tempNameSuffix)}`; - this.ffmpeg.FS( - "writeFile", - tempInputFilePath, - await getUint8ArrayView(inputFile), - ); - tempOutputFilePath = `${generateTempName(10, outputFileName)}`; - - cmd = cmd.map((cmdPart) => { - if (cmdPart === FFMPEG_PLACEHOLDER) { - return ""; - } else if (cmdPart === INPUT_PATH_PLACEHOLDER) { - return tempInputFilePath; - } else if (cmdPart === OUTPUT_PATH_PLACEHOLDER) { - return tempOutputFilePath; - } else { - return cmdPart; - } - }); - log.info(`${cmd}`); - await this.ffmpeg.run(...cmd); - return new File( - [this.ffmpeg.FS("readFile", tempOutputFilePath)], - outputFileName, - ); - } finally { - try { - this.ffmpeg.FS("unlink", tempInputFilePath); - } catch (e) { - log.error("unlink input file failed", e); - } - try { - this.ffmpeg.FS("unlink", tempOutputFilePath); - } catch (e) { - log.error("unlink output file failed", e); - } - } - } -} - -function getFileExtension(filename: string) { - const lastDotPosition = filename.lastIndexOf("."); - if (lastDotPosition === -1) return null; - else { - return filename.slice(lastDotPosition + 1); - } -} diff --git a/web/apps/photos/src/services/watch.ts b/web/apps/photos/src/services/watch.ts new file mode 100644 index 000000000..f036676b6 --- /dev/null +++ b/web/apps/photos/src/services/watch.ts @@ -0,0 +1,616 @@ +/** + * @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 { ensureString } from "@/utils/ensure"; +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 { type FileWithCollection2 } 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. + */ + 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; + /** + * A map from file paths to an Ente file for files that were uploaded (or + * symlinked) as part of the most recent upload attempt. + */ + private uploadedFileForPath = new Map(); + /** + * A set of file paths that could not be uploaded in the most recent upload + * attempt. These are the uploads that failed due to a permanent error that + * a retry will not fix. + */ + 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. It will + * initiate the sync but will not await its completion. + * + * This is passed as a param to {@link init}. + */ + private requestSyncWithRemote: () => 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, + requestSyncWithRemote: () => void, + ) { + this.upload = upload; + this.requestSyncWithRemote = requestSyncWithRemote; + this.registerListeners(); + this.syncWithDisk(); + } + + /** Return `true` if we are currently using the uploader. */ + isUploadRunning() { + return this.uploadRunning; + } + + /** Return `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: FileWithCollection2, + file: EncryptedEnteFile, + ) { + // The files we get here will have fileWithCollection.file as a string, + // not as a File or a ElectronFile + if ( + [ + UPLOAD_RESULT.ADDED_SYMLINK, + UPLOAD_RESULT.UPLOADED, + UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL, + UPLOAD_RESULT.ALREADY_UPLOADED, + ].includes(fileUploadResult) + ) { + if (fileWithCollection.isLivePhoto) { + this.uploadedFileForPath.set( + ensureString(fileWithCollection.livePhotoAssets.image), + file, + ); + this.uploadedFileForPath.set( + ensureString(fileWithCollection.livePhotoAssets.video), + file, + ); + } else { + this.uploadedFileForPath.set( + ensureString(fileWithCollection.file), + file, + ); + } + } else if ( + [UPLOAD_RESULT.UNSUPPORTED, UPLOAD_RESULT.TOO_LARGE].includes( + fileUploadResult, + ) + ) { + if (fileWithCollection.isLivePhoto) { + this.unUploadableFilePaths.add( + ensureString(fileWithCollection.livePhotoAssets.image), + ); + this.unUploadableFilePaths.add( + ensureString(fileWithCollection.livePhotoAssets.video), + ); + } else { + this.unUploadableFilePaths.add( + ensureString(fileWithCollection.file), + ); + } + } + } + + /** + * Callback invoked by the uploader whenever all the files we requested to + * {@link upload} get uploaded. + */ + async allFileUploadsDone( + filesWithCollection: FileWithCollection2[], + collections: Collection[], + ) { + const electron = ensureElectron(); + const watch = this.activeWatch; + + log.debug(() => + JSON.stringify({ + f: "watch/allFileUploadsDone", + filesWithCollection, + collections, + watch, + }), + ); + + const { syncedFiles, ignoredFiles } = + this.deduceSyncedAndIgnored(filesWithCollection); + + 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 deduceSyncedAndIgnored(filesWithCollection: FileWithCollection2[]) { + const syncedFiles: FolderWatch["syncedFiles"] = []; + const ignoredFiles: FolderWatch["ignoredFiles"] = []; + + const markSynced = (file: EncryptedEnteFile, path: string) => { + syncedFiles.push({ + path, + uploadedFileID: file.id, + collectionID: file.collectionID, + }); + this.uploadedFileForPath.delete(path); + }; + + const markIgnored = (path: string) => { + log.debug(() => `Permanently ignoring file at ${path}`); + ignoredFiles.push(path); + this.unUploadableFilePaths.delete(path); + }; + + for (const fileWithCollection of filesWithCollection) { + if (fileWithCollection.isLivePhoto) { + const imagePath = ensureString( + fileWithCollection.livePhotoAssets.image, + ); + const videoPath = ensureString( + fileWithCollection.livePhotoAssets.video, + ); + + const imageFile = this.uploadedFileForPath.get(imagePath); + const videoFile = this.uploadedFileForPath.get(videoPath); + + if (imageFile && videoFile) { + markSynced(imageFile, imagePath); + markSynced(videoFile, videoPath); + } else if ( + this.unUploadableFilePaths.has(imagePath) && + this.unUploadableFilePaths.has(videoPath) + ) { + markIgnored(imagePath); + markIgnored(videoPath); + } + } else { + const path = ensureString(fileWithCollection.file); + const file = this.uploadedFileForPath.get(path); + if (file) { + markSynced(file, path); + } else if (this.unUploadableFilePaths.has(path)) { + markIgnored(path); + } + } + } + + 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"]) { + const syncedFileForID = new Map(); + for (const file of syncedFiles) + syncedFileForID.set(file.uploadedFileID, file); + + const files = await getLocalFiles(); + const filesToTrash = files.filter((file) => { + const correspondingSyncedFile = syncedFileForID.get(file.id); + if ( + correspondingSyncedFile && + correspondingSyncedFile.collectionID == file.collectionID + ) { + return true; + } + return false; + }); + + const filesByCollectionID = groupFilesBasedOnCollectionID(filesToTrash); + for (const [id, files] of filesByCollectionID.entries()) { + await removeFromCollection(id, files); + } + + this.requestSyncWithRemote(); + } +} + +/** The singleton instance of {@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..78b46670c 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; @@ -87,8 +92,8 @@ export interface UploadAsset { isLivePhoto?: boolean; file?: File | ElectronFile; livePhotoAssets?: LivePhotoAssets; - isElectron?: boolean; } + export interface LivePhotoAssets { image: globalThis.File | ElectronFile; video: globalThis.File | ElectronFile; @@ -100,6 +105,23 @@ export interface FileWithCollection extends UploadAsset { collectionID?: number; } +export interface UploadAsset2 { + isLivePhoto?: boolean; + file?: File | ElectronFile | string; + livePhotoAssets?: LivePhotoAssets2; +} + +export interface LivePhotoAssets2 { + image: File | ElectronFile | string; + video: File | ElectronFile | string; +} + +export interface FileWithCollection2 extends UploadAsset2 { + localID: number; + collection?: Collection; + collectionID?: number; +} + export type ParsedMetadataJSONMap = Map; export interface UploadURL { @@ -151,13 +173,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/comlink/ComlinkConvertWorker.ts b/web/apps/photos/src/utils/comlink/ComlinkConvertWorker.ts deleted file mode 100644 index 860317158..000000000 --- a/web/apps/photos/src/utils/comlink/ComlinkConvertWorker.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { haveWindow } from "@/next/env"; -import { ComlinkWorker } from "@/next/worker/comlink-worker"; -import { Remote } from "comlink"; -import { DedicatedConvertWorker } from "worker/convert.worker"; - -class ComlinkConvertWorker { - private comlinkWorkerInstance: Remote; - - async getInstance() { - if (!this.comlinkWorkerInstance) { - this.comlinkWorkerInstance = - await getDedicatedConvertWorker().remote; - } - return this.comlinkWorkerInstance; - } -} - -export const getDedicatedConvertWorker = () => { - if (haveWindow()) { - const cryptoComlinkWorker = new ComlinkWorker< - typeof DedicatedConvertWorker - >( - "ente-convert-worker", - new Worker(new URL("worker/convert.worker.ts", import.meta.url)), - ); - return cryptoComlinkWorker; - } -}; - -export default new ComlinkConvertWorker(); diff --git a/web/apps/photos/src/utils/comlink/ComlinkFFmpegWorker.ts b/web/apps/photos/src/utils/comlink/ComlinkFFmpegWorker.ts deleted file mode 100644 index 29d19d6fa..000000000 --- a/web/apps/photos/src/utils/comlink/ComlinkFFmpegWorker.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ComlinkWorker } from "@/next/worker/comlink-worker"; -import { Remote } from "comlink"; -import { DedicatedFFmpegWorker } from "worker/ffmpeg.worker"; - -class ComlinkFFmpegWorker { - private comlinkWorkerInstance: Promise>; - - async getInstance() { - if (!this.comlinkWorkerInstance) { - const comlinkWorker = getDedicatedFFmpegWorker(); - this.comlinkWorkerInstance = comlinkWorker.remote; - } - return this.comlinkWorkerInstance; - } -} - -const getDedicatedFFmpegWorker = () => { - const cryptoComlinkWorker = new ComlinkWorker( - "ente-ffmpeg-worker", - new Worker(new URL("worker/ffmpeg.worker.ts", import.meta.url)), - ); - return cryptoComlinkWorker; -}; - -export default new ComlinkFFmpegWorker(); diff --git a/web/apps/photos/src/utils/comlink/ComlinkMLWorker.ts b/web/apps/photos/src/utils/comlink/ComlinkMLWorker.ts index c1ed53f7b..f312a2c5c 100644 --- a/web/apps/photos/src/utils/comlink/ComlinkMLWorker.ts +++ b/web/apps/photos/src/utils/comlink/ComlinkMLWorker.ts @@ -1,6 +1,6 @@ import { haveWindow } from "@/next/env"; import { ComlinkWorker } from "@/next/worker/comlink-worker"; -import { DedicatedMLWorker } from "worker/ml.worker"; +import { type DedicatedMLWorker } from "worker/ml.worker"; export const getDedicatedMLWorker = (name: string) => { if (haveWindow()) { diff --git a/web/apps/photos/src/utils/comlink/ComlinkSearchWorker.ts b/web/apps/photos/src/utils/comlink/ComlinkSearchWorker.ts index bc6506605..4886bacda 100644 --- a/web/apps/photos/src/utils/comlink/ComlinkSearchWorker.ts +++ b/web/apps/photos/src/utils/comlink/ComlinkSearchWorker.ts @@ -1,7 +1,7 @@ import { haveWindow } from "@/next/env"; import { ComlinkWorker } from "@/next/worker/comlink-worker"; import { Remote } from "comlink"; -import { DedicatedSearchWorker } from "worker/search.worker"; +import { type DedicatedSearchWorker } from "worker/search.worker"; class ComlinkSearchWorker { private comlinkWorkerInstance: Remote; diff --git a/web/apps/photos/src/utils/ffmpeg/index.ts b/web/apps/photos/src/utils/ffmpeg/index.ts deleted file mode 100644 index 1b3445976..000000000 --- a/web/apps/photos/src/utils/ffmpeg/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time"; -import { NULL_LOCATION } from "constants/upload"; -import { ParsedExtractedMetadata } from "types/upload"; - -enum MetadataTags { - CREATION_TIME = "creation_time", - APPLE_CONTENT_IDENTIFIER = "com.apple.quicktime.content.identifier", - APPLE_LIVE_PHOTO_IDENTIFIER = "com.apple.quicktime.live-photo.auto", - APPLE_CREATION_DATE = "com.apple.quicktime.creationdate", - APPLE_LOCATION_ISO = "com.apple.quicktime.location.ISO6709", - LOCATION = "location", -} - -export function parseFFmpegExtractedMetadata(encodedMetadata: Uint8Array) { - const metadataString = new TextDecoder().decode(encodedMetadata); - const metadataPropertyArray = metadataString.split("\n"); - const metadataKeyValueArray = metadataPropertyArray.map((property) => - property.split("="), - ); - const validKeyValuePairs = metadataKeyValueArray.filter( - (keyValueArray) => keyValueArray.length === 2, - ) as Array<[string, string]>; - - const metadataMap = Object.fromEntries(validKeyValuePairs); - - const location = parseAppleISOLocation( - metadataMap[MetadataTags.APPLE_LOCATION_ISO] ?? - metadataMap[MetadataTags.LOCATION], - ); - - const creationTime = parseCreationTime( - metadataMap[MetadataTags.APPLE_CREATION_DATE] ?? - metadataMap[MetadataTags.CREATION_TIME], - ); - const parsedMetadata: ParsedExtractedMetadata = { - creationTime, - location: { - latitude: location.latitude, - longitude: location.longitude, - }, - width: null, - height: null, - }; - return parsedMetadata; -} - -function parseAppleISOLocation(isoLocation: string) { - let location = NULL_LOCATION; - if (isoLocation) { - const [latitude, longitude] = isoLocation - .match(/(\+|-)\d+\.*\d+/g) - .map((x) => parseFloat(x)); - - location = { latitude, longitude }; - } - return location; -} - -function parseCreationTime(creationTime: string) { - let dateTime = null; - if (creationTime) { - dateTime = validateAndGetCreationUnixTimeInMicroSeconds( - new Date(creationTime), - ); - } - 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..a6275f254 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -1,13 +1,11 @@ -import { convertBytesToHumanReadable } from "@/next/file"; +import { decodeLivePhoto } from "@/media/live-photo"; import log from "@/next/log"; -import type { Electron } from "@/next/types/ipc"; +import { CustomErrorMessage, type Electron } from "@/next/types/ipc"; import { workerBridge } from "@/next/worker/worker-bridge"; import ComlinkCryptoWorker from "@ente/shared/crypto"; -import { CustomError } from "@ente/shared/error"; -import { isPlaybackPossible } from "@ente/shared/media/video-playback"; import { LS_KEYS, getData } from "@ente/shared/storage/localStorage"; import { User } from "@ente/shared/user/types"; -import { downloadUsingAnchor } from "@ente/shared/utils"; +import { downloadUsingAnchor, withTimeout } from "@ente/shared/utils"; import { FILE_TYPE, RAW_FORMATS, @@ -20,19 +18,14 @@ import { import { t } from "i18next"; import isElectron from "is-electron"; import { moveToHiddenCollection } from "services/collectionService"; -import DownloadManager, { - LivePhotoSourceURL, - SourceURLs, -} from "services/download"; -import * as ffmpegService from "services/ffmpeg/ffmpegService"; +import DownloadManager from "services/download"; import { deleteFromTrash, trashFiles, updateFileMagicMetadata, updateFilePublicMagicMetadata, } from "services/fileService"; -import heicConversionService from "services/heicConversionService"; -import { decodeLivePhoto } from "services/livePhotoService"; +import { heicToJPEG } from "services/heic-convert"; import { getFileType } from "services/typeDetectionService"; import { updateFileCreationDateInEXIF } from "services/upload/exifService"; import { @@ -55,8 +48,6 @@ import { isArchivedFile, updateMagicMetadata } from "utils/magicMetadata"; import { safeFileName } from "utils/native-fs"; import { writeStream } from "utils/native-stream"; -const WAIT_TIME_IMAGE_CONVERSION = 30 * 1000; - export enum FILE_OPS_TYPE { DOWNLOAD, FIX_TIME, @@ -67,6 +58,20 @@ export enum FILE_OPS_TYPE { DELETE_PERMANENTLY, } +class ModuleState { + /** + * This will be set to true if we get an error from the Node.js side of our + * desktop app telling us that native JPEG conversion is not available for + * the current OS/arch combination. That way, we can stop pestering it again + * and again (saving an IPC round-trip). + * + * Note the double negative when it is used. + */ + isNativeJPEGConversionNotAvailable = false; +} + +const moduleState = new ModuleState(); + export async function getUpdatedEXIFFileForDownload( fileReader: FileReader, file: EnteFile, @@ -97,19 +102,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), @@ -131,16 +137,16 @@ export async function downloadFile(file: EnteFile) { } } -export function groupFilesBasedOnCollectionID(files: EnteFile[]) { - const collectionWiseFiles = new Map(); +/** Segment the given {@link files} into lists indexed by their collection ID */ +export const groupFilesBasedOnCollectionID = (files: EnteFile[]) => { + const result = new Map(); for (const file of files) { - if (!collectionWiseFiles.has(file.collectionID)) { - collectionWiseFiles.set(file.collectionID, []); - } - collectionWiseFiles.get(file.collectionID).push(file); + const id = file.collectionID; + if (!result.has(id)) result.set(id, []); + result.get(id).push(file); } - return collectionWiseFiles; -} + return result; +}; function getSelectedFileIds(selectedFiles: SelectedState) { const filesIDs: number[] = []; @@ -247,18 +253,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]; @@ -282,235 +276,66 @@ export function generateStreamFromArrayBuffer(data: Uint8Array) { }); } -export async function getRenderableFileURL( - file: EnteFile, - fileBlob: Blob, - originalFileURL: string, - forceConvert: boolean, -): Promise { - let srcURLs: SourceURLs["url"]; - switch (file.metadata.fileType) { - case FILE_TYPE.IMAGE: { - const convertedBlob = await getRenderableImage( - file.metadata.title, - fileBlob, - ); - const convertedURL = getFileObjectURL( - originalFileURL, - fileBlob, - convertedBlob, - ); - srcURLs = convertedURL; - break; - } - case FILE_TYPE.LIVE_PHOTO: { - srcURLs = await getRenderableLivePhotoURL( - file, - fileBlob, - forceConvert, - ); - break; - } - case FILE_TYPE.VIDEO: { - const convertedBlob = await getPlayableVideo( - file.metadata.title, - fileBlob, - forceConvert, - ); - const convertedURL = getFileObjectURL( - originalFileURL, - fileBlob, - convertedBlob, - ); - srcURLs = convertedURL; - break; - } - default: { - srcURLs = originalFileURL; - break; - } - } - - let isOriginal: boolean; - if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { - isOriginal = false; - } else { - isOriginal = (srcURLs as string) === (originalFileURL as string); - } - - return { - url: srcURLs, - isOriginal, - isRenderable: - file.metadata.fileType !== FILE_TYPE.LIVE_PHOTO && !!srcURLs, - type: - file.metadata.fileType === FILE_TYPE.LIVE_PHOTO - ? "livePhoto" - : "normal", - }; -} - -async function getRenderableLivePhotoURL( - file: EnteFile, - fileBlob: Blob, - forceConvert: boolean, -): Promise { - const livePhoto = await decodeLivePhoto(file, fileBlob); - - const getRenderableLivePhotoImageURL = async () => { - try { - const imageBlob = new Blob([livePhoto.image]); - const convertedImageBlob = await getRenderableImage( - livePhoto.imageNameTitle, - imageBlob, - ); - - return URL.createObjectURL(convertedImageBlob); - } catch (e) { - //ignore and return null - return null; - } - }; - - const getRenderableLivePhotoVideoURL = async () => { - try { - const videoBlob = new Blob([livePhoto.video]); - - const convertedVideoBlob = await getPlayableVideo( - livePhoto.videoNameTitle, - videoBlob, - forceConvert, - true, - ); - return URL.createObjectURL(convertedVideoBlob); - } catch (e) { - //ignore and return null - return null; - } - }; - - return { - image: getRenderableLivePhotoImageURL, - video: getRenderableLivePhotoVideoURL, - }; -} - -export async function getPlayableVideo( - videoNameTitle: string, - videoBlob: Blob, - forceConvert = false, - runOnWeb = false, -) { - try { - const isPlayable = await isPlaybackPossible( - URL.createObjectURL(videoBlob), - ); - if (isPlayable && !forceConvert) { - return videoBlob; - } else { - if (!forceConvert && !runOnWeb && !isElectron()) { - return null; - } - log.info( - `video format not supported, converting it name: ${videoNameTitle}`, - ); - const mp4ConvertedVideo = await ffmpegService.convertToMP4( - new File([videoBlob], videoNameTitle), - ); - log.info(`video successfully converted ${videoNameTitle}`); - return new Blob([await mp4ConvertedVideo.arrayBuffer()]); - } - } catch (e) { - log.error("video conversion failed", e); - return null; - } -} - -export async function getRenderableImage(fileName: string, imageBlob: Blob) { +export const getRenderableImage = async (fileName: string, imageBlob: Blob) => { let fileTypeInfo: FileTypeInfo; try { const tempFile = new File([imageBlob], fileName); fileTypeInfo = await getFileType(tempFile); - log.debug(() => `file type info: ${JSON.stringify(fileTypeInfo)}`); + log.debug( + () => + `Obtaining renderable image for ${JSON.stringify(fileTypeInfo)}`, + ); const { exactType } = fileTypeInfo; - let convertedImageBlob: Blob; - if (isRawFile(exactType)) { - try { - if (!isSupportedRawFormat(exactType)) { - throw Error(CustomError.UNSUPPORTED_RAW_FORMAT); - } - if (!isElectron()) { - throw new Error("not available on web"); - } - log.info( - `RawConverter called for ${fileName}-${convertBytesToHumanReadable( - imageBlob.size, - )}`, - ); - convertedImageBlob = await convertToJPEGInElectron( - imageBlob, - fileName, - ); - log.info(`${fileName} successfully converted`); - } catch (e) { - try { - if (!isFileHEIC(exactType)) { - throw e; - } - log.info( - `HEICConverter called for ${fileName}-${convertBytesToHumanReadable( - imageBlob.size, - )}`, - ); - convertedImageBlob = - await heicConversionService.convert(imageBlob); - log.info(`${fileName} successfully converted`); - } catch (e) { - throw Error(CustomError.NON_PREVIEWABLE_FILE); - } - } - return convertedImageBlob; - } else { + if (!isRawFile(exactType)) { + // Not something we know how to handle yet, give back the original. return imageBlob; } + + let jpegBlob: Blob | undefined; + + const available = !moduleState.isNativeJPEGConversionNotAvailable; + if (isElectron() && available && isSupportedRawFormat(exactType)) { + // If we're running in our desktop app, see if our Node.js layer can + // convert this into a JPEG using native tools for us. + try { + jpegBlob = await nativeConvertToJPEG(fileName, imageBlob); + } catch (e) { + if (e.message == CustomErrorMessage.NotAvailable) { + moduleState.isNativeJPEGConversionNotAvailable = true; + } else { + log.error("Native conversion to JPEG failed", e); + } + } + } + + if (!jpegBlob && isFileHEIC(exactType)) { + // If it is an HEIC file, use our web HEIC converter. + jpegBlob = await heicToJPEG(imageBlob); + } + + return jpegBlob; } catch (e) { log.error( - `Failed to get renderable image for ${JSON.stringify(fileTypeInfo)}`, + `Failed to get renderable image for ${JSON.stringify(fileTypeInfo ?? fileName)}`, e, ); - return null; + return undefined; } -} +}; -const convertToJPEGInElectron = async ( - fileBlob: Blob, - filename: string, -): Promise => { - try { - const startTime = Date.now(); - const inputFileData = new Uint8Array(await fileBlob.arrayBuffer()); - const electron = globalThis.electron; - const convertedFileData = electron - ? await electron.convertToJPEG(inputFileData, filename) - : await workerBridge.convertToJPEG(inputFileData, filename); - log.info( - `originalFileSize:${convertBytesToHumanReadable( - fileBlob?.size, - )},convertedFileSize:${convertBytesToHumanReadable( - convertedFileData?.length, - )}, native conversion time: ${Date.now() - startTime}ms `, - ); - return new Blob([convertedFileData]); - } catch (e) { - if ( - e.message !== - CustomError.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED - ) { - log.error("failed to convert to jpeg natively", e); - } - throw e; - } +const nativeConvertToJPEG = async (fileName: string, imageBlob: Blob) => { + const startTime = Date.now(); + const imageData = new Uint8Array(await imageBlob.arrayBuffer()); + const electron = globalThis.electron; + // If we're running in a worker, we need to reroute the request back to + // the main thread since workers don't have access to the `window` (and + // thus, to the `window.electron`) object. + const jpegData = electron + ? await electron.convertToJPEG(fileName, imageData) + : await workerBridge.convertToJPEG(fileName, imageData); + log.debug(() => `Native JPEG conversion took ${Date.now() - startTime} ms`); + return new Blob([jpegData]); }; export function isFileHEIC(exactType: string) { @@ -813,21 +638,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}`); @@ -870,7 +696,6 @@ export const copyFileToClipboard = async (fileUrl: string) => { const image = new Image(); const blobPromise = new Promise((resolve, reject) => { - let timeout: NodeJS.Timeout = null; try { image.setAttribute("src", fileUrl); image.onload = () => { @@ -884,26 +709,17 @@ export const copyFileToClipboard = async (fileUrl: string) => { "image/png", 1, ); - - clearTimeout(timeout); }; } catch (e) { - log.error("failed to copy to clipboard", e); + log.error("Failed to copy to clipboard", e); reject(e); - } finally { - clearTimeout(timeout); } - timeout = setTimeout( - () => reject(new Error("Operation timed out")), - WAIT_TIME_IMAGE_CONVERSION, - ); }); - const { ClipboardItem } = window; + const blob = await withTimeout(blobPromise, 30 * 1000); - await navigator.clipboard - .write([new ClipboardItem({ "image/png": blobPromise })]) - .catch((e) => log.error("failed to copy to clipboard", e)); + const { ClipboardItem } = window; + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); }; export function getLatestVersionFiles(files: EnteFile[]) { @@ -1072,16 +888,3 @@ const fixTimeHelper = async ( ) => { setFixCreationTimeAttributes({ files: selectedFiles }); }; - -const getFileObjectURL = ( - originalFileURL: string, - originalBlob: Blob, - convertedBlob: Blob, -) => { - const convertedURL = convertedBlob - ? convertedBlob === originalBlob - ? originalFileURL - : URL.createObjectURL(convertedBlob) - : null; - return convertedURL; -}; 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/native-stream.ts b/web/apps/photos/src/utils/native-stream.ts index 809aa9e20..7dba1acf9 100644 --- a/web/apps/photos/src/utils/native-stream.ts +++ b/web/apps/photos/src/utils/native-stream.ts @@ -15,22 +15,41 @@ * @param stream The stream which should be written into the file. * */ export const writeStream = async (path: string, stream: ReadableStream) => { + // TODO(MR): This doesn't currently work. + // + // Not sure what I'm doing wrong here; I've opened an issue upstream + // https://github.com/electron/electron/issues/41872 + // + // A gist with a minimal reproduction + // https://gist.github.com/mnvr/e08d9f4876fb8400b7615347b4d268eb + // + // Meanwhile, write the complete body in one go (this'll eventually run into + // memory failures with large files - just a temporary stopgap to get the + // code to work). + + /* // The duplex parameter needs to be set to 'half' when streaming requests. // // Currently browsers, and specifically in our case, since this code runs // only within our desktop (Electron) app, Chromium, don't support 'full' // duplex mode (i.e. streaming both the request and the response). // https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests - // - // In another twist, the TypeScript libdom.d.ts does not include the - // "duplex" parameter, so we need to cast to get TypeScript to let this code - // through. e.g. see https://github.com/node-fetch/node-fetch/issues/1769 const req = new Request(`stream://write${path}`, { // GET can't have a body method: "POST", body: stream, + // @ts-expect-error TypeScript's libdom.d.ts does not include the + // "duplex" parameter, e.g. see + // https://github.com/node-fetch/node-fetch/issues/1769. duplex: "half", - } as unknown as RequestInit); + }); + */ + + const req = new Request(`stream://write${path}`, { + method: "POST", + body: await new Response(stream).blob(), + }); + const res = await fetch(req); if (!res.ok) throw new Error( 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..7d082166c 100644 --- a/web/apps/photos/src/utils/upload/index.ts +++ b/web/apps/photos/src/utils/upload/index.ts @@ -1,17 +1,14 @@ +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 { 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, + type FileWithCollection2, } from "types/upload"; const TYPE_JSON = "json"; @@ -48,12 +45,13 @@ export function areFilesSame( * precision of file times to prevent timing attacks and fingerprinting. * Context: https://developer.mozilla.org/en-US/docs/Web/API/File/lastModified#reduced_time_precision */ + const oneSecond = 1e6; if ( existingFile.fileType === newFile.fileType && Math.abs(existingFile.creationTime - newFile.creationTime) < - A_SEC_IN_MICROSECONDS && + oneSecond && Math.abs(existingFile.modificationTime - newFile.modificationTime) < - A_SEC_IN_MICROSECONDS && + oneSecond && existingFile.title === newFile.title ) { return true; @@ -103,22 +101,60 @@ export function segregateMetadataAndMediaFiles( return { mediaFiles, metadataJSONFiles }; } +export function segregateMetadataAndMediaFiles2( + filesWithCollectionToUpload: FileWithCollection2[], +) { + const metadataJSONFiles: FileWithCollection2[] = []; + const mediaFiles: FileWithCollection2[] = []; + filesWithCollectionToUpload.forEach((fileWithCollection) => { + const file = fileWithCollection.file; + const s = typeof file == "string" ? file : file.name; + if (s.toLowerCase().endsWith(TYPE_JSON)) { + metadataJSONFiles.push(fileWithCollection); + } else { + mediaFiles.push(fileWithCollection); + } + }); + return { mediaFiles, metadataJSONFiles }; +} + export function areFileWithCollectionsSame( - firstFile: FileWithCollection, - secondFile: FileWithCollection, + firstFile: FileWithCollection2, + secondFile: FileWithCollection2, ): boolean { 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]; @@ -163,11 +199,15 @@ export function getImportSuggestion( // b => [e,f,g], // c => [h, i]] export function groupFilesBasedOnParentFolder( - toUploadFiles: File[] | ElectronFile[], + toUploadFiles: File[] | ElectronFile[] | string[], ) { - const collectionNameToFilesMap = new Map(); + const collectionNameToFilesMap = new Map< + string, + File[] | ElectronFile[] | string[] + >(); for (const file of toUploadFiles) { - const filePath = file["path"] as string; + const filePath = + typeof file == "string" ? file : (file["path"] as string); let folderPath = filePath.substring(0, filePath.lastIndexOf("/")); // If the parent folder of a file is "metadata" @@ -187,17 +227,25 @@ export function groupFilesBasedOnParentFolder( if (!collectionNameToFilesMap.has(folderName)) { collectionNameToFilesMap.set(folderName, []); } - collectionNameToFilesMap.get(folderName).push(file); + // TODO: Remove the cast + collectionNameToFilesMap.get(folderName).push(file as any); } return collectionNameToFilesMap; } -export function filterOutSystemFiles(files: File[] | ElectronFile[]) { +export function filterOutSystemFiles( + files: File[] | ElectronFile[] | string[] | undefined | null, +) { + if (!files) return files; + if (files[0] instanceof File) { const browserFiles = files as File[]; return browserFiles.filter((file) => { return !isSystemFile(file); }); + } else if (typeof files[0] == "string") { + const filePaths = files as string[]; + return filePaths.filter((path) => !isHiddenFile(path)); } else { const electronFiles = files as ElectronFile[]; return electronFiles.filter((file) => { @@ -209,3 +257,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/upload/uploadRetrier.ts b/web/apps/photos/src/utils/upload/uploadRetrier.ts index 3d314fd14..ca2764f3f 100644 --- a/web/apps/photos/src/utils/upload/uploadRetrier.ts +++ b/web/apps/photos/src/utils/upload/uploadRetrier.ts @@ -1,4 +1,4 @@ -import { sleep } from "@ente/shared/utils"; +import { wait } from "@ente/shared/utils"; const retrySleepTimeInMilliSeconds = [2000, 5000, 10000]; @@ -18,7 +18,7 @@ export async function retryHTTPCall( checkForBreakingError(e); } if (attemptNumber < retrySleepTimeInMilliSeconds.length) { - await sleep(retrySleepTimeInMilliSeconds[attemptNumber]); + await wait(retrySleepTimeInMilliSeconds[attemptNumber]); return await retrier(func, attemptNumber + 1); } else { throw e; 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/src/worker/ffmpeg.worker.ts b/web/apps/photos/src/worker/ffmpeg.worker.ts index d3f503abb..8403c3f6c 100644 --- a/web/apps/photos/src/worker/ffmpeg.worker.ts +++ b/web/apps/photos/src/worker/ffmpeg.worker.ts @@ -1,15 +1,117 @@ -import * as Comlink from "comlink"; -import { WasmFFmpeg } from "services/wasm/ffmpeg"; +import { nameAndExtension } from "@/next/file"; +import log from "@/next/log"; +import { withTimeout } from "@ente/shared/utils"; +import QueueProcessor from "@ente/shared/utils/queueProcessor"; +import { generateTempName } from "@ente/shared/utils/temp"; +import { expose } from "comlink"; +import { + ffmpegPathPlaceholder, + inputPathPlaceholder, + outputPathPlaceholder, +} from "constants/ffmpeg"; +import { FFmpeg, createFFmpeg } from "ffmpeg-wasm"; +import { getUint8ArrayView } from "services/readerService"; export class DedicatedFFmpegWorker { - wasmFFmpeg: WasmFFmpeg; + private wasmFFmpeg: WasmFFmpeg; + constructor() { this.wasmFFmpeg = new WasmFFmpeg(); } - run(cmd, inputFile, outputFileName, dontTimeout) { - return this.wasmFFmpeg.run(cmd, inputFile, outputFileName, dontTimeout); + /** + * Execute a ffmpeg {@link command}. + * + * This is a sibling of {@link ffmpegExec} in ipc.ts exposed by the desktop + * app. See [Note: ffmpeg in Electron]. + */ + run(cmd, inputFile, outputFileName, timeoutMS) { + return this.wasmFFmpeg.run(cmd, inputFile, outputFileName, timeoutMS); } } -Comlink.expose(DedicatedFFmpegWorker, self); +expose(DedicatedFFmpegWorker, self); + +export class WasmFFmpeg { + private ffmpeg: FFmpeg; + private ready: Promise = null; + private ffmpegTaskQueue = new QueueProcessor(); + + constructor() { + this.ffmpeg = createFFmpeg({ + corePath: "/js/ffmpeg/ffmpeg-core.js", + mt: false, + }); + + this.ready = this.init(); + } + + private async init() { + if (!this.ffmpeg.isLoaded()) { + await this.ffmpeg.load(); + } + } + + async run( + cmd: string[], + inputFile: File, + outputFileName: string, + timeoutMS, + ) { + const exec = () => this.execute(cmd, inputFile, outputFileName); + const request = this.ffmpegTaskQueue.queueUpRequest(() => + timeoutMS ? withTimeout(exec(), timeoutMS) : exec(), + ); + return await request.promise; + } + + private async execute( + cmd: string[], + inputFile: File, + outputFileName: string, + ) { + let tempInputFilePath: string; + let tempOutputFilePath: string; + try { + await this.ready; + const [, extension] = nameAndExtension(inputFile.name); + const tempNameSuffix = extension ? `input.${extension}` : "input"; + tempInputFilePath = `${generateTempName(10, tempNameSuffix)}`; + this.ffmpeg.FS( + "writeFile", + tempInputFilePath, + await getUint8ArrayView(inputFile), + ); + tempOutputFilePath = `${generateTempName(10, outputFileName)}`; + + cmd = cmd.map((cmdPart) => { + if (cmdPart === ffmpegPathPlaceholder) { + return ""; + } else if (cmdPart === inputPathPlaceholder) { + return tempInputFilePath; + } else if (cmdPart === outputPathPlaceholder) { + return tempOutputFilePath; + } else { + return cmdPart; + } + }); + log.info(`${cmd}`); + await this.ffmpeg.run(...cmd); + return new File( + [this.ffmpeg.FS("readFile", tempOutputFilePath)], + outputFileName, + ); + } finally { + try { + this.ffmpeg.FS("unlink", tempInputFilePath); + } catch (e) { + log.error("unlink input file failed", e); + } + try { + this.ffmpeg.FS("unlink", tempOutputFilePath); + } catch (e) { + log.error("unlink output file failed", e); + } + } + } +} diff --git a/web/apps/photos/src/worker/convert.worker.ts b/web/apps/photos/src/worker/heic-convert.worker.ts similarity index 62% rename from web/apps/photos/src/worker/convert.worker.ts rename to web/apps/photos/src/worker/heic-convert.worker.ts index d8ab22d3a..3ce795e3d 100644 --- a/web/apps/photos/src/worker/convert.worker.ts +++ b/web/apps/photos/src/worker/heic-convert.worker.ts @@ -1,21 +1,21 @@ -import * as Comlink from "comlink"; +import { expose } from "comlink"; import HeicConvert from "heic-convert"; import { getUint8ArrayView } from "services/readerService"; -export class DedicatedConvertWorker { - async convertHEICToJPEG(fileBlob: Blob) { - return convertHEICToJPEG(fileBlob); +export class DedicatedHEICConvertWorker { + async heicToJPEG(heicBlob: Blob) { + return heicToJPEG(heicBlob); } } -Comlink.expose(DedicatedConvertWorker, self); +expose(DedicatedHEICConvertWorker, self); /** * Convert a HEIC file to a JPEG file. * * Both the input and output are blobs. */ -export const convertHEICToJPEG = async (heicBlob: Blob): Promise => { +export const heicToJPEG = async (heicBlob: Blob): Promise => { const filedata = await getUint8ArrayView(heicBlob); const result = await HeicConvert({ buffer: filedata, format: "JPEG" }); const convertedFileData = new Uint8Array(result); 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/accounts/components/ChangeEmail.tsx b/web/packages/accounts/components/ChangeEmail.tsx index 3f47be8a1..ec647e671 100644 --- a/web/packages/accounts/components/ChangeEmail.tsx +++ b/web/packages/accounts/components/ChangeEmail.tsx @@ -6,7 +6,7 @@ import FormPaperFooter from "@ente/shared/components/Form/FormPaper/Footer"; import LinkButton from "@ente/shared/components/LinkButton"; import SubmitButton from "@ente/shared/components/SubmitButton"; import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; -import { sleep } from "@ente/shared/utils"; +import { wait } from "@ente/shared/utils"; import { Alert, Box, TextField } from "@mui/material"; import { Formik, FormikHelpers } from "formik"; import { t } from "i18next"; @@ -59,7 +59,7 @@ function ChangeEmailForm({ appName }: PageProps) { setData(LS_KEYS.USER, { ...getData(LS_KEYS.USER), email }); setLoading(false); setSuccess(true); - await sleep(1000); + await wait(1000); goToApp(); } catch (e) { setLoading(false); diff --git a/web/packages/accounts/components/two-factor/VerifyForm.tsx b/web/packages/accounts/components/two-factor/VerifyForm.tsx index 810a6c010..b7f7fc278 100644 --- a/web/packages/accounts/components/two-factor/VerifyForm.tsx +++ b/web/packages/accounts/components/two-factor/VerifyForm.tsx @@ -9,7 +9,7 @@ import { VerticallyCentered, } from "@ente/shared/components/Container"; import SubmitButton from "@ente/shared/components/SubmitButton"; -import { sleep } from "@ente/shared/utils"; +import { wait } from "@ente/shared/utils"; import { Box, Typography } from "@mui/material"; interface formValues { @@ -33,7 +33,7 @@ export default function VerifyTwoFactor(props: Props) { const markSuccessful = async () => { setWaiting(false); setSuccess(true); - await sleep(1000); + await wait(1000); }; const submitForm = async ( 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/locales/de-DE/translation.json b/web/packages/next/locales/de-DE/translation.json index 7a7a2a3d9..de7980f3e 100644 --- a/web/packages/next/locales/de-DE/translation.json +++ b/web/packages/next/locales/de-DE/translation.json @@ -2,8 +2,8 @@ "HERO_SLIDE_1_TITLE": "
Private Sicherungen
für deine Erinnerungen
", "HERO_SLIDE_1": "Standardmäßig Ende-zu-Ende verschlüsselt", "HERO_SLIDE_2_TITLE": "
Sicher gespeichert
in einem Luftschutzbunker
", - "HERO_SLIDE_2": "Entwickelt um zu bewahren", - "HERO_SLIDE_3_TITLE": "
Verfügbar
überall
", + "HERO_SLIDE_2": "Entwickelt um zu überleben", + "HERO_SLIDE_3_TITLE": "
Überall
verfügbar
", "HERO_SLIDE_3": "Android, iOS, Web, Desktop", "LOGIN": "Anmelden", "SIGN_UP": "Registrieren", @@ -168,7 +168,7 @@ "UPDATE_PAYMENT_METHOD": "Zahlungsmethode aktualisieren", "MONTHLY": "Monatlich", "YEARLY": "Jährlich", - "update_subscription_title": "", + "update_subscription_title": "Tarifänderung bestätigen", "UPDATE_SUBSCRIPTION_MESSAGE": "Sind Sie sicher, dass Sie Ihren Tarif ändern möchten?", "UPDATE_SUBSCRIPTION": "Plan ändern", "CANCEL_SUBSCRIPTION": "Abonnement kündigen", @@ -278,15 +278,15 @@ "LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "Ihr Browser oder ein Addon blockiert Ente vor der Speicherung von Daten im lokalen Speicher. Bitte versuchen Sie, den Browser-Modus zu wechseln und die Seite neu zu laden.", "SEND_OTT": "OTP senden", "EMAIl_ALREADY_OWNED": "Diese E-Mail wird bereits verwendet", - "ETAGS_BLOCKED": "", - "LIVE_PHOTOS_DETECTED": "", + "ETAGS_BLOCKED": "

Die folgenden Dateien konnten aufgrund deiner Browser-Konfiguration nicht hochgeladen werden.

Bitte deaktiviere alle Add-ons, die Ente daran hindern könnten, eTags zum Hochladen großer Dateien zu verwenden oder verwende unsere Desktop-App für ein zuverlässigeres Import-Erlebnis.

", + "LIVE_PHOTOS_DETECTED": "Die Foto- und Videodateien deiner Live-Fotos wurden in einer einzigen Datei zusammengeführt", "RETRY_FAILED": "Fehlgeschlagene Uploads erneut probieren", "FAILED_UPLOADS": "Fehlgeschlagene Uploads ", "SKIPPED_FILES": "Ignorierte Uploads", "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Das Vorschaubild konnte nicht erzeugt werden", "UNSUPPORTED_FILES": "Nicht unterstützte Dateien", "SUCCESSFUL_UPLOADS": "Erfolgreiche Uploads", - "SKIPPED_INFO": "", + "SKIPPED_INFO": "Diese wurden übersprungen, da es Dateien mit gleichen Namen im selben Album gibt", "UNSUPPORTED_INFO": "Ente unterstützt diese Dateiformate noch nicht", "BLOCKED_UPLOADS": "Blockierte Uploads", "INPROGRESS_METADATA_EXTRACTION": "In Bearbeitung", @@ -315,20 +315,20 @@ "REMOVE_FROM_COLLECTION": "Aus Album entfernen", "TRASH": "Papierkorb", "MOVE_TO_TRASH": "In Papierkorb verschieben", - "TRASH_FILES_MESSAGE": "", - "TRASH_FILE_MESSAGE": "", + "TRASH_FILES_MESSAGE": "Ausgewählte Dateien werden aus allen Alben entfernt und in den Papierkorb verschoben.", + "TRASH_FILE_MESSAGE": "Die Datei wird aus allen Alben entfernt und in den Papierkorb verschoben.", "DELETE_PERMANENTLY": "Dauerhaft löschen", "RESTORE": "Wiederherstellen", "RESTORE_TO_COLLECTION": "In Album wiederherstellen", "EMPTY_TRASH": "Papierkorb leeren", "EMPTY_TRASH_TITLE": "Papierkorb leeren?", - "EMPTY_TRASH_MESSAGE": "", + "EMPTY_TRASH_MESSAGE": "Diese Dateien werden dauerhaft aus Ihrem Ente-Konto gelöscht.", "LEAVE_SHARED_ALBUM": "Ja, verlassen", "LEAVE_ALBUM": "Album verlassen", "LEAVE_SHARED_ALBUM_TITLE": "Geteiltes Album verlassen?", - "LEAVE_SHARED_ALBUM_MESSAGE": "", + "LEAVE_SHARED_ALBUM_MESSAGE": "Du wirst das Album verlassen und es wird nicht mehr für dich sichtbar sein.", "NOT_FILE_OWNER": "Dateien in einem freigegebenen Album können nicht gelöscht werden", - "CONFIRM_SELF_REMOVE_MESSAGE": "", + "CONFIRM_SELF_REMOVE_MESSAGE": "Ausgewählte Elemente werden aus diesem Album entfernt. Elemente, die sich nur in diesem Album befinden, werden nach Unkategorisiert verschoben.", "CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "Einige der Elemente, die du entfernst, wurden von anderen Nutzern hinzugefügt und du wirst den Zugriff auf sie verlieren.", "SORT_BY_CREATION_TIME_ASCENDING": "Ältestem", "SORT_BY_UPDATION_TIME_DESCENDING": "Zuletzt aktualisiert", @@ -337,8 +337,8 @@ "FIX_CREATION_TIME_IN_PROGRESS": "Zeit wird repariert", "CREATION_TIME_UPDATED": "Datei-Zeit aktualisiert", "UPDATE_CREATION_TIME_NOT_STARTED": "Wählen Sie die Option, die Sie verwenden möchten", - "UPDATE_CREATION_TIME_COMPLETED": "", - "UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "", + "UPDATE_CREATION_TIME_COMPLETED": "Alle Dateien erfolgreich aktualisiert", + "UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "Aktualisierung der Dateizeit für einige Dateien fehlgeschlagen, bitte versuche es erneut", "CAPTION_CHARACTER_LIMIT": "Maximal 5000 Zeichen", "DATE_TIME_ORIGINAL": "", "DATE_TIME_DIGITIZED": "", @@ -358,10 +358,10 @@ "participants_one": "1 Teilnehmer", "participants_other": "{{count, number}} Teilnehmer", "ADD_VIEWERS": "Betrachter hinzufügen", - "CHANGE_PERMISSIONS_TO_VIEWER": "", - "CHANGE_PERMISSIONS_TO_COLLABORATOR": "", + "CHANGE_PERMISSIONS_TO_VIEWER": "

{{selectedEmail}} wird nicht in der Lage sein, weitere Fotos zum Album

hinzuzufügen. {{selectedEmail}} wird weiterhin die eigenen Fotos aus dem Album entfernen können

", + "CHANGE_PERMISSIONS_TO_COLLABORATOR": "{{selectedEmail}} wird Fotos zum Album hinzufügen können", "CONVERT_TO_VIEWER": "Ja, zu \"Beobachter\" ändern", - "CONVERT_TO_COLLABORATOR": "", + "CONVERT_TO_COLLABORATOR": "Ja, in Kollaborateur umwandeln", "CHANGE_PERMISSION": "Berechtigung ändern?", "REMOVE_PARTICIPANT": "Entfernen?", "CONFIRM_REMOVE": "Ja, entfernen", @@ -408,11 +408,11 @@ "STOP_ALL_UPLOADS_MESSAGE": "", "STOP_UPLOADS_HEADER": "Hochladen stoppen?", "YES_STOP_UPLOADS": "Ja, Hochladen stoppen", - "STOP_DOWNLOADS_HEADER": "", - "YES_STOP_DOWNLOADS": "", - "STOP_ALL_DOWNLOADS_MESSAGE": "", + "STOP_DOWNLOADS_HEADER": "Downloads anhalten?", + "YES_STOP_DOWNLOADS": "Ja, Downloads anhalten", + "STOP_ALL_DOWNLOADS_MESSAGE": "Bist du dir sicher, dass du alle laufenden Downloads anhalten möchtest?", "albums_one": "1 Album", - "albums_other": "", + "albums_other": "{{count, number}} Alben", "ALL_ALBUMS": "Alle Alben", "ALBUMS": "Alben", "ALL_HIDDEN_ALBUMS": "", @@ -424,7 +424,7 @@ "COPIED": "Kopiert", "WATCH_FOLDERS": "", "UPGRADE_NOW": "Jetzt upgraden", - "RENEW_NOW": "", + "RENEW_NOW": "Jetzt erneuern", "STORAGE": "Speicher", "USED": "verwendet", "YOU": "Sie", @@ -432,10 +432,10 @@ "FREE": "frei", "OF": "von", "WATCHED_FOLDERS": "", - "NO_FOLDERS_ADDED": "", + "NO_FOLDERS_ADDED": "Noch keine Ordner hinzugefügt!", "FOLDERS_AUTOMATICALLY_MONITORED": "", "UPLOAD_NEW_FILES_TO_ENTE": "", - "REMOVE_DELETED_FILES_FROM_ENTE": "", + "REMOVE_DELETED_FILES_FROM_ENTE": "Gelöschte Dateien aus Ente entfernen", "ADD_FOLDER": "Ordner hinzufügen", "STOP_WATCHING": "", "STOP_WATCHING_FOLDER": "", @@ -455,48 +455,48 @@ "CURRENT_USAGE": "Aktuelle Nutzung ist {{usage}}", "WEAK_DEVICE": "", "DRAG_AND_DROP_HINT": "", - "CONFIRM_ACCOUNT_DELETION_MESSAGE": "", + "CONFIRM_ACCOUNT_DELETION_MESSAGE": "Deine hochgeladenen Daten werden zur Löschung vorgemerkt und dein Konto wird endgültig gelöscht.

Dieser Vorgang kann nicht rückgängig gemacht werden.", "AUTHENTICATE": "Authentifizieren", "UPLOADED_TO_SINGLE_COLLECTION": "", "UPLOADED_TO_SEPARATE_COLLECTIONS": "", "NEVERMIND": "Egal", "UPDATE_AVAILABLE": "Neue Version verfügbar", - "UPDATE_INSTALLABLE_MESSAGE": "", + "UPDATE_INSTALLABLE_MESSAGE": "Eine neue Version von Ente ist für die Installation bereit.", "INSTALL_NOW": "Jetzt installieren", "INSTALL_ON_NEXT_LAUNCH": "Beim nächsten Start installieren", - "UPDATE_AVAILABLE_MESSAGE": "", - "DOWNLOAD_AND_INSTALL": "", + "UPDATE_AVAILABLE_MESSAGE": "Eine neue Version von Ente wurde veröffentlicht, aber sie kann nicht automatisch heruntergeladen und installiert werden.", + "DOWNLOAD_AND_INSTALL": "Herunterladen und installieren", "IGNORE_THIS_VERSION": "Diese Version ignorieren", "TODAY": "Heute", "YESTERDAY": "Gestern", "NAME_PLACEHOLDER": "Name...", - "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "Alben können nicht aus Datei/Ordnermix erstellt werden", "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "", - "CHOSE_THEME": "", - "ML_SEARCH": "", + "CHOSE_THEME": "Design auswählen", + "ML_SEARCH": "Gesichtserkennung", "ENABLE_ML_SEARCH_DESCRIPTION": "", - "ML_MORE_DETAILS": "", - "ENABLE_FACE_SEARCH": "", - "ENABLE_FACE_SEARCH_TITLE": "", - "ENABLE_FACE_SEARCH_DESCRIPTION": "", + "ML_MORE_DETAILS": "Weitere Details", + "ENABLE_FACE_SEARCH": "Gesichtserkennung aktivieren", + "ENABLE_FACE_SEARCH_TITLE": "Gesichtserkennung aktivieren?", + "ENABLE_FACE_SEARCH_DESCRIPTION": "

Wenn du die Gesichtserkennung aktivierst, wird Ente Gesichtsgeometrie aus deinen Fotos extrahieren. Dies wird auf deinem Gerät geschehen, und alle erzeugten biometrischen Daten werden Ende-zu-verschlüsselt.

Bitte klicke hier für weitere Informationen über diese Funktion in unserer Datenschutzerklärung

", "DISABLE_BETA": "Beta deaktivieren", - "DISABLE_FACE_SEARCH": "", - "DISABLE_FACE_SEARCH_TITLE": "", + "DISABLE_FACE_SEARCH": "Gesichtserkennung deaktivieren", + "DISABLE_FACE_SEARCH_TITLE": "Gesichtserkennung deaktivieren?", "DISABLE_FACE_SEARCH_DESCRIPTION": "", "ADVANCED": "Erweitert", - "FACE_SEARCH_CONFIRMATION": "", - "LABS": "", + "FACE_SEARCH_CONFIRMATION": "Ich verstehe und möchte Ente erlauben, Gesichtsgeometrie zu verarbeiten", + "LABS": "Experimente", "YOURS": "", "PASSPHRASE_STRENGTH_WEAK": "Passwortstärke: Schwach", - "PASSPHRASE_STRENGTH_MODERATE": "", + "PASSPHRASE_STRENGTH_MODERATE": "Passwortstärke: Moderat", "PASSPHRASE_STRENGTH_STRONG": "Passwortstärke: Stark", "PREFERENCES": "Einstellungen", "LANGUAGE": "Sprache", - "EXPORT_DIRECTORY_DOES_NOT_EXIST": "", + "EXPORT_DIRECTORY_DOES_NOT_EXIST": "Ungültiges Exportverzeichnis", "EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "", - "SUBSCRIPTION_VERIFICATION_ERROR": "", + "SUBSCRIPTION_VERIFICATION_ERROR": "Verifizierung des Abonnements fehlgeschlagen", "STORAGE_UNITS": { - "B": "", + "B": "B", "KB": "KB", "MB": "MB", "GB": "GB", @@ -520,8 +520,8 @@ "PUBLIC_COLLECT_SUBTEXT": "", "STOP_EXPORT": "Stop", "EXPORT_PROGRESS": "", - "MIGRATING_EXPORT": "", - "RENAMING_COLLECTION_FOLDERS": "", + "MIGRATING_EXPORT": "Vorbereiten...", + "RENAMING_COLLECTION_FOLDERS": "Albumordner umbenennen...", "TRASHING_DELETED_FILES": "", "TRASHING_DELETED_COLLECTIONS": "", "CONTINUOUS_EXPORT": "", @@ -536,12 +536,12 @@ "NOT_LISTED": "" }, "DELETE_ACCOUNT_FEEDBACK_LABEL": "", - "DELETE_ACCOUNT_FEEDBACK_PLACEHOLDER": "", - "CONFIRM_DELETE_ACCOUNT_CHECKBOX_LABEL": "", + "DELETE_ACCOUNT_FEEDBACK_PLACEHOLDER": "Feedback", + "CONFIRM_DELETE_ACCOUNT_CHECKBOX_LABEL": "Ja, ich möchte dieses Konto und alle enthaltenen Daten endgültig und unwiderruflich löschen", "CONFIRM_DELETE_ACCOUNT": "Kontolöschung bestätigen", "FEEDBACK_REQUIRED": "", - "FEEDBACK_REQUIRED_FOUND_ANOTHER_SERVICE": "", - "RECOVER_TWO_FACTOR": "", + "FEEDBACK_REQUIRED_FOUND_ANOTHER_SERVICE": "Was macht der andere Dienst besser?", + "RECOVER_TWO_FACTOR": "Zwei-Faktor wiederherstellen", "at": "", "AUTH_NEXT": "Weiter", "AUTH_DOWNLOAD_MOBILE_APP": "", @@ -556,48 +556,48 @@ "SELECT_COLLECTION": "Album auswählen", "PIN_ALBUM": "Album anheften", "UNPIN_ALBUM": "Album lösen", - "DOWNLOAD_COMPLETE": "", - "DOWNLOADING_COLLECTION": "", - "DOWNLOAD_FAILED": "", - "DOWNLOAD_PROGRESS": "", - "CHRISTMAS": "", - "CHRISTMAS_EVE": "", + "DOWNLOAD_COMPLETE": "Download abgeschlossen", + "DOWNLOADING_COLLECTION": "Lade {{name}} herunter", + "DOWNLOAD_FAILED": "Herunterladen fehlgeschlagen", + "DOWNLOAD_PROGRESS": "{{progress.current}} / {{progress.total}} Dateien", + "CHRISTMAS": "Weihnachten", + "CHRISTMAS_EVE": "Heiligabend", "NEW_YEAR": "", "NEW_YEAR_EVE": "", - "IMAGE": "", - "VIDEO": "", - "LIVE_PHOTO": "", - "CONVERT": "", + "IMAGE": "Bild", + "VIDEO": "Video", + "LIVE_PHOTO": "Live-Foto", + "CONVERT": "Konvertieren", "CONFIRM_EDITOR_CLOSE_MESSAGE": "", "CONFIRM_EDITOR_CLOSE_DESCRIPTION": "", - "BRIGHTNESS": "", - "CONTRAST": "", - "SATURATION": "", - "BLUR": "", - "INVERT_COLORS": "", - "ASPECT_RATIO": "", - "SQUARE": "", - "ROTATE_LEFT": "", - "ROTATE_RIGHT": "", - "FLIP_VERTICALLY": "", - "FLIP_HORIZONTALLY": "", + "BRIGHTNESS": "Helligkeit", + "CONTRAST": "Kontrast", + "SATURATION": "Sättigung", + "BLUR": "Weichzeichnen", + "INVERT_COLORS": "Farben invertieren", + "ASPECT_RATIO": "Seitenverhältnis", + "SQUARE": "Quadrat", + "ROTATE_LEFT": "Nach links drehen", + "ROTATE_RIGHT": "Nach rechts drehen", + "FLIP_VERTICALLY": "Vertikal spiegeln", + "FLIP_HORIZONTALLY": "Horizontal spiegeln", "DOWNLOAD_EDITED": "", - "SAVE_A_COPY_TO_ENTE": "", - "RESTORE_ORIGINAL": "", - "TRANSFORM": "", - "COLORS": "", - "FLIP": "", - "ROTATION": "", - "RESET": "", - "PHOTO_EDITOR": "", + "SAVE_A_COPY_TO_ENTE": "Kopie in Ente speichern", + "RESTORE_ORIGINAL": "Original wiederherstellen", + "TRANSFORM": "Transformieren", + "COLORS": "Farben", + "FLIP": "Spiegeln", + "ROTATION": "Drehen", + "RESET": "Zurücksetzen", + "PHOTO_EDITOR": "Foto-Editor", "FASTER_UPLOAD": "", "FASTER_UPLOAD_DESCRIPTION": "", "MAGIC_SEARCH_STATUS": "", - "INDEXED_ITEMS": "", - "CAST_ALBUM_TO_TV": "", - "ENTER_CAST_PIN_CODE": "", - "PAIR_DEVICE_TO_TV": "", - "TV_NOT_FOUND": "", + "INDEXED_ITEMS": "Indizierte Elemente", + "CAST_ALBUM_TO_TV": "Album auf Fernseher wiedergeben", + "ENTER_CAST_PIN_CODE": "Gib den Code auf dem Fernseher unten ein, um dieses Gerät zu koppeln.", + "PAIR_DEVICE_TO_TV": "Geräte koppeln", + "TV_NOT_FOUND": "Fernseher nicht gefunden. Hast du die PIN korrekt eingegeben?", "AUTO_CAST_PAIR": "", "AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "", "PAIR_WITH_PIN": "", @@ -605,21 +605,21 @@ "PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "", "VISIT_CAST_ENTE_IO": "", "CAST_AUTO_PAIR_FAILED": "", - "FREEHAND": "", - "APPLY_CROP": "", + "FREEHAND": "Freihand", + "APPLY_CROP": "Zuschnitt anwenden", "PHOTO_EDIT_REQUIRED_TO_SAVE": "", - "PASSKEYS": "", - "DELETE_PASSKEY": "", - "DELETE_PASSKEY_CONFIRMATION": "", - "RENAME_PASSKEY": "", - "ADD_PASSKEY": "", - "ENTER_PASSKEY_NAME": "", - "PASSKEYS_DESCRIPTION": "", - "CREATED_AT": "", - "PASSKEY_LOGIN_FAILED": "", - "PASSKEY_LOGIN_URL_INVALID": "", - "PASSKEY_LOGIN_ERRORED": "", - "TRY_AGAIN": "", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "", - "LOGIN_WITH_PASSKEY": "" + "PASSKEYS": "Passkeys", + "DELETE_PASSKEY": "Passkey löschen", + "DELETE_PASSKEY_CONFIRMATION": "Bist du sicher, dass du diesen Passkey löschen willst? Dieser Vorgang ist nicht umkehrbar.", + "RENAME_PASSKEY": "Passkey umbenennen", + "ADD_PASSKEY": "Passkey hinzufügen", + "ENTER_PASSKEY_NAME": "Passkey-Namen eingeben", + "PASSKEYS_DESCRIPTION": "Passkeys sind ein moderner und sicherer zweiter Faktor für dein Ente-Konto. Sie nutzen die biometrische Authentifizierung des Geräts für Komfort und Sicherheit.", + "CREATED_AT": "Erstellt am", + "PASSKEY_LOGIN_FAILED": "Passkey-Anmeldung fehlgeschlagen", + "PASSKEY_LOGIN_URL_INVALID": "Die Anmelde-URL ist ungültig.", + "PASSKEY_LOGIN_ERRORED": "Ein Fehler trat auf beim Anmelden mit dem Passkey auf.", + "TRY_AGAIN": "Erneut versuchen", + "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "Folge den Schritten in deinem Browser, um mit dem Anmelden fortzufahren.", + "LOGIN_WITH_PASSKEY": "Mit Passkey anmelden" } diff --git a/web/packages/next/locales/it-IT/translation.json b/web/packages/next/locales/it-IT/translation.json index eb3e6bfa8..b66131ad7 100644 --- a/web/packages/next/locales/it-IT/translation.json +++ b/web/packages/next/locales/it-IT/translation.json @@ -7,7 +7,7 @@ "HERO_SLIDE_3": "Android, iOS, Web, Desktop", "LOGIN": "Accedi", "SIGN_UP": "Registrati", - "NEW_USER": "", + "NEW_USER": "Prima volta con Ente", "EXISTING_USER": "Accedi", "ENTER_NAME": "Inserisci il nome", "PUBLIC_UPLOADER_NAME_MESSAGE": "Aggiungi un nome in modo che i tuoi amici sappiano chi ringraziare per queste fantastiche foto!", @@ -168,18 +168,18 @@ "UPDATE_PAYMENT_METHOD": "Aggiorna metodo di pagamento", "MONTHLY": "Mensile", "YEARLY": "Annuale", - "update_subscription_title": "", + "update_subscription_title": "Conferma le modifiche al piano", "UPDATE_SUBSCRIPTION_MESSAGE": "Sei sicuro di voler cambiare il piano?", "UPDATE_SUBSCRIPTION": "Cambia piano", "CANCEL_SUBSCRIPTION": "Annulla abbonamento", "CANCEL_SUBSCRIPTION_MESSAGE": "

Tutti i tuoi dati saranno cancellati dai nostri server alla fine di questo periodo di fatturazione.

Sei sicuro di voler annullare il tuo abbonamento?

", - "CANCEL_SUBSCRIPTION_WITH_ADDON_MESSAGE": "", + "CANCEL_SUBSCRIPTION_WITH_ADDON_MESSAGE": "

Sei sicuro di volere annullare il tuo abbonamento?

", "SUBSCRIPTION_CANCEL_FAILED": "Impossibile annullare l'abbonamento", "SUBSCRIPTION_CANCEL_SUCCESS": "Abbonamento annullato con successo", "REACTIVATE_SUBSCRIPTION": "Riattiva abbonamento", "REACTIVATE_SUBSCRIPTION_MESSAGE": "Una volta riattivato, ti verrà addebitato il valore di {{date, dateTime}}", "SUBSCRIPTION_ACTIVATE_SUCCESS": "Iscrizione attivata con successo ", - "SUBSCRIPTION_ACTIVATE_FAILED": "", + "SUBSCRIPTION_ACTIVATE_FAILED": "Impossibile riattivare il rinnovo dell'abbonamento", "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "Grazie", "CANCEL_SUBSCRIPTION_ON_MOBILE": "Annulla abbonamento mobile", "CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE": "", @@ -201,7 +201,7 @@ "CREATE_ALBUM_FAILED": "Operazione di creazione dell'album fallita, per favore riprova", "SEARCH": "Ricerca", "SEARCH_RESULTS": "Risultati della ricerca", - "NO_RESULTS": "", + "NO_RESULTS": "Nessun risultato trovato", "SEARCH_HINT": "", "SEARCH_TYPE": { "COLLECTION": "Album", @@ -219,7 +219,7 @@ "photos_count_other": "", "TERMS_AND_CONDITIONS": "", "ADD_TO_COLLECTION": "Aggiungi all'album", - "SELECTED": "", + "SELECTED": "selezionato", "PEOPLE": "Persone", "INDEXING_SCHEDULED": "", "ANALYZING_PHOTOS": "", @@ -241,8 +241,8 @@ "DISABLE_MAPS": "Disattivare Mappa?", "ENABLE_MAP_DESCRIPTION": "", "DISABLE_MAP_DESCRIPTION": "", - "DISABLE_MAP": "", - "DETAILS": "", + "DISABLE_MAP": "Disattivare Mappa", + "DETAILS": "Dettagli", "VIEW_EXIF": "", "NO_EXIF": "", "EXIF": "EXIF", @@ -258,23 +258,23 @@ "LOST_DEVICE": "", "INCORRECT_CODE": "Codice errato", "TWO_FACTOR_INFO": "Aggiungi un ulteriore livello di sicurezza richiedendo più informazioni rispetto a email e password per eseguire l'accesso al tuo account", - "DISABLE_TWO_FACTOR_LABEL": "", + "DISABLE_TWO_FACTOR_LABEL": "Disabilita l'autenticazione a due fattori", "UPDATE_TWO_FACTOR_LABEL": "", "DISABLE": "", "RECONFIGURE": "", "UPDATE_TWO_FACTOR": "", "UPDATE_TWO_FACTOR_MESSAGE": "", - "UPDATE": "", + "UPDATE": "Aggiorna", "DISABLE_TWO_FACTOR": "", "DISABLE_TWO_FACTOR_MESSAGE": "", "TWO_FACTOR_DISABLE_FAILED": "", "EXPORT_DATA": "Esporta dati", - "SELECT_FOLDER": "", - "DESTINATION": "", + "SELECT_FOLDER": "Seleziona cartella", + "DESTINATION": "Destinazione", "START": "", "LAST_EXPORT_TIME": "", - "EXPORT_AGAIN": "", - "LOCAL_STORAGE_NOT_ACCESSIBLE": "", + "EXPORT_AGAIN": "Risincronizza", + "LOCAL_STORAGE_NOT_ACCESSIBLE": "Archivio locale non accessibile", "LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "", "SEND_OTT": "Invia OTP", "EMAIl_ALREADY_OWNED": "Email già in uso", diff --git a/web/packages/next/locales/sv-SE/translation.json b/web/packages/next/locales/sv-SE/translation.json index afc9b4ec4..77462524d 100644 --- a/web/packages/next/locales/sv-SE/translation.json +++ b/web/packages/next/locales/sv-SE/translation.json @@ -12,7 +12,7 @@ "ENTER_NAME": "Ange namn", "PUBLIC_UPLOADER_NAME_MESSAGE": "", "ENTER_EMAIL": "Ange e-postadress", - "EMAIL_ERROR": "", + "EMAIL_ERROR": "Ange en giltig e-postadress", "REQUIRED": "", "EMAIL_SENT": "", "CHECK_INBOX": "", @@ -80,7 +80,7 @@ "DOWNLOAD_HIDDEN_ITEMS": "", "COPY_OPTION": "", "TOGGLE_FULLSCREEN": "", - "ZOOM_IN_OUT": "", + "ZOOM_IN_OUT": "Zooma in/ut", "PREVIOUS": "", "NEXT": "", "TITLE_PHOTOS": "", 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..d87b8e830 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,34 +183,94 @@ export interface Electron { * @param contents The string contents to write. */ writeFile: (path: string, contents: string) => Promise; - }; - /* - * TODO: AUDIT below this - Some of the types we use below are not copyable - * across process boundaries, and such functions will (expectedly) fail at - * runtime. For such functions, find an efficient alternative or refactor - * the dataflow. - */ + /** + * Return true if there is an item at {@link dirPath}, and it is as + * directory. + */ + isDir: (dirPath: string) => Promise; + }; // - Conversion + /** + * Try to convert an arbitrary image into JPEG using native layer tools. + * + * The behaviour is OS dependent. On macOS we use the `sips` utility, and on + * some Linux architectures we use an ImageMagick executable bundled with + * our desktop app. + * + * In other cases (primarily Windows), where native JPEG conversion is not + * yet possible, this function will throw an error with the + * {@link CustomErrorMessage.NotAvailable} message. + * + * @param fileName The name of the file whose data we're being given. + * @param imageData The raw image data (the contents of the image file). + * @returns JPEG data of the converted image. + */ convertToJPEG: ( - fileData: Uint8Array, - filename: string, + fileName: string, + imageData: Uint8Array, ) => Promise; + /** + * Generate a JPEG thumbnail for the given image. + * + * The behaviour is OS dependent. On macOS we use the `sips` utility, and on + * some Linux architectures we use an ImageMagick executable bundled with + * our desktop app. + * + * In other cases (primarily Windows), where native thumbnail generation is + * not yet possible, this function will throw an error with the + * {@link CustomErrorMessage.NotAvailable} message. + * + * @param inputFile The file whose thumbnail we want. + * @param maxDimension The maximum width or height of the generated + * thumbnail. + * @param maxSize Maximum size (in bytes) of the generated thumbnail. + * @returns JPEG data of the generated thumbnail. + */ generateImageThumbnail: ( inputFile: File | ElectronFile, maxDimension: number, maxSize: number, ) => Promise; - runFFmpegCmd: ( - cmd: string[], - inputFile: File | ElectronFile, + /** + * Execute a ffmpeg {@link command}. + * + * This executes the command using the ffmpeg executable we bundle with our + * desktop app. There is also a ffmpeg wasm implementation that we use when + * running on the web, it also has a sibling function with the same + * parameters. See [Note: ffmpeg in Electron]. + * + * @param command An array of strings, each representing one positional + * parameter in the command to execute. Placeholders for the input, output + * and ffmpeg's own path are replaced before executing the command + * (respectively {@link inputPathPlaceholder}, + * {@link outputPathPlaceholder}, {@link ffmpegPathPlaceholder}). + * + * @param inputDataOrPath The bytes of the input file, or the path to the + * input file on the user's local disk. In both cases, the data gets + * serialized to a temporary file, and then that path gets substituted in + * the ffmpeg {@link command} by {@link inputPathPlaceholder}. + * + * @param outputFileName The name of the file we instruct ffmpeg to produce + * when giving it the given {@link command}. The contents of this file get + * returned as the result. + * + * @param timeoutMS If non-zero, then abort and throw a timeout error if the + * ffmpeg command takes more than the given number of milliseconds. + * + * @returns The contents of the output file produced by the ffmpeg command + * at {@link outputFileName}. + */ + ffmpegExec: ( + command: string[], + inputDataOrPath: Uint8Array | string, outputFileName: string, - dontTimeout?: boolean, - ) => Promise; + timeoutMS: number, + ) => Promise; // - ML @@ -242,7 +286,18 @@ export interface Electron { clipImageEmbedding: (jpegImageData: Uint8Array) => Promise; /** - * Return a CLIP embedding of the given image. + * Return a CLIP embedding of the given image if we already have the model + * downloaded and prepped. If the model is not available return `undefined`. + * + * This differs from the other sibling ML functions in that it doesn't wait + * for the model download to finish. It does trigger a model download, but + * then immediately returns `undefined`. At some future point, when the + * model downloaded finishes, calls to this function will start returning + * the result we seek. + * + * The reason for doing it in this asymmetric way is because CLIP text + * embeddings are used as part of deducing user initiated search results, + * and we don't want to block that interaction on a large network request. * * See: [Note: CLIP based magic search] * @@ -250,7 +305,9 @@ export interface Electron { * * @returns A CLIP embedding. */ - clipTextEmbedding: (text: string) => Promise; + clipTextEmbeddingIfAvailable: ( + text: string, + ) => Promise; /** * Detect faces in the given image using YOLO. @@ -284,51 +341,256 @@ 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; + + /* + * TODO: AUDIT below this - Some of the types we use below are not copyable + * across process boundaries, and such functions will (expectedly) fail at + * runtime. For such functions, find an efficient alternative or refactor + * the dataflow. + */ + + // - + getElectronFilesFromGoogleZip: ( filePath: string, ) => Promise; - setToUploadCollection: (collectionName: string) => Promise; getDirFiles: (dirPath: string) => Promise; } + +/** + * Errors that have special semantics on the web side. + * + * [Note: Custom errors across Electron/Renderer boundary] + * + * If we need to identify errors thrown by the main process when invoked from + * the renderer process, we can only use the `message` field because: + * + * > Errors thrown throw `handle` in the main process are not transparent as + * > they are serialized and only the `message` property from the original error + * > is provided to the renderer process. + * > + * > - https://www.electronjs.org/docs/latest/tutorial/ipc + * > + * > Ref: https://github.com/electron/electron/issues/24427 + */ +export const CustomErrorMessage = { + NotAvailable: "This feature in not available on the current OS/arch", +}; + +/** + * 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/next/worker/comlink-worker.ts b/web/packages/next/worker/comlink-worker.ts index a5237fccc..7bae126a4 100644 --- a/web/packages/next/worker/comlink-worker.ts +++ b/web/packages/next/worker/comlink-worker.ts @@ -12,24 +12,17 @@ export class ComlinkWorker InstanceType> { this.name = name; this.worker = worker; - this.worker.onerror = (ev) => { + worker.onerror = (event) => { log.error( - `Got error event from worker: ${JSON.stringify({ - errorEvent: JSON.stringify(ev), - name: this.name, - })}`, + `Got error event from worker: ${JSON.stringify({ event, name })}`, ); }; - log.debug(() => `Initiated ${this.name}`); - const comlink = wrap(this.worker); + log.debug(() => `Initiated web worker ${name}`); + const comlink = wrap(worker); this.remote = new comlink() as Promise>>; expose(workerBridge, worker); } - public getName() { - return this.name; - } - public terminate() { this.worker.terminate(); log.debug(() => `Terminated ${this.name}`); @@ -43,15 +36,16 @@ export class ComlinkWorker InstanceType> { * `workerBridge` object after importing it from `worker-bridge.ts`. * * Not all workers need access to all these functions, and this can indeed be - * done in a more fine-grained, per-worker, manner if needed. + * done in a more fine-grained, per-worker, manner if needed. For now, since it + * is a motley bunch, we just inject them all. */ const workerBridge = { // Needed: generally (presumably) logToDisk, // Needed by ML worker getAuthToken: () => ensureLocalUser().then((user) => user.token), - convertToJPEG: (inputFileData: Uint8Array, filename: string) => - ensureElectron().convertToJPEG(inputFileData, filename), + convertToJPEG: (fileName: string, imageData: Uint8Array) => + ensureElectron().convertToJPEG(fileName, imageData), detectFaces: (input: Float32Array) => ensureElectron().detectFaces(input), faceEmbedding: (input: Float32Array) => ensureElectron().faceEmbedding(input), diff --git a/web/packages/shared/crypto/index.ts b/web/packages/shared/crypto/index.ts index 00ac8d32f..4e20fb92a 100644 --- a/web/packages/shared/crypto/index.ts +++ b/web/packages/shared/crypto/index.ts @@ -1,6 +1,6 @@ import { ComlinkWorker } from "@/next/worker/comlink-worker"; import { Remote } from "comlink"; -import { DedicatedCryptoWorker } from "./internal/crypto.worker"; +import { type DedicatedCryptoWorker } from "./internal/crypto.worker"; class ComlinkCryptoWorker { private comlinkWorkerInstance: diff --git a/web/packages/shared/error/index.ts b/web/packages/shared/error/index.ts index 12a87d2db..e9c9270b8 100644 --- a/web/packages/shared/error/index.ts +++ b/web/packages/shared/error/index.ts @@ -74,8 +74,6 @@ export const CustomError = { EXIF_DATA_NOT_FOUND: "exif data not found", SELECT_FOLDER_ABORTED: "select folder aborted", NON_MEDIA_FILE: "non media file", - UNSUPPORTED_RAW_FORMAT: "unsupported raw format", - NON_PREVIEWABLE_FILE: "non previewable file", PROCESSING_FAILED: "processing failed", EXPORT_RECORD_JSON_PARSING_FAILED: "export record json parsing failed", TWO_FACTOR_ENABLED: "two factor enabled", @@ -84,8 +82,6 @@ export const CustomError = { ServerError: "server error", FILE_NOT_FOUND: "file not found", UNSUPPORTED_PLATFORM: "Unsupported platform", - MODEL_DOWNLOAD_PENDING: - "Model download pending, skipping clip search request", UPDATE_URL_FILE_ID_MISMATCH: "update url file id mismatch", URL_ALREADY_SET: "url already set", FILE_CONVERSION_FAILED: "file conversion failed", diff --git a/web/packages/shared/utils/index.ts b/web/packages/shared/utils/index.ts index c027b6cb6..568ec5cc4 100644 --- a/web/packages/shared/utils/index.ts +++ b/web/packages/shared/utils/index.ts @@ -4,9 +4,8 @@ * This function is a promisified `setTimeout`. It returns a promise that * resolves after {@link ms} milliseconds. */ -export async function sleep(ms: number) { - await new Promise((resolve) => setTimeout(resolve, ms)); -} +export const wait = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); export function downloadAsFile(filename: string, content: string) { const file = new Blob([content], { @@ -49,29 +48,27 @@ export async function retryAsyncFunction( if (attemptNumber === waitTimeBeforeNextTry.length) { throw e; } - await sleep(waitTimeBeforeNextTry[attemptNumber]); + await wait(waitTimeBeforeNextTry[attemptNumber]); } } } -export const promiseWithTimeout = async ( - request: Promise, - timeout: number, -): Promise => { - const timeoutRef = { current: null }; - const rejectOnTimeout = new Promise((_, reject) => { - timeoutRef.current = setTimeout( +/** + * Await the given {@link promise} for {@link timeoutMS} milliseconds. If it + * does not resolve within {@link timeoutMS}, then reject with a timeout error. + */ +export const withTimeout = async (promise: Promise, ms: number) => { + let timeoutId: ReturnType; + const rejectOnTimeout = new Promise((_, reject) => { + timeoutId = setTimeout( () => reject(new Error("Operation timed out")), - timeout, + ms, ); }); - const requestWithTimeOutCancellation = async () => { - const resp = await request; - clearTimeout(timeoutRef.current); - return resp; + const promiseAndCancelTimeout = async () => { + const result = await promise; + clearTimeout(timeoutId); + return result; }; - return await Promise.race([ - requestWithTimeOutCancellation(), - rejectOnTimeout, - ]); + return Promise.race([promiseAndCancelTimeout(), rejectOnTimeout]); }; diff --git a/web/packages/utils/ensure.ts b/web/packages/utils/ensure.ts new file mode 100644 index 000000000..761cedc99 --- /dev/null +++ b/web/packages/utils/ensure.ts @@ -0,0 +1,16 @@ +/** + * 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; +}; + +/** + * Throw an exception if the given value is not a string. + */ +export const ensureString = (v: unknown): string => { + if (typeof v != "string") + throw new Error(`Expected a string, instead found ${String(v)}`); + 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==