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-lint.yml b/.github/workflows/mobile-lint.yml index 48e38dc6c..57b2ca4db 100644 --- a/.github/workflows/mobile-lint.yml +++ b/.github/workflows/mobile-lint.yml @@ -9,7 +9,7 @@ on: - ".github/workflows/mobile-lint.yml" env: - FLUTTER_VERSION: "3.13.4" + FLUTTER_VERSION: "3.19.5" jobs: lint: diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index 3ce4db8d2..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.13.4" + 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_ar.arb b/auth/lib/l10n/arb/app_ar.arb index ea93af7a4..68bd38900 100644 --- a/auth/lib/l10n/arb/app_ar.arb +++ b/auth/lib/l10n/arb/app_ar.arb @@ -78,14 +78,12 @@ "data": "البيانات", "importCodes": "رمزالاستيراد", "importTypePlainText": "نص عادي", - "importTypeEnteEncrypted": "تصدير مشفر ente", "passwordForDecryptingExport": "كلمة المرور لفك تشفير التصدير", "passwordEmptyError": "لا يمكن أن تكون كلمة المرور فارغة", "importFromApp": "استيراد الرموز من {appName}", "importGoogleAuthGuide": "قم بتصدير حساباتك من Google Authenticator إلى رمز QR code باستخدام خيار \"Transfer Accounts\" ثم استخدم جهازًا آخر لمسح رمز الاستجابة السريعة ضوئيًا.\n\nنصيحة: يمكنك استخدام كاميرا الويب الخاصة بالكمبيوتر المحمول لالتقاط صورة لرمز الاستجابة السريعة.", "importSelectJsonFile": "حدد ملف JSON", "importSelectAppExport": "حدد ملف التصدير {appName}", - "importEnteEncGuide": "حدد ملف JSON المشفر الذي تم تصديره من ente", "importRaivoGuide": "استخدم خيار تصدير OTP إلى أرشيف Zip في إعدادات Raivo.\n\nاستخرج ملف zip واسترد ملف JSON.", "importBitwardenGuide": "استخدم خيار \"تصدير خزانة\" داخل أدوات Bitwarden واستيراد ملف JSON غير مشفر.", "importAegisGuide": "استخدم خيار \"Export the vault\" في إعدادات Aegis.\n\nإذا كان المخزن الخاص بك مشفرًا، فستحتاج إلى إدخال كلمة مرور المخزن لفك تشفير المخزن.", @@ -115,22 +113,18 @@ "copied": "تم النسخ", "pleaseTryAgain": "حاول مرة اخرى", "existingUser": "المستخدم موجود", - "newUser": "جديد إلى Ente", "delete": "حذف", "enterYourPasswordHint": "أدخل كلمة المرور الخاصة بك", "forgotPassword": "هل نسيت كلمة المرور", "oops": "عذرًا", "suggestFeatures": "اقتراح ميزة", "faq": "الأسئلة الأكثر شيوعاً", - "faq_q_1": "ما مدى أمان المصادقة؟", - "faq_a_1": "يتم تشفير جميع الرموز التي تقوم بنسخها احتياطا عبر Ente. وهذا يعني أنه يمكنك فقط الوصول إلى الرموز الخاصة بك. تطبيقاتنا مفتوحة المصدر وقد تم مراجعة التشفير خارجيا.", "faq_q_2": "هل يمكنني الوصول إلى رموزي على سطح المكتب؟", "faq_a_2": "يمكنك الوصول إلى رموزك على الويب @ auth.ente.io.", "faq_q_3": "كيف يمكنني حذف الرموز؟", "faq_a_3": "يمكنك حذف الرمز عن طريق السحب لليسار على هذا العنصر.", "faq_q_4": "كيف يمكنني دعم هذا المشروع؟", "faq_a_4": "يمكنك دعم تطوير هذا المشروع عن طريق الاشتراك في تطبيق الصور @ ente.io.", - "faq_q_5": "كيف يمكنني تمكين قفل FaceID في المصادقة Ente", "faq_a_5": "يمكنك تمكين قفل FaceID تحت الإعدادات => الحماية => قفل الشاشة.", "somethingWentWrongMessage": "حدث خطأ ما، يرجى المحاولة مرة أخرى", "leaveFamily": "مغادرة خطة العائلة", @@ -144,6 +138,8 @@ "enterCodeHint": "أدخل الرمز المكون من 6 أرقام من\nتطبيق المصادقة", "lostDeviceTitle": "جهاز مفقود ؟", "twoFactorAuthTitle": "المصادقة الثنائية", + "passkeyAuthTitle": "التحقق من مفتاح المرور", + "verifyPasskey": "تحقق من مفتاح المرور", "recoverAccount": "إسترجاع الحساب", "enterRecoveryKeyHint": "أدخل رمز الاسترداد", "recover": "استرداد", @@ -197,6 +193,8 @@ "recoveryKeySaveDescription": "نحن لا نخزن هذا المفتاح، يرجى حفظ مفتاح الـ 24 كلمة هذا في مكان آمن.", "doThisLater": "قم بهذا لاحقاً", "saveKey": "حفظ المفتاح", + "save": "حفظ", + "send": "إرسال", "back": "الرجوع", "createAccount": "إنشاء حساب", "passwordStrength": "قوة كلمة المرور: {passwordStrengthValue}", @@ -344,7 +342,6 @@ "deleteCodeAuthMessage": "المصادقة لحذف الرمز", "showQRAuthMessage": "المصادقة لإظهار رمز QR", "confirmAccountDeleteTitle": "تأكيد حذف الحساب", - "confirmAccountDeleteMessage": "هذا الحساب مرتبط بتطبيقات Ente أخرى، إذا كنت تستخدم أي منها.\n\nبياناتك التي تم تحميلها، عبر جميع تطبيقات Ente سيتم جدولتها للحذف، وسيتم حذف حسابك بشكل دائم.", "androidBiometricHint": "التحقق من الهوية", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -404,5 +401,7 @@ "signOutOtherDevices": "تسجيل الخروج من الأجهزة الأخرى", "doNotSignOut": "لا تقم بتسجيل الخروج", "hearUsWhereTitle": "كيف سمعت عن Ente؟ (اختياري)", - "hearUsExplanation": "نحن لا نتتبع تثبيت التطبيق. سيكون من المفيد إذا أخبرتنا أين وجدتنا!" + "hearUsExplanation": "نحن لا نتتبع تثبيت التطبيق. سيكون من المفيد إذا أخبرتنا أين وجدتنا!", + "passkey": "مفتاح المرور", + "developerSettings": "اعدادات المطور" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_de.arb b/auth/lib/l10n/arb/app_de.arb index 7b21fcab1..f3ea23b51 100644 --- a/auth/lib/l10n/arb/app_de.arb +++ b/auth/lib/l10n/arb/app_de.arb @@ -78,14 +78,12 @@ "data": "Datei", "importCodes": "Codes importieren", "importTypePlainText": "Klartext", - "importTypeEnteEncrypted": "ente verschlüsselt exportieren", "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.", @@ -115,22 +113,20 @@ "copied": "Kopiert", "pleaseTryAgain": "Bitte versuchen Sie es erneut", "existingUser": "Bestehender Benutzer", - "newUser": "Neu bei ente", + "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 ente Auth?", - "faq_a_1": "Alle Codes, die Sie über ente sichern, werden Ende-zu-Ende-verschlüsselt gespeichert. Das bedeutet, dass nur Sie auf Ihre Codes zugreifen können. Unsere Apps sind Open Source und unsere Kryptografie wurde extern überprüft.", + "faq_q_1": "Wie sicher ist Auth?", "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 FaceID Sperre in ente 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", @@ -199,6 +195,7 @@ "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", "back": "Zurück", "createAccount": "Account erstellen", "passwordStrength": "Passwortstärke: {passwordStrengthValue}", @@ -346,7 +343,6 @@ "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, sofern du diese benutzt.\n\nDeine hochgeladenen Daten werden zur permanenten Löschung freigegeben. Dies gilt für alle ente Apps.", "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." @@ -407,6 +403,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_es.arb b/auth/lib/l10n/arb/app_es.arb index 0740accb1..41113f0b9 100644 --- a/auth/lib/l10n/arb/app_es.arb +++ b/auth/lib/l10n/arb/app_es.arb @@ -78,14 +78,12 @@ "data": "Datos", "importCodes": "Importar códigos", "importTypePlainText": "Texto sin formato", - "importTypeEnteEncrypted": "Exportación cifrada ente", "passwordForDecryptingExport": "Contraseña para descifrar exportación", "passwordEmptyError": "La contraseña no puede estar vacía", "importFromApp": "Importar códigos de {appName}", "importGoogleAuthGuide": "Exportar tus cuentas desde Google Authenticator a un código QR usando la opción \"Transferir Cuentas\". A continuación, usando otro dispositivo, escanee el código QR.\n\nConsejo: Puede usar la webcam de su portátil para tomar una foto del código QR.", "importSelectJsonFile": "Seleccione el archivo JSON", "importSelectAppExport": "Seleccione el archivo de exportación de {appName}", - "importEnteEncGuide": "Seleccione el archivo JSON cifrado exportado desde ente", "importRaivoGuide": "Utilice la opción \"Exportar códigos a un archivo de Zip\" en la configuración de Raivo.\n\nExtraiga el archivo zip e importe el archivo JSON.", "importBitwardenGuide": "Use la opción \"Exportar caja fuerte\" dentro del menú Herramientas de Bitwarden e importe el fichero JSON no crifrado.", "importAegisGuide": "Utilice la opción \"Exportar la bóveda\" en ajustes de Aegis.\n\nSi tu bóveda es cifrada, necesitara entrar contraseña de bóveda para descifrar la bóveda.", @@ -115,22 +113,18 @@ "copied": "Copiado", "pleaseTryAgain": "Por favor, inténtalo nuevamente", "existingUser": "Usuario existente", - "newUser": "Nuevo en ente", "delete": "Borrar", "enterYourPasswordHint": "Ingrese su contraseña", "forgotPassword": "Olvidé mi contraseña", "oops": "Ups", "suggestFeatures": "Sugerir funcionalidades", "faq": "Preguntas Frecuentes", - "faq_q_1": "¿Cuán seguro es ente Auth?", - "faq_a_1": "Todos los códigos que copia de seguridad vía ente se almacenan cifrados de extremo a extremo. Esto significa que solo usted puede acceder a sus códigos. Nuestras aplicaciones son de código abierto y nuestra criptografía ha sido auditada externamente.", "faq_q_2": "¿Puedo acceder a mis códigos en el escritorio?", "faq_a_2": "Puede acceder a tus códigos en la web en auth.ente.io.", "faq_q_3": "¿Cómo puedo borrar códigos?", "faq_a_3": "Puede eliminar un código deslizando a la izquierda en ese elemento.", "faq_q_4": "¿Cómo puedo apoyar este proyecto?", "faq_a_4": "Puedes apoyar el desarrollo de este proyecto suscribiéndote a nuestra app de Fotos en ente.io.", - "faq_q_5": "¿Cómo puedo habilitar bloqueo FaceID en ente Auth", "faq_a_5": "Puede activar el bloqueo FaceID en Ajustes → Seguridad → Pantalla de bloqueo.", "somethingWentWrongMessage": "Algo ha ido mal, por favor, prueba otra vez", "leaveFamily": "Dejar plan familiar", @@ -344,7 +338,6 @@ "deleteCodeAuthMessage": "Autenticar para borrar código", "showQRAuthMessage": "Autenticar para mostrar código QR", "confirmAccountDeleteTitle": "Confirmar eliminación de la cuenta", - "confirmAccountDeleteMessage": "Esta cuenta está vinculada a otras aplicaciones de ente, si utiliza alguna.\n\nSe programará la eliminación de los datos que cargue en todas las aplicaciones de ente y su cuenta se eliminará permanentemente.", "androidBiometricHint": "Verificar identidad", "@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_fa.arb b/auth/lib/l10n/arb/app_fa.arb index 90d9e1f81..0cba193a9 100644 --- a/auth/lib/l10n/arb/app_fa.arb +++ b/auth/lib/l10n/arb/app_fa.arb @@ -85,7 +85,6 @@ "copied": "کپی شد", "pleaseTryAgain": "لطفا دوباره تلاش کنید", "existingUser": "کاربر موجود", - "newUser": "کاربر جدید ente", "delete": "حذف", "enterYourPasswordHint": "رمز عبور خود را وارد کنید", "forgotPassword": "فراموشی رمز عبور", diff --git a/auth/lib/l10n/arb/app_fi.arb b/auth/lib/l10n/arb/app_fi.arb index 8dfe22530..72309b331 100644 --- a/auth/lib/l10n/arb/app_fi.arb +++ b/auth/lib/l10n/arb/app_fi.arb @@ -74,7 +74,6 @@ "copied": "Jäljennetty", "pleaseTryAgain": "Yritä uudestaan", "existingUser": "Jo valmiiksi olemassaoleva käyttäjä", - "newUser": "Uusi Ente-käyttäjä", "delete": "Poista", "enterYourPasswordHint": "Syötä salasanasi", "forgotPassword": "Olen unohtanut salasanani", diff --git a/auth/lib/l10n/arb/app_fr.arb b/auth/lib/l10n/arb/app_fr.arb index 6fa997b84..04a7058c7 100644 --- a/auth/lib/l10n/arb/app_fr.arb +++ b/auth/lib/l10n/arb/app_fr.arb @@ -1,5 +1,6 @@ { "account": "Compte", + "unlock": "Déverrouiller", "recoveryKey": "Clé de récupération", "counterAppBarTitle": "Compteur", "@counterAppBarTitle": { @@ -77,15 +78,17 @@ "data": "Données", "importCodes": "Importer les codes", "importTypePlainText": "Texte brut", - "importTypeEnteEncrypted": "ente Exportation chiffrée", "passwordForDecryptingExport": "Mot de passe pour déchiffrer l'exportation", "passwordEmptyError": "Le mot de passe ne peut pas être vide", "importFromApp": "Importer des codes depuis {appName}", "importGoogleAuthGuide": "Exportez vos comptes depuis Google Authenticator vers un code QR en utilisant l'option \"Transférer des comptes\". Ensuite, en utilisant un autre appareil, scannez le code QR.\n\nAstuce : Vous pouvez utiliser la webcam de votre ordinateur portable pour prendre une photo du code QR.", "importSelectJsonFile": "Sélectionnez un fichier JSON", - "importEnteEncGuide": "Sélectionnez le fichier JSON chiffré exporté à partir de ente", + "importSelectAppExport": "Sélectionnez le fichier d'exportation {appName}", "importRaivoGuide": "Utilisez l'option \"Exporter les OTPs vers l'archive Zip\" dans les paramètres de Raivo.\n\nExtrayez le fichier zip et importez le fichier JSON.", + "importBitwardenGuide": "Utilisez l'option « Exporter le coffre » dans les outils Bitwarden et importez le fichier JSON non chiffré.", "importAegisGuide": "Utilisez l'option \"Exporter le coffre-fort\" dans les paramètres d'Aegis.\n\nSi votre coffre-fort est crypté, vous devrez saisir le mot de passe du coffre-fort pour déchiffrer le coffre-fort.", + "import2FasGuide": "Utilisez l'option \"Paramètres->Sauvegarde -Export\" dans 2FAS.\n\nSi votre sauvegarde est chiffrée, vous devrez entrer le mot de passe pour déchiffrer la sauvegarde", + "importLastpassGuide": "Utilisez l'option \"Transférer des comptes\" dans les paramètres de l'authentificateur Lastpass et appuyez sur \"Exporter des comptes vers un fichier\". Importez le JSON téléchargé.", "exportCodes": "Exporter les codes", "importLabel": "Importer", "importInstruction": "Veuillez sélectionner un fichier qui contient une liste de vos codes dans le format suivant", @@ -97,6 +100,8 @@ "authToViewYourRecoveryKey": "Veuillez vous authentifier pour afficher votre clé de récupération", "authToChangeYourEmail": "Veuillez vous authentifier pour modifier votre adresse e-mail", "authToChangeYourPassword": "Veuillez vous authentifier pour modifier votre mot de passe", + "authToViewSecrets": "Veuillez vous authentifier pour voir vos souvenirs", + "authToInitiateSignIn": "Veuillez vous authentifier pour ouvrir une session de sauvegarde.", "ok": "Ok", "cancel": "Annuler", "yes": "Oui", @@ -108,22 +113,18 @@ "copied": "Copié", "pleaseTryAgain": "Veuillez réessayer", "existingUser": "Utilisateur existant", - "newUser": "Nouveau sur ente", "delete": "Supprimer", "enterYourPasswordHint": "Saisir votre mot de passe", "forgotPassword": "Mot de passe oublié", "oops": "Oups", "suggestFeatures": "Suggérer des fonctionnalités", "faq": "FAQ", - "faq_q_1": "À quel point ente Auth est-il sécurisé ?", - "faq_a_1": "Tous les codes que vous sauvegardez via ente sont chiffrés de bout en bout. Cela signifie que vous seul pouvez accéder à vos codes. Nos applications sont open source et notre cryptographie a fait l'objet d'un audit externe.", "faq_q_2": "Puis-je accéder à mes codes sur mon ordinateur ?", "faq_a_2": "Vous pouvez accéder à vos codes sur le web via auth.ente.io.", "faq_q_3": "Comment puis-je supprimer des codes ?", "faq_a_3": "Vous pouvez supprimer un code en glissant vers la gauche.", "faq_q_4": "Comment puis-je soutenir le projet ?", "faq_a_4": "Vous pouvez soutenir le développement de ce projet en vous abonnant à notre application Photos, ente.io.", - "faq_q_5": "Comment puis-je activer le verrouillage FaceID sur ente Auth", "faq_a_5": "Vous pouvez activer le verrouillage FaceID dans Paramètres → Sécurité → Écran de verrouillage.", "somethingWentWrongMessage": "Quelque chose s'est mal passé, veuillez recommencer", "leaveFamily": "Quitter le plan familial", @@ -137,6 +138,8 @@ "enterCodeHint": "Saisir le code à 6 caractères de votre appli d'authentification", "lostDeviceTitle": "Appareil perdu ?", "twoFactorAuthTitle": "Authentification à deux facteurs", + "passkeyAuthTitle": "Vérification du code d'accès", + "verifyPasskey": "Vérifier le code d'accès", "recoverAccount": "Récupérer un compte", "enterRecoveryKeyHint": "Saisissez votre clé de récupération", "recover": "Restaurer", @@ -190,6 +193,10 @@ "recoveryKeySaveDescription": "Nous ne stockons pas cette clé, veuillez enregistrer cette clé de 24 mots dans un endroit sûr.", "doThisLater": "Plus tard", "saveKey": "Enregistrer la clé", + "save": "Sauvegarder", + "send": "Envoyer", + "saveOrSendDescription": "Voulez-vous enregistrer ceci sur votre stockage (dossier Téléchargements par défaut) ou l'envoyer à d'autres applications ?", + "saveOnlyDescription": "Voulez-vous enregistrer ceci sur votre stockage (dossier Téléchargements par défaut) ?", "back": "Retour", "createAccount": "Créer un compte", "passwordStrength": "Force du mot de passe : {passwordStrengthValue}", @@ -329,6 +336,7 @@ "offlineModeWarning": "Vous avez choisi de procéder sans sauvegarde. Veuillez prendre des sauvegardes manuelles pour vous assurer que vos codes sont sûrs.", "showLargeIcons": "Afficher les grandes icônes", "shouldHideCode": "Cacher les codes", + "doubleTapToViewHiddenCode": "Vous pouvez appuyer deux fois sur une entrée pour afficher le code", "focusOnSearchBar": "Cibler le champ de recherche au démarrage de l'application", "confirmUpdatingkey": "Êtes-vous sûr de vouloir mettre à jour la clé secrète ?", "minimizeAppOnCopy": "Réduire l'application après la copie", @@ -336,5 +344,75 @@ "deleteCodeAuthMessage": "Authentification requise pour supprimer le code", "showQRAuthMessage": "Authentification requise pour afficher le code QR", "confirmAccountDeleteTitle": "Confirmer la suppression du compte", - "confirmAccountDeleteMessage": "Ce compte peut être lié à d'autres applications ente.\n\nVos données seront bientôt effacées de toutes les applications et votre compte sera définitivement supprimé." + "androidBiometricHint": "Vérifier l’identité", + "@androidBiometricHint": { + "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." + }, + "androidBiometricNotRecognized": "Non reconnu. Veuillez réessayer.", + "@androidBiometricNotRecognized": { + "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters." + }, + "androidBiometricSuccess": "Parfait", + "@androidBiometricSuccess": { + "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters." + }, + "androidCancelButton": "Annuler", + "@androidCancelButton": { + "description": "Message showed on a button that the user can click to leave the current dialog. It is used on Android side. Maximum 30 characters." + }, + "androidSignInTitle": "Authentification requise", + "@androidSignInTitle": { + "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters." + }, + "androidBiometricRequiredTitle": "Empreinte digitale requise", + "@androidBiometricRequiredTitle": { + "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters." + }, + "androidDeviceCredentialsRequiredTitle": "Identifiants de l'appareil requis", + "@androidDeviceCredentialsRequiredTitle": { + "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters." + }, + "androidDeviceCredentialsSetupDescription": "Identifiants de l'appareil requis", + "@androidDeviceCredentialsSetupDescription": { + "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side." + }, + "goToSettings": "Allez dans les paramètres", + "@goToSettings": { + "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." + }, + "androidGoToSettingsDescription": "L'authentification biométrique n'est pas configurée sur votre appareil. Allez dans 'Paramètres > Sécurité' pour l'ajouter.", + "@androidGoToSettingsDescription": { + "description": "Message advising the user to go to the settings and configure biometric on their device. It shows in a dialog on Android side." + }, + "iOSLockOut": "L'authentification biométrique est désactivée. Veuillez verrouiller et déverrouiller votre écran pour l'activer.", + "@iOSLockOut": { + "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side." + }, + "iOSGoToSettingsDescription": "L'authentification biométrique n'est pas configurée sur votre appareil. Veuillez activer Touch ID ou Face ID sur votre téléphone.", + "@iOSGoToSettingsDescription": { + "description": "Message advising the user to go to the settings and configure Biometrics for their device. It shows in a dialog on iOS side." + }, + "iOSOkButton": "Ok", + "@iOSOkButton": { + "description": "Message showed on a button that the user can click to leave the current dialog. It is used on iOS side. Maximum 30 characters." + }, + "noInternetConnection": "Aucune connexion internet", + "pleaseCheckYourInternetConnectionAndTryAgain": "Veuillez vérifier votre connexion internet puis réessayer.", + "signOutFromOtherDevices": "Déconnexion des autres appareils", + "signOutOtherBody": "Si vous pensez que quelqu'un connaît peut-être votre mot de passe, vous pouvez forcer tous les autres appareils utilisant votre compte à se déconnecter.", + "signOutOtherDevices": "Déconnecter les autres appareils", + "doNotSignOut": "Ne pas se déconnecter", + "hearUsWhereTitle": "Comment avez-vous entendu parler de Ente? (facultatif)", + "hearUsExplanation": "Nous ne suivons pas les installations d'applications. Il serait utile que vous nous disiez comment vous nous avez trouvés !", + "recoveryKeySaved": "Clé de récupération enregistrée dans le dossier Téléchargements !", + "waitingForBrowserRequest": "En attente de la requête du navigateur...", + "waitingForVerification": "En attente de vérification...", + "passkey": "Code d'accès", + "developerSettingsWarning": "Êtes-vous sûr de vouloir modifier les paramètres du développeur ?", + "developerSettings": "Paramètres du développeur", + "serverEndpoint": "Point de terminaison serveur", + "invalidEndpoint": "Point de terminaison non valide", + "invalidEndpointMessage": "Désolé, le point de terminaison que vous avez entré n'est pas valide. Veuillez en entrer un valide puis réessayez.", + "endpointUpdatedMessage": "Point de terminaison mis à jour avec succès", + "customEndpoint": "Connecté à {endpoint}" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_he.arb b/auth/lib/l10n/arb/app_he.arb index c3703d1d0..330585097 100644 --- a/auth/lib/l10n/arb/app_he.arb +++ b/auth/lib/l10n/arb/app_he.arb @@ -77,13 +77,11 @@ "data": "נתונים", "importCodes": "ייבא קוד", "importTypePlainText": "טקסט רגיל", - "importTypeEnteEncrypted": "ייצוא ente מוצפן", "passwordForDecryptingExport": "סיסמא כדי לפענח יצוא", "passwordEmptyError": "הסיסמה אינה יכולה להיות ריקה", "importFromApp": "יבא קודים מ-{appName}", "importGoogleAuthGuide": "יצא את החשבונות שלך מ-Google Authenticator לקוד QR תוך שימוש באפשרות \"Transfer Accounts\". אז, תוך שימוש במכשיר אחר, סרוק את הקוד QR.", "importSelectJsonFile": "בחר קובץ JSON", - "importEnteEncGuide": "בחר את קובץ ה-JSON המוצפן שייוצא מ-ente", "importRaivoGuide": "השתמש באפוציה של \"Export OTPs to Zip archive\" בהגדרות של Raivo.", "importAegisGuide": "אנא השתמש באפשרות של \"ייצוא של הכספת\" בתוך ההגדרות של Aegis.", "exportCodes": "ייצא קודים", @@ -108,22 +106,18 @@ "copied": "הועתק", "pleaseTryAgain": "אנא נסה שנית", "existingUser": "משתמש קיים", - "newUser": "חדש בente", "delete": "למחוק", "enterYourPasswordHint": "הכנס סיסמא", "forgotPassword": "שכחתי סיסמה", "oops": "אופס", "suggestFeatures": "הציעו מאפיינים", "faq": "שאלות נפוצות", - "faq_q_1": "כמה מאובטח ente Auth?", - "faq_a_1": "כל הקודים שאתה מגבה דרך ente מאוחסנים מקצה לקצה בהצפנה. הכוונה שרק אתה יכול לגשת לקודים שלך. האפליקציות שלנו הם מפותחות דרך קוד פתוח והקריפטוגרפיה שלנו מבוקרת חיצונית.", "faq_q_2": "האם ישנה אפשרות להשתמש בקודים שלי במחשב?", "faq_a_2": "אתה יכול לגשת לקודים שלך ברשת ב- auth.ente.io.", "faq_q_3": "איך אפשר למחוק קודים?", "faq_a_3": "אתה יכול למחוק את הקוד על-ידי החלקה שמאלה על הפריט הזה.", "faq_q_4": "איך אפשר לתמוך בפרויקט זה?", "faq_a_4": "אתה יכול לתמוך בפיתוח של הפרויקט הזה על ידי שתירשם לאפליקצית תמונות שלנו ב-ente.io.", - "faq_q_5": "איך אני יכול להפעיל מנעול FaceID ב-ente Auth", "faq_a_5": "אתה יכול להפעיל מנעול FaceID תחת הגדרות -> אבטחה -> מסך נעילה.", "somethingWentWrongMessage": "משהו השתבש, אנא נסה שנית", "leaveFamily": "עזוב משפחה", diff --git a/auth/lib/l10n/arb/app_it.arb b/auth/lib/l10n/arb/app_it.arb index 1cec02cd2..e35fd11dc 100644 --- a/auth/lib/l10n/arb/app_it.arb +++ b/auth/lib/l10n/arb/app_it.arb @@ -78,14 +78,12 @@ "data": "Dati", "importCodes": "Importa codici", "importTypePlainText": "Testo in chiaro", - "importTypeEnteEncrypted": "ente Esportazione criptata", "passwordForDecryptingExport": "Password per decriptare il file esportato", "passwordEmptyError": "La password è obbligatoria", "importFromApp": "Importa codici da {appName}", "importGoogleAuthGuide": "Esporta i tuoi account da Google Authenticator in un codice QR utilizzando l'opzione \"Trasferisci Account\". Quindi, usando un altro dispositivo, scansiona il codice QR.\n\nSuggerimento: Puoi usare la webcam del tuo computer portatile per scattare una foto del codice QR.", "importSelectJsonFile": "Seleziona file JSON", "importSelectAppExport": "Seleziona il file di esportazione {appName}", - "importEnteEncGuide": "Seleziona il file JSON criptato esportato da ente", "importRaivoGuide": "Utilizza l'opzione \"Esporta i codici OTP in archivio Zip\" nelle impostazioni di Raivo.\n\nEstrai il file zip e importa il file JSON.", "importBitwardenGuide": "Utilizzare l'opzione \"Esporta vault\" all'interno di Bitwarden Tools e importa il file JSON non crittografato.", "importAegisGuide": "Usa l'opzione \"Esporta la cassaforte\" nelle impostazioni di Aegis.\n\nSe la tua cassaforte è criptata, dovrai inserire la password della cassaforte per decriptarla.", @@ -114,22 +112,18 @@ "copied": "Copiato", "pleaseTryAgain": "Per favore riprova", "existingUser": "Accedi", - "newUser": "Nuovo utente", "delete": "Cancella", "enterYourPasswordHint": "Inserisci la tua password", "forgotPassword": "Password dimenticata", "oops": "Oops", "suggestFeatures": "Suggerisci funzionalità", "faq": "FAQ", - "faq_q_1": "Quanto è sicuro ente Auth?", - "faq_a_1": "Tutti i codici di cui fai il backup tramite ente sono memorizzati con crittografia end-to-end. Ciò significa che solo tu puoi accedere ai tuoi codici. Le nostre app sono open source e la nostra crittografia è stata verificata esternamente.", "faq_q_2": "Posso accedere ai miei codici sul desktop?", "faq_a_2": "Puoi accedere ai tuoi codici sul web @ auth.ente.io.", "faq_q_3": "Come posso cancellare i codici?", "faq_a_3": "Puoi eliminare un codice scorrendo il dito a sinistra sul codice in questione.", "faq_q_4": "Come posso supportare questo progetto?", "faq_a_4": "Puoi supportare lo sviluppo di questo progetto abbonandoti alla nostra app Photos @ ente.io.", - "faq_q_5": "Come posso abilitare il blocco FaceID in ente Auth", "faq_a_5": "Puoi abilitare il blocco FaceID in Impostazioni → Sicurezza → Schermata di blocco.", "somethingWentWrongMessage": "Qualcosa è andato storto, per favore riprova", "leaveFamily": "Abbandona il piano famiglia", @@ -196,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}", @@ -343,7 +338,6 @@ "deleteCodeAuthMessage": "Autenticarsi per cancellare il codice", "showQRAuthMessage": "Autenticarsi per mostrare il codice QR", "confirmAccountDeleteTitle": "Conferma l'eliminazione dell'account", - "confirmAccountDeleteMessage": "Questo account è collegato ad altre app di ente, se ne utilizzi.\n\nI tuoi dati caricati, su tutte le app di ente, saranno pianificati per la cancellazione e il tuo account verrà eliminato definitivamente.", "androidBiometricHint": "Verifica l'identità", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -403,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_ja.arb b/auth/lib/l10n/arb/app_ja.arb index 5b281747f..60d0a5150 100644 --- a/auth/lib/l10n/arb/app_ja.arb +++ b/auth/lib/l10n/arb/app_ja.arb @@ -78,14 +78,13 @@ "data": "データ", "importCodes": "コードをインポート", "importTypePlainText": "プレーンテキスト", - "importTypeEnteEncrypted": "ente 暗号化エクスポート", "passwordForDecryptingExport": "復号化用パスワード", "passwordEmptyError": "パスワードは空欄にできません", "importFromApp": "{appName} からコードをインポート", "importGoogleAuthGuide": "Google Authenticator の \"アカウントを転送\" オプションを使用してあなたのアカウントを QR コードにエクスポートしてください。その後、他のデバイスで QR コードを読み取ってください。\n\nヒント: ノートパソコンのウェブカメラを使用して QR コードを撮影することができます。", "importSelectJsonFile": "JSON ファイルを選択", "importSelectAppExport": "{appName} のエクスポートファイルを選択", - "importEnteEncGuide": "ente からエクスポートされた暗号化 JSON ファイルを選択", + "importEnteEncGuide": "Enteからエクスポートした暗号化されたJSONファイルを選択してください", "importRaivoGuide": "Raivo の設定の \"OTP を zip アーカイブにエクスポート\" を使用してください。\n\nzip ファイルを解凍し JSON ファイルをインポートしてください。", "importBitwardenGuide": "Bitwarden Tools の \"保管庫のエクスポート\" オプションを使用後、平文の JSON ファイルをインポートしてください。", "importAegisGuide": "Aegis の設定の \"保管庫をエクスポート\" を使用してください。\n\n保管庫が暗号化されている場合、保管庫を復号するためにパスワードの入力が必要になります。", @@ -115,22 +114,21 @@ "copied": "コピーしました", "pleaseTryAgain": "再度お試しください", "existingUser": "既存のユーザー", - "newUser": "ente 新規ユーザー", + "newUser": "Enteを初めて使用", "delete": "削除", "enterYourPasswordHint": "パスワードを入力してください", "forgotPassword": "パスワードを忘れた場合", "oops": "おっと", "suggestFeatures": "機能を提案", "faq": "FAQ", - "faq_q_1": "ente Auth はどのくらい安全ですか?", - "faq_a_1": "ente でバックアップされたすべてのコードはエンドツーエンドで暗号化されて保管されます。これはあなただけがコードにアクセスできることを意味します。私たちのアプリはオープンソースであり、私たちの暗号は外部有識者によって検証済みです。", + "faq_q_1": "Authはどのくらい安全ですか?", "faq_q_2": "パソコンから私のコードにアクセスできますか?", "faq_a_2": "auth.ente.io で Web からコードにアクセス可能です。", "faq_q_3": "コードを削除するにはどうすればいいですか?", "faq_a_3": "その項目を左にスワイプすることでコードを削除できます。", "faq_q_4": "このプロジェクトを支援するにはどうすればいいですか?", "faq_a_4": "ente.io で私たちの写真アプリを購読することでこのプロジェクトの開発を支援できます。", - "faq_q_5": "ente Auth で FaceID ロックを有効にするにはどうすればいいですか?", + "faq_q_5": "AuthでFaceIDロックを有効にするにはどうすればいいですか?", "faq_a_5": "設定→セキュリティ→画面のロックから FaceID ロックを有効にできます。", "somethingWentWrongMessage": "問題が発生しました、再試行してください", "leaveFamily": "ファミリープランから退会", @@ -199,6 +197,10 @@ "recoveryKeySaveDescription": "私たちはこのキーを保存しません。この 24 単語のキーを安全な場所に保存してください。", "doThisLater": "後で行う", "saveKey": "キーを保存", + "save": "保存", + "send": "送信", + "saveOrSendDescription": "これをストレージ (デフォルトではダウンロードフォルダ) に保存しますか、もしくは他のアプリに送信しますか?", + "saveOnlyDescription": "これをストレージに保存しますか? (デフォルトではダウンロードフォルダに保存)", "back": "戻る", "createAccount": "アカウント作成", "passwordStrength": "パスワードの強度: {passwordStrengthValue}", @@ -346,7 +348,6 @@ "deleteCodeAuthMessage": "コードを削除するためには認証が必要です", "showQRAuthMessage": "QR コードを表示するためには認証が必要です", "confirmAccountDeleteTitle": "アカウントの削除に同意", - "confirmAccountDeleteMessage": "このアカウントは他の ente アプリも使用している場合はそれらに結びつけられています。\n\nすべての ente アプリであなたがアップロードしたデータは削除がスケジュールされ、あなたのアカウントは永久に削除されます。", "androidBiometricHint": "本人を確認する", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -407,6 +408,7 @@ "doNotSignOut": "サインアウトしない", "hearUsWhereTitle": "Ente についてどのようにお聞きになりましたか?(任意)", "hearUsExplanation": "私たちはアプリのインストールを追跡していません。私たちをお知りになった場所を教えてください!", + "recoveryKeySaved": "リカバリキーをダウンロードフォルダに保存しました!", "waitingForBrowserRequest": "ブラウザのリクエストを待っています...", "waitingForVerification": "認証を待っています...", "passkey": "パスキー", diff --git a/auth/lib/l10n/arb/app_ka.arb b/auth/lib/l10n/arb/app_ka.arb index d7be292dd..cb7dc8281 100644 --- a/auth/lib/l10n/arb/app_ka.arb +++ b/auth/lib/l10n/arb/app_ka.arb @@ -74,7 +74,6 @@ "data": "მონაცემები", "importCodes": "კოდების იმპორტირება", "importTypePlainText": "სტანდარტული ტექსტი", - "importTypeEnteEncrypted": "ente დაშიფრული ექსპორტი", "passwordForDecryptingExport": "ექსპორტის გაშიფრვის პაროლი", "passwordEmptyError": "პაროლის ველი არ შეიძლება იყოს ცარიელი", "emailVerificationToggle": "ელექტრონული ფოსტის ვერიფიკაცია", diff --git a/auth/lib/l10n/arb/app_nl.arb b/auth/lib/l10n/arb/app_nl.arb index ad1728204..2e84ae11b 100644 --- a/auth/lib/l10n/arb/app_nl.arb +++ b/auth/lib/l10n/arb/app_nl.arb @@ -78,7 +78,7 @@ "data": "Gegevens", "importCodes": "Codes importeren", "importTypePlainText": "Kale tekst", - "importTypeEnteEncrypted": "ente versleutelde export", + "importTypeEnteEncrypted": "Ente versleutelde export", "passwordForDecryptingExport": "Wachtwoord voor het decoderen van export", "passwordEmptyError": "Wachtwoord kan niet leeg zijn", "importFromApp": "Importeer codes van {appName}", @@ -115,15 +115,15 @@ "copied": "Gekopieerd", "pleaseTryAgain": "Probeer het nog eens", "existingUser": "Bestaande gebruiker", - "newUser": "Nieuw bij ente", + "newUser": "Nieuw bij Ente", "delete": "Verwijderen", "enterYourPasswordHint": "Voer je wachtwoord in", "forgotPassword": "Wachtwoord vergeten", "oops": "Oeps", "suggestFeatures": "Features voorstellen", "faq": "Veelgestelde vragen", - "faq_q_1": "Hoe veilig is ente Auth?", - "faq_a_1": "Alle codes in ente zijn versleuteld opgeslagen met end-to-end encryptie. Dit betekent dat alleen jij toegang hebt tot je codes. Onze apps zijn open source en onze cryptografie is extern gecontroleerd.", + "faq_q_1": "Hoe veilig is Ente Auth?", + "faq_a_1": "Alle codes in Auth zijn versleuteld opgeslagen met end-to-end encryptie. Dit betekent dat alleen jij toegang hebt tot je codes. Onze apps zijn open source en onze cryptografie is extern gecontroleerd.", "faq_q_2": "Kan ik toegang krijgen tot mijn codes op desktop?", "faq_a_2": "U heeft toegang tot uw codes op het web @ auth.ente.io.", "faq_q_3": "Hoe kan ik codes verwijderen?", @@ -199,6 +199,10 @@ "recoveryKeySaveDescription": "We slaan deze code niet op, bewaar deze code met 24 woorden op een veilige plaats.", "doThisLater": "Doe dit later", "saveKey": "Sleutel opslaan", + "save": "Opslaan", + "send": "Verzenden", + "saveOrSendDescription": "Wil je dit opslaan naar je opslagruimte (Downloads map) of naar andere apps versturen?", + "saveOnlyDescription": "Wil je dit opslaan naar je opslagruimte (Downloads map)?", "back": "Terug", "createAccount": "Account aanmaken", "passwordStrength": "Wachtwoord sterkte: {passwordStrengthValue}", @@ -346,7 +350,7 @@ "deleteCodeAuthMessage": "Authenticeren om code te verwijderen", "showQRAuthMessage": "Authenticeren om QR-code te tonen", "confirmAccountDeleteTitle": "Account verwijderen bevestigen", - "confirmAccountDeleteMessage": "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.", + "confirmAccountDeleteMessage": "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.", "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." @@ -407,6 +411,7 @@ "doNotSignOut": "Niet uitloggen", "hearUsWhereTitle": "Hoe hoorde je over Ente? (optioneel)", "hearUsExplanation": "Wij gebruiken geen tracking. Het zou helpen als je ons vertelt waar je ons gevonden hebt!", + "recoveryKeySaved": "Herstelsleutel opgeslagen in de Downloads map!", "waitingForBrowserRequest": "Wachten op browserverzoek...", "waitingForVerification": "Wachten op verificatie...", "passkey": "Passkey", diff --git a/auth/lib/l10n/arb/app_pl.arb b/auth/lib/l10n/arb/app_pl.arb index 3221799d0..8ebc935dc 100644 --- a/auth/lib/l10n/arb/app_pl.arb +++ b/auth/lib/l10n/arb/app_pl.arb @@ -78,13 +78,11 @@ "data": "Dane", "importCodes": "Importuj kody", "importTypePlainText": "Zwykły tekst", - "importTypeEnteEncrypted": "Zaszyfrowany eksport ente", "passwordForDecryptingExport": "Hasło do odszyfrowania eksportu", "passwordEmptyError": "Pole hasło nie może być puste", "importFromApp": "Importuj kody z {appName}", "importGoogleAuthGuide": "Wyeksportuj twoje konta z Google Authenticator do kodu QR używając opcji \"Przenieś konta\". Potem używając innego urządzenia, zeskanuj kod QR.", "importSelectJsonFile": "Wybierz plik JSON", - "importEnteEncGuide": "Wybierz zaszyfrowany plik JSON wyeksportowany z ente", "importRaivoGuide": "Użyj opcji \"Eksportuj OTP do archiwum ZIP\" w Ustawieniach Raivo.\n\nWyodrębnij plik zip i zaimportuj plik JSON.", "importAegisGuide": "Użyj opcji \"Eksportuj sejf\" w ustawieniach Aegis.\n\nJeśli twój sejf jest zaszyfrowany, musisz wprowadzić hasło sejfu, aby odszyfrować sejf.", "exportCodes": "Eksportuj kody", @@ -110,22 +108,18 @@ "copied": "Skopiowano", "pleaseTryAgain": "Proszę spróbować ponownie", "existingUser": "Istniejący użytkownik", - "newUser": "Nowy do ente", "delete": "Usuń", "enterYourPasswordHint": "Wprowadź swoje hasło", "forgotPassword": "Nie pamiętam hasła", "oops": "Ups", "suggestFeatures": "Zaproponuj funkcje", "faq": "Najczęściej zadawane pytania (FAQ)", - "faq_q_1": "Jak bezpieczny jest ente Auth?", - "faq_a_1": "Wszystkie kody, których tworzysz kopię zapasową za pomocą ente są przechowywane zaszyfrowane end-to-end. Oznacza to, że tylko Ty możesz uzyskać dostęp do swoich kodów. Nasze aplikacje są otwarto-źródłowe, a nasza kryptografia została poddana sprawdzeniu z zewnątrz.", "faq_q_2": "Czy mogę uzyskać dostęp do moich kodów na komputerze?", "faq_a_2": "Możesz uzyskać dostęp do swoich kodów na stronie auth.ente.io.", "faq_q_3": "Jak mogę usunąć kody?", "faq_a_3": "Możesz usunąć kod, przesuwając go w lewo.", "faq_q_4": "Jak mogę wesprzeć ten projekt?", "faq_a_4": "Możesz wspierać rozwój tego projektu, subskrybując do naszej aplikacji Zdjęcia na ente.io.", - "faq_q_5": "Jak mogę włączyć blokadę FaceID w ente Auth?", "faq_a_5": "Możesz włączyć blokadę FaceID w Ustawienia → Bezpieczeństwo→ Ekran blokady.", "somethingWentWrongMessage": "Coś poszło nie tak. Proszę, spróbuj ponownie", "leaveFamily": "Opuść rodzinę", @@ -338,7 +332,6 @@ "deleteCodeAuthMessage": "Uwierzytelnij, aby usunąć kod", "showQRAuthMessage": "Uwierzytelnij, aby pokazać kod QR", "confirmAccountDeleteTitle": "Potwierdź usunięcie konta", - "confirmAccountDeleteMessage": "To konto jest połączone z innymi aplikacjami ente, jeśli ich używasz.\n\nTwoje przesłane dane, we wszystkich aplikacjach ente, zostaną zaplanowane do usunięcia, a Twoje konto zostanie trwale usunięte.", "androidBiometricNotRecognized": "Nie rozpoznano. Spróbuj ponownie.", "@androidBiometricNotRecognized": { "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters." diff --git a/auth/lib/l10n/arb/app_pt.arb b/auth/lib/l10n/arb/app_pt.arb index e6ca32893..b27a018fb 100644 --- a/auth/lib/l10n/arb/app_pt.arb +++ b/auth/lib/l10n/arb/app_pt.arb @@ -78,14 +78,14 @@ "data": "Dados", "importCodes": "Importar códigos", "importTypePlainText": "Texto simples", - "importTypeEnteEncrypted": "ente Exportação criptografada", + "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 pelo ente", + "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.", @@ -115,22 +115,22 @@ "copied": "Copiado", "pleaseTryAgain": "Por favor, tente novamente", "existingUser": "Usuário Existente", - "newUser": "Novo no ente", + "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 ente Auth?", - "faq_a_1": "Todos os códigos que você faz cópia de segurança via ente 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_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 ente Auth", + "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", @@ -199,6 +199,10 @@ "recoveryKeySaveDescription": "Não armazenamos essa chave, por favor, salve essa chave de 24 palavras em um lugar seguro.", "doThisLater": "Fazer isso mais tarde", "saveKey": "Salvar chave", + "save": "Salvar", + "send": "Enviar", + "saveOrSendDescription": "Você deseja salvar isso no seu armazenamento (pasta de downloads por padrão) ou enviá-lo para outros aplicativos?", + "saveOnlyDescription": "Você deseja salvar isto no seu armazenamento (pasta de downloads por padrão)?", "back": "Voltar", "createAccount": "Criar uma conta", "passwordStrength": "Força da senha: {passwordStrengthValue}", @@ -346,7 +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.", + "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." @@ -407,6 +411,7 @@ "doNotSignOut": "Não encerrar sessão", "hearUsWhereTitle": "Como você ouviu sobre o Ente? (opcional)", "hearUsExplanation": "Não rastreamos instalações do aplicativo. Seria útil se você nos contasse onde nos encontrou!", + "recoveryKeySaved": "Chave de recuperação salva na pasta Downloads!", "waitingForBrowserRequest": "Aguardando solicitação do navegador...", "waitingForVerification": "Esperando por verificação...", "passkey": "Chave de acesso", diff --git a/auth/lib/l10n/arb/app_ru.arb b/auth/lib/l10n/arb/app_ru.arb index afbfb297d..7ae37a87b 100644 --- a/auth/lib/l10n/arb/app_ru.arb +++ b/auth/lib/l10n/arb/app_ru.arb @@ -78,14 +78,12 @@ "data": "Данные", "importCodes": "Импортировать коды", "importTypePlainText": "Обычный текст", - "importTypeEnteEncrypted": "ente Зашифрованный экспорт", "passwordForDecryptingExport": "Пароль для расшифровки экспорта", "passwordEmptyError": "Пароль не может быть пустым", "importFromApp": "Импорт кодов из {appName}", "importGoogleAuthGuide": "Экспортируйте учетные записи из Google Authenticator в QR-код, используя опцию «Перенести учетные записи». Затем с помощью другого устройства отсканируйте QR-код.\n\nСовет: Чтобы сфотографировать QR-код, можно воспользоваться веб-камерой ноутбука.", "importSelectJsonFile": "Выбрать JSON-файл", "importSelectAppExport": "Выбрать файл экспорта {appName}", - "importEnteEncGuide": "Выберите зашифрованный JSON-файл, экспортированный из ente", "importRaivoGuide": "Используйте опцию «Export OTPs to Zip archive» в настройках Raivo.\n\nРаспакуйте zip-архив и импортируйте JSON-файл.", "importBitwardenGuide": "Используйте опцию \"Экспортировать хранилище\" в Bitwarden Tools и импортируйте незашифрованный JSON файл.", "importAegisGuide": "Используйте опцию «Экспортировать хранилище» в настройках Aegis.\n\nЕсли ваше хранилище зашифровано, то для его расшифровки потребуется ввести пароль хранилища.", @@ -113,22 +111,18 @@ "copied": "Скопировано", "pleaseTryAgain": "Пожалуйста, попробуйте ещё раз", "existingUser": "Существующий пользователь", - "newUser": "Новый аккаунт", "delete": "Удалить", "enterYourPasswordHint": "Введите пароль", "forgotPassword": "Забыл пароль", "oops": "Ой", "suggestFeatures": "Предложить идеи", "faq": "FAQ", - "faq_q_1": "Насколько безопасен ente Auth?", - "faq_a_1": "Все коды, которые вы резервируете с помощью ente, хранятся в зашифрованном виде. Это означает, что только вы можете получить доступ к своим кодам. Наши приложения имеют открытый исходный код, а наша криптография прошла внешний аудит.", "faq_q_2": "Могу ли я получить доступ к моим кодам на компьютере?", "faq_a_2": "Вы можете получить доступ к своим кодам на сайте @ auth.ente.io.", "faq_q_3": "Как я могу удалить коды?", "faq_a_3": "Вы можете удалить код, проведя пальцем влево по этому элементу.", "faq_q_4": "Как я могу поддержать этот проект?", "faq_a_4": "Вы можете поддержать развитие этого проекта, подписавшись на наше приложение Photos @ ente.io.", - "faq_q_5": "Как мне включить блокировку FaceID в ente Auth?", "faq_a_5": "Вы можете включить блокировку FaceID в Настройки → Безопасность → Экран блокировки.", "somethingWentWrongMessage": "Что-то пошло не так. Попробуйте еще раз", "leaveFamily": "Покинуть семью", @@ -342,7 +336,6 @@ "deleteCodeAuthMessage": "Аутентификация для удаления кода", "showQRAuthMessage": "Аутентификация для отображения QR-кода", "confirmAccountDeleteTitle": "Подтвердить удаление аккаунта", - "confirmAccountDeleteMessage": "Эта учетная запись связана с другими приложениями ente, если вы ими пользуетесь.\n\nЗагруженные вами данные во всех приложениях ente будут запланированы к удалению, а ваша учетная запись будет удалена без возможности восстановления.", "androidBiometricHint": "Подтвердите личность", "@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 d99ed5f5f..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", @@ -73,7 +74,6 @@ "oops": "Hoppsan", "suggestFeatures": "Föreslå funktionalitet", "faq": "FAQ", - "faq_q_1": "Hur säkert är ente Auth?", "scan": "Skanna", "twoFactorAuthTitle": "Tvåfaktorsautentisering", "enterRecoveryKeyHint": "Ange din återställningsnyckel", @@ -105,6 +105,7 @@ "recoveryKeyCopiedToClipboard": "Återställningsnyckel kopierad till urklipp", "recoveryKeyOnForgotPassword": "Om du glömmer ditt lösenord är det enda sättet du kan återställa dina data med denna nyckel.", "saveKey": "Spara nyckel", + "save": "Spara", "back": "Tillbaka", "createAccount": "Skapa konto", "password": "Lösenord", diff --git a/auth/lib/l10n/arb/app_ti.arb b/auth/lib/l10n/arb/app_ti.arb index f8d2b95d1..27147ebb6 100644 --- a/auth/lib/l10n/arb/app_ti.arb +++ b/auth/lib/l10n/arb/app_ti.arb @@ -78,14 +78,12 @@ "data": "ሓበሬታ", "importCodes": "ኮድ ኣእቱ", "importTypePlainText": "Plain text", - "importTypeEnteEncrypted": "ente Encrypted export", "passwordForDecryptingExport": "Password to decrypt export", "passwordEmptyError": "Password can not be empty", "importFromApp": "Import codes from {appName}", "importGoogleAuthGuide": "Export your accounts from Google Authenticator to a QR code using the \"Transfer Accounts\" option. Then using another device, scan the QR code.\n\nTip: You can use your laptop's webcam to take a picture of the QR code.", "importSelectJsonFile": "Select JSON file", "importSelectAppExport": "Select {appName} export file", - "importEnteEncGuide": "Select the encrypted JSON file exported from ente", "importRaivoGuide": "Use the \"Export OTPs to Zip archive\" option in Raivo's Settings.\n\nExtract the zip file and import the JSON file.", "importBitwardenGuide": "Use the \"Export vault\" option within Bitwarden Tools and import the unencrypted JSON file.", "importAegisGuide": "Use the \"Export the vault\" option in Aegis's Settings.\n\nIf your vault is encrypted, you will need to enter vault password to decrypt the vault.", @@ -114,22 +112,18 @@ "copied": "Copied", "pleaseTryAgain": "Please try again", "existingUser": "Existing User", - "newUser": "New to ente", "delete": "Delete", "enterYourPasswordHint": "Enter your password", "forgotPassword": "Forgot password", "oops": "ዉዉኡ", "suggestFeatures": "Suggest features", "faq": "FAQ", - "faq_q_1": "How secure is ente Auth?", - "faq_a_1": "All codes you backup via ente is stored end-to-end encrypted. This means only you can access your codes. Our apps are open source and our cryptography has been externally audited.", "faq_q_2": "Can I access my codes on desktop?", "faq_a_2": "You can access your codes on the web @ auth.ente.io.", "faq_q_3": "How can I delete codes?", "faq_a_3": "You can delete a code by swiping left on that item.", "faq_q_4": "How can I support this project?", "faq_a_4": "You can support the development of this project by subscribing to our Photos app @ ente.io.", - "faq_q_5": "How can I enable FaceID lock in ente Auth", "faq_a_5": "You can enable FaceID lock under Settings → Security → Lockscreen.", "somethingWentWrongMessage": "Something went wrong, please try again", "leaveFamily": "Leave family", @@ -343,7 +337,6 @@ "deleteCodeAuthMessage": "Authenticate to delete code", "showQRAuthMessage": "Authenticate to show QR code", "confirmAccountDeleteTitle": "Confirm account deletion", - "confirmAccountDeleteMessage": "This account is linked to other ente apps, if you use any.\n\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted.", "androidBiometricHint": "Verify identity", "@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_tr.arb b/auth/lib/l10n/arb/app_tr.arb index 4564abae1..9b847faf0 100644 --- a/auth/lib/l10n/arb/app_tr.arb +++ b/auth/lib/l10n/arb/app_tr.arb @@ -78,14 +78,12 @@ "data": "Veri", "importCodes": "Kodu içe aktar", "importTypePlainText": "Salt metin", - "importTypeEnteEncrypted": "ente Şifreli dışa aktarma", "passwordForDecryptingExport": "Dışa aktarımın şifresini çözmek için parola", "passwordEmptyError": "Şifre boş olamaz", "importFromApp": "Kodları {appName} uygulamasından içe aktarın", "importGoogleAuthGuide": "\"Hesapları Aktar\" seçeneğini kullanarak hesaplarınızı Google Authenticator'dan bir QR koduna aktarın. Ardından başka bir cihaz kullanarak QR kodunu tarayın.\n\nİpucu: QR kodunun fotoğrafını çekmek için dizüstü bilgisayarınızın kamerasını kullanabilirsiniz.", "importSelectJsonFile": "JSON dosyasını seçin", "importSelectAppExport": "{appName} dışarı aktarma dosyasını seçin", - "importEnteEncGuide": "Ente'den dışa aktarılan şifrelenmiş JSON dosyasını seçin", "importRaivoGuide": "Raivo'nun ayarlarında \"OTP'leri Zip arşivine aktar\" seçeneğini kullanın.\n\nZip dosyasını çıkarın ve JSON dosyasını içe aktarın.", "importBitwardenGuide": "Bitwarden Tools içindeki \"Kasayı dışa aktar\" seçeneğini kullanın ve şifrelenmemiş JSON dosyasını içe aktarın.", "importAegisGuide": "Aegis'in Ayarlarında \"Kasayı dışa aktar\" seçeneğini kullanın.\n\nKasanız şifrelenmişse, kasanın şifresini çözmek için kasa parolasını girmeniz gerekecektir.", @@ -115,22 +113,18 @@ "copied": "Kopyalandı", "pleaseTryAgain": "Lütfen tekrar deneyin", "existingUser": "Mevcut kullanıcı", - "newUser": "Yeni ente kullanıcısı", "delete": "Sil", "enterYourPasswordHint": "Parolanızı girin", "forgotPassword": "Şifremi unuttum", "oops": "Hay aksi", "suggestFeatures": "Özellik önerin", "faq": "SSS", - "faq_q_1": "Ente Auth ne kadar güvenli?", - "faq_a_1": "Ente aracılığıyla yedeklediğiniz tüm kodlar uçtan uca şifrelenmiş olarak saklanır. Bu, kodlarınıza yalnızca sizin erişebileceğiniz anlamına gelir. Uygulamalarımız açık kaynaklıdır ve kriptografimiz harici olarak denetlenmiştir.", "faq_q_2": "Kodlarıma masaüstünden erişebilir miyim?", "faq_a_2": "Kodlarınıza internet üzerinden @ auth.ente.io adresinden erişebilirsiniz.", "faq_q_3": "Kodları nasıl silebilirim?", "faq_a_3": "Bir kodu, o öğenin üzerinde sola kaydırarak silebilirsiniz.", "faq_q_4": "Bu projeye nasıl destek olabilirim?", "faq_a_4": "Fotoğraflar uygulamamıza @ ente.io abone olarak bu projenin geliştirilmesine destek olabilirsiniz.", - "faq_q_5": "FaceID kilidini ente Auth'ta nasıl etkinleştirebilirim", "faq_a_5": "FaceID kilidini Ayarlar → Güvenlik → Kilit Ekranı altında etkinleştirebilirsiniz.", "somethingWentWrongMessage": "Bir şeyler ters gitti, lütfen tekrar deneyin", "leaveFamily": "Aile planından ayrıl", @@ -344,7 +338,6 @@ "deleteCodeAuthMessage": "Kodu silmek için doğrulama yapın", "showQRAuthMessage": "QR kodunu göstermek için doğrulama yapın", "confirmAccountDeleteTitle": "Hesap silme işlemini onayla", - "confirmAccountDeleteMessage": "Bu hesap, eğer kullanıyorsanız, diğer ente uygulamalarıyla bağlantılıdır.\n\nTüm ente uygulamalarında yüklediğiniz veriler silinmek üzere programlanacak ve hesabınız kalıcı olarak silinecektir.", "androidBiometricHint": "Kimliği doğrula", "@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_vi.arb b/auth/lib/l10n/arb/app_vi.arb index 61747181d..e318f9b55 100644 --- a/auth/lib/l10n/arb/app_vi.arb +++ b/auth/lib/l10n/arb/app_vi.arb @@ -78,14 +78,12 @@ "data": "Dữ liệu", "importCodes": "Nhập mã", "importTypePlainText": "Văn bản thuần", - "importTypeEnteEncrypted": "xuất ente đã mã hóa", "passwordForDecryptingExport": "Mật khẩu để giải mã xuất", "passwordEmptyError": "Mật khẩu không thể để trống", "importFromApp": "Nhập mã từ {appName}", "importGoogleAuthGuide": "Xuất dữ liệu tài khoản của bạn từ Google Authenticator sang mã QR bằng tùy chọn \"Chuyển tài khoản\". Sau đó dùng thiết bị khác quét mã QR.", "importSelectJsonFile": "Chọn tệp JSON", "importSelectAppExport": "Chọn {appName} tệp dữ liệu xuất", - "importEnteEncGuide": "Chọn tệp JSON được mã hóa đã xuất từ ​​ente", "importRaivoGuide": "Sử dụng tùy chọn \"Xuất OTP sang lưu trữ Zip\" trong cài đặt của Raivo.", "importBitwardenGuide": "Sử dụng tùy chọn \"Xuất vault\" trong công cụ Bitwarden và nhập tệp JSON không được mã hóa.", "importAegisGuide": "Nếu vault của bạn được mã hóa, bạn sẽ cần nhập mật khẩu vault để giải mã vault.", @@ -114,22 +112,18 @@ "copied": "\u001dĐã sao chép", "pleaseTryAgain": "Vui lòng thử lại", "existingUser": "Người dùng hiện tại", - "newUser": "Mới tham gia ente", "delete": "Xóa", "enterYourPasswordHint": "Nhập mật khẩu của bạn", "forgotPassword": "Quên mật khẩu", "oops": "Rất tiếc", "suggestFeatures": "Tính năng đề nghị", "faq": "Câu hỏi thường gặp", - "faq_q_1": "Mức độ an toàn của ente Auth như thế nào?", - "faq_a_1": "Tất cả các mã bạn sao lưu qua ente đều được lưu trữ dưới dạng mã hóa đầu cuối. Điều này có nghĩa là chỉ bạn mới có thể truy cập mã của mình. Ứng dụng của chúng tôi là nguồn mở và mật mã của chúng tôi đã được kiểm toán độc lập.", "faq_q_2": "Tôi có thể truy cập mã của mình trên máy tính không?", "faq_a_2": "Bạn có thể truy cập mã của mình trên web @ auth.ente.io.", "faq_q_3": "Làm cách nào để xóa mã?", "faq_a_3": "Bạn có thể xóa mã bằng cách vuốt sang trái vào mục đó.", "faq_q_4": "Tôi có thể hỗ trợ dự án này như thế nào?", "faq_a_4": "Bạn có thể hỗ trợ sự phát triển của dự án này bằng cách đăng ký ứng dụng Ảnh @ ente.io của chúng tôi.", - "faq_q_5": "Tôi có thể bật khóa FaceID trong ente Auth như thế nào", "faq_a_5": "Bạn có thể bật khóa FaceID trong Cài đặt → Bảo mật → Màn hình khóa.", "somethingWentWrongMessage": "Phát hiện có lỗi, xin thử lại", "leaveFamily": "Rời khỏi gia đình", @@ -343,7 +337,6 @@ "deleteCodeAuthMessage": "Xác minh để xóa mã", "showQRAuthMessage": "Xác minh để hiển thị mã QR", "confirmAccountDeleteTitle": "Xác nhận xóa tài khoản", - "confirmAccountDeleteMessage": "Tài khoản này được liên kết với các ứng dụng ente khác, nếu bạn sử dụng bất kỳ ứng dụng nào.\n\nDữ liệu đã tải lên của bạn, trên tất cả các ứng dụng, sẽ bị lên lịch xóa và tài khoản của bạn sẽ bị xóa vĩnh viễn.", "androidBiometricHint": "Xác định danh tính", "@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_zh.arb b/auth/lib/l10n/arb/app_zh.arb index 5328a14b9..077ee26fd 100644 --- a/auth/lib/l10n/arb/app_zh.arb +++ b/auth/lib/l10n/arb/app_zh.arb @@ -78,14 +78,14 @@ "data": "数据", "importCodes": "导入代码", "importTypePlainText": "纯文本", - "importTypeEnteEncrypted": "ente 加密导出", + "importTypeEnteEncrypted": "Ente 加密导出", "passwordForDecryptingExport": "用来解密导出的密码", "passwordEmptyError": "密码不能为空", "importFromApp": "从 {appName} 导入代码", "importGoogleAuthGuide": "使用“转移帐户”选项将您的帐户从 Google 身份验证器导出到二维码。然后使用另一台设备扫描二维码。\n\n提示:您可以使用笔记本电脑的网络摄像头拍摄二维码的照片。", "importSelectJsonFile": "选择 JSON 文件", "importSelectAppExport": "选择 {appName} 的导出文件", - "importEnteEncGuide": "选择从ente导出的加密JSON文件", + "importEnteEncGuide": "选择从 Ente 导出的 JSON 加密文件", "importRaivoGuide": "使用 Raivo 设置中的“将 OTP 导出到 Zip 存档”选项。\n\n解压 zip 文件并导入 JSON 文件。", "importBitwardenGuide": "使用 Bitwarden 工具中的“导出保管库”选项并导入未加密的 JSON 文件。", "importAegisGuide": "在Aegis的设置中使用\"导出密码库\"选项。\n\n如果您的密码库已加密,您需要输入密码才能解密密码库。", @@ -115,22 +115,22 @@ "copied": "已复制", "pleaseTryAgain": "请重试", "existingUser": "已注册用户", - "newUser": "刚来到ente", + "newUser": "初来 Ente", "delete": "删除", "enterYourPasswordHint": "输入您的密码", "forgotPassword": "忘记密码", "oops": "哎呀", "suggestFeatures": "建议新功能", "faq": "常见问题", - "faq_q_1": "ente Auth的安全程度如何?", - "faq_a_1": "您通过 ente 备份的所有代码均以端到端加密方式存储。这意味着只有您可以访问您的代码。 我们的应用程序是开源的,我们的加密技术已经过外部审计。", + "faq_q_1": "Auth 的安全性如何?", + "faq_a_1": "您通过 Auth 备份的所有代码均以端到端加密方式存储。这意味着只有您可以访问您的代码。我们的应用程序是开源的并且我们的加密技术已经过外部审计。", "faq_q_2": "我可以在桌面上访问我的代码吗?", "faq_a_2": "您可以在 web @auth.ente.io 上访问您的代码。", "faq_q_3": "我如何删除代码?", "faq_a_3": "您可以通过向左滑动该项目来删除该代码。", "faq_q_4": "我该如何支持该项目?", "faq_a_4": "您可以通过订阅我们的照片应用程序@ente.io来支持该项目的开发。", - "faq_q_5": "如何在 ente Auth 中启用 FaceID 锁定", + "faq_q_5": "我如何启用 Auth 中的面容 ID 锁", "faq_a_5": "您可以在“设置”→“安全”→“锁屏”下启用 FaceID 锁定。", "somethingWentWrongMessage": "出了点问题,请重试", "leaveFamily": "离开家庭", @@ -143,7 +143,7 @@ "verifyEmail": "验证电子邮件", "enterCodeHint": "从你的身份验证器应用中\n输入6位数字代码", "lostDeviceTitle": "丢失了设备吗?", - "twoFactorAuthTitle": "双因素认证", + "twoFactorAuthTitle": "双重认证", "passkeyAuthTitle": "通行密钥认证", "verifyPasskey": "验证通行密钥", "recoverAccount": "恢复账户", @@ -199,6 +199,10 @@ "recoveryKeySaveDescription": "我们不会存储此密钥,请将此24个单词密钥保存在一个安全的地方。", "doThisLater": "稍后再做", "saveKey": "保存密钥", + "save": "保存", + "send": "发送", + "saveOrSendDescription": "您想将其保存到您的内置存储(默认情况下为“下载”文件夹)还是将其发送到其他应用程序?", + "saveOnlyDescription": "您想将其保存到您的内置存储中(默认情况下为“下载”文件夹)吗?", "back": "返回", "createAccount": "创建账户", "passwordStrength": "密码强度: {passwordStrengthValue}", @@ -321,7 +325,7 @@ "emailChangedTo": "电子邮件已更改为 {newEmail}", "authenticationFailedPleaseTryAgain": "认证失败,请重试", "authenticationSuccessful": "认证成功!", - "twofactorAuthenticationSuccessfullyReset": "双因素身份验证已成功重置", + "twofactorAuthenticationSuccessfullyReset": "双重认证已成功重置", "incorrectRecoveryKey": "恢复密钥不正确", "theRecoveryKeyYouEnteredIsIncorrect": "您输入的恢复密钥不正确", "enterPassword": "输入密码", @@ -346,7 +350,7 @@ "deleteCodeAuthMessage": "删除代码需要身份验证", "showQRAuthMessage": "显示QR码需要身份验证", "confirmAccountDeleteTitle": "确认删除账户", - "confirmAccountDeleteMessage": "该账户已链接到其他ente旗下的应用程序(如果您使用任何相关的应用程序)。\n\n您在所有ente旗下应用程序中上传的数据都将被安排删除,并且您的账户将被永久删除。", + "confirmAccountDeleteMessage": "如果您使用其他 Ente 应用程序,该账户将会与其他应用程序链接。\n\n在所有 Ente 应用程序中,您上传的数据将被安排用于删除,并且您的账户将被永久删除。", "androidBiometricHint": "验证身份", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -407,6 +411,7 @@ "doNotSignOut": "不要退登", "hearUsWhereTitle": "您是如何知道Ente的? (可选的)", "hearUsExplanation": "我们不跟踪应用程序安装情况。如果您告诉我们您是在哪里找到我们的,将会有所帮助!", + "recoveryKeySaved": "恢复密钥已保存在下载文件夹中!", "waitingForBrowserRequest": "正在等待浏览器请求...", "waitingForVerification": "等待验证...", "passkey": "通行密钥", diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index ead979d7d..c3397d79a 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -51,6 +51,7 @@ class _HomePageState extends State { final scaffoldKey = GlobalKey(); final TextEditingController _textController = TextEditingController(); + final FocusNode searchInputFocusNode = FocusNode(); bool _showSearchBox = false; String _searchText = ""; List _codes = []; @@ -80,6 +81,17 @@ class _HomePageState extends State { setState(() {}); }); _showSearchBox = PreferenceService.instance.shouldAutoFocusOnSearchBar(); + if (_showSearchBox) { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + // https://github.com/flutter/flutter/issues/20706#issuecomment-646328652 + FocusScope.of(context).unfocus(); + Timer(const Duration(milliseconds: 1), () { + FocusScope.of(context).requestFocus(searchInputFocusNode); + }); + }, + ); + } } void _loadCodes() { @@ -192,6 +204,7 @@ class _HomePageState extends State { title: !_showSearchBox ? const Text('Ente Auth') : TextField( + focusNode: searchInputFocusNode, autofocus: _searchText.isEmpty, controller: _textController, onChanged: (val) { diff --git a/auth/pubspec.lock b/auth/pubspec.lock index 73b3075cb..2d61b77c3 100644 --- a/auth/pubspec.lock +++ b/auth/pubspec.lock @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.5.0" async: dependency: transitive description: @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: built_value - sha256: fedde275e0a6b798c3296963c5cd224e3e1b55d0e478d5b7e65e6b540f363a0e + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb url: "https://pub.dev" source: hosted - version: "8.9.1" + version: "8.9.2" characters: dependency: transitive description: @@ -318,10 +318,10 @@ packages: dependency: "direct main" description: name: dio - sha256: "0978e9a3e45305a80a7210dbeaf79d6ee8bee33f70c8e542dc654c952070217f" + sha256: "639179e1cc0957779e10dd5b786ce180c477c4c0aca5aaba5d1700fa2e834801" url: "https://pub.dev" source: hosted - version: "5.4.2+1" + version: "5.4.3" dotted_border: dependency: "direct main" description: @@ -586,10 +586,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: b068ffc46f82a55844acfa4fdbb61fad72fa2aef0905548419d97f0f95c456da + sha256: "8cf40eebf5dec866a6d1956ad7b4f7016e6c0cc69847ab946833b7d43743809f" url: "https://pub.dev" source: hosted - version: "2.0.17" + version: "2.0.19" flutter_secure_storage: dependency: "direct main" description: @@ -1053,18 +1053,18 @@ packages: dependency: "direct main" description: name: path_provider - sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b + sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.4" path_provider_foundation: dependency: transitive description: @@ -1133,10 +1133,10 @@ packages: dependency: "direct main" description: name: pointycastle - sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29" + sha256: "70fe966348fe08c34bf929582f1d8247d9d9408130723206472b4687227e4333" url: "https://pub.dev" source: hosted - version: "3.7.4" + version: "3.8.0" pool: dependency: transitive description: @@ -1221,18 +1221,18 @@ packages: dependency: "direct main" description: name: sentry - sha256: a460aa48568d47140dd0557410b624d344ffb8c05555107ac65035c1097cf1ad + sha256: fe99a06970b909a491b7f89d54c9b5119772e3a48a400308a6e129625b333f5b url: "https://pub.dev" source: hosted - version: "7.18.0" + version: "7.19.0" sentry_flutter: dependency: "direct main" description: name: sentry_flutter - sha256: "3d0d1d4e0e407d276ae8128d123263ccbc37e988bae906765efd6f37d544f4c6" + sha256: fc013d4a753447320f62989b1871fdc1f20c77befcc8be3e38774dd7402e7a62 url: "https://pub.dev" source: hosted - version: "7.18.0" + version: "7.19.0" share_plus: dependency: "direct main" description: @@ -1253,18 +1253,18 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" shared_preferences_foundation: dependency: transitive description: @@ -1387,10 +1387,10 @@ packages: description: path: sqflite ref: HEAD - resolved-ref: "075b3e2f81e691a19a500e7ff6db2953de7f83a9" + resolved-ref: f281785e12e8b1abf2f9d41a587fc83d810724cf url: "https://github.com/tekartik/sqflite" source: git - version: "2.3.2" + version: "2.3.3" sqflite_common: dependency: transitive description: @@ -1411,10 +1411,10 @@ packages: dependency: "direct main" description: name: sqlite3 - sha256: "072128763f1547e3e9b4735ce846bfd226d68019ccda54db4cd427b12dfdedc9" + sha256: "1abbeb84bf2b1a10e5e1138c913123c8aa9d83cd64e5f9a0dd847b3c83063202" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.2" sqlite3_flutter_libs: dependency: "direct main" description: @@ -1515,10 +1515,10 @@ packages: dependency: "direct main" description: name: tray_manager - sha256: "4ab709d70a4374af172f8c39e018db33a4271265549c6fc9d269a65e5f4b0225" + sha256: e0ac9a88b2700f366b8629b97e8663b6ef450a2f169560a685dc167bfe9c9c29 url: "https://pub.dev" source: hosted - version: "0.2.1" + version: "0.2.2" tuple: dependency: "direct main" description: @@ -1547,18 +1547,18 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "0ecc004c62fd3ed36a2ffcbe0dd9700aee63bd7532d0b642a488b1ec310f492e" + sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" url: "https://pub.dev" source: hosted - version: "6.2.5" + version: "6.2.6" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: d4ed0711849dd8e33eb2dd69c25db0d0d3fdc37e0a62e629fe32f57a22db2745 + sha256: "360a6ed2027f18b73c8d98e159dda67a61b7f2e0f6ec26e86c3ada33b0621775" url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.1" url_launcher_ios: dependency: transitive description: @@ -1611,10 +1611,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: cd210a09f7c18cbe5a02511718e0334de6559871052c90a90c0cca46a4aa81c8 + sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" url: "https://pub.dev" source: hosted - version: "4.3.3" + version: "4.4.0" vector_graphics: dependency: transitive description: @@ -1675,18 +1675,18 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "2.4.5" win32: dependency: "direct main" description: name: win32 - sha256: "8cb58b45c47dcb42ab3651533626161d6b67a2921917d8d429791f76972b3480" + sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a" url: "https://pub.dev" source: hosted - version: "5.3.0" + version: "5.4.0" win32_registry: dependency: transitive description: diff --git a/desktop/package.json b/desktop/package.json index 16ba23eb9..69d54f75b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -11,7 +11,7 @@ "build-main:quick": "tsc && electron-builder --dir --config.compression=store --config.mac.identity=null", "build-renderer": "cd ../web && yarn install && yarn build:photos && cd ../desktop && shx rm -f out && shx ln -sf ../web/apps/photos/out out", "build:quick": "yarn build-renderer && yarn build-main:quick", - "dev": "concurrently --names 'main,rndr' \"yarn dev-main\" \"yarn dev-renderer\"", + "dev": "concurrently --kill-others --success first --names 'main,rndr' \"yarn dev-main\" \"yarn dev-renderer\"", "dev-main": "tsc && electron app/main.js", "dev-renderer": "cd ../web && yarn install && yarn dev:photos", "postinstall": "electron-builder install-app-deps", @@ -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 68b2cc51c..467d9c881 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -8,7 +8,8 @@ * * https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process */ -import { app, BrowserWindow, Menu } from "electron/main"; +import { nativeImage } from "electron"; +import { app, BrowserWindow, Menu, protocol, Tray } from "electron/main"; import serveNextAt from "next-electron-server"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; @@ -16,60 +17,47 @@ import os from "node:os"; import path from "node:path"; import { addAllowOriginHeader, - createWindow, - handleDockIconHideOnAutoLaunch, handleDownloads, handleExternalLinks, - setupMacWindowOnDockIconClick, - setupTrayItem, } from "./main/init"; import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc"; import log, { initLogging } from "./main/log"; -import { createApplicationMenu } from "./main/menu"; +import { createApplicationMenu, createTrayContextMenu } from "./main/menu"; import { setupAutoUpdater } from "./main/services/app-update"; -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"; -let appIsQuitting = false; - -let updateIsAvailable = false; - -export const isAppQuitting = (): boolean => { - return appIsQuitting; -}; - -export const setIsAppQuitting = (value: boolean): void => { - appIsQuitting = value; -}; - -export const isUpdateAvailable = (): boolean => { - return updateIsAvailable; -}; - -export const setIsUpdateAvailable = (value: boolean): void => { - updateIsAvailable = value; -}; - /** * The URL where the renderer HTML is being served from. */ -export const rendererURL = "next://app"; +export const rendererURL = "ente://app"; /** - * next-electron-server allows up to directly use the output of `next build` in - * production mode and `next dev` in development mode, whilst keeping the rest - * of our code the same. + * We want to hide our window instead of closing it when the user presses the + * cross button on the window. * - * It uses protocol handlers to serve files from the "next://app" protocol + * > This is because there is 1. a perceptible initial window creation time for + * > our app, and 2. because the long running processes like export and watch + * > folders are tied to the lifetime of the window and otherwise won't run in + * > the background. * - * - In development this is proxied to http://localhost:3000 - * - In production it serves files from the `/out` directory + * Intercepting the window close event and using that to instead hide it is + * easy, however that prevents the actual app quit to stop working (since the + * window never gets closed). * - * For more details, see this comparison: - * https://github.com/HaNdTriX/next-electron-server/issues/5 + * So to achieve our original goal (hide window instead of closing) without + * disabling expected app quits, we keep a flag, and we turn it on when we're + * part of the quit sequence. When this flag is on, we bypass the code that + * prevents the window from being closed. */ -const setupRendererServer = () => { - serveNextAt(rendererURL); +let shouldAllowWindowClose = false; + +export const allowWindowClose = (): void => { + shouldAllowWindowClose = true; }; /** @@ -87,29 +75,195 @@ const logStartupBanner = () => { log.info("Running on", { platform, osRelease, systemVersion }); }; -function enableSharedArrayBufferSupport() { - app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer"); -} +/** + * next-electron-server allows up to directly use the output of `next build` in + * production mode and `next dev` in development mode, whilst keeping the rest + * of our code the same. + * + * It uses protocol handlers to serve files from the "ente://" protocol. + * + * - In development this is proxied to http://localhost:3000 + * - In production it serves files from the `/out` directory + * + * For more details, see this comparison: + * https://github.com/HaNdTriX/next-electron-server/issues/5 + */ +const setupRendererServer = () => serveNextAt(rendererURL); + +/** + * Register privileged schemes. + * + * We have two privileged schemes: + * + * 1. "ente", used for serving our web app (@see {@link setupRendererServer}). + * + * 2. "stream", used for streaming IPC (@see {@link registerStreamProtocol}). + * + * Both of these need some privileges, however, the documentation for Electron's + * [registerSchemesAsPrivileged](https://www.electronjs.org/docs/latest/api/protocol) + * says: + * + * > This method ... can be called only once. + * + * The library we use for the "ente" scheme, next-electron-server, already calls + * it once when we invoke {@link setupRendererServer}. + * + * In practice calling it multiple times just causes the values to be + * overwritten, and the last call wins. So we don't need to modify + * next-electron-server to prevent it from calling registerSchemesAsPrivileged. + * Instead, we (a) repeat what next-electron-server had done here, and (b) + * ensure that we're called after {@link setupRendererServer}. + */ +const registerPrivilegedSchemes = () => { + protocol.registerSchemesAsPrivileged([ + { + // Taken verbatim from next-electron-server's code (index.js) + scheme: "ente", + privileges: { + standard: true, + secure: true, + allowServiceWorkers: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + { + scheme: "stream", + privileges: { + // TODO(MR): Remove the commented bits if we don't end up + // needing them by the time the IPC refactoring is done. + + // Prevent the insecure origin issues when fetching this + // secure: true, + // Allow the web fetch API in the renderer to use this scheme. + supportFetchAPI: true, + // Allow it to be used with video tags. + // stream: true, + }, + }, + ]); +}; /** * [Note: Increased disk cache for the desktop app] * * Set the "disk-cache-size" command line flag to ask the Chromium process to * use a larger size for the caches that it keeps on disk. This allows us to use - * the same web-native caching mechanism on both the web and the desktop app, - * just ask the embedded Chromium to be a bit more generous in disk usage when + * the web based caching mechanisms on both the web and the desktop app, just + * ask the embedded Chromium to be a bit more generous in disk usage when * running as the desktop app. * - * The size we provide is in bytes. We set it to a large value, 5 GB (5 * 1024 * - * 1024 * 1024 = 5368709120) + * The size we provide is in bytes. * https://www.electronjs.org/docs/latest/api/command-line-switches#--disk-cache-sizesize * * Note that increasing the disk cache size does not guarantee that Chromium * will respect in verbatim, it uses its own heuristics atop this hint. * https://superuser.com/questions/378991/what-is-chrome-default-cache-size-limit/1577693#1577693 + * + * See also: [Note: Caching files]. */ -const increaseDiskCache = () => { - app.commandLine.appendSwitch("disk-cache-size", "5368709120"); +const increaseDiskCache = () => + app.commandLine.appendSwitch( + "disk-cache-size", + `${5 * 1024 * 1024 * 1024}`, // 5 GB + ); + +/** + * Create an return the {@link BrowserWindow} that will form our app's UI. + * + * This window will show the HTML served from {@link rendererURL}. + */ +const createMainWindow = async () => { + // Create the main window. This'll show our web content. + const window = new BrowserWindow({ + webPreferences: { + preload: path.join(app.getAppPath(), "preload.js"), + sandbox: true, + }, + // The color to show in the window until the web content gets loaded. + // See: https://www.electronjs.org/docs/latest/api/browser-window#setting-the-backgroundcolor-property + backgroundColor: "black", + // We'll show it conditionally depending on `wasAutoLaunched` later. + show: false, + }); + + const wasAutoLaunched = await autoLauncher.wasAutoLaunched(); + if (wasAutoLaunched) { + // Don't automatically show the app's window if we were auto-launched. + // On macOS, also hide the dock icon on macOS. + if (process.platform == "darwin") app.dock.hide(); + } else { + // Show our window (maximizing it) otherwise. + window.maximize(); + } + + // Open the DevTools automatically when running in dev mode + if (isDev) window.webContents.openDevTools(); + + window.webContents.on("render-process-gone", (_, details) => { + log.error(`render-process-gone: ${details}`); + window.webContents.reload(); + }); + + window.webContents.on("unresponsive", () => { + log.error( + "Main window's webContents are unresponsive, will restart the renderer process", + ); + window.webContents.forcefullyCrashRenderer(); + }); + + window.on("close", (event) => { + if (!shouldAllowWindowClose) { + event.preventDefault(); + window.hide(); + } + return false; + }); + + window.on("hide", () => { + // On macOS, when hiding the window also hide the app's icon in the dock + // if the user has selected the Settings > Hide dock icon checkbox. + if (process.platform == "darwin" && userPreferences.get("hideDockIcon")) + app.dock.hide(); + }); + + window.on("show", () => { + if (process.platform == "darwin") app.dock.show(); + }); + + // Let ipcRenderer know when mainWindow is in the foreground so that it can + // in turn inform the renderer process. + window.on("focus", () => window.webContents.send("mainWindowFocus")); + + return window; +}; + +/** + * Add an icon for our app in the system tray. + * + * For example, these are the small icons that appear on the top right of the + * screen in the main menu bar on macOS. + */ +const setupTrayItem = (mainWindow: BrowserWindow) => { + // There are a total of 6 files corresponding to this tray icon. + // + // On macOS, use template images (filename needs to end with "Template.ext") + // https://www.electronjs.org/docs/latest/api/native-image#template-image-macos + // + // And for each (template or otherwise), there are 3 "retina" variants + // https://www.electronjs.org/docs/latest/api/native-image#high-resolution-image + const iconName = + process.platform == "darwin" + ? "taskbar-icon-Template.png" + : "taskbar-icon.png"; + const trayImgPath = path.join( + isDev ? "build" : process.resourcesPath, + iconName, + ); + const trayIcon = nativeImage.createFromPath(trayImgPath); + const tray = new Tray(trayIcon); + tray.setToolTip("Ente Photos"); + tray.setContextMenu(createTrayContextMenu(mainWindow)); }; /** @@ -141,12 +295,19 @@ const deleteLegacyDiskCacheDirIfExists = async () => { } }; -const attachEventHandlers = (mainWindow: BrowserWindow) => { - // Let ipcRenderer know when mainWindow is in the foreground so that it can - // in turn inform the renderer process. - mainWindow.on("focus", () => - mainWindow.webContents.send("mainWindowFocus"), - ); +/** + * 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 = () => { @@ -156,22 +317,21 @@ const main = () => { return; } - let mainWindow: BrowserWindow; + let mainWindow: BrowserWindow | undefined; initLogging(); - setupRendererServer(); logStartupBanner(); - handleDockIconHideOnAutoLaunch(); + // The order of the next two calls is important + setupRendererServer(); + registerPrivilegedSchemes(); increaseDiskCache(); - enableSharedArrayBufferSupport(); + migrateLegacyWatchStoreIfNeeded(); app.on("second-instance", () => { // Someone tried to run a second instance, we should focus our window. if (mainWindow) { mainWindow.show(); - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } + if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } }); @@ -180,21 +340,26 @@ const main = () => { // // Note that some Electron APIs can only be used after this event occurs. app.on("ready", async () => { - mainWindow = await createWindow(); - const watcher = initWatcher(mainWindow); - setupTrayItem(mainWindow); - setupMacWindowOnDockIconClick(); - Menu.setApplicationMenu(await createApplicationMenu(mainWindow)); + // Create window and prepare for renderer + mainWindow = await createMainWindow(); attachIPCHandlers(); - attachFSWatchIPCHandlers(watcher); - if (!isDev) setupAutoUpdater(mainWindow); + attachFSWatchIPCHandlers(createWatcher(mainWindow)); + registerStreamProtocol(); handleDownloads(mainWindow); handleExternalLinks(mainWindow); addAllowOriginHeader(mainWindow); - attachEventHandlers(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. @@ -202,7 +367,11 @@ const main = () => { } }); - app.on("before-quit", () => setIsAppQuitting(true)); + // This is a macOS only event. Show our window when the user activates the + // app, e.g. by clicking on its dock icon. + app.on("activate", () => mainWindow?.show()); + + app.on("before-quit", allowWindowClose); }; main(); diff --git a/desktop/src/main/fs.ts b/desktop/src/main/fs.ts index 0da89fb00..2428d3a80 100644 --- a/desktop/src/main/fs.ts +++ b/desktop/src/main/fs.ts @@ -1,133 +1,29 @@ /** * @file file system related functions exposed over the context bridge. */ -import { createWriteStream, existsSync } from "node:fs"; +import { existsSync } from "node:fs"; import fs from "node:fs/promises"; -import path from "node:path"; -import { Readable } from "node:stream"; export const fsExists = (path: string) => existsSync(path); -/** - * Write a (web) ReadableStream to a file at the given {@link filePath}. - * - * The returned promise resolves when the write completes. - * - * @param filePath The local filesystem path where the file should be written. - * @param readableStream A [web - * ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) - */ -export const writeStream = (filePath: string, readableStream: ReadableStream) => - writeNodeStream(filePath, convertWebReadableStreamToNode(readableStream)); +export const fsRename = (oldPath: string, newPath: string) => + fs.rename(oldPath, newPath); -/** - * Convert a Web ReadableStream into a Node.js ReadableStream - * - * This can be used to, for example, write a ReadableStream obtained via - * `net.fetch` into a file using the Node.js `fs` APIs - */ -const convertWebReadableStreamToNode = (readableStream: ReadableStream) => { - const reader = readableStream.getReader(); - const rs = new Readable(); - - rs._read = async () => { - try { - const result = await reader.read(); - - if (!result.done) { - rs.push(Buffer.from(result.value)); - } else { - rs.push(null); - return; - } - } catch (e) { - rs.emit("error", e); - } - }; - - return rs; -}; - -const writeNodeStream = async ( - filePath: string, - fileStream: NodeJS.ReadableStream, -) => { - const writeable = createWriteStream(filePath); - - fileStream.on("error", (error) => { - writeable.destroy(error); // Close the writable stream with an error - }); - - fileStream.pipe(writeable); - - await new Promise((resolve, reject) => { - writeable.on("finish", resolve); - writeable.on("error", async (e: unknown) => { - if (existsSync(filePath)) { - await fs.unlink(filePath); - } - reject(e); - }); - }); -}; - -/* TODO: Audit below this */ - -export const checkExistsAndCreateDir = (dirPath: string) => +export const fsMkdirIfNeeded = (dirPath: string) => fs.mkdir(dirPath, { recursive: true }); -export const saveStreamToDisk = writeStream; +export const fsRmdir = (path: string) => fs.rmdir(path); -export const saveFileToDisk = (path: string, contents: string) => - fs.writeFile(path, contents); +export const fsRm = (path: string) => fs.rm(path); -export const readTextFile = async (filePath: string) => +export const fsReadTextFile = async (filePath: string) => fs.readFile(filePath, "utf-8"); -export const moveFile = async (sourcePath: string, destinationPath: string) => { - if (!existsSync(sourcePath)) { - throw new Error("File does not exist"); - } - if (existsSync(destinationPath)) { - throw new Error("Destination file already exists"); - } - // check if destination folder exists - const destinationFolder = path.dirname(destinationPath); - await fs.mkdir(destinationFolder, { recursive: true }); - await fs.rename(sourcePath, destinationPath); -}; +export const fsWriteFile = (path: string, contents: string) => + fs.writeFile(path, contents); -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(); -}; - -export const deleteFolder = async (folderPath: string) => { - // Ensure it is folder - if (!isFolder(folderPath)) return; - - // Ensure folder is empty - const files = await fs.readdir(folderPath); - if (files.length > 0) throw new Error("Folder is not empty"); - - // rm -rf it - await fs.rmdir(folderPath); -}; - -export const rename = async (oldPath: string, newPath: string) => { - if (!existsSync(oldPath)) throw new Error("Path does not exist"); - await fs.rename(oldPath, newPath); -}; - -export const deleteFile = async (filePath: string) => { - // Ensure it exists - if (!existsSync(filePath)) return; - - // And is a file - const stat = await fs.stat(filePath); - if (!stat.isFile()) throw new Error("Path is not a file"); - - // rm it - return fs.rm(filePath); + const stat = await fs.stat(dirPath); + return stat.isDirectory(); }; diff --git a/desktop/src/main/init.ts b/desktop/src/main/init.ts index 2bea075a3..d3e9b28b4 100644 --- a/desktop/src/main/init.ts +++ b/desktop/src/main/init.ts @@ -1,102 +1,7 @@ -import { BrowserWindow, Tray, app, nativeImage, shell } from "electron"; +import { BrowserWindow, app, shell } from "electron"; import { existsSync } from "node:fs"; import path from "node:path"; -import { isAppQuitting, rendererURL } from "../main"; -import log from "./log"; -import { createTrayContextMenu } from "./menu"; -import { isPlatform } from "./platform"; -import autoLauncher from "./services/autoLauncher"; -import { getHideDockIconPreference } from "./services/userPreference"; -import { isDev } from "./util"; - -/** - * Create an return the {@link BrowserWindow} that will form our app's UI. - * - * This window will show the HTML served from {@link rendererURL}. - */ -export const createWindow = async () => { - // Create the main window. This'll show our web content. - const mainWindow = new BrowserWindow({ - webPreferences: { - preload: path.join(app.getAppPath(), "preload.js"), - }, - // The color to show in the window until the web content gets loaded. - // See: https://www.electronjs.org/docs/latest/api/browser-window#setting-the-backgroundcolor-property - backgroundColor: "black", - // We'll show it conditionally depending on `wasAutoLaunched` later. - show: false, - }); - - const wasAutoLaunched = await autoLauncher.wasAutoLaunched(); - if (wasAutoLaunched) { - // Keep the macOS dock icon hidden if we were auto launched. - if (process.platform == "darwin") app.dock.hide(); - } else { - // Show our window (maximizing it) if this is not an auto-launch on - // login. - mainWindow.maximize(); - } - - mainWindow.loadURL(rendererURL); - - // Open the DevTools automatically when running in dev mode - if (isDev) mainWindow.webContents.openDevTools(); - - mainWindow.webContents.on("render-process-gone", (_, details) => { - log.error(`render-process-gone: ${details}`); - mainWindow.webContents.reload(); - }); - - mainWindow.webContents.on("unresponsive", () => { - log.error("webContents unresponsive"); - mainWindow.webContents.forcefullyCrashRenderer(); - }); - - mainWindow.on("close", function (event) { - if (!isAppQuitting()) { - event.preventDefault(); - mainWindow.hide(); - } - return false; - }); - - mainWindow.on("hide", () => { - // On macOS, also hide the app's icon in the dock if the user has - // selected the Settings > Hide dock icon checkbox. - const shouldHideDockIcon = getHideDockIconPreference(); - if (process.platform == "darwin" && shouldHideDockIcon) { - app.dock.hide(); - } - }); - - mainWindow.on("show", () => { - if (process.platform == "darwin") app.dock.show(); - }); - - return mainWindow; -}; - -export const setupTrayItem = (mainWindow: BrowserWindow) => { - // There are a total of 6 files corresponding to this tray icon. - // - // On macOS, use template images (filename needs to end with "Template.ext") - // https://www.electronjs.org/docs/latest/api/native-image#template-image-macos - // - // And for each (template or otherwise), there are 3 "retina" variants - // https://www.electronjs.org/docs/latest/api/native-image#high-resolution-image - const iconName = - process.platform == "darwin" - ? "taskbar-icon-Template.png" - : "taskbar-icon.png"; - const trayImgPath = path.join( - isDev ? "build" : process.resourcesPath, - iconName, - ); - const trayIcon = nativeImage.createFromPath(trayImgPath); - const tray = new Tray(trayIcon); - tray.setToolTip("Ente Photos"); - tray.setContextMenu(createTrayContextMenu(mainWindow)); -}; +import { rendererURL } from "../main"; export function handleDownloads(mainWindow: BrowserWindow) { mainWindow.webContents.session.on("will-download", (_, item) => { @@ -137,23 +42,6 @@ export function getUniqueSavePath(filename: string, directory: string): string { return uniqueFileSavePath; } -export function setupMacWindowOnDockIconClick() { - app.on("activate", function () { - const windows = BrowserWindow.getAllWindows(); - // we allow only one window - windows[0].show(); - }); -} - -export async function handleDockIconHideOnAutoLaunch() { - const shouldHideDockIcon = getHideDockIconPreference(); - const wasAutoLaunched = await autoLauncher.wasAutoLaunched(); - - if (isPlatform("mac") && shouldHideDockIcon && wasAutoLaunched) { - app.dock.hide(); - } -} - function lowerCaseHeaders(responseHeaders: Record) { const headers: Record = {}; for (const key of Object.keys(responseHeaders)) { diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 180e68cdc..eab2e8b59 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -10,7 +10,12 @@ import type { FSWatcher } from "chokidar"; import { ipcMain } from "electron/main"; -import type { ElectronFile, FILE_PATH_TYPE, WatchMapping } from "../types/ipc"; +import type { + CollectionMapping, + ElectronFile, + FolderWatch, + PendingUploads, +} from "../types/ipc"; import { selectDirectory, showUploadDirsDialog, @@ -18,16 +23,14 @@ import { showUploadZipDialog, } from "./dialogs"; import { - checkExistsAndCreateDir, - deleteFile, - deleteFolder, fsExists, - isFolder, - moveFile, - readTextFile, - rename, - saveFileToDisk, - saveStreamToDisk, + fsIsDir, + fsMkdirIfNeeded, + fsReadTextFile, + fsRename, + fsRm, + fsRmdir, + fsWriteFile, } from "./fs"; import { logToDisk } from "./log"; import { @@ -51,16 +54,17 @@ import { } from "./services/store"; import { getElectronFilesFromGoogleZip, - getPendingUploads, - setToUploadCollection, - setToUploadFiles, + pendingUploads, + setPendingUploadCollection, + setPendingUploadFiles, } from "./services/upload"; import { - addWatchMapping, - getWatchMappings, - removeWatchMapping, - updateWatchMappingIgnoredFiles, - updateWatchMappingSyncedFiles, + watchAdd, + watchFindFiles, + watchGet, + watchRemove, + watchUpdateIgnoredFiles, + watchUpdateSyncedFiles, } from "./services/watch"; import { openDirectory, openLogDirectory } from "./util"; @@ -114,6 +118,28 @@ export const attachIPCHandlers = () => { ipcMain.on("skipAppUpdate", (_, version) => skipAppUpdate(version)); + // - FS + + ipcMain.handle("fsExists", (_, path) => fsExists(path)); + + ipcMain.handle("fsRename", (_, oldPath: string, newPath: string) => + fsRename(oldPath, newPath), + ); + + ipcMain.handle("fsMkdirIfNeeded", (_, dirPath) => fsMkdirIfNeeded(dirPath)); + + ipcMain.handle("fsRmdir", (_, path: string) => fsRmdir(path)); + + ipcMain.handle("fsRm", (_, path: string) => fsRm(path)); + + ipcMain.handle("fsReadTextFile", (_, path: string) => fsReadTextFile(path)); + + ipcMain.handle("fsWriteFile", (_, path: string, contents: string) => + fsWriteFile(path, contents), + ); + + ipcMain.handle("fsIsDir", (_, dirPath: string) => fsIsDir(dirPath)); + // - Conversion ipcMain.handle("convertToJPEG", (_, fileData, filename) => @@ -165,60 +191,26 @@ export const attachIPCHandlers = () => { ipcMain.handle("showUploadZipDialog", () => showUploadZipDialog()); - // - FS - - ipcMain.handle("fsExists", (_, path) => fsExists(path)); - - // - FS Legacy - - ipcMain.handle("checkExistsAndCreateDir", (_, dirPath) => - checkExistsAndCreateDir(dirPath), - ); - - ipcMain.handle( - "saveStreamToDisk", - (_, path: string, fileStream: ReadableStream) => - saveStreamToDisk(path, fileStream), - ); - - ipcMain.handle("saveFileToDisk", (_, path: string, contents: string) => - saveFileToDisk(path, contents), - ); - - ipcMain.handle("readTextFile", (_, path: string) => readTextFile(path)); - - ipcMain.handle("isFolder", (_, dirPath: string) => isFolder(dirPath)); - - ipcMain.handle("moveFile", (_, oldPath: string, newPath: string) => - moveFile(oldPath, newPath), - ); - - ipcMain.handle("deleteFolder", (_, path: string) => deleteFolder(path)); - - ipcMain.handle("deleteFile", (_, path: string) => deleteFile(path)); - - ipcMain.handle("rename", (_, oldPath: string, newPath: string) => - rename(oldPath, newPath), - ); - // - 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)); }; @@ -227,42 +219,36 @@ export const attachIPCHandlers = () => { * watch folder functionality. * * It gets passed a {@link FSWatcher} instance which it can then forward to the - * actual handlers. + * actual handlers if they need access to it to do their thing. */ export const attachFSWatchIPCHandlers = (watcher: FSWatcher) => { // - Watch - ipcMain.handle( - "addWatchMapping", - ( - _, - collectionName: string, - folderPath: string, - uploadStrategy: number, - ) => - addWatchMapping( - watcher, - collectionName, - folderPath, - uploadStrategy, - ), - ); - - ipcMain.handle("removeWatchMapping", (_, folderPath: string) => - removeWatchMapping(watcher, folderPath), - ); - - ipcMain.handle("getWatchMappings", () => getWatchMappings()); + ipcMain.handle("watchGet", () => watchGet(watcher)); ipcMain.handle( - "updateWatchMappingSyncedFiles", - (_, folderPath: string, files: WatchMapping["syncedFiles"]) => - updateWatchMappingSyncedFiles(folderPath, files), + "watchAdd", + (_, folderPath: string, collectionMapping: CollectionMapping) => + watchAdd(watcher, folderPath, collectionMapping), + ); + + ipcMain.handle("watchRemove", (_, folderPath: string) => + watchRemove(watcher, folderPath), ); ipcMain.handle( - "updateWatchMappingIgnoredFiles", - (_, folderPath: string, files: WatchMapping["ignoredFiles"]) => - updateWatchMappingIgnoredFiles(folderPath, files), + "watchUpdateSyncedFiles", + (_, syncedFiles: FolderWatch["syncedFiles"], folderPath: string) => + watchUpdateSyncedFiles(syncedFiles, folderPath), + ); + + ipcMain.handle( + "watchUpdateIgnoredFiles", + (_, ignoredFiles: FolderWatch["ignoredFiles"], folderPath: string) => + watchUpdateIgnoredFiles(ignoredFiles, folderPath), + ); + + ipcMain.handle("watchFindFiles", (_, folderPath: string) => + watchFindFiles(folderPath), ); }; diff --git a/desktop/src/main/menu.ts b/desktop/src/main/menu.ts index 7801e77ae..bd8810428 100644 --- a/desktop/src/main/menu.ts +++ b/desktop/src/main/menu.ts @@ -5,13 +5,10 @@ import { MenuItemConstructorOptions, shell, } from "electron"; -import { setIsAppQuitting } from "../main"; +import { allowWindowClose } from "../main"; import { forceCheckForAppUpdates } from "./services/app-update"; -import autoLauncher from "./services/autoLauncher"; -import { - getHideDockIconPreference, - setHideDockIconPreference, -} from "./services/userPreference"; +import autoLauncher from "./services/auto-launcher"; +import { userPreferences } from "./stores/user-preferences"; import { openLogDirectory } from "./util"; /** Create and return the entries in the app's main menu bar */ @@ -21,7 +18,7 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => { // Whenever the menu is redrawn the current value of these variables is used // to set the checked state for the various settings checkboxes. let isAutoLaunchEnabled = await autoLauncher.isEnabled(); - let shouldHideDockIcon = getHideDockIconPreference(); + let shouldHideDockIcon = userPreferences.get("hideDockIcon"); const macOSOnly = (options: MenuItemConstructorOptions[]) => process.platform == "darwin" ? options : []; @@ -39,7 +36,9 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => { }; const toggleHideDockIcon = () => { - setHideDockIconPreference(!shouldHideDockIcon); + // Persist + userPreferences.set("hideDockIcon", !shouldHideDockIcon); + // And update the in-memory state shouldHideDockIcon = !shouldHideDockIcon; }; @@ -196,7 +195,7 @@ export const createTrayContextMenu = (mainWindow: BrowserWindow) => { }; const handleClose = () => { - setIsAppQuitting(true); + allowWindowClose(); app.quit(); }; 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 272a1ca6c..a3f4d3bed 100644 --- a/desktop/src/main/services/app-update.ts +++ b/desktop/src/main/services/app-update.ts @@ -1,11 +1,11 @@ import { compareVersions } from "compare-versions"; -import { app, BrowserWindow } from "electron"; import { default as electronLog } from "electron-log"; import { autoUpdater } from "electron-updater"; -import { setIsAppQuitting, setIsUpdateAvailable } from "../../main"; -import { AppUpdateInfo } from "../../types/ipc"; +import { app, BrowserWindow } from "electron/main"; +import { allowWindowClose } from "../../main"; +import { AppUpdate } from "../../types/ipc"; import log from "../log"; -import { userPreferencesStore } from "../stores/user-preferences"; +import { userPreferences } from "../stores/user-preferences"; export const setupAutoUpdater = (mainWindow: BrowserWindow) => { autoUpdater.logger = electronLog; @@ -20,8 +20,8 @@ export const setupAutoUpdater = (mainWindow: BrowserWindow) => { * Check for app update check ignoring any previously saved skips / mutes. */ export const forceCheckForAppUpdates = (mainWindow: BrowserWindow) => { - userPreferencesStore.delete("skipAppVersion"); - userPreferencesStore.delete("muteUpdateNotificationVersion"); + userPreferences.delete("skipAppVersion"); + userPreferences.delete("muteUpdateNotificationVersion"); checkForUpdatesAndNotify(mainWindow); }; @@ -41,21 +41,19 @@ const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => { return; } - if (version === userPreferencesStore.get("skipAppVersion")) { + if (version === userPreferences.get("skipAppVersion")) { log.info(`User chose to skip version ${version}`); return; } - const mutedVersion = userPreferencesStore.get( - "muteUpdateNotificationVersion", - ); + const mutedVersion = userPreferences.get("muteUpdateNotificationVersion"); if (version === mutedVersion) { log.info(`User has muted update notifications for version ${version}`); 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(); @@ -74,8 +72,6 @@ const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => { log.error("Auto update failed", error); showUpdateDialog({ autoUpdatable: false, version }); }); - - setIsUpdateAvailable(true); }; /** @@ -87,12 +83,12 @@ export const appVersion = () => `v${app.getVersion()}`; export const updateAndRestart = () => { log.info("Restarting the app to apply update"); - setIsAppQuitting(true); + allowWindowClose(); autoUpdater.quitAndInstall(); }; export const updateOnNextRestart = (version: string) => - userPreferencesStore.set("muteUpdateNotificationVersion", version); + userPreferences.set("muteUpdateNotificationVersion", version); export const skipAppUpdate = (version: string) => - userPreferencesStore.set("skipAppVersion", version); + userPreferences.set("skipAppVersion", 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/ffmpeg.ts b/desktop/src/main/services/ffmpeg.ts index e9639a26f..2597bae60 100644 --- a/desktop/src/main/services/ffmpeg.ts +++ b/desktop/src/main/services/ffmpeg.ts @@ -2,8 +2,8 @@ import pathToFfmpeg from "ffmpeg-static"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import { ElectronFile } from "../../types/ipc"; -import { writeStream } from "../fs"; import log from "../log"; +import { writeStream } from "../stream"; import { generateTempFilePath, getTempDirPath } from "../temp"; import { execAsync } from "../util"; diff --git a/desktop/src/main/services/fs.ts b/desktop/src/main/services/fs.ts index 7a29d581b..30ccf146b 100644 --- a/desktop/src/main/services/fs.ts +++ b/desktop/src/main/services/fs.ts @@ -91,19 +91,6 @@ export async function getElectronFile(filePath: string): Promise { }; } -export const getValidPaths = (paths: string[]) => { - if (!paths) { - return [] as string[]; - } - return paths.filter(async (path) => { - try { - await fs.stat(path).then((stat) => stat.isFile()); - } catch (e) { - return false; - } - }); -}; - export const getZipFileStream = async ( zip: StreamZip.StreamZipAsync, filePath: string, diff --git a/desktop/src/main/services/imageProcessor.ts b/desktop/src/main/services/imageProcessor.ts index 890e0e634..f636c153a 100644 --- a/desktop/src/main/services/imageProcessor.ts +++ b/desktop/src/main/services/imageProcessor.ts @@ -2,9 +2,8 @@ import { existsSync } from "fs"; import fs from "node:fs/promises"; import path from "path"; import { CustomErrors, ElectronFile } from "../../types/ipc"; -import { writeStream } from "../fs"; import log from "../log"; -import { isPlatform } from "../platform"; +import { writeStream } from "../stream"; import { generateTempFilePath } from "../temp"; import { execAsync, isDev } from "../util"; import { deleteTempFile } from "./ffmpeg"; @@ -67,19 +66,15 @@ const IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [ OUTPUT_PATH_PLACEHOLDER, ]; -function getImageMagickStaticPath() { - return isDev - ? "resources/image-magick" - : path.join(process.resourcesPath, "image-magick"); -} +const imageMagickStaticPath = () => + path.join(isDev ? "build" : process.resourcesPath, "image-magick"); export async function convertToJPEG( fileData: Uint8Array, filename: string, ): Promise { - if (isPlatform("windows")) { + if (process.platform == "win32") throw Error(CustomErrors.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED); - } const convertedFileData = await convertToJPEG_(fileData, filename); return convertedFileData; } @@ -126,7 +121,7 @@ function constructConvertCommand( tempOutputFilePath: string, ) { let convertCmd: string[]; - if (isPlatform("mac")) { + if (process.platform == "darwin") { convertCmd = SIPS_HEIC_CONVERT_COMMAND_TEMPLATE.map((cmdPart) => { if (cmdPart === INPUT_PATH_PLACEHOLDER) { return tempInputFilePath; @@ -136,11 +131,11 @@ function constructConvertCommand( } return cmdPart; }); - } else if (isPlatform("linux")) { + } else if (process.platform == "linux") { convertCmd = IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE.map( (cmdPart) => { if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) { - return getImageMagickStaticPath(); + return imageMagickStaticPath(); } if (cmdPart === INPUT_PATH_PLACEHOLDER) { return tempInputFilePath; @@ -165,11 +160,10 @@ export async function generateImageThumbnail( let inputFilePath = null; let createdTempInputFile = null; try { - if (isPlatform("windows")) { + if (process.platform == "win32") throw Error( CustomErrors.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED, ); - } if (!existsSync(inputFile.path)) { const tempFilePath = await generateTempFilePath(inputFile.name); await writeStream(tempFilePath, await inputFile.stream()); @@ -240,7 +234,7 @@ function constructThumbnailGenerationCommand( quality: number, ) { let thumbnailGenerationCmd: string[]; - if (isPlatform("mac")) { + if (process.platform == "darwin") { thumbnailGenerationCmd = SIPS_THUMBNAIL_GENERATE_COMMAND_TEMPLATE.map( (cmdPart) => { if (cmdPart === INPUT_PATH_PLACEHOLDER) { @@ -258,11 +252,11 @@ function constructThumbnailGenerationCommand( return cmdPart; }, ); - } else if (isPlatform("linux")) { + } else if (process.platform == "linux") { thumbnailGenerationCmd = IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE.map((cmdPart) => { if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) { - return getImageMagickStaticPath(); + return imageMagickStaticPath(); } if (cmdPart === INPUT_PATH_PLACEHOLDER) { return inputFilePath; diff --git a/desktop/src/main/services/ml-clip.ts b/desktop/src/main/services/ml-clip.ts index 63fa75148..46af2552b 100644 --- a/desktop/src/main/services/ml-clip.ts +++ b/desktop/src/main/services/ml-clip.ts @@ -11,8 +11,8 @@ 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 "../fs"; import log from "../log"; +import { writeStream } from "../stream"; import { generateTempFilePath } from "../temp"; import { deleteTempFile } from "./ffmpeg"; import { diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 10402db21..60e8241e1 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -15,8 +15,8 @@ import { existsSync } from "fs"; import fs from "node:fs/promises"; import path from "node:path"; import * as ort from "onnxruntime-node"; -import { writeStream } from "../fs"; import log from "../log"; +import { writeStream } from "../stream"; /** * Download the model named {@link modelName} if we don't already have it. 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/userPreference.ts b/desktop/src/main/services/userPreference.ts deleted file mode 100644 index c20657aa9..000000000 --- a/desktop/src/main/services/userPreference.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { userPreferencesStore } from "../stores/user-preferences"; - -export function getHideDockIconPreference() { - return userPreferencesStore.get("hideDockIcon"); -} - -export function setHideDockIconPreference(shouldHideDockIcon: boolean) { - userPreferencesStore.set("hideDockIcon", shouldHideDockIcon); -} 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 396e7a86c..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", }, @@ -18,7 +18,7 @@ const userPreferencesSchema: Schema = { }, }; -export const userPreferencesStore = new Store({ +export const userPreferences = new Store({ name: "userPreferences", schema: userPreferencesSchema, }); 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/stream.ts b/desktop/src/main/stream.ts new file mode 100644 index 000000000..8ddb80dc6 --- /dev/null +++ b/desktop/src/main/stream.ts @@ -0,0 +1,116 @@ +/** + * @file stream data to-from renderer using a custom protocol handler. + */ +import { protocol } from "electron/main"; +import { createWriteStream, existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import { Readable } from "node:stream"; +import log from "./log"; + +/** + * Register a protocol handler that we use for streaming large files between the + * main process (node) and the renderer process (browser) layer. + * + * [Note: IPC streams] + * + * When running without node integration, there is no direct way to pass streams + * across IPC. And passing the entire contents of the file is not feasible for + * large video files because of the memory pressure the copying would entail. + * + * As an alternative, we register a custom protocol handler that can provided a + * bi-directional stream. The renderer can stream data to the node side by + * streaming the request. The node side can stream to the renderer side by + * streaming the response. + * + * See also: [Note: Transferring large amount of data over IPC] + * + * Depends on {@link registerPrivilegedSchemes}. + */ +export const registerStreamProtocol = () => { + protocol.handle("stream", async (request: Request) => { + const url = request.url; + const { host, pathname } = new URL(url); + // Convert e.g. "%20" to spaces. + const path = decodeURIComponent(pathname); + switch (host) { + /* stream://write/path/to/file */ + /* host-pathname----- */ + case "write": + try { + await writeStream(path, request.body); + return new Response("", { status: 200 }); + } catch (e) { + log.error(`Failed to write stream for ${url}`, e); + return new Response( + `Failed to write stream: ${e.message}`, + { status: 500 }, + ); + } + default: + return new Response("", { status: 404 }); + } + }); +}; + +/** + * Write a (web) ReadableStream to a file at the given {@link filePath}. + * + * The returned promise resolves when the write completes. + * + * @param filePath The local filesystem path where the file should be written. + * @param readableStream A [web + * ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) + */ +export const writeStream = (filePath: string, readableStream: ReadableStream) => + writeNodeStream(filePath, convertWebReadableStreamToNode(readableStream)); + +/** + * Convert a Web ReadableStream into a Node.js ReadableStream + * + * This can be used to, for example, write a ReadableStream obtained via + * `net.fetch` into a file using the Node.js `fs` APIs + */ +const convertWebReadableStreamToNode = (readableStream: ReadableStream) => { + const reader = readableStream.getReader(); + const rs = new Readable(); + + rs._read = async () => { + try { + const result = await reader.read(); + + if (!result.done) { + rs.push(Buffer.from(result.value)); + } else { + rs.push(null); + return; + } + } catch (e) { + rs.emit("error", e); + } + }; + + return rs; +}; + +const writeNodeStream = async ( + filePath: string, + fileStream: NodeJS.ReadableStream, +) => { + const writeable = createWriteStream(filePath); + + fileStream.on("error", (error) => { + writeable.destroy(error); // Close the writable stream with an error + }); + + fileStream.pipe(writeable); + + await new Promise((resolve, reject) => { + writeable.on("finish", resolve); + writeable.on("error", async (e: unknown) => { + if (existsSync(filePath)) { + await fs.unlink(filePath); + } + reject(e); + }); + }); +}; diff --git a/desktop/src/main/util.ts b/desktop/src/main/util.ts index d0c6699e9..b997d738e 100644 --- a/desktop/src/main/util.ts +++ b/desktop/src/main/util.ts @@ -56,6 +56,13 @@ export const openDirectory = async (dirPath: string) => { if (res) throw new Error(`Failed to open directory ${dirPath}: res`); }; +/** + * Open the app's log directory in the system's folder viewer. + * + * @see {@link openDirectory} + */ +export const openLogDirectory = () => openDirectory(logDirectoryPath()); + /** * Return the path where the logs for the app are saved. * @@ -72,10 +79,3 @@ export const openDirectory = async (dirPath: string) => { * */ const logDirectoryPath = () => app.getPath("logs"); - -/** - * Open the app's log directory in the system's folder viewer. - * - * @see {@link openDirectory} - */ -export const openLogDirectory = () => openDirectory(logDirectoryPath()); diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 2db39e229..7d0df41d5 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -40,12 +40,13 @@ import { contextBridge, ipcRenderer } from "electron/renderer"; // While we can't import other code, we can import types since they're just -// needed when compiling and will not be needed / looked around for at runtime. +// needed when compiling and will not be needed or looked around for at runtime. import type { - AppUpdateInfo, + AppUpdate, + CollectionMapping, ElectronFile, - FILE_PATH_TYPE, - WatchMapping, + FolderWatch, + PendingUploads, } from "./types/ipc"; // - General @@ -77,12 +78,12 @@ const onMainWindowFocus = (cb?: () => void) => { // - App update const onAppUpdateAvailable = ( - cb?: ((updateInfo: AppUpdateInfo) => void) | undefined, + cb?: ((update: AppUpdate) => void) | undefined, ) => { ipcRenderer.removeAllListeners("appUpdateAvailable"); if (cb) { - ipcRenderer.on("appUpdateAvailable", (_, updateInfo: AppUpdateInfo) => - cb(updateInfo), + ipcRenderer.on("appUpdateAvailable", (_, update: AppUpdate) => + cb(update), ); } }; @@ -96,9 +97,31 @@ const skipAppUpdate = (version: string) => { ipcRenderer.send("skipAppUpdate", version); }; +// - FS + const fsExists = (path: string): Promise => ipcRenderer.invoke("fsExists", path); +const fsMkdirIfNeeded = (dirPath: string): Promise => + ipcRenderer.invoke("fsMkdirIfNeeded", dirPath); + +const fsRename = (oldPath: string, newPath: string): Promise => + ipcRenderer.invoke("fsRename", oldPath, newPath); + +const fsRmdir = (path: string): Promise => + ipcRenderer.invoke("fsRmdir", path); + +const fsRm = (path: string): Promise => ipcRenderer.invoke("fsRm", path); + +const fsReadTextFile = (path: string): Promise => + ipcRenderer.invoke("fsReadTextFile", path); + +const fsWriteFile = (path: string, contents: string): Promise => + ipcRenderer.invoke("fsWriteFile", path, contents); + +const fsIsDir = (dirPath: string): Promise => + ipcRenderer.invoke("fsIsDir", dirPath); + // - AUDIT below this // - Conversion @@ -169,108 +192,78 @@ const showUploadZipDialog = (): Promise<{ // - Watch -const registerWatcherFunctions = ( - addFile: (file: ElectronFile) => Promise, - removeFile: (path: string) => Promise, - removeFolder: (folderPath: string) => Promise, -) => { - ipcRenderer.removeAllListeners("watch-add"); - ipcRenderer.removeAllListeners("watch-unlink"); - ipcRenderer.removeAllListeners("watch-unlink-dir"); - ipcRenderer.on("watch-add", (_, file: ElectronFile) => addFile(file)); - ipcRenderer.on("watch-unlink", (_, filePath: string) => - removeFile(filePath), - ); - ipcRenderer.on("watch-unlink-dir", (_, folderPath: string) => - removeFolder(folderPath), +const watchGet = (): Promise => ipcRenderer.invoke("watchGet"); + +const watchAdd = ( + folderPath: string, + collectionMapping: CollectionMapping, +): Promise => + ipcRenderer.invoke("watchAdd", folderPath, collectionMapping); + +const watchRemove = (folderPath: string): Promise => + ipcRenderer.invoke("watchRemove", folderPath); + +const watchUpdateSyncedFiles = ( + syncedFiles: FolderWatch["syncedFiles"], + folderPath: string, +): Promise => + ipcRenderer.invoke("watchUpdateSyncedFiles", syncedFiles, folderPath); + +const watchUpdateIgnoredFiles = ( + ignoredFiles: FolderWatch["ignoredFiles"], + folderPath: string, +): Promise => + ipcRenderer.invoke("watchUpdateIgnoredFiles", ignoredFiles, folderPath); + +const watchOnAddFile = (f: (path: string, watch: FolderWatch) => void) => { + ipcRenderer.removeAllListeners("watchAddFile"); + ipcRenderer.on("watchAddFile", (_, path: string, watch: FolderWatch) => + f(path, watch), ); }; -const addWatchMapping = ( - collectionName: string, - folderPath: string, - uploadStrategy: number, -): Promise => - ipcRenderer.invoke( - "addWatchMapping", - collectionName, - folderPath, - uploadStrategy, +const watchOnRemoveFile = (f: (path: string, watch: FolderWatch) => void) => { + ipcRenderer.removeAllListeners("watchRemoveFile"); + ipcRenderer.on("watchRemoveFile", (_, path: string, watch: FolderWatch) => + f(path, watch), ); +}; -const removeWatchMapping = (folderPath: string): Promise => - ipcRenderer.invoke("removeWatchMapping", folderPath); +const watchOnRemoveDir = (f: (path: string, watch: FolderWatch) => void) => { + ipcRenderer.removeAllListeners("watchRemoveDir"); + ipcRenderer.on("watchRemoveDir", (_, path: string, watch: FolderWatch) => + f(path, watch), + ); +}; -const getWatchMappings = (): Promise => - ipcRenderer.invoke("getWatchMappings"); - -const updateWatchMappingSyncedFiles = ( - folderPath: string, - files: WatchMapping["syncedFiles"], -): Promise => - ipcRenderer.invoke("updateWatchMappingSyncedFiles", folderPath, files); - -const updateWatchMappingIgnoredFiles = ( - folderPath: string, - files: WatchMapping["ignoredFiles"], -): Promise => - ipcRenderer.invoke("updateWatchMappingIgnoredFiles", folderPath, files); - -// - FS Legacy - -const checkExistsAndCreateDir = (dirPath: string): Promise => - ipcRenderer.invoke("checkExistsAndCreateDir", dirPath); - -const saveStreamToDisk = ( - path: string, - fileStream: ReadableStream, -): Promise => ipcRenderer.invoke("saveStreamToDisk", path, fileStream); - -const saveFileToDisk = (path: string, contents: string): Promise => - ipcRenderer.invoke("saveFileToDisk", path, contents); - -const readTextFile = (path: string): Promise => - ipcRenderer.invoke("readTextFile", path); - -const isFolder = (dirPath: string): Promise => - ipcRenderer.invoke("isFolder", dirPath); - -const moveFile = (oldPath: string, newPath: string): Promise => - ipcRenderer.invoke("moveFile", oldPath, newPath); - -const deleteFolder = (path: string): Promise => - ipcRenderer.invoke("deleteFolder", path); - -const deleteFile = (path: string): Promise => - ipcRenderer.invoke("deleteFile", path); - -const rename = (oldPath: string, newPath: string): Promise => - ipcRenderer.invoke("rename", oldPath, newPath); +const watchFindFiles = (folderPath: string): Promise => + ipcRenderer.invoke("watchFindFiles", folderPath); // - Upload -const getPendingUploads = (): Promise<{ - files: ElectronFile[]; - collectionName: string; - type: string; -}> => ipcRenderer.invoke("getPendingUploads"); +const pendingUploads = (): Promise => + ipcRenderer.invoke("pendingUploads"); -const setToUploadFiles = ( - type: FILE_PATH_TYPE, +const setPendingUploadCollection = (collectionName: string): Promise => + ipcRenderer.invoke("setPendingUploadCollection", collectionName); + +const setPendingUploadFiles = ( + type: PendingUploads["type"], filePaths: string[], -): Promise => ipcRenderer.invoke("setToUploadFiles", type, filePaths); +): Promise => + ipcRenderer.invoke("setPendingUploadFiles", type, filePaths); + +// - const getElectronFilesFromGoogleZip = ( filePath: string, ): Promise => ipcRenderer.invoke("getElectronFilesFromGoogleZip", filePath); -const setToUploadCollection = (collectionName: string): Promise => - ipcRenderer.invoke("setToUploadCollection", collectionName); - const getDirFiles = (dirPath: string): Promise => ipcRenderer.invoke("getDirFiles", dirPath); +// // These objects exposed here will become available to the JS code in our // renderer (the web/ code) as `window.ElectronAPIs.*` // @@ -303,8 +296,12 @@ const getDirFiles = (dirPath: string): Promise => // // 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, @@ -315,58 +312,67 @@ contextBridge.exposeInMainWorld("electron", { onMainWindowFocus, // - App update + onAppUpdateAvailable, updateAndRestart, updateOnNextRestart, skipAppUpdate, + // - FS + + fs: { + exists: fsExists, + rename: fsRename, + mkdirIfNeeded: fsMkdirIfNeeded, + rmdir: fsRmdir, + rm: fsRm, + readTextFile: fsReadTextFile, + writeFile: fsWriteFile, + isDir: fsIsDir, + }, + // - Conversion + convertToJPEG, generateImageThumbnail, runFFmpegCmd, // - ML + clipImageEmbedding, clipTextEmbedding, detectFaces, faceEmbedding, // - File selection + selectDirectory, showUploadFilesDialog, showUploadDirsDialog, showUploadZipDialog, // - Watch - registerWatcherFunctions, - addWatchMapping, - removeWatchMapping, - getWatchMappings, - updateWatchMappingSyncedFiles, - updateWatchMappingIgnoredFiles, - // - FS - fs: { - exists: fsExists, + watch: { + get: watchGet, + add: watchAdd, + remove: watchRemove, + onAddFile: watchOnAddFile, + onRemoveFile: watchOnRemoveFile, + onRemoveDir: watchOnRemoveDir, + findFiles: watchFindFiles, + updateSyncedFiles: watchUpdateSyncedFiles, + updateIgnoredFiles: watchUpdateIgnoredFiles, }, - // - FS legacy - // TODO: Move these into fs + document + rename if needed - checkExistsAndCreateDir, - saveStreamToDisk, - saveFileToDisk, - readTextFile, - isFolder, - moveFile, - deleteFolder, - deleteFile, - rename, - // - Upload - getPendingUploads, - setToUploadFiles, + pendingUploads, + setPendingUploadCollection, + setPendingUploadFiles, + + // - + getElectronFilesFromGoogleZip, - setToUploadCollection, getDirFiles, }); diff --git a/desktop/src/types/ipc.ts b/desktop/src/types/ipc.ts index 3dba231f2..d96341982 100644 --- a/desktop/src/types/ipc.ts +++ b/desktop/src/types/ipc.ts @@ -5,6 +5,32 @@ * See [Note: types.ts <-> preload.ts <-> ipc.ts] */ +export interface AppUpdate { + autoUpdatable: boolean; + version: string; +} + +export interface FolderWatch { + collectionMapping: CollectionMapping; + folderPath: string; + syncedFiles: FolderWatchSyncedFile[]; + ignoredFiles: string[]; +} + +export type CollectionMapping = "root" | "parent"; + +export interface FolderWatchSyncedFile { + path: string; + uploadedFileID: number; + collectionID: number; +} + +export interface PendingUploads { + collectionName: string; + type: "files" | "zips"; + files: ElectronFile[]; +} + /** * Errors that have special semantics on the web side. * @@ -51,32 +77,3 @@ export interface ElectronFile { blob: () => Promise; arrayBuffer: () => Promise; } - -interface WatchMappingSyncedFile { - path: string; - uploadedFileID: number; - collectionID: number; -} - -export interface WatchMapping { - rootFolderName: string; - uploadStrategy: number; - folderPath: string; - syncedFiles: WatchMappingSyncedFile[]; - ignoredFiles: string[]; -} - -export interface WatchStoreType { - mappings: WatchMapping[]; -} - -export enum FILE_PATH_TYPE { - /* eslint-disable no-unused-vars */ - FILES = "files", - ZIPS = "zips", -} - -export interface AppUpdateInfo { - autoUpdatable: boolean; - version: string; -} diff --git a/desktop/src/types/main.ts b/desktop/src/types/main.ts deleted file mode 100644 index 546749c54..000000000 --- a/desktop/src/types/main.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { FILE_PATH_TYPE } from "./ipc"; - -export interface AutoLauncherClient { - isEnabled: () => Promise; - toggleAutoLaunch: () => Promise; - wasAutoLaunched: () => Promise; -} - -export interface UploadStoreType { - filePaths: string[]; - zipPaths: string[]; - collectionName: string; -} - -export interface KeysStoreType { - AnonymizeUserID: { - id: string; - }; -} - -/* eslint-disable no-unused-vars */ -export const FILE_PATH_KEYS: { - [k in FILE_PATH_TYPE]: keyof UploadStoreType; -} = { - [FILE_PATH_TYPE.ZIPS]: "zipPaths", - [FILE_PATH_TYPE.FILES]: "filePaths", -}; - -export interface SafeStorageStoreType { - encryptionKey: string; -} diff --git a/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/.vitepress/sidebar.ts b/docs/docs/.vitepress/sidebar.ts index 2d473a4e3..6af9e3556 100644 --- a/docs/docs/.vitepress/sidebar.ts +++ b/docs/docs/.vitepress/sidebar.ts @@ -180,6 +180,10 @@ export const sidebar = [ text: "Connect to custom server", link: "/self-hosting/guides/custom-server/", }, + { + text: "Hosting the web app", + link: "/self-hosting/guides/web-app", + }, { text: "Administering your server", link: "/self-hosting/guides/admin", @@ -207,6 +211,10 @@ export const sidebar = [ text: "Verification code", link: "/self-hosting/faq/otp", }, + { + text: "Shared albums", + link: "/self-hosting/faq/sharing", + }, ], }, { diff --git a/docs/docs/auth/faq/enteception/index.md b/docs/docs/auth/faq/enteception/index.md index 4b3167f73..1155986b7 100644 --- a/docs/docs/auth/faq/enteception/index.md +++ b/docs/docs/auth/faq/enteception/index.md @@ -12,7 +12,7 @@ There are multiple answers, none of which are better or worse, they just depend on your situation and risk tolerance. If you are using the same account for both Ente Photos and Ente Auth and have -enabled 2FA from the ente Photos app, we recommend that you ensure you store +enabled 2FA from the Ente Photos app, we recommend that you ensure you store your recovery key in a safe place (writing it down on a paper is a good idea). This key can be used to bypass Ente 2FA in case you are locked out. 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/docs/docs/self-hosting/faq/sharing.md b/docs/docs/self-hosting/faq/sharing.md new file mode 100644 index 000000000..4e3652ff7 --- /dev/null +++ b/docs/docs/self-hosting/faq/sharing.md @@ -0,0 +1,59 @@ +--- +title: Album sharing +description: Getting album sharing to work using an self-hosted Ente +--- + +# Is public sharing available for self-hosted instances? + +Yes. + +You'll need to run two instances of the web app, one is regular web app, but +another one is the same code but running on a different origin (i.e. on a +different hostname or different port). + +Then, you need to tell the regular web app to use your second instance to +service public links. You can do this by setting the +`NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT` to point to your second instance when running +or building the regular web app. + +For more details, see +[.env](https://github.com/ente-io/ente/blob/main/web/apps/photos/.env) and +[.env.development](https://github.com/ente-io/ente/blob/main/web/apps/photos/.env.development). + +As a concrete example, assuming we have a Ente server running on +`localhost:8080`, we can start two instances of the web app, passing them +`NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT` that points to the origin +("scheme://host[:port]") of the second "albums" instance. + +The first one, the normal web app + +```sh +NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:8080 \ + NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT=http://localhost:3002 \ + yarn dev:photos +``` + +The second one, the same code but acting as the "albums" app (the only +difference is the port it is running on): + +```sh +NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:8080 \ + NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT=http://localhost:3002 \ + yarn dev:albums +``` + +If you also want to change the prefix (the origin) in the generated public +links, to use your custom albums endpoint in the generated public link instead +of albums.ente.io, set `apps.public-albums` property in museum's configuration + +For example, when running using the starter docker compose file, you can do this +by creating a `museum.yaml` and defining the following configuration there: + +```yaml +apps: + public-albums: http://localhost:3002 +``` + +(For more details, see +[local.yaml](https://github.com/ente-io/ente/blob/main/server/configurations/local.yaml) +in the server's source code). diff --git a/docs/docs/self-hosting/guides/web-app.md b/docs/docs/self-hosting/guides/web-app.md new file mode 100644 index 000000000..49dfdd114 --- /dev/null +++ b/docs/docs/self-hosting/guides/web-app.md @@ -0,0 +1,61 @@ +--- +title: Hosting the web app +description: Building and hosting Ente's web app, connecting it to your self-hosted server +--- + +# Web app + +The getting started instructions mention using `yarn dev` (which is an alias of +`yarn dev:photos`) to serve your web app. + +```sh +cd ente/web +git submodule update --init --recursive +yarn install +NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:8080 yarn dev:photos +``` + +This is fine for trying this out and verifying that your self-hosted server is +working correctly etc. But if you would like to use the web app for a longer +term, then it is recommended that you use a production build. + +To create a production build, you can run the same process, but instead do a +`yarn build` (which is an alias for `yarn build:photos`). For example, + +```sh +NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:8080 yarn build:photos +``` + +This creates a production build, which is a static site consisting of a folder +of HTML/CSS/JS files that can then be deployed on any standard web server. + +Nginx is a common choice for a web server, and you can then put the generated +static site (from the `web/apps/photos/out` folder) to where nginx would serve +them. Note that there is nothing specific to nginx here - you can use any web +server - the basic gist is that yarn build will produce a web/apps/photos/out +folder that you can then serve with any web server of your choice. + +If you're new to web development, you might find the [web app's README], and +some of the documentation it its source code - +[docs/new.md](https://github.com/ente-io/ente/blob/main/web/docs/new.md), +[docs/dev.md](https://github.com/ente-io/ente/blob/main/web/docs/dev.md) - +useful. We've also documented the process we use for our own production +deploypments in +[docs/deploy.md](https://github.com/ente-io/ente/blob/main/web/docs/deploy.md), +though be aware that that is probably overkill for simple cases. + +## Using Docker + +We currently don't offer pre-built Docker images for the web app, however it is +quite easy to build and deploy the web app in a Docker container without +installing anything extra on your machine. For example, you can use the +dockerfile from this +[discussion](https://github.com/ente-io/ente/discussions/1183), or use the +Dockerfile mentioned in the +[notes](https://help.ente.io/self-hosting/guides/external-s3) created by a +community member. + +## Public sharing + +If you'd also like to enable public sharing on the web app you're running, +please follow the [step here](https://help.ente.io/self-hosting/faq/sharing). diff --git a/mobile/README.md b/mobile/README.md index b9fcaff60..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.16.9](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/analysis_options.yaml b/mobile/analysis_options.yaml index cebeb1391..f30be877b 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -22,6 +22,7 @@ linter: - use_key_in_widget_constructors - cancel_subscriptions + - avoid_empty_else - exhaustive_cases @@ -59,6 +60,7 @@ analyzer: prefer_final_locals: warning unnecessary_const: error cancel_subscriptions: error + unrelated_type_equality_checks: error unawaited_futures: warning # convert to warning after fixing existing issues diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 1a83c96af..b5225db8e 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -11,11 +11,6 @@ if (localPropertiesFile.exists()) { } } -/*def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -}*/ - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -26,11 +21,6 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -//apply plugin: 'com.android.application' -//apply plugin: 'kotlin-android' -//apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - - def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 9a2e993d5..ef9c650fd 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -3,12 +3,9 @@ PODS: - Flutter - battery_info (0.0.1): - Flutter - - bonsoir_darwin (3.0.0): - - Flutter - - FlutterMacOS - connectivity_plus (0.0.1): - Flutter - - ReachabilitySwift + - FlutterMacOS - dart_ui_isolate (0.0.1): - Flutter - device_info_plus (0.0.1): @@ -173,7 +170,6 @@ PODS: - Flutter - FlutterMacOS - PromisesObjC (2.4.0) - - ReachabilitySwift (5.2.1) - receive_sharing_intent (1.6.8): - Flutter - screen_brightness_ios (0.1.0): @@ -233,8 +229,7 @@ PODS: DEPENDENCIES: - background_fetch (from `.symlinks/plugins/background_fetch/ios`) - battery_info (from `.symlinks/plugins/battery_info/ios`) - - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) - dart_ui_isolate (from `.symlinks/plugins/dart_ui_isolate/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_saver (from `.symlinks/plugins/file_saver/ios`) @@ -299,7 +294,6 @@ SPEC REPOS: - onnxruntime-objc - OrderedSet - PromisesObjC - - ReachabilitySwift - SDWebImage - SDWebImageWebPCoder - Sentry @@ -312,10 +306,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/background_fetch/ios" battery_info: :path: ".symlinks/plugins/battery_info/ios" - bonsoir_darwin: - :path: ".symlinks/plugins/bonsoir_darwin/darwin" connectivity_plus: - :path: ".symlinks/plugins/connectivity_plus/ios" + :path: ".symlinks/plugins/connectivity_plus/darwin" dart_ui_isolate: :path: ".symlinks/plugins/dart_ui_isolate/ios" device_info_plus: @@ -414,8 +406,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: background_fetch: 2319bf7e18237b4b269430b7f14d177c0df09c5a battery_info: 09f5c9ee65394f2291c8c6227bedff345b8a730c - bonsoir_darwin: 127bdc632fdc154ae2f277a4d5c86a6212bc75be - connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a + connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db dart_ui_isolate: d5bcda83ca4b04f129d70eb90110b7a567aece14 device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 @@ -464,7 +455,6 @@ SPEC CHECKSUMS: permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - ReachabilitySwift: 5ae15e16814b5f9ef568963fb2c87aeb49158c66 receive_sharing_intent: 6837b01768e567fe8562182397bf43d63d8c6437 screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 SDWebImage: 40b0b4053e36c660a764958bff99eed16610acbb @@ -480,11 +470,11 @@ SPEC CHECKSUMS: Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e uni_links: d97da20c7701486ba192624d99bffaaffcfc298a url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586 - video_player_avfoundation: 02011213dab73ae3687df27ce441fbbcc82b5579 + video_player_avfoundation: 2b4384f3b157206b5e150a0083cdc0c905d260d3 video_thumbnail: c4e2a3c539e247d4de13cd545344fd2d26ffafd1 volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9 wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47 PODFILE CHECKSUM: c1a8f198a245ed1f10e40b617efdb129b021b225 -COCOAPODS: 1.14.3 +COCOAPODS: 1.15.2 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index bcc400698..3b38f2ac3 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -285,7 +285,6 @@ "${BUILT_PRODUCTS_DIR}/Mantle/Mantle.framework", "${BUILT_PRODUCTS_DIR}/OrderedSet/OrderedSet.framework", "${BUILT_PRODUCTS_DIR}/PromisesObjC/FBLPromises.framework", - "${BUILT_PRODUCTS_DIR}/ReachabilitySwift/Reachability.framework", "${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework", "${BUILT_PRODUCTS_DIR}/SDWebImageWebPCoder/SDWebImageWebPCoder.framework", "${BUILT_PRODUCTS_DIR}/Sentry/Sentry.framework", @@ -293,7 +292,6 @@ "${BUILT_PRODUCTS_DIR}/Toast/Toast.framework", "${BUILT_PRODUCTS_DIR}/background_fetch/background_fetch.framework", "${BUILT_PRODUCTS_DIR}/battery_info/battery_info.framework", - "${BUILT_PRODUCTS_DIR}/bonsoir_darwin/bonsoir_darwin.framework", "${BUILT_PRODUCTS_DIR}/connectivity_plus/connectivity_plus.framework", "${BUILT_PRODUCTS_DIR}/dart_ui_isolate/dart_ui_isolate.framework", "${BUILT_PRODUCTS_DIR}/device_info_plus/device_info_plus.framework", @@ -370,7 +368,6 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Mantle.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OrderedSet.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBLPromises.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Reachability.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImageWebPCoder.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework", @@ -378,7 +375,6 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Toast.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/background_fetch.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/battery_info.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/bonsoir_darwin.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/connectivity_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/dart_ui_isolate.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/device_info_plus.framework", 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 2da14ca4e..004580145 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; @@ -45,6 +46,9 @@ class FFDefault { 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; + const kDefaultProductionEndpoint = 'https://api.ente.io'; const int intMaxValue = 9223372036854775807; @@ -71,11 +75,11 @@ const kSearchSectionLimit = 9; const iOSGroupID = "group.io.ente.frame.SlideshowWidget"; -const blackThumbnailBase64 = '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB' + - 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQ' + - 'EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARC' + - 'ACWASwDAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUF' + - 'BAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk' + +const blackThumbnailBase64 = '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB' + 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQ' + 'EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARC' + 'ACWASwDAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUF' + 'BAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk' + '6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztL' + 'W2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAA' + 'AAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVY' + diff --git a/mobile/lib/db/device_files_db.dart b/mobile/lib/db/device_files_db.dart index 5009dc0e0..25c88daca 100644 --- a/mobile/lib/db/device_files_db.dart +++ b/mobile/lib/db/device_files_db.dart @@ -340,7 +340,7 @@ extension DeviceFiles on FilesDB { int ownerID, ) async { final db = await database; - const String rawQuery = ''' + const String rawQuery = ''' SELECT ${FilesDB.columnLocalID}, ${FilesDB.columnUploadedFileID}, ${FilesDB.columnFileSize} FROM ${FilesDB.filesTable} diff --git a/mobile/lib/db/files_db.dart b/mobile/lib/db/files_db.dart index d4a0aae65..dc821d793 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, ], @@ -507,13 +491,10 @@ class FilesDB { } Future getAnyUploadedFile(int uploadedID) async { - final db = await instance.database; - final results = await db.query( - filesTable, - where: '$columnUploadedFileID = ?', - whereArgs: [ - uploadedID, - ], + final db = await instance.sqliteAsyncDB; + final results = await db.getAll( + 'SELECT * FROM $filesTable WHERE $columnUploadedFileID = ?', + [uploadedID], ); if (results.isEmpty) { return null; @@ -522,13 +503,11 @@ class FilesDB { } 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 +518,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 +683,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 +698,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 +726,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 +759,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 +774,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 +1009,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, @@ -1324,7 +1315,7 @@ class FilesDB { return result; } - Future> getFileIDToCreationTime() async { + Future> getFileIDToCreationTime() async { final db = await instance.database; final rows = await db.rawQuery( ''' 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 c6207274f..74225acbd 100644 --- a/mobile/lib/generated/intl/messages_de.dart +++ b/mobile/lib/generated/intl/messages_de.dart @@ -1216,6 +1216,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 0f53d69e8..0ff575fff 100644 --- a/mobile/lib/generated/intl/messages_en.dart +++ b/mobile/lib/generated/intl/messages_en.dart @@ -1179,6 +1179,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 355ecb0e0..14fdde86a 100644 --- a/mobile/lib/generated/intl/messages_pt.dart +++ b/mobile/lib/generated/intl/messages_pt.dart @@ -1219,6 +1219,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 65e2af71f..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( @@ -1026,7 +1027,7 @@ class MessageLookup extends MessageLookupByLibrary { "selectedFilesAreNotOnEnte": MessageLookupByLibrary.simpleMessage("所选文件不在 Ente 上"), "selectedFoldersWillBeEncryptedAndBackedUp": - MessageLookupByLibrary.simpleMessage("所选文件夹将被加密和备份"), + MessageLookupByLibrary.simpleMessage("所选文件夹将被加密并备份"), "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": MessageLookupByLibrary.simpleMessage("所选项目将从所有相册中删除并移动到回收站。"), "selectedPhotos": m46, diff --git a/mobile/lib/generated/l10n.dart b/mobile/lib/generated/l10n.dart index e114b674f..4da7e9abc 100644 --- a/mobile/lib/generated/l10n.dart +++ b/mobile/lib/generated/l10n.dart @@ -8583,6 +8583,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 386912970..40912d282 100644 --- a/mobile/lib/l10n/intl_de.arb +++ b/mobile/lib/l10n/intl_de.arb @@ -1205,5 +1205,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 df86f5acf..7a94dd839 100644 --- a/mobile/lib/l10n/intl_en.arb +++ b/mobile/lib/l10n/intl_en.arb @@ -1214,5 +1214,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 c278bda87..665574f97 100644 --- a/mobile/lib/l10n/intl_pt.arb +++ b/mobile/lib/l10n/intl_pt.arb @@ -1212,5 +1212,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 8eb97df78..54fed47df 100644 --- a/mobile/lib/l10n/intl_zh.arb +++ b/mobile/lib/l10n/intl_zh.arb @@ -417,7 +417,7 @@ "pendingItems": "待处理项目", "clearIndexes": "清空索引", "selectFoldersForBackup": "选择要备份的文件夹", - "selectedFoldersWillBeEncryptedAndBackedUp": "所选文件夹将被加密和备份", + "selectedFoldersWillBeEncryptedAndBackedUp": "所选文件夹将被加密并备份", "unselectAll": "取消全部选择", "selectAll": "全选", "skip": "跳过", @@ -1211,5 +1211,6 @@ "invalidEndpointMessage": "抱歉,您输入的端点无效。请输入有效的端点,然后重试。", "endpointUpdatedMessage": "端点更新成功", "customEndpoint": "已连接至 {endpoint}", - "createCollaborativeLink": "创建协作链接" + "createCollaborativeLink": "创建协作链接", + "search": "搜索" } \ No newline at end of file diff --git a/mobile/lib/models/file/file.dart b/mobile/lib/models/file/file.dart index e9aac98cd..3495ee18d 100644 --- a/mobile/lib/models/file/file.dart +++ b/mobile/lib/models/file/file.dart @@ -85,13 +85,24 @@ class EnteFile { static int parseFileCreationTime(String? fileTitle, AssetEntity asset) { int creationTime = asset.createDateTime.microsecondsSinceEpoch; + final int modificationTime = asset.modifiedDateTime.microsecondsSinceEpoch; if (creationTime >= jan011981Time) { // assuming that fileSystem is returning correct creationTime. // During upload, this might get overridden with exif Creation time + // When the assetModifiedTime is less than creationTime, than just use + // that as creationTime. This is to handle cases where file might be + // copied to the fileSystem from somewhere else See #https://superuser.com/a/1091147 + if (modificationTime >= jan011981Time && + modificationTime < creationTime) { + _logger.info( + 'LocalID: ${asset.id} modification time is less than creation time. Using modification time as creation time', + ); + creationTime = modificationTime; + } return creationTime; } else { - if (asset.modifiedDateTime.microsecondsSinceEpoch >= jan011981Time) { - creationTime = asset.modifiedDateTime.microsecondsSinceEpoch; + if (modificationTime >= jan011981Time) { + creationTime = modificationTime; } else { creationTime = DateTime.now().toUtc().microsecondsSinceEpoch; } @@ -106,7 +117,6 @@ class EnteFile { // ignore } } - return creationTime; } diff --git a/mobile/lib/models/location/location.freezed.dart b/mobile/lib/models/location/location.freezed.dart index e3cc1a19d..135377e74 100644 --- a/mobile/lib/models/location/location.freezed.dart +++ b/mobile/lib/models/location/location.freezed.dart @@ -12,7 +12,7 @@ part of 'location.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); Location _$LocationFromJson(Map json) { return _Location.fromJson(json); @@ -67,21 +67,22 @@ class _$LocationCopyWithImpl<$Res, $Val extends Location> } /// @nodoc -abstract class _$$_LocationCopyWith<$Res> implements $LocationCopyWith<$Res> { - factory _$$_LocationCopyWith( - _$_Location value, $Res Function(_$_Location) then) = - __$$_LocationCopyWithImpl<$Res>; +abstract class _$$LocationImplCopyWith<$Res> + implements $LocationCopyWith<$Res> { + factory _$$LocationImplCopyWith( + _$LocationImpl value, $Res Function(_$LocationImpl) then) = + __$$LocationImplCopyWithImpl<$Res>; @override @useResult $Res call({double? latitude, double? longitude}); } /// @nodoc -class __$$_LocationCopyWithImpl<$Res> - extends _$LocationCopyWithImpl<$Res, _$_Location> - implements _$$_LocationCopyWith<$Res> { - __$$_LocationCopyWithImpl( - _$_Location _value, $Res Function(_$_Location) _then) +class __$$LocationImplCopyWithImpl<$Res> + extends _$LocationCopyWithImpl<$Res, _$LocationImpl> + implements _$$LocationImplCopyWith<$Res> { + __$$LocationImplCopyWithImpl( + _$LocationImpl _value, $Res Function(_$LocationImpl) _then) : super(_value, _then); @pragma('vm:prefer-inline') @@ -90,7 +91,7 @@ class __$$_LocationCopyWithImpl<$Res> Object? latitude = freezed, Object? longitude = freezed, }) { - return _then(_$_Location( + return _then(_$LocationImpl( latitude: freezed == latitude ? _value.latitude : latitude // ignore: cast_nullable_to_non_nullable @@ -105,11 +106,11 @@ class __$$_LocationCopyWithImpl<$Res> /// @nodoc @JsonSerializable() -class _$_Location implements _Location { - const _$_Location({required this.latitude, required this.longitude}); +class _$LocationImpl implements _Location { + const _$LocationImpl({required this.latitude, required this.longitude}); - factory _$_Location.fromJson(Map json) => - _$$_LocationFromJson(json); + factory _$LocationImpl.fromJson(Map json) => + _$$LocationImplFromJson(json); @override final double? latitude; @@ -122,10 +123,10 @@ class _$_Location implements _Location { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$_Location && + other is _$LocationImpl && (identical(other.latitude, latitude) || other.latitude == latitude) && (identical(other.longitude, longitude) || @@ -139,12 +140,12 @@ class _$_Location implements _Location { @JsonKey(ignore: true) @override @pragma('vm:prefer-inline') - _$$_LocationCopyWith<_$_Location> get copyWith => - __$$_LocationCopyWithImpl<_$_Location>(this, _$identity); + _$$LocationImplCopyWith<_$LocationImpl> get copyWith => + __$$LocationImplCopyWithImpl<_$LocationImpl>(this, _$identity); @override Map toJson() { - return _$$_LocationToJson( + return _$$LocationImplToJson( this, ); } @@ -153,9 +154,10 @@ class _$_Location implements _Location { abstract class _Location implements Location { const factory _Location( {required final double? latitude, - required final double? longitude}) = _$_Location; + required final double? longitude}) = _$LocationImpl; - factory _Location.fromJson(Map json) = _$_Location.fromJson; + factory _Location.fromJson(Map json) = + _$LocationImpl.fromJson; @override double? get latitude; @@ -163,6 +165,6 @@ abstract class _Location implements Location { double? get longitude; @override @JsonKey(ignore: true) - _$$_LocationCopyWith<_$_Location> get copyWith => + _$$LocationImplCopyWith<_$LocationImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/mobile/lib/models/location/location.g.dart b/mobile/lib/models/location/location.g.dart index fe91798f9..39f546d68 100644 --- a/mobile/lib/models/location/location.g.dart +++ b/mobile/lib/models/location/location.g.dart @@ -6,12 +6,13 @@ part of 'location.dart'; // JsonSerializableGenerator // ************************************************************************** -_$_Location _$$_LocationFromJson(Map json) => _$_Location( +_$LocationImpl _$$LocationImplFromJson(Map json) => + _$LocationImpl( latitude: (json['latitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(), ); -Map _$$_LocationToJson(_$_Location instance) => +Map _$$LocationImplToJson(_$LocationImpl instance) => { 'latitude': instance.latitude, 'longitude': instance.longitude, diff --git a/mobile/lib/models/location_tag/location_tag.freezed.dart b/mobile/lib/models/location_tag/location_tag.freezed.dart index 2c0f795b6..fcec42492 100644 --- a/mobile/lib/models/location_tag/location_tag.freezed.dart +++ b/mobile/lib/models/location_tag/location_tag.freezed.dart @@ -12,7 +12,7 @@ part of 'location_tag.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); LocationTag _$LocationTagFromJson(Map json) { return _LocationTag.fromJson(json); @@ -101,11 +101,11 @@ class _$LocationTagCopyWithImpl<$Res, $Val extends LocationTag> } /// @nodoc -abstract class _$$_LocationTagCopyWith<$Res> +abstract class _$$LocationTagImplCopyWith<$Res> implements $LocationTagCopyWith<$Res> { - factory _$$_LocationTagCopyWith( - _$_LocationTag value, $Res Function(_$_LocationTag) then) = - __$$_LocationTagCopyWithImpl<$Res>; + factory _$$LocationTagImplCopyWith( + _$LocationTagImpl value, $Res Function(_$LocationTagImpl) then) = + __$$LocationTagImplCopyWithImpl<$Res>; @override @useResult $Res call( @@ -120,11 +120,11 @@ abstract class _$$_LocationTagCopyWith<$Res> } /// @nodoc -class __$$_LocationTagCopyWithImpl<$Res> - extends _$LocationTagCopyWithImpl<$Res, _$_LocationTag> - implements _$$_LocationTagCopyWith<$Res> { - __$$_LocationTagCopyWithImpl( - _$_LocationTag _value, $Res Function(_$_LocationTag) _then) +class __$$LocationTagImplCopyWithImpl<$Res> + extends _$LocationTagCopyWithImpl<$Res, _$LocationTagImpl> + implements _$$LocationTagImplCopyWith<$Res> { + __$$LocationTagImplCopyWithImpl( + _$LocationTagImpl _value, $Res Function(_$LocationTagImpl) _then) : super(_value, _then); @pragma('vm:prefer-inline') @@ -136,7 +136,7 @@ class __$$_LocationTagCopyWithImpl<$Res> Object? bSquare = null, Object? centerPoint = null, }) { - return _then(_$_LocationTag( + return _then(_$LocationTagImpl( name: null == name ? _value.name : name // ignore: cast_nullable_to_non_nullable @@ -163,8 +163,8 @@ class __$$_LocationTagCopyWithImpl<$Res> /// @nodoc @JsonSerializable() -class _$_LocationTag extends _LocationTag { - const _$_LocationTag( +class _$LocationTagImpl extends _LocationTag { + const _$LocationTagImpl( {required this.name, required this.radius, required this.aSquare, @@ -172,8 +172,8 @@ class _$_LocationTag extends _LocationTag { required this.centerPoint}) : super._(); - factory _$_LocationTag.fromJson(Map json) => - _$$_LocationTagFromJson(json); + factory _$LocationTagImpl.fromJson(Map json) => + _$$LocationTagImplFromJson(json); @override final String name; @@ -192,10 +192,10 @@ class _$_LocationTag extends _LocationTag { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$_LocationTag && + other is _$LocationTagImpl && (identical(other.name, name) || other.name == name) && (identical(other.radius, radius) || other.radius == radius) && (identical(other.aSquare, aSquare) || other.aSquare == aSquare) && @@ -212,12 +212,12 @@ class _$_LocationTag extends _LocationTag { @JsonKey(ignore: true) @override @pragma('vm:prefer-inline') - _$$_LocationTagCopyWith<_$_LocationTag> get copyWith => - __$$_LocationTagCopyWithImpl<_$_LocationTag>(this, _$identity); + _$$LocationTagImplCopyWith<_$LocationTagImpl> get copyWith => + __$$LocationTagImplCopyWithImpl<_$LocationTagImpl>(this, _$identity); @override Map toJson() { - return _$$_LocationTagToJson( + return _$$LocationTagImplToJson( this, ); } @@ -229,11 +229,11 @@ abstract class _LocationTag extends LocationTag { required final double radius, required final double aSquare, required final double bSquare, - required final Location centerPoint}) = _$_LocationTag; + required final Location centerPoint}) = _$LocationTagImpl; const _LocationTag._() : super._(); factory _LocationTag.fromJson(Map json) = - _$_LocationTag.fromJson; + _$LocationTagImpl.fromJson; @override String get name; @@ -247,6 +247,6 @@ abstract class _LocationTag extends LocationTag { Location get centerPoint; @override @JsonKey(ignore: true) - _$$_LocationTagCopyWith<_$_LocationTag> get copyWith => + _$$LocationTagImplCopyWith<_$LocationTagImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/mobile/lib/models/location_tag/location_tag.g.dart b/mobile/lib/models/location_tag/location_tag.g.dart index 2f159bf99..06ccad402 100644 --- a/mobile/lib/models/location_tag/location_tag.g.dart +++ b/mobile/lib/models/location_tag/location_tag.g.dart @@ -6,8 +6,8 @@ part of 'location_tag.dart'; // JsonSerializableGenerator // ************************************************************************** -_$_LocationTag _$$_LocationTagFromJson(Map json) => - _$_LocationTag( +_$LocationTagImpl _$$LocationTagImplFromJson(Map json) => + _$LocationTagImpl( name: json['name'] as String, radius: (json['radius'] as num).toDouble(), aSquare: (json['aSquare'] as num).toDouble(), @@ -16,7 +16,7 @@ _$_LocationTag _$$_LocationTagFromJson(Map json) => Location.fromJson(json['centerPoint'] as Map), ); -Map _$$_LocationTagToJson(_$_LocationTag instance) => +Map _$$LocationTagImplToJson(_$LocationTagImpl instance) => { 'name': instance.name, 'radius': instance.radius, diff --git a/mobile/lib/models/typedefs.dart b/mobile/lib/models/typedefs.dart index d358180da..284d3a244 100644 --- a/mobile/lib/models/typedefs.dart +++ b/mobile/lib/models/typedefs.dart @@ -18,3 +18,4 @@ typedef VoidCallbackParamSearchResutlsStream = void Function( typedef FutureVoidCallback = Future Function(); typedef FutureOrVoidCallback = FutureOr Function(); typedef FutureVoidCallbackParamStr = Future Function(String); +typedef FutureVoidCallbackParamBool = Future Function(bool); diff --git a/mobile/lib/services/collections_service.dart b/mobile/lib/services/collections_service.dart index ae8ae150f..8b82f6576 100644 --- a/mobile/lib/services/collections_service.dart +++ b/mobile/lib/services/collections_service.dart @@ -28,9 +28,11 @@ 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/services/app_lifecycle_service.dart'; import "package:photos/services/favorites_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/remote_sync_service.dart'; @@ -1148,11 +1150,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 (!FeatureFlagService.instance.isInternalUserOrDebugBuild()) { + 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 +1213,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 +1317,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 +1655,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/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/machine_learning/semantic_search/frameworks/ml_framework.dart b/mobile/lib/services/machine_learning/semantic_search/frameworks/ml_framework.dart index 3abd57db7..d3736d768 100644 --- a/mobile/lib/services/machine_learning/semantic_search/frameworks/ml_framework.dart +++ b/mobile/lib/services/machine_learning/semantic_search/frameworks/ml_framework.dart @@ -4,7 +4,6 @@ import "dart:io"; import "package:connectivity_plus/connectivity_plus.dart"; import "package:logging/logging.dart"; import "package:photos/core/errors.dart"; - import "package:photos/core/event_bus.dart"; import "package:photos/events/event.dart"; import "package:photos/services/remote_assets_service.dart"; @@ -23,7 +22,7 @@ abstract class MLFramework { MLFramework(this.shouldDownloadOverMobileData) { Connectivity() .onConnectivityChanged - .listen((ConnectivityResult result) async { + .listen((List result) async { _logger.info("Connectivity changed to $result"); if (_state == InitializationState.waitingForNetwork && await _canDownload()) { @@ -135,9 +134,11 @@ abstract class MLFramework { } Future _canDownload() async { - final connectivityResult = await (Connectivity().checkConnectivity()); - return connectivityResult != ConnectivityResult.mobile || - shouldDownloadOverMobileData; + final List connections = + await (Connectivity().checkConnectivity()); + final bool isConnectedToMobile = + connections.contains(ConnectivityResult.mobile); + return !isConnectedToMobile || shouldDownloadOverMobileData; } } 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/sync_service.dart b/mobile/lib/services/sync_service.dart index 057e600df..873270f34 100644 --- a/mobile/lib/services/sync_service.dart +++ b/mobile/lib/services/sync_service.dart @@ -45,7 +45,9 @@ class SyncService { sync(); }); - Connectivity().onConnectivityChanged.listen((ConnectivityResult result) { + Connectivity() + .onConnectivityChanged + .listen((List result) { _logger.info("Connectivity change detected " + result.toString()); if (Configuration.instance.hasConfiguredAccount()) { sync(); diff --git a/mobile/lib/services/update_service.dart b/mobile/lib/services/update_service.dart index 21a2c59bc..28c5732c8 100644 --- a/mobile/lib/services/update_service.dart +++ b/mobile/lib/services/update_service.dart @@ -16,7 +16,7 @@ class UpdateService { static final UpdateService instance = UpdateService._privateConstructor(); static const kUpdateAvailableShownTimeKey = "update_available_shown_time_key"; static const changeLogVersionKey = "update_change_log_key"; - static const currentChangeLogVersion = 17; + static const currentChangeLogVersion = 18; LatestVersionInfo? _latestVersion; final _logger = Logger("UpdateService"); diff --git a/mobile/lib/ui/account/email_entry_page.dart b/mobile/lib/ui/account/email_entry_page.dart index 888542f16..143f593ef 100644 --- a/mobile/lib/ui/account/email_entry_page.dart +++ b/mobile/lib/ui/account/email_entry_page.dart @@ -148,7 +148,9 @@ class _EmailEntryPageState extends State { style: Theme.of(context).textTheme.titleMedium, autofillHints: const [AutofillHints.email], decoration: InputDecoration( - fillColor: _emailIsValid ? _validFieldValueColor : null, + fillColor: _emailIsValid + ? _validFieldValueColor + : getEnteColorScheme(context).fillFaint, filled: true, hintText: S.of(context).email, contentPadding: const EdgeInsets.symmetric( @@ -195,8 +197,9 @@ class _EmailEntryPageState extends State { enableSuggestions: true, autofillHints: const [AutofillHints.newPassword], decoration: InputDecoration( - fillColor: - _passwordIsValid ? _validFieldValueColor : null, + fillColor: _passwordIsValid + ? _validFieldValueColor + : getEnteColorScheme(context).fillFaint, filled: true, hintText: S.of(context).password, contentPadding: const EdgeInsets.symmetric( @@ -265,7 +268,7 @@ class _EmailEntryPageState extends State { decoration: InputDecoration( fillColor: _passwordsMatch && _passwordIsValid ? _validFieldValueColor - : null, + : getEnteColorScheme(context).fillFaint, filled: true, hintText: S.of(context).confirmPassword, contentPadding: const EdgeInsets.symmetric( @@ -343,7 +346,7 @@ class _EmailEntryPageState extends State { child: TextFormField( style: Theme.of(context).textTheme.titleMedium, decoration: InputDecoration( - fillColor: null, + fillColor: getEnteColorScheme(context).fillFaint, filled: true, contentPadding: const EdgeInsets.symmetric( horizontal: 16, diff --git a/mobile/lib/ui/account/login_page.dart b/mobile/lib/ui/account/login_page.dart index 546a5f3b9..7d79dc856 100644 --- a/mobile/lib/ui/account/login_page.dart +++ b/mobile/lib/ui/account/login_page.dart @@ -29,18 +29,18 @@ class _LoginPageState extends State { final Logger _logger = Logger('_LoginPageState'); @override - void initState() { + void didChangeDependencies() { + super.didChangeDependencies(); if ((_config.getEmail() ?? '').isNotEmpty) { updateEmail(_config.getEmail()!); } else if (kDebugMode) { updateEmail(const String.fromEnvironment("email")); } - super.initState(); } @override Widget build(BuildContext context) { - final isKeypadOpen = MediaQuery.of(context).viewInsets.bottom > 100; + final isKeypadOpen = MediaQuery.viewInsetsOf(context).bottom > 100; FloatingActionButtonLocation? fabLocation() { if (isKeypadOpen) { @@ -237,7 +237,7 @@ class _LoginPageState extends State { if (_emailIsValid) { _emailInputFieldColor = const Color.fromRGBO(45, 194, 98, 0.2); } else { - _emailInputFieldColor = null; + _emailInputFieldColor = getEnteColorScheme(context).fillFaint; } } } diff --git a/mobile/lib/ui/account/login_pwd_verification_page.dart b/mobile/lib/ui/account/login_pwd_verification_page.dart index 884a5a4c7..ac974c20d 100644 --- a/mobile/lib/ui/account/login_pwd_verification_page.dart +++ b/mobile/lib/ui/account/login_pwd_verification_page.dart @@ -251,6 +251,7 @@ class _LoginPasswordVerificationPageState borderSide: BorderSide.none, borderRadius: BorderRadius.circular(6), ), + fillColor: getEnteColorScheme(context).fillFaint, suffixIcon: _passwordInFocus ? IconButton( icon: Icon( diff --git a/mobile/lib/ui/account/ott_verification_page.dart b/mobile/lib/ui/account/ott_verification_page.dart index 9dee22e16..d03861055 100644 --- a/mobile/lib/ui/account/ott_verification_page.dart +++ b/mobile/lib/ui/account/ott_verification_page.dart @@ -171,6 +171,7 @@ class _OTTVerificationPageState extends State { borderSide: BorderSide.none, borderRadius: BorderRadius.circular(6), ), + fillColor: getEnteColorScheme(context).fillFaint, ), controller: _verificationCodeController, autofocus: false, diff --git a/mobile/lib/ui/account/password_entry_page.dart b/mobile/lib/ui/account/password_entry_page.dart index a5099e333..54b7af850 100644 --- a/mobile/lib/ui/account/password_entry_page.dart +++ b/mobile/lib/ui/account/password_entry_page.dart @@ -12,6 +12,7 @@ import "package:photos/generated/l10n.dart"; import "package:photos/l10n/l10n.dart"; import "package:photos/models/key_gen_result.dart"; import 'package:photos/services/user_service.dart'; +import "package:photos/theme/ente_theme.dart"; import 'package:photos/ui/account/recovery_key_page.dart'; import 'package:photos/ui/common/dynamic_fab.dart'; import 'package:photos/ui/common/web_page.dart'; @@ -218,8 +219,9 @@ class _PasswordEntryPageState extends State { child: TextFormField( autofillHints: const [AutofillHints.newPassword], decoration: InputDecoration( - fillColor: - _isPasswordValid ? _validFieldValueColor : null, + fillColor: _isPasswordValid + ? _validFieldValueColor + : getEnteColorScheme(context).fillFaint, filled: true, hintText: S.of(context).password, contentPadding: const EdgeInsets.all(20), @@ -282,7 +284,9 @@ class _PasswordEntryPageState extends State { autofillHints: const [AutofillHints.newPassword], onEditingComplete: () => TextInput.finishAutofillContext(), decoration: InputDecoration( - fillColor: _passwordsMatch ? _validFieldValueColor : null, + fillColor: _passwordsMatch + ? _validFieldValueColor + : getEnteColorScheme(context).fillFaint, filled: true, hintText: S.of(context).confirmPassword, contentPadding: const EdgeInsets.symmetric( diff --git a/mobile/lib/ui/account/password_reentry_page.dart b/mobile/lib/ui/account/password_reentry_page.dart index cfbc30c10..d3f6be564 100644 --- a/mobile/lib/ui/account/password_reentry_page.dart +++ b/mobile/lib/ui/account/password_reentry_page.dart @@ -222,6 +222,7 @@ class _PasswordReentryPageState extends State { decoration: InputDecoration( hintText: S.of(context).enterYourPassword, filled: true, + fillColor: getEnteColorScheme(context).fillFaint, contentPadding: const EdgeInsets.all(20), border: UnderlineInputBorder( borderSide: BorderSide.none, diff --git a/mobile/lib/ui/account/recovery_page.dart b/mobile/lib/ui/account/recovery_page.dart index d5c651f92..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, ), ); @@ -103,6 +103,7 @@ class _RecoveryPageState extends State { child: TextFormField( decoration: InputDecoration( filled: true, + fillColor: getEnteColorScheme(context).fillFaint, hintText: S.of(context).enterYourRecoveryKey, contentPadding: const EdgeInsets.all(20), border: UnderlineInputBorder( diff --git a/mobile/lib/ui/account/request_pwd_verification_page.dart b/mobile/lib/ui/account/request_pwd_verification_page.dart index 9da169dea..e29d56886 100644 --- a/mobile/lib/ui/account/request_pwd_verification_page.dart +++ b/mobile/lib/ui/account/request_pwd_verification_page.dart @@ -174,6 +174,7 @@ class _RequestPasswordVerificationPageState decoration: InputDecoration( hintText: context.l10n.enterYourPassword, filled: true, + fillColor: getEnteColorScheme(context).fillFaint, contentPadding: const EdgeInsets.all(20), border: UnderlineInputBorder( borderSide: BorderSide.none, diff --git a/mobile/lib/ui/account/verify_recovery_page.dart b/mobile/lib/ui/account/verify_recovery_page.dart index 063d5f4b1..f4acc4738 100644 --- a/mobile/lib/ui/account/verify_recovery_page.dart +++ b/mobile/lib/ui/account/verify_recovery_page.dart @@ -11,6 +11,7 @@ import "package:photos/generated/l10n.dart"; import 'package:photos/services/local_authentication_service.dart'; import 'package:photos/services/user_remote_flag_service.dart'; import 'package:photos/services/user_service.dart'; +import "package:photos/theme/ente_theme.dart"; import 'package:photos/ui/account/recovery_key_page.dart'; import 'package:photos/ui/common/gradient_button.dart'; import 'package:photos/ui/components/buttons/button_widget.dart'; @@ -162,6 +163,7 @@ class _VerifyRecoveryPageState extends State { TextFormField( decoration: InputDecoration( filled: true, + fillColor: getEnteColorScheme(context).fillFaint, hintText: S.of(context).enterYourRecoveryKey, contentPadding: const EdgeInsets.all(20), border: UnderlineInputBorder( 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/components/toggle_switch_widget.dart b/mobile/lib/ui/components/toggle_switch_widget.dart index 9d1de1539..de6507c1f 100644 --- a/mobile/lib/ui/components/toggle_switch_widget.dart +++ b/mobile/lib/ui/components/toggle_switch_widget.dart @@ -1,3 +1,6 @@ +import "dart:io"; + +import "package:flutter/cupertino.dart"; import 'package:flutter/material.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/models/execution_states.dart'; @@ -49,59 +52,23 @@ class _ToggleSwitchWidgetState extends State { height: 31, child: FittedBox( fit: BoxFit.contain, - child: Switch.adaptive( - inactiveTrackColor: Colors.transparent, - activeTrackColor: enteColorScheme.primary500, - activeColor: Colors.white, - inactiveThumbColor: enteColorScheme.primary500, - trackOutlineColor: MaterialStateColor.resolveWith( - (states) => enteColorScheme.primary500, - ), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - value: toggleValue ?? false, - onChanged: (negationOfToggleValue) async { - setState(() { - toggleValue = negationOfToggleValue; - //start showing inProgress statu icons if toggle takes more than debounce time - _debouncer.run( - () => Future( - () { - setState(() { - executionState = ExecutionState.inProgress; - }); - }, + child: Platform.isAndroid + ? Switch( + inactiveTrackColor: Colors.transparent, + activeTrackColor: enteColorScheme.primary500, + activeColor: Colors.white, + inactiveThumbColor: enteColorScheme.primary500, + trackOutlineColor: MaterialStateColor.resolveWith( + (states) => enteColorScheme.primary500, ), - ); - }); - final Stopwatch stopwatch = Stopwatch()..start(); - await widget.onChanged.call().onError( - (error, stackTrace) => _debouncer.cancelDebounce(), - ); - //for toggle feedback on short unsuccessful onChanged - await _feedbackOnUnsuccessfulToggle(stopwatch); - //debouncer gets canceled if onChanged takes less than debounce time - _debouncer.cancelDebounce(); - - final newValue = widget.value.call(); - setState(() { - if (toggleValue == newValue) { - if (executionState == ExecutionState.inProgress) { - executionState = ExecutionState.successful; - Future.delayed(const Duration(seconds: 2), () { - if (mounted) { - setState(() { - executionState = ExecutionState.idle; - }); - } - }); - } - } else { - toggleValue = !toggleValue!; - executionState = ExecutionState.idle; - } - }); - }, - ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: toggleValue ?? false, + onChanged: _onChanged, + ) + : CupertinoSwitch( + value: toggleValue ?? false, + onChanged: _onChanged, + ), ), ), ], @@ -137,4 +104,47 @@ class _ToggleSwitchWidgetState extends State { ); } } + + Future _onChanged(bool negationOfToggleValue) async { + setState(() { + toggleValue = negationOfToggleValue; + //start showing inProgress statu icons if toggle takes more than debounce time + _debouncer.run( + () => Future( + () { + setState(() { + executionState = ExecutionState.inProgress; + }); + }, + ), + ); + }); + final Stopwatch stopwatch = Stopwatch()..start(); + await widget.onChanged.call().onError( + (error, stackTrace) => _debouncer.cancelDebounce(), + ); + //for toggle feedback on short unsuccessful onChanged + await _feedbackOnUnsuccessfulToggle(stopwatch); + //debouncer gets canceled if onChanged takes less than debounce time + _debouncer.cancelDebounce(); + + final newValue = widget.value.call(); + setState(() { + if (toggleValue == newValue) { + if (executionState == ExecutionState.inProgress) { + executionState = ExecutionState.successful; + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() { + executionState = ExecutionState.idle; + }); + } + }); + } + } else { + toggleValue = !toggleValue!; + executionState = ExecutionState.idle; + } + }); + } } 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/notification/update/change_log_page.dart b/mobile/lib/ui/notification/update/change_log_page.dart index 8cf629cd6..289d84590 100644 --- a/mobile/lib/ui/notification/update/change_log_page.dart +++ b/mobile/lib/ui/notification/update/change_log_page.dart @@ -122,14 +122,18 @@ class _ChangeLogPageState extends State { final List items = []; items.addAll([ ChangeLogEntry( - "Share an Album to Multiple Contacts at Once", - 'Adding multiple viewers and collaborators just got easier!\n' - '\nYou can now select multiple contacts and add all of them at once.', + "Improved Performance for Large Galleries ✨", + 'We\'ve made significant improvements to how quickly galleries load and' + ' with less stutter, especially for those with a lot of photos and videos.', ), ChangeLogEntry( - "Bug Fixes and Performance Improvements", - 'Many a bugs were squashed in this release and have improved performance on app start.\n' - '\nIf you run into any bugs, please write to team@ente.io, or let us know on Discord! 🙏', + "Enhanced Functionality for Video Backups", + 'Even if video backups are disabled, you can now manually upload individual videos.', + ), + ChangeLogEntry( + "Bug Fixes", + 'Many a bugs were squashed in this release.\n' + '\nIf you run into any, please write to team@ente.io, or let us know on Discord! 🙏', ), ]); 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/store_subscription_page.dart b/mobile/lib/ui/payment/store_subscription_page.dart index 4bcdd4d5b..3925bf017 100644 --- a/mobile/lib/ui/payment/store_subscription_page.dart +++ b/mobile/lib/ui/payment/store_subscription_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import "package:flutter/cupertino.dart"; import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; @@ -385,43 +386,49 @@ class _StoreSubscriptionPageState extends State { } Widget _showSubscriptionToggle() { - Widget planText(String title, bool reduceOpacity) { - return Padding( - padding: const EdgeInsets.only(left: 4, right: 4), - child: Text( - title, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(reduceOpacity ? 0.5 : 1.0), - ), - ), - ); - } - return Container( padding: const EdgeInsets.only(left: 8, right: 8, top: 2, bottom: 2), margin: const EdgeInsets.only(bottom: 6), child: Column( children: [ RepaintBoundary( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - planText(S.of(context).monthly, showYearlyPlan), - Switch( - value: showYearlyPlan, - activeColor: Colors.white, - inactiveThumbColor: Colors.white, - activeTrackColor: getEnteColorScheme(context).strokeMuted, - onChanged: (value) async { - showYearlyPlan = value; - await _filterStorePlansForUi(); - }, - ), - planText(S.of(context).yearly, !showYearlyPlan), - ], + child: SizedBox( + width: 250, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: SegmentedButton( + style: SegmentedButton.styleFrom( + selectedBackgroundColor: + getEnteColorScheme(context).fillMuted, + selectedForegroundColor: + getEnteColorScheme(context).textBase, + side: BorderSide( + color: getEnteColorScheme(context).strokeMuted, + width: 1, + ), + ), + segments: >[ + ButtonSegment( + label: Text(S.of(context).monthly), + value: false, + ), + ButtonSegment( + label: Text(S.of(context).yearly), + value: true, + ), + ], + selected: {showYearlyPlan}, + onSelectionChanged: (p0) { + showYearlyPlan = p0.first; + _filterStorePlansForUi(); + }, + ), + ), + ], + ), ), ), _isFreePlanUser() && !UpdateService.instance.isPlayStoreFlavor() diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index 4a04d49d1..31694f174 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import "package:flutter/cupertino.dart"; import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; import "package:logging/logging.dart"; @@ -537,43 +538,49 @@ class _StripeSubscriptionPageState extends State { } Widget _showSubscriptionToggle() { - Widget planText(String title, bool reduceOpacity) { - return Padding( - padding: const EdgeInsets.only(left: 4, right: 4), - child: Text( - title, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(reduceOpacity ? 0.5 : 1.0), - ), - ), - ); - } - return Container( padding: const EdgeInsets.only(left: 8, right: 8, top: 2, bottom: 2), margin: const EdgeInsets.only(bottom: 6), child: Column( children: [ RepaintBoundary( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - planText(S.of(context).monthly, _showYearlyPlan), - Switch( - value: _showYearlyPlan, - activeColor: Colors.white, - inactiveThumbColor: Colors.white, - activeTrackColor: getEnteColorScheme(context).strokeMuted, - onChanged: (value) async { - _showYearlyPlan = value; - await _filterStripeForUI(); - }, - ), - planText(S.of(context).yearly, !_showYearlyPlan), - ], + child: SizedBox( + width: 250, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: SegmentedButton( + style: SegmentedButton.styleFrom( + selectedBackgroundColor: + getEnteColorScheme(context).fillMuted, + selectedForegroundColor: + getEnteColorScheme(context).textBase, + side: BorderSide( + color: getEnteColorScheme(context).strokeMuted, + width: 1, + ), + ), + segments: >[ + ButtonSegment( + label: Text(S.of(context).monthly), + value: false, + ), + ButtonSegment( + label: Text(S.of(context).yearly), + value: true, + ), + ], + selected: {_showYearlyPlan}, + onSelectionChanged: (p0) { + _showYearlyPlan = p0.first; + _filterStripeForUI(); + }, + ), + ), + ], + ), ), ), _isFreePlanUser() && !UpdateService.instance.isPlayStoreFlavor() 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/sharing/manage_links_widget.dart b/mobile/lib/ui/sharing/manage_links_widget.dart index 02878735e..3478d1864 100644 --- a/mobile/lib/ui/sharing/manage_links_widget.dart +++ b/mobile/lib/ui/sharing/manage_links_widget.dart @@ -15,6 +15,7 @@ import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/divider_widget.dart'; import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/components/menu_section_description_widget.dart'; +import "package:photos/ui/components/toggle_switch_widget.dart"; import 'package:photos/ui/sharing/pickers/device_limit_picker_page.dart'; import 'package:photos/ui/sharing/pickers/link_expiry_picker_page.dart'; import 'package:photos/utils/crypto_util.dart'; @@ -78,14 +79,12 @@ class _ManageSharedLinkWidgetState extends State { ), alignCaptionedTextToLeft: true, menuItemColor: getEnteColorScheme(context).fillFaint, - trailingWidget: Switch.adaptive( - value: widget.collection!.publicURLs?.firstOrNull - ?.enableCollect ?? - false, - onChanged: (value) async { + trailingWidget: ToggleSwitchWidget( + value: () => isCollectEnabled, + onChanged: () async { await _updateUrlSettings( context, - {'enableCollect': value}, + {'enableCollect': !isCollectEnabled}, ); }, ), @@ -168,14 +167,14 @@ class _ManageSharedLinkWidgetState extends State { isBottomBorderRadiusRemoved: true, isTopBorderRadiusRemoved: true, menuItemColor: getEnteColorScheme(context).fillFaint, - trailingWidget: Switch.adaptive( - value: isDownloadEnabled, - onChanged: (value) async { + trailingWidget: ToggleSwitchWidget( + value: () => isDownloadEnabled, + onChanged: () async { await _updateUrlSettings( context, - {'enableDownload': value}, + {'enableDownload': !isDownloadEnabled}, ); - if (!value) { + if (!isDownloadEnabled) { // ignore: unawaited_futures showErrorDialog( context, @@ -198,10 +197,10 @@ class _ManageSharedLinkWidgetState extends State { alignCaptionedTextToLeft: true, isTopBorderRadiusRemoved: true, menuItemColor: getEnteColorScheme(context).fillFaint, - trailingWidget: Switch.adaptive( - value: isPasswordEnabled, - onChanged: (enablePassword) async { - if (enablePassword) { + trailingWidget: ToggleSwitchWidget( + value: () => isPasswordEnabled, + onChanged: () async { + if (!isPasswordEnabled) { // ignore: unawaited_futures showTextInputDialog( context, diff --git a/mobile/lib/ui/tabs/home_widget.dart b/mobile/lib/ui/tabs/home_widget.dart index 802b40be8..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, @@ -325,6 +341,7 @@ class _HomeWidgetState extends State { constraints: const BoxConstraints(maxWidth: 430), child: Drawer( width: double.infinity, + shape: const RoundedRectangleBorder(), child: _settingsPage, ), ) @@ -340,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 fe05e12b4..c9af24f71 100644 --- a/mobile/lib/ui/tools/app_lock.dart +++ b/mobile/lib/ui/tools/app_lock.dart @@ -138,9 +138,9 @@ class _AppLockState extends State with WidgetsBindingObserver { } Widget get _lockScreen { - return WillPopScope( + return PopScope( + canPop: false, child: this.widget.lockScreen, - onWillPop: () => Future.value(false), ); } diff --git a/mobile/lib/ui/tools/editor/image_editor_page.dart b/mobile/lib/ui/tools/editor/image_editor_page.dart index ca36db002..4830df952 100644 --- a/mobile/lib/ui/tools/editor/image_editor_page.dart +++ b/mobile/lib/ui/tools/editor/image_editor_page.dart @@ -63,14 +63,14 @@ class _ImageEditorPageState extends State { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async { + return PopScope( + canPop: false, + onPopInvoked: (didPop) async { if (_hasBeenEdited()) { await _showExitConfirmationDialog(context); } else { replacePage(context, DetailPage(widget.detailPageConfig)); } - return false; }, child: Scaffold( appBar: AppBar( diff --git a/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart b/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart index bb5e77818..33417f14f 100644 --- a/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart +++ b/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart @@ -19,6 +19,7 @@ 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/services/collections_service.dart'; +import "package:photos/services/feature_flag_service.dart"; import 'package:photos/services/hidden_service.dart'; import 'package:photos/services/machine_learning/face_ml/feedback/cluster_feedback.dart'; import "package:photos/services/machine_learning/face_ml/person/person_service.dart"; @@ -69,6 +70,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 @@ -79,6 +81,7 @@ class _FileSelectionActionsWidgetState @override void initState() { currentUserID = Configuration.instance.getUserID()!; + split = FilesSplit.split([], currentUserID); widget.selectedFiles.addListener(_selectFileChangeListener); collectionActions = CollectionActions(CollectionsService.instance); @@ -105,6 +108,7 @@ class _FileSelectionActionsWidgetState @override Widget build(BuildContext context) { + _isInternalUser = FeatureFlagService.instance.isInternalUserOrDebugBuild(); final ownedFilesCount = split.ownedByCurrentUser.length; final ownedAndPendingUploadFilesCount = ownedFilesCount + split.pendingUploads.length; @@ -167,13 +171,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 { @@ -181,8 +186,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, ), ); } @@ -472,7 +477,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..98ed03f7a 100644 --- a/mobile/lib/ui/viewer/file/file_app_bar.dart +++ b/mobile/lib/ui/viewer/file/file_app_bar.dart @@ -19,6 +19,7 @@ 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/services/collections_service.dart'; +import "package:photos/services/feature_flag_service.dart"; import 'package:photos/services/hidden_service.dart'; import 'package:photos/services/ignored_files_service.dart'; import 'package:photos/services/local_sync_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,11 @@ class FileAppBarState extends State { ); } // only show fav option for files owned by the user - if (isOwnedByUser && !isFileHidden && isFileUploaded) { - actions.add( + if ((isOwnedByUser || + FeatureFlagService.instance.isInternalUserOrDebugBuild()) && + !isFileHidden && + isFileUploaded) { + _actions.add( Padding( padding: const EdgeInsets.all(8), child: FavoriteWidget(widget.file), @@ -126,7 +153,7 @@ class FileAppBarState extends State { ); } if (!isFileUploaded) { - actions.add( + _actions.add( UploadIconWidget( file: widget.file, key: ValueKey(widget.file.tag), @@ -241,7 +268,7 @@ class FileAppBarState extends State { } } if (items.isNotEmpty) { - actions.add( + _actions.add( PopupMenuButton( itemBuilder: (context) { return items; @@ -262,13 +289,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_details/favorite_widget.dart b/mobile/lib/ui/viewer/file_details/favorite_widget.dart index 15fb7397c..c2ad0e771 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,70 @@ 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: 12, + ) // 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/search/search_widget.dart b/mobile/lib/ui/viewer/search/search_widget.dart index 8eecbb81f..c917d60e9 100644 --- a/mobile/lib/ui/viewer/search/search_widget.dart +++ b/mobile/lib/ui/viewer/search/search_widget.dart @@ -6,6 +6,7 @@ import "package:logging/logging.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/generic_search_result.dart"; import "package:photos/models/search/index_of_indexed_stack.dart"; import "package:photos/models/search/search_result.dart"; @@ -133,18 +134,16 @@ 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( borderSide: BorderSide.none, ), @@ -163,6 +162,9 @@ class SearchWidgetState extends State { minHeight: 44, minWidth: 44, ), + contentPadding: const EdgeInsets.symmetric( + vertical: 8, + ), prefixIcon: Hero( tag: "search_icon", child: Icon( @@ -170,6 +172,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 c43229152..d5518c4c4 100644 --- a/mobile/lib/utils/dialog_util.dart +++ b/mobile/lib/utils/dialog_util.dart @@ -295,7 +295,6 @@ ProgressDialog createProgressDialog( context, type: ProgressDialogType.normal, isDismissible: isDismissible, - barrierColor: Colors.black12, ); dialog.style( message: message, 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 7c898f985..adf5847f3 100644 --- a/mobile/lib/utils/file_uploader.dart +++ b/mobile/lib/utils/file_uploader.dart @@ -29,6 +29,7 @@ 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'; @@ -37,6 +38,7 @@ import 'package:photos/utils/crypto_util.dart'; import 'package:photos/utils/file_download_util.dart'; import 'package:photos/utils/file_uploader_util.dart'; import "package:photos/utils/file_util.dart"; +import "package:photos/utils/multipart_upload_util.dart"; import 'package:shared_preferences/shared_preferences.dart'; import 'package:tuple/tuple.dart'; import "package:uuid/uuid.dart"; @@ -170,7 +172,7 @@ class FileUploader { ); return CollectionsService.instance - .addToCollection(collectionID, [uploadedFile]).then((aVoid) { + .addOrCopyToCollection(collectionID, [uploadedFile]).then((aVoid) { return uploadedFile; }); }); @@ -353,9 +355,10 @@ class FileUploader { if (isForceUpload) { return; } - final connectivityResult = await (Connectivity().checkConnectivity()); + final List connections = + await (Connectivity().checkConnectivity()); bool canUploadUnderCurrentNetworkConditions = true; - if (connectivityResult == ConnectivityResult.mobile) { + if (connections.any((element) => element == ConnectivityResult.mobile)) { canUploadUnderCurrentNetworkConditions = Configuration.instance.shouldBackupOverMobileData(); } @@ -492,8 +495,23 @@ class FileUploader { final String thumbnailObjectKey = await _putFile(thumbnailUploadURL, encryptedThumbnailFile); - final fileUploadURL = await _getUploadURL(); - final String fileObjectKey = await _putFile(fileUploadURL, encryptedFile); + // 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() + ? await calculatePartCount( + await encryptedFile.length(), + ) + : 1; + + late String fileObjectKey; + + if (count <= 1) { + final fileUploadURL = await _getUploadURL(); + fileObjectKey = await _putFile(fileUploadURL, encryptedFile); + } else { + final fileUploadURLs = await getMultipartUploadURLs(count); + fileObjectKey = await putMultipartFile(fileUploadURLs, encryptedFile); + } final metadata = await file.getMetadataForUpload(mediaUploadData); final encryptedMetadataResult = await CryptoUtil.encryptChaCha( diff --git a/mobile/lib/utils/multipart_upload_util.dart b/mobile/lib/utils/multipart_upload_util.dart new file mode 100644 index 000000000..6e0eda8ca --- /dev/null +++ b/mobile/lib/utils/multipart_upload_util.dart @@ -0,0 +1,157 @@ +// ignore_for_file: implementation_imports + +import "dart:io"; + +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/utils/xml_parser_util.dart"; + +final _enteDio = NetworkClient.instance.enteDio; +final _dio = NetworkClient.instance.getDio(); + +class PartETag extends XmlParsableObject { + final int partNumber; + final String eTag; + + PartETag(this.partNumber, this.eTag); + + @override + String get elementName => "Part"; + + @override + Map toMap() { + return { + "PartNumber": partNumber, + "ETag": eTag, + }; + } +} + +class MultipartUploadURLs { + final String objectKey; + final List partsURLs; + final String completeURL; + + MultipartUploadURLs({ + required this.objectKey, + required this.partsURLs, + required this.completeURL, + }); + + factory MultipartUploadURLs.fromMap(Map map) { + return MultipartUploadURLs( + objectKey: map["urls"]["objectKey"], + partsURLs: (map["urls"]["partURLs"] as List).cast(), + completeURL: map["urls"]["completeURL"], + ); + } +} + +Future calculatePartCount(int fileSize) async { + final partCount = (fileSize / multipartPartSize).ceil(); + return partCount; +} + +Future getMultipartUploadURLs(int count) async { + try { + assert( + FeatureFlagService.instance.isInternalUserOrDebugBuild(), + "Multipart upload should not be enabled for external users.", + ); + final response = await _enteDio.get( + "/files/multipart-upload-urls", + queryParameters: { + "count": count, + }, + ); + + return MultipartUploadURLs.fromMap(response.data); + } on Exception catch (e) { + Logger("MultipartUploadURL").severe(e); + rethrow; + } +} + +Future putMultipartFile( + MultipartUploadURLs urls, + File encryptedFile, +) async { + // upload individual parts and get their etags + final etags = await uploadParts(urls.partsURLs, encryptedFile); + + // complete the multipart upload + await completeMultipartUpload(etags, urls.completeURL); + + return urls.objectKey; +} + +Future> uploadParts( + List partsURLs, + File encryptedFile, +) async { + final partsLength = partsURLs.length; + final etags = {}; + + for (int i = 0; i < partsLength; i++) { + final partURL = partsURLs[i]; + final isLastPart = i == partsLength - 1; + final fileSize = isLastPart + ? encryptedFile.lengthSync() % multipartPartSize + : multipartPartSize; + + final response = await _dio.put( + partURL, + data: encryptedFile.openRead( + i * multipartPartSize, + isLastPart ? null : multipartPartSize, + ), + options: Options( + headers: { + Headers.contentLengthHeader: fileSize, + }, + ), + ); + + final eTag = response.headers.value("etag"); + + if (eTag?.isEmpty ?? true) { + throw Exception('ETAG_MISSING'); + } + + etags[i] = eTag!; + } + + return etags; +} + +Future completeMultipartUpload( + Map partEtags, + String completeURL, +) async { + final body = convertJs2Xml({ + 'CompleteMultipartUpload': partEtags.entries + .map( + (e) => PartETag( + e.key + 1, + e.value, + ), + ) + .toList(), + }).replaceAll('"', '').replaceAll('"', ''); + + try { + await _dio.post( + completeURL, + data: body, + options: Options( + contentType: "text/xml", + ), + ); + } catch (e) { + Logger("MultipartUpload").severe(e); + rethrow; + } +} diff --git a/mobile/lib/utils/xml_parser_util.dart b/mobile/lib/utils/xml_parser_util.dart new file mode 100644 index 000000000..9490fc40c --- /dev/null +++ b/mobile/lib/utils/xml_parser_util.dart @@ -0,0 +1,41 @@ +// ignore_for_file: implementation_imports + +import "package:xml/xml.dart"; + +// used for classes that can be converted to xml +abstract class XmlParsableObject { + Map toMap(); + String get elementName; +} + +// for converting the response to xml +String convertJs2Xml(Map json) { + final builder = XmlBuilder(); + buildXml(builder, json); + return builder.buildDocument().toXmlString( + pretty: true, + indent: ' ', + ); +} + +// for building the xml node tree recursively +void buildXml(XmlBuilder builder, dynamic node) { + if (node is Map) { + node.forEach((key, value) { + builder.element(key, nest: () => buildXml(builder, value)); + }); + } else if (node is List) { + for (var item in node) { + buildXml(builder, item); + } + } else if (node is XmlParsableObject) { + builder.element( + node.elementName, + nest: () { + buildXml(builder, node.toMap()); + }, + ); + } else { + builder.text(node.toString()); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 889b6003f..0da864aeb 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.5.0" async: dependency: transitive description: @@ -113,38 +113,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.6" - bonsoir: - dependency: transitive - description: - name: bonsoir - sha256: "800d77c0581fff06cc43ef2b7723dfe5ee9b899ab0fdf80fb1c7b8829a5deb5c" - url: "https://pub.dev" - source: hosted - version: "3.0.0+1" - bonsoir_android: - dependency: transitive - description: - name: bonsoir_android - sha256: "7207c36fd7e0f3c7c2d8cf353f02bd640d96e2387d575837f8ac051c9cbf4aa7" - url: "https://pub.dev" - source: hosted - version: "3.0.0+1" - bonsoir_darwin: - dependency: transitive - description: - name: bonsoir_darwin - sha256: "7211042c85da2d6efa80c0976bbd9568f2b63624097779847548ed4530675ade" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - bonsoir_platform_interface: - dependency: transitive - description: - name: bonsoir_platform_interface - sha256: "64d57cd52bd477b4891e9b9d419e6408da171ed9e0efc8aa716e7e343d5d93ad" - url: "https://pub.dev" - source: hosted - version: "3.0.0" boolean_selector: dependency: transitive description: @@ -241,14 +209,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - cast: - dependency: "direct main" - description: - name: cast - sha256: b70f6be547a53481dffec93ad3cc4974fae5ed707f0b677d4a50c329d7299b98 - url: "https://pub.dev" - source: hosted - version: "2.0.0" characters: dependency: transitive description: @@ -326,18 +286,18 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "77a180d6938f78ca7d2382d2240eb626c0f6a735d0bfdce227d8ffb80f95c48b" + sha256: ebe15d94de9dd7c31dc2ac54e42780acdf3384b1497c69290c9f3c5b0279fc57 url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "6.0.2" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a + sha256: b6a56efe1e6675be240de39107281d4034b64ac23438026355b4234042a35adb url: "https://pub.dev" source: hosted - version: "1.2.4" + version: "2.0.0" convert: dependency: transitive description: @@ -822,10 +782,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "592dc01a18961a51c24ae5d963b724b2b7fa4a95c100fe8eb6ca8a5a4732cadf" + sha256: "8cf40eebf5dec866a6d1956ad7b4f7016e6c0cc69847ab946833b7d43743809f" url: "https://pub.dev" source: hosted - version: "2.0.18" + version: "2.0.19" flutter_secure_storage: dependency: "direct main" description: @@ -1069,10 +1029,10 @@ packages: dependency: transitive description: name: image_editor_ohos - sha256: "55c08871814efdd19b3927327b5913649dd1ea36e0a83aa77ab668dad3160dcc" + sha256: aee8fa1490fedbb98583dfaebb4162c295abeb0044e94f2eb2ad52ae419e6f6e url: "https://pub.dev" source: hosted - version: "0.0.6" + version: "0.0.7" image_editor_platform_interface: dependency: transitive description: @@ -1589,18 +1549,18 @@ packages: dependency: "direct main" description: name: path_provider - sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b + sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "51f0d2c554cfbc9d6a312ab35152fc77e2f0b758ce9f1a444a3a1e5b8f3c6b7f" + sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.4" path_provider_foundation: dependency: transitive description: @@ -1941,18 +1901,18 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" shared_preferences_foundation: dependency: transitive description: @@ -2111,7 +2071,7 @@ packages: source: hosted version: "0.3.0" sqlite3: - dependency: "direct main" + dependency: transitive description: name: sqlite3 sha256: "072128763f1547e3e9b4735ce846bfd226d68019ccda54db4cd427b12dfdedc9" @@ -2346,18 +2306,18 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "0ecc004c62fd3ed36a2ffcbe0dd9700aee63bd7532d0b642a488b1ec310f492e" + sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" url: "https://pub.dev" source: hosted - version: "6.2.5" + version: "6.2.6" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: d4ed0711849dd8e33eb2dd69c25db0d0d3fdc37e0a62e629fe32f57a22db2745 + sha256: "360a6ed2027f18b73c8d98e159dda67a61b7f2e0f6ec26e86c3ada33b0621775" url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.1" url_launcher_ios: dependency: transitive description: @@ -2435,18 +2395,18 @@ packages: dependency: transitive description: name: video_player_android - sha256: "4dd9b8b86d70d65eecf3dcabfcdfbb9c9115d244d022654aba49a00336d540c2" + sha256: "821cff3446bbde255e8d03c12fe1f9810c69fee2c26c394545b13d824ba63c2e" url: "https://pub.dev" source: hosted - version: "2.4.12" + version: "2.4.13" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "309e3962795e761be010869bae65c0b0e45b5230c5cee1bec72197ca7db040ed" + sha256: "00c49b1d68071341397cf760b982c1e26ed9232464c8506ee08378a5cca5070d" url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.5.7" video_player_platform_interface: dependency: transitive description: @@ -2608,7 +2568,7 @@ packages: source: hosted version: "1.0.4" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 10a7c1460..22ccbe8eb 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.78+598 +version: 0.8.84+604 publish_to: none environment: @@ -27,7 +27,6 @@ dependencies: battery_info: ^1.1.1 bip39: ^1.0.6 cached_network_image: ^3.0.0 - cast: ^2.0.0 chewie: git: url: https://github.com/ente-io/chewie.git @@ -37,11 +36,7 @@ dependencies: collection: # dart computer: git: "https://github.com/ente-io/computer.git" - connectivity_plus: - git: - url: https://github.com/ente-io/plus_plugins.git - ref: check_mobile_first - path: packages/connectivity_plus/connectivity_plus/ + connectivity_plus: ^6.0.2 cross_file: ^0.3.3 crypto: ^3.0.2 cupertino_icons: ^1.0.0 @@ -90,7 +85,7 @@ dependencies: flutter_sodium: ^0.2.0 flutter_staggered_grid_view: ^0.6.2 fluttertoast: ^8.0.6 - freezed_annotation: ^2.2.0 + freezed_annotation: ^2.4.1 google_nav_bar: ^5.0.5 home_widget: ^0.5.0 html_unescape: ^2.0.0 @@ -150,7 +145,6 @@ dependencies: simple_cluster: ^0.3.0 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 @@ -173,12 +167,11 @@ dependencies: wallpaper_manager_flutter: ^0.0.2 wechat_assets_picker: ^8.6.3 widgets_to_image: ^0.0.2 + xml: ^6.3.0 dependency_overrides: - connectivity_plus: ^4.0.0 - - #Remove this after upgrading to flutter v3.19x - #Build fails when resolving to latest version of ffi on flutter v3.16.x + # Remove this after removing dependency from flutter_sodium. + # Newer flutter packages depends on ffi > 2.0.0 while flutter_sodium depends on ffi < 2.0.0 ffi: 2.1.0 video_player: git: @@ -195,7 +188,7 @@ dev_dependencies: flutter_lints: ^2.0.1 flutter_test: sdk: flutter - freezed: ^2.3.2 + freezed: ^2.5.2 integration_test: sdk: flutter isar_generator: ^3.1.0+1 diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index 4cbc00612..8be76120d 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" @@ -163,7 +164,7 @@ func main() { ObjectCopiesRepo: objectCopiesRepo, UsageRepo: usageRepo} familyRepo := &repo.FamilyRepository{DB: db} trashRepo := &repo.TrashRepository{DB: db, ObjectRepo: objectRepo, FileRepo: fileRepo, QueueRepo: queueRepo} - publicCollectionRepo := &repo.PublicCollectionRepository{DB: db} + publicCollectionRepo := repo.NewPublicCollectionRepository(db, viper.GetString("apps.public-albums")) collectionRepo := &repo.CollectionRepository{DB: db, FileRepo: fileRepo, PublicCollectionRepo: publicCollectionRepo, TrashRepo: trashRepo, SecretEncryptionKey: secretEncryptionKeyBytes, QueueRepo: queueRepo, LatencyLogger: latencyLogger} pushRepo := &repo.PushTokenRepository{DB: db} @@ -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) diff --git a/server/configurations/local.yaml b/server/configurations/local.yaml index bbad3f278..7785f5601 100644 --- a/server/configurations/local.yaml +++ b/server/configurations/local.yaml @@ -71,6 +71,14 @@ http: # By default, this is false, and museum will bind to 8080 without TLS. # use-tls: true +# Specify the base endpoints for various apps +apps: + # 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: host: localhost 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/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/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/data_cleanup/controller.go b/server/pkg/controller/data_cleanup/controller.go index 05727ff0c..c6f2a1f81 100644 --- a/server/pkg/controller/data_cleanup/controller.go +++ b/server/pkg/controller/data_cleanup/controller.go @@ -190,8 +190,17 @@ func (c *DeleteUserCleanupController) storageCheck(ctx context.Context, item *en } func (c *DeleteUserCleanupController) isDeleted(item *entity.DataCleanup) error { - _, err := c.UserRepo.Get(item.UserID) + u, err := c.UserRepo.Get(item.UserID) if err == nil { + // user is not deleted, double check by verifying email is not empty + if u.Email != "" { + // todo: remove this logic after next deployment. This is to only handle cases + // where we have not removed scheduled delete entry for account post recovery. + remErr := c.Repo.RemoveScheduledDelete(context.Background(), item.UserID) + if remErr != nil { + return stacktrace.Propagate(remErr, "failed to remove scheduled delete entry") + } + } return stacktrace.Propagate(ente.NewBadRequestWithMessage("User ID is linked to undeleted account"), "") } if !errors.Is(err, ente.ErrUserDeleted) { diff --git a/server/pkg/controller/file.go b/server/pkg/controller/file.go index 12d173e25..a4ac4b1b7 100644 --- a/server/pkg/controller/file.go +++ b/server/pkg/controller/file.go @@ -59,25 +59,41 @@ const ( DeletedObjectQueueLock = "deleted_objects_queue_lock" ) -// Create adds an entry for a file in the respective tables -func (c *FileController) Create(ctx context.Context, userID int64, file ente.File, userAgent string, app ente.App) (ente.File, error) { +func (c *FileController) validateFileCreateOrUpdateReq(userID int64, file ente.File) error { objectPathPrefix := strconv.FormatInt(userID, 10) + "/" if !strings.HasPrefix(file.File.ObjectKey, objectPathPrefix) || !strings.HasPrefix(file.Thumbnail.ObjectKey, objectPathPrefix) { - return file, stacktrace.Propagate(ente.ErrBadRequest, "Incorrect object key reported") + return stacktrace.Propagate(ente.ErrBadRequest, "Incorrect object key reported") + } + if file.EncryptedKey == "" || file.KeyDecryptionNonce == "" { + return stacktrace.Propagate(ente.ErrBadRequest, "EncryptedKey and KeyDecryptionNonce are required") + } + if file.File.DecryptionHeader == "" || file.Thumbnail.DecryptionHeader == "" { + return stacktrace.Propagate(ente.ErrBadRequest, "DecryptionHeader for file & thumb is required") + } + if file.UpdationTime == 0 { + return stacktrace.Propagate(ente.ErrBadRequest, "UpdationTime is required") } collection, err := c.CollectionRepo.Get(file.CollectionID) if err != nil { - return file, stacktrace.Propagate(err, "") + return stacktrace.Propagate(err, "") } // Verify that user owns the collection. // Warning: Do not remove this check if collection.Owner.ID != userID || file.OwnerID != userID { - return file, stacktrace.Propagate(ente.ErrPermissionDenied, "") + return stacktrace.Propagate(ente.ErrPermissionDenied, "") } if collection.IsDeleted { - return file, stacktrace.Propagate(ente.ErrNotFound, "collection has been deleted") + return stacktrace.Propagate(ente.ErrNotFound, "collection has been deleted") } + return nil +} +// Create adds an entry for a file in the respective tables +func (c *FileController) Create(ctx context.Context, userID int64, file ente.File, userAgent string, app ente.App) (ente.File, error) { + err := c.validateFileCreateOrUpdateReq(userID, file) + if err != nil { + return file, stacktrace.Propagate(err, "") + } hotDC := c.S3Config.GetHotDataCenter() // sizeOf will do also HEAD check to ensure that the object exists in the // current hot DC @@ -115,7 +131,7 @@ func (c *FileController) Create(ctx context.Context, userID int64, file ente.Fil // all iz well var usage int64 - file, usage, err = c.FileRepo.Create(file, fileSize, thumbnailSize, fileSize+thumbnailSize, collection.Owner.ID, app) + file, usage, err = c.FileRepo.Create(file, fileSize, thumbnailSize, fileSize+thumbnailSize, userID, app) if err != nil { if err == ente.ErrDuplicateFileObjectFound || err == ente.ErrDuplicateThumbnailObjectFound { var existing ente.File @@ -144,9 +160,9 @@ func (c *FileController) Create(ctx context.Context, userID int64, file ente.Fil // Update verifies permissions and updates the specified file func (c *FileController) Update(ctx context.Context, userID int64, file ente.File, app ente.App) (ente.UpdateFileResponse, error) { var response ente.UpdateFileResponse - objectPathPrefix := strconv.FormatInt(userID, 10) + "/" - if !strings.HasPrefix(file.File.ObjectKey, objectPathPrefix) || !strings.HasPrefix(file.Thumbnail.ObjectKey, objectPathPrefix) { - return response, stacktrace.Propagate(ente.ErrBadRequest, "Incorrect object key reported") + err := c.validateFileCreateOrUpdateReq(userID, file) + if err != nil { + return response, stacktrace.Propagate(err, "") } ownerID, err := c.FileRepo.GetOwnerID(file.ID) if err != nil { diff --git a/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/public_collection.go b/server/pkg/controller/public_collection.go index 76a604937..694db8bb1 100644 --- a/server/pkg/controller/public_collection.go +++ b/server/pkg/controller/public_collection.go @@ -80,7 +80,7 @@ func (c *PublicCollectionController) CreateAccessToken(ctx context.Context, req } } response := ente.PublicURL{ - URL: fmt.Sprintf(repo.BaseShareURL, accessToken), + URL: c.PublicCollectionRepo.GetAlbumUrl(accessToken), ValidTill: req.ValidTill, DeviceLimit: req.DeviceLimit, EnableDownload: true, @@ -151,7 +151,7 @@ func (c *PublicCollectionController) UpdateSharedUrl(ctx context.Context, req en return ente.PublicURL{}, stacktrace.Propagate(err, "") } return ente.PublicURL{ - URL: fmt.Sprintf(repo.BaseShareURL, publicCollectionToken.Token), + URL: c.PublicCollectionRepo.GetAlbumUrl(publicCollectionToken.Token), DeviceLimit: publicCollectionToken.DeviceLimit, ValidTill: publicCollectionToken.ValidTill, EnableDownload: publicCollectionToken.EnableDownload, diff --git a/server/pkg/controller/user/user.go b/server/pkg/controller/user/user.go index afba09058..ddd6cf2de 100644 --- a/server/pkg/controller/user/user.go +++ b/server/pkg/controller/user/user.go @@ -368,7 +368,15 @@ func (c *UserController) HandleAccountRecovery(ctx *gin.Context, req ente.Recove return stacktrace.Propagate(err, "") } err = c.UserRepo.UpdateEmail(req.UserID, encryptedEmail, emailHash) - return stacktrace.Propagate(err, "failed to update email") + if err != nil { + return stacktrace.Propagate(err, "failed to update email") + } + err = c.DataCleanupRepo.RemoveScheduledDelete(ctx, req.UserID) + if err != nil { + logrus.WithError(err).Error("failed to remove scheduled delete") + return stacktrace.Propagate(err, "") + } + return stacktrace.Propagate(err, "") } func (c *UserController) attachFreeSubscription(userID int64) (ente.Subscription, error) { diff --git a/server/pkg/repo/collection.go b/server/pkg/repo/collection.go index 38700dec4..9310f33d4 100644 --- a/server/pkg/repo/collection.go +++ b/server/pkg/repo/collection.go @@ -216,7 +216,7 @@ pct.access_token, pct.valid_till, pct.device_limit, pct.created_at, pct.updated_ if _, ok := addPublicUrlMap[pctToken.String]; !ok { addPublicUrlMap[pctToken.String] = true url := ente.PublicURL{ - URL: fmt.Sprintf(BaseShareURL, pctToken.String), + URL: repo.PublicCollectionRepo.GetAlbumUrl(pctToken.String), DeviceLimit: int(pctDeviceLimit.Int32), ValidTill: pctValidTill.Int64, EnableDownload: pctEnableDownload.Bool, @@ -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/datacleanup/repository.go b/server/pkg/repo/datacleanup/repository.go index 4870cecf5..fc1a1c08f 100644 --- a/server/pkg/repo/datacleanup/repository.go +++ b/server/pkg/repo/datacleanup/repository.go @@ -3,7 +3,7 @@ package datacleanup import ( "context" "database/sql" - + "fmt" entity "github.com/ente-io/museum/ente/data_cleanup" "github.com/ente-io/museum/pkg/utils/time" "github.com/ente-io/stacktrace" @@ -19,6 +19,21 @@ func (r *Repository) Insert(ctx context.Context, userID int64) error { return stacktrace.Propagate(err, "failed to insert") } +func (r *Repository) RemoveScheduledDelete(ctx context.Context, userID int64) error { + res, execErr := r.DB.ExecContext(ctx, `DELETE from data_cleanup where user_id= $1 and stage = $2`, userID, entity.Scheduled) + if execErr != nil { + return execErr + } + affected, affErr := res.RowsAffected() + if affErr != nil { + return affErr + } + if affected != 1 { + return fmt.Errorf("only one row should have been affected, got %d", affected) + } + return nil +} + func (r *Repository) GetItemsPendingCompletion(ctx context.Context, limit int) ([]*entity.DataCleanup, error) { rows, err := r.DB.QueryContext(ctx, `SELECT user_id, stage, stage_schedule_time, stage_attempt_count, created_at, updated_at from data_cleanup where stage != $1 and stage_schedule_time < now_utc_micro_seconds() 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 0b1d2514f..6c6106a77 100644 --- a/server/pkg/repo/public_collection.go +++ b/server/pkg/repo/public_collection.go @@ -16,7 +16,23 @@ const BaseShareURL = "https://albums.ente.io?t=%s" // PublicCollectionRepository defines the methods for inserting, updating and // retrieving entities related to public collections type PublicCollectionRepository struct { - DB *sql.DB + DB *sql.DB + albumHost string +} + +// NewPublicCollectionRepository .. +func NewPublicCollectionRepository(db *sql.DB, albumHost string) *PublicCollectionRepository { + if albumHost == "" { + albumHost = "https://albums.ente.io" + } + return &PublicCollectionRepository{ + DB: db, + albumHost: albumHost, + } +} + +func (pcr *PublicCollectionRepository) GetAlbumUrl(token string) string { + return fmt.Sprintf("%s?t=%s", pcr.albumHost, token) } func (pcr *PublicCollectionRepository) Insert(ctx context.Context, @@ -59,7 +75,7 @@ func (pcr *PublicCollectionRepository) GetCollectionToActivePublicURLMap(ctx con if err = rows.Scan(&collectionID, &accessToken, &publicUrl.ValidTill, &publicUrl.DeviceLimit, &publicUrl.EnableDownload, &publicUrl.EnableCollect, &nonce, &memLimit, &opsLimit); err != nil { return nil, stacktrace.Propagate(err, "") } - publicUrl.URL = fmt.Sprintf(BaseShareURL, accessToken) + publicUrl.URL = pcr.GetAlbumUrl(accessToken) if nonce != nil { publicUrl.Nonce = nonce publicUrl.MemLimit = memLimit diff --git a/web/apps/cast/package.json b/web/apps/cast/package.json index ee318ef61..2437c6c14 100644 --- a/web/apps/cast/package.json +++ b/web/apps/cast/package.json @@ -3,11 +3,11 @@ "version": "0.0.0", "private": true, "dependencies": { + "@/media": "*", "@/next": "*", "@ente/accounts": "*", "@ente/eslint-config": "*", "@ente/shared": "*", - "jszip": "3.10.1", "mime-types": "^2.1.35" } } diff --git a/web/apps/cast/src/components/PhotoAuditorium.tsx b/web/apps/cast/src/components/PhotoAuditorium.tsx index 0042dfe95..6aa2c3990 100644 --- a/web/apps/cast/src/components/PhotoAuditorium.tsx +++ b/web/apps/cast/src/components/PhotoAuditorium.tsx @@ -1,50 +1,24 @@ -import { SlideshowContext } from "pages/slideshow"; -import { useContext, useEffect, useState } from "react"; +import { useEffect } from "react"; -export default function PhotoAuditorium({ - url, - nextSlideUrl, -}: { +interface PhotoAuditoriumProps { url: string; nextSlideUrl: string; -}) { - const { showNextSlide } = useContext(SlideshowContext); - - const [showPreloadedNextSlide, setShowPreloadedNextSlide] = useState(false); - const [nextSlidePrerendered, setNextSlidePrerendered] = useState(false); - const [prerenderTime, setPrerenderTime] = useState(null); - + showNextSlide: () => void; +} +export const PhotoAuditorium: React.FC = ({ + url, + nextSlideUrl, + showNextSlide, +}) => { useEffect(() => { - let timeout: NodeJS.Timeout; - let timeout2: NodeJS.Timeout; - - if (nextSlidePrerendered) { - const elapsedTime = prerenderTime ? Date.now() - prerenderTime : 0; - const delayTime = Math.max(10000 - elapsedTime, 0); - - if (elapsedTime >= 10000) { - setShowPreloadedNextSlide(true); - } else { - timeout = setTimeout(() => { - setShowPreloadedNextSlide(true); - }, delayTime); - } - - if (showNextSlide) { - timeout2 = setTimeout(() => { - showNextSlide(); - setNextSlidePrerendered(false); - setPrerenderTime(null); - setShowPreloadedNextSlide(false); - }, delayTime); - } - } + const timeoutId = window.setTimeout(() => { + showNextSlide(); + }, 10000); return () => { - if (timeout) clearTimeout(timeout); - if (timeout2) clearTimeout(timeout2); + if (timeoutId) clearTimeout(timeoutId); }; - }, [nextSlidePrerendered, showNextSlide, prerenderTime]); + }, [showNextSlide]); return (
- { - setNextSlidePrerendered(true); - setPrerenderTime(Date.now()); + /> +
); -} +}; diff --git a/web/apps/cast/src/components/Theatre/PhotoAuditorium.tsx b/web/apps/cast/src/components/Theatre/PhotoAuditorium.tsx deleted file mode 100644 index 0042dfe95..000000000 --- a/web/apps/cast/src/components/Theatre/PhotoAuditorium.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { SlideshowContext } from "pages/slideshow"; -import { useContext, useEffect, useState } from "react"; - -export default function PhotoAuditorium({ - url, - nextSlideUrl, -}: { - url: string; - nextSlideUrl: string; -}) { - const { showNextSlide } = useContext(SlideshowContext); - - const [showPreloadedNextSlide, setShowPreloadedNextSlide] = useState(false); - const [nextSlidePrerendered, setNextSlidePrerendered] = useState(false); - const [prerenderTime, setPrerenderTime] = useState(null); - - useEffect(() => { - let timeout: NodeJS.Timeout; - let timeout2: NodeJS.Timeout; - - if (nextSlidePrerendered) { - const elapsedTime = prerenderTime ? Date.now() - prerenderTime : 0; - const delayTime = Math.max(10000 - elapsedTime, 0); - - if (elapsedTime >= 10000) { - setShowPreloadedNextSlide(true); - } else { - timeout = setTimeout(() => { - setShowPreloadedNextSlide(true); - }, delayTime); - } - - if (showNextSlide) { - timeout2 = setTimeout(() => { - showNextSlide(); - setNextSlidePrerendered(false); - setPrerenderTime(null); - setShowPreloadedNextSlide(false); - }, delayTime); - } - } - - return () => { - if (timeout) clearTimeout(timeout); - if (timeout2) clearTimeout(timeout2); - }; - }, [nextSlidePrerendered, showNextSlide, prerenderTime]); - - return ( -
-
- - { - setNextSlidePrerendered(true); - setPrerenderTime(Date.now()); - }} - /> -
-
- ); -} diff --git a/web/apps/cast/src/components/Theatre/VideoAuditorium.tsx b/web/apps/cast/src/components/Theatre/VideoAuditorium.tsx deleted file mode 100644 index 2bf5ed490..000000000 --- a/web/apps/cast/src/components/Theatre/VideoAuditorium.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import mime from "mime-types"; -import { SlideshowContext } from "pages/slideshow"; -import { useContext, useEffect, useRef } from "react"; - -export default function VideoAuditorium({ - name, - url, -}: { - name: string; - url: string; -}) { - const { showNextSlide } = useContext(SlideshowContext); - - const videoRef = useRef(null); - - useEffect(() => { - attemptPlay(); - }, [url, videoRef]); - - const attemptPlay = async () => { - if (videoRef.current) { - try { - await videoRef.current.play(); - } catch { - showNextSlide(); - } - } - }; - - return ( -
- -
- ); -} diff --git a/web/apps/cast/src/components/Theatre/index.tsx b/web/apps/cast/src/components/Theatre/index.tsx deleted file mode 100644 index f7cac9c54..000000000 --- a/web/apps/cast/src/components/Theatre/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { FILE_TYPE } from "constants/file"; -import PhotoAuditorium from "./PhotoAuditorium"; -// import VideoAuditorium from './VideoAuditorium'; - -interface fileProp { - fileName: string; - fileURL: string; - type: FILE_TYPE; -} - -interface IProps { - file1: fileProp; - file2: fileProp; -} - -export default function Theatre(props: IProps) { - switch (props.file1.type && props.file2.type) { - case FILE_TYPE.IMAGE: - return ( - - ); - // case FILE_TYPE.VIDEO: - // return ( - // - // ); - } -} diff --git a/web/apps/cast/src/pages/slideshow.tsx b/web/apps/cast/src/pages/slideshow.tsx index 692e61154..774bbd4da 100644 --- a/web/apps/cast/src/pages/slideshow.tsx +++ b/web/apps/cast/src/pages/slideshow.tsx @@ -1,9 +1,9 @@ import log from "@/next/log"; import PairedSuccessfullyOverlay from "components/PairedSuccessfullyOverlay"; -import Theatre from "components/Theatre"; +import { PhotoAuditorium } from "components/PhotoAuditorium"; import { FILE_TYPE } from "constants/file"; import { useRouter } from "next/router"; -import { createContext, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { getCastCollection, getLocalFiles, @@ -13,25 +13,20 @@ import { Collection } from "types/collection"; import { EnteFile } from "types/file"; import { getPreviewableImage, isRawFileFromFileName } from "utils/file"; -export const SlideshowContext = createContext<{ - showNextSlide: () => void; -}>(null); - const renderableFileURLCache = new Map(); export default function Slideshow() { - const [collectionFiles, setCollectionFiles] = useState([]); - - const [currentFile, setCurrentFile] = useState( - undefined, - ); - const [nextFile, setNextFile] = useState(undefined); - const [loading, setLoading] = useState(true); const [castToken, setCastToken] = useState(""); const [castCollection, setCastCollection] = useState< Collection | undefined - >(undefined); + >(); + const [collectionFiles, setCollectionFiles] = useState([]); + const [currentFileId, setCurrentFileId] = useState(); + const [currentFileURL, setCurrentFileURL] = useState(); + const [nextFileURL, setNextFileURL] = useState(); + + const router = useRouter(); const syncCastFiles = async (token: string) => { try { @@ -72,29 +67,16 @@ export default function Slideshow() { const isFileEligibleForCast = (file: EnteFile) => { const fileType = file.metadata.fileType; - if (fileType !== FILE_TYPE.IMAGE && fileType !== FILE_TYPE.LIVE_PHOTO) { + if (fileType !== FILE_TYPE.IMAGE && fileType !== FILE_TYPE.LIVE_PHOTO) return false; - } - const fileSizeLimit = 100 * 1024 * 1024; + if (file.info.fileSize > 100 * 1024 * 1024) return false; - if (file.info.fileSize > fileSizeLimit) { - return false; - } - - const name = file.metadata.title; - - if (fileType === FILE_TYPE.IMAGE) { - if (isRawFileFromFileName(name)) { - return false; - } - } + if (isRawFileFromFileName(file.metadata.title)) return false; return true; }; - const router = useRouter(); - useEffect(() => { try { const castToken = window.localStorage.getItem("castToken"); @@ -117,9 +99,9 @@ export default function Slideshow() { showNextSlide(); }, [collectionFiles]); - const showNextSlide = () => { + const showNextSlide = async () => { const currentIndex = collectionFiles.findIndex( - (file) => file.id === currentFile?.id, + (file) => file.id === currentFileId, ); const nextIndex = (currentIndex + 1) % collectionFiles.length; @@ -128,63 +110,44 @@ export default function Slideshow() { const nextFile = collectionFiles[nextIndex]; const nextNextFile = collectionFiles[nextNextIndex]; - setCurrentFile(nextFile); - setNextFile(nextNextFile); + let nextURL = renderableFileURLCache.get(nextFile.id); + let nextNextURL = renderableFileURLCache.get(nextNextFile.id); + + if (!nextURL) { + try { + const blob = await getPreviewableImage(nextFile, castToken); + const url = URL.createObjectURL(blob); + renderableFileURLCache.set(nextFile.id, url); + nextURL = url; + } catch (e) { + return; + } + } + + if (!nextNextURL) { + try { + const blob = await getPreviewableImage(nextNextFile, castToken); + const url = URL.createObjectURL(blob); + renderableFileURLCache.set(nextNextFile.id, url); + nextNextURL = url; + } catch (e) { + return; + } + } + + setLoading(false); + setCurrentFileId(nextFile.id); + setCurrentFileURL(nextURL); + setNextFileURL(nextNextURL); }; - const [renderableFileURL, setRenderableFileURL] = useState(""); - - const getRenderableFileURL = async () => { - if (!currentFile) return; - - const cacheValue = renderableFileURLCache.get(currentFile.id); - if (cacheValue) { - setRenderableFileURL(cacheValue); - setLoading(false); - return; - } - - try { - const blob = await getPreviewableImage( - currentFile as EnteFile, - castToken, - ); - - const url = URL.createObjectURL(blob); - - renderableFileURLCache.set(currentFile?.id, url); - - setRenderableFileURL(url); - } catch (e) { - return; - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (currentFile) { - getRenderableFileURL(); - } - }, [currentFile]); + if (loading) return ; return ( - <> - - - - {loading && } - + ); } diff --git a/web/apps/cast/src/services/livePhotoService.ts b/web/apps/cast/src/services/livePhotoService.ts deleted file mode 100644 index 789234bd3..000000000 --- a/web/apps/cast/src/services/livePhotoService.ts +++ /dev/null @@ -1,32 +0,0 @@ -import JSZip from "jszip"; -import { EnteFile } from "types/file"; -import { - getFileExtensionWithDot, - getFileNameWithoutExtension, -} from "utils/file"; - -class LivePhoto { - image: Uint8Array; - video: Uint8Array; - imageNameTitle: string; - videoNameTitle: string; -} - -export const decodeLivePhoto = async (file: EnteFile, zipBlob: Blob) => { - const originalName = getFileNameWithoutExtension(file.metadata.title); - const zip = await JSZip.loadAsync(zipBlob, { createFolders: true }); - - const livePhoto = new LivePhoto(); - for (const zipFilename in zip.files) { - if (zipFilename.startsWith("image")) { - livePhoto.imageNameTitle = - originalName + getFileExtensionWithDot(zipFilename); - livePhoto.image = await zip.files[zipFilename].async("uint8array"); - } else if (zipFilename.startsWith("video")) { - livePhoto.videoNameTitle = - originalName + getFileExtensionWithDot(zipFilename); - livePhoto.video = await zip.files[zipFilename].async("uint8array"); - } - } - return livePhoto; -}; diff --git a/web/apps/cast/src/services/typeDetectionService.ts b/web/apps/cast/src/services/typeDetectionService.ts index c52e2d80c..5acd3844d 100644 --- a/web/apps/cast/src/services/typeDetectionService.ts +++ b/web/apps/cast/src/services/typeDetectionService.ts @@ -1,3 +1,4 @@ +import { nameAndExtension } from "@/next/file"; import log from "@/next/log"; import { CustomError } from "@ente/shared/error"; import { FILE_TYPE } from "constants/file"; @@ -7,7 +8,6 @@ import { } from "constants/upload"; import FileType from "file-type"; import { FileTypeInfo } from "types/upload"; -import { getFileExtension } from "utils/file"; import { getUint8ArrayView } from "./readerService"; const TYPE_VIDEO = "video"; @@ -40,7 +40,8 @@ export async function getFileType(receivedFile: File): Promise { mimeType: typeResult.mime, }; } catch (e) { - const fileFormat = getFileExtension(receivedFile.name); + const ne = nameAndExtension(receivedFile.name); + const fileFormat = ne[1].toLowerCase(); const whiteListedFormat = WHITELISTED_FILE_FORMATS.find( (a) => a.exactType === fileFormat, ); 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 caa15d743..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,32 +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 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), - ]; -} - -export function getFileExtension(filename: string) { - return splitFilenameAndExtension(filename)[1]?.toLocaleLowerCase(); -} - export function generateStreamFromArrayBuffer(data: Uint8Array) { return new ReadableStream({ async start(controller: ReadableStreamDefaultController) { @@ -129,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) { @@ -151,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/payments/README.md b/web/apps/payments/README.md index 959bedabe..ebf3a6390 100644 --- a/web/apps/payments/README.md +++ b/web/apps/payments/README.md @@ -73,8 +73,9 @@ stripe: key: stripe_dev_key webhook-secret: stripe_dev_webhook_secret whitelisted-redirect-urls: - - "http://localhost:3000/gallery" - - "http://192.168.1.2:3001/frameRedirect" + - http://localhost:3000/gallery + - http://localhost:3001/desktop-redirect + - http://192.168.1.2:3001/frameRedirect path: success: ?status=success&session_id={CHECKOUT_SESSION_ID} cancel: ?status=fail&reason=canceled diff --git a/web/apps/payments/src/services/billing-service.ts b/web/apps/payments/src/services/billing-service.ts index 35f67660c..c0ba5da1e 100644 --- a/web/apps/payments/src/services/billing-service.ts +++ b/web/apps/payments/src/services/billing-service.ts @@ -9,6 +9,14 @@ import { loadStripe } from "@stripe/stripe-js"; * redirect to the client or to some fallback URL. */ export const parseAndHandleRequest = async () => { + // See: [Note: Intercept payments redirection to desktop app] + if (window.location.pathname == "/desktop-redirect") { + const desktopRedirectURL = new URL("ente://app/gallery"); + desktopRedirectURL.search = new URL(window.location.href).search; + window.location.href = desktopRedirectURL.href; + return; + } + try { const urlParams = new URLSearchParams(window.location.search); const productID = urlParams.get("productID"); @@ -291,6 +299,8 @@ const redirectToApp = ( status: RedirectStatus, reason?: FailureReason, ) => { + // [Note: Intercept payments redirection to desktop app] + // // The desktop app passes "/desktop-redirect" as `redirectURL`. // This is just a placeholder, we want to intercept this and instead // redirect to the ente:// scheme protocol handler that is internally being diff --git a/web/apps/photos/.env b/web/apps/photos/.env index 2680ead9f..a039e9105 100644 --- a/web/apps/photos/.env +++ b/web/apps/photos/.env @@ -61,6 +61,9 @@ # on port 3002 (using `yarn dev:albums`), we can connect to it and emulate the # production behaviour. # +# Note: To use your custom albums endpoint in the generated public link, set the +# `apps.public-albums` property in museum's configuration. +# # Enhancement: Consider splitting this into a separate app/ in this repository. # That can also reduce bundle sizes and make it load faster. # diff --git a/web/apps/photos/.env.development b/web/apps/photos/.env.development index a8277281d..fd4d63c08 100644 --- a/web/apps/photos/.env.development +++ b/web/apps/photos/.env.development @@ -12,8 +12,14 @@ #NEXT_PUBLIC_ENTE_ENDPOINT = http://localhost:8080 # If you wish to preview how the shared albums work, you can use `yarn -# dev:albums`. The equivalent CLI command using env vars would be +# dev:albums`. You'll need to run two instances. + +# The equivalent CLI commands using env vars would be: # +# # For the normal web app +# NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:8080 NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT=http://localhost:3002 yarn dev:photos +# +# # For the albums app # NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:8080 NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT=http://localhost:3002 yarn dev:albums #NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT = http://localhost:3002 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/ExportInProgress.tsx b/web/apps/photos/src/components/ExportInProgress.tsx index ce2da895c..280ae52d4 100644 --- a/web/apps/photos/src/components/ExportInProgress.tsx +++ b/web/apps/photos/src/components/ExportInProgress.tsx @@ -10,9 +10,9 @@ import { LinearProgress, styled, } from "@mui/material"; -import { ExportStage } from "constants/export"; import { t } from "i18next"; import { Trans } from "react-i18next"; +import { ExportStage } from "services/export"; import { ExportProgress } from "types/export"; export const ComfySpan = styled("span")` diff --git a/web/apps/photos/src/components/ExportModal.tsx b/web/apps/photos/src/components/ExportModal.tsx index 877dee90f..ae0de37a6 100644 --- a/web/apps/photos/src/components/ExportModal.tsx +++ b/web/apps/photos/src/components/ExportModal.tsx @@ -14,12 +14,14 @@ import { Switch, Typography, } from "@mui/material"; -import { ExportStage } from "constants/export"; import { t } from "i18next"; import isElectron from "is-electron"; import { AppContext } from "pages/_app"; import { useContext, useEffect, useState } from "react"; -import exportService from "services/export"; +import exportService, { + ExportStage, + selectAndPrepareExportDirectory, +} from "services/export"; import { ExportProgress, ExportSettings } from "types/export"; import { EnteFile } from "types/file"; import { getExportDirectoryDoesNotExistMessage } from "utils/ui"; @@ -78,21 +80,6 @@ export default function ExportModal(props: Props) { void syncExportRecord(exportFolder); }, [props.show]); - // ============= - // STATE UPDATERS - // ============== - const updateExportFolder = (newFolder: string) => { - exportService.updateExportSettings({ folder: newFolder }); - setExportFolder(newFolder); - }; - - const updateContinuousExport = (updatedContinuousExport: boolean) => { - exportService.updateExportSettings({ - continuousExport: updatedContinuousExport, - }); - setContinuousExport(updatedContinuousExport); - }; - // ====================== // HELPER FUNCTIONS // ======================= @@ -102,8 +89,9 @@ export default function ExportModal(props: Props) { appContext.setDialogMessage( getExportDirectoryDoesNotExistMessage(), ); - throw Error(CustomError.EXPORT_FOLDER_DOES_NOT_EXIST); + return false; } + return true; }; const syncExportRecord = async (exportFolder: string): Promise => { @@ -132,42 +120,34 @@ export default function ExportModal(props: Props) { // ============= const handleChangeExportDirectoryClick = async () => { - try { - const newFolder = await exportService.changeExportDirectory(); - log.info(`Export folder changed to ${newFolder}`); - updateExportFolder(newFolder); - void syncExportRecord(newFolder); - } catch (e) { - if (e.message !== CustomError.SELECT_FOLDER_ABORTED) { - log.error("handleChangeExportDirectoryClick failed", e); - } - } + const newFolder = await selectAndPrepareExportDirectory(); + if (!newFolder) return; + + log.info(`Export folder changed to ${newFolder}`); + exportService.updateExportSettings({ folder: newFolder }); + setExportFolder(newFolder); + await syncExportRecord(newFolder); }; const toggleContinuousExport = async () => { - try { - await verifyExportFolderExists(); - const newContinuousExport = !continuousExport; - if (newContinuousExport) { - exportService.enableContinuousExport(); - } else { - exportService.disableContinuousExport(); - } - updateContinuousExport(newContinuousExport); - } catch (e) { - log.error("onContinuousExportChange failed", e); + if (!(await verifyExportFolderExists())) return; + + const newContinuousExport = !continuousExport; + if (newContinuousExport) { + exportService.enableContinuousExport(); + } else { + exportService.disableContinuousExport(); } + exportService.updateExportSettings({ + continuousExport: newContinuousExport, + }); + setContinuousExport(newContinuousExport); }; const startExport = async () => { - try { - await verifyExportFolderExists(); - await exportService.scheduleExport(); - } catch (e) { - if (e.message !== CustomError.EXPORT_FOLDER_DOES_NOT_EXIST) { - log.error("scheduleExport failed", e); - } - } + if (!(await verifyExportFolderExists())) return; + + await exportService.scheduleExport(); }; const stopExport = () => { diff --git a/web/apps/photos/src/components/MachineLearning/ImageViews.tsx b/web/apps/photos/src/components/MachineLearning/ImageViews.tsx deleted file mode 100644 index 72800271b..000000000 --- a/web/apps/photos/src/components/MachineLearning/ImageViews.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import log from "@/next/log"; -import { cached } from "@ente/shared/storage/cacheStorage/helpers"; -import { LS_KEYS, getData } from "@ente/shared/storage/localStorage"; -import { User } from "@ente/shared/user/types"; -import { Skeleton, styled } from "@mui/material"; -import { useEffect, useState } from "react"; -import machineLearningService from "services/machineLearning/machineLearningService"; -import { imageBitmapToBlob } from "utils/image"; - -export const FaceCropsRow = styled("div")` - & > img { - width: 256px; - height: 256px; - } -`; - -export const FaceImagesRow = styled("div")` - & > img { - width: 112px; - height: 112px; - } -`; - -export function ImageCacheView(props: { - url: string; - cacheName: string; - faceID: string; -}) { - const [imageBlob, setImageBlob] = useState(); - - useEffect(() => { - let didCancel = false; - async function loadImage() { - try { - const user: User = getData(LS_KEYS.USER); - let blob: Blob; - if (!props.url || !props.cacheName || !user) { - blob = undefined; - } else { - blob = await cached( - props.cacheName, - props.url, - async () => { - try { - log.debug( - () => - `ImageCacheView: regenerate face crop for ${props.faceID}`, - ); - return machineLearningService.regenerateFaceCrop( - user.token, - user.id, - props.faceID, - ); - } catch (e) { - log.error( - "ImageCacheView: regenerate face crop failed", - e, - ); - } - }, - ); - } - - !didCancel && setImageBlob(blob); - } catch (e) { - log.error("ImageCacheView useEffect failed", e); - } - } - loadImage(); - return () => { - didCancel = true; - }; - }, [props.url, props.cacheName]); - - return ( - <> - - - ); -} - -export function ImageBitmapView(props: { image: ImageBitmap }) { - const [imageBlob, setImageBlob] = useState(); - - useEffect(() => { - let didCancel = false; - - async function loadImage() { - const blob = props.image && (await imageBitmapToBlob(props.image)); - !didCancel && setImageBlob(blob); - } - - loadImage(); - return () => { - didCancel = true; - }; - }, [props.image]); - - return ( - <> - - - ); -} - -export function ImageBlobView(props: { blob: Blob }) { - const [imgUrl, setImgUrl] = useState(); - - useEffect(() => { - try { - setImgUrl(props.blob && URL.createObjectURL(props.blob)); - } catch (e) { - console.error( - "ImageBlobView: can not create object url for blob: ", - props.blob, - e, - ); - } - }, [props.blob]); - - return imgUrl ? ( - - ) : ( - - ); -} diff --git a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/enableFaceSearch.tsx b/web/apps/photos/src/components/MachineLearning/MLSearchSettings/enableFaceSearch.tsx deleted file mode 100644 index a007cb398..000000000 --- a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/enableFaceSearch.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { - Button, - Checkbox, - DialogProps, - FormControlLabel, - FormGroup, - Link, - Stack, - Typography, -} from "@mui/material"; -import { EnteDrawer } from "components/EnteDrawer"; -import Titlebar from "components/Titlebar"; -import { t } from "i18next"; -import { useEffect, useState } from "react"; -import { Trans } from "react-i18next"; -export default function EnableFaceSearch({ - open, - onClose, - enableFaceSearch, - onRootClose, -}) { - const [acceptTerms, setAcceptTerms] = useState(false); - - useEffect(() => { - setAcceptTerms(false); - }, [open]); - - const handleRootClose = () => { - onClose(); - onRootClose(); - }; - - const handleDrawerClose: DialogProps["onClose"] = (_, reason) => { - if (reason === "backdropClick") { - handleRootClose(); - } else { - onClose(); - } - }; - return ( - - - - - - - ), - }} - /> - - - - setAcceptTerms(e.target.checked) - } - /> - } - label={t("FACE_SEARCH_CONFIRMATION")} - /> - - - - - - - - - ); -} diff --git a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/enableMLSearch.tsx b/web/apps/photos/src/components/MachineLearning/MLSearchSettings/enableMLSearch.tsx deleted file mode 100644 index 1cd0a3b3f..000000000 --- a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/enableMLSearch.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Box, Button, Stack, Typography } from "@mui/material"; -import Titlebar from "components/Titlebar"; -import { t } from "i18next"; -import { Trans } from "react-i18next"; -import { openLink } from "utils/common"; - -export default function EnableMLSearch({ - onClose, - enableMlSearch, - onRootClose, -}) { - const showDetails = () => - openLink("https://ente.io/blog/desktop-ml-beta", true); - - return ( - - - - - {" "} - - - - - - - - - - - ); -} diff --git a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/index.tsx b/web/apps/photos/src/components/MachineLearning/MLSearchSettings/index.tsx deleted file mode 100644 index 9b33a984a..000000000 --- a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/index.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import log from "@/next/log"; -import { Box, DialogProps, Typography } from "@mui/material"; -import { EnteDrawer } from "components/EnteDrawer"; -import { t } from "i18next"; -import { AppContext } from "pages/_app"; -import { useContext, useState } from "react"; -import { Trans } from "react-i18next"; -import { - getFaceSearchEnabledStatus, - updateFaceSearchEnabledStatus, -} from "services/userService"; -import EnableFaceSearch from "./enableFaceSearch"; -import EnableMLSearch from "./enableMLSearch"; -import ManageMLSearch from "./manageMLSearch"; - -const MLSearchSettings = ({ open, onClose, onRootClose }) => { - const { - updateMlSearchEnabled, - mlSearchEnabled, - setDialogMessage, - somethingWentWrong, - startLoading, - finishLoading, - } = useContext(AppContext); - - const [enableFaceSearchView, setEnableFaceSearchView] = useState(false); - - const openEnableFaceSearch = () => { - setEnableFaceSearchView(true); - }; - const closeEnableFaceSearch = () => { - setEnableFaceSearchView(false); - }; - - const enableMlSearch = async () => { - try { - const hasEnabledFaceSearch = await getFaceSearchEnabledStatus(); - if (!hasEnabledFaceSearch) { - openEnableFaceSearch(); - } else { - updateMlSearchEnabled(true); - } - } catch (e) { - log.error("Enable ML search failed", e); - somethingWentWrong(); - } - }; - - const enableFaceSearch = async () => { - try { - startLoading(); - await updateFaceSearchEnabledStatus(true); - updateMlSearchEnabled(true); - closeEnableFaceSearch(); - finishLoading(); - } catch (e) { - log.error("Enable face search failed", e); - somethingWentWrong(); - } - }; - - const disableMlSearch = async () => { - try { - await updateMlSearchEnabled(false); - onClose(); - } catch (e) { - log.error("Disable ML search failed", e); - somethingWentWrong(); - } - }; - - const disableFaceSearch = async () => { - try { - startLoading(); - await updateFaceSearchEnabledStatus(false); - await disableMlSearch(); - finishLoading(); - } catch (e) { - log.error("Disable face search failed", e); - somethingWentWrong(); - } - }; - - const confirmDisableFaceSearch = () => { - setDialogMessage({ - title: t("DISABLE_FACE_SEARCH_TITLE"), - content: ( - - - - ), - close: { text: t("CANCEL") }, - proceed: { - variant: "primary", - text: t("DISABLE_FACE_SEARCH"), - action: disableFaceSearch, - }, - }); - }; - - const handleRootClose = () => { - onClose(); - onRootClose(); - }; - - const handleDrawerClose: DialogProps["onClose"] = (_, reason) => { - if (reason === "backdropClick") { - handleRootClose(); - } else { - onClose(); - } - }; - - return ( - - - {mlSearchEnabled ? ( - - ) : ( - - )} - - - - - ); -}; - -export default MLSearchSettings; diff --git a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/manageMLSearch.tsx b/web/apps/photos/src/components/MachineLearning/MLSearchSettings/manageMLSearch.tsx deleted file mode 100644 index 15dacd7b2..000000000 --- a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/manageMLSearch.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Box, Stack } from "@mui/material"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; -import { MenuItemGroup } from "components/Menu/MenuItemGroup"; -import Titlebar from "components/Titlebar"; -import { t } from "i18next"; - -export default function ManageMLSearch({ - onClose, - disableMlSearch, - handleDisableFaceSearch, - onRootClose, -}) { - return ( - - - - - - - - - - - - - - ); -} 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/PhotoViewer/FileInfo/index.tsx b/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx index 24de200c0..34fdb8e34 100644 --- a/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx @@ -10,11 +10,8 @@ import TextSnippetOutlined from "@mui/icons-material/TextSnippetOutlined"; import { Box, DialogProps, Link, Stack, styled } from "@mui/material"; import { Chip } from "components/Chip"; import { EnteDrawer } from "components/EnteDrawer"; -import { - PhotoPeopleList, - UnidentifiedFaces, -} from "components/MachineLearning/PeopleList"; import Titlebar from "components/Titlebar"; +import { PhotoPeopleList, UnidentifiedFaces } from "components/ml/PeopleList"; import LinkButton from "components/pages/gallery/LinkButton"; import { t } from "i18next"; import { AppContext } from "pages/_app"; diff --git a/web/apps/photos/src/components/Search/SearchBar/searchInput/MenuWithPeople.tsx b/web/apps/photos/src/components/Search/SearchBar/searchInput/MenuWithPeople.tsx index 89bdce56a..6ebc0d942 100644 --- a/web/apps/photos/src/components/Search/SearchBar/searchInput/MenuWithPeople.tsx +++ b/web/apps/photos/src/components/Search/SearchBar/searchInput/MenuWithPeople.tsx @@ -1,6 +1,6 @@ import { Row } from "@ente/shared/components/Container"; import { Box, styled } from "@mui/material"; -import { PeopleList } from "components/MachineLearning/PeopleList"; +import { PeopleList } from "components/ml/PeopleList"; import { t } from "i18next"; import { AppContext } from "pages/_app"; import { useContext } from "react"; diff --git a/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx b/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx index ca52b9cad..d7cf151e6 100644 --- a/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx +++ b/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx @@ -57,7 +57,8 @@ export default function SearchInput(props: Iprops) { const appContext = useContext(AppContext); const handleChange = (value: SearchOption) => { setValue(value); - setQuery(value.label); + setQuery(value?.label); + blur(); }; const handleInputChange = (value: string, actionMeta: InputActionMeta) => { diff --git a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx index 817aecb2b..6972cc161 100644 --- a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx +++ b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx @@ -3,9 +3,9 @@ import ChevronRight from "@mui/icons-material/ChevronRight"; import ScienceIcon from "@mui/icons-material/Science"; import { Box, DialogProps, Stack, Typography } from "@mui/material"; import { EnteDrawer } from "components/EnteDrawer"; -import MLSearchSettings from "components/MachineLearning/MLSearchSettings"; import MenuSectionTitle from "components/Menu/MenuSectionTitle"; import Titlebar from "components/Titlebar"; +import { MLSearchSettings } from "components/ml/MLSearchSettings"; import { t } from "i18next"; import { useContext, useEffect, useState } from "react"; 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..752b729a0 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -1,15 +1,11 @@ +import { ensureElectron } from "@/next/electron"; import log from "@/next/log"; -import type { Electron } from "@/next/types/ipc"; +import type { CollectionMapping, Electron } from "@/next/types/ipc"; import { CustomError } from "@ente/shared/error"; import { isPromise } from "@ente/shared/utils"; import DiscFullIcon from "@mui/icons-material/DiscFull"; import UserNameInputDialog from "components/UserNameInputDialog"; -import { - DEFAULT_IMPORT_SUGGESTION, - PICKED_UPLOAD_TYPE, - UPLOAD_STAGES, - UPLOAD_STRATEGY, -} from "constants/upload"; +import { PICKED_UPLOAD_TYPE, UPLOAD_STAGES } from "constants/upload"; import { t } from "i18next"; import isElectron from "is-electron"; import { AppContext } from "pages/_app"; @@ -17,14 +13,14 @@ import { GalleryContext } from "pages/gallery"; import { useContext, useEffect, useRef, useState } from "react"; import billingService from "services/billingService"; import { getLatestCollections } from "services/collectionService"; -import ImportService from "services/importService"; +import { setToUploadCollection } from "services/pending-uploads"; import { getPublicCollectionUID, getPublicCollectionUploaderName, savePublicCollectionUploaderName, } from "services/publicCollectionService"; import uploadManager from "services/upload/uploadManager"; -import watchFolderService from "services/watchFolder/watchFolderService"; +import watcher from "services/watch"; import { NotificationAttributes } from "types/Notification"; import { Collection } from "types/collection"; import { @@ -35,11 +31,7 @@ import { SetLoading, UploadTypeSelectorIntent, } from "types/gallery"; -import { - ElectronFile, - FileWithCollection, - ImportSuggestion, -} from "types/upload"; +import { ElectronFile, FileWithCollection } from "types/upload"; import { InProgressUpload, SegregatedFinishedUploads, @@ -53,13 +45,15 @@ import { getRootLevelFileWithFolderNotAllowMessage, } from "utils/ui"; import { + DEFAULT_IMPORT_SUGGESTION, filterOutSystemFiles, getImportSuggestion, groupFilesBasedOnParentFolder, + type ImportSuggestion, } from "utils/upload"; import { SetCollectionNamerAttributes } from "../Collections/CollectionNamer"; +import { CollectionMappingChoiceModal } from "./CollectionMappingChoiceModal"; import UploadProgress from "./UploadProgress"; -import UploadStrategyChoiceModal from "./UploadStrategyChoiceModal"; import UploadTypeSelector from "./UploadTypeSelector"; const FIRST_ALBUM_NAME = "My First Album"; @@ -137,11 +131,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 +166,40 @@ 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, + ); + } + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const upload = (collectionName: string, filePaths: string[]) => { + isPendingDesktopUpload.current = true; + pendingDesktopUploadCollectionName.current = collectionName; + + // TODO (MR): + // setElectronFiles(filePaths); + }; + + const requestSyncWithRemote = () => { + props.syncWithRemote().catch((e) => { + log.error( + "Ignoring error when syncing trash changes with remote", + e, + ); + }); + }; + + watcher.init(upload, requestSyncWithRemote); } }, [ publicCollectionGalleryContext.accessedThroughSharedURL, @@ -291,18 +302,16 @@ export default function Uploader(props: Props) { }`, ); if (uploadManager.isUploadRunning()) { - if (watchFolderService.isUploadRunning()) { + if (watcher.isUploadRunning()) { + // Pause watch folder sync on user upload log.info( - "watchFolder upload was running, pausing it to run user upload", + "Folder watcher was uploading, pausing it to first run user upload", ); - // pause watch folder service on user upload - watchFolderService.pauseRunningSync(); + watcher.pauseRunningSync(); } else { log.info( - "an upload is already running, rejecting new upload request", + "Ignoring new upload request because an upload is already running", ); - // no-op - // a user upload is already in progress return; } } @@ -330,7 +339,7 @@ export default function Uploader(props: Props) { const importSuggestion = getImportSuggestion( pickedUploadType.current, - toUploadFiles.current, + toUploadFiles.current.map((file) => file["path"]), ); setImportSuggestion(importSuggestion); @@ -391,7 +400,7 @@ export default function Uploader(props: Props) { }; const uploadFilesToNewCollections = async ( - strategy: UPLOAD_STRATEGY, + strategy: CollectionMapping, collectionName?: string, ) => { try { @@ -405,7 +414,7 @@ export default function Uploader(props: Props) { string, (File | ElectronFile)[] >(); - if (strategy === UPLOAD_STRATEGY.SINGLE_COLLECTION) { + if (strategy == "root") { collectionNameToFilesMap.set( collectionName, toUploadFiles.current, @@ -505,18 +514,19 @@ export default function Uploader(props: Props) { if ( electron && !isPendingDesktopUpload.current && - !watchFolderService.isUploadRunning() + !watcher.isUploadRunning() ) { - await ImportService.setToUploadCollection(collections); + await setToUploadCollection(collections); + // TODO (MR): What happens when we have both? if (zipPaths.current) { - await electron.setToUploadFiles( - PICKED_UPLOAD_TYPE.ZIPS, + await electron.setPendingUploadFiles( + "zips", zipPaths.current, ); zipPaths.current = null; } - await electron.setToUploadFiles( - PICKED_UPLOAD_TYPE.FILES, + await electron.setPendingUploadFiles( + "files", filesWithCollectionToUploadIn.map( ({ file }) => (file as ElectronFile).path, ), @@ -532,14 +542,14 @@ export default function Uploader(props: Props) { closeUploadProgress(); } if (isElectron()) { - if (watchFolderService.isUploadRunning()) { - await watchFolderService.allFileUploadsDone( + if (watcher.isUploadRunning()) { + await watcher.allFileUploadsDone( filesWithCollectionToUploadIn, collections, ); - } else if (watchFolderService.isSyncPaused()) { + } else if (watcher.isSyncPaused()) { // resume the service after user upload is done - watchFolderService.resumePausedSync(); + watcher.resumePausedSync(); } } } catch (e) { @@ -605,10 +615,7 @@ export default function Uploader(props: Props) { } const uploadToSingleNewCollection = (collectionName: string) => { - uploadFilesToNewCollections( - UPLOAD_STRATEGY.SINGLE_COLLECTION, - collectionName, - ); + uploadFilesToNewCollections("root", collectionName); }; const showCollectionCreateModal = (suggestedName: string) => { @@ -647,7 +654,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 +662,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 +787,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/components/ml/MLSearchSettings.tsx b/web/apps/photos/src/components/ml/MLSearchSettings.tsx new file mode 100644 index 000000000..583b79529 --- /dev/null +++ b/web/apps/photos/src/components/ml/MLSearchSettings.tsx @@ -0,0 +1,327 @@ +import log from "@/next/log"; +import { + Box, + Button, + Checkbox, + DialogProps, + FormControlLabel, + FormGroup, + Link, + Stack, + Typography, +} from "@mui/material"; +import { EnteDrawer } from "components/EnteDrawer"; +import { EnteMenuItem } from "components/Menu/EnteMenuItem"; +import { MenuItemGroup } from "components/Menu/MenuItemGroup"; +import Titlebar from "components/Titlebar"; +import { t } from "i18next"; +import { AppContext } from "pages/_app"; +import { useContext, useEffect, useState } from "react"; +import { Trans } from "react-i18next"; +import { + getFaceSearchEnabledStatus, + updateFaceSearchEnabledStatus, +} from "services/userService"; +import { openLink } from "utils/common"; + +export const MLSearchSettings = ({ open, onClose, onRootClose }) => { + const { + updateMlSearchEnabled, + mlSearchEnabled, + setDialogMessage, + somethingWentWrong, + startLoading, + finishLoading, + } = useContext(AppContext); + + const [enableFaceSearchView, setEnableFaceSearchView] = useState(false); + + const openEnableFaceSearch = () => { + setEnableFaceSearchView(true); + }; + const closeEnableFaceSearch = () => { + setEnableFaceSearchView(false); + }; + + const enableMlSearch = async () => { + try { + const hasEnabledFaceSearch = await getFaceSearchEnabledStatus(); + if (!hasEnabledFaceSearch) { + openEnableFaceSearch(); + } else { + updateMlSearchEnabled(true); + } + } catch (e) { + log.error("Enable ML search failed", e); + somethingWentWrong(); + } + }; + + const enableFaceSearch = async () => { + try { + startLoading(); + await updateFaceSearchEnabledStatus(true); + updateMlSearchEnabled(true); + closeEnableFaceSearch(); + finishLoading(); + } catch (e) { + log.error("Enable face search failed", e); + somethingWentWrong(); + } + }; + + const disableMlSearch = async () => { + try { + await updateMlSearchEnabled(false); + onClose(); + } catch (e) { + log.error("Disable ML search failed", e); + somethingWentWrong(); + } + }; + + const disableFaceSearch = async () => { + try { + startLoading(); + await updateFaceSearchEnabledStatus(false); + await disableMlSearch(); + finishLoading(); + } catch (e) { + log.error("Disable face search failed", e); + somethingWentWrong(); + } + }; + + const confirmDisableFaceSearch = () => { + setDialogMessage({ + title: t("DISABLE_FACE_SEARCH_TITLE"), + content: ( + + + + ), + close: { text: t("CANCEL") }, + proceed: { + variant: "primary", + text: t("DISABLE_FACE_SEARCH"), + action: disableFaceSearch, + }, + }); + }; + + const handleRootClose = () => { + onClose(); + onRootClose(); + }; + + const handleDrawerClose: DialogProps["onClose"] = (_, reason) => { + if (reason === "backdropClick") { + handleRootClose(); + } else { + onClose(); + } + }; + + return ( + + + {mlSearchEnabled ? ( + + ) : ( + + )} + + + + + ); +}; + +function EnableFaceSearch({ open, onClose, enableFaceSearch, onRootClose }) { + const [acceptTerms, setAcceptTerms] = useState(false); + + useEffect(() => { + setAcceptTerms(false); + }, [open]); + + const handleRootClose = () => { + onClose(); + onRootClose(); + }; + + const handleDrawerClose: DialogProps["onClose"] = (_, reason) => { + if (reason === "backdropClick") { + handleRootClose(); + } else { + onClose(); + } + }; + return ( + + + + + + + ), + }} + /> + + + + setAcceptTerms(e.target.checked) + } + /> + } + label={t("FACE_SEARCH_CONFIRMATION")} + /> + + + + + + + + + ); +} + +function EnableMLSearch({ onClose, enableMlSearch, onRootClose }) { + const showDetails = () => + openLink("https://ente.io/blog/desktop-ml-beta", true); + + return ( + + + + + {" "} + + + + + + + + + + + ); +} + +function ManageMLSearch({ + onClose, + disableMlSearch, + handleDisableFaceSearch, + onRootClose, +}) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/web/apps/photos/src/components/MachineLearning/PeopleList.tsx b/web/apps/photos/src/components/ml/PeopleList.tsx similarity index 70% rename from web/apps/photos/src/components/MachineLearning/PeopleList.tsx rename to web/apps/photos/src/components/ml/PeopleList.tsx index 0e358ce33..8e6bc968f 100644 --- a/web/apps/photos/src/components/MachineLearning/PeopleList.tsx +++ b/web/apps/photos/src/components/ml/PeopleList.tsx @@ -1,17 +1,14 @@ +import { cachedOrNew } from "@/next/blob-cache"; +import { ensureLocalUser } from "@/next/local-user"; import log from "@/next/log"; -import { CACHES } from "@ente/shared/storage/cacheStorage/constants"; -import { styled } from "@mui/material"; +import { Skeleton, styled } from "@mui/material"; import { Legend } from "components/PhotoViewer/styledComponents/Legend"; import { t } from "i18next"; import React, { useEffect, useState } from "react"; +import machineLearningService from "services/machineLearning/machineLearningService"; import { EnteFile } from "types/file"; import { Face, Person } from "types/machineLearning"; -import { - getAllPeople, - getPeopleList, - getUnidentifiedFaces, -} from "utils/machineLearning"; -import { ImageCacheView } from "./ImageViews"; +import { getPeopleList, getUnidentifiedFaces } from "utils/machineLearning"; const FaceChipContainer = styled("div")` display: flex; @@ -63,10 +60,9 @@ export const PeopleList = React.memo((props: PeopleListProps) => { props.onSelect && props.onSelect(person, index) } > - ))} @@ -111,36 +107,6 @@ export function PhotoPeopleList(props: PhotoPeopleListProps) { ); } -export interface AllPeopleListProps extends PeopleListPropsBase { - limit?: number; -} - -export function AllPeopleList(props: AllPeopleListProps) { - const [people, setPeople] = useState>([]); - - useEffect(() => { - let didCancel = false; - - async function updateFaceImages() { - try { - let people = await getAllPeople(); - if (props.limit) { - people = people.slice(0, props.limit); - } - !didCancel && setPeople(people); - } catch (e) { - log.error("updateFaceImages failed", e); - } - } - updateFaceImages(); - return () => { - didCancel = true; - }; - }, [props.limit]); - - return ; -} - export function UnidentifiedFaces(props: { file: EnteFile; updateMLDataIndex: number; @@ -173,10 +139,9 @@ export function UnidentifiedFaces(props: { {faces && faces.map((face, index) => ( - ))} @@ -184,3 +149,43 @@ export function UnidentifiedFaces(props: { ); } + +interface FaceCropImageViewProps { + faceId: string; + cacheKey?: string; +} + +const FaceCropImageView: React.FC = ({ + faceId, + cacheKey, +}) => { + const [objectURL, setObjectURL] = useState(); + + useEffect(() => { + let didCancel = false; + + if (cacheKey) { + cachedOrNew("face-crops", cacheKey, async () => { + const user = await ensureLocalUser(); + return machineLearningService.regenerateFaceCrop( + user.token, + user.id, + faceId, + ); + }).then((blob) => { + if (!didCancel) setObjectURL(URL.createObjectURL(blob)); + }); + } else setObjectURL(undefined); + + return () => { + didCancel = true; + if (objectURL) URL.revokeObjectURL(objectURL); + }; + }, [faceId, cacheKey]); + + return objectURL ? ( + + ) : ( + + ); +}; diff --git a/web/apps/photos/src/constants/export.ts b/web/apps/photos/src/constants/export.ts deleted file mode 100644 index cd6c0c0ee..000000000 --- a/web/apps/photos/src/constants/export.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const ENTE_METADATA_FOLDER = "metadata"; - -export const ENTE_TRASH_FOLDER = "Trash"; - -export enum ExportStage { - INIT = 0, - MIGRATION = 1, - STARTING = 2, - EXPORTING_FILES = 3, - TRASHING_DELETED_FILES = 4, - RENAMING_COLLECTION_FOLDERS = 5, - TRASHING_DELETED_COLLECTIONS = 6, - FINISHED = 7, -} diff --git a/web/apps/photos/src/constants/upload.ts b/web/apps/photos/src/constants/upload.ts index 6d9f63d78..1f8858bc3 100644 --- a/web/apps/photos/src/constants/upload.ts +++ b/web/apps/photos/src/constants/upload.ts @@ -1,11 +1,6 @@ import { ENCRYPTION_CHUNK_SIZE } from "@ente/shared/crypto/constants"; import { FILE_TYPE } from "constants/file"; -import { - FileTypeInfo, - ImportSuggestion, - Location, - ParsedExtractedMetadata, -} from "types/upload"; +import { FileTypeInfo, Location, ParsedExtractedMetadata } from "types/upload"; // list of format that were missed by type-detection for some files. export const WHITELISTED_FILE_FORMATS: FileTypeInfo[] = [ @@ -111,12 +106,6 @@ export const NULL_EXTRACTED_METADATA: ParsedExtractedMetadata = { export const A_SEC_IN_MICROSECONDS = 1e6; -export const DEFAULT_IMPORT_SUGGESTION: ImportSuggestion = { - rootFolderName: "", - hasNestedFolders: false, - hasRootLevelFileWithFolder: false, -}; - export const BLACK_THUMBNAIL_BASE64 = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB" + "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQ" + diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 06961d6c9..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, @@ -52,7 +52,7 @@ import "photoswipe/dist/photoswipe.css"; import { createContext, useEffect, useRef, useState } from "react"; import LoadingBar from "react-top-loading-bar"; import DownloadManager from "services/download"; -import exportService from "services/export"; +import exportService, { resumeExportsIfNeeded } from "services/export"; import mlWorkManager from "services/machineLearning/mlWorkManager"; import { getFamilyPortalRedirectURL, @@ -64,7 +64,6 @@ import { NotificationAttributes, SetNotificationAttributes, } from "types/Notification"; -import { isExportInProgress } from "utils/export"; import { getMLSearchConfig, updateMLSearchConfig, @@ -92,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; @@ -129,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)"); @@ -161,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: , @@ -171,7 +167,7 @@ export default function App({ Component, pageProps }: AppProps) { message: t("UPDATE_AVAILABLE"), onClick: () => setDialogMessage( - getUpdateAvailableForDownloadMessage(updateInfo), + getUpdateAvailableForDownloadMessage(update), ), }); } @@ -214,37 +210,10 @@ export default function App({ Component, pageProps }: AppProps) { return; } const initExport = async () => { - try { - log.info("init export"); - const token = getToken(); - if (!token) { - log.info( - "User not logged in, not starting export continuous sync job", - ); - return; - } - await DownloadManager.init(APPS.PHOTOS, { token }); - const exportSettings = exportService.getExportSettings(); - if ( - !(await exportService.exportFolderExists( - exportSettings?.folder, - )) - ) { - return; - } - const exportRecord = await exportService.getExportRecord( - exportSettings.folder, - ); - if (exportSettings.continuousExport) { - exportService.enableContinuousExport(); - } - if (isExportInProgress(exportRecord.stage)) { - log.info("export was in progress, resuming"); - exportService.scheduleExport(); - } - } catch (e) { - log.error("init export failed", e); - } + const token = getToken(); + if (!token) return; + await DownloadManager.init(APPS.PHOTOS, { token }); + await resumeExportsIfNeeded(); }; initExport(); try { @@ -431,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/collectionService.ts b/web/apps/photos/src/services/collectionService.ts index 0812ca8f9..552512c4d 100644 --- a/web/apps/photos/src/services/collectionService.ts +++ b/web/apps/photos/src/services/collectionService.ts @@ -1385,7 +1385,6 @@ export async function moveToHiddenCollection(files: EnteFile[]) { hiddenCollection = await createHiddenCollection(); } const groupiedFiles = groupFilesBasedOnCollectionID(files); - // eslint-disable-next-line @typescript-eslint/no-unused-vars for (const [collectionID, files] of groupiedFiles.entries()) { if (collectionID === hiddenCollection.id) { continue; @@ -1404,7 +1403,6 @@ export async function unhideToCollection( ) { try { const groupiedFiles = groupFilesBasedOnCollectionID(files); - // eslint-disable-next-line @typescript-eslint/no-unused-vars for (const [collectionID, files] of groupiedFiles.entries()) { if (collectionID === collection.id) { continue; diff --git a/web/apps/photos/src/services/download/clients/photos.ts b/web/apps/photos/src/services/download/clients/photos.ts index a605aa64d..6a4d9cddb 100644 --- a/web/apps/photos/src/services/download/clients/photos.ts +++ b/web/apps/photos/src/services/download/clients/photos.ts @@ -10,14 +10,11 @@ export class PhotosDownloadClient implements DownloadClient { private token: string, private timeout: number, ) {} + updateTokens(token: string) { this.token = token; } - updateTimeout(timeout: number) { - this.timeout = timeout; - } - async downloadThumbnail(file: EnteFile): Promise { if (!this.token) { throw Error(CustomError.TOKEN_MISSING); diff --git a/web/apps/photos/src/services/download/clients/publicAlbums.ts b/web/apps/photos/src/services/download/clients/publicAlbums.ts index be4fe34c4..48cb2292a 100644 --- a/web/apps/photos/src/services/download/clients/publicAlbums.ts +++ b/web/apps/photos/src/services/download/clients/publicAlbums.ts @@ -20,10 +20,6 @@ export class PublicAlbumsDownloadClient implements DownloadClient { this.passwordToken = passwordToken; } - updateTimeout(timeout: number) { - this.timeout = timeout; - } - downloadThumbnail = async (file: EnteFile) => { if (!this.token) { throw Error(CustomError.TOKEN_MISSING); diff --git a/web/apps/photos/src/services/download/index.ts b/web/apps/photos/src/services/download/index.ts index d262c74aa..41af5c055 100644 --- a/web/apps/photos/src/services/download/index.ts +++ b/web/apps/photos/src/services/download/index.ts @@ -1,21 +1,17 @@ +import { openCache, type BlobCache } from "@/next/blob-cache"; import log from "@/next/log"; import { APPS } from "@ente/shared/apps/constants"; 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 { CacheStorageService } from "@ente/shared/storage/cacheStorage"; -import { CACHES } from "@ente/shared/storage/cacheStorage/constants"; -import { LimitedCache } from "@ente/shared/storage/cacheStorage/types"; import { Remote } from "comlink"; import { FILE_TYPE } from "constants/file"; -import isElectron from "is-electron"; import { EnteFile } from "types/file"; import { generateStreamFromArrayBuffer, getRenderableFileURL, } from "utils/file"; -import { isInternalUser } from "utils/user"; import { PhotosDownloadClient } from "./clients/photos"; import { PublicAlbumsDownloadClient } from "./clients/publicAlbums"; @@ -43,7 +39,6 @@ export type OnDownloadProgress = (event: { export interface DownloadClient { updateTokens: (token: string, passwordToken?: string) => void; - updateTimeout: (timeout: number) => void; downloadThumbnail: ( file: EnteFile, timeout?: number, @@ -58,9 +53,14 @@ export interface DownloadClient { class DownloadManagerImpl { private ready: boolean = false; private downloadClient: DownloadClient; - private thumbnailCache?: LimitedCache; - // disk cache is only available on electron - private diskFileCache?: LimitedCache; + /** Local cache for thumbnails. Might not be available. */ + private thumbnailCache?: BlobCache; + /** + * Local cache for the files themselves. + * + * Only available when we're running in the desktop app. + */ + private fileCache?: BlobCache; private cryptoWorker: Remote; private fileObjectURLPromises = new Map>(); @@ -74,23 +74,36 @@ class DownloadManagerImpl { async init( app: APPS, tokens?: { token: string; passwordToken?: string } | { token: string }, - timeout?: number, ) { - try { - if (this.ready) { - log.info("DownloadManager already initialized"); - return; - } - this.downloadClient = createDownloadClient(app, tokens, timeout); - this.thumbnailCache = await openThumbnailCache(); - this.diskFileCache = isElectron() && (await openDiskFileCache()); - this.cryptoWorker = await ComlinkCryptoWorker.getInstance(); - this.ready = true; - eventBus.on(Events.LOGOUT, this.logoutHandler.bind(this), this); - } catch (e) { - log.error("DownloadManager init failed", e); - throw e; + if (this.ready) { + log.info("DownloadManager already initialized"); + return; } + this.downloadClient = createDownloadClient(app, tokens); + try { + this.thumbnailCache = await openCache("thumbs"); + } catch (e) { + log.error( + "Failed to open thumbnail cache, will continue without it", + e, + ); + } + // TODO (MR): Revisit full file caching cf disk space usage + // try { + // if (isElectron()) this.fileCache = await openCache("files"); + // } catch (e) { + // log.error("Failed to open file cache, will continue without it", e); + // } + this.cryptoWorker = await ComlinkCryptoWorker.getInstance(); + this.ready = true; + eventBus.on(Events.LOGOUT, this.logoutHandler.bind(this), this); + } + + private ensureInitialized() { + if (!this.ready) + throw new Error( + "Attempting to use an uninitialized download manager", + ); } private async logoutHandler() { @@ -118,44 +131,10 @@ class DownloadManagerImpl { this.cryptoWorker = cryptoWorker; } - updateTimeout(timeout: number) { - this.downloadClient.updateTimeout(timeout); - } - setProgressUpdater(progressUpdater: (value: Map) => void) { this.progressUpdater = progressUpdater; } - private async getCachedThumbnail(fileID: number) { - try { - const cacheResp: Response = await this.thumbnailCache?.match( - fileID.toString(), - ); - - if (cacheResp) { - return new Uint8Array(await cacheResp.arrayBuffer()); - } - } catch (e) { - log.error("failed to get cached thumbnail", e); - throw e; - } - } - private async getCachedFile(file: EnteFile): Promise { - try { - if (!this.diskFileCache) { - return null; - } - const cacheResp: Response = await this.diskFileCache?.match( - file.id.toString(), - { sizeInBytes: file.info?.fileSize }, - ); - return cacheResp?.clone(); - } catch (e) { - log.error("failed to get cached file", e); - throw e; - } - } - private downloadThumb = async (file: EnteFile) => { const encrypted = await this.downloadClient.downloadThumbnail(file); const decrypted = await this.cryptoWorker.decryptThumbnail( @@ -167,37 +146,21 @@ class DownloadManagerImpl { }; async getThumbnail(file: EnteFile, localOnly = false) { - try { - if (!this.ready) { - throw Error(CustomError.DOWNLOAD_MANAGER_NOT_READY); - } - const cachedThumb = await this.getCachedThumbnail(file.id); - if (cachedThumb) { - return cachedThumb; - } - if (localOnly) { - return null; - } - const thumb = await this.downloadThumb(file); + this.ensureInitialized(); - this.thumbnailCache - ?.put(file.id.toString(), new Response(thumb)) - .catch((e) => { - log.error("thumb cache put failed", e); - // TODO: handle storage full exception. - }); - return thumb; - } catch (e) { - log.error("getThumbnail failed", e); - throw e; - } + const key = file.id.toString(); + const cached = await this.thumbnailCache.get(key); + if (cached) return new Uint8Array(await cached.arrayBuffer()); + if (localOnly) return null; + + const thumb = await this.downloadThumb(file); + this.thumbnailCache?.put(key, new Blob([thumb])); + return thumb; } async getThumbnailForPreview(file: EnteFile, localOnly = false) { + this.ensureInitialized(); try { - if (!this.ready) { - throw Error(CustomError.DOWNLOAD_MANAGER_NOT_READY); - } if (!this.thumbnailObjectURLPromises.has(file.id)) { const thumbPromise = this.getThumbnail(file, localOnly); const thumbURLPromise = thumbPromise.then( @@ -222,10 +185,8 @@ class DownloadManagerImpl { file: EnteFile, forceConvert = false, ): Promise => { + this.ensureInitialized(); try { - if (!this.ready) { - throw Error(CustomError.DOWNLOAD_MANAGER_NOT_READY); - } const getFileForPreviewPromise = async () => { const fileBlob = await new Response( await this.getFile(file, true), @@ -260,10 +221,8 @@ class DownloadManagerImpl { file: EnteFile, cacheInMemory = false, ): Promise> { + this.ensureInitialized(); try { - if (!this.ready) { - throw Error(CustomError.DOWNLOAD_MANAGER_NOT_READY); - } const getFilePromise = async (): Promise => { const fileStream = await this.downloadFile(file); const fileBlob = await new Response(fileStream).blob(); @@ -297,191 +256,166 @@ class DownloadManagerImpl { private async downloadFile( file: EnteFile, ): Promise> { - try { - log.info(`download attempted for fileID:${file.id}`); - const onDownloadProgress = this.trackDownloadProgress( - file.id, - file.info?.fileSize, - ); - if ( - file.metadata.fileType === FILE_TYPE.IMAGE || - file.metadata.fileType === FILE_TYPE.LIVE_PHOTO - ) { - let encrypted = await this.getCachedFile(file); - if (!encrypted) { - encrypted = new Response( - await this.downloadClient.downloadFile( - file, - onDownloadProgress, - ), - ); - if (this.diskFileCache) { - this.diskFileCache - .put(file.id.toString(), encrypted.clone()) - .catch((e) => { - log.error("file cache put failed", e); - // TODO: handle storage full exception. - }); - } - } - this.clearDownloadProgress(file.id); - try { - const decrypted = await this.cryptoWorker.decryptFile( - new Uint8Array(await encrypted.arrayBuffer()), - await this.cryptoWorker.fromB64( - file.file.decryptionHeader, - ), - file.key, - ); - return generateStreamFromArrayBuffer(decrypted); - } catch (e) { - if (e.message === CustomError.PROCESSING_FAILED) { - log.error( - `Failed to process file with fileID:${file.id}, localID: ${file.metadata.localID}, version: ${file.metadata.version}, deviceFolder:${file.metadata.deviceFolder}`, - e, - ); - } - throw e; - } + log.info(`download attempted for file id ${file.id}`); + + const onDownloadProgress = this.trackDownloadProgress( + file.id, + file.info?.fileSize, + ); + + const cacheKey = file.id.toString(); + + if ( + file.metadata.fileType === FILE_TYPE.IMAGE || + file.metadata.fileType === FILE_TYPE.LIVE_PHOTO + ) { + const cachedBlob = await this.fileCache?.get(cacheKey); + let encryptedArrayBuffer = await cachedBlob?.arrayBuffer(); + if (!encryptedArrayBuffer) { + const array = await this.downloadClient.downloadFile( + file, + onDownloadProgress, + ); + encryptedArrayBuffer = array.buffer; + this.fileCache?.put(cacheKey, new Blob([encryptedArrayBuffer])); } - - let resp: Response = await this.getCachedFile(file); - if (!resp) { - resp = await this.downloadClient.downloadFileStream(file); - if (this.diskFileCache) { - this.diskFileCache - .put(file.id.toString(), resp.clone()) - .catch((e) => { - log.error("file cache put failed", e); - }); + this.clearDownloadProgress(file.id); + try { + const decrypted = await this.cryptoWorker.decryptFile( + new Uint8Array(encryptedArrayBuffer), + await this.cryptoWorker.fromB64(file.file.decryptionHeader), + file.key, + ); + return generateStreamFromArrayBuffer(decrypted); + } catch (e) { + if (e.message === CustomError.PROCESSING_FAILED) { + log.error( + `Failed to process file with fileID:${file.id}, localID: ${file.metadata.localID}, version: ${file.metadata.version}, deviceFolder:${file.metadata.deviceFolder}`, + e, + ); } + throw e; } - const reader = resp.body.getReader(); - - const contentLength = +resp.headers.get("Content-Length") ?? 0; - let downloadedBytes = 0; - - const stream = new ReadableStream({ - start: async (controller) => { - try { - const decryptionHeader = - await this.cryptoWorker.fromB64( - file.file.decryptionHeader, - ); - const fileKey = await this.cryptoWorker.fromB64( - file.key, - ); - const { pullState, decryptionChunkSize } = - await this.cryptoWorker.initChunkDecryption( - decryptionHeader, - fileKey, - ); - let data = new Uint8Array(); - // The following function handles each data chunk - const push = () => { - // "done" is a Boolean and value a "Uint8Array" - reader.read().then(async ({ done, value }) => { - try { - // Is there more data to read? - if (!done) { - downloadedBytes += value.byteLength; - onDownloadProgress({ - loaded: downloadedBytes, - total: contentLength, - }); - const buffer = new Uint8Array( - data.byteLength + value.byteLength, - ); - buffer.set(new Uint8Array(data), 0); - buffer.set( - new Uint8Array(value), - data.byteLength, - ); - if ( - buffer.length > decryptionChunkSize - ) { - const fileData = buffer.slice( - 0, - decryptionChunkSize, - ); - try { - const { decryptedData } = - await this.cryptoWorker.decryptFileChunk( - fileData, - pullState, - ); - controller.enqueue( - decryptedData, - ); - data = - buffer.slice( - decryptionChunkSize, - ); - } catch (e) { - if ( - e.message === - CustomError.PROCESSING_FAILED - ) { - log.error( - `Failed to process file ${file.id} from localID: ${file.metadata.localID} version: ${file.metadata.version} deviceFolder:${file.metadata.deviceFolder}`, - e, - ); - } - throw e; - } - } else { - data = buffer; - } - push(); - } else { - if (data) { - try { - const { decryptedData } = - await this.cryptoWorker.decryptFileChunk( - data, - pullState, - ); - controller.enqueue( - decryptedData, - ); - data = null; - } catch (e) { - if ( - e.message === - CustomError.PROCESSING_FAILED - ) { - log.error( - `Failed to process file ${file.id} from localID: ${file.metadata.localID} version: ${file.metadata.version} deviceFolder:${file.metadata.deviceFolder}`, - e, - ); - } - throw e; - } - } - controller.close(); - } - } catch (e) { - log.error( - "Failed to process file chunk", - e, - ); - controller.error(e); - } - }); - }; - - push(); - } catch (e) { - log.error("Failed to process file stream", e); - controller.error(e); - } - }, - }); - return stream; - } catch (e) { - log.error("Failed to download file", e); - throw e; } + + const cachedBlob = await this.fileCache?.get(cacheKey); + let res: Response; + if (cachedBlob) res = new Response(cachedBlob); + else { + res = await this.downloadClient.downloadFileStream(file); + this?.fileCache.put(cacheKey, await res.blob()); + } + const reader = res.body.getReader(); + + const contentLength = +res.headers.get("Content-Length") ?? 0; + let downloadedBytes = 0; + + const stream = new ReadableStream({ + start: async (controller) => { + try { + const decryptionHeader = await this.cryptoWorker.fromB64( + file.file.decryptionHeader, + ); + const fileKey = await this.cryptoWorker.fromB64(file.key); + const { pullState, decryptionChunkSize } = + await this.cryptoWorker.initChunkDecryption( + decryptionHeader, + fileKey, + ); + let data = new Uint8Array(); + // The following function handles each data chunk + const push = () => { + // "done" is a Boolean and value a "Uint8Array" + reader.read().then(async ({ done, value }) => { + try { + // Is there more data to read? + if (!done) { + downloadedBytes += value.byteLength; + onDownloadProgress({ + loaded: downloadedBytes, + total: contentLength, + }); + const buffer = new Uint8Array( + data.byteLength + value.byteLength, + ); + buffer.set(new Uint8Array(data), 0); + buffer.set( + new Uint8Array(value), + data.byteLength, + ); + if (buffer.length > decryptionChunkSize) { + const fileData = buffer.slice( + 0, + decryptionChunkSize, + ); + try { + const { decryptedData } = + await this.cryptoWorker.decryptFileChunk( + fileData, + pullState, + ); + controller.enqueue(decryptedData); + data = + buffer.slice( + decryptionChunkSize, + ); + } catch (e) { + if ( + e.message === + CustomError.PROCESSING_FAILED + ) { + log.error( + `Failed to process file ${file.id} from localID: ${file.metadata.localID} version: ${file.metadata.version} deviceFolder:${file.metadata.deviceFolder}`, + e, + ); + } + throw e; + } + } else { + data = buffer; + } + push(); + } else { + if (data) { + try { + const { decryptedData } = + await this.cryptoWorker.decryptFileChunk( + data, + pullState, + ); + controller.enqueue(decryptedData); + data = null; + } catch (e) { + if ( + e.message === + CustomError.PROCESSING_FAILED + ) { + log.error( + `Failed to process file ${file.id} from localID: ${file.metadata.localID} version: ${file.metadata.version} deviceFolder:${file.metadata.deviceFolder}`, + e, + ); + } + throw e; + } + } + controller.close(); + } + } catch (e) { + log.error("Failed to process file chunk", e); + controller.error(e); + } + }); + }; + + push(); + } catch (e) { + log.error("Failed to process file stream", e); + controller.error(e); + } + }, + }); + + return stream; } trackDownloadProgress = (fileID: number, fileSize: number) => { @@ -514,43 +448,11 @@ const DownloadManager = new DownloadManagerImpl(); export default DownloadManager; -async function openThumbnailCache() { - try { - return await CacheStorageService.open(CACHES.THUMBS); - } catch (e) { - log.error("Failed to open thumbnail cache", e); - if (isInternalUser()) { - throw e; - } else { - return null; - } - } -} - -async function openDiskFileCache() { - try { - if (!isElectron()) { - throw Error(CustomError.NOT_AVAILABLE_ON_WEB); - } - return await CacheStorageService.open(CACHES.FILES); - } catch (e) { - log.error("Failed to open file cache", e); - if (isInternalUser()) { - throw e; - } else { - return null; - } - } -} - function createDownloadClient( app: APPS, tokens?: { token: string; passwordToken?: string } | { token: string }, - timeout?: number, ): DownloadClient { - if (!timeout) { - timeout = 300000; // 5 minute - } + const timeout = 300000; // 5 minute if (app === APPS.ALBUMS) { if (!tokens) { tokens = { token: undefined, passwordToken: undefined }; diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index f2e90139a..882c36f9b 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -1,67 +1,77 @@ +import { decodeLivePhoto } from "@/media/live-photo"; import { ensureElectron } from "@/next/electron"; import log from "@/next/log"; import { CustomError } from "@ente/shared/error"; 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 QueueProcessor, { CancellationStatus, RequestCanceller, } from "@ente/shared/utils/queueProcessor"; -import { ExportStage } from "constants/export"; import { FILE_TYPE } from "constants/file"; import { Collection } from "types/collection"; import { + CollectionExportNames, ExportProgress, ExportRecord, ExportSettings, ExportUIUpdaters, + FileExportNames, } from "types/export"; import { EnteFile } from "types/file"; +import { Metadata } from "types/upload"; import { constructCollectionNameMap, getCollectionUserFacingName, getNonEmptyPersonalCollections, } from "utils/collection"; -import { - convertCollectionIDExportNameObjectToMap, - convertFileIDExportNameObjectToMap, - getCollectionExportPath, - getCollectionExportedFiles, - getCollectionIDFromFileUID, - getDeletedExportedCollections, - getDeletedExportedFiles, - getExportRecordFileUID, - getFileExportPath, - getFileMetadataExportPath, - getGoogleLikeMetadataFile, - getLivePhotoExportName, - getMetadataFileExportPath, - getMetadataFolderExportPath, - getRenamedExportedCollections, - getTrashedFileExportPath, - getUnExportedFiles, - getUniqueCollectionExportName, - getUniqueFileExportName, - isLivePhotoExportName, - parseLivePhotoExportName, -} from "utils/export"; import { generateStreamFromArrayBuffer, getPersonalFiles, getUpdatedEXIFFileForDownload, mergeMetadata, } from "utils/file"; +import { safeDirectoryName, safeFileName } from "utils/native-fs"; +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"; -const EXPORT_RECORD_FILE_NAME = "export_status.json"; +/** Name of the JSON file in which we keep the state of the export. */ +const exportRecordFileName = "export_status.json"; -export const ENTE_EXPORT_DIRECTORY = "ente Photos"; +/** + * Name of the top level directory which we create underneath the selected + * directory when the user starts an export to the filesystem. + */ +const exportDirectoryName = "Ente Photos"; + +/** + * Name of the directory in which we put our metadata when exporting to the + * filesystem. + */ +export const exportMetadataDirectoryName = "metadata"; + +/** + * Name of the directory in which we keep trash items when deleting files that + * have been exported to the local disk previously. + */ +export const exportTrashDirectoryName = "Trash"; + +export enum ExportStage { + INIT = 0, + MIGRATION = 1, + STARTING = 2, + EXPORTING_FILES = 3, + TRASHING_DELETED_FILES = 4, + RENAMING_COLLECTION_FOLDERS = 5, + TRASHING_DELETED_COLLECTIONS = 6, + FINISHED = 7, +} export const NULL_EXPORT_RECORD: ExportRecord = { version: 3, @@ -155,23 +165,6 @@ class ExportService { this.uiUpdater.setLastExportTime(exportTime); } - async changeExportDirectory() { - try { - const newRootDir = await ensureElectron().selectDirectory(); - if (!newRootDir) { - throw Error(CustomError.SELECT_FOLDER_ABORTED); - } - const newExportDir = `${newRootDir}/${ENTE_EXPORT_DIRECTORY}`; - await ensureElectron().checkExistsAndCreateDir(newExportDir); - return newExportDir; - } catch (e) { - if (e.message !== CustomError.SELECT_FOLDER_ABORTED) { - log.error("changeExportDirectory failed", e); - } - throw e; - } - } - enableContinuousExport() { try { if (this.continuousExportEventHandler) { @@ -475,6 +468,7 @@ class ExportService { renamedCollections: Collection[], isCanceled: CancellationStatus, ) { + const fs = ensureElectron().fs; try { for (const collection of renamedCollections) { try { @@ -484,24 +478,16 @@ class ExportService { await this.verifyExportFolderExists(exportFolder); const oldCollectionExportName = collectionIDExportNameMap.get(collection.id); - const oldCollectionExportPath = getCollectionExportPath( + const oldCollectionExportPath = `${exportFolder}/${oldCollectionExportName}`; + const newCollectionExportName = await safeDirectoryName( exportFolder, - oldCollectionExportName, + getCollectionUserFacingName(collection), + fs.exists, ); - - const newCollectionExportName = - await getUniqueCollectionExportName( - exportFolder, - getCollectionUserFacingName(collection), - ); log.info( `renaming collection with id ${collection.id} from ${oldCollectionExportName} to ${newCollectionExportName}`, ); - const newCollectionExportPath = getCollectionExportPath( - exportFolder, - newCollectionExportName, - ); - + const newCollectionExportPath = `${exportFolder}/${newCollectionExportName}`; await this.addCollectionExportedRecord( exportFolder, collection.id, @@ -512,7 +498,7 @@ class ExportService { newCollectionExportName, ); try { - await ensureElectron().rename( + await fs.rename( oldCollectionExportPath, newCollectionExportPath, ); @@ -560,6 +546,7 @@ class ExportService { exportFolder: string, isCanceled: CancellationStatus, ) { + const fs = ensureElectron().fs; try { const exportRecord = await this.getExportRecord(exportFolder); const collectionIDPathMap = @@ -587,23 +574,18 @@ class ExportService { "collection is not empty, can't remove", ); } - const collectionExportPath = getCollectionExportPath( - exportFolder, - collectionExportName, - ); + const collectionExportPath = `${exportFolder}/${collectionExportName}`; await this.removeCollectionExportedRecord( exportFolder, collectionID, ); try { // delete the collection metadata folder - await ensureElectron().deleteFolder( + await fs.rmdir( getMetadataFolderExportPath(collectionExportPath), ); // delete the collection folder - await ensureElectron().deleteFolder( - collectionExportPath, - ); + await fs.rmdir(collectionExportPath); } catch (e) { await this.addCollectionExportedRecord( exportFolder, @@ -648,6 +630,7 @@ class ExportService { incrementFailed: () => void, isCanceled: CancellationStatus, ): Promise { + const fs = ensureElectron().fs; try { for (const file of files) { log.info( @@ -682,14 +665,9 @@ class ExportService { collectionExportName, ); } - const collectionExportPath = getCollectionExportPath( - exportDir, - collectionExportName, - ); - await ensureElectron().checkExistsAndCreateDir( - collectionExportPath, - ); - await ensureElectron().checkExistsAndCreateDir( + const collectionExportPath = `${exportDir}/${collectionExportName}`; + await fs.mkdirIfNeeded(collectionExportPath); + await fs.mkdirIfNeeded( getMetadataFolderExportPath(collectionExportPath), ); await this.downloadAndSave( @@ -750,108 +728,32 @@ class ExportService { try { const fileExportName = fileIDExportNameMap.get(fileUID); const collectionID = getCollectionIDFromFileUID(fileUID); - const collectionExportPath = getCollectionExportPath( - exportDir, - collectionIDExportNameMap.get(collectionID), - ); + const collectionExportName = + collectionIDExportNameMap.get(collectionID); + await this.removeFileExportedRecord(exportDir, fileUID); try { if (isLivePhotoExportName(fileExportName)) { - const { - image: imageExportName, - video: videoExportName, - } = parseLivePhotoExportName(fileExportName); - const imageExportPath = getFileExportPath( - collectionExportPath, - imageExportName, - ); - log.info( - `moving image file ${imageExportPath} to trash folder`, - ); - if (await this.exists(imageExportPath)) { - await ensureElectron().moveFile( - imageExportPath, - await getTrashedFileExportPath( - exportDir, - imageExportPath, - ), - ); - } + const { image, video } = + parseLivePhotoExportName(fileExportName); - const imageMetadataFileExportPath = - getMetadataFileExportPath(imageExportPath); - - if ( - await this.exists(imageMetadataFileExportPath) - ) { - await ensureElectron().moveFile( - imageMetadataFileExportPath, - await getTrashedFileExportPath( - exportDir, - imageMetadataFileExportPath, - ), - ); - } - - const videoExportPath = getFileExportPath( - collectionExportPath, - videoExportName, + await moveToTrash( + exportDir, + collectionExportName, + image, ); - log.info( - `moving video file ${videoExportPath} to trash folder`, + + await moveToTrash( + exportDir, + collectionExportName, + video, ); - if (await this.exists(videoExportPath)) { - await ensureElectron().moveFile( - videoExportPath, - await getTrashedFileExportPath( - exportDir, - videoExportPath, - ), - ); - } - const videoMetadataFileExportPath = - getMetadataFileExportPath(videoExportPath); - if ( - await this.exists(videoMetadataFileExportPath) - ) { - await ensureElectron().moveFile( - videoMetadataFileExportPath, - await getTrashedFileExportPath( - exportDir, - videoMetadataFileExportPath, - ), - ); - } } else { - const fileExportPath = getFileExportPath( - collectionExportPath, + await moveToTrash( + exportDir, + collectionExportName, fileExportName, ); - const trashedFilePath = - await getTrashedFileExportPath( - exportDir, - fileExportPath, - ); - log.info( - `moving file ${fileExportPath} to ${trashedFilePath} trash folder`, - ); - if (await this.exists(fileExportPath)) { - await ensureElectron().moveFile( - fileExportPath, - trashedFilePath, - ); - } - const metadataFileExportPath = - getMetadataFileExportPath(fileExportPath); - if (await this.exists(metadataFileExportPath)) { - await ensureElectron().moveFile( - metadataFileExportPath, - await getTrashedFileExportPath( - exportDir, - metadataFileExportPath, - ), - ); - } } } catch (e) { await this.addFileExportedRecord( @@ -861,7 +763,7 @@ class ExportService { ); throw e; } - log.info(`trashing file with id ${fileUID} successful`); + log.info(`Moved file id ${fileUID} to Trash`); } catch (e) { log.error("trashing failed for a file", e); if ( @@ -983,8 +885,8 @@ class ExportService { try { const exportRecord = await this.getExportRecord(folder); const newRecord: ExportRecord = { ...exportRecord, ...newData }; - await ensureElectron().saveFileToDisk( - `${folder}/${EXPORT_RECORD_FILE_NAME}`, + await ensureElectron().fs.writeFile( + `${folder}/${exportRecordFileName}`, JSON.stringify(newRecord, null, 2), ); return newRecord; @@ -998,14 +900,15 @@ class ExportService { } async getExportRecord(folder: string, retry = true): Promise { + const electron = ensureElectron(); + const fs = electron.fs; try { await this.verifyExportFolderExists(folder); - const exportRecordJSONPath = `${folder}/${EXPORT_RECORD_FILE_NAME}`; - if (!(await this.exists(exportRecordJSONPath))) { + const exportRecordJSONPath = `${folder}/${exportRecordFileName}`; + if (!(await fs.exists(exportRecordJSONPath))) { return this.createEmptyExportRecord(exportRecordJSONPath); } - const recordFile = - await ensureElectron().readTextFile(exportRecordJSONPath); + const recordFile = await fs.readTextFile(exportRecordJSONPath); try { return JSON.parse(recordFile); } catch (e) { @@ -1031,18 +934,17 @@ class ExportService { collectionID: number, collectionIDNameMap: Map, ) { + const fs = ensureElectron().fs; await this.verifyExportFolderExists(exportFolder); const collectionName = collectionIDNameMap.get(collectionID); - const collectionExportName = await getUniqueCollectionExportName( + const collectionExportName = await safeDirectoryName( exportFolder, collectionName, + fs.exists, ); - const collectionExportPath = getCollectionExportPath( - exportFolder, - collectionExportName, - ); - await ensureElectron().checkExistsAndCreateDir(collectionExportPath); - await ensureElectron().checkExistsAndCreateDir( + const collectionExportPath = `${exportFolder}/${collectionExportName}`; + await fs.mkdirIfNeeded(collectionExportPath); + await fs.mkdirIfNeeded( getMetadataFolderExportPath(collectionExportPath), ); @@ -1054,6 +956,7 @@ class ExportService { collectionExportPath: string, file: EnteFile, ): Promise { + const electron = ensureElectron(); try { const fileUID = getExportRecordFileUID(file); const originalFileStream = await downloadManager.getFile(file); @@ -1074,9 +977,10 @@ class ExportService { file, ); } else { - const fileExportName = await getUniqueFileExportName( + const fileExportName = await safeFileName( collectionExportPath, file.metadata.title, + electron.fs.exists, ); await this.addFileExportedRecord( exportDir, @@ -1089,8 +993,8 @@ class ExportService { fileExportName, file, ); - await ensureElectron().saveStreamToDisk( - getFileExportPath(collectionExportPath, fileExportName), + await writeStream( + `${collectionExportPath}/${fileExportName}`, updatedFileStream, ); } catch (e) { @@ -1111,15 +1015,18 @@ class ExportService { fileStream: ReadableStream, file: EnteFile, ) { + const fs = ensureElectron().fs; const fileBlob = await new Response(fileStream).blob(); - const livePhoto = await decodeLivePhoto(file, fileBlob); - const imageExportName = await getUniqueFileExportName( + const livePhoto = await decodeLivePhoto(file.metadata.title, fileBlob); + const imageExportName = await safeFileName( collectionExportPath, - livePhoto.imageNameTitle, + livePhoto.imageFileName, + fs.exists, ); - const videoExportName = await getUniqueFileExportName( + const videoExportName = await safeFileName( collectionExportPath, - livePhoto.videoNameTitle, + livePhoto.videoFileName, + fs.exists, ); const livePhotoExportName = getLivePhotoExportName( imageExportName, @@ -1131,32 +1038,34 @@ class ExportService { livePhotoExportName, ); try { - const imageStream = generateStreamFromArrayBuffer(livePhoto.image); + const imageStream = generateStreamFromArrayBuffer( + livePhoto.imageData, + ); await this.saveMetadataFile( collectionExportPath, imageExportName, file, ); - await ensureElectron().saveStreamToDisk( - getFileExportPath(collectionExportPath, imageExportName), + await writeStream( + `${collectionExportPath}/${imageExportName}`, imageStream, ); - const videoStream = generateStreamFromArrayBuffer(livePhoto.video); + const videoStream = generateStreamFromArrayBuffer( + livePhoto.videoData, + ); await this.saveMetadataFile( collectionExportPath, videoExportName, file, ); try { - await ensureElectron().saveStreamToDisk( - getFileExportPath(collectionExportPath, videoExportName), + await writeStream( + `${collectionExportPath}/${videoExportName}`, videoStream, ); } catch (e) { - await ensureElectron().deleteFile( - getFileExportPath(collectionExportPath, imageExportName), - ); + await fs.rm(`${collectionExportPath}/${imageExportName}`); throw e; } } catch (e) { @@ -1170,7 +1079,7 @@ class ExportService { fileExportName: string, file: EnteFile, ) { - await ensureElectron().saveFileToDisk( + await ensureElectron().fs.writeFile( getFileMetadataExportPath(collectionExportPath, fileExportName), getGoogleLikeMetadataFile(fileExportName, file), ); @@ -1180,20 +1089,8 @@ class ExportService { return this.exportInProgress; }; - exists = (path: string) => { - return ensureElectron().fs.exists(path); - }; - - rename = (oldPath: string, newPath: string) => { - return ensureElectron().rename(oldPath, newPath); - }; - - checkExistsAndCreateDir = (path: string) => { - return ensureElectron().checkExistsAndCreateDir(path); - }; - exportFolderExists = async (exportFolder: string) => { - return exportFolder && (await this.exists(exportFolder)); + return exportFolder && (await ensureElectron().fs.exists(exportFolder)); }; private verifyExportFolderExists = async (exportFolder: string) => { @@ -1211,11 +1108,304 @@ class ExportService { private createEmptyExportRecord = async (exportRecordJSONPath: string) => { const exportRecord: ExportRecord = NULL_EXPORT_RECORD; - await ensureElectron().saveFileToDisk( + await ensureElectron().fs.writeFile( exportRecordJSONPath, JSON.stringify(exportRecord, null, 2), ); return exportRecord; }; } -export default new ExportService(); + +const exportService = new ExportService(); + +export default exportService; + +/** + * If there are any in-progress exports, or if continuous exports are enabled, + * resume them. + */ +export const resumeExportsIfNeeded = async () => { + const exportSettings = exportService.getExportSettings(); + if (!(await exportService.exportFolderExists(exportSettings?.folder))) { + return; + } + const exportRecord = await exportService.getExportRecord( + exportSettings.folder, + ); + if (exportSettings.continuousExport) { + exportService.enableContinuousExport(); + } + if (isExportInProgress(exportRecord.stage)) { + log.debug(() => "Resuming in-progress export"); + exportService.scheduleExport(); + } +}; + +/** + * Prompt the user to select a directory and create an export directory in it. + * + * If the user cancels the selection, return undefined. + */ +export const selectAndPrepareExportDirectory = async (): Promise< + string | undefined +> => { + const electron = ensureElectron(); + + const rootDir = await electron.selectDirectory(); + if (!rootDir) return undefined; + + const exportDir = `${rootDir}/${exportDirectoryName}`; + await electron.fs.mkdirIfNeeded(exportDir); + return exportDir; +}; + +export const getExportRecordFileUID = (file: EnteFile) => + `${file.id}_${file.collectionID}_${file.updationTime}`; + +export const getCollectionIDFromFileUID = (fileUID: string) => + Number(fileUID.split("_")[1]); + +const convertCollectionIDExportNameObjectToMap = ( + collectionExportNames: CollectionExportNames, +): Map => { + return new Map( + Object.entries(collectionExportNames ?? {}).map((e) => { + return [Number(e[0]), String(e[1])]; + }), + ); +}; + +const convertFileIDExportNameObjectToMap = ( + fileExportNames: FileExportNames, +): Map => { + return new Map( + Object.entries(fileExportNames ?? {}).map((e) => { + return [String(e[0]), String(e[1])]; + }), + ); +}; + +const getRenamedExportedCollections = ( + collections: Collection[], + exportRecord: ExportRecord, +) => { + if (!exportRecord?.collectionExportNames) { + return []; + } + const collectionIDExportNameMap = convertCollectionIDExportNameObjectToMap( + exportRecord.collectionExportNames, + ); + const renamedCollections = collections.filter((collection) => { + if (collectionIDExportNameMap.has(collection.id)) { + const currentExportName = collectionIDExportNameMap.get( + collection.id, + ); + + const collectionExportName = + getCollectionUserFacingName(collection); + + if (currentExportName === collectionExportName) { + return false; + } + const hasNumberedSuffix = currentExportName.match(/\(\d+\)$/); + const currentExportNameWithoutNumberedSuffix = hasNumberedSuffix + ? currentExportName.replace(/\(\d+\)$/, "") + : currentExportName; + + return ( + collectionExportName !== currentExportNameWithoutNumberedSuffix + ); + } + return false; + }); + return renamedCollections; +}; + +const getDeletedExportedCollections = ( + collections: Collection[], + exportRecord: ExportRecord, +) => { + if (!exportRecord?.collectionExportNames) { + return []; + } + const presentCollections = new Set( + collections.map((collection) => collection.id), + ); + const deletedExportedCollections = Object.keys( + exportRecord?.collectionExportNames, + ) + .map(Number) + .filter((collectionID) => { + if (!presentCollections.has(collectionID)) { + return true; + } + return false; + }); + return deletedExportedCollections; +}; + +const getUnExportedFiles = ( + allFiles: EnteFile[], + exportRecord: ExportRecord, +) => { + if (!exportRecord?.fileExportNames) { + return allFiles; + } + const exportedFiles = new Set(Object.keys(exportRecord?.fileExportNames)); + const unExportedFiles = allFiles.filter((file) => { + if (!exportedFiles.has(getExportRecordFileUID(file))) { + return true; + } + return false; + }); + return unExportedFiles; +}; + +const getDeletedExportedFiles = ( + allFiles: EnteFile[], + exportRecord: ExportRecord, +): string[] => { + if (!exportRecord?.fileExportNames) { + return []; + } + const presentFileUIDs = new Set( + allFiles?.map((file) => getExportRecordFileUID(file)), + ); + const deletedExportedFiles = Object.keys( + exportRecord?.fileExportNames, + ).filter((fileUID) => { + if (!presentFileUIDs.has(fileUID)) { + return true; + } + return false; + }); + return deletedExportedFiles; +}; + +const getCollectionExportedFiles = ( + exportRecord: ExportRecord, + collectionID: number, +): string[] => { + if (!exportRecord?.fileExportNames) { + return []; + } + const collectionExportedFiles = Object.keys( + exportRecord?.fileExportNames, + ).filter((fileUID) => { + const fileCollectionID = Number(fileUID.split("_")[1]); + if (fileCollectionID === collectionID) { + return true; + } else { + return false; + } + }); + return collectionExportedFiles; +}; + +const getGoogleLikeMetadataFile = (fileExportName: string, file: EnteFile) => { + const metadata: Metadata = file.metadata; + const creationTime = Math.floor(metadata.creationTime / 1000000); + const modificationTime = Math.floor( + (metadata.modificationTime ?? metadata.creationTime) / 1000000, + ); + const captionValue: string = file?.pubMagicMetadata?.data?.caption; + return JSON.stringify( + { + title: fileExportName, + caption: captionValue, + creationTime: { + timestamp: creationTime, + formatted: formatDateTimeShort(creationTime * 1000), + }, + modificationTime: { + timestamp: modificationTime, + formatted: formatDateTimeShort(modificationTime * 1000), + }, + geoData: { + latitude: metadata.latitude, + longitude: metadata.longitude, + }, + }, + null, + 2, + ); +}; + +export const getMetadataFolderExportPath = (collectionExportPath: string) => + `${collectionExportPath}/${exportMetadataDirectoryName}`; + +// if filepath is /home/user/Ente/Export/Collection1/1.jpg +// then metadata path is /home/user/Ente/Export/Collection1/ENTE_METADATA_FOLDER/1.jpg.json +const getFileMetadataExportPath = ( + collectionExportPath: string, + fileExportName: string, +) => + `${collectionExportPath}/${exportMetadataDirectoryName}/${fileExportName}.json`; + +export const getLivePhotoExportName = ( + imageExportName: string, + videoExportName: string, +) => + JSON.stringify({ + image: imageExportName, + video: videoExportName, + }); + +export const isLivePhotoExportName = (exportName: string) => { + try { + JSON.parse(exportName); + return true; + } catch (e) { + return false; + } +}; + +const parseLivePhotoExportName = ( + livePhotoExportName: string, +): { image: string; video: string } => { + const { image, video } = JSON.parse(livePhotoExportName); + return { image, video }; +}; + +const isExportInProgress = (exportStage: ExportStage) => + exportStage > ExportStage.INIT && exportStage < ExportStage.FINISHED; + +/** + * Move {@link fileName} in {@link collectionName} to Trash. + * + * Also move its associated metadata JSON to Trash. + * + * @param exportDir The root directory on the user's filesystem where we are + * exporting to. + * */ +const moveToTrash = async ( + exportDir: string, + collectionName: string, + fileName: string, +) => { + const fs = ensureElectron().fs; + + const filePath = `${exportDir}/${collectionName}/${fileName}`; + const trashDir = `${exportDir}/${exportTrashDirectoryName}/${collectionName}`; + const metadataFileName = `${fileName}.json`; + const metadataFilePath = `${exportDir}/${collectionName}/${exportMetadataDirectoryName}/${metadataFileName}`; + const metadataTrashDir = `${exportDir}/${exportTrashDirectoryName}/${collectionName}/${exportMetadataDirectoryName}`; + + log.info(`Moving file ${filePath} and its metadata to trash folder`); + + if (await fs.exists(filePath)) { + await fs.mkdirIfNeeded(trashDir); + const trashFilePath = await safeFileName(trashDir, fileName, fs.exists); + await fs.rename(filePath, trashFilePath); + } + + if (await fs.exists(metadataFilePath)) { + await fs.mkdirIfNeeded(metadataTrashDir); + const metadataTrashFilePath = await safeFileName( + metadataTrashDir, + metadataFileName, + fs.exists, + ); + await fs.rename(filePath, metadataTrashFilePath); + } +}; diff --git a/web/apps/photos/src/services/export/migration.ts b/web/apps/photos/src/services/export/migration.ts index 6c79420ed..3f471b539 100644 --- a/web/apps/photos/src/services/export/migration.ts +++ b/web/apps/photos/src/services/export/migration.ts @@ -1,3 +1,5 @@ +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"; @@ -6,7 +8,6 @@ import { FILE_TYPE } from "constants/file"; import { getLocalCollections } from "services/collectionService"; import downloadManager from "services/download"; import { getAllLocalFiles } from "services/fileService"; -import { decodeLivePhoto } from "services/livePhotoService"; import { Collection } from "types/collection"; import { CollectionExportNames, @@ -15,34 +16,29 @@ import { ExportRecordV0, ExportRecordV1, ExportRecordV2, + ExportedCollectionPaths, FileExportNames, } from "types/export"; import { EnteFile } from "types/file"; import { getNonEmptyPersonalCollections } from "utils/collection"; import { - getCollectionExportPath, + getIDBasedSortedFiles, + getPersonalFiles, + mergeMetadata, + splitFilenameAndExtension, +} from "utils/file"; +import { + safeDirectoryName, + safeFileName, + sanitizeFilename, +} from "utils/native-fs"; +import { + exportMetadataDirectoryName, getCollectionIDFromFileUID, getExportRecordFileUID, getLivePhotoExportName, getMetadataFolderExportPath, -} from "utils/export"; -import { - convertCollectionIDFolderPathObjectToMap, - getExportedFiles, - getFileMetadataSavePath, - getFileSavePath, - getOldCollectionFolderPath, - getOldFileMetadataSavePath, - getOldFileSavePath, - getUniqueCollectionFolderPath, - getUniqueFileExportNameForMigration, - getUniqueFileSaveName, -} from "utils/export/migration"; -import { - getIDBasedSortedFiles, - getPersonalFiles, - mergeMetadata, -} from "utils/file"; +} from "."; import exportService from "./index"; export async function migrateExport( @@ -193,40 +189,29 @@ async function migrationV4ToV5(exportDir: string, exportRecord: ExportRecord) { await removeCollectionExportMissingMetadataFolder(exportDir, exportRecord); } -/* - This updates the folder name of already exported folders from the earlier format of - `collectionID_collectionName` to newer `collectionName(numbered)` format -*/ -async function migrateCollectionFolders( +/** + * Update the folder name of already exported folders from the earlier format of + * `collectionID_collectionName` to newer `collectionName(numbered)` format. + */ +const migrateCollectionFolders = async ( collections: Collection[], exportDir: string, collectionIDPathMap: Map, -) { +) => { + const fs = ensureElectron().fs; for (const collection of collections) { - const oldCollectionExportPath = getOldCollectionFolderPath( - exportDir, - collection.id, - collection.name, - ); - const newCollectionExportPath = await getUniqueCollectionFolderPath( + const oldPath = `${exportDir}/${collection.id}_${oldSanitizeName(collection.name)}`; + const newPath = await safeDirectoryName( exportDir, collection.name, + fs.exists, ); - collectionIDPathMap.set(collection.id, newCollectionExportPath); - if (!(await exportService.exists(oldCollectionExportPath))) { - continue; - } - await exportService.rename( - oldCollectionExportPath, - newCollectionExportPath, - ); - await addCollectionExportedRecordV1( - exportDir, - collection.id, - newCollectionExportPath, - ); + collectionIDPathMap.set(collection.id, newPath); + if (!(await fs.exists(oldPath))) continue; + await fs.rename(oldPath, newPath); + await addCollectionExportedRecordV1(exportDir, collection.id, newPath); } -} +}; /* This updates the file name of already exported files from the earlier format of @@ -236,37 +221,27 @@ async function migrateFiles( files: EnteFile[], collectionIDPathMap: Map, ) { + const fs = ensureElectron().fs; for (const file of files) { - const oldFileSavePath = getOldFileSavePath( - collectionIDPathMap.get(file.collectionID), - file, - ); - const oldFileMetadataSavePath = getOldFileMetadataSavePath( - collectionIDPathMap.get(file.collectionID), - file, - ); - const newFileSaveName = await getUniqueFileSaveName( - collectionIDPathMap.get(file.collectionID), + const collectionPath = collectionIDPathMap.get(file.collectionID); + const metadataPath = `${collectionPath}/${exportMetadataDirectoryName}`; + + const oldFileName = `${file.id}_${oldSanitizeName(file.metadata.title)}`; + const oldFilePath = `${collectionPath}/${oldFileName}`; + const oldFileMetadataPath = `${metadataPath}/${oldFileName}.json`; + + const newFileName = await safeFileName( + collectionPath, file.metadata.title, + fs.exists, ); + const newFilePath = `${collectionPath}/${newFileName}`; + const newFileMetadataPath = `${metadataPath}/${newFileName}.json`; - const newFileSavePath = getFileSavePath( - collectionIDPathMap.get(file.collectionID), - newFileSaveName, - ); + if (!(await fs.exists(oldFilePath))) continue; - const newFileMetadataSavePath = getFileMetadataSavePath( - collectionIDPathMap.get(file.collectionID), - newFileSaveName, - ); - if (!(await exportService.exists(oldFileSavePath))) { - continue; - } - await exportService.rename(oldFileSavePath, newFileSavePath); - await exportService.rename( - oldFileMetadataSavePath, - newFileMetadataSavePath, - ); + await fs.rename(oldFilePath, newFilePath); + await fs.rename(oldFileMetadataPath, newFileMetadataPath); } } @@ -343,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( @@ -426,6 +404,7 @@ async function removeCollectionExportMissingMetadataFolder( exportDir: string, exportRecord: ExportRecord, ) { + const fs = ensureElectron().fs; if (!exportRecord?.collectionExportNames) { return; } @@ -439,9 +418,9 @@ async function removeCollectionExportMissingMetadataFolder( collectionExportName, ] of properlyExportedCollectionsAll) { if ( - await exportService.exists( + await fs.exists( getMetadataFolderExportPath( - getCollectionExportPath(exportDir, collectionExportName), + `${exportDir}/${collectionExportName}`, ), ) ) { @@ -475,3 +454,68 @@ async function removeCollectionExportMissingMetadataFolder( }; await exportService.updateExportRecord(exportDir, updatedExportRecord); } + +const convertCollectionIDFolderPathObjectToMap = ( + exportedCollectionPaths: ExportedCollectionPaths, +): Map => { + return new Map( + Object.entries(exportedCollectionPaths ?? {}).map((e) => { + return [Number(e[0]), String(e[1])]; + }), + ); +}; + +const getExportedFiles = ( + allFiles: EnteFile[], + exportRecord: ExportRecordV0 | ExportRecordV1 | ExportRecordV2, +) => { + if (!exportRecord?.exportedFiles) { + return []; + } + const exportedFileIds = new Set(exportRecord?.exportedFiles); + const exportedFiles = allFiles.filter((file) => { + if (exportedFileIds.has(getExportRecordFileUID(file))) { + return true; + } else { + return false; + } + }); + return exportedFiles; +}; + +const oldSanitizeName = (name: string) => + name.replaceAll("/", "_").replaceAll(" ", "_"); + +const getFileSavePath = (collectionFolderPath: string, fileSaveName: string) => + `${collectionFolderPath}/${fileSaveName}`; + +const getUniqueFileExportNameForMigration = ( + collectionPath: string, + filename: string, + usedFilePaths: Map>, +) => { + let fileExportName = sanitizeFilename(filename); + let count = 1; + while ( + usedFilePaths + .get(collectionPath) + ?.has(getFileSavePath(collectionPath, fileExportName)) + ) { + const filenameParts = splitFilenameAndExtension( + sanitizeFilename(filename), + ); + if (filenameParts[1]) { + fileExportName = `${filenameParts[0]}(${count}).${filenameParts[1]}`; + } else { + fileExportName = `${filenameParts[0]}(${count})`; + } + count++; + } + if (!usedFilePaths.has(collectionPath)) { + usedFilePaths.set(collectionPath, new Set()); + } + usedFilePaths + .get(collectionPath) + .add(getFileSavePath(collectionPath, fileExportName)); + return fileExportName; +}; 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 3116ac23c..1dedadf15 100644 --- a/web/apps/photos/src/services/machineLearning/faceService.ts +++ b/web/apps/photos/src/services/machineLearning/faceService.ts @@ -1,3 +1,4 @@ +import { openCache } from "@/next/blob-cache"; import log from "@/next/log"; import { DetectedFace, @@ -14,7 +15,6 @@ import { getOriginalImageBitmap, isDifferentOrOld, } from "utils/machineLearning"; -import { storeFaceCrop } from "utils/machineLearning/faceCrop"; import mlIDbStorage from "utils/storage/mlIDbStorage"; import ReaderService from "./readerService"; @@ -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(); @@ -225,23 +227,15 @@ class FaceService { face.detection, syncContext.config.faceCrop, ); - try { - face.crop = await storeFaceCrop( - face.id, - faceCrop, - syncContext.config.faceCrop.blobOptions, - ); - } catch (e) { - // TODO(MR): Temporarily ignoring errors about failing cache puts - // when using a custom scheme in Electron. Needs an alternative - // approach, perhaps OPFS. - console.error( - "Ignoring error when caching face crop, the face crop will not be available", - e, - ); - } - const blob = await imageBitmapToBlob(faceCrop.image); + + const blobOptions = syncContext.config.faceCrop.blobOptions; + const blob = await imageBitmapToBlob(faceCrop.image, blobOptions); + + const cache = await openCache("face-crops"); + await cache.put(face.id, blob); + faceCrop.image.close(); + return blob; } 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/machineLearning/peopleService.ts b/web/apps/photos/src/services/machineLearning/peopleService.ts index dbd2706bc..ad7d7bcec 100644 --- a/web/apps/photos/src/services/machineLearning/peopleService.ts +++ b/web/apps/photos/src/services/machineLearning/peopleService.ts @@ -62,7 +62,7 @@ class PeopleService { (a, b) => b.detection.probability - a.detection.probability, ); - if (personFace && !personFace.crop?.imageUrl) { + if (personFace && !personFace.crop?.cacheKey) { const file = await getLocalFile(personFace.fileId); const imageBitmap = await getOriginalImageBitmap(file); await FaceService.saveFaceCrop( @@ -76,7 +76,7 @@ class PeopleService { id: index, files: faces.map((f) => f.fileId), displayFaceId: personFace?.id, - displayImageUrl: personFace?.crop?.imageUrl, + faceCropCacheKey: personFace?.crop?.cacheKey, }; await mlIDbStorage.putPerson(person); diff --git a/web/apps/photos/src/services/pending-uploads.ts b/web/apps/photos/src/services/pending-uploads.ts new file mode 100644 index 000000000..3b219f5b0 --- /dev/null +++ b/web/apps/photos/src/services/pending-uploads.ts @@ -0,0 +1,42 @@ +import { ensureElectron } from "@/next/electron"; +import { Collection } from "types/collection"; +import { ElectronFile, FileWithCollection } from "types/upload"; + +export const setToUploadCollection = async (collections: Collection[]) => { + let collectionName: string = null; + /* collection being one suggest one of two things + 1. Either the user has upload to a single existing collection + 2. Created a new single collection to upload to + may have had multiple folder, but chose to upload + to one album + hence saving the collection name when upload collection count is 1 + helps the info of user choosing this options + and on next upload we can directly start uploading to this collection + */ + if (collections.length === 1) { + collectionName = collections[0].name; + } + await ensureElectron().setPendingUploadCollection(collectionName); +}; + +export const updatePendingUploads = async (files: FileWithCollection[]) => { + const filePaths = []; + for (const fileWithCollection of files) { + if (fileWithCollection.isLivePhoto) { + filePaths.push( + (fileWithCollection.livePhotoAssets.image as ElectronFile).path, + (fileWithCollection.livePhotoAssets.video as ElectronFile).path, + ); + } else { + filePaths.push((fileWithCollection.file as ElectronFile).path); + } + } + await ensureElectron().setPendingUploadFiles("files", filePaths); +}; + +export const cancelRemainingUploads = async () => { + const electron = ensureElectron(); + await electron.setPendingUploadCollection(undefined); + await electron.setPendingUploadFiles("zips", []); + await electron.setPendingUploadFiles("files", []); +}; diff --git a/web/apps/photos/src/services/upload/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..5a8c4e1f5 100644 --- a/web/apps/photos/src/services/upload/metadataService.ts +++ b/web/apps/photos/src/services/upload/metadataService.ts @@ -1,5 +1,8 @@ +import { encodeLivePhoto } from "@/media/live-photo"; +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 { parseDateFromFusedDateString, tryToParseDateTime, @@ -7,21 +10,35 @@ 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, + LIVE_PHOTO_ASSET_SIZE_LIMIT, + NULL_EXTRACTED_METADATA, + NULL_LOCATION, +} from "constants/upload"; +import * as ffmpegService from "services/ffmpeg/ffmpegService"; +import { getElectronFileStream, getFileStream } from "services/readerService"; +import { getFileType } from "services/typeDetectionService"; import { FilePublicMagicMetadataProps } from "types/file"; import { + DataStream, ElectronFile, ExtractMetadataResult, FileTypeInfo, + FileWithCollection, + LivePhotoAssets, Location, Metadata, ParsedExtractedMetadata, ParsedMetadataJSON, + ParsedMetadataJSONMap, } from "types/upload"; -import { splitFilenameAndExtension } from "utils/file"; +import { getFileTypeFromExtensionForLivePhotoClustering } from "utils/file/livePhoto"; +import { getUint8ArrayView } from "../readerService"; import { getEXIFLocation, getEXIFTime, getParsedExifData } from "./exifService"; -import { getFileHash } from "./hashService"; -import { getVideoMetadata } from "./videoMetadataService"; +import { generateThumbnail } from "./thumbnailService"; +import uploadCancelService from "./uploadCancelService"; +import { extractFileMetadata } from "./uploadService"; const NULL_PARSED_METADATA_JSON: ParsedMetadataJSON = { creationTime: null, @@ -272,3 +289,366 @@ 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: 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 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 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; + } +} + +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/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index 82b761091..a01cd1775 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -8,13 +8,16 @@ import { Events, eventBus } from "@ente/shared/events"; import { Remote } from "comlink"; import { UPLOAD_RESULT, UPLOAD_STAGES } from "constants/upload"; import isElectron from "is-electron"; -import ImportService from "services/importService"; +import { + cancelRemainingUploads, + updatePendingUploads, +} from "services/pending-uploads"; import { getLocalPublicFiles, getPublicCollectionUID, } from "services/publicCollectionService"; import { getDisableCFUploadProxyFlag } from "services/userService"; -import watchFolderService from "services/watchFolder/watchFolderService"; +import watcher from "services/watch"; import { Collection } from "types/collection"; import { EncryptedEnteFile, EnteFile } from "types/file"; import { SetFiles } from "types/gallery"; @@ -37,8 +40,7 @@ import { } 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, { uploader } from "./uploadService"; const MAX_CONCURRENT_UPLOADS = 4; @@ -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); @@ -387,11 +389,13 @@ class UploadManager { uploadedFile: EncryptedEnteFile, ) { if (isElectron()) { - await watchFolderService.onFileUpload( - fileUploadResult, - fileWithCollection, - uploadedFile, - ); + if (watcher.isUploadRunning()) { + await watcher.onFileUpload( + fileUploadResult, + fileWithCollection, + uploadedFile, + ); + } } } @@ -431,12 +435,12 @@ class UploadManager { this.remainingFiles = this.remainingFiles.filter( (file) => !areFileWithCollectionsSame(file, fileWithCollection), ); - await ImportService.updatePendingUploads(this.remainingFiles); + await updatePendingUploads(this.remainingFiles); } } public shouldAllowNewUpload = () => { - return !this.uploadInProgress || watchFolderService.isUploadRunning(); + return !this.uploadInProgress || watcher.isUploadRunning(); }; } diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index 95e4752a7..abcf49591 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -1,14 +1,34 @@ +import { 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 { sleep } from "@ente/shared/utils"; import { Remote } from "comlink"; +import { + FILE_READER_CHUNK_SIZE, + MAX_FILE_SIZE_SUPPORTED, + 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, @@ -22,28 +42,37 @@ import { UploadURL, isDataStream, } 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 { + MAX_FILE_NAME_LENGTH_GOOGLE_EXPORT, clusterLivePhotoFiles, extractLivePhotoMetadata, + extractMetadata, + getClippedMetadataJSONMapKeyForFile, getLivePhotoFileType, getLivePhotoName, getLivePhotoSize, + getMetadataJSONMapKeyForFile, readLivePhoto, -} from "./livePhotoService"; -import { constructPublicMagicMetadata } from "./magicMetadataService"; +} from "./metadataService"; import { uploadStreamUsingMultipart } from "./multiPartUploadService"; import publicUploadHttpClient from "./publicUploadHttpClient"; +import { generateThumbnail } from "./thumbnailService"; 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< @@ -310,4 +339,368 @@ 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; +} + +function getFilename(file: File | ElectronFile) { + return 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, + }; +} + +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 }; +} + +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: 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/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/watch.ts b/web/apps/photos/src/services/watch.ts new file mode 100644 index 000000000..77467a497 --- /dev/null +++ b/web/apps/photos/src/services/watch.ts @@ -0,0 +1,642 @@ +/** + * @file Interface with the Node.js layer of our desktop app to provide the + * watch folders functionality. + */ + +import { ensureElectron } from "@/next/electron"; +import { basename, dirname } from "@/next/file"; +import log from "@/next/log"; +import type { + CollectionMapping, + FolderWatch, + FolderWatchSyncedFile, +} from "@/next/types/ipc"; +import { UPLOAD_RESULT } from "constants/upload"; +import debounce from "debounce"; +import uploadManager from "services/upload/uploadManager"; +import { Collection } from "types/collection"; +import { EncryptedEnteFile } from "types/file"; +import { ElectronFile, FileWithCollection } from "types/upload"; +import { groupFilesBasedOnCollectionID } from "utils/file"; +import { isHiddenFile } from "utils/upload"; +import { removeFromCollection } from "./collectionService"; +import { getLocalFiles } from "./fileService"; + +/** + * Watch for file system folders and automatically update the corresponding Ente + * collections. + * + * This class relies on APIs exposed over the Electron IPC layer, and thus only + * works when we're running inside our desktop app. + */ +class FolderWatcher { + /** Pending file system events that we need to process. */ + private eventQueue: WatchEvent[] = []; + /** The folder watch whose event we're currently processing */ + private activeWatch: FolderWatch | undefined; + /** + * If the file system directory corresponding to the (root) folder path of a + * folder watch is deleted on disk, we note down that in this queue so that + * we can ignore any file system events that come for it next. + */ + private deletedFolderPaths: string[] = []; + /** `true` if we are using the uploader. */ + private uploadRunning = false; + /** `true` if we are temporarily paused to let a user upload go through. */ + private isPaused = false; + private filePathToUploadedFileIDMap = new Map(); + private unUploadableFilePaths = new Set(); + + /** + * A function to call when we want to enqueue a new upload of the given list + * of file paths to the given Ente collection. + * + * This is passed as a param to {@link init}. + */ + private upload: (collectionName: string, filePaths: string[]) => void; + /** + * A function to call when we want to sync with the backend. 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: FileWithCollection, + file: EncryptedEnteFile, + ) { + if ( + [ + UPLOAD_RESULT.ADDED_SYMLINK, + UPLOAD_RESULT.UPLOADED, + UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL, + UPLOAD_RESULT.ALREADY_UPLOADED, + ].includes(fileUploadResult) + ) { + if (fileWithCollection.isLivePhoto) { + this.filePathToUploadedFileIDMap.set( + (fileWithCollection.livePhotoAssets.image as ElectronFile) + .path, + file, + ); + this.filePathToUploadedFileIDMap.set( + (fileWithCollection.livePhotoAssets.video as ElectronFile) + .path, + file, + ); + } else { + this.filePathToUploadedFileIDMap.set( + (fileWithCollection.file as ElectronFile).path, + file, + ); + } + } else if ( + [UPLOAD_RESULT.UNSUPPORTED, UPLOAD_RESULT.TOO_LARGE].includes( + fileUploadResult, + ) + ) { + if (fileWithCollection.isLivePhoto) { + this.unUploadableFilePaths.add( + (fileWithCollection.livePhotoAssets.image as ElectronFile) + .path, + ); + this.unUploadableFilePaths.add( + (fileWithCollection.livePhotoAssets.video as ElectronFile) + .path, + ); + } else { + this.unUploadableFilePaths.add( + (fileWithCollection.file as ElectronFile).path, + ); + } + } + } + + /** + * Callback invoked by the uploader whenever all the files we requested to + * {@link upload} get uploaded. + */ + async allFileUploadsDone( + filesWithCollection: FileWithCollection[], + collections: Collection[], + ) { + const electron = ensureElectron(); + const watch = this.activeWatch; + + log.debug(() => + JSON.stringify({ + f: "watch/allFileUploadsDone", + filesWithCollection, + collections, + watch, + }), + ); + + const { syncedFiles, ignoredFiles } = + this.parseAllFileUploadsDone(filesWithCollection); + + log.debug(() => + JSON.stringify({ + f: "watch/allFileUploadsDone", + syncedFiles, + ignoredFiles, + }), + ); + + if (syncedFiles.length > 0) + await electron.watch.updateSyncedFiles( + watch.syncedFiles.concat(syncedFiles), + watch.folderPath, + ); + + if (ignoredFiles.length > 0) + await electron.watch.updateIgnoredFiles( + watch.ignoredFiles.concat(ignoredFiles), + watch.folderPath, + ); + + this.activeWatch = undefined; + this.uploadRunning = false; + + this.debouncedRunNextEvent(); + } + + private parseAllFileUploadsDone(filesWithCollection: FileWithCollection[]) { + const syncedFiles: FolderWatch["syncedFiles"] = []; + const ignoredFiles: FolderWatch["ignoredFiles"] = []; + + for (const fileWithCollection of filesWithCollection) { + if (fileWithCollection.isLivePhoto) { + const imagePath = ( + fileWithCollection.livePhotoAssets.image as ElectronFile + ).path; + const videoPath = ( + fileWithCollection.livePhotoAssets.video as ElectronFile + ).path; + + if ( + this.filePathToUploadedFileIDMap.has(imagePath) && + this.filePathToUploadedFileIDMap.has(videoPath) + ) { + const imageFile = { + path: imagePath, + uploadedFileID: + this.filePathToUploadedFileIDMap.get(imagePath).id, + collectionID: + this.filePathToUploadedFileIDMap.get(imagePath) + .collectionID, + }; + const videoFile = { + path: videoPath, + uploadedFileID: + this.filePathToUploadedFileIDMap.get(videoPath).id, + collectionID: + this.filePathToUploadedFileIDMap.get(videoPath) + .collectionID, + }; + syncedFiles.push(imageFile); + syncedFiles.push(videoFile); + log.debug( + () => + `added image ${JSON.stringify( + imageFile, + )} and video file ${JSON.stringify( + videoFile, + )} to uploadedFiles`, + ); + } else if ( + this.unUploadableFilePaths.has(imagePath) && + this.unUploadableFilePaths.has(videoPath) + ) { + ignoredFiles.push(imagePath); + ignoredFiles.push(videoPath); + log.debug( + () => + `added image ${imagePath} and video file ${videoPath} to rejectedFiles`, + ); + } + this.filePathToUploadedFileIDMap.delete(imagePath); + this.filePathToUploadedFileIDMap.delete(videoPath); + } else { + const filePath = (fileWithCollection.file as ElectronFile).path; + + if (this.filePathToUploadedFileIDMap.has(filePath)) { + const file = { + path: filePath, + uploadedFileID: + this.filePathToUploadedFileIDMap.get(filePath).id, + collectionID: + this.filePathToUploadedFileIDMap.get(filePath) + .collectionID, + }; + syncedFiles.push(file); + log.debug(() => `added file ${JSON.stringify(file)}`); + } else if (this.unUploadableFilePaths.has(filePath)) { + ignoredFiles.push(filePath); + log.debug(() => `added file ${filePath} to rejectedFiles`); + } + this.filePathToUploadedFileIDMap.delete(filePath); + } + } + + return { syncedFiles, ignoredFiles }; + } + + private pruneFileEventsFromDeletedFolderPaths() { + const deletedFolderPath = this.deletedFolderPaths.shift(); + if (!deletedFolderPath) return false; + + this.eventQueue = this.eventQueue.filter( + (event) => !event.filePath.startsWith(deletedFolderPath), + ); + + return true; + } + + private async moveToTrash(syncedFiles: FolderWatch["syncedFiles"]) { + 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/export/index.ts b/web/apps/photos/src/types/export/index.ts index ce85f32fd..64ef249ed 100644 --- a/web/apps/photos/src/types/export/index.ts +++ b/web/apps/photos/src/types/export/index.ts @@ -1,4 +1,4 @@ -import { ExportStage } from "constants/export"; +import type { ExportStage } from "services/export"; import { EnteFile } from "types/file"; export interface ExportProgress { diff --git a/web/apps/photos/src/types/machineLearning/index.ts b/web/apps/photos/src/types/machineLearning/index.ts index d0c902333..2c3961cdf 100644 --- a/web/apps/photos/src/types/machineLearning/index.ts +++ b/web/apps/photos/src/types/machineLearning/index.ts @@ -90,7 +90,7 @@ export interface FaceCrop { } export interface StoredFaceCrop { - imageUrl: string; + cacheKey: string; imageBox: Box; } @@ -128,7 +128,7 @@ export interface Person { name?: string; files: Array; displayFaceId?: string; - displayImageUrl?: string; + faceCropCacheKey?: string; } export interface MlFileData { @@ -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..35164ec47 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; @@ -151,13 +156,6 @@ export interface ParsedExtractedMetadata { height: number; } -// This is used to prompt the user the make upload strategy choice -export interface ImportSuggestion { - rootFolderName: string; - hasNestedFolders: boolean; - hasRootLevelFileWithFolder: boolean; -} - export interface PublicUploadProps { token: string; passwordToken: string; diff --git a/web/apps/photos/src/types/watchFolder/index.ts b/web/apps/photos/src/types/watchFolder/index.ts deleted file mode 100644 index bd55704de..000000000 --- a/web/apps/photos/src/types/watchFolder/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { UPLOAD_STRATEGY } from "constants/upload"; -import { ElectronFile } from "types/upload"; - -export interface WatchMappingSyncedFile { - path: string; - uploadedFileID: number; - collectionID: number; -} - -export interface WatchMapping { - rootFolderName: string; - folderPath: string; - uploadStrategy: UPLOAD_STRATEGY; - syncedFiles: WatchMappingSyncedFile[]; - ignoredFiles: string[]; -} - -export interface EventQueueItem { - type: "upload" | "trash"; - folderPath: string; - collectionName?: string; - paths?: string[]; - files?: ElectronFile[]; -} diff --git a/web/apps/photos/src/utils/collection/index.ts b/web/apps/photos/src/utils/collection/index.ts index c18861515..b0116964f 100644 --- a/web/apps/photos/src/utils/collection/index.ts +++ b/web/apps/photos/src/utils/collection/index.ts @@ -1,6 +1,6 @@ +import { ensureElectron } from "@/next/electron"; import log from "@/next/log"; import { CustomError } from "@ente/shared/error"; -import { getAlbumsURL } from "@ente/shared/network/api"; import { LS_KEYS, getData } from "@ente/shared/storage/localStorage"; import { getUnixTimeInMicroSecondsWithDelta } from "@ente/shared/time"; import { User } from "@ente/shared/user/types"; @@ -30,7 +30,6 @@ import { updatePublicCollectionMagicMetadata, updateSharedCollectionMagicMetadata, } from "services/collectionService"; -import exportService from "services/export"; import { getAllLocalFiles, getLocalFiles } from "services/fileService"; import { COLLECTION_ROLE, @@ -42,12 +41,9 @@ import { import { EnteFile } from "types/file"; import { SetFilesDownloadProgressAttributes } from "types/gallery"; import { SUB_TYPE, VISIBILITY_STATE } from "types/magicMetadata"; -import { - getCollectionExportPath, - getUniqueCollectionExportName, -} from "utils/export"; import { downloadFilesWithProgress } from "utils/file"; import { isArchivedCollection, updateMagicMetadata } from "utils/magicMetadata"; +import { safeDirectoryName } from "utils/native-fs"; export enum COLLECTION_OPS_TYPE { ADD, @@ -172,15 +168,14 @@ async function createCollectionDownloadFolder( downloadDirPath: string, collectionName: string, ) { - const collectionDownloadName = await getUniqueCollectionExportName( + const fs = ensureElectron().fs; + const collectionDownloadName = await safeDirectoryName( downloadDirPath, collectionName, + fs.exists, ); - const collectionDownloadPath = getCollectionExportPath( - downloadDirPath, - collectionDownloadName, - ); - await exportService.checkExistsAndCreateDir(collectionDownloadPath); + const collectionDownloadPath = `${downloadDirPath}/${collectionDownloadName}`; + await fs.mkdirIfNeeded(collectionDownloadPath); return collectionDownloadPath; } @@ -193,11 +188,6 @@ export function appendCollectionKeyToShareURL( } const sharableURL = new URL(url); - const albumsURL = new URL(getAlbumsURL()); - - sharableURL.protocol = albumsURL.protocol; - sharableURL.host = albumsURL.host; - sharableURL.pathname = albumsURL.pathname; const bytes = Buffer.from(collectionKey, "base64"); sharableURL.hash = bs58.encode(bytes); diff --git a/web/apps/photos/src/utils/export/index.ts b/web/apps/photos/src/utils/export/index.ts deleted file mode 100644 index a98e431b2..000000000 --- a/web/apps/photos/src/utils/export/index.ts +++ /dev/null @@ -1,312 +0,0 @@ -import exportService from "services/export"; -import { Collection } from "types/collection"; -import { - CollectionExportNames, - ExportRecord, - FileExportNames, -} from "types/export"; - -import { EnteFile } from "types/file"; - -import { formatDateTimeShort } from "@ente/shared/time/format"; -import { - ENTE_METADATA_FOLDER, - ENTE_TRASH_FOLDER, - ExportStage, -} from "constants/export"; -import sanitize from "sanitize-filename"; -import { Metadata } from "types/upload"; -import { getCollectionUserFacingName } from "utils/collection"; -import { splitFilenameAndExtension } from "utils/file"; - -export const getExportRecordFileUID = (file: EnteFile) => - `${file.id}_${file.collectionID}_${file.updationTime}`; - -export const getCollectionIDFromFileUID = (fileUID: string) => - Number(fileUID.split("_")[1]); - -export const convertCollectionIDExportNameObjectToMap = ( - collectionExportNames: CollectionExportNames, -): Map => { - return new Map( - Object.entries(collectionExportNames ?? {}).map((e) => { - return [Number(e[0]), String(e[1])]; - }), - ); -}; - -export const convertFileIDExportNameObjectToMap = ( - fileExportNames: FileExportNames, -): Map => { - return new Map( - Object.entries(fileExportNames ?? {}).map((e) => { - return [String(e[0]), String(e[1])]; - }), - ); -}; - -export const getRenamedExportedCollections = ( - collections: Collection[], - exportRecord: ExportRecord, -) => { - if (!exportRecord?.collectionExportNames) { - return []; - } - const collectionIDExportNameMap = convertCollectionIDExportNameObjectToMap( - exportRecord.collectionExportNames, - ); - const renamedCollections = collections.filter((collection) => { - if (collectionIDExportNameMap.has(collection.id)) { - const currentExportName = collectionIDExportNameMap.get( - collection.id, - ); - - const collectionExportName = - getCollectionUserFacingName(collection); - - if (currentExportName === collectionExportName) { - return false; - } - const hasNumberedSuffix = currentExportName.match(/\(\d+\)$/); - const currentExportNameWithoutNumberedSuffix = hasNumberedSuffix - ? currentExportName.replace(/\(\d+\)$/, "") - : currentExportName; - - return ( - collectionExportName !== currentExportNameWithoutNumberedSuffix - ); - } - return false; - }); - return renamedCollections; -}; - -export const getDeletedExportedCollections = ( - collections: Collection[], - exportRecord: ExportRecord, -) => { - if (!exportRecord?.collectionExportNames) { - return []; - } - const presentCollections = new Set( - collections.map((collection) => collection.id), - ); - const deletedExportedCollections = Object.keys( - exportRecord?.collectionExportNames, - ) - .map(Number) - .filter((collectionID) => { - if (!presentCollections.has(collectionID)) { - return true; - } - return false; - }); - return deletedExportedCollections; -}; - -export const getUnExportedFiles = ( - allFiles: EnteFile[], - exportRecord: ExportRecord, -) => { - if (!exportRecord?.fileExportNames) { - return allFiles; - } - const exportedFiles = new Set(Object.keys(exportRecord?.fileExportNames)); - const unExportedFiles = allFiles.filter((file) => { - if (!exportedFiles.has(getExportRecordFileUID(file))) { - return true; - } - return false; - }); - return unExportedFiles; -}; - -export const getDeletedExportedFiles = ( - allFiles: EnteFile[], - exportRecord: ExportRecord, -): string[] => { - if (!exportRecord?.fileExportNames) { - return []; - } - const presentFileUIDs = new Set( - allFiles?.map((file) => getExportRecordFileUID(file)), - ); - const deletedExportedFiles = Object.keys( - exportRecord?.fileExportNames, - ).filter((fileUID) => { - if (!presentFileUIDs.has(fileUID)) { - return true; - } - return false; - }); - return deletedExportedFiles; -}; - -export const getCollectionExportedFiles = ( - exportRecord: ExportRecord, - collectionID: number, -): string[] => { - if (!exportRecord?.fileExportNames) { - return []; - } - const collectionExportedFiles = Object.keys( - exportRecord?.fileExportNames, - ).filter((fileUID) => { - const fileCollectionID = Number(fileUID.split("_")[1]); - if (fileCollectionID === collectionID) { - return true; - } else { - return false; - } - }); - return collectionExportedFiles; -}; - -export const getGoogleLikeMetadataFile = ( - fileExportName: string, - file: EnteFile, -) => { - const metadata: Metadata = file.metadata; - const creationTime = Math.floor(metadata.creationTime / 1000000); - const modificationTime = Math.floor( - (metadata.modificationTime ?? metadata.creationTime) / 1000000, - ); - const captionValue: string = file?.pubMagicMetadata?.data?.caption; - return JSON.stringify( - { - title: fileExportName, - caption: captionValue, - creationTime: { - timestamp: creationTime, - formatted: formatDateTimeShort(creationTime * 1000), - }, - modificationTime: { - timestamp: modificationTime, - formatted: formatDateTimeShort(modificationTime * 1000), - }, - geoData: { - latitude: metadata.latitude, - longitude: metadata.longitude, - }, - }, - null, - 2, - ); -}; - -export const sanitizeName = (name: string) => - sanitize(name, { replacement: "_" }); - -export const getUniqueCollectionExportName = async ( - dir: string, - collectionName: string, -): Promise => { - let collectionExportName = sanitizeName(collectionName); - let count = 1; - while ( - (await exportService.exists( - getCollectionExportPath(dir, collectionExportName), - )) || - collectionExportName === ENTE_TRASH_FOLDER - ) { - collectionExportName = `${sanitizeName(collectionName)}(${count})`; - count++; - } - return collectionExportName; -}; - -export const getMetadataFolderExportPath = (collectionExportPath: string) => - `${collectionExportPath}/${ENTE_METADATA_FOLDER}`; - -export const getUniqueFileExportName = async ( - collectionExportPath: string, - filename: string, -) => { - let fileExportName = sanitizeName(filename); - let count = 1; - while ( - await exportService.exists( - getFileExportPath(collectionExportPath, fileExportName), - ) - ) { - const filenameParts = splitFilenameAndExtension(sanitizeName(filename)); - if (filenameParts[1]) { - fileExportName = `${filenameParts[0]}(${count}).${filenameParts[1]}`; - } else { - fileExportName = `${filenameParts[0]}(${count})`; - } - count++; - } - return fileExportName; -}; - -export const getFileMetadataExportPath = ( - collectionExportPath: string, - fileExportName: string, -) => `${collectionExportPath}/${ENTE_METADATA_FOLDER}/${fileExportName}.json`; - -export const getCollectionExportPath = ( - exportFolder: string, - collectionExportName: string, -) => `${exportFolder}/${collectionExportName}`; - -export const getFileExportPath = ( - collectionExportPath: string, - fileExportName: string, -) => `${collectionExportPath}/${fileExportName}`; - -export const getTrashedFileExportPath = async ( - exportDir: string, - path: string, -) => { - const fileRelativePath = path.replace(`${exportDir}/`, ""); - let trashedFilePath = `${exportDir}/${ENTE_TRASH_FOLDER}/${fileRelativePath}`; - let count = 1; - while (await exportService.exists(trashedFilePath)) { - const trashedFilePathParts = splitFilenameAndExtension(trashedFilePath); - if (trashedFilePathParts[1]) { - trashedFilePath = `${trashedFilePathParts[0]}(${count}).${trashedFilePathParts[1]}`; - } else { - trashedFilePath = `${trashedFilePathParts[0]}(${count})`; - } - count++; - } - return trashedFilePath; -}; - -// if filepath is /home/user/Ente/Export/Collection1/1.jpg -// then metadata path is /home/user/Ente/Export/Collection1/ENTE_METADATA_FOLDER/1.jpg.json -export const getMetadataFileExportPath = (filePath: string) => { - // extract filename and collection folder path - const filename = filePath.split("/").pop(); - const collectionExportPath = filePath.replace(`/${filename}`, ""); - return `${collectionExportPath}/${ENTE_METADATA_FOLDER}/${filename}.json`; -}; - -export const getLivePhotoExportName = ( - imageExportName: string, - videoExportName: string, -) => - JSON.stringify({ - image: imageExportName, - video: videoExportName, - }); - -export const isLivePhotoExportName = (exportName: string) => { - try { - JSON.parse(exportName); - return true; - } catch (e) { - return false; - } -}; - -export const parseLivePhotoExportName = ( - livePhotoExportName: string, -): { image: string; video: string } => { - const { image, video } = JSON.parse(livePhotoExportName); - return { image, video }; -}; - -export const isExportInProgress = (exportStage: ExportStage) => - exportStage > ExportStage.INIT && exportStage < ExportStage.FINISHED; diff --git a/web/apps/photos/src/utils/export/migration.ts b/web/apps/photos/src/utils/export/migration.ts deleted file mode 100644 index c8988cac4..000000000 --- a/web/apps/photos/src/utils/export/migration.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { ENTE_METADATA_FOLDER } from "constants/export"; -import exportService from "services/export"; -import { - ExportedCollectionPaths, - ExportRecordV0, - ExportRecordV1, - ExportRecordV2, -} from "types/export"; -import { EnteFile } from "types/file"; -import { splitFilenameAndExtension } from "utils/ffmpeg"; -import { getExportRecordFileUID, sanitizeName } from "."; - -export const convertCollectionIDFolderPathObjectToMap = ( - exportedCollectionPaths: ExportedCollectionPaths, -): Map => { - return new Map( - Object.entries(exportedCollectionPaths ?? {}).map((e) => { - return [Number(e[0]), String(e[1])]; - }), - ); -}; - -export const getExportedFiles = ( - allFiles: EnteFile[], - exportRecord: ExportRecordV0 | ExportRecordV1 | ExportRecordV2, -) => { - if (!exportRecord?.exportedFiles) { - return []; - } - const exportedFileIds = new Set(exportRecord?.exportedFiles); - const exportedFiles = allFiles.filter((file) => { - if (exportedFileIds.has(getExportRecordFileUID(file))) { - return true; - } else { - return false; - } - }); - return exportedFiles; -}; - -export const oldSanitizeName = (name: string) => - name.replaceAll("/", "_").replaceAll(" ", "_"); - -export const getUniqueCollectionFolderPath = async ( - dir: string, - collectionName: string, -): Promise => { - let collectionFolderPath = `${dir}/${sanitizeName(collectionName)}`; - let count = 1; - while (await exportService.exists(collectionFolderPath)) { - collectionFolderPath = `${dir}/${sanitizeName( - collectionName, - )}(${count})`; - count++; - } - return collectionFolderPath; -}; - -export const getMetadataFolderPath = (collectionFolderPath: string) => - `${collectionFolderPath}/${ENTE_METADATA_FOLDER}`; - -export const getUniqueFileSaveName = async ( - collectionPath: string, - filename: string, -) => { - let fileSaveName = sanitizeName(filename); - let count = 1; - while ( - await exportService.exists( - getFileSavePath(collectionPath, fileSaveName), - ) - ) { - const filenameParts = splitFilenameAndExtension(sanitizeName(filename)); - if (filenameParts[1]) { - fileSaveName = `${filenameParts[0]}(${count}).${filenameParts[1]}`; - } else { - fileSaveName = `${filenameParts[0]}(${count})`; - } - count++; - } - return fileSaveName; -}; - -export const getOldFileSaveName = (filename: string, fileID: number) => - `${fileID}_${oldSanitizeName(filename)}`; - -export const getFileMetadataSavePath = ( - collectionFolderPath: string, - fileSaveName: string, -) => `${collectionFolderPath}/${ENTE_METADATA_FOLDER}/${fileSaveName}.json`; - -export const getFileSavePath = ( - collectionFolderPath: string, - fileSaveName: string, -) => `${collectionFolderPath}/${fileSaveName}`; - -export const getOldCollectionFolderPath = ( - dir: string, - collectionID: number, - collectionName: string, -) => `${dir}/${collectionID}_${oldSanitizeName(collectionName)}`; - -export const getOldFileSavePath = ( - collectionFolderPath: string, - file: EnteFile, -) => - `${collectionFolderPath}/${file.id}_${oldSanitizeName( - file.metadata.title, - )}`; - -export const getOldFileMetadataSavePath = ( - collectionFolderPath: string, - file: EnteFile, -) => - `${collectionFolderPath}/${ENTE_METADATA_FOLDER}/${ - file.id - }_${oldSanitizeName(file.metadata.title)}.json`; - -export const getUniqueFileExportNameForMigration = ( - collectionPath: string, - filename: string, - usedFilePaths: Map>, -) => { - let fileExportName = sanitizeName(filename); - let count = 1; - while ( - usedFilePaths - .get(collectionPath) - ?.has(getFileSavePath(collectionPath, fileExportName)) - ) { - const filenameParts = splitFilenameAndExtension(sanitizeName(filename)); - if (filenameParts[1]) { - fileExportName = `${filenameParts[0]}(${count}).${filenameParts[1]}`; - } else { - fileExportName = `${filenameParts[0]}(${count})`; - } - count++; - } - if (!usedFilePaths.has(collectionPath)) { - usedFilePaths.set(collectionPath, new Set()); - } - usedFilePaths - .get(collectionPath) - .add(getFileSavePath(collectionPath, fileExportName)); - return fileExportName; -}; diff --git a/web/apps/photos/src/utils/ffmpeg/index.ts b/web/apps/photos/src/utils/ffmpeg/index.ts index 1b3445976..8a4332a7f 100644 --- a/web/apps/photos/src/utils/ffmpeg/index.ts +++ b/web/apps/photos/src/utils/ffmpeg/index.ts @@ -65,13 +65,3 @@ function parseCreationTime(creationTime: string) { } return dateTime; } - -export function splitFilenameAndExtension(filename: string): [string, string] { - const lastDotPosition = filename.lastIndexOf("."); - if (lastDotPosition === -1) return [filename, null]; - else - return [ - filename.slice(0, lastDotPosition), - filename.slice(lastDotPosition + 1), - ]; -} diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index ad93dcb5a..cc3ddc5e1 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -1,3 +1,4 @@ +import { decodeLivePhoto } from "@/media/live-photo"; import { convertBytesToHumanReadable } from "@/next/file"; import log from "@/next/log"; import type { Electron } from "@/next/types/ipc"; @@ -32,7 +33,6 @@ import { updateFilePublicMagicMetadata, } from "services/fileService"; import heicConversionService from "services/heicConversionService"; -import { decodeLivePhoto } from "services/livePhotoService"; import { getFileType } from "services/typeDetectionService"; import { updateFileCreationDateInEXIF } from "services/upload/exifService"; import { @@ -51,8 +51,9 @@ import { } from "types/gallery"; import { VISIBILITY_STATE } from "types/magicMetadata"; import { FileTypeInfo } from "types/upload"; -import { getFileExportPath, getUniqueFileExportName } from "utils/export"; 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; @@ -96,19 +97,20 @@ export async function downloadFile(file: EnteFile) { await DownloadManager.getFile(file), ).blob(); if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { - const livePhoto = await decodeLivePhoto(file, fileBlob); - const image = new File([livePhoto.image], livePhoto.imageNameTitle); + const { imageFileName, imageData, videoFileName, videoData } = + await decodeLivePhoto(file.metadata.title, fileBlob); + const image = new File([imageData], imageFileName); const imageType = await getFileType(image); const tempImageURL = URL.createObjectURL( - new Blob([livePhoto.image], { type: imageType.mimeType }), + new Blob([imageData], { type: imageType.mimeType }), ); - const video = new File([livePhoto.video], livePhoto.videoNameTitle); + const video = new File([videoData], videoFileName); const videoType = await getFileType(video); const tempVideoURL = URL.createObjectURL( - new Blob([livePhoto.video], { type: videoType.mimeType }), + new Blob([videoData], { type: videoType.mimeType }), ); - downloadUsingAnchor(tempImageURL, livePhoto.imageNameTitle); - downloadUsingAnchor(tempVideoURL, livePhoto.videoNameTitle); + downloadUsingAnchor(tempImageURL, imageFileName); + downloadUsingAnchor(tempVideoURL, videoFileName); } else { const fileType = await getFileType( new File([fileBlob], file.metadata.title), @@ -130,16 +132,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[] = []; @@ -246,18 +248,6 @@ export async function decryptFile( } } -export function getFileNameWithoutExtension(filename: string) { - const lastDotPosition = filename.lastIndexOf("."); - if (lastDotPosition === -1) return filename; - else return filename.slice(0, lastDotPosition); -} - -export function getFileExtensionWithDot(filename: string) { - const lastDotPosition = filename.lastIndexOf("."); - if (lastDotPosition === -1) return ""; - else return filename.slice(lastDotPosition); -} - export function splitFilenameAndExtension(filename: string): [string, string] { const lastDotPosition = filename.lastIndexOf("."); if (lastDotPosition === -1) return [filename, null]; @@ -354,13 +344,13 @@ async function getRenderableLivePhotoURL( fileBlob: Blob, forceConvert: boolean, ): Promise { - const livePhoto = await decodeLivePhoto(file, fileBlob); + const livePhoto = await decodeLivePhoto(file.metadata.title, fileBlob); const getRenderableLivePhotoImageURL = async () => { try { - const imageBlob = new Blob([livePhoto.image]); + const imageBlob = new Blob([livePhoto.imageData]); const convertedImageBlob = await getRenderableImage( - livePhoto.imageNameTitle, + livePhoto.imageFileName, imageBlob, ); @@ -373,10 +363,9 @@ async function getRenderableLivePhotoURL( const getRenderableLivePhotoVideoURL = async () => { try { - const videoBlob = new Blob([livePhoto.video]); - + const videoBlob = new Blob([livePhoto.videoData]); const convertedVideoBlob = await getPlayableVideo( - livePhoto.videoNameTitle, + livePhoto.videoFileName, videoBlob, forceConvert, true, @@ -440,7 +429,7 @@ export async function getRenderableImage(fileName: string, imageBlob: Blob) { } if (!isElectron()) { - throw Error(CustomError.NOT_AVAILABLE_ON_WEB); + throw new Error("not available on web"); } log.info( `RawConverter called for ${fileName}-${convertBytesToHumanReadable( @@ -798,54 +787,48 @@ async function downloadFileDesktop( electron: Electron, fileReader: FileReader, file: EnteFile, - downloadPath: string, + downloadDir: string, ) { - const fileStream = (await DownloadManager.getFile( + const fs = electron.fs; + const stream = (await DownloadManager.getFile( file, )) as ReadableStream; - const updatedFileStream = await getUpdatedEXIFFileForDownload( + const updatedStream = await getUpdatedEXIFFileForDownload( fileReader, file, - fileStream, + stream, ); if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { - const fileBlob = await new Response(updatedFileStream).blob(); - const livePhoto = await decodeLivePhoto(file, fileBlob); - const imageExportName = await getUniqueFileExportName( - downloadPath, - livePhoto.imageNameTitle, - ); - const imageStream = generateStreamFromArrayBuffer(livePhoto.image); - await electron.saveStreamToDisk( - getFileExportPath(downloadPath, imageExportName), - imageStream, + const fileBlob = await new Response(updatedStream).blob(); + const { imageFileName, imageData, videoFileName, videoData } = + await decodeLivePhoto(file.metadata.title, fileBlob); + const imageExportName = await safeFileName( + downloadDir, + imageFileName, + fs.exists, ); + const imageStream = generateStreamFromArrayBuffer(imageData); + await writeStream(`${downloadDir}/${imageExportName}`, imageStream); try { - const videoExportName = await getUniqueFileExportName( - downloadPath, - livePhoto.videoNameTitle, - ); - const videoStream = generateStreamFromArrayBuffer(livePhoto.video); - await electron.saveStreamToDisk( - getFileExportPath(downloadPath, videoExportName), - videoStream, + const videoExportName = await safeFileName( + downloadDir, + videoFileName, + fs.exists, ); + const videoStream = generateStreamFromArrayBuffer(videoData); + await writeStream(`${downloadDir}/${videoExportName}`, videoStream); } catch (e) { - await electron.deleteFile( - getFileExportPath(downloadPath, imageExportName), - ); + await fs.rm(`${downloadDir}/${imageExportName}`); throw e; } } else { - const fileExportName = await getUniqueFileExportName( - downloadPath, + const fileExportName = await safeFileName( + downloadDir, file.metadata.title, + fs.exists, ); - await electron.saveStreamToDisk( - getFileExportPath(downloadPath, fileExportName), - updatedFileStream, - ); + await writeStream(`${downloadDir}/${fileExportName}`, updatedStream); } } diff --git a/web/apps/photos/src/utils/machineLearning/faceCrop.ts b/web/apps/photos/src/utils/machineLearning/faceCrop.ts index 5486c6448..d437a942d 100644 --- a/web/apps/photos/src/utils/machineLearning/faceCrop.ts +++ b/web/apps/photos/src/utils/machineLearning/faceCrop.ts @@ -1,13 +1,5 @@ -import { CacheStorageService } from "@ente/shared/storage/cacheStorage"; -import { CACHES } from "@ente/shared/storage/cacheStorage/constants"; -import { BlobOptions } from "types/image"; -import { - FaceAlignment, - FaceCrop, - FaceCropConfig, - StoredFaceCrop, -} from "types/machineLearning"; -import { cropWithRotation, imageBitmapToBlob } from "utils/image"; +import { FaceAlignment, FaceCrop, FaceCropConfig } from "types/machineLearning"; +import { cropWithRotation } from "utils/image"; import { enlargeBox } from "."; import { Box } from "../../../thirdparty/face-api/classes"; @@ -16,9 +8,14 @@ export function getFaceCrop( alignment: FaceAlignment, config: FaceCropConfig, ): FaceCrop { - const box = getAlignedFaceBox(alignment); + const alignmentBox = new Box({ + x: alignment.center.x - alignment.size / 2, + y: alignment.center.y - alignment.size / 2, + width: alignment.size, + height: alignment.size, + }).round(); const scaleForPadding = 1 + config.padding * 2; - const paddedBox = enlargeBox(box, scaleForPadding).round(); + const paddedBox = enlargeBox(alignmentBox, scaleForPadding).round(); const faceImageBitmap = cropWithRotation(imageBitmap, paddedBox, 0, { width: config.maxSize, height: config.maxSize, @@ -29,36 +26,3 @@ export function getFaceCrop( imageBox: paddedBox, }; } - -function getAlignedFaceBox(alignment: FaceAlignment) { - return new Box({ - x: alignment.center.x - alignment.size / 2, - y: alignment.center.y - alignment.size / 2, - width: alignment.size, - height: alignment.size, - }).round(); -} - -export async function storeFaceCrop( - faceId: string, - faceCrop: FaceCrop, - blobOptions: BlobOptions, -): Promise { - const faceCropBlob = await imageBitmapToBlob(faceCrop.image, blobOptions); - return storeFaceCropForBlob(faceId, faceCrop.imageBox, faceCropBlob); -} - -async function storeFaceCropForBlob( - faceId: string, - imageBox: Box, - faceCropBlob: Blob, -) { - const faceCropUrl = `/${faceId}`; - const faceCropResponse = new Response(faceCropBlob); - const faceCropCache = await CacheStorageService.open(CACHES.FACE_CROPS); - await faceCropCache.put(faceCropUrl, faceCropResponse); - return { - imageUrl: faceCropUrl, - imageBox: imageBox, - }; -} diff --git a/web/apps/photos/src/utils/machineLearning/index.ts b/web/apps/photos/src/utils/machineLearning/index.ts index d0eacf1a6..a89bccc4c 100644 --- a/web/apps/photos/src/utils/machineLearning/index.ts +++ b/web/apps/photos/src/utils/machineLearning/index.ts @@ -1,11 +1,9 @@ +import { decodeLivePhoto } from "@/media/live-photo"; import log from "@/next/log"; -import { CACHES } from "@ente/shared/storage/cacheStorage/constants"; -import { cached } from "@ente/shared/storage/cacheStorage/helpers"; 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 { @@ -136,30 +134,17 @@ 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])); } } -export async function getOriginalImageBitmap( - file: EnteFile, - queue?: PQueue, - useCache: boolean = false, -) { - let fileBlob; - - if (useCache) { - fileBlob = await cached(CACHES.FILES, file.id.toString(), () => { - return getOriginalConvertedFile(file, queue); - }); - } else { - fileBlob = await getOriginalConvertedFile(file, queue); - } +export async function getOriginalImageBitmap(file: EnteFile, queue?: PQueue) { + const fileBlob = await getOriginalConvertedFile(file, queue); log.info("[MLService] Got file: ", file.id.toString()); - return getImageBlobBitmap(fileBlob); } diff --git a/web/apps/photos/src/utils/native-fs.ts b/web/apps/photos/src/utils/native-fs.ts new file mode 100644 index 000000000..2ef896302 --- /dev/null +++ b/web/apps/photos/src/utils/native-fs.ts @@ -0,0 +1,79 @@ +/** + * @file Utilities for native filesystem access. + * + * While they don't have any direct dependencies to our desktop app, they were + * written for use by the code that runs in our desktop app. + */ + +import { nameAndExtension } from "@/next/file"; +import sanitize from "sanitize-filename"; +import { + exportMetadataDirectoryName, + exportTrashDirectoryName, +} from "services/export"; + +/** + * Sanitize string for use as file or directory name. + * + * Return a string suitable for use as a file or directory name by replacing + * directory separators and invalid characters in the input string {@link s} + * with "_". + */ +export const sanitizeFilename = (s: string) => + sanitize(s, { replacement: "_" }); + +/** + * Return a new sanitized and unique directory name based on {@link name} that + * is not the same as any existing item in the given {@link directoryPath}. + * + * We also ensure we don't return names which might collide with our own special + * directories. + * + * @param exists A function to check if an item already exists at the given + * path. Usually, you'd pass `fs.exists` from {@link Electron}. + * + * See also: {@link safeFileame} + */ +export const safeDirectoryName = async ( + directoryPath: string, + name: string, + exists: (path: string) => Promise, +): Promise => { + const specialDirectoryNames = [ + exportTrashDirectoryName, + exportMetadataDirectoryName, + ]; + + let result = sanitizeFilename(name); + let count = 1; + while ( + (await exists(`${directoryPath}/${result}`)) || + specialDirectoryNames.includes(result) + ) { + result = `${sanitizeFilename(name)}(${count})`; + count++; + } + return result; +}; + +/** + * Return a new sanitized and unique file name based on {@link name} that is not + * the same as any existing item in the given {@link directoryPath}. + * + * This is a sibling of {@link safeDirectoryName} for use with file names. + */ +export const safeFileName = async ( + directoryPath: string, + name: string, + exists: (path: string) => Promise, +) => { + let result = sanitizeFilename(name); + let count = 1; + while (await exists(`${directoryPath}/${result}`)) { + const [fn, ext] = nameAndExtension(sanitizeFilename(name)); + if (ext) result = `${fn}(${count}).${ext}`; + else result = `${fn}(${count})`; + count++; + } + return result; +}; diff --git a/web/apps/photos/src/utils/native-stream.ts b/web/apps/photos/src/utils/native-stream.ts new file mode 100644 index 000000000..7dba1acf9 --- /dev/null +++ b/web/apps/photos/src/utils/native-stream.ts @@ -0,0 +1,58 @@ +/** + * @file Streaming IPC communication with the Node.js layer of our desktop app. + * + * NOTE: These functions only work when we're running in our desktop app. + */ + +/** + * Write the given stream to a file on the local machine. + * + * **This only works when we're running in our desktop app**. It uses the + * "stream://" protocol handler exposed by our custom code in the Node.js layer. + * See: [Note: IPC streams]. + * + * @param path The path on the local machine where to write the file to. + * @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 + 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", + }); + */ + + 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( + `Failed to write stream to ${path}: HTTP ${res.status}`, + ); +}; diff --git a/web/apps/photos/src/utils/storage/mlIDbStorage.ts b/web/apps/photos/src/utils/storage/mlIDbStorage.ts index 8be60afac..40e6dad66 100644 --- a/web/apps/photos/src/utils/storage/mlIDbStorage.ts +++ b/web/apps/photos/src/utils/storage/mlIDbStorage.ts @@ -83,6 +83,27 @@ class MLIDbStorage { log.error("ML Indexed DB blocking"); }, async upgrade(db, oldVersion, newVersion, tx) { + let wasMLSearchEnabled = false; + try { + const searchConfig: unknown = await tx + .objectStore("configs") + .get(ML_SEARCH_CONFIG_NAME); + if ( + searchConfig && + typeof searchConfig == "object" && + "enabled" in searchConfig && + typeof searchConfig.enabled == "boolean" + ) { + wasMLSearchEnabled = searchConfig.enabled; + } + } catch (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"}`, + ); + if (oldVersion < 1) { const filesStore = db.createObjectStore("files", { keyPath: "fileId", @@ -124,15 +145,28 @@ class MLIDbStorage { .add(DEFAULT_ML_SEARCH_CONFIG, ML_SEARCH_CONFIG_NAME); } if (oldVersion < 4) { - // TODO(MR): This loses the user's settings. - db.deleteObjectStore("configs"); - db.createObjectStore("configs"); + try { + await tx + .objectStore("configs") + .delete(ML_SEARCH_CONFIG_NAME); - db.deleteObjectStore("things"); + await tx + .objectStore("configs") + .add( + { enabled: wasMLSearchEnabled }, + ML_SEARCH_CONFIG_NAME, + ); + + db.deleteObjectStore("things"); + } catch { + // TODO: ignore for now as we finalize the new version + // the shipped implementation should have a more + // deterministic migration. + } } log.info( - `Ml DB upgraded to version: ${newVersion} from version: ${oldVersion}`, + `ML DB upgraded from version ${oldVersion} to version ${newVersion}`, ); }, }); 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 6cce03aa9..4e6d216cf 100644 --- a/web/apps/photos/src/utils/upload/index.ts +++ b/web/apps/photos/src/utils/upload/index.ts @@ -1,18 +1,10 @@ -import { ENTE_METADATA_FOLDER } from "constants/export"; +import { basename, dirname } from "@/next/file"; import { FILE_TYPE } from "constants/file"; -import { - A_SEC_IN_MICROSECONDS, - DEFAULT_IMPORT_SUGGESTION, - PICKED_UPLOAD_TYPE, -} from "constants/upload"; +import { A_SEC_IN_MICROSECONDS, PICKED_UPLOAD_TYPE } from "constants/upload"; import isElectron from "is-electron"; +import { exportMetadataDirectoryName } from "services/export"; import { EnteFile } from "types/file"; -import { - ElectronFile, - FileWithCollection, - ImportSuggestion, - Metadata, -} from "types/upload"; +import { ElectronFile, FileWithCollection, Metadata } from "types/upload"; const TYPE_JSON = "json"; const DEDUPE_COLLECTION = new Set(["icloud library", "icloudlibrary"]); @@ -110,15 +102,36 @@ export function areFileWithCollectionsSame( return firstFile.localID === secondFile.localID; } +/** + * Return true if all the paths in the given list are items that belong to the + * same (arbitrary) directory. + * + * Empty list of paths is considered to be in the same directory. + */ +export const areAllInSameDirectory = (paths: string[]) => + new Set(paths.map(dirname)).size == 1; + +// This is used to prompt the user the make upload strategy choice +export interface ImportSuggestion { + rootFolderName: string; + hasNestedFolders: boolean; + hasRootLevelFileWithFolder: boolean; +} + +export const DEFAULT_IMPORT_SUGGESTION: ImportSuggestion = { + rootFolderName: "", + hasNestedFolders: false, + hasRootLevelFileWithFolder: false, +}; + export function getImportSuggestion( uploadType: PICKED_UPLOAD_TYPE, - toUploadFiles: File[] | ElectronFile[], + paths: string[], ): ImportSuggestion { if (isElectron() && uploadType === PICKED_UPLOAD_TYPE.FILES) { return DEFAULT_IMPORT_SUGGESTION; } - const paths: string[] = toUploadFiles.map((file) => file["path"]); const getCharCount = (str: string) => (str.match(/\//g) ?? []).length; paths.sort((path1, path2) => getCharCount(path1) - getCharCount(path2)); const firstPath = paths[0]; @@ -175,7 +188,7 @@ export function groupFilesBasedOnParentFolder( // For Eg,For FileList -> [a/x.png, a/metadata/x.png.json] // they will both we grouped into the collection "a" // This is cluster the metadata json files in the same collection as the file it is for - if (folderPath.endsWith(ENTE_METADATA_FOLDER)) { + if (folderPath.endsWith(exportMetadataDirectoryName)) { folderPath = folderPath.substring(0, folderPath.lastIndexOf("/")); } const folderName = folderPath.substring( @@ -209,3 +222,10 @@ export function filterOutSystemFiles(files: File[] | ElectronFile[]) { export function isSystemFile(file: File | ElectronFile) { return file.name.startsWith("."); } + +/** + * Return true if the file at the given {@link path} is hidden. + * + * Hidden files are those whose names begin with a "." (dot). + */ +export const isHiddenFile = (path: string) => basename(path).startsWith("."); diff --git a/web/apps/photos/src/utils/watch/index.ts b/web/apps/photos/src/utils/watch/index.ts deleted file mode 100644 index eb16780dd..000000000 --- a/web/apps/photos/src/utils/watch/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ElectronFile } from "types/upload"; -import { WatchMapping } from "types/watchFolder"; -import { isSystemFile } from "utils/upload"; - -function isSyncedOrIgnoredFile(file: ElectronFile, mapping: WatchMapping) { - return ( - mapping.ignoredFiles.includes(file.path) || - mapping.syncedFiles.find((f) => f.path === file.path) - ); -} - -export function getValidFilesToUpload( - files: ElectronFile[], - mapping: WatchMapping, -) { - const uniqueFilePaths = new Set(); - return files.filter((file) => { - if (!isSystemFile(file) && !isSyncedOrIgnoredFile(file, mapping)) { - if (!uniqueFilePaths.has(file.path)) { - uniqueFilePaths.add(file.path); - return true; - } - } - return false; - }); -} diff --git a/web/apps/photos/tests/zip-file-reading.test.ts b/web/apps/photos/tests/zip-file-reading.test.ts index 6ac20bfee..07d70f067 100644 --- a/web/apps/photos/tests/zip-file-reading.test.ts +++ b/web/apps/photos/tests/zip-file-reading.test.ts @@ -96,7 +96,7 @@ export const testZipWithRootFileReadingTest = async () => { const importSuggestion = getImportSuggestion( PICKED_UPLOAD_TYPE.ZIPS, - files, + files.map((file) => file["path"]), ); if (!importSuggestion.rootFolderName) { throw Error( diff --git a/web/docs/dependencies.md b/web/docs/dependencies.md index 5be71bd3e..7dece3a37 100644 --- a/web/docs/dependencies.md +++ b/web/docs/dependencies.md @@ -17,6 +17,8 @@ package: - "eslint-plugin-react-hooks", "eslint-plugin-react-namespace-import" - Some React specific ESLint rules and configurations that are used by the workspaces that have React code. +- "eslint-plugin-react-refresh" - A plugin to ensure that React components are + exported in a way that they can be HMR-ed. - "prettier-plugin-organize-imports" - A Prettier plugin to sort imports. - "prettier-plugin-packagejson" - A Prettier plugin to also prettify `package.json`. @@ -108,7 +110,7 @@ with Next.js. For more details, see [translations.md](translations.md). -## Meta Frameworks +## Meta frameworks ### Next.js @@ -121,3 +123,22 @@ set of defaults for bundling our app into a static export which we can then deploy to our webserver. In addition, the Next.js page router is convenient. Apart from this, while we use a few tidbits from Next.js here and there, overall our apps are regular React SPAs, and are not particularly tied to Next. + +### Vite + +For some of our newer code, we have started to use [Vite](https://vitejs.dev). +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. + +## Media + +- "jszip" is used for reading zip files in JavaScript. Live photos are zip + files under the hood. + +## Photos app specific + +### Misc + +- "sanitize-filename" is for converting arbitrary strings into strings that + are suitable for being used as filenames. diff --git a/web/docs/storage.md b/web/docs/storage.md index 8f072684b..d01654b23 100644 --- a/web/docs/storage.md +++ b/web/docs/storage.md @@ -8,9 +8,32 @@ cleared when the browser tab is closed. The data in local storage is tied to the Document's origin (scheme + host). +Some things that get stored here are: + +- Details about the logged in user, in particular their user id and a auth + token we can use to make API calls on their behalf. + +- Various user preferences + ## Session Storage +Data tied to the browser tab's lifetime. + +We store the user's encryption key here. + ## Indexed DB We use the LocalForage library for storing things in Indexed DB. This library falls back to localStorage in case Indexed DB storage is not available. + +Indexed DB allows for larger sizes than local/session storage, and is generally +meant for larger, tabular data. + +## OPFS + +OPFS is used for caching entire files when we're running under Electron (the Web +Cache API is used in the browser). + +As it name suggests, it is an entire filesystem, private for us ("origin"). In +is not undbounded though, and the storage is not guaranteed to be persistent (at +least with the APIs we use), hence the cache designation. diff --git a/web/package.json b/web/package.json index 3b8697bd8..2d5919eb1 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "dev:payments": "yarn workspace payments dev", "dev:photos": "yarn workspace photos next dev", "dev:staff": "yarn workspace staff dev", - "lint": "yarn prettier --check . && yarn workspaces run eslint --report-unused-disable-directives", + "lint": "yarn prettier --check . && yarn workspaces run eslint --report-unused-disable-directives .", "lint-fix": "yarn prettier --write . && yarn workspaces run eslint --fix .", "preview": "yarn preview:photos", "preview:accounts": "yarn build:accounts && python3 -m http.server -d apps/accounts/out 3001", diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 87a320e36..fb0e1c929 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -1,7 +1,7 @@ +import { clearCaches } from "@/next/blob-cache"; import log from "@/next/log"; import { Events, eventBus } from "@ente/shared/events"; import InMemoryStore from "@ente/shared/storage/InMemoryStore"; -import { deleteAllCache } from "@ente/shared/storage/cacheStorage/helpers"; import { clearFiles } from "@ente/shared/storage/localForage/helpers"; import { clearData } from "@ente/shared/storage/localStorage"; import { clearKeys } from "@ente/shared/storage/sessionStorage"; @@ -31,7 +31,7 @@ export const logoutUser = async () => { log.error("Ignoring error when clearing data", e); } try { - await deleteAllCache(); + await clearCaches(); } catch (e) { log.error("Ignoring error when clearing caches", e); } diff --git a/web/packages/build-config/eslintrc-react.js b/web/packages/build-config/eslintrc-react.js index 13df31cfd..7a0f04fe5 100644 --- a/web/packages/build-config/eslintrc-react.js +++ b/web/packages/build-config/eslintrc-react.js @@ -5,5 +5,12 @@ module.exports = { "plugin:react/recommended", "plugin:react-hooks/recommended", ], + plugins: ["react-refresh"], settings: { react: { version: "18.2" } }, + rules: { + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, }; diff --git a/web/packages/build-config/package.json b/web/packages/build-config/package.json index 52a29fb6a..e46bb96b1 100644 --- a/web/packages/build-config/package.json +++ b/web/packages/build-config/package.json @@ -7,6 +7,7 @@ "@typescript-eslint/parser": "^7", "eslint-plugin-react": "^7.34", "eslint-plugin-react-hooks": "^4.6", + "eslint-plugin-react-refresh": "^0.4.6", "prettier-plugin-organize-imports": "^3.2", "prettier-plugin-packagejson": "^2.4" } diff --git a/web/packages/eslint-config/index.js b/web/packages/eslint-config/index.js index 930ebab4e..ee73deae3 100644 --- a/web/packages/eslint-config/index.js +++ b/web/packages/eslint-config/index.js @@ -24,6 +24,7 @@ module.exports = { "max-len": "off", "new-cap": "off", "no-invalid-this": "off", + "no-throw-literal": "error", // TODO(MR): We want this off anyway, for now forcing it here eqeqeq: "off", "object-curly-spacing": ["error", "always"], 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/blob-cache.ts b/web/packages/next/blob-cache.ts new file mode 100644 index 000000000..8789a5078 --- /dev/null +++ b/web/packages/next/blob-cache.ts @@ -0,0 +1,219 @@ +import isElectron from "is-electron"; + +const blobCacheNames = [ + "thumbs", + "face-crops", + // Desktop app only + "files", +] as const; + +/** + * Namespaces into which our blob caches are divided + * + * Note that namespaces are just arbitrary (but predefined) strings to split the + * cached data into "folders", so to speak. + * */ +export type BlobCacheNamespace = (typeof blobCacheNames)[number]; + +/** + * A namespaced blob cache. + * + * This cache is suitable for storing large amounts of data (entire files). + * + * To obtain a cache for a given namespace, use {@link openCache}. To clear all + * cached data (e.g. during logout), use {@link clearCaches}. + * + * [Note: Caching files] + * + * The underlying implementation of the cache is different depending on the + * runtime environment. + * + * * The preferred implementation, and the one that is used when we're running + * in a browser, is to use the standard [Web + * Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache). + * + * * However when running under Electron (when this code runs as part of our + * desktop app), a custom OPFS based cache is used instead. This is because + * Electron currently doesn't support using standard Web Cache API for data + * served by a custom protocol handler (See this + * [issue](https://github.com/electron/electron/issues/35033), and the + * underlying restriction that comes from + * [Chromium](https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/cache_storage/cache.cc;l=83-87?q=%22Request%20scheme%20%27%22&ss=chromium)) + * + * [OPFS](https://web.dev/articles/origin-private-file-system) stands for Origin + * Private File System. It is a recent API that allows a web site to store + * reasonably large amounts of data. One option (that may still become possible + * in the future) was to always use OPFS for caching instead of this dual + * implementation, however currently [Safari does not support writing to OPFS + * outside of web + * workers](https://webkit.org/blog/12257/the-file-system-access-api-with-origin-private-file-system/) + * ([the WebKit bug](https://bugs.webkit.org/show_bug.cgi?id=231706)), so it's + * not trivial to use this as a full on replacement of the Web Cache in the + * browser. So for now we go with this split implementation. + * + * See also: [Note: Increased disk cache for the desktop app]. + */ +export interface BlobCache { + /** + * Get the data corresponding to {@link key} (if found) from the cache. + */ + get: (key: string) => Promise; + /** + * Add the given {@link key}-value ({@link blob}) pair to the cache. + */ + put: (key: string, blob: Blob) => Promise; + /** + * Delete the blob corresponding to the given {@link key}. + * + * The returned promise resolves to `true` if a cache entry was found, + * otherwise it resolves to `false`. + * */ + delete: (key: string) => Promise; +} + +/** + * Return the {@link BlobCache} corresponding to the given {@link name}. + * + * @param name One of the arbitrary but predefined namespaces of type + * {@link BlobCacheNamespace} which group related data and allow us to use the + * same key across namespaces. + */ +export const openCache = async ( + name: BlobCacheNamespace, +): Promise => + isElectron() ? openOPFSCacheWeb(name) : openWebCache(name); + +/** + * [Note: ArrayBuffer vs Blob vs Uint8Array] + * + * ArrayBuffers are in memory, while blobs are unreified, and can directly point + * to on disk objects too. + * + * If we are just passing data around without necessarily needing to manipulate + * it, and we already have a blob, it's best to just pass that blob. Further, + * blobs also retains the file's encoding information , and are thus a layer + * above array buffers which are just raw byte sequences. + * + * ArrayBuffers are not directly manipulatable, which is where some sort of a + * typed array or a data view comes into the picture. The typed `Uint8Array` is + * a common way. + * + * To convert from ArrayBuffer to Uint8Array, + * + * new Uint8Array(arrayBuffer) + * + * Blobs are immutable, but a usual scenario is storing an entire file in a + * blob, and when the need comes to display it, we can obtain a URL for it using + * + * URL.createObjectURL(blob) + * + * Also note that a File is a Blob! + * + * To convert from a Blob to ArrayBuffer + * + * await blob.arrayBuffer() + * + * To convert from an ArrayBuffer or Uint8Array to Blob + * + * new Blob([arrayBuffer, andOrAnyArray, andOrstring]) + * + * Refs: + * - https://github.com/yigitunallar/arraybuffer-vs-blob + * - https://stackoverflow.com/questions/11821096/what-is-the-difference-between-an-arraybuffer-and-a-blob + */ + +/** An implementation of {@link BlobCache} using Web Cache APIs */ +const openWebCache = async (name: BlobCacheNamespace) => { + const cache = await caches.open(name); + return { + get: async (key: string) => { + const res = await cache.match(key); + return await res?.blob(); + }, + put: (key: string, blob: Blob) => cache.put(key, new Response(blob)), + delete: (key: string) => cache.delete(key), + }; +}; + +/** An implementation of {@link BlobCache} using OPFS */ +const openOPFSCacheWeb = async (name: BlobCacheNamespace) => { + // While all major browsers support OPFS now, their implementations still + // have various quirks. However, we don't need to handle all possible cases + // and can just instead use the APIs and guarantees Chromium provides since + // this code will only run in our Electron app (which'll use Chromium as the + // renderer). + // + // So for our purpose, these can serve as the doc for what's available: + // https://web.dev/articles/origin-private-file-system + + const root = await navigator.storage.getDirectory(); + const caches = await root.getDirectoryHandle("cache", { create: true }); + const cache = await caches.getDirectoryHandle(name, { create: true }); + + return { + get: async (key: string) => { + try { + const fileHandle = await cache.getFileHandle(key); + return await fileHandle.getFile(); + } catch (e) { + if (e instanceof DOMException && e.name == "NotFoundError") + return undefined; + throw e; + } + }, + put: async (key: string, blob: Blob) => { + const fileHandle = await cache.getFileHandle(key, { + create: true, + }); + const writable = await fileHandle.createWritable(); + await writable.write(blob); + await writable.close(); + }, + delete: async (key: string) => { + try { + await cache.removeEntry(key); + return true; + } catch (e) { + if (e instanceof DOMException && e.name == "NotFoundError") + return false; + throw e; + } + }, + }; +}; + +/** + * Return a cached blob for {@link key} in {@link cacheName}. If the blob is not + * found in the cache, recreate/fetch it using {@link get}, cache it, and then + * return it. + */ +export const cachedOrNew = async ( + cacheName: BlobCacheNamespace, + key: string, + get: () => Promise, +): Promise => { + const cache = await openCache(cacheName); + const cachedBlob = await cache.get(key); + if (cachedBlob) return cachedBlob; + + const blob = await get(); + await cache.put(key, blob); + return blob; +}; + +/** + * Delete all cached data. + * + * Meant for use during logout, to reset the state of the user's account. + */ +export const clearCaches = async () => + isElectron() ? clearOPFSCaches() : clearWebCaches(); + +const clearWebCaches = async () => { + await Promise.all(blobCacheNames.map((name) => caches.delete(name))); +}; + +const clearOPFSCaches = async () => { + const root = await navigator.storage.getDirectory(); + await root.removeEntry("cache", { recursive: true }); +}; diff --git a/web/packages/next/file.ts b/web/packages/next/file.ts index 0f07ba3ce..83b20f2ec 100644 --- a/web/packages/next/file.ts +++ b/web/packages/next/file.ts @@ -1,5 +1,71 @@ 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): FileNameComponents => { + const i = fileName.lastIndexOf("."); + // No extension + if (i == -1) return [fileName, undefined]; + // 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) { return `${file.name}_${convertBytesToHumanReadable(file.size)}`; } diff --git a/web/packages/next/local-user.ts b/web/packages/next/local-user.ts new file mode 100644 index 000000000..d20bb7781 --- /dev/null +++ b/web/packages/next/local-user.ts @@ -0,0 +1,42 @@ +// TODO: This file belongs to the accounts package +import * as yup from "yup"; + +const localUserSchema = yup.object({ + /** The user's ID. */ + id: yup.number().required(), + /** The user's email. */ + email: yup.string().required(), + /** + * The user's (plaintext) auth token. + * + * It is used for making API calls on their behalf. + */ + token: yup.string().required(), +}); + +/** Locally available data for the logged in user's */ +export type LocalUser = yup.InferType; + +/** + * Return the logged-in user (if someone is indeed logged in). + * + * The user's data is stored in the browser's localStorage. + */ +export const localUser = async (): Promise => { + // TODO(MR): duplicate of LS_KEYS.USER + const s = localStorage.getItem("user"); + if (!s) return undefined; + return await localUserSchema.validate(JSON.parse(s), { + strict: true, + }); +}; + +/** + * A wrapper over {@link localUser} with that throws if no one is logged in. + */ +export const ensureLocalUser = async (): Promise => { + const user = await localUser(); + if (!user) + throw new Error("Attempting to access user data when not logged in"); + return user; +}; diff --git a/web/packages/next/locales/de-DE/translation.json b/web/packages/next/locales/de-DE/translation.json index 7a7a2a3d9..38b877fd4 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": "Ihre hochgeladenen Daten werden zur Löschung vorgemerkt, und Ihr 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": "", + "FREEHAND": "Freihand", "APPLY_CROP": "", "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/nl-NL/translation.json b/web/packages/next/locales/nl-NL/translation.json index 42ca25ca4..c12a38f8b 100644 --- a/web/packages/next/locales/nl-NL/translation.json +++ b/web/packages/next/locales/nl-NL/translation.json @@ -168,7 +168,7 @@ "UPDATE_PAYMENT_METHOD": "Betalingsmethode bijwerken", "MONTHLY": "Maandelijks", "YEARLY": "Jaarlijks", - "update_subscription_title": "", + "update_subscription_title": "Bevestig verandering van abonnement", "UPDATE_SUBSCRIPTION_MESSAGE": "Weet u zeker dat u uw abonnement wilt wijzigen?", "UPDATE_SUBSCRIPTION": "Abonnement wijzigen", "CANCEL_SUBSCRIPTION": "Abonnement opzeggen", 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/next.config.base.js b/web/packages/next/next.config.base.js index f0d1481b4..a3076fa5c 100644 --- a/web/packages/next/next.config.base.js +++ b/web/packages/next/next.config.base.js @@ -59,11 +59,21 @@ const nextConfig = { GIT_SHA: gitSHA(), }, - // https://dev.to/marcinwosinek/how-to-add-resolve-fallback-to-webpack-5-in-nextjs-10-i6j + // Customize the webpack configuration used by Next.js webpack: (config, { isServer }) => { + // https://dev.to/marcinwosinek/how-to-add-resolve-fallback-to-webpack-5-in-nextjs-10-i6j if (!isServer) { config.resolve.fallback.fs = false; } + + // Suppress the warning "Critical dependency: require function is used + // in a way in which dependencies cannot be statically extracted" when + // import heic-convert. + // + // Upstream issue, which currently doesn't have a workaround. + // https://github.com/catdad-experiments/libheif-js/issues/23 + config.ignoreWarnings = [{ module: /libheif-js/ }]; + return config; }, }; 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 5b0979eaa..0628bb0ca 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -3,23 +3,7 @@ // // See [Note: types.ts <-> preload.ts <-> ipc.ts] -import type { ElectronFile, WatchMapping } from "./file"; - -export interface AppUpdateInfo { - autoUpdatable: boolean; - version: string; -} - -export enum FILE_PATH_TYPE { - FILES = "files", - ZIPS = "zips", -} - -export enum PICKED_UPLOAD_TYPE { - FILES = "files", - FOLDERS = "folders", - ZIPS = "zips", -} +import type { ElectronFile } from "./file"; /** * Extra APIs provided by our Node.js layer when our code is running inside our @@ -80,7 +64,7 @@ export interface Electron { * * If no such key is found, return `undefined`. * - * @see {@link saveEncryptionKey}. + * See also: {@link saveEncryptionKey}. */ encryptionKey: () => Promise; @@ -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; /** @@ -155,15 +139,56 @@ export interface Electron { * or watching some folders for changes and syncing them automatically. * * Towards this end, this fs object provides some generic file system access - * functions that are needed for such features. In addition, there are other - * feature specific methods too in the top level electron object. + * functions that are needed for such features (in some cases, there are + * other feature specific methods too in the top level electron object). */ fs: { - /** - * Return true if there is a file or directory at the given - * {@link path}. - */ + /** Return true if there is an item at the given {@link path}. */ exists: (path: string) => Promise; + + /** + * Equivalent of `mkdir -p`. + * + * Create a directory at the given path if it does not already exist. + * Any parent directories in the path that don't already exist will also + * be created recursively, i.e. this command is analogous to an running + * `mkdir -p`. + */ + mkdirIfNeeded: (dirPath: string) => Promise; + + /** Rename {@link oldPath} to {@link newPath} */ + rename: (oldPath: string, newPath: string) => Promise; + + /** + * Equivalent of `rmdir`. + * + * Delete the directory at the {@link path} if it is empty. + */ + rmdir: (path: string) => Promise; + + /** + * Equivalent of `rm`. + * + * Delete the file at {@link path}. + */ + rm: (path: string) => Promise; + + /** Read the string contents of a file at {@link path}. */ + readTextFile: (path: string) => Promise; + + /** + * Write a string to a file, replacing the file if it already exists. + * + * @param path The path of the file. + * @param contents The string contents to write. + */ + writeFile: (path: string, contents: string) => Promise; + + /** + * Return true if there is an item at {@link dirPath}, and it is as + * directory. + */ + isDir: (dirPath: string) => Promise; }; /* @@ -249,62 +274,229 @@ export interface Electron { // - Watch - registerWatcherFunctions: ( - addFile: (file: ElectronFile) => Promise, - removeFile: (path: string) => Promise, - removeFolder: (folderPath: string) => Promise, - ) => void; + /** + * Interface with the file system watcher running in our Node.js layer. + * + * [Note: Folder vs Directory in the context of FolderWatch-es] + * + * A note on terminology: The word "folder" is used to the top level root + * folder for which a {@link FolderWatch} has been added. This folder is + * also in 1-1 correspondence to be a directory on the user's disk. It can + * have other, nested directories too (which may or may not be getting + * mapped to separate Ente collections), but we'll not refer to these nested + * directories as folders - only the root of the tree, which the user + * dragged/dropped or selected to set up the folder watch, will be referred + * to as a folder when naming things. + */ + watch: { + /** + * Return the list of folder watches, pruning non-existing directories. + * + * The list of folder paths (and auxillary details) is persisted in the + * Node.js layer. The implementation of this function goes through the + * list, permanently removes any watches whose on-disk directory is no + * longer present, and returns this pruned list of watches. + */ + get: () => Promise; - addWatchMapping: ( - collectionName: string, - folderPath: string, - uploadStrategy: number, - ) => Promise; + /** + * Add a new folder watch for the given {@link folderPath}. + * + * This adds a new entry in the list of watches (persisting them on + * disk), and also starts immediately observing for file system events + * that happen within {@link folderPath}. + * + * @param collectionMapping Determines how nested directories (if any) + * get mapped to Ente collections. + * + * @returns The updated list of watches. + */ + add: ( + folderPath: string, + collectionMapping: CollectionMapping, + ) => Promise; - removeWatchMapping: (folderPath: string) => Promise; + /** + * Remove the pre-existing watch for the given {@link folderPath}. + * + * Persist this removal, and also stop listening for file system events + * that happen within the {@link folderPath}. + * + * @returns The updated list of watches. + */ + remove: (folderPath: string) => Promise; - getWatchMappings: () => Promise; + /** + * Update the list of synced files for the folder watch associated + * with the given {@link folderPath}. + */ + updateSyncedFiles: ( + syncedFiles: FolderWatch["syncedFiles"], + folderPath: string, + ) => Promise; - updateWatchMappingSyncedFiles: ( - folderPath: string, - files: WatchMapping["syncedFiles"], - ) => Promise; + /** + * Update the list of ignored file paths for the folder watch + * associated with the given {@link folderPath}. + */ + updateIgnoredFiles: ( + ignoredFiles: FolderWatch["ignoredFiles"], + folderPath: string, + ) => Promise; - updateWatchMappingIgnoredFiles: ( - folderPath: string, - files: WatchMapping["ignoredFiles"], - ) => Promise; + /** + * Register the function to invoke when a file is added in one of the + * folders we are watching. + * + * The callback function is passed the path to the file that was added, + * and the folder watch it was associated with. + * + * The path is guaranteed to use POSIX separators ('/'). + */ + onAddFile: (f: (path: string, watch: FolderWatch) => void) => void; - // - FS legacy - checkExistsAndCreateDir: (dirPath: string) => Promise; - saveStreamToDisk: ( - path: string, - fileStream: ReadableStream, - ) => Promise; - saveFileToDisk: (path: string, contents: string) => Promise; - readTextFile: (path: string) => Promise; - isFolder: (dirPath: string) => Promise; - moveFile: (oldPath: string, newPath: string) => Promise; - deleteFolder: (path: string) => Promise; - deleteFile: (path: string) => Promise; - rename: (oldPath: string, newPath: string) => Promise; + /** + * Register the function to invoke when a file is removed in one of the + * folders we are watching. + * + * The callback function is passed the path to the file that was + * removed, and the folder watch it was associated with. + * + * The path is guaranteed to use POSIX separators ('/'). + */ + onRemoveFile: (f: (path: string, watch: FolderWatch) => void) => void; + + /** + * Register the function to invoke when a directory is removed in one of + * the folders we are watching. + * + * The callback function is passed the path to the directory that was + * removed, and the folder watch it was associated with. + * + * The path is guaranteed to use POSIX separators ('/'). + */ + onRemoveDir: (f: (path: string, watch: FolderWatch) => void) => void; + + /** + * Return the paths of all the files under the given folder. + * + * This function walks the directory tree starting at {@link folderPath} + * and returns a list of the absolute paths of all the files that exist + * therein. It will recursively traverse into nested directories, and + * return the absolute paths of the files there too. + * + * The returned paths are guaranteed to use POSIX separators ('/'). + */ + findFiles: (folderPath: string) => Promise; + }; // - Upload - getPendingUploads: () => Promise<{ - files: ElectronFile[]; - collectionName: string; - type: string; - }>; - setToUploadFiles: ( - /** TODO(MR): This is the actual type */ - // type: FILE_PATH_TYPE, - type: PICKED_UPLOAD_TYPE, + /** + * Return any pending uploads that were previously enqueued but haven't yet + * been completed. + * + * The state of pending uploads is persisted in the Node.js layer. + * + * Note that we might have both outstanding zip and regular file uploads at + * the same time. In such cases, the zip file ones get precedence. + */ + pendingUploads: () => Promise; + + /** + * Set or clear the name of the collection where the pending upload is + * directed to. + */ + setPendingUploadCollection: (collectionName: string) => Promise; + + /** + * Update the list of files (of {@link type}) associated with the pending + * upload. + */ + setPendingUploadFiles: ( + type: PendingUploads["type"], filePaths: string[], ) => Promise; + + // - + getElectronFilesFromGoogleZip: ( filePath: string, ) => Promise; - setToUploadCollection: (collectionName: string) => Promise; getDirFiles: (dirPath: string) => Promise; } + +/** + * Data passed across the IPC bridge when an app update is available. + */ +export interface AppUpdate { + /** `true` if the user automatically update to this (new) version */ + autoUpdatable: boolean; + /** The new version that is available */ + version: string; +} + +/** + * A top level folder that was selected by the user for watching. + * + * The user can set up multiple such watches. Each of these can in turn be + * syncing multiple on disk folders to one or more Ente collections (depending + * on the value of {@link collectionMapping}). + * + * This type is passed across the IPC boundary. It is persisted on the Node.js + * side. + */ +export interface FolderWatch { + /** + * Specify if nested files should all be mapped to the same single root + * collection, or if there should be a collection per directory that has + * files. @see {@link CollectionMapping}. + */ + collectionMapping: CollectionMapping; + /** + * The path to the (root) folder we are watching. + */ + folderPath: string; + /** + * Files that have already been uploaded. + */ + syncedFiles: FolderWatchSyncedFile[]; + /** + * Files (paths) that should be ignored when uploading. + */ + ignoredFiles: string[]; +} + +/** + * The ways in which directories are mapped to collection. + * + * This comes into play when we have nested directories that we are trying to + * upload or watch on the user's local file system. + */ +export type CollectionMapping = + /** All files go into a single collection named after the root directory. */ + | "root" + /** Each file goes to a collection named after its parent directory. */ + | "parent"; + +/** + * An on-disk file that was synced as part of a folder watch. + */ +export interface FolderWatchSyncedFile { + path: string; + uploadedFileID: number; + collectionID: number; +} + +/** + * When the user starts an upload, we remember the files they'd selected or drag + * and dropped so that we can resume (if needed) when the app restarts after + * being stopped in the middle of the uploads. + */ +export interface PendingUploads { + /** The collection to which we're uploading */ + collectionName: string; + /* The upload can be either of a Google Takeout zip, or regular files */ + type: "files" | "zips"; + files: ElectronFile[]; +} diff --git a/web/packages/next/worker/comlink-worker.ts b/web/packages/next/worker/comlink-worker.ts index f082ac114..a5237fccc 100644 --- a/web/packages/next/worker/comlink-worker.ts +++ b/web/packages/next/worker/comlink-worker.ts @@ -1,6 +1,7 @@ import { ensureElectron } from "@/next/electron"; import log, { logToDisk } from "@/next/log"; import { expose, wrap, type Remote } from "comlink"; +import { ensureLocalUser } from "../local-user"; export class ComlinkWorker InstanceType> { public remote: Promise>>; @@ -35,29 +36,20 @@ export class ComlinkWorker InstanceType> { } } -// TODO(MR): Temporary method to forward auth tokens to workers -const getAuthToken = () => { - // LS_KEYS.USER - const userJSONString = localStorage.getItem("user"); - if (!userJSONString) return undefined; - const json: unknown = JSON.parse(userJSONString); - if (!json || typeof json != "object" || !("token" in json)) - return undefined; - const token = json.token; - if (typeof token != "string") return undefined; - return token; -}; - /** - * A minimal set of utility functions that we expose to all workers that we - * create. + * A set of utility functions that we expose to all workers that we create. * * Inside the worker's code, this can be accessed by using the sibling * `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. */ const workerBridge = { + // Needed: generally (presumably) logToDisk, - getAuthToken, + // Needed by ML worker + getAuthToken: () => ensureLocalUser().then((user) => user.token), convertToJPEG: (inputFileData: Uint8Array, filename: string) => ensureElectron().convertToJPEG(inputFileData, filename), detectFaces: (input: Float32Array) => ensureElectron().detectFaces(input), diff --git a/web/packages/shared/components/EnteLogo.tsx b/web/packages/shared/components/EnteLogo.tsx index a22be3f5e..50c1e8303 100644 --- a/web/packages/shared/components/EnteLogo.tsx +++ b/web/packages/shared/components/EnteLogo.tsx @@ -3,6 +3,7 @@ import { styled } from "@mui/material"; const LogoImage = styled("img")` margin: 3px 0; pointer-events: none; + vertical-align: middle; `; export function EnteLogo(props) { diff --git a/web/packages/shared/error/index.ts b/web/packages/shared/error/index.ts index 6ed4c7486..12a87d2db 100644 --- a/web/packages/shared/error/index.ts +++ b/web/packages/shared/error/index.ts @@ -74,7 +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", - NOT_AVAILABLE_ON_WEB: "not available on web", UNSUPPORTED_RAW_FORMAT: "unsupported raw format", NON_PREVIEWABLE_FILE: "non previewable file", PROCESSING_FAILED: "processing failed", @@ -87,7 +86,6 @@ export const CustomError = { UNSUPPORTED_PLATFORM: "Unsupported platform", MODEL_DOWNLOAD_PENDING: "Model download pending, skipping clip search request", - DOWNLOAD_MANAGER_NOT_READY: "Download manager not initialized", 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/network/HTTPService.ts b/web/packages/shared/network/HTTPService.ts index 350f7f01d..eda0709f5 100644 --- a/web/packages/shared/network/HTTPService.ts +++ b/web/packages/shared/network/HTTPService.ts @@ -125,7 +125,6 @@ class HTTPService { /** * Returns axios interceptors. */ - // eslint-disable-next-line class-methods-use-this public getInterceptors() { return axios.interceptors; } @@ -137,7 +136,6 @@ class HTTPService { * over what was sent in config. */ public async request(config: AxiosRequestConfig, customConfig?: any) { - // eslint-disable-next-line no-param-reassign config.headers = { ...this.headers, ...config.headers, diff --git a/web/packages/shared/storage/cacheStorage/constants.ts b/web/packages/shared/storage/cacheStorage/constants.ts deleted file mode 100644 index 4a31b7897..000000000 --- a/web/packages/shared/storage/cacheStorage/constants.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum CACHES { - THUMBS = "thumbs", - FACE_CROPS = "face-crops", - // Desktop app only - FILES = "files", -} diff --git a/web/packages/shared/storage/cacheStorage/factory.ts b/web/packages/shared/storage/cacheStorage/factory.ts deleted file mode 100644 index 0de07aeec..000000000 --- a/web/packages/shared/storage/cacheStorage/factory.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { LimitedCacheStorage } from "./types"; - -class cacheStorageFactory { - getCacheStorage(): LimitedCacheStorage { - return transformBrowserCacheStorageToLimitedCacheStorage(caches); - } -} - -export const CacheStorageFactory = new cacheStorageFactory(); - -function transformBrowserCacheStorageToLimitedCacheStorage( - caches: CacheStorage, -): LimitedCacheStorage { - return { - async open(cacheName) { - const cache = await caches.open(cacheName); - return { - match: (key) => { - // options are not supported in the browser - return cache.match(key); - }, - put: cache.put.bind(cache), - delete: cache.delete.bind(cache), - }; - }, - delete: caches.delete.bind(caches), - }; -} diff --git a/web/packages/shared/storage/cacheStorage/helpers.ts b/web/packages/shared/storage/cacheStorage/helpers.ts deleted file mode 100644 index b306d5683..000000000 --- a/web/packages/shared/storage/cacheStorage/helpers.ts +++ /dev/null @@ -1,55 +0,0 @@ -import log from "@/next/log"; -import { CacheStorageService } from "."; -import { CACHES } from "./constants"; -import { LimitedCache } from "./types"; - -export async function cached( - cacheName: string, - id: string, - get: () => Promise, -): Promise { - const cache = await CacheStorageService.open(cacheName); - const cacheResponse = await cache.match(id); - - let result: Blob; - if (cacheResponse) { - result = await cacheResponse.blob(); - } else { - result = await get(); - - try { - await cache.put(id, new Response(result)); - } catch (e) { - // TODO: handle storage full exception. - console.error("Error while storing file to cache: ", id); - } - } - - return result; -} - -let thumbCache: LimitedCache; - -export async function getBlobFromCache( - cacheName: string, - url: string, -): Promise { - if (!thumbCache) { - thumbCache = await CacheStorageService.open(cacheName); - } - const response = await thumbCache.match(url); - if (!response) { - return undefined; - } - return response.blob(); -} - -export async function deleteAllCache() { - try { - await CacheStorageService.delete(CACHES.THUMBS); - await CacheStorageService.delete(CACHES.FACE_CROPS); - await CacheStorageService.delete(CACHES.FILES); - } catch (e) { - log.error("deleteAllCache failed", e); // log and ignore - } -} diff --git a/web/packages/shared/storage/cacheStorage/index.ts b/web/packages/shared/storage/cacheStorage/index.ts deleted file mode 100644 index a70bf0927..000000000 --- a/web/packages/shared/storage/cacheStorage/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import log from "@/next/log"; -import { CacheStorageFactory } from "./factory"; - -const SecurityError = "SecurityError"; -const INSECURE_OPERATION = "The operation is insecure."; -async function openCache(cacheName: string, cacheLimit?: number) { - try { - return await CacheStorageFactory.getCacheStorage().open( - cacheName, - cacheLimit, - ); - } catch (e) { - // ignoring insecure operation error, as it is thrown in incognito mode in firefox - if (e.name === SecurityError && e.message === INSECURE_OPERATION) { - // no-op - } else { - // log and ignore, we don't want to break the caller flow, when cache is not available - log.error("openCache failed", e); - } - } -} -async function deleteCache(cacheName: string) { - try { - return await CacheStorageFactory.getCacheStorage().delete(cacheName); - } catch (e) { - // ignoring insecure operation error, as it is thrown in incognito mode in firefox - if (e.name === SecurityError && e.message === INSECURE_OPERATION) { - // no-op - } else { - // log and ignore, we don't want to break the caller flow, when cache is not available - log.error("deleteCache failed", e); - } - } -} - -export const CacheStorageService = { open: openCache, delete: deleteCache }; diff --git a/web/packages/shared/storage/cacheStorage/types.ts b/web/packages/shared/storage/cacheStorage/types.ts deleted file mode 100644 index d52055922..000000000 --- a/web/packages/shared/storage/cacheStorage/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface LimitedCacheStorage { - open: ( - cacheName: string, - cacheLimitInBytes?: number, - ) => Promise; - delete: (cacheName: string) => Promise; -} - -export interface LimitedCache { - match: ( - key: string, - options?: { sizeInBytes?: number }, - ) => Promise; - put: (key: string, data: Response) => Promise; - delete: (key: string) => Promise; -} diff --git a/web/packages/shared/themes/components.ts b/web/packages/shared/themes/components.ts index 10e122fe2..6d8eb3880 100644 --- a/web/packages/shared/themes/components.ts +++ b/web/packages/shared/themes/components.ts @@ -2,7 +2,6 @@ import { Shadow, ThemeColorsOptions } from "@mui/material"; import { Components } from "@mui/material/styles/components"; import { TypographyOptions } from "@mui/material/styles/createTypography"; -// eslint-disable-next-line @typescript-eslint/no-unused-vars export const getComponents = ( colors: ThemeColorsOptions, typography: TypographyOptions, diff --git a/web/packages/shared/utils/index.ts b/web/packages/shared/utils/index.ts index 1ed02fabe..c027b6cb6 100644 --- a/web/packages/shared/utils/index.ts +++ b/web/packages/shared/utils/index.ts @@ -1,7 +1,11 @@ -export async function sleep(time: number) { - await new Promise((resolve) => { - setTimeout(() => resolve(null), time); - }); +/** + * Wait for {@link ms} milliseconds + * + * 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 function downloadAsFile(filename: string, content: string) { diff --git a/web/packages/utils/ensure.ts b/web/packages/utils/ensure.ts new file mode 100644 index 000000000..2e8f9a213 --- /dev/null +++ b/web/packages/utils/ensure.ts @@ -0,0 +1,7 @@ +/** + * Throw an exception if the given value is undefined. + */ +export const ensure = (v: T | undefined): T => { + if (v === undefined) throw new Error("Required value was not found"); + return v; +}; diff --git a/web/yarn.lock b/web/yarn.lock index 90300ce69..61d2cfeae 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -2290,6 +2290,11 @@ eslint-plugin-jsx-a11y@^6.7.1: resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3" integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g== +eslint-plugin-react-refresh@^0.4.6: + version "0.4.6" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.6.tgz#e8e8accab681861baed00c5c12da70267db0936f" + integrity sha512-NjGXdm7zgcKRkKMua34qVO9doI7VOxZ6ancSvBELJSSoX97jyndXcSoa8XBh69JoB31dNz3EEzlMcizZl7LaMA== + eslint-plugin-react@^7.33.2: version "7.33.2" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz#69ee09443ffc583927eafe86ffebb470ee737608" @@ -3247,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==