diff --git a/.github/workflows/auth-lint.yml b/.github/workflows/auth-lint.yml index 4518c542da..2362d2b344 100644 --- a/.github/workflows/auth-lint.yml +++ b/.github/workflows/auth-lint.yml @@ -8,7 +8,7 @@ on: - ".github/workflows/auth-lint.yml" env: - FLUTTER_VERSION: "3.24.0" + FLUTTER_VERSION: "3.24.1" jobs: lint: diff --git a/.github/workflows/auth-release.yml b/.github/workflows/auth-release.yml index ef7a3d9192..d67cf1c7e9 100644 --- a/.github/workflows/auth-release.yml +++ b/.github/workflows/auth-release.yml @@ -29,7 +29,7 @@ on: - "auth-v*" env: - FLUTTER_VERSION: "3.24.0" + FLUTTER_VERSION: "3.24.1" jobs: build-ubuntu: diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index 0918925ace..aee0df534c 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -24,6 +24,27 @@ { "title": "AscendEX" }, + { + "title": "Battle.net", + "slug": "battlenet", + "altNames": [ + "Battle net", + "Blizzard" + ] + }, + { + "title": "Bethesda", + "altNames": [ + "Bethesda Softworks" + ] + }, + { + "title": "BinanceUS", + "slug": "binance_us", + "altNames": [ + "Binance US" + ] + }, { "title": "Bitfinex" }, @@ -31,12 +52,12 @@ "title": "bitget" }, { - "titile":"bitget wallet", - "slug":"bitget_wallet" + "title": "bitget wallet", + "slug": "bitget_wallet" }, { "title": "Bitmart", - "hex":"000000" + "hex": "000000" }, { "title": "BitMEX" @@ -51,8 +72,7 @@ "title": "Bitstamp" }, { - "title": "Bitvavo", - "hex": "0051FF" + "title": "Bitvavo" }, { "title": "Bitwarden" @@ -70,27 +90,24 @@ "blockchain.com", "blockchain.com Wallet", "blockchain.com Exchange" - ], - "slug": "blockchain" + ] }, { "title": "BorgBase", "altNames": [ "borg" - ], - "slug": "BorgBase" + ] }, { "title": "Booking", - "slug": "booking", - "altNames":[ + "altNames": [ "Booking.com" ] }, { "title": "Brave Creators", "slug": "brave_creators", - "altNames":[ + "altNames": [ "Brave", "Brave Rewards", "Brave Browser" @@ -110,15 +127,11 @@ "slug": "cih", "hex": "D14633" }, - { - "title": "Cloudflare" - }, { "title": "CloudAMQP" }, { - "title": "ConfigCat", - "slug": "configcat" + "title": "Cloudflare" }, { "title": "CoinDCX" @@ -128,8 +141,7 @@ }, { "title": "Control D", - "slug": "controld", - "hex": "5FD800" + "slug": "controld" }, { "title": "Crowdpear" @@ -139,7 +151,6 @@ "slug": "crypto", "altNames": [ "crypto", - "Crypto.com", "Crypto com" ] }, @@ -147,8 +158,7 @@ "title": "DCS", "altNames": [ "Digital Combat Simulator" - ], - "slug": "dcs" + ] }, { "title": "DEGIRO" @@ -173,16 +183,22 @@ "slug": "dusnet" }, { - "title":"ecitizen kenya", - "slug":"ecitizen_kenya" + "title": "ecitizen kenya", + "slug": "ecitizen_kenya" + }, + { + "title": "ecloud", + "altNames": [ + "Murena" + ] }, { "title": "ente", "hex": "1DB954" }, { - "title": "enom" - }, + "title": "enom" + }, { "title": "Epic Games", "slug": "epic_games", @@ -218,10 +234,10 @@ }, { "title": "Gosuslugi", + "slug": "Gosuslugi", "altNames": [ "Госуслуги" - ], - "slug": "Gosuslugi" + ] }, { "title": "Habbo" @@ -240,16 +256,13 @@ "title": "HuggingFace", "altNames": [ "Hugging Face" - ], - "slug": "huggingface" + ] }, { - "title": "IceDrive", - "slug": "Icedrive" + "title": "IceDrive" }, { - "titile": "Infomaniak", - "slug": "infomaniak" + "title": "Infomaniak" }, { "title": "ING" @@ -270,8 +283,7 @@ "hex": "000000" }, { - "title": "IVPN", - "slug": "IVPN" + "title": "IVPN" }, { "title": "Jagex", @@ -281,8 +293,7 @@ "title": "Kagi" }, { - "title": "Kick", - "hex": "53FC19" + "title": "Kick" }, { "title": "Kite" @@ -295,15 +306,13 @@ "color": "00CC00" }, { - "title": "Kraken", - "hex": "5848D5" + "title": "Kraken" }, { "title": "Kronos" }, { - "title": "KuCoin", - "hex": "01BC8D" + "title": "KuCoin" }, { "title": "La Poste", @@ -333,7 +342,6 @@ "mathstodon", "fosstodon" ], - "slug": "mastodon", "hex": "6364FF" }, { @@ -364,13 +372,6 @@ { "title": "Mozilla" }, - { - "title": "Murena", - "altNames": [ - "eCloud" - ], - "slug": "ecloud" - }, { "title": "MyFRITZ!Net", "slug": "myfritz", @@ -406,6 +407,12 @@ { "title": "NextDNS" }, + { + "title": "Newton", + "altNames": [ + "Newton Crypto" + ] + }, { "title": "ngrok", "hex": "858585" @@ -420,8 +427,7 @@ "title": "Notion" }, { - "title": "NuCommunity", - "slug": "nucommunity" + "title": "NuCommunity" }, { "title": "NVIDIA" @@ -430,16 +436,17 @@ "title": "Odido" }, { - "titile": "OpenObserve", + "title": "OpenObserve", "slug": "open_observe", - "altNames":[ + "altNames": [ "openobserve.ai", "openobserve ai" ] }, { "title": "okx", - "hex": "000000" }, + "hex": "000000" + }, { "title": "Parsec" }, @@ -447,16 +454,13 @@ "title": "PayPal" }, { - "title": "pCloud", - "slug": "pCloud" + "title": "pCloud" }, { - "title": "Peerberry", - "hex": "03E5A5" + "title": "Peerberry" }, { - "title": "Pingvin Share", - "hex": "485099" + "title": "Pingvin Share" }, { "title": "Plutus", @@ -466,12 +470,10 @@ "title": "Poloniex" }, { - "title": "Porkbun", - "hex": "F27777" + "title": "Porkbun" }, { - "title": "PostNL", - "color": "EF8300" + "title": "PostNL" }, { "title": "Privacy Guides", @@ -495,7 +497,6 @@ "title": "Registro br", "slug": "registro_br", "altNames": [ - "Registro br", "registrobr", "Registro.br" ] @@ -507,13 +508,15 @@ "title": "Revolt", "hex": "858585" }, + { + "title": "RippleMatch" + }, { "title": "Rockstar Games", "slug": "rockstar_games" }, { - "title": "RuneMate", - "hex": "2ECC71" + "title": "RuneMate" }, { "title": "Rust Language Forum", @@ -529,6 +532,9 @@ { "title": "service-bw" }, + { + "title": "Shakepay" + }, { "title": "SimpleLogin" }, @@ -551,16 +557,14 @@ ] }, { - "title": "SMTP2GO", - "slug": "smtp2go" + "title": "SMTP2GO" }, { "title": "Snapchat" }, { "title": "Standard Notes", - "slug": "standardnotes", - "hex": "2173E6" + "slug": "standardnotes" }, { "title": "Surfshark" @@ -570,9 +574,7 @@ "slug": "synology_dsm" }, { - "title": "TCPShield", - "slug": "tcpshield", - "hex": "FFFFFF" + "title": "TCPShield" }, { "title": "Techlore", @@ -596,9 +598,7 @@ "title": "TorGuard" }, { - "title": "Trading 212", - "slug": "trading212", - "hex": "4BA4DE" + "title": "Trading 212" }, { "title": "TradingView" @@ -635,16 +635,16 @@ "hex": "858585" }, { - "title": "Uphold", - "slug": "uphold", - "hex": "6FE68A" + "title": "Uphold" }, { "title": "Upstox" }, { - "titile": "Vikunja", - "slug": "vikunja" + "title": "Vikunja" + }, + { + "title": "Wealthfront" }, { "title": "Wealthsimple" @@ -659,71 +659,30 @@ { "title": "Wise" }, - { - "title": "WYZE", - "slug": "wyze" - }, { "title": "WorkOS", - "slug": "workos", "altNames": [ "Work OS" ] }, + { + "title": "WYZE" + }, + { + "title": "yahoo" + }, { "title": "Yandex", "altNames": [ "Ya", "Яндекс" - ], - "slug": "Yandex" - }, - { - "title": "yahoo" + ] }, { "title": "YNAB", "altNames": [ "You Need A Budget" - ], - "slug": "ynab", - "hex": "3B5EDA" - }, - { - "title": "Shakepay", - "slug": "shakepay" - }, - { - "title": "Newton", - "altNames": ["Newton Crypto"], - "slug": "newton" - }, - { - "title": "RippleMatch", - "slug": "ripplematch" - }, - { - "title": "T-Mobile ID", - "altNames": [ - "T-Mobile" - ], - "slug": "t-mobile" - }, - { - "title": "Wealthfront", - "slug": "wealthfront" - }, - { - "title": "BinanceUS", - "altNames": [ - "Binance US" - ], - "slug": "binance_us" - }, - { - "title": "Bethesda Softworks", - "altNames": ["Bethesda"], - "slug": "bethesda" + ] } ] -} +} \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/battlenet.svg b/auth/assets/custom-icons/icons/battlenet.svg new file mode 100644 index 0000000000..023e205622 --- /dev/null +++ b/auth/assets/custom-icons/icons/battlenet.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/auth/assets/custom-icons/icons/kucoin.svg b/auth/assets/custom-icons/icons/kucoin.svg index 1b67b54717..d51a1660d8 100644 --- a/auth/assets/custom-icons/icons/kucoin.svg +++ b/auth/assets/custom-icons/icons/kucoin.svg @@ -1 +1 @@ - + diff --git a/auth/assets/custom-icons/icons/postnl.svg b/auth/assets/custom-icons/icons/postnl.svg index 3aa9415188..8ab3ff6784 100644 --- a/auth/assets/custom-icons/icons/postnl.svg +++ b/auth/assets/custom-icons/icons/postnl.svg @@ -1 +1 @@ - + diff --git a/auth/assets/custom-icons/icons/tcpshield.svg b/auth/assets/custom-icons/icons/tcpshield.svg index 9f6ce24091..6e6914700f 100644 --- a/auth/assets/custom-icons/icons/tcpshield.svg +++ b/auth/assets/custom-icons/icons/tcpshield.svg @@ -1,27 +1,8 @@ - - TCPShield - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + diff --git a/auth/flutter b/auth/flutter index 80c2e84975..5874a72aa4 160000 --- a/auth/flutter +++ b/auth/flutter @@ -1 +1 @@ -Subproject commit 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 +Subproject commit 5874a72aa4c779a02553007c47dacbefba2374dc diff --git a/auth/lib/l10n/arb/app_bg.arb b/auth/lib/l10n/arb/app_bg.arb index 12730e7933..85b0440407 100644 --- a/auth/lib/l10n/arb/app_bg.arb +++ b/auth/lib/l10n/arb/app_bg.arb @@ -67,7 +67,7 @@ "pleaseWait": "Моля изчакайте...", "generatingEncryptionKeysTitle": "Генерират се ключове за шифроване...", "recreatePassword": "Създайте отново парола", - "recreatePasswordMessage": "Текущото устройство не е достатъчно мощно, за да потвърди паролата Ви, така че трябва да го генерираме отново веднъж по начин, който работи с всички устройства. \n\nВлезте с Вашия ключ за възстановяване и генерирайте отново паролата си (можете да използвате същата отново, ако желаете).", + "recreatePasswordMessage": "Текущото устройство не е достатъчно мощно, за да потвърди паролата Ви, така че трябва да го генерираме отново веднъж по начин, който работи с всички устройства. \n\nМоля, влезте с Вашия ключ за възстановяване и генерирайте отново паролата си (можете да използвате същата отново, ако желаете).", "useRecoveryKey": "Използвайте ключ за възстановяване", "incorrectPasswordTitle": "Грешна парола", "welcomeBack": "Добре дошли отново!", @@ -130,7 +130,61 @@ "faq_q_3": "Как мога да изтрия кодове?", "faq_a_3": "Можете да изтриете код, като плъзнете наляво върху него.", "faq_q_4": "Как мога да подкрепя този проект?", + "faq_a_4": "Можете да подкрепите развитието на този проект, като се абонирате за нашето приложение за снимки @ ente.io.", + "faq_q_5": "Как мога да активирам заключване чрез FaceID в Auth", + "faq_a_5": "Можете да активирате заключване чрез FaceID в Настройки → Сигурност → Заключен екран.", + "somethingWentWrongMessage": "Нещо се обърка, моля опитайте отново", + "leaveFamily": "Напуснете семейството", + "leaveFamilyMessage": "Сигурни ли сте, че искате да напуснете семейния план?", + "inFamilyPlanMessage": "Вие сте на семеен план!", + "swipeHint": "Плъзнете наляво, за да редактирате или премахнете кодове", "scan": "Сканиране", + "scanACode": "Скениране на код", + "verify": "Потвърждаване", + "verifyEmail": "Потвърдете имейла", + "enterCodeHint": "Въведете 6-цифрения код от\nВашето приложение за удостоверяване", + "lostDeviceTitle": "Загубено устройство?", + "twoFactorAuthTitle": "Двуфакторно удостоверяване", + "passkeyAuthTitle": "Удостоверяване с ключ за парола", + "verifyPasskey": "Потвърдете ключ за парола", + "recoverAccount": "Възстановяване на акаунт", + "enterRecoveryKeyHint": "Въведете Вашия ключ за възстановяване", + "recover": "Възстановяване", + "contactSupportViaEmailMessage": "Моля, изпратете имейл до {email} от Вашия регистриран имейл адрес", + "@contactSupportViaEmailMessage": { + "placeholders": { + "email": { + "type": "String" + } + } + }, + "invalidQRCode": "Невалиден QR код", + "noRecoveryKeyTitle": "Няма ключ за възстановяване?", + "enterEmailHint": "Въведете Вашият имейл адрес", + "invalidEmailTitle": "Невалиден имейл адрес", + "invalidEmailMessage": "Моля, въведете валиден имейл адрес.", + "deleteAccount": "Изтриване на акаунта", + "deleteAccountQuery": "Ще съжаляваме да си тръгнете. Изправени ли сте пред някакъв проблем?", + "yesSendFeedbackAction": "Да, изпращане на обратна връзка", + "noDeleteAccountAction": "Не, изтриване на акаунта", + "initiateAccountDeleteTitle": "Моля, удостоверете се, за да инициирате изтриването на акаунта", + "sendEmail": "Изпратете имейл", + "createNewAccount": "Създаване на нов акаунт", + "weakStrength": "Слаба", + "strongStrength": "Силна", + "moderateStrength": "Умерена", + "confirmPassword": "Потвърждаване на паролата", + "close": "Затваряне", + "oopsSomethingWentWrong": "Ами сега, нещо се обърка.", + "selectLanguage": "Изберете език", + "language": "Език", + "social": "Социални мрежи", + "security": "Сигурност", + "lockscreen": "Заключен екран", + "authToChangeLockscreenSetting": "Моля, удостоверете се, за да промените настройката за заключен екран", + "deviceLockEnablePreSteps": "За да активирате заключването на устройството, моля, задайте парола за устройството или заключване на екрана в системните настройки.", + "viewActiveSessions": "Вижте активните сесии", + "authToViewYourActiveSessions": "Моля, удостоверете се, за да видите Вашите активни сесии", "searchHint": "Търсене...", "search": "Търсене", "sorryUnableToGenCode": "За съжаление не може да се генерира код за {issuerName}", @@ -183,46 +237,118 @@ "insecureDevice": "Несигурно устройство", "sorryWeCouldNotGenerateSecureKeysOnThisDevicennplease": "За съжаление не можахме да генерираме защитени ключове на това устройство.\n\nМоля, регистрирайте се от друго устройство.", "howItWorks": "Как работи", + "ackPasswordLostWarning": "Разбирам, че ако загубя паролата си, може да загубя данните си, тъй като данните ми са шифровани от край до край.", + "loginTerms": "С натискането на вход, се съгласявам с условията за ползване и политиката за поверителност", + "logInLabel": "Вход", "logout": "Изход", "areYouSureYouWantToLogout": "Наистина ли искате да излезете от профила си?", "yesLogout": "Да, излез", "exit": "Изход", + "verifyingRecoveryKey": "Проверка на ключа за възстановяване...", + "recoveryKeyVerified": "Ключът за възстановяване е проверен", + "recoveryKeySuccessBody": "Страхотно! Вашият ключ за възстановяване е валиден. Благодарим Ви за проверката.\n\nМоля, не забравяйте да запазите безопасно архивирания си ключ за възстановяване.", + "invalidRecoveryKey": "Въведеният от Вас ключ за възстановяване не е валиден. Моля, уверете се, че съдържа 24 думи и проверете правописа на всяка.\n\nАко сте въвели по-стар код за възстановяване, уверете се, че е дълъг 64 знака и проверете всеки от тях.", + "recreatePasswordTitle": "Създайте отново парола", + "recreatePasswordBody": "Текущото устройство не е достатъчно мощно, за да потвърди паролата Ви, но можем да я регенерираме по начин, който работи с всички устройства.\n\nМоля, влезте с Вашия ключ за възстановяване и генерирайте отново паролата си (можете да използвате същата отново, ако желаете).", "invalidKey": "Невалиден ключ", "tryAgain": "Опитайте отново", + "viewRecoveryKey": "Вижте ключа за възстановяване", + "confirmRecoveryKey": "Потвърдете ключа за възстановяване", + "recoveryKeyVerifyReason": "Вашият ключ за възстановяване е единственият начин да възстановите Вашите снимки, ако забравите паролата си. Можете да намерите своя ключ за възстановяване в Настройки > Акаунт.\n\nМоля, въведете Вашия ключ за възстановяване тук, за да проверите дали сте го запазили правилно.", + "confirmYourRecoveryKey": "Потвърдете Вашия ключ за възстановяване", "confirm": "Потвърждаване", + "emailYourLogs": "Изпратете Вашата история на действията на имейл", + "pleaseSendTheLogsTo": "Моля, изпратете историята на действията на \n{toEmail}", + "copyEmailAddress": "Копиране на имейл адрес", + "exportLogs": "Експорт на файловете с историята", + "enterYourRecoveryKey": "Въведете Вашия ключ за възстановяване", + "tempErrorContactSupportIfPersists": "Изглежда нещо се обърка. Моля, опитайте отново след известно време. Ако грешката продължава, моля, свържете се с нашия екип за поддръжка.", + "networkHostLookUpErr": "Не може да се свърже с Ente, моля, проверете мрежовите си настройки и се свържете с поддръжката, ако проблемът продължава.", + "networkConnectionRefusedErr": "Не може да се свърже с Ente, моля, опитайте отново след известно време. Ако проблемът продължава, моля, свържете се с поддръжката.", + "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Изглежда нещо се обърка. Моля, опитайте отново след известно време. Ако грешката продължава, моля, свържете се с нашия екип за поддръжка.", "about": "Относно", + "weAreOpenSource": "Ние сме с отворен код!", "privacy": "Поверителност", "terms": "Условия", "checkForUpdates": "Провери за актуализации", "checkStatus": "Проверка на състоянието", "downloadUpdate": "Изтегляне", + "criticalUpdateAvailable": "Налична е критична актуализация", "updateAvailable": "Налична актуализация", "update": "Актуализиране", "checking": "Извършва се проверка...", + "youAreOnTheLatestVersion": "Вие сте с най-новата версия", "warning": "Предупреждение", + "exportWarningDesc": "Експортираният файл съдържа поверителна информация. Моля, съхранявайте го безопасно.", "iUnderStand": "Разбрах", "@iUnderStand": { "description": "Text for the button to confirm the user understands the warning" }, + "authToExportCodes": "Моля, удостоверете се, за да експортирате Вашите кодове", "importSuccessTitle": "Ура!", + "importSuccessDesc": "Импортирахте {count} кода!", + "@importSuccessDesc": { + "placeholders": { + "count": { + "description": "The number of codes imported", + "type": "int", + "example": "1" + } + } + }, "sorry": "Съжаляваме", + "importFailureDesc": "Неуспешен анализ на избрания файл.\nМоля, пишете на support@ente.io, ако имате нужда от помощ!", "pendingSyncs": "Предупреждение", + "pendingSyncsWarningBody": "Някои от вашите кодове не са архивирани.\n\nМоля, уверете се, че имате резервно копие на тези кодове, преди да излезете.", + "checkInboxAndSpamFolder": "Моля, проверете входящата си поща (и спама), за да завършите проверката", + "tapToEnterCode": "Докоснете, за да въведете код", "resendEmail": "Повторно изпращане на имейл", + "weHaveSendEmailTo": "Изпратихме имейл до {email}", + "@weHaveSendEmailTo": { + "description": "Text to indicate that we have sent a mail to the user", + "placeholders": { + "email": { + "description": "The email address of the user", + "type": "String", + "example": "example@ente.io" + } + } + }, "activeSessions": "Активни сесии", "somethingWentWrongPleaseTryAgain": "Нещо се обърка, моля опитайте отново", + "thisWillLogYouOutOfThisDevice": "Това ще Ви изкара от профила на това устройство!", + "thisWillLogYouOutOfTheFollowingDevice": "Това ще Ви изкара от профила на следното устройство:", + "terminateSession": "Прекратяване на сесията?", "terminate": "Прекратяване", "thisDevice": "Това устройство", + "toResetVerifyEmail": "За да нулирате паролата си, моля, първо потвърдете своя имейл.", "thisEmailIsAlreadyInUse": "Този имейл вече се използва", + "verificationFailedPleaseTryAgain": "Неуспешно проверка, моля опитайте отново", + "yourVerificationCodeHasExpired": "Вашият код за потвърждение е изтекъл", "incorrectCode": "Неправилен код", + "sorryTheCodeYouveEnteredIsIncorrect": "За съжаление кодът, който сте въвели, е неправилен", + "emailChangedTo": "Имейлът е променен на {newEmail}", "authenticationFailedPleaseTryAgain": "Неуспешно удостоверяване, моля опитайте отново", "authenticationSuccessful": "Успешно удостоверяване!", "twofactorAuthenticationSuccessfullyReset": "Двуфакторното удостоверяване бе успешно нулирано", + "incorrectRecoveryKey": "Неправилен ключ за възстановяване", + "theRecoveryKeyYouEnteredIsIncorrect": "Въведеният от Вас ключ за възстановяване е неправилен", "enterPassword": "Въведете парола", "selectExportFormat": "Изберете формат за експортиране", + "exportDialogDesc": "Шифрованите експорти ще бъдат защитени с парола по Ваш избор.", "encrypted": "Шифровано", "plainText": "Обикновен текст", "passwordToEncryptExport": "Парола за шифроване на експортирането", "export": "Експортиране", + "useOffline": "Използвайте без резервни копия", + "signInToBackup": "Влезте, за да архивирате Вашите кодове", + "singIn": "Вход", + "sigInBackupReminder": "Моля, експортирайте Вашите кодове, за да сте сигурни, че имате резервно копие, от което можете да ги възстановите.", + "offlineModeWarning": "Избрахте да продължите без резервни копия. Моля, направете ръчни резервни копия, за да сте сигурни, че Вашите кодове са в безопасност.", + "showLargeIcons": "Показване на големи икони", + "shouldHideCode": "Скриване на кодове", + "doubleTapToViewHiddenCode": "Можете да докоснете два пъти върху запис, за да видите кода", + "focusOnSearchBar": "Фокусиране на търсенето при стартиране на приложението", "confirmUpdatingkey": "Сигурни ли сте, че искате да актуализирате секретния ключ?", "minimizeAppOnCopy": "Минимизиране на приложението при копиране", "editCodeAuthMessage": "Удостоверете се, за да редактирате кода", diff --git a/auth/lib/l10n/arb/app_de.arb b/auth/lib/l10n/arb/app_de.arb index 4d67965b2c..217fe8f2ae 100644 --- a/auth/lib/l10n/arb/app_de.arb +++ b/auth/lib/l10n/arb/app_de.arb @@ -72,7 +72,7 @@ "incorrectPasswordTitle": "Falsches Passwort", "welcomeBack": "Willkommen zurück!", "madeWithLoveAtPrefix": "gemacht mit ❤️ bei ", - "supportDevs": "Bei ente registrieren um das Projekt zu unterstützen.", + "supportDevs": "Bei ente registrieren, um das Projekt zu unterstützen", "supportDiscount": "Benutze den Rabattcode \"AUTH\" für 10% Rabatt im ersten Jahr", "changeEmail": "E-Mail ändern", "changePassword": "Passwort ändern", @@ -182,6 +182,7 @@ "security": "Sicherheit", "lockscreen": "Sperrbildschirm", "authToChangeLockscreenSetting": "Bitte authentifizieren um die Einstellungen des Sperrbildschirms zu ändern", + "deviceLockEnablePreSteps": "Um die Gerätesperre zu aktivieren, richte bitte einen Gerätepasscode oder eine Bildschirmsperre in den Systemeinstellungen ein.", "viewActiveSessions": "Aktive Sitzungen anzeigen", "authToViewYourActiveSessions": "Bitte authentifizieren um, die aktiven Sitzungen zu sehen", "searchHint": "Suchen...", @@ -441,5 +442,29 @@ "deleteTagTitle": "Tag löschen?", "deleteTagMessage": "Sind Sie sicher, dass Sie diesen Code löschen wollen? Diese Aktion ist unumkehrbar.", "somethingWentWrongParsingCode": "Wir konnten {x} Codes nicht parsen.", - "updateNotAvailable": "Update ist nicht verfügbar" + "updateNotAvailable": "Update ist nicht verfügbar", + "viewRawCodes": "Rohcodes anzeigen", + "rawCodes": "Rohcodes", + "rawCodeData": "Rohcode Daten", + "appLock": "App-Sperre", + "noSystemLockFound": "Keine Systemsperre gefunden", + "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Um die App-Sperre zu aktivieren, konfiguriere bitte den Gerätepasscode oder die Bildschirmsperre in den Systemeinstellungen.", + "autoLock": "Automatisches Sperren", + "immediately": "Sofort", + "reEnterPassword": "Passwort erneut eingeben", + "reEnterPin": "PIN erneut eingeben", + "next": "Weiter", + "tooManyIncorrectAttempts": "Zu viele fehlerhafte Versuche", + "tapToUnlock": "Zum Entsperren antippen", + "setNewPassword": "Neues Passwort festlegen", + "deviceLock": "Gerätesperre", + "hideContent": "Inhalte verstecken", + "hideContentDescriptionAndroid": "Versteckt Inhalte der App beim Wechseln zwischen Apps und deaktiviert Screenshots", + "hideContentDescriptioniOS": "Versteckt Inhalte der App beim Wechseln zwischen Apps", + "autoLockFeatureDescription": "Zeit, nach der die App gesperrt wird, nachdem sie in den Hintergrund verschoben wurde", + "appLockDescription": "Wähle zwischen dem Standard-Sperrbildschirm deines Gerätes und einem eigenen Sperrbildschirm mit PIN oder Passwort.", + "pinLock": "PIN-Sperre", + "enterPin": "PIN eingeben", + "setNewPin": "Neue PIN festlegen", + "importFailureDescNew": "Die ausgewählte Datei konnte nicht verarbeitet werden." } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_el.arb b/auth/lib/l10n/arb/app_el.arb index 0bbe9b9b81..e6f1d23b7c 100644 --- a/auth/lib/l10n/arb/app_el.arb +++ b/auth/lib/l10n/arb/app_el.arb @@ -182,6 +182,7 @@ "security": "Ασφάλεια", "lockscreen": "Οθόνη κλειδώματος", "authToChangeLockscreenSetting": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να αλλάξετε τις ρυθμίσεις οθόνης κλειδώματος", + "deviceLockEnablePreSteps": "Για να ενεργοποιήσετε το κλείδωμα της συσκευής, παρακαλώ ρυθμίστε τον κωδικό πρόσβασης της συσκευής ή το κλείδωμα οθόνης στις ρυθμίσεις του συστήματός σας.", "viewActiveSessions": "Προβολή ενεργών συνεδριών", "authToViewYourActiveSessions": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να δείτε τις ενεργές συνεδρίες σας", "searchHint": "Αναζήτηση...", @@ -458,6 +459,10 @@ "setNewPassword": "Ορίστε νέο κωδικό πρόσβασης", "deviceLock": "Κλείδωμα συσκευής", "hideContent": "Απόκρυψη περιεχομένου", + "hideContentDescriptionAndroid": "Απόκρυψη περιεχομένου εφαρμογής στην εναλλαγή εφαρμογών και απενεργοποίηση στιγμιότυπων οθόνης", + "hideContentDescriptioniOS": "Απόκρυψη περιεχομένου εφαρμογών στην εναλλαγή εφαρμογών", + "autoLockFeatureDescription": "Χρόνος μετά τον οποίο η εφαρμογή κλειδώνει μετά την τοποθέτηση στο παρασκήνιο", + "appLockDescription": "Επιλέξτε ανάμεσα στην προεπιλεγμένη οθόνη κλειδώματος της συσκευής σας και σε μια προσαρμοσμένη οθόνη κλειδώματος με ένα PIN ή έναν κωδικό πρόσβασης.", "pinLock": "Κλείδωμα καρφιτσωμάτων", "enterPin": "Εισαγωγή PIN", "setNewPin": "Ορίστε νέο PIN", diff --git a/auth/lib/l10n/arb/app_fr.arb b/auth/lib/l10n/arb/app_fr.arb index 1c411338ea..ec8ce6f86d 100644 --- a/auth/lib/l10n/arb/app_fr.arb +++ b/auth/lib/l10n/arb/app_fr.arb @@ -19,7 +19,7 @@ "pleaseVerifyDetails": "Veuillez vérifier vos informations et réessayez", "codeIssuerHint": "Émetteur", "codeSecretKeyHint": "Clé secrète", - "codeAccountHint": "Compte (vous@exemple.com)", + "codeAccountHint": "Compte (nom@exemple.com)", "codeTagHint": "Tag", "accountKeyType": "Type de clé", "sessionExpired": "Session expirée", @@ -118,7 +118,7 @@ "existingUser": "Utilisateur existant", "newUser": "Nouveau dans Ente", "delete": "Supprimer", - "enterYourPasswordHint": "Saisir votre mot de passe", + "enterYourPasswordHint": "Entrez votre mot de passe", "forgotPassword": "Mot de passe oublié", "oops": "Oups", "suggestFeatures": "Suggérer des fonctionnalités", @@ -135,14 +135,14 @@ "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", - "leaveFamilyMessage": "Êtes-vous certains de vouloir quitter le plan familial?", + "leaveFamilyMessage": "Êtes-vous sûr de vouloir quitter le plan familial ?", "inFamilyPlanMessage": "Vous êtes sur un plan familial !", "swipeHint": "Glisser vers la gauche pour modifier ou supprimer des codes", "scan": "Analyser", "scanACode": "Scanner un code", "verify": "Vérifier", "verifyEmail": "Vérifier l'e-mail", - "enterCodeHint": "Saisir le code à 6 caractères de votre appli d'authentification", + "enterCodeHint": "Entrez le code à 6 chiffres de votre application d'authentification", "lostDeviceTitle": "Appareil perdu ?", "twoFactorAuthTitle": "Authentification à deux facteurs", "passkeyAuthTitle": "Vérification du code d'accès", @@ -446,7 +446,9 @@ "viewRawCodes": "Afficher les codes bruts", "rawCodes": "Codes bruts", "rawCodeData": "Données de code brut", + "appLock": "Verrouillage d'application", "noSystemLockFound": "Aucun verrou système trouvé", + "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Pour activer le verrouillage d'application, veuillez configurer un code d'accès ou le verrouillage de l'écran dans les paramètres de votre appareil.", "autoLock": "Verrouillage automatique", "immediately": "Immédiatement", "reEnterPassword": "Ressaisir le mot de passe", @@ -457,6 +459,9 @@ "setNewPassword": "Définir un nouveau mot de passe", "deviceLock": "Verrouillage de l'appareil", "hideContent": "Masquer le contenu", + "autoLockFeatureDescription": "Délai après lequel l'application se verrouille une fois qu'elle a été mise en arrière-plan", + "appLockDescription": "Choisissez entre l'écran de verrouillage par défaut de votre appareil et un écran de verrouillage par code PIN ou mot de passe personnalisé.", + "pinLock": "Verrouillage par code PIN", "enterPin": "Saisir le code PIN", "setNewPin": "Définir un nouveau code PIN", "importFailureDescNew": "Impossible de lire le fichier sélectionné." diff --git a/auth/lib/l10n/arb/app_sv.arb b/auth/lib/l10n/arb/app_sv.arb index e47c1b8c0e..e0c817c13c 100644 --- a/auth/lib/l10n/arb/app_sv.arb +++ b/auth/lib/l10n/arb/app_sv.arb @@ -20,10 +20,11 @@ "@sessionExpired": { "description": "Title of the dialog when the users current session is invalid/expired" }, - "pleaseLoginAgain": "Vänligen logga in igen", + "pleaseLoginAgain": "Logga in igen", "loggingOut": "Loggar ut...", "saveAction": "Spara", "nextTotpTitle": "nästa", + "deleteCodeTitle": "Radera kod?", "deleteCodeMessage": "Vill du ta bort den här koden? Det går inte att ångra den här åtgärden.", "viewLogsAction": "Visa loggar", "emailLogsTitle": "E-posta loggar", @@ -63,6 +64,7 @@ "importCodes": "Importera koder", "exportCodes": "Exportera koder", "importLabel": "Importera", + "ok": "OK", "cancel": "Avbryt", "yes": "Ja", "no": "Nej", @@ -109,8 +111,21 @@ "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", + "send": "Skicka", "back": "Tillbaka", "createAccount": "Skapa konto", + "passwordStrength": "Lösenordsstyrka: {passwordStrengthValue}", + "@passwordStrength": { + "description": "Text to indicate the password strength", + "placeholders": { + "passwordStrengthValue": { + "description": "The strength of the password as a string", + "type": "String", + "example": "Weak or Moderate or Strong" + } + }, + "message": "Password Strength: {passwordStrengthText}" + }, "password": "Lösenord", "privacyPolicyTitle": "Integritetspolicy", "termsOfServicesTitle": "Villkor", @@ -151,6 +166,7 @@ "incorrectRecoveryKey": "Felaktig återställningsnyckel", "enterPassword": "Ange lösenord", "export": "Exportera", + "signInToBackup": "Logga in för att säkerhetskopiera dina koder", "singIn": "Logga in", "shouldHideCode": "Dölj koder", "androidCancelButton": "Avbryt", @@ -163,7 +179,11 @@ }, "noInternetConnection": "Ingen internetanslutning", "pleaseCheckYourInternetConnectionAndTryAgain": "Kontrollera din internetanslutning och försök igen.", + "signOutOtherDevices": "Logga ut andra enheter", "loginSessionExpiredDetails": "Din session har upphört. Logga in igen.", + "create": "Skapa", + "editTag": "Redigera tagg", + "deleteTagTitle": "Radera tagg?", "immediately": "Omedelbart", "reEnterPassword": "Ange lösenord igen", "reEnterPin": "Ange PIN-kod igen", diff --git a/auth/lib/l10n/arb/app_ta.arb b/auth/lib/l10n/arb/app_ta.arb new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/auth/lib/l10n/arb/app_ta.arb @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_vi.arb b/auth/lib/l10n/arb/app_vi.arb index 672afa36c3..1c75de3109 100644 --- a/auth/lib/l10n/arb/app_vi.arb +++ b/auth/lib/l10n/arb/app_vi.arb @@ -21,12 +21,13 @@ "codeSecretKeyHint": "Khóa bí mật", "codeAccountHint": "Tài khoản (bạn@miền.com)", "codeTagHint": "Thẻ", + "accountKeyType": "Loại khóa", "sessionExpired": "Phiên làm việc đã hết hạn", "@sessionExpired": { "description": "Title of the dialog when the users current session is invalid/expired" }, "pleaseLoginAgain": "Vui lòng đăng nhập lại", - "loggingOut": "Đang thoát...", + "loggingOut": "Đang đăng xuất...", "timeBasedKeyType": "Dựa trên thời gian (TOTP)", "counterBasedKeyType": "Dựa trên bộ đếm (HOTP)", "saveAction": "Lưu", @@ -74,7 +75,7 @@ "supportDevs": "Đăng ký ente để hỗ trợ dự án này.", "supportDiscount": "Sử dụng mã giảm giá \"AUTH\" để được giảm 10% trong năm đầu tiên", "changeEmail": "Thay đổi email", - "changePassword": "Thay đổi mật khẩu", + "changePassword": "Đổi mật khẩu", "data": "Dữ liệu", "importCodes": "Nhập mã", "importTypePlainText": "Văn bản thuần", @@ -131,7 +132,7 @@ "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": "Làm sao để tôi bật FaceID trong ente", "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", + "somethingWentWrongMessage": "Đã xảy ra lỗi, xin thử lại", "leaveFamily": "Rời khỏi gia đình", "leaveFamilyMessage": "Bạn có chắc chắn muốn thoát khỏi gói dành cho gia đình không?", "inFamilyPlanMessage": "Bạn đang sử dụng gói dành cho gia đình!", @@ -141,7 +142,7 @@ "verify": "Xác minh", "verifyEmail": "Xác nhận địa chỉ Email", "enterCodeHint": "Nhập mã gồm 6 chữ số từ ứng dụng xác thực của bạn", - "lostDeviceTitle": "Bạn đã mất thiết bị?", + "lostDeviceTitle": "Mất thiết bị?", "twoFactorAuthTitle": "Xác thực hai yếu tố", "recoverAccount": "Khôi phục tài khoản", "enterRecoveryKeyHint": "Nhập khóa khôi phục của bạn", @@ -154,6 +155,7 @@ } } }, + "invalidQRCode": "Mã QR không hợp lệ", "noRecoveryKeyTitle": "Không có khóa khôi phục?", "enterEmailHint": "Nhập địa chỉ email của bạn", "invalidEmailTitle": "Địa chỉ email không hợp lệ", @@ -177,6 +179,7 @@ "security": "Bảo mật", "lockscreen": "Màn hình khoá", "authToChangeLockscreenSetting": "Vui lòng xác thực để thay đổi cài đặt màn hình khóa", + "deviceLockEnablePreSteps": "Để bật khoá thiết bị, vui lòng thiết lập mật khẩu thiết bị hoặc khóa màn hình trong cài đặt hệ thống của bạn.", "viewActiveSessions": "Xem danh sách phiên làm việc hiện tại", "authToViewYourActiveSessions": "Vui lòng xác thực để xem danh sách phiên làm việc của bạn", "searchHint": "Tìm kiếm...", @@ -195,6 +198,8 @@ "recoveryKeySaveDescription": "Chúng tôi không lưu trữ khóa này, vui lòng lưu khóa 24 từ này ở nơi an toàn.", "doThisLater": "Để sau", "saveKey": "Lưu khóa", + "save": "Lưu", + "send": "Gửi", "back": "Quay lại", "createAccount": "Tạo tài khoản", "passwordStrength": "Độ mạnh mật khẩu: {passwordStrengthValue}", @@ -253,6 +258,8 @@ "exportLogs": "Xuất nhật ký", "enterYourRecoveryKey": "Nhập khóa khôi phục của bạn", "tempErrorContactSupportIfPersists": "Có vẻ như đã xảy ra sự cố. Vui lòng thử lại sau một thời gian. Nếu lỗi vẫn tiếp diễn, vui lòng liên hệ với nhóm hỗ trợ của chúng tôi.", + "networkHostLookUpErr": "Không thể kết nối đến Ente, vui lòng kiểm tra lại kết nối mạng. Nếu vẫn còn lỗi, xin vui lòng liên hệ hỗ trợ.", + "networkConnectionRefusedErr": "Không thể kết nối đến Ente, vui lòng thử lại sau. Nếu vẫn còn lỗi, xin vui lòng liên hệ hỗ trợ.", "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Có vẻ như đã xảy ra sự cố. Vui lòng thử lại sau một thời gian. Nếu lỗi vẫn tiếp diễn, vui lòng liên hệ với nhóm hỗ trợ của chúng tôi.", "about": "Về chúng tôi", "weAreOpenSource": "Chúng tôi có mã nguồn mở!", @@ -342,6 +349,7 @@ "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 trên các nền tảng khác, nếu bạn có sử dụng.\n\nDữ liệu đã tải lên của bạn, trên mọi nền tả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." @@ -402,11 +410,37 @@ "doNotSignOut": "Không được đăng xuất", "hearUsWhereTitle": "Bạn biết đến Ente bằng cách nào? (không bắt buộc)", "hearUsExplanation": "Chúng tôi không theo dõi lượt cài đặt ứng dụng. Sẽ rất hữu ích nếu bạn cho chúng tôi biết nơi bạn tìm thấy chúng tôi!", + "recoveryKeySaved": "Đã lưu khoá dự phòng vào thư mục Tải về!", + "waitingForVerification": "Đang chờ xác thực", + "loginSessionExpired": "Phiên làm việc hết hạn", + "loginSessionExpiredDetails": "Phiên làm việc hết hạn. Vui lòng đăng nhập lại.", + "developerSettings": "Cài đặt cho nhà phát triển", + "customEndpoint": "Đã kết nối đến", + "pinText": "Ghim", + "unpinText": "Bỏ ghim", + "tags": "Thẻ", + "createNewTag": "Tạo thẻ mới", + "tag": "Thẻ", + "create": "Tạo", + "editTag": "Sửa thẻ", + "deleteTagTitle": "Xóa thẻ?", + "deleteTagMessage": "Bạn có chắc chắn muốn xóa thẻ này không? Hành động này không thể đảo ngược.", "updateNotAvailable": "Cập nhật không khả dụng", "viewRawCodes": "Xem mã nguồn", "rawCodes": "Mã nguồn", + "appLock": "Khóa ứng dụng", + "autoLock": "Tự động khóa", + "immediately": "Tức thì", + "reEnterPassword": "Nhập lại mật khẩu", + "reEnterPin": "Nhập lại mã PIN", + "next": "Tiếp", + "tooManyIncorrectAttempts": "Quá nhiều lần thử không chính xác", + "tapToUnlock": "Nhấn để mở khóa", "setNewPassword": "Đặt lại mật khẩu", "deviceLock": "Khóa thiết bị", + "hideContent": "Ẩn nội dung", + "hideContentDescriptionAndroid": "Ẩn nội dung khi chuyển ứng dụng và chặn chụp màn hình", + "hideContentDescriptioniOS": "Ẩn nội dung khi chuyển ứng dụng", "pinLock": "Mã PIN", "enterPin": "Nhập mã PIN", "setNewPin": "Đổi mã PIN" diff --git a/auth/lib/l10n/arb/app_zh.arb b/auth/lib/l10n/arb/app_zh.arb index b20bf2dd38..a62a7c13f3 100644 --- a/auth/lib/l10n/arb/app_zh.arb +++ b/auth/lib/l10n/arb/app_zh.arb @@ -27,15 +27,15 @@ "description": "Title of the dialog when the users current session is invalid/expired" }, "pleaseLoginAgain": "请重新登录", - "loggingOut": "正在退出登录...", - "timeBasedKeyType": "基于时间的 (TOTP)", - "counterBasedKeyType": "基于计数器的(HOTP)", + "loggingOut": "正在登出...", + "timeBasedKeyType": "基于时间 (TOTP)", + "counterBasedKeyType": "基于计数器 (HOTP)", "saveAction": "保存", "nextTotpTitle": "下一个", "deleteCodeTitle": "要删除代码吗?", - "deleteCodeMessage": "您确定要删除此代码吗?此操作是不可逆的。", + "deleteCodeMessage": "您确定要删除此代码吗?此操作不可逆。", "viewLogsAction": "查看日志", - "sendLogsDescription": "这将跨日志发送以帮助我们调试您的问题。 虽然我们采取预防措施以确保不记录敏感信息,但我们鼓励您在共享这些日志之前先查看它们。", + "sendLogsDescription": "这将发送日志以帮助我们调试您的问题。虽然我们采取预防措施确保不记录敏感信息,但我们建议您在共享这些日志之前先查看它们。", "preparingLogsTitle": "正在准备日志...", "emailLogsTitle": "电子邮件日志", "emailLogsMessage": "请将日志发送至 {email}", @@ -67,13 +67,13 @@ "pleaseWait": "请稍候...", "generatingEncryptionKeysTitle": "正在生成加密密钥...", "recreatePassword": "重新创建密码", - "recreatePasswordMessage": "当前设备的强度不足以验证您的密码, 所以我们需要以一种能够与所有设备一起运行的方式重新生成它。 \n\n请使用您的恢复密钥登录并重新生成您的密码 (如果您愿意,您可以再次使用相同密钥)。", + "recreatePasswordMessage": "当前设备的功能不足以验证您的密码,因此我们需要以一种适用于所有设备的方式重新生成一次密码。\n\n请使用您的恢复密钥登录并重新生成您的密码(如果您愿意,可以再次使用相同的密码)。", "useRecoveryKey": "使用恢复密钥", "incorrectPasswordTitle": "密码错误", "welcomeBack": "欢迎回来!", "madeWithLoveAtPrefix": "用❤️制成 ", "supportDevs": "订阅 ente 以支持此项目。", - "supportDiscount": "使用优惠券号码“AUTH”获得第一年优惠10%的折扣", + "supportDiscount": "使用优惠码“AUTH”可享受首年 10% 折扣", "changeEmail": "修改邮箱", "changePassword": "修改密码", "data": "数据", @@ -83,29 +83,29 @@ "passwordForDecryptingExport": "用来解密导出的密码", "passwordEmptyError": "密码不能为空", "importFromApp": "从 {appName} 导入代码", - "importGoogleAuthGuide": "使用“转移帐户”选项将您的帐户从 Google 身份验证器导出到二维码。然后使用另一台设备扫描二维码。\n\n提示:您可以使用笔记本电脑的网络摄像头拍摄二维码的照片。", + "importGoogleAuthGuide": "使用“转移账户”选项将您的账户从 Google Authenticator 导出到二维码。然后使用另一台设备扫描二维码。\n\n提示:您可以使用笔记本电脑的摄像头拍摄二维码的照片。", "importSelectJsonFile": "选择 JSON 文件", "importSelectAppExport": "选择 {appName} 的导出文件", "importEnteEncGuide": "选择从 Ente 导出的 JSON 加密文件", "importRaivoGuide": "使用 Raivo 设置中的“将 OTP 导出到 Zip 存档”选项。\n\n解压 zip 文件并导入 JSON 文件。", - "importBitwardenGuide": "使用 Bitwarden 工具中的“导出保管库”选项并导入未加密的 JSON 文件。", - "importAegisGuide": "在Aegis的设置中使用\"导出密码库\"选项。\n\n如果您的密码库已加密,您需要输入密码才能解密密码库。", - "import2FasGuide": "使用 2FAS 中的“设置 -> 备份 - 导出”选项。\n\n如果您的备份已被加密,则需要输入密码才能解密备份", + "importBitwardenGuide": "使用 Bitwarden 工具中的“导出密码库”选项并导入未加密的 JSON 文件。", + "importAegisGuide": "使用 Aegis 设置中的“导出密码库”选项。\n\n如果您的密码库已加密,则需要输入密码库密码才能解密密码库。", + "import2FasGuide": "使用 2FAS 中的“设置 -> 备份 -> 导出”选项。\n\n如果您的备份已加密,则需要输入密码来解密备份", "importLastpassGuide": "使用 Lastpass Authenticator 设置中的“转移账户”选项,然后按“将账户导出到文件”。导入下载的 JSON。", "exportCodes": "导出代码", "importLabel": "导入", - "importInstruction": "请以以下格式选择包含代码列表的文件", + "importInstruction": "请选择一个包含以下格式的代码列表的文件", "importCodeDelimiterInfo": "代码可以用逗号或新行分隔。", "selectFile": "选择文件", "emailVerificationToggle": "电子邮件验证", - "emailVerificationEnableWarning": "如果您将 2FA 存储到我们的电子邮件中,则打开电子邮件验证可能会导致僵局。如果您被一项服务锁定,您可能无法登录另一项服务。", + "emailVerificationEnableWarning": "为避免被锁在您的账户之外,请在启用电子邮件验证之前确保在 Ente Auth 之外存储电子邮件双重验证的副本。", "authToChangeEmailVerificationSetting": "请进行身份验证以更改电子邮件验证", "authToViewYourRecoveryKey": "请验证以查看您的恢复密钥", "authToChangeYourEmail": "请验证以更改您的电子邮件", "authToChangeYourPassword": "请验证以更改密码", - "authToViewSecrets": "请进行身份验证以查看您的秘密", + "authToViewSecrets": "请进行身份验证以查看您的密钥", "authToInitiateSignIn": "请进行身份验证以启动登录进行备份。", - "ok": "好的", + "ok": "确定", "cancel": "取消", "yes": "是", "no": "否", @@ -125,8 +125,8 @@ "faq": "常见问题", "faq_q_1": "Auth 的安全性如何?", "faq_a_1": "您通过 Auth 备份的所有代码均以端到端加密方式存储。这意味着只有您可以访问您的代码。我们的应用程序是开源的并且我们的加密技术已经过外部审计。", - "faq_q_2": "我可以在桌面上访问我的代码吗?", - "faq_a_2": "您可以在 web @auth.ente.io 上访问您的代码。", + "faq_q_2": "我可以在桌面设备上访问我的代码吗?", + "faq_a_2": "您可以在网页 auth.ente.io 上访问您的代码。", "faq_q_3": "我如何删除代码?", "faq_a_3": "您可以通过向左滑动该项目来删除该代码。", "faq_q_4": "我该如何支持该项目?", @@ -240,9 +240,9 @@ "ackPasswordLostWarning": "我明白,如果我丢失密码,我可能会丢失我的数据,因为我的数据是 端到端加密的。", "loginTerms": "点击登录后,我同意 服务条款隐私政策", "logInLabel": "登录", - "logout": "退出登录", - "areYouSureYouWantToLogout": "您确定要退出登录吗?", - "yesLogout": "是的,退出登陆", + "logout": "登出", + "areYouSureYouWantToLogout": "您确定要登出吗?", + "yesLogout": "是的,登出", "exit": "退出", "verifyingRecoveryKey": "正在验证恢复密钥...", "recoveryKeyVerified": "恢复密钥已验证", @@ -299,7 +299,7 @@ "sorry": "抱歉", "importFailureDesc": "无法解析选定的文件。\n如果您需要帮助,请写入support@ente.io!", "pendingSyncs": "警告", - "pendingSyncsWarningBody": "您的一些代码尚未备份。\n\n请确保您在退出登录之前备份这些代码。", + "pendingSyncsWarningBody": "您的一些代码尚未备份。\n\n请确保您在登出之前备份这些代码。", "checkInboxAndSpamFolder": "请检查您的收件箱 (或者是在您的“垃圾邮件”列表内) 以完成验证", "tapToEnterCode": "点击以输入代码", "resendEmail": "重新发送电子邮件", @@ -316,8 +316,8 @@ }, "activeSessions": "已登录的设备", "somethingWentWrongPleaseTryAgain": "出了点问题,请重试", - "thisWillLogYouOutOfThisDevice": "这将使您在此设备上退出登录!", - "thisWillLogYouOutOfTheFollowingDevice": "这将使您在以下设备中退出登录:", + "thisWillLogYouOutOfThisDevice": "这将使您登出该设备!", + "thisWillLogYouOutOfTheFollowingDevice": "这将使您登出以下设备:", "terminateSession": "是否终止会话?", "terminate": "终止", "thisDevice": "此设备", @@ -396,7 +396,7 @@ "@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": "生物识别身份验证已禁用。请锁定再解锁您的屏幕以启用它。", + "iOSLockOut": "生物识别身份验证已禁用。请锁定并解锁屏幕以启用该功能。", "@iOSLockOut": { "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side." }, @@ -410,11 +410,11 @@ }, "noInternetConnection": "无互联网连接", "pleaseCheckYourInternetConnectionAndTryAgain": "请检查您的互联网连接,然后重试。", - "signOutFromOtherDevices": "从其他设备退出登录", - "signOutOtherBody": "如果你认为有人可能知道你的密码,你可以强制所有使用你账户的其他设备退出登录。", + "signOutFromOtherDevices": "从其他设备登出", + "signOutOtherBody": "如果您认为有人可能知道您的密码,您可以强制所有其他使用您账户的设备登出。", "signOutOtherDevices": "登出其他设备", - "doNotSignOut": "不要退登", - "hearUsWhereTitle": "您是如何知道Ente的? (可选的)", + "doNotSignOut": "不要登出", + "hearUsWhereTitle": "您是怎么知道 Ente 的?(可选)", "hearUsExplanation": "我们不跟踪应用程序安装情况。如果您告诉我们您是在哪里找到我们的,将会有所帮助!", "recoveryKeySaved": "恢复密钥已保存在下载文件夹中!", "waitingForBrowserRequest": "正在等待浏览器请求...", diff --git a/auth/lib/ui/code_timer_progress.dart b/auth/lib/ui/code_timer_progress.dart index a215f0ca02..a825a6ca43 100644 --- a/auth/lib/ui/code_timer_progress.dart +++ b/auth/lib/ui/code_timer_progress.dart @@ -1,48 +1,56 @@ import 'package:ente_auth/theme/ente_theme.dart'; -import 'package:ente_auth/ui/linear_progress_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +class CodeTimerProgressCache { + static final Map _cache = {}; + + static CodeTimerProgress getCachedWidget(int period) { + if (!_cache.containsKey(period)) { + _cache[period] = CodeTimerProgress(period: period); + } + return _cache[period]!; + } +} + class CodeTimerProgress extends StatefulWidget { final int period; - CodeTimerProgress({ + const CodeTimerProgress({ super.key, required this.period, }); @override - State createState() => _CodeTimerProgressState(); + State createState() => _CodeTimerProgressState(); } class _CodeTimerProgressState extends State with SingleTickerProviderStateMixin { late final Ticker _ticker; - double _progress = 0.0; + late final ValueNotifier _progress; late final int _microSecondsInPeriod; @override void initState() { super.initState(); _microSecondsInPeriod = widget.period * 1000000; - _ticker = createTicker((elapsed) { - _updateTimeRemaining(); - }); + _progress = ValueNotifier(0.0); + _ticker = createTicker(_updateTimeRemaining); _ticker.start(); - _updateTimeRemaining(); + _updateTimeRemaining(Duration.zero); } - void _updateTimeRemaining() { - int timeRemaining = (_microSecondsInPeriod) - + void _updateTimeRemaining(Duration elapsed) { + int timeRemaining = _microSecondsInPeriod - (DateTime.now().microsecondsSinceEpoch % _microSecondsInPeriod); - setState(() { - _progress = (timeRemaining / _microSecondsInPeriod); - }); + _progress.value = timeRemaining / _microSecondsInPeriod; } @override void dispose() { _ticker.dispose(); + _progress.dispose(); super.dispose(); } @@ -50,12 +58,46 @@ class _CodeTimerProgressState extends State Widget build(BuildContext context) { return SizedBox( height: 3, - child: LinearProgressWidget( - color: _progress > 0.4 - ? getEnteColorScheme(context).primary700 - : Colors.orange, - fractionOfStorage: _progress, + child: ValueListenableBuilder( + valueListenable: _progress, + builder: (context, progress, _) { + return CustomPaint( + painter: _ProgressPainter( + progress: progress, + color: progress > 0.4 + ? getEnteColorScheme(context).primary700 + : Colors.orange, + ), + size: Size.infinite, + ); + }, ), ); } } + +class _ProgressPainter extends CustomPainter { + final double progress; + final Color color; + + _ProgressPainter({required this.progress, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final rect = RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, size.width * progress, size.height), + const Radius.circular(2), + ); + + canvas.drawRRect(rect, paint); + } + + @override + bool shouldRepaint(_ProgressPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index 4cd263be5a..4c3e748e3a 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -49,6 +49,7 @@ class _CodeWidgetState extends State { late bool _shouldShowLargeIcon; late bool _hideCode; bool isMaskingEnabled = false; + int _codeTimeStep = -1; @override void initState() { @@ -57,11 +58,22 @@ class _CodeWidgetState extends State { _hideCode = isMaskingEnabled; _everySecondTimer = Timer.periodic(const Duration(milliseconds: 500), (Timer t) { - String newCode = _getCurrentOTP(); - if (newCode != _currentCode.value) { - _currentCode.value = newCode; - if (widget.code.type.isTOTPCompatible) { - _nextCode.value = _getNextTotp(); + int newStep = 0; + if (widget.code.type != Type.hotp) { + newStep = (((DateTime.now().millisecondsSinceEpoch ~/ 1000).round()) ~/ + widget.code.period) + .floor(); + } else { + newStep = widget.code.counter; + } + if (_codeTimeStep != newStep) { + _codeTimeStep = newStep; + String newCode = _getCurrentOTP(); + if (newCode != _currentCode.value) { + _currentCode.value = newCode; + if (widget.code.type.isTOTPCompatible) { + _nextCode.value = _getNextTotp(); + } } } }); @@ -111,8 +123,8 @@ class _CodeWidgetState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (widget.code.type.isTOTPCompatible) - CodeTimerProgress( - period: widget.code.period, + CodeTimerProgressCache.getCachedWidget( + widget.code.period, ), const SizedBox(height: 28), Row( diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index 1e17947210..f9c6410d9d 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -55,6 +55,9 @@ class _HomePageState extends State { final Logger _logger = Logger("HomePage"); final scaffoldKey = GlobalKey(); + // Used to request focus on the search box when clicked the search icon + late FocusNode searchBoxFocusNode; + final TextEditingController _textController = TextEditingController(); final bool _autoFocusSearch = PreferenceService.instance.shouldAutoFocusOnSearchBar(); @@ -89,6 +92,8 @@ class _HomePageState extends State { setState(() {}); }); _showSearchBox = _autoFocusSearch; + + searchBoxFocusNode = FocusNode(); } void _loadCodes() { @@ -158,6 +163,9 @@ class _HomePageState extends State { _triggerLogoutEvent?.cancel(); _iconsChangedEvent?.cancel(); _textController.removeListener(_applyFilteringAndRefresh); + + searchBoxFocusNode.dispose(); + super.dispose(); } @@ -241,6 +249,7 @@ class _HomePageState extends State { border: InputBorder.none, focusedBorder: InputBorder.none, ), + focusNode: searchBoxFocusNode, ), centerTitle: true, actions: [ @@ -258,6 +267,12 @@ class _HomePageState extends State { _searchText = ""; } else { _searchText = _textController.text; + + // Request focus on the search box + // For Windows only for now. "Platform.isWindows" can be removed if other platforms has been tested. + if (Platform.isWindows) { + searchBoxFocusNode.requestFocus(); + } } _applyFilteringAndRefresh(); }, diff --git a/desktop/CHANGELOG.md b/desktop/CHANGELOG.md index 9a32cd383f..e55fa1e3e9 100644 --- a/desktop/CHANGELOG.md +++ b/desktop/CHANGELOG.md @@ -4,6 +4,7 @@ - Improved date search, including support for day of week and hour of day. - Fix video thumbnail generation and upload on Intel macOS. +- Club a photo and video into a live photo only if both are within 2 minutes. - . ## v1.7.3 diff --git a/desktop/src/main/services/zip.ts b/desktop/src/main/services/zip.ts index 5a7f4242f0..17a7205bce 100644 --- a/desktop/src/main/services/zip.ts +++ b/desktop/src/main/services/zip.ts @@ -65,9 +65,9 @@ export const markClosableZip = (zipPath: string) => { */ export const clearOpenZipCache = () => { if (_refCount.size > 0) { - const keys = JSON.stringify([..._refCount.keys()]); + const kvs = JSON.stringify([..._refCount.entries()]); throw new Error( - `Attempting to clear zip file cache when some items are still in use: ${keys}`, + `Attempting to clear zip file cache when some items are still in use: ${kvs}`, ); } _cache.clear(); diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index d32eecc627..261ab32a21 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -4,8 +4,7 @@ import { net, protocol } from "electron/main"; import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; -import { Readable } from "node:stream"; -import { ReadableStream } from "node:stream/web"; +import { Writable } from "node:stream"; import { pathToFileURL } from "node:url"; import log from "./log"; import { ffmpegConvertToMP4 } from "./services/ffmpeg"; @@ -120,20 +119,27 @@ const handleReadZip = async (zipPath: string, entryName: string) => { return new Response("", { status: 404 }); } - // This returns an "old style" NodeJS.ReadableStream. + // zip.stream returns an "old style" NodeJS.ReadableStream. We then write it + // to the writable end of the web stream pipe, the readable end of which is + // relayed back to the renderer as the response. + const { writable, readable } = new TransformStream(); const stream = await zip.stream(entry); - // Convert it into a new style NodeJS.Readable. - const nodeReadable = new Readable({ emitClose: true }).wrap(stream); - // Then convert it into a Web stream. - const webReadableStreamAny = Readable.toWeb(nodeReadable); - // However, we get a ReadableStream now. This doesn't go into the - // `BodyInit` expected by the Response constructor, which wants a - // ReadableStream. Force a cast. - const webReadableStream = - webReadableStreamAny as ReadableStream; - // Let go of the zip handle when the underlying stream closes. - nodeReadable.on("close", () => markClosableZip(zipPath)); + const nodeWritable = Writable.fromWeb(writable); + stream.pipe(nodeWritable); + + nodeWritable.on("error", (e: unknown) => { + // If the renderer process closes the network connection (say when it + // only needs the content-length and doesn't care about the body), we + // get an AbortError. Handle them here otherwise they litter the logs + // with unhandled exceptions. + if (e instanceof Error && e.name == "AbortError") return; + log.error("Error event for the writable end of zip stream", e); + }); + + nodeWritable.on("close", () => { + markClosableZip(zipPath); + }); // While it is documented that entry.time is the modification time, // the units are not mentioned. By seeing the source code, we can @@ -142,8 +148,7 @@ const handleReadZip = async (zipPath: string, entryName: string) => { // https://github.com/antelle/node-stream-zip/blob/master/node_stream_zip.js const modifiedMs = entry.time; - // @ts-expect-error [Note: Node and web stream type mismatch] - return new Response(webReadableStream, { + return new Response(readable, { headers: { // We don't know the exact type, but it doesn't really matter, just // set it to a generic binary content-type so that the browser diff --git a/docs/docs/.vitepress/sidebar.ts b/docs/docs/.vitepress/sidebar.ts index 62b3cedf87..35eac00fd5 100644 --- a/docs/docs/.vitepress/sidebar.ts +++ b/docs/docs/.vitepress/sidebar.ts @@ -205,6 +205,15 @@ export const sidebar = [ }, ], }, + { + text: "Troubleshooting", + items: [ + { + text: "Windows login", + link: "/auth/troubleshooting/windows-login", + }, + ], + }, ], }, { diff --git a/docs/docs/auth/troubleshooting/windows-login.md b/docs/docs/auth/troubleshooting/windows-login.md new file mode 100644 index 0000000000..2a990fc7af --- /dev/null +++ b/docs/docs/auth/troubleshooting/windows-login.md @@ -0,0 +1,43 @@ +--- +title: Unable to login on Windows Desktop +description: + Troubleshooting when you are not able to login or register on Ente Auth app on Windows +--- + + + +# Windows Login Error + + +### HandshakeException: Handshake error in client + +This error usually happens when the Trusted Root certificates on your Windows machine are outdated. + +To update the Trusted Root Certificates on Windows, you can use the `certutil` command. Here are the steps to do so: + +1. **Open Command Prompt as Administrator**: + - Press `Windows + X` and select `Command Prompt (Admin)` or `Windows PowerShell (Admin)`. + +2. **Run the following command to update the root certificates**: + ```bash + certutil -generateSSTFromWU roots.sst + ``` + This command will generate a file named `roots.sst` that contains the latest root certificates from Windows Update. + +3. **Install the new root certificates**: + ```bash + certutil -addstore -f ROOT roots.sst + ``` + This command will add the certificates from the `roots.sst` file to the Trusted Root Certification Authorities store. + +4. **Clean up**: + After the installation, you can delete the `roots.sst` file if you no longer need it: + ```bash + del roots.sst + ``` + +Make sure to restart your application after updating the certificates to ensure the changes take effect. + +If the above steps don't resolve the issue, please follow [this guide](https://woshub.com/updating-trusted-root-certificates-in-windows-10/#h2_3) to update your trusted root certicates, and try again. + + diff --git a/docs/docs/photos/features/share.md b/docs/docs/photos/features/share.md index a1b9be376a..076b5546e1 100644 --- a/docs/docs/photos/features/share.md +++ b/docs/docs/photos/features/share.md @@ -57,6 +57,26 @@ If you wish to collect photos from folks who are not Ente, you can do so with our Links. Simply tick the box that says "Allow uploads", and anyone who has access to the link will be able to add photos to your album. +## Organization + +You can favorite items that have been shared with you, and organize them into +your own albums. + +When you perform these operations, Ente will create a hard copy of these items, +that you fully own. This means, these copied items will count against your +storage space. + +We understand there are use cases where this approach will consume extra space +(for eg. if you are organizing photos of a family member). We chose hard copies +as a first version to avoid complexities regarding the ownership of shared +items, in case the original owner were to delete it from their own library. + +We plan to tackle these complexities in the future, by copying a reference to +the item that was shared, instead of the actual file, so that your storage will +only get consumed if the original owner deletes it from their library. If this +sounds useful to you, please participate in [this +discussion](https://github.com/ente-io/ente/discussions/790). + ## Technical details More details, including technical aspect about how the sharing features were diff --git a/mobile/android/app/src/debug/res/values/strings.xml b/mobile/android/app/src/debug/res/values/strings.xml index 9749285e5b..2253459b7e 100644 --- a/mobile/android/app/src/debug/res/values/strings.xml +++ b/mobile/android/app/src/debug/res/values/strings.xml @@ -1,4 +1,4 @@ - ente debug + Ente Debug backup debug diff --git a/mobile/android/app/src/dev/res/values/strings.xml b/mobile/android/app/src/dev/res/values/strings.xml index 3f5e2af1d1..50a363d10f 100644 --- a/mobile/android/app/src/dev/res/values/strings.xml +++ b/mobile/android/app/src/dev/res/values/strings.xml @@ -1,4 +1,4 @@ - ente dev + Ente Dev backup dev diff --git a/mobile/android/app/src/face/res/values/strings.xml b/mobile/android/app/src/face/res/values/strings.xml index 4932deb961..ac4281e80e 100644 --- a/mobile/android/app/src/face/res/values/strings.xml +++ b/mobile/android/app/src/face/res/values/strings.xml @@ -1,4 +1,4 @@ - ente face + Ente Face backup face diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 9824022683..3f14877d91 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,7 +1,8 @@ import Flutter import UIKit +import AVFoundation -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, @@ -12,11 +13,36 @@ import UIKit UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate } + let controller : FlutterViewController = window?.rootViewController as! FlutterViewController + let audioSessionChannel = FlutterMethodChannel(name: "io.ente.frame/audio_session", + binaryMessenger: controller.binaryMessenger) + + audioSessionChannel.setMethodCallHandler({ + (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + if call.method == "setAudioSessionCategory" { + self.setAudioSessionCategory(result: result) + } else { + result(FlutterMethodNotImplemented) + } + }) + GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + private func setAudioSessionCategory(result: @escaping FlutterResult) { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers, .defaultToSpeaker]) + try AVAudioSession.sharedInstance().setActive(true) + result(nil) + } catch { + result(FlutterError(code: "AUDIO_SESSION_ERROR", + message: "Failed to set audio session category", + details: error.localizedDescription)) + } + } + override func applicationDidBecomeActive(_ application: UIApplication) { signal(SIGPIPE, SIG_IGN) } diff --git a/mobile/lib/audio_session_handler.dart b/mobile/lib/audio_session_handler.dart new file mode 100644 index 0000000000..8aede31b12 --- /dev/null +++ b/mobile/lib/audio_session_handler.dart @@ -0,0 +1,16 @@ +import "package:flutter/services.dart"; +import "package:logging/logging.dart"; + +class AudioSessionHandler { + static final _logger = Logger("AudioSessionHandler"); + static const MethodChannel _channel = + MethodChannel('io.ente.frame/audio_session'); + + static Future setAudioSessionCategory() async { + try { + await _channel.invokeMethod('setAudioSessionCategory'); + } on PlatformException catch (e) { + _logger.warning("Failed to set audio session category: '${e.message}'."); + } + } +} diff --git a/mobile/lib/events/backup_updated_event.dart b/mobile/lib/events/backup_updated_event.dart new file mode 100644 index 0000000000..7d710df199 --- /dev/null +++ b/mobile/lib/events/backup_updated_event.dart @@ -0,0 +1,10 @@ +import "dart:collection"; + +import "package:photos/events/event.dart"; +import "package:photos/models/backup/backup_item.dart"; + +class BackupUpdatedEvent extends Event { + final LinkedHashMap items; + + BackupUpdatedEvent(this.items); +} diff --git a/mobile/lib/generated/intl/messages_en.dart b/mobile/lib/generated/intl/messages_en.dart index cd4e029a65..7ef0bc6292 100644 --- a/mobile/lib/generated/intl/messages_en.dart +++ b/mobile/lib/generated/intl/messages_en.dart @@ -390,6 +390,9 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Backup over mobile data"), "backupSettings": MessageLookupByLibrary.simpleMessage("Backup settings"), + "backupStatus": MessageLookupByLibrary.simpleMessage("Backup status"), + "backupStatusDescription": MessageLookupByLibrary.simpleMessage( + "Items that have been backed up will show up here"), "backupVideos": MessageLookupByLibrary.simpleMessage("Backup videos"), "blackFridaySale": MessageLookupByLibrary.simpleMessage("Black Friday Sale"), diff --git a/mobile/lib/generated/l10n.dart b/mobile/lib/generated/l10n.dart index 9cb39c1c2a..b2d8acb6f3 100644 --- a/mobile/lib/generated/l10n.dart +++ b/mobile/lib/generated/l10n.dart @@ -3172,6 +3172,26 @@ class S { ); } + /// `Backup status` + String get backupStatus { + return Intl.message( + 'Backup status', + name: 'backupStatus', + desc: '', + args: [], + ); + } + + /// `Items that have been backed up will show up here` + String get backupStatusDescription { + return Intl.message( + 'Items that have been backed up will show up here', + name: 'backupStatusDescription', + desc: '', + args: [], + ); + } + /// `Backup over mobile data` String get backupOverMobileData { return Intl.message( diff --git a/mobile/lib/l10n/intl_da.arb b/mobile/lib/l10n/intl_da.arb index 1f008e0571..77050c0cd3 100644 --- a/mobile/lib/l10n/intl_da.arb +++ b/mobile/lib/l10n/intl_da.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "Vi er kede af at du forlader os. Forklar venligst hvorfor, så vi kan forbedre os.", "feedback": "Feedback", "kindlyHelpUsWithThisInformation": "Hjælp os venligst med disse oplysninger", - "confirmDeletePrompt": "Ja, jeg ønsker at slette denne konto og alle dens data permanent.", "confirmAccountDeletion": "Bekræft Sletning Af Konto", "deleteAccountPermanentlyButton": "Slet konto permanent", "yourAccountHasBeenDeleted": "Din konto er blevet slettet", diff --git a/mobile/lib/l10n/intl_de.arb b/mobile/lib/l10n/intl_de.arb index f7250591dd..b57cb823a8 100644 --- a/mobile/lib/l10n/intl_de.arb +++ b/mobile/lib/l10n/intl_de.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Wir bedauern sehr, dass du dein Konto löschen möchtest. Du würdest uns sehr helfen, wenn du uns kurz einige Gründe hierfür nennen könntest.", "feedback": "Rückmeldung", "kindlyHelpUsWithThisInformation": "Bitte gib diese Daten ein", - "confirmDeletePrompt": "Ja, ich möchte dieses Konto und alle enthaltenen Daten endgültig und unwiderruflich löschen.", + "confirmDeletePrompt": "Ja, ich möchte dieses Konto und alle enthaltenen Daten über alle Apps endgültig und unwiderruflich löschen.", "confirmAccountDeletion": "Kontolöschung bestätigen", "deleteAccountPermanentlyButton": "Konto unwiderruflich löschen", "yourAccountHasBeenDeleted": "Dein Benutzerkonto wurde gelöscht", diff --git a/mobile/lib/l10n/intl_en.arb b/mobile/lib/l10n/intl_en.arb index ad4c9a2b85..491a612017 100644 --- a/mobile/lib/l10n/intl_en.arb +++ b/mobile/lib/l10n/intl_en.arb @@ -453,6 +453,8 @@ "showMemories": "Show memories", "yearsAgo": "{count, plural, one{{count} year ago} other{{count} years ago}}", "backupSettings": "Backup settings", + "backupStatus": "Backup status", + "backupStatusDescription": "Items that have been backed up will show up here", "backupOverMobileData": "Backup over mobile data", "backupVideos": "Backup videos", "disableAutoLock": "Disable auto lock", diff --git a/mobile/lib/l10n/intl_es.arb b/mobile/lib/l10n/intl_es.arb index a08d08e877..152cc88d1f 100644 --- a/mobile/lib/l10n/intl_es.arb +++ b/mobile/lib/l10n/intl_es.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "Lamentamos que te vayas. Por favor, explícanos el motivo para ayudarnos a mejorar.", "feedback": "Sugerencias", "kindlyHelpUsWithThisInformation": "Por favor ayúdanos con esta información", - "confirmDeletePrompt": "Sí, quiero eliminar permanentemente esta cuenta y todos sus datos.", "confirmAccountDeletion": "Confirmar borrado de cuenta", "deleteAccountPermanentlyButton": "Eliminar cuenta permanentemente", "yourAccountHasBeenDeleted": "Tu cuenta ha sido eliminada", diff --git a/mobile/lib/l10n/intl_fa.arb b/mobile/lib/l10n/intl_fa.arb index b42295354d..6b242950bd 100644 --- a/mobile/lib/l10n/intl_fa.arb +++ b/mobile/lib/l10n/intl_fa.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "ما متاسفیم که می‌بینیم شما می‌روید. لطفا نظرات خود را برای کمک به بهبود ما به اشتراک بگذارید.", "feedback": "بازخورد", "kindlyHelpUsWithThisInformation": "لطفا با این اطلاعات به ما کمک کنید", - "confirmDeletePrompt": "بله، من می‌خواهم برای همیشه این حساب کاربری و تمام اطلاعات آن را حذف کنم.", "confirmAccountDeletion": "تایید حذف حساب کاربری", "deleteAccountPermanentlyButton": "حذف دائمی حساب کاربری", "yourAccountHasBeenDeleted": "حساب کاربری شما حذف شده است", diff --git a/mobile/lib/l10n/intl_fr.arb b/mobile/lib/l10n/intl_fr.arb index 55714b9125..018f065b71 100644 --- a/mobile/lib/l10n/intl_fr.arb +++ b/mobile/lib/l10n/intl_fr.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Nous sommes désolés de vous voir partir. N'hésitez pas à partager vos commentaires pour nous aider à améliorer le service.", "feedback": "Commentaires", "kindlyHelpUsWithThisInformation": "Merci de nous aider avec cette information", - "confirmDeletePrompt": "Oui, je veux supprimer définitivement ce compte et toutes ses données.", + "confirmDeletePrompt": "Oui, je veux supprimer définitivement ce compte et ses données dans toutes les applications.", "confirmAccountDeletion": "Confirmer la suppression du compte", "deleteAccountPermanentlyButton": "Supprimer définitivement le compte", "yourAccountHasBeenDeleted": "Votre compte a été supprimé", @@ -277,6 +277,7 @@ "change": "Modifier", "unavailableReferralCode": "Désolé, ce code n'est pas disponible.", "codeChangeLimitReached": "Désolé, vous avez atteint la limite de changements de code.", + "onlyFamilyAdminCanChangeCode": "Veuillez contacter {familyAdminEmail} pour modifier votre code.", "storageInGB": "{storageAmountInGB} Go", "claimed": "Réclamée", "@claimed": { @@ -413,7 +414,13 @@ "photoGridSize": "Taille de la grille photo", "manageDeviceStorage": "Gérer le stockage de l'appareil", "machineLearning": "Apprentissage automatique", + "mlConsent": "Activer l'apprentissage automatique", + "mlConsentTitle": "Activer l'apprentissage automatique ?", + "mlConsentDescription": "Si vous activez l'apprentissage automatique, Ente extraira des informations comme la géométrie des visages, incluant les photos partagées avec vous. \nCela se fera sur votre appareil, avec un cryptage de bout-en-bout de toutes les données biométriques générées.", + "mlConsentPrivacy": "Veuillez cliquer ici pour plus de détails sur cette fonctionnalité dans notre politique de confidentialité", + "mlConsentConfirmation": "Je comprends, et souhaite activer l'apprentissage automatique", "magicSearch": "Recherche magique", + "mlIndexingDescription": "Veuillez noter que l'apprentissage automatique entraînera une augmentation de l'utilisation de la bande passante et de la batterie, jusqu'à ce que tous les éléments soient indexés. \nEnvisagez d'utiliser l'application de bureau pour une indexation plus rapide, tous les résultats seront automatiquement synchronisés.", "loadingModel": "Téléchargement des modèles...", "waitingForWifi": "En attente de connexion Wi-Fi...", "status": "État", @@ -489,6 +496,7 @@ "removeDuplicates": "Supprimer les doublons", "removeDuplicatesDesc": "Examiner et supprimer les fichiers qui sont des doublons exacts.", "viewLargeFiles": "Fichiers volumineux", + "viewLargeFilesDesc": "Afficher les fichiers qui consomment le plus de stockage.", "noDuplicates": "✨ Aucun doublon", "youveNoDuplicateFilesThatCanBeCleared": "Vous n'avez aucun fichier dédupliqué pouvant être nettoyé", "success": "Succès", @@ -1145,6 +1153,7 @@ "successfullyHid": "Masquage réussi", "successfullyUnhid": "Masquage réussi", "crashReporting": "Rapports d'erreurs", + "resumableUploads": "Chargements à poursuivre", "addToHiddenAlbum": "Ajouter à un album masqué", "moveToHiddenAlbum": "Déplacer vers un album masqué", "fileTypes": "Types de fichiers", @@ -1247,12 +1256,29 @@ "foundFaces": "Visages trouvés", "clusteringProgress": "Progression du regroupement", "indexingIsPaused": "L'indexation est en pause. Elle reprendra automatiquement lorsque l'appareil sera prêt.", + "trim": "Recadrer", + "crop": "Rogner", "rotate": "Pivoter", "left": "Gauche", "right": "Droite", "whatsNew": "Nouveautés", "reviewSuggestions": "Consulter les suggestions", "useAsCover": "Utiliser comme couverture", + "notPersonLabel": "Pas {name}?", + "@notPersonLabel": { + "description": "Label to indicate that the person in the photo is not the person whose name is mentioned", + "placeholders": { + "name": { + "content": "{name}", + "type": "String" + } + } + }, + "enable": "Activer", + "enabled": "Activé", + "moreDetails": "Plus de détails", + "enableMLIndexingDesc": "Ente prend en charge l'apprentissage automatique sur l'appareil pour la reconnaissance faciale, la recherche magique et d'autres fonctionnalités de recherche avancée", + "magicSearchHint": "La recherche magique permet de rechercher des photos par leur contenu, par exemple 'fleur', 'voiture rouge', 'documents d'identité'", "panorama": "Panorama", "reenterPassword": "Ressaisir le mot de passe", "reenterPin": "Ressaisir le code PIN", @@ -1278,6 +1304,8 @@ "pleaseSelectQuickLinksToRemove": "Veuillez sélectionner les liens rapides à supprimer", "removePublicLinks": "Supprimer les liens publics", "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "Ceci supprimera les liens publics de tous les liens rapides sélectionnés.", + "guestView": "Vue invité", + "guestViewEnablePreSteps": "Pour activer la vue invité, veuillez configurer le code d'accès de l'appareil ou le verrouillage de l'écran dans les paramètres de votre système.", "cl_guest_view_title": "Vue invité", "cl_guest_view_description": "Montrer des photos à un ami en les transmettant sur votre téléphone ? Ne vous inquiétez pas si vous les faites glisser trop loin.\nLa vue \"invité\" les verrouillera dans les photos que vous avez sélectionnées.", "cl_guest_view_call_to_action": "Sélectionnez les photos et fixez les en \"Vue Invité\".", @@ -1286,6 +1314,8 @@ "cl_video_player_title": "Lecteur vidéo", "cl_video_player_description": "Intégration d'un nouveau lecteur vidéo, avec de meilleurs contrôles de lecture et la prise en charge des vidéos HDR.", "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Pour activer le verrouillage d'application, veuillez configurer le code d'accès de l'appareil ou le verrouillage de l'écran dans les paramètres de votre système.", + "appLockDescriptions": "Choisissez entre l'écran de verrouillage par défaut de votre appareil et un écran de verrouillage personnalisé avec un code PIN ou un mot de passe.", + "authToViewPasskey": "Veuillez vous authentifier pour afficher votre clé de récupération", "loopVideoOn": "Loop video on", "loopVideoOff": "Loop video off" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_he.arb b/mobile/lib/l10n/intl_he.arb index 237ccf1332..c5ec380698 100644 --- a/mobile/lib/l10n/intl_he.arb +++ b/mobile/lib/l10n/intl_he.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "אנחנו מצטערים לראות שאתה עוזב. אנא תחלוק את המשוב שלך כדי לעזור לנו להשתפר.", "feedback": "משוב", "kindlyHelpUsWithThisInformation": "אנא עזור לנו עם המידע הזה", - "confirmDeletePrompt": "כן, אני רוצה למחוק לצמיתות את החשבון הזה וכל המידע שלו.", "confirmAccountDeletion": "אשר את מחיקת החשבון", "deleteAccountPermanentlyButton": "מחק את החשבון לצמיתות", "yourAccountHasBeenDeleted": "החשבון שלך נמחק", diff --git a/mobile/lib/l10n/intl_hi.arb b/mobile/lib/l10n/intl_hi.arb index c70c8bee50..4caa68c3ae 100644 --- a/mobile/lib/l10n/intl_hi.arb +++ b/mobile/lib/l10n/intl_hi.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "आपको जाता हुए देख कर हमें खेद है। कृपया हमें बेहतर बनने में सहायता के लिए अपनी प्रतिक्रिया साझा करें।", "feedback": "प्रतिपुष्टि", "kindlyHelpUsWithThisInformation": "कृपया हमें इस जानकारी के लिए सहायता करें", - "confirmDeletePrompt": "हां, मैं इस अकाउंट और इसके सभी डेटा को स्थायी रूप से हटाना चाहता/चाहती हूं।", "confirmAccountDeletion": "अकाउंट डिलीट करने की पुष्टि करें", "deleteAccountPermanentlyButton": "अकाउंट स्थायी रूप से डिलीट करें", "yourAccountHasBeenDeleted": "आपका अकाउंट डिलीट कर दिया गया है", diff --git a/mobile/lib/l10n/intl_id.arb b/mobile/lib/l10n/intl_id.arb index ca032e8060..63a83f0663 100644 --- a/mobile/lib/l10n/intl_id.arb +++ b/mobile/lib/l10n/intl_id.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Kami sedih kamu pergi. Silakan bagikan masukanmu agar kami bisa jadi lebih baik.", "feedback": "Masukan", "kindlyHelpUsWithThisInformation": "Harap bantu kami dengan informasi ini", - "confirmDeletePrompt": "Ya, saya ingin menghapus akun ini dan seluruh data yang terkait secara permanen.", + "confirmDeletePrompt": "Ya, saya ingin menghapus akun ini dan seluruh datanya secara permanen di semua aplikasi.", "confirmAccountDeletion": "Konfirmasi Penghapusan Akun", "deleteAccountPermanentlyButton": "Hapus Akun Secara Permanen", "yourAccountHasBeenDeleted": "Akunmu telah dihapus", @@ -426,6 +426,7 @@ "status": "Status", "indexedItems": "Item terindeks", "pendingItems": "Item menunggu", + "clearIndexes": "Hapus indeks", "selectFoldersForBackup": "Pilih folder yang perlu dicadangkan", "selectedFoldersWillBeEncryptedAndBackedUp": "Folder yang terpilih akan dienkripsi dan dicadangkan", "unselectAll": "Batalkan semua pilihan", @@ -437,11 +438,18 @@ "showMemories": "Lihat kenangan", "yearsAgo": "{count, plural, other{{count} tahun lalu}}", "backupSettings": "Pengaturan pencadangan", + "backupStatus": "Status pencadangan", + "backupStatusDescription": "Item yang sudah dicadangkan akan terlihat di sini", "backupOverMobileData": "Cadangkan dengan data seluler", "backupVideos": "Cadangkan video", "disableAutoLock": "Nonaktifkan kunci otomatis", + "deviceLockExplanation": "Nonaktfikan kunci layar perangkat saat Ente berada di latar depan dan ada pencadangan yang sedang berlangsung. Hal ini biasanya tidak diperlukan, namun dapat membantu unggahan dan import awal berkas berkas besar selesai lebih cepat.", + "about": "Tentang", + "weAreOpenSource": "Kode kami sumber terbuka!", "privacy": "Privasi", "terms": "Ketentuan", + "checkForUpdates": "Periksa pembaruan", + "checkStatus": "Periksa status", "checking": "Memeriksa...", "youAreOnTheLatestVersion": "Kamu menggunakan versi terbaru", "account": "Akun", @@ -458,10 +466,13 @@ "yesLogout": "Ya, keluar", "aNewVersionOfEnteIsAvailable": "Versi baru dari Ente telah tersedia.", "update": "Perbarui", + "installManually": "Instal secara manual", "criticalUpdateAvailable": "Pembaruan penting tersedia", "updateAvailable": "Pembaruan tersedia", "ignoreUpdate": "Abaikan", "downloading": "Mengunduh...", + "cannotDeleteSharedFiles": "Tidak dapat menghapus file berbagi", + "theDownloadCouldNotBeCompleted": "Unduhan tidak dapat diselesaikan", "retry": "Coba lagi", "backedUpFolders": "Folder yang dicadangkan", "backup": "Pencadangan", @@ -472,8 +483,12 @@ "removeDuplicates": "Hapus duplikat", "removeDuplicatesDesc": "Lihat dan hapus file yang sama persis.", "viewLargeFiles": "File berukuran besar", + "viewLargeFilesDesc": "Tampilkan file yang banyak mengkonsumsi ruang penyimpanan.", "noDuplicates": "✨ Tak ada file duplikat", + "youveNoDuplicateFilesThatCanBeCleared": "Kamu tidak memiliki file duplikat yang dapat di hapus", "success": "Berhasil", + "rateUs": "Beri kami nilai", + "remindToEmptyDeviceTrash": "Kosongkan juga “Baru Saja Dihapus” dari “Pengaturan” -> “Penyimpanan” untuk mengklaim ruang yang baru dikosongkan", "youHaveSuccessfullyFreedUp": "Kamu telah berhasil membersihkan {storageSaved}!", "@youHaveSuccessfullyFreedUp": { "description": "The text to display when the user has successfully freed up storage", @@ -485,6 +500,7 @@ } } }, + "remindToEmptyEnteTrash": "Kosongkan juga \"Sampah\" untuk mendapatkan ruang yang baru dikosongkan", "sparkleSuccess": "✨ Berhasil", "duplicateFileCountWithStorageSaved": "Kamu telah menghapus {count, plural, other{{count} file duplikat}} dan membersihkan ({storageSaved}!)", "@duplicateFileCountWithStorageSaved": { @@ -502,14 +518,18 @@ } }, "familyPlans": "Paket keluarga", + "referrals": "Referensi", "notifications": "Notifikasi", "sharedPhotoNotifications": "Foto terbagi baru", + "sharedPhotoNotificationsExplanation": "Terima notifikasi apabila seseorang menambahkan foto ke album bersama yang kamu ikuti", "advanced": "Lanjutan", "general": "Umum", "security": "Keamanan", "authToViewYourRecoveryKey": "Harap autentikasi untuk melihat kunci pemulihan kamu", "twofactor": "Autentikasi dua langkah", "authToConfigureTwofactorAuthentication": "Harap autentikasi untuk mengatur autentikasi dua langkah", + "lockscreen": "Kunci layar", + "authToChangeLockscreenSetting": "Lakukan autentikasi untuk mengubah pengaturan kunci layar", "viewActiveSessions": "Lihat sesi aktif", "authToViewYourActiveSessions": "Harap autentikasi untuk melihat sesi aktif kamu", "disableTwofactor": "Nonaktifkan autentikasi dua langkah", @@ -519,6 +539,7 @@ "social": "Sosial", "rateUsOnStore": "Beri nilai di {storeName}", "blog": "Blog", + "merchandise": "Barang Dagangan", "twitter": "Twitter", "mastodon": "Mastodon", "matrix": "Matrix", @@ -549,6 +570,7 @@ "renewsOn": "Langganan akan diperpanjang pada {endDate}", "freeTrialValidTill": "Percobaan gratis berlaku hingga {endDate}", "validTill": "Berlaku hingga {endDate}", + "addOnValidTill": "Add-on {storageAmount} kamu berlaku sampai {endDate}", "playStoreFreeTrialValidTill": "Percobaan gratis berlaku hingga {endDate}.\nKamu dapat memilih paket berbayar setelahnya.", "subWillBeCancelledOn": "Langganan kamu akan dibatalkan pada {endDate}", "subscription": "Langganan", @@ -576,18 +598,46 @@ }, "confirmPlanChange": "Konfirmasi perubahan paket", "areYouSureYouWantToChangeYourPlan": "Apakah kamu yakin ingin mengubah paket kamu?", + "youCannotDowngradeToThisPlan": "Anda tidak dapat turun ke paket ini", + "cancelOtherSubscription": "Harap batalkan langganan kamu dari {paymentProvider} terlebih dahulu", + "@cancelOtherSubscription": { + "description": "The text to display when the user has an existing subscription from a different payment provider", + "type": "text", + "placeholders": { + "paymentProvider": { + "example": "Apple", + "type": "String" + } + } + }, "optionalAsShortAsYouLike": "Opsional, pendek pun tak apa...", "send": "Kirim", "askCancelReason": "Langganan kamu telah dibatalkan. Apakah kamu ingin membagikan alasannya?", "thankYouForSubscribing": "Terima kasih telah berlangganan!", "yourPurchaseWasSuccessful": "Pembelianmu berhasil", + "yourPlanWasSuccessfullyUpgraded": "Paket kamu berhasil ditingkatkan", + "yourPlanWasSuccessfullyDowngraded": "Paket kamu berhasil di turunkan", "yourSubscriptionWasUpdatedSuccessfully": "Langgananmu telah berhasil diperbarui", "googlePlayId": "ID Google Play", "appleId": "ID Apple", + "playstoreSubscription": "Langganan PlayStore", + "appstoreSubscription": "Langganan AppStore", "subAlreadyLinkedErrMessage": "{id} kamu telah terhubung dengan akun Ente lain.\nJika kamu ingin menggunakan {id} kamu untuk akun ini, silahkan hubungi tim bantuan kami", "visitWebToManage": "Silakan buka web.ente.io untuk mengatur langgananmu", + "couldNotUpdateSubscription": "Tidak dapat memperbarui langganan", "pleaseContactSupportAndWeWillBeHappyToHelp": "Silakan hubungi support@ente.io dan kami akan dengan senang hati membantu!", "paymentFailed": "Pembayaran gagal", + "paymentFailedTalkToProvider": "Harap hubungi dukungan {providerName} jika kamu dikenai biaya", + "@paymentFailedTalkToProvider": { + "description": "The text to display when the payment failed", + "type": "text", + "placeholders": { + "providerName": { + "example": "AppStore|PlayStore", + "type": "String" + } + } + }, "continueOnFreeTrial": "Lanjut dengan percobaan gratis", "areYouSureYouWantToExit": "Apakah kamu yakin ingin keluar?", "thankYou": "Terima kasih", @@ -601,8 +651,10 @@ "leave": "Tinggalkan", "rateTheApp": "Nilai app ini", "startBackup": "Mulai pencadangan", + "noPhotosAreBeingBackedUpRightNow": "Tidak ada foto yang sedang dicadangkan sekarang", "grantFullAccessPrompt": "Harap berikan akses ke semua foto di app Pengaturan", "openSettings": "Buka Pengaturan", + "selectMorePhotos": "Pilih lebih banyak foto", "existingUser": "Masuk", "privateBackups": "Cadangan pribadi", "forYourMemories": "untuk kenanganmu", @@ -614,6 +666,7 @@ "everywhere": "di mana saja", "androidIosWebDesktop": "Android, iOS, Web, Desktop", "mobileWebDesktop": "Seluler, Web, Desktop", + "newToEnte": "Baru di Ente", "pleaseLoginAgain": "Silakan masuk akun lagi", "autoLogoutMessage": "Akibat kesalahan teknis, kamu telah keluar dari akunmu. Kami mohon maaf atas ketidaknyamanannya.", "yourSubscriptionHasExpired": "Langgananmu telah berakhir", @@ -848,6 +901,9 @@ "networkHostLookUpErr": "Tidak dapat terhubung dengan Ente, harap periksa pengaturan jaringan kamu dan hubungi dukungan jika masalah berlanjut.", "networkConnectionRefusedErr": "Tidak dapat terhubung dengan Ente, silakan coba lagi setelah beberapa saat. Jika masalah berlanjut, harap hubungi dukungan.", "cachedData": "Data cache", + "remoteThumbnails": "Thumbnail jarak jauh", + "pendingSync": "Sinkronisasi yang tertunda", + "localGallery": "Galeri lokal", "todaysLogs": "Log hari ini", "viewLogs": "Lihat log", "preparingLogs": "Menyiapkan log...", diff --git a/mobile/lib/l10n/intl_it.arb b/mobile/lib/l10n/intl_it.arb index f03bb21411..e84355ca9c 100644 --- a/mobile/lib/l10n/intl_it.arb +++ b/mobile/lib/l10n/intl_it.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Ci dispiace vederti andare via. Facci sapere se hai bisogno di aiuto o se vuoi aiutarci a migliorare.", "feedback": "Suggerimenti", "kindlyHelpUsWithThisInformation": "Aiutaci con queste informazioni", - "confirmDeletePrompt": "Sì, voglio eliminare definitivamente questo account e tutti i suoi dati.", + "confirmDeletePrompt": "Sì, voglio eliminare definitivamente questo account e i dati associati a esso su tutte le applicazioni.", "confirmAccountDeletion": "Conferma eliminazione account", "deleteAccountPermanentlyButton": "Cancella definitivamente il tuo account", "yourAccountHasBeenDeleted": "Il tuo account è stato eliminato", @@ -24,6 +24,7 @@ "sendEmail": "Invia email", "deleteRequestSLAText": "La tua richiesta verrà elaborata entro 72 ore.", "deleteEmailRequest": "Invia un'email a account-deletion@ente.io dal tuo indirizzo email registrato.", + "entePhotosPerm": "Ente necessita del permesso per preservare le tue foto", "ok": "Ok", "createAccount": "Crea account", "createNewAccount": "Crea un nuovo account", @@ -225,14 +226,17 @@ }, "description": "Number of participants in an album, including the album owner." }, + "collabLinkSectionDescription": "Crea un link per consentire alle persone di aggiungere e visualizzare foto nel tuo album condiviso senza bisogno di un'applicazione o di un account Ente. Ottimo per raccogliere foto di un evento.", "collectPhotos": "Raccogli le foto", "collaborativeLink": "Link collaborativo", + "shareWithNonenteUsers": "Condividi con utenti che non hanno un account Ente", "createPublicLink": "Crea link pubblico", "sendLink": "Invia link", "copyLink": "Copia link", "linkHasExpired": "Il link è scaduto", "publicLinkEnabled": "Link pubblico abilitato", "shareALink": "Condividi un link", + "sharedAlbumSectionDescription": "Crea album condivisi e collaborativi con altri utenti di Ente, inclusi gli utenti con piani gratuiti.", "shareWithPeopleSectionTitle": "{numberOfPeople, plural, =0 {Condividi con persone specifiche} =1 {Condividi con una persona} other {Condividi con {numberOfPeople} persone}}", "@shareWithPeopleSectionTitle": { "placeholders": { @@ -256,10 +260,12 @@ }, "verificationId": "ID di verifica", "verifyEmailID": "Verifica {email}", + "emailNoEnteAccount": "{email} non ha un account Ente.\n\nInvia un invito per condividere foto.", "shareMyVerificationID": "Ecco il mio ID di verifica: {verificationID} per ente.io.", "shareTextConfirmOthersVerificationID": "Hey, puoi confermare che questo è il tuo ID di verifica: {verificationID} su ente.io", "somethingWentWrong": "Qualcosa è andato storto", "sendInvite": "Invita", + "shareTextRecommendUsingEnte": "Scarica Ente in modo da poter facilmente condividere foto e video in qualità originale\n\nhttps://ente.io", "done": "Completato", "applyCodeTitle": "Applica codice", "enterCodeDescription": "Inserisci il codice fornito dal tuo amico per richiedere spazio gratuito per entrambi", @@ -267,6 +273,11 @@ "failedToApplyCode": "Impossibile applicare il codice", "enterReferralCode": "Inserisci il codice di invito", "codeAppliedPageTitle": "Codice applicato", + "changeYourReferralCode": "Cambia il tuo codice invito", + "change": "Cambia", + "unavailableReferralCode": "Siamo spiacenti, questo codice non è disponibile.", + "codeChangeLimitReached": "Siamo spiacenti, hai raggiunto il limite di modifiche del codice.", + "onlyFamilyAdminCanChangeCode": "Per favore contatta {familyAdminEmail} per cambiare il tuo codice.", "storageInGB": "{storageAmountInGB} GB", "claimed": "Riscattato", "@claimed": { @@ -276,6 +287,7 @@ "claimMore": "Richiedine di più!", "theyAlsoGetXGb": "Anche loro riceveranno {storageAmountInGB} GB", "freeStorageOnReferralSuccess": "{storageAmountInGB} GB ogni volta che qualcuno si iscrive a un piano a pagamento e applica il tuo codice", + "shareTextReferralCode": "Codice invito Ente: {referralCode} \n\nInseriscilo in Impostazioni → Generali → Inviti per ottenere {referralStorageInGB} GB gratis dopo la sottoscrizione a un piano a pagamento\n\nhttps://ente.io", "claimFreeStorage": "Richiedi spazio gratuito", "inviteYourFriends": "Invita i tuoi amici", "failedToFetchReferralDetails": "Impossibile recuperare i dettagli. Per favore, riprova più tardi.", @@ -298,6 +310,7 @@ } }, "faq": "FAQ", + "help": "Aiuto", "oopsSomethingWentWrong": "Oops! Qualcosa è andato storto", "peopleUsingYourCode": "Persone che hanno usato il tuo codice", "eligible": "idoneo", @@ -327,6 +340,7 @@ "removeParticipantBody": "{userEmail} verrà rimosso da questo album condiviso\n\nQualsiasi foto aggiunta dall'utente verrà rimossa dall'album", "keepPhotos": "Mantieni foto", "deletePhotos": "Elimina foto", + "inviteToEnte": "Invita su Ente", "removePublicLink": "Rimuovi link pubblico", "disableLinkMessage": "Questo rimuoverà il link pubblico per accedere a \"{albumName}\".", "sharing": "Condivisione in corso...", @@ -342,7 +356,10 @@ "videoSmallCase": "video", "photoSmallCase": "foto", "singleFileDeleteHighlight": "Verrà eliminato da tutti gli album.", + "singleFileInBothLocalAndRemote": "Questo {fileType} è sia su Ente che sul tuo dispositivo.", + "singleFileInRemoteOnly": "Questo {fileType} verrà eliminato da Ente.", "singleFileDeleteFromDevice": "Questo {fileType} verrà eliminato dal tuo dispositivo.", + "deleteFromEnte": "Elimina da Ente", "yesDelete": "Sì, elimina", "movedToTrash": "Spostato nel cestino", "deleteFromDevice": "Elimina dal dispositivo", @@ -396,6 +413,10 @@ }, "photoGridSize": "Dimensione griglia foto", "manageDeviceStorage": "Gestisci memoria dispositivo", + "mlConsentDescription": "Se abiliti il Machine Learning, Ente estrarrà informazioni come la geometria del volto dai file, inclusi quelli condivisi con te.\n\nQuesto accadrà sul tuo dispositivo, e qualsiasi informazione biometrica generata sarà crittografata end-to-end.", + "mlConsentPrivacy": "Clicca qui per maggiori dettagli su questa funzione nella nostra informativa sulla privacy", + "mlIndexingDescription": "Si prega di notare che l'attivazione dell'apprendimento automatico si tradurrà in un maggior utilizzo della connessione e della batteria fino a quando tutti gli elementi non saranno indicizzati. Valuta di utilizzare l'applicazione desktop per un'indicizzazione più veloce, tutti i risultati verranno sincronizzati automaticamente.", + "loadingModel": "Scaricamento modelli...", "waitingForWifi": "In attesa del WiFi...", "status": "Stato", "indexedItems": "Elementi indicizzati", @@ -430,11 +451,13 @@ "backupOverMobileData": "Backup su dati mobili", "backupVideos": "Backup dei video", "disableAutoLock": "Disabilita blocco automatico", + "deviceLockExplanation": "Disabilita il blocco schermo del dispositivo quando Ente è in primo piano e c'è un backup in corso. Questo normalmente non è necessario ma può aiutare a completare più velocemente grossi caricamenti e l'importazione iniziale di grandi librerie.", "about": "Info", "weAreOpenSource": "Siamo open source!", "privacy": "Privacy", "terms": "Termini d'uso", "checkForUpdates": "Controlla aggiornamenti", + "checkStatus": "Verifica stato", "checking": "Controllo in corso...", "youAreOnTheLatestVersion": "Stai utilizzando l'ultima versione", "account": "Account", @@ -449,6 +472,7 @@ "authToInitiateAccountDeletion": "Autenticati per avviare l'eliminazione dell'account", "areYouSureYouWantToLogout": "Sei sicuro di volerti disconnettere?", "yesLogout": "Sì, disconnetti", + "aNewVersionOfEnteIsAvailable": "Una nuova versione di Ente è disponibile.", "update": "Aggiorna", "installManually": "Installa manualmente", "criticalUpdateAvailable": "Un aggiornamento importante è disponibile", @@ -461,9 +485,13 @@ "backedUpFolders": "Cartelle salvate", "backup": "Backup", "freeUpDeviceSpace": "Libera spazio", + "freeUpDeviceSpaceDesc": "Risparmia spazio sul tuo dispositivo cancellando i file che sono già stati salvati online.", "allClear": "✨ Tutto pulito", "noDeviceThatCanBeDeleted": "Non hai file su questo dispositivo che possono essere eliminati", "removeDuplicates": "Rimuovi i doppioni", + "removeDuplicatesDesc": "Verifica e rimuovi i file che sono esattamente duplicati.", + "viewLargeFiles": "File di grandi dimensioni", + "viewLargeFilesDesc": "Visualizza i file che stanno occupando la maggior parte dello spazio di archiviazione.", "noDuplicates": "✨ Nessun doppione", "youveNoDuplicateFilesThatCanBeCleared": "Non hai file duplicati che possono essere cancellati", "success": "Operazione riuscita", @@ -536,6 +564,7 @@ "systemTheme": "Sistema", "freeTrial": "Prova gratuita", "selectYourPlan": "Seleziona un piano", + "enteSubscriptionPitch": "Ente conserva i tuoi ricordi in modo che siano sempre a disposizione, anche se perdi il tuo dispositivo.", "enteSubscriptionShareWithFamily": "Aggiungi la tua famiglia al tuo piano.", "currentUsageIs": "Spazio attualmente utilizzato ", "@currentUsageIs": { @@ -549,6 +578,8 @@ "renewsOn": "Si rinnova il {endDate}", "freeTrialValidTill": "La prova gratuita termina il {endDate}", "validTill": "Valido fino al {endDate}", + "addOnValidTill": "Il tuo spazio aggiuntivo di {storageAmount} è valido fino al {endDate}", + "playStoreFreeTrialValidTill": "Prova gratuita valida fino al {endDate}.\nIn seguito potrai scegliere un piano a pagamento.", "subWillBeCancelledOn": "L'abbonamento verrà cancellato il {endDate}", "subscription": "Abbonamento", "paymentDetails": "Dettagli di Pagamento", @@ -599,6 +630,7 @@ "appleId": "Apple ID", "playstoreSubscription": "Abbonamento su PlayStore", "appstoreSubscription": "abbonamento AppStore", + "subAlreadyLinkedErrMessage": "Il tuo {id} è già collegato a un altro account Ente.\nSe desideri utilizzare il tuo {id} con questo account, per favore contatta il nostro supporto''", "visitWebToManage": "Visita web.ente.io per gestire il tuo abbonamento", "couldNotUpdateSubscription": "Impossibile aggiornare l'abbonamento", "pleaseContactSupportAndWeWillBeHappyToHelp": "Contatta support@ente.io e saremo felici di aiutarti!", @@ -619,6 +651,7 @@ "thankYou": "Grazie", "failedToVerifyPaymentStatus": "Impossibile verificare lo stato del pagamento", "pleaseWaitForSometimeBeforeRetrying": "Riprova tra qualche minuto", + "paymentFailedMessage": "Purtroppo il tuo pagamento non è riuscito. Contatta l'assistenza e ti aiuteremo!", "youAreOnAFamilyPlan": "Sei un utente con piano famiglia!", "contactFamilyAdmin": "Contatta {familyAdminEmail} per gestire il tuo abbonamento", "leaveFamily": "Abbandona il piano famiglia", @@ -642,7 +675,9 @@ "everywhere": "ovunque", "androidIosWebDesktop": "Android, iOS, Web, Desktop", "mobileWebDesktop": "Mobile, Web, Desktop", + "newToEnte": "Prima volta con Ente", "pleaseLoginAgain": "Effettua nuovamente l'accesso", + "autoLogoutMessage": "A causa di problemi tecnici, sei stato disconnesso. Ci scusiamo per l'inconveniente.", "yourSubscriptionHasExpired": "Il tuo abbonamento è scaduto", "storageLimitExceeded": "Limite d'archiviazione superato", "upgrade": "Acquista altro spazio", @@ -653,10 +688,12 @@ }, "backupFailed": "Backup fallito", "couldNotBackUpTryLater": "Impossibile eseguire il backup dei tuoi dati.\nRiproveremo più tardi.", + "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "Ente può criptare e conservare i file solo se gliene concedi l'accesso", "pleaseGrantPermissions": "Concedi i permessi", "grantPermission": "Concedi il permesso", "privateSharing": "Condivisioni private", "shareOnlyWithThePeopleYouWant": "Condividi solo con le persone che vuoi", + "usePublicLinksForPeopleNotOnEnte": "Usa link pubblici per persone non registrate su Ente", "allowPeopleToAddPhotos": "Permetti alle persone di aggiungere foto", "shareAnAlbumNow": "Condividi un album", "collectEventPhotos": "Raccogli le foto di un evento", @@ -679,6 +716,21 @@ "deleteEmptyAlbumsWithQuestionMark": "Eliminare gli album vuoti?", "deleteAlbumsDialogBody": "Questo eliminerà tutti gli album vuoti. È utile quando si desidera ridurre l'ingombro nella lista degli album.", "deleteProgress": "Eliminazione di {currentlyDeleting} / {totalCount}", + "genericProgress": "Elaborazione {currentlyProcessing} / {totalCount}", + "@genericProgress": { + "description": "Generic progress text to display when processing multiple items", + "type": "text", + "placeholders": { + "currentlyProcessing": { + "example": "1", + "type": "int" + }, + "totalCount": { + "example": "10", + "type": "int" + } + } + }, "permanentlyDelete": "Elimina definitivamente", "canOnlyCreateLinkForFilesOwnedByYou": "Puoi creare solo link per i file di tua proprietà", "publicLinkCreated": "Link pubblico creato", @@ -693,11 +745,13 @@ "unhide": "Mostra", "unarchive": "Rimuovi dall'archivio", "favorite": "Preferito", + "removeFromFavorite": "Rimuovi dai preferiti", "shareLink": "Condividi link", "createCollage": "Crea un collage", "saveCollage": "Salva il collage", "collageSaved": "Collage salvato nella galleria", "collageLayout": "Disposizione", + "addToEnte": "Aggiungi a Ente", "addToAlbum": "Aggiungi all'album", "delete": "Cancella", "hide": "Nascondi", @@ -762,7 +816,10 @@ "photosAddedByYouWillBeRemovedFromTheAlbum": "Le foto aggiunte da te verranno rimosse dall'album", "youveNoFilesInThisAlbumThatCanBeDeleted": "Non hai file in questo album che possono essere eliminati", "youDontHaveAnyArchivedItems": "Non hai nulla di archiviato.", + "ignoredFolderUploadReason": "Alcuni file in questo album vengono ignorati dal caricamento perché erano stati precedentemente eliminati da Ente.", "resetIgnoredFiles": "Ripristina i file ignorati", + "deviceFilesAutoUploading": "I file aggiunti a questo album del dispositivo verranno automaticamente caricati su Ente.", + "turnOnBackupForAutoUpload": "Attiva il backup per caricare automaticamente i file aggiunti a questa cartella del dispositivo su Ente.", "noHiddenPhotosOrVideos": "Nessuna foto o video nascosti", "toHideAPhotoOrVideo": "Per nascondere una foto o un video", "openTheItem": "• Apri la foto o il video", @@ -788,6 +845,7 @@ "close": "Chiudi", "setAs": "Imposta come", "fileSavedToGallery": "File salvato nella galleria", + "filesSavedToGallery": "File salvati nella galleria", "fileFailedToSaveToGallery": "Impossibile salvare il file nella galleria", "download": "Scarica", "pressAndHoldToPlayVideo": "Tieni premuto per riprodurre il video", @@ -890,6 +948,7 @@ "renameFile": "Rinomina file", "enterFileName": "Inserisci un nome per il file", "filesDeleted": "File eliminati", + "selectedFilesAreNotOnEnte": "I file selezionati non sono su Ente", "thisActionCannotBeUndone": "Questa azione non può essere annullata", "emptyTrash": "Vuoi svuotare il cestino?", "permDeleteWarning": "Tutti gli elementi nel cestino verranno eliminati definitivamente\n\nQuesta azione non può essere annullata", @@ -898,6 +957,7 @@ "permanentlyDeleteFromDevice": "Eliminare definitivamente dal dispositivo?", "someOfTheFilesYouAreTryingToDeleteAre": "Alcuni dei file che si sta tentando di eliminare sono disponibili solo sul dispositivo e non possono essere recuperati se cancellati", "theyWillBeDeletedFromAllAlbums": "Verranno eliminati da tutti gli album.", + "someItemsAreInBothEnteAndYourDevice": "Alcuni elementi sono sia su Ente che sul tuo dispositivo.", "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": "Gli elementi selezionati verranno eliminati da tutti gli album e spostati nel cestino.", "theseItemsWillBeDeletedFromYourDevice": "Questi file verranno eliminati dal tuo dispositivo.", "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Sembra che qualcosa sia andato storto. Riprova tra un po'. Se l'errore persiste, contatta il nostro team di supporto.", @@ -933,11 +993,17 @@ "loadMessage7": "Le nostre app per smartphone vengono eseguite in background per crittografare e eseguire il backup di qualsiasi nuova foto o video", "loadMessage8": "web.ente.io ha un uploader intuitivo", "loadMessage9": "Usiamo Xchacha20Poly1305 per crittografare in modo sicuro i tuoi dati", + "photoDescriptions": "Descrizioni delle foto", + "fileTypesAndNames": "Tipi e nomi di file", "location": "Luogo", + "moments": "Momenti", + "searchFaceEmptySection": "Le persone saranno mostrate qui una volta completata l'indicizzazione", "searchDatesEmptySection": "Ricerca per data, mese o anno", "searchLocationEmptySection": "Raggruppa foto scattate entro un certo raggio da una foto", "searchPeopleEmptySection": "Invita persone e vedrai qui tutte le foto condivise da loro", "searchAlbumsEmptySection": "Album", + "searchFileTypesAndNamesEmptySection": "Tipi e nomi di file", + "searchCaptionEmptySection": "Aggiungi descrizioni come \"#viaggio\" nelle informazioni delle foto per trovarle rapidamente qui", "language": "Lingua", "selectLanguage": "Seleziona una lingua", "locationName": "Nome della località", @@ -986,6 +1052,7 @@ "@storageUsageInfo": { "description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used" }, + "availableStorageSpace": "{freeAmount} {storageUnit} liberi", "appVersion": "Versione: {versionValue}", "verifyIDLabel": "Verifica", "fileInfoAddDescHint": "Aggiungi descrizione...", @@ -996,6 +1063,7 @@ }, "setRadius": "Imposta raggio", "familyPlanPortalTitle": "Famiglia", + "familyPlanOverview": "Aggiungi 5 membri della famiglia al tuo piano esistente senza pagare extra.\n\nOgni membro ottiene il proprio spazio privato e non può vedere i file dell'altro a meno che non siano condivisi.\n\nI piani familiari sono disponibili per i clienti che hanno un abbonamento Ente a pagamento.\n\nIscriviti ora per iniziare!", "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." @@ -1073,21 +1141,43 @@ "noAlbumsSharedByYouYet": "Ancora nessun album condiviso da te", "sharedWithYou": "Condivise con te", "sharedByYou": "Condivise da te", + "inviteYourFriendsToEnte": "Invita i tuoi amici a Ente", "failedToDownloadVideo": "Download del video non riuscito", "hiding": "Nascondendo...", "unhiding": "Rimuovendo dal nascondiglio...", "successfullyHid": "Nascosta con successo", "successfullyUnhid": "Rimossa dal nascondiglio con successo", "crashReporting": "Segnalazione di crash", + "resumableUploads": "Caricamenti riattivabili", "addToHiddenAlbum": "Aggiungi ad album nascosto", "moveToHiddenAlbum": "Sposta in album nascosto", + "fileTypes": "Tipi di file", "hearUsWhereTitle": "Come hai sentito parlare di Ente? (opzionale)", "hearUsExplanation": "Non teniamo traccia del numero di installazioni dell'app. Sarebbe utile se ci dicesse dove ci ha trovato!", "viewAddOnButton": "Visualizza componenti aggiuntivi", "addOns": "Componenti aggiuntivi", "addOnPageSubtitle": "Dettagli dei componenti aggiuntivi", + "yourMap": "La tua mappa", + "modifyYourQueryOrTrySearchingFor": "Modifica la tua interrogazione o prova a cercare", + "blackFridaySale": "Offerta del Black Friday", + "photos": "Foto", + "videos": "Video", "searchHint3": "Album, nomi di file e tipi", "searchHint4": "Luogo", + "addYourPhotosNow": "Aggiungi le tue foto ora", + "searchResultCount": "{count, plural, one{{count} risultato trovato} other{{count} risultati trovati}}", + "@searchResultCount": { + "description": "Text to tell user how many results were found for their search query", + "placeholders": { + "count": { + "example": "1|2|3", + "type": "int" + } + } + }, + "faces": "Volti", + "people": "Persone", + "contents": "Contenuti", "addNew": "Aggiungi nuovo", "@addNew": { "description": "Text to add a new item (location tag, album, caption etc)" @@ -1104,6 +1194,29 @@ "selectALocationFirst": "Scegli prima una posizione", "changeLocationOfSelectedItems": "Cambiare la posizione degli elementi selezionati?", "editsToLocationWillOnlyBeSeenWithinEnte": "Le modifiche alla posizione saranno visibili solo all'interno di Ente", + "waitingForVerification": "In attesa di verifica...", + "passkey": "Passkey", + "passkeyAuthTitle": "Verifica della passkey", + "passKeyPendingVerification": "La verifica è ancora in corso", + "loginSessionExpired": "Sessione scaduta", + "loginSessionExpiredDetails": "La sessione è scaduta. Si prega di accedere nuovamente.", + "verifyPasskey": "Verifica passkey", + "playOnTv": "Riproduci album sulla TV", + "pair": "Abbina", + "deviceNotFound": "Dispositivo non trovato", + "castInstruction": "Visita cast.ente.io sul dispositivo che vuoi abbinare.\n\nInserisci il codice qui sotto per riprodurre l'album sulla tua TV.", + "deviceCodeHint": "Inserisci il codice", + "joinDiscord": "Unisciti a Discord", + "locations": "Luoghi", + "descriptions": "Descrizioni", + "addAName": "Aggiungi un nome", + "findPeopleByName": "Trova rapidamente le persone per nome", + "addViewers": "{count, plural, zero {Aggiungi visualizzatore} one {Aggiungi visualizzatore} other {Aggiungi visualizzatori}}", + "addCollaborators": "{count, plural, zero {Aggiungi collaboratore} one {Aggiungi collaboratore} other {Aggiungi collaboratori}}", + "developerSettings": "Impostazioni sviluppatore", + "serverEndpoint": "Endpoint del server", + "invalidEndpoint": "Endpoint invalido", + "invalidEndpointMessage": "Spiacenti, l'endpoint inserito non è valido. Inserisci un endpoint valido e riprova.", "loopVideoOn": "Loop video on", "loopVideoOff": "Loop video off" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_nl.arb b/mobile/lib/l10n/intl_nl.arb index d1db7d56d4..e887b8839c 100644 --- a/mobile/lib/l10n/intl_nl.arb +++ b/mobile/lib/l10n/intl_nl.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "We vinden het jammer je te zien gaan. Deel je feedback om ons te helpen verbeteren.", "feedback": "Feedback", "kindlyHelpUsWithThisInformation": "Help ons alsjeblieft met deze informatie", - "confirmDeletePrompt": "Ja, ik wil permanent mijn account inclusief alle gegevens verwijderen.", + "confirmDeletePrompt": "Ja, ik wil mijn account en de bijbehorende gegevens verspreid over alle apps permanent verwijderen.", "confirmAccountDeletion": "Account verwijderen bevestigen", "deleteAccountPermanentlyButton": "Account permanent verwijderen", "yourAccountHasBeenDeleted": "Je account is verwijderd", @@ -453,6 +453,8 @@ "showMemories": "Toon herinneringen", "yearsAgo": "{count, plural, one{{count} jaar geleden} other{{count} jaar geleden}}", "backupSettings": "Back-up instellingen", + "backupStatus": "Back-upstatus", + "backupStatusDescription": "Items die zijn geback-upt, worden hier getoond", "backupOverMobileData": "Back-up maken via mobiele data", "backupVideos": "Back-up video's", "disableAutoLock": "Automatisch vergrendelen uitschakelen", @@ -496,6 +498,7 @@ "removeDuplicates": "Duplicaten verwijderen", "removeDuplicatesDesc": "Controleer en verwijder bestanden die exacte kopieën zijn.", "viewLargeFiles": "Grote bestanden", + "viewLargeFilesDesc": "Bekijk bestanden die de meeste opslagruimte verbruiken.", "noDuplicates": "✨ Geen duplicaten", "youveNoDuplicateFilesThatCanBeCleared": "Je hebt geen dubbele bestanden die kunnen worden gewist", "success": "Succes", @@ -1314,6 +1317,7 @@ "cl_video_player_description": "Een verfrissende nieuwe videospeler, met betere afspeelknoppen en ondersteuning voor HDR-video's.", "appLockDescriptions": "Kies tussen het standaard vergrendelscherm van uw apparaat en een aangepast vergrendelscherm met een pincode of wachtwoord.", "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Om appvergrendeling in te schakelen, moet u een toegangscode of schermvergrendeling instellen in uw systeeminstellingen.", + "authToViewPasskey": "Verifieer uzelf om uw toegangssleutel te bekijken", "loopVideoOn": "Loop video on", "loopVideoOff": "Loop video off" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_no.arb b/mobile/lib/l10n/intl_no.arb index cfc1df2fa9..f6053b0025 100644 --- a/mobile/lib/l10n/intl_no.arb +++ b/mobile/lib/l10n/intl_no.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "Vi er lei oss for at du forlater oss. Gi oss gjerne en tilbakemelding så vi kan forbedre oss.", "feedback": "Tilbakemelding", "kindlyHelpUsWithThisInformation": "Vær vennlig og hjelp oss med denne informasjonen", - "confirmDeletePrompt": "Ja, jeg ønsker å slette denne kontoen og all dataen dens permanent.", "confirmAccountDeletion": "Bekreft sletting av konto", "deleteAccountPermanentlyButton": "Slett bruker for altid", "yourAccountHasBeenDeleted": "Brukeren din har blitt slettet", diff --git a/mobile/lib/l10n/intl_pl.arb b/mobile/lib/l10n/intl_pl.arb index cf4654be3a..b91803894a 100644 --- a/mobile/lib/l10n/intl_pl.arb +++ b/mobile/lib/l10n/intl_pl.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Przykro nam, że odchodzisz. Wyjaśnij nam, dlaczego nas opuszczasz, aby pomóc ulepszać nasze usługi.", "feedback": "Opinia", "kindlyHelpUsWithThisInformation": "Pomóż nam z tą informacją", - "confirmDeletePrompt": "Tak, chcę trwale usunąć konto i wszystkie dane z nim powiązane.", + "confirmDeletePrompt": "Tak, chcę trwale usunąć to konto i jego dane ze wszystkich aplikacji.", "confirmAccountDeletion": "Potwierdź usunięcie konta", "deleteAccountPermanentlyButton": "Usuń konto na stałe", "yourAccountHasBeenDeleted": "Twoje konto zostało usunięte", @@ -453,6 +453,8 @@ "showMemories": "Pokaż wspomnienia", "yearsAgo": "{count, plural, one{{count} rok temu} few {{count} lata temu} many {{count} lat temu} other{{count} lata temu}}", "backupSettings": "Ustawienia kopii zapasowej", + "backupStatus": "Status kopii zapasowej", + "backupStatusDescription": "Elementy, których kopia zapasowa została utworzona, zostaną wyświetlone w tym miejscu", "backupOverMobileData": "Kopia zapasowa przez dane mobilne", "backupVideos": "Utwórz kopię zapasową wideo", "disableAutoLock": "Wyłącz automatyczną blokadę", diff --git a/mobile/lib/l10n/intl_pt.arb b/mobile/lib/l10n/intl_pt.arb index b57fb95802..be89dd9b24 100644 --- a/mobile/lib/l10n/intl_pt.arb +++ b/mobile/lib/l10n/intl_pt.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Lamentamos ver você partir. Por favor, compartilhe seus comentários para nos ajudar a melhorar.", "feedback": "Comentários", "kindlyHelpUsWithThisInformation": "Ajude-nos com esta informação", - "confirmDeletePrompt": "Sim, desejo excluir permanentemente esta conta e todos os seus dados.", + "confirmDeletePrompt": "Sim, eu quero excluir permanentemente esta conta e seus dados em todos os aplicativos.", "confirmAccountDeletion": "Confirmar exclusão da conta", "deleteAccountPermanentlyButton": "Excluir conta permanentemente", "yourAccountHasBeenDeleted": "Sua conta foi excluída", @@ -453,6 +453,8 @@ "showMemories": "Mostrar memórias", "yearsAgo": "{count, plural, one{{count} anos atrás} other{{count} anos atrás}}", "backupSettings": "Configurações de backup", + "backupStatus": "Status do Backup", + "backupStatusDescription": "Os itens que foram salvos no backup aparecerão aqui", "backupOverMobileData": "Backup usando dados móveis", "backupVideos": "Backup de vídeos", "disableAutoLock": "Desativar bloqueio automático", diff --git a/mobile/lib/l10n/intl_ru.arb b/mobile/lib/l10n/intl_ru.arb index a61e1bd024..b1c12748c9 100644 --- a/mobile/lib/l10n/intl_ru.arb +++ b/mobile/lib/l10n/intl_ru.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Мы сожалеем, что вы уходите. Пожалуйста, объясните, почему вы уходите, чтобы помочь нам развиваться.", "feedback": "Отзыв", "kindlyHelpUsWithThisInformation": "Пожалуйста, помогите нам с этой информацией", - "confirmDeletePrompt": "Да, я хочу навсегда удалить эту учётную запись и все её данные.", + "confirmDeletePrompt": "Да, я хочу навсегда удалить эту учётную запись и все её данные во всех приложениях Ente.", "confirmAccountDeletion": "Подтвердить удаление учётной записи", "deleteAccountPermanentlyButton": "Удалить аккаунт навсегда", "yourAccountHasBeenDeleted": "Ваша учетная запись была удалена", @@ -128,7 +128,7 @@ } } }, - "twofactorSetup": "Установка двуфакторной аутентификации", + "twofactorSetup": "Вход с 2FA", "enterCode": "Введите код", "scanCode": "Сканировать код", "codeCopiedToClipboard": "Код скопирован в буфер обмена", @@ -275,6 +275,7 @@ "codeAppliedPageTitle": "Код применён", "changeYourReferralCode": "Изменить ваш реферальный код", "change": "Изменить", + "unavailableReferralCode": "Извините, такого кода не существует.", "storageInGB": "{storageAmountInGB} Гигабайт", "claimed": "Получено", "@claimed": { @@ -306,8 +307,8 @@ } } }, - "faq": "ЧаВо", - "help": "помощь", + "faq": "Ответы на ваши вопросы", + "help": "Помощь", "oopsSomethingWentWrong": "Ой! Что-то пошло не так", "peopleUsingYourCode": "Люди использующие ваш код", "eligible": "подходящий", @@ -411,7 +412,13 @@ "photoGridSize": "Размер сетки фотографий", "manageDeviceStorage": "Управление хранилищем устройства", "machineLearning": "Machine learning", + "mlConsent": "Включить машинное обучение", + "mlConsentTitle": "Включить машинное обучение?", + "mlConsentDescription": "Если вы включите машинное обучение, Ente будет извлекать информацию из файлов (например, геометрию лица), включая те, которыми с вами поделились.\n\nЭто будет происходить на вашем устройстве, и любая сгенерированная биометрическая информация будет зашифрована с использованием сквозного (End-to-End) шифрования между вашим устройством и сервером.", + "mlConsentPrivacy": "Пожалуйста, нажмите здесь, чтобы узнать больше об этой функции в нашей политике конфиденциальности", + "mlConsentConfirmation": "Я понимаю и хочу включить машинное обучение", "magicSearch": "Волшебный поиск", + "mlIndexingDescription": "Обратите внимание, что машинное обучение приведёт к повышенному потреблению трафика и батареи, пока все элементы не будут проиндексированы. Рекомендуем использовать ПК версию для более быстрого индексирования. Полученные результаты будут синхронизированы автоматически между устройствами.", "loadingModel": "Загрузка моделей...", "waitingForWifi": "Ожидание WiFi...", "status": "Статус", @@ -443,7 +450,7 @@ }, "showMemories": "Показать воспоминания", "yearsAgo": "{count, plural, one{{count} год назад} other{{count} лет назад}}", - "backupSettings": "Резервная копия настроек", + "backupSettings": "Настройки резервного копирования", "backupOverMobileData": "Резервное копирование через мобильную сеть", "backupVideos": "Резервное копирование видео", "disableAutoLock": "Отключить автоблокировку", @@ -461,8 +468,8 @@ "authToChangeYourEmail": "Пожалуйста, авторизуйтесь, чтобы изменить адрес электронной почты", "changePassword": "Изменить пароль", "authToChangeYourPassword": "Пожалуйста, авторизуйтесь, чтобы изменить пароль", - "emailVerificationToggle": "Подтверждение электронной почты", - "authToChangeEmailVerificationSetting": "Авторизуйтесь, чтобы изменить подтверждение электронной почты", + "emailVerificationToggle": "Вход с кодом на почту", + "authToChangeEmailVerificationSetting": "Пожалуйста, войдите, чтобы изменить настройку подтверждения электронной почты", "exportYourData": "Экспорт данных", "logout": "Выйти", "authToInitiateAccountDeletion": "Пожалуйста, авторизуйтесь, чтобы начать удаление аккаунта", @@ -1143,6 +1150,7 @@ "successfullyHid": "Успешно скрыто", "successfullyUnhid": "Успешно показано", "crashReporting": "Отчеты об ошибках", + "resumableUploads": "Поддержка дозагрузки файл(а/ов) при разрыве связи", "addToHiddenAlbum": "Добавить в скрытый альбом", "moveToHiddenAlbum": "Переместить в скрытый альбом", "fileTypes": "Типы файлов", @@ -1266,6 +1274,8 @@ "enable": "Включить", "enabled": "Включено", "moreDetails": "Подробнее", + "enableMLIndexingDesc": "Ente поддерживает машинное обучение на устройстве для распознавания лиц, умного поиска и других расширенных функций поиска", + "magicSearchHint": "Умный поиск позволяет искать фотографии по их содержимому, например, 'цветок', 'красная машина', 'паспорт', 'документы'", "panorama": "Панорама", "reenterPassword": "Подтвердите пароль", "reenterPin": "Введите PIN-код ещё раз", diff --git a/mobile/lib/l10n/intl_sv.arb b/mobile/lib/l10n/intl_sv.arb index 89d190c136..e683130777 100644 --- a/mobile/lib/l10n/intl_sv.arb +++ b/mobile/lib/l10n/intl_sv.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "Vi är ledsna att se dig lämna oss. Vänligen dela dina synpunkter för att hjälpa oss att förbättra.", "feedback": "Feedback", "kindlyHelpUsWithThisInformation": "Vänligen hjälp oss med denna information", - "confirmDeletePrompt": "Ja, jag vill ta bort detta konto och all data permanent.", "confirmAccountDeletion": "Bekräfta radering av konto", "deleteAccountPermanentlyButton": "Radera kontot permanent", "yourAccountHasBeenDeleted": "Ditt konto har raderats", @@ -281,6 +280,7 @@ "description": "Used to indicate storage claimed, like 10GB Claimed" }, "inviteYourFriends": "Bjud in dina vänner", + "help": "Hjälp", "subscribe": "Prenumerera", "trash": "Papperskorg", "photoSmallCase": "foto", diff --git a/mobile/lib/l10n/intl_ta.arb b/mobile/lib/l10n/intl_ta.arb new file mode 100644 index 0000000000..d3d26e203c --- /dev/null +++ b/mobile/lib/l10n/intl_ta.arb @@ -0,0 +1,19 @@ +{ + "@@locale ": "en", + "enterYourEmailAddress": "உங்கள் மின்னஞ்சல் முகவரியை உள்ளிடவும்", + "accountWelcomeBack": "மீண்டும் வருக!", + "email": "மின்னஞ்சல்", + "cancel": "ரத்து செய்", + "verify": "சரிபார்க்கவும்", + "invalidEmailAddress": "தவறான மின்னஞ்சல் முகவரி", + "enterValidEmail": "சரியான மின்னஞ்சல் முகவரியை உள்ளிடவும்.", + "deleteAccount": "கணக்கை நீக்கு", + "askDeleteReason": "உங்கள் கணக்கை நீக்குவதற்கான முக்கிய காரணம் என்ன?", + "deleteAccountFeedbackPrompt": "நீங்கள் வெளியேறுவதை கண்டு வருந்துகிறோம். எங்களை மேம்படுத்த உதவ உங்கள் கருத்தைப் பகிரவும்.", + "feedback": "பின்னூட்டம்", + "kindlyHelpUsWithThisInformation": "இந்த தகவலுடன் தயவுசெய்து எங்களுக்கு உதவுங்கள்", + "confirmDeletePrompt": "ஆம், எல்லா செயலிகளிலும் இந்தக் கணக்கையும் அதன் தரவையும் நிரந்தரமாக நீக்க விரும்புகிறேன்.", + "confirmAccountDeletion": "கணக்கு நீக்குதலை உறுதிப்படுத்தவும்", + "deleteAccountPermanentlyButton": "கணக்கை நிரந்தரமாக நீக்கவும்", + "deleteReason1": "எனக்கு தேவையான ஒரு முக்கிய அம்சம் இதில் இல்லை" +} \ No newline at end of file diff --git a/mobile/lib/l10n/intl_th.arb b/mobile/lib/l10n/intl_th.arb index a0c717ce17..66969bd67e 100644 --- a/mobile/lib/l10n/intl_th.arb +++ b/mobile/lib/l10n/intl_th.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "เราเสียใจที่เห็นคุณไป โปรดแบ่งปันความคิดเห็นของคุณเพื่อช่วยให้เราปรับปรุง", "feedback": "ความคิดเห็น", "kindlyHelpUsWithThisInformation": "กรุณาช่วยเราด้วยข้อมูลนี้", - "confirmDeletePrompt": "ใช่ ฉันต้องการลบบัญชีนี้และข้อมูลที่เกี่ยวข้องทั้งหมดแบบถาวร", "confirmAccountDeletion": "ยืนยันการลบบัญชี", "deleteAccountPermanentlyButton": "ลบบัญชีถาวร", "yourAccountHasBeenDeleted": "บัญชีของคุณถูกลบแล้ว", diff --git a/mobile/lib/l10n/intl_tr.arb b/mobile/lib/l10n/intl_tr.arb index 18c052eb7d..ac14533e01 100644 --- a/mobile/lib/l10n/intl_tr.arb +++ b/mobile/lib/l10n/intl_tr.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "Aramızdan ayrıldığınız için üzgünüz. Lütfen kendimizi geliştirmemize yardımcı olun. Neden ayrıldığınızı Açıklar mısınız.", "feedback": "Geri Bildirim", "kindlyHelpUsWithThisInformation": "Lütfen bu bilgilerle bize yardımcı olun", - "confirmDeletePrompt": "Evet, bu hesabı ve tüm verileri kalıcı olarak silmek istiyorum.", "confirmAccountDeletion": "Hesap silme işlemini onayla", "deleteAccountPermanentlyButton": "Hesabımı kalıcı olarak sil", "yourAccountHasBeenDeleted": "Hesabınız silindi", diff --git a/mobile/lib/l10n/intl_zh.arb b/mobile/lib/l10n/intl_zh.arb index bacd3f7623..d5bcea1820 100644 --- a/mobile/lib/l10n/intl_zh.arb +++ b/mobile/lib/l10n/intl_zh.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "我们很抱歉看到您离开。请分享您的反馈以帮助我们改进。", "feedback": "反馈", "kindlyHelpUsWithThisInformation": "请帮助我们了解这个信息", - "confirmDeletePrompt": "是的,我想永久删除此账户及其相关数据.", + "confirmDeletePrompt": "是的,我想永久删除此账户及其所有关联的应用程序的数据。", "confirmAccountDeletion": "确认删除账户", "deleteAccountPermanentlyButton": "永久删除账户", "yourAccountHasBeenDeleted": "您的账户已删除", @@ -453,6 +453,8 @@ "showMemories": "显示回忆", "yearsAgo": "{count, plural, one{{count} 年前} other{{count} 年前}}", "backupSettings": "备份设置", + "backupStatus": "备份状态", + "backupStatusDescription": "已备份的项目将显示在此处", "backupOverMobileData": "通过移动数据备份", "backupVideos": "备份视频", "disableAutoLock": "禁用自动锁定", diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 09126649df..c2c3a38fa6 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -14,6 +14,7 @@ import 'package:logging/logging.dart'; import "package:media_kit/media_kit.dart"; import 'package:path_provider/path_provider.dart'; import 'package:photos/app.dart'; +import "package:photos/audio_session_handler.dart"; import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/error-reporting/super_logging.dart'; @@ -73,6 +74,10 @@ const kFGTaskDeathTimeoutInMicroseconds = 5000000; void main() async { debugRepaintRainbowEnabled = false; WidgetsFlutterBinding.ensureInitialized(); + //For audio to work on vidoes in iOS when in silent mode. + if (Platform.isIOS) { + unawaited(AudioSessionHandler.setAudioSessionCategory()); + } MediaKit.ensureInitialized(); final savedThemeMode = await AdaptiveTheme.getThemeMode(); diff --git a/mobile/lib/models/backup/backup_item.dart b/mobile/lib/models/backup/backup_item.dart new file mode 100644 index 0000000000..957f0f883c --- /dev/null +++ b/mobile/lib/models/backup/backup_item.dart @@ -0,0 +1,60 @@ +import "dart:async"; + +import "package:photos/models/backup/backup_item_status.dart"; +import "package:photos/models/file/file.dart"; + +class BackupItem { + final BackupItemStatus status; + final EnteFile file; + final int collectionID; + final Completer completer; + final Object? error; + + BackupItem({ + required this.status, + required this.file, + required this.collectionID, + required this.completer, + this.error, + }); + + BackupItem copyWith({ + BackupItemStatus? status, + EnteFile? file, + int? collectionID, + Completer? completer, + Object? error, + }) { + return BackupItem( + status: status ?? this.status, + file: file ?? this.file, + collectionID: collectionID ?? this.collectionID, + completer: completer ?? this.completer, + error: error ?? this.error, + ); + } + + @override + String toString() { + return 'BackupItem(status: $status, file: $file, collectionID: $collectionID, error: $error)'; + } + + @override + bool operator ==(covariant BackupItem other) { + if (identical(this, other)) return true; + + return other.status == status && + other.file == file && + other.collectionID == collectionID && + other.completer == completer && + other.error == error; + } + + @override + int get hashCode { + return status.hashCode ^ + file.hashCode ^ + collectionID.hashCode ^ + completer.hashCode; + } +} diff --git a/mobile/lib/models/backup/backup_item_status.dart b/mobile/lib/models/backup/backup_item_status.dart new file mode 100644 index 0000000000..b4aedfa562 --- /dev/null +++ b/mobile/lib/models/backup/backup_item_status.dart @@ -0,0 +1,7 @@ +enum BackupItemStatus { + inBackground, + inQueue, + uploading, + completed, + retry, +} diff --git a/mobile/lib/ui/home/status_bar_widget.dart b/mobile/lib/ui/home/status_bar_widget.dart index 8df1a90242..461a92a1c9 100644 --- a/mobile/lib/ui/home/status_bar_widget.dart +++ b/mobile/lib/ui/home/status_bar_widget.dart @@ -16,6 +16,7 @@ import 'package:photos/ui/account/verify_recovery_page.dart'; import 'package:photos/ui/components/home_header_widget.dart'; import 'package:photos/ui/components/notification_widget.dart'; import 'package:photos/ui/home/header_error_widget.dart'; +import "package:photos/ui/settings/backup/backup_status_screen.dart"; import 'package:photos/utils/navigation_util.dart'; const double kContainerHeight = 36; @@ -90,7 +91,16 @@ class _StatusBarWidgetState extends State { centerWidget: _showStatus ? _showErrorBanner ? const Text("ente", style: brandStyleMedium) - : const SyncStatusWidget() + : GestureDetector( + onTap: () { + routeToPage( + context, + const BackupStatusScreen(), + forceCustomPageRoute: true, + ).ignore(); + }, + child: const SyncStatusWidget(), + ) : const Text("ente", style: brandStyleMedium), ), _showErrorBanner diff --git a/mobile/lib/ui/settings/backup/backup_item_card.dart b/mobile/lib/ui/settings/backup/backup_item_card.dart new file mode 100644 index 0000000000..40be26cbdc --- /dev/null +++ b/mobile/lib/ui/settings/backup/backup_item_card.dart @@ -0,0 +1,173 @@ +import "dart:typed_data"; + +import 'package:flutter/material.dart'; +import "package:photos/models/backup/backup_item.dart"; +import "package:photos/models/backup/backup_item_status.dart"; +import 'package:photos/theme/ente_theme.dart'; +import "package:photos/utils/file_uploader.dart"; +import "package:photos/utils/thumbnail_util.dart"; + +class BackupItemCard extends StatefulWidget { + const BackupItemCard({ + super.key, + required this.item, + }); + + final BackupItem item; + + @override + State createState() => _BackupItemCardState(); +} + +class _BackupItemCardState extends State { + Uint8List? thumbnail; + String? folderName; + + @override + void initState() { + super.initState(); + _getThumbnail(); + _getFolderName(); + } + + @override + void dispose() { + super.dispose(); + } + + _getThumbnail() async { + thumbnail = await getThumbnail(widget.item.file); + setState(() {}); + } + + _getFolderName() async { + folderName = widget.item.file.deviceFolder ?? ''; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return Container( + height: 60, + margin: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Theme.of(context).brightness == Brightness.light + ? const Color(0xFF000000).withOpacity(0.08) + : const Color(0xFFFFFFFF).withOpacity(0.08), + width: 1, + ), + ), + child: Row( + children: [ + SizedBox( + width: 60, + height: 60, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: thumbnail != null + ? Image.memory( + thumbnail!, + fit: BoxFit.cover, + ) + : const SizedBox(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.item.file.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + height: 20 / 16, + color: Theme.of(context).brightness == Brightness.light + ? const Color(0xFF000000) + : const Color(0xFFFFFFFF), + ), + ), + const SizedBox(height: 4), + Text( + folderName ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + height: 17 / 14, + color: Theme.of(context).brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, 0.7) + : const Color.fromRGBO(255, 255, 255, 0.7), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + height: 48, + width: 48, + child: Center( + child: switch (widget.item.status) { + BackupItemStatus.uploading => SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2.0, + color: colorScheme.primary700, + ), + ), + BackupItemStatus.completed => const SizedBox( + width: 24, + height: 24, + child: Icon( + Icons.check, + color: Color(0xFF00B33C), + ), + ), + BackupItemStatus.inQueue => SizedBox( + width: 24, + height: 24, + child: Icon( + Icons.history, + color: Theme.of(context).brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, .6) + : const Color.fromRGBO(255, 255, 255, .6), + ), + ), + BackupItemStatus.retry => IconButton( + icon: const Icon( + Icons.sync, + color: Color(0xFFFDB816), + ), + onPressed: () async { + await FileUploader.instance.upload( + widget.item.file, + widget.item.collectionID, + ); + }, + ), + BackupItemStatus.inBackground => SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2.0, + color: Theme.of(context).brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, .6) + : const Color.fromRGBO(255, 255, 255, .6), + ), + ), + }, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/ui/settings/backup/backup_status_screen.dart b/mobile/lib/ui/settings/backup/backup_status_screen.dart new file mode 100644 index 0000000000..0d7199ca47 --- /dev/null +++ b/mobile/lib/ui/settings/backup/backup_status_screen.dart @@ -0,0 +1,111 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import "dart:collection"; + +import 'package:flutter/material.dart'; +import "package:photos/core/event_bus.dart"; +import "package:photos/events/backup_updated_event.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/backup/backup_item.dart"; +import 'package:photos/ui/components/title_bar_title_widget.dart'; +import 'package:photos/ui/components/title_bar_widget.dart'; +import "package:photos/ui/settings/backup/backup_item_card.dart"; +import "package:photos/utils/file_uploader.dart"; + +class BackupStatusScreen extends StatefulWidget { + const BackupStatusScreen({super.key}); + + @override + State createState() => _BackupStatusScreenState(); +} + +class _BackupStatusScreenState extends State { + LinkedHashMap items = FileUploader.instance.allBackups; + + @override + void initState() { + super.initState(); + + checkBackupUpdatedEvent(); + } + + void checkBackupUpdatedEvent() { + Bus.instance.on().listen((event) { + items = event.items; + setState(() {}); + }); + } + + @override + Widget build(BuildContext context) { + final List items = this.items.values.toList(); + + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: S.of(context).backupStatus, + ), + ), + items.isEmpty + ? SliverFillRemaining( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 60, + vertical: 12, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_upload_outlined, + color: + Theme.of(context).brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, 0.6) + : const Color.fromRGBO(255, 255, 255, 0.6), + ), + const SizedBox(height: 16), + Text( + S.of(context).backupStatusDescription, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + height: 20 / 16, + color: + Theme.of(context).brightness == Brightness.light + ? const Color(0xFF000000).withOpacity(0.7) + : const Color(0xFFFFFFFF).withOpacity(0.7), + ), + ), + const SizedBox(height: 48), + ], + ), + ), + ) + : SliverList( + delegate: SliverChildBuilderDelegate( + (delegateBuildContext, index) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 16, + ), + child: ListView.builder( + shrinkWrap: true, + primary: false, + itemBuilder: (context, index) { + return BackupItemCard(item: items[index]); + }, + itemCount: items.length, + ), + ); + }, + childCount: 1, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/utils/file_uploader.dart b/mobile/lib/utils/file_uploader.dart index 6e81e5acc5..0b3ee1c4bc 100644 --- a/mobile/lib/utils/file_uploader.dart +++ b/mobile/lib/utils/file_uploader.dart @@ -16,11 +16,14 @@ import 'package:photos/core/event_bus.dart'; import 'package:photos/core/network/network.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/db/upload_locks_db.dart'; +import "package:photos/events/backup_updated_event.dart"; import "package:photos/events/file_uploaded_event.dart"; import 'package:photos/events/files_updated_event.dart'; import 'package:photos/events/local_photos_updated_event.dart'; import 'package:photos/events/subscription_purchased_event.dart'; import 'package:photos/main.dart'; +import "package:photos/models/backup/backup_item.dart"; +import "package:photos/models/backup/backup_item_status.dart"; import 'package:photos/models/encryption_result.dart'; import 'package:photos/models/file/file.dart'; import 'package:photos/models/file/file_type.dart'; @@ -59,11 +62,15 @@ class FileUploader { final _enteDio = NetworkClient.instance.enteDio; final LinkedHashMap _queue = LinkedHashMap(); + final LinkedHashMap _allBackups = + LinkedHashMap(); final _uploadLocks = UploadLocksDB.instance; final kSafeBufferForLockExpiry = const Duration(days: 1).inMicroseconds; final kBGTaskDeathTimeout = const Duration(seconds: 5).inMicroseconds; final _uploadURLs = Queue(); + LinkedHashMap get allBackups => _allBackups; + // Maintains the count of files in the current upload session. // Upload session is the period between the first entry into the _queue and last entry out of the _queue int _totalCountInUploadSession = 0; @@ -160,6 +167,13 @@ class FileUploader { if (!_queue.containsKey(localID)) { final completer = Completer(); _queue[localID] = FileUploadItem(file, collectionID, completer); + _allBackups[localID] = BackupItem( + status: BackupItemStatus.inQueue, + file: file, + collectionID: collectionID, + completer: completer, + ); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); _pollQueue(); return completer.future; } @@ -203,6 +217,11 @@ class FileUploader { }); for (final id in uploadsToBeRemoved) { _queue.remove(id)?.completer.completeError(reason); + _allBackups[id] = _allBackups[id]!.copyWith( + status: BackupItemStatus.retry, + error: reason, + ); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); } _totalCountInUploadSession = 0; } @@ -225,6 +244,9 @@ class FileUploader { }); for (final id in uploadsToBeRemoved) { _queue.remove(id)?.completer.completeError(reason); + _allBackups[id] = _allBackups[id]! + .copyWith(status: BackupItemStatus.retry, error: reason); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); } _logger.info( 'number of enteries removed from queue ${uploadsToBeRemoved.length}', @@ -262,6 +284,10 @@ class FileUploader { } if (pendingEntry != null) { pendingEntry.status = UploadStatus.inProgress; + _allBackups[pendingEntry.file.localID!] = + _allBackups[pendingEntry.file.localID]! + .copyWith(status: BackupItemStatus.uploading); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); _encryptAndUploadFileToCollection( pendingEntry.file, pendingEntry.collectionID, @@ -291,13 +317,22 @@ class FileUploader { }, ); _queue.remove(localID)!.completer.complete(uploadedFile); + _allBackups[localID] = + _allBackups[localID]!.copyWith(status: BackupItemStatus.completed); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); return uploadedFile; } catch (e) { if (e is LockAlreadyAcquiredError) { _queue[localID]!.status = UploadStatus.inBackground; + _allBackups[localID] = _allBackups[localID]! + .copyWith(status: BackupItemStatus.inBackground); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); return _queue[localID]!.completer.future; } else { _queue.remove(localID)!.completer.completeError(e); + _allBackups[localID] = _allBackups[localID]! + .copyWith(status: BackupItemStatus.retry, error: e); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); return null; } } finally { @@ -406,7 +441,26 @@ class FileUploader { Future forceUpload(EnteFile file, int collectionID) async { _hasInitiatedForceUpload = true; - return _tryToUpload(file, collectionID, true); + final isInQueue = _allBackups[file.localID!] != null; + try { + final result = await _tryToUpload(file, collectionID, true); + if (isInQueue) { + _allBackups[file.localID!] = _allBackups[file.localID]!.copyWith( + status: BackupItemStatus.completed, + ); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); + } + return result; + } catch (error) { + if (isInQueue) { + _allBackups[file.localID!] = _allBackups[file.localID]!.copyWith( + status: BackupItemStatus.retry, + error: error, + ); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); + } + rethrow; + } } Future _tryToUpload( @@ -426,6 +480,14 @@ class FileUploader { return fileOnDisk; } } + + if (_allBackups[file.localID!] != null && + _allBackups[file.localID]!.status != BackupItemStatus.uploading) { + _allBackups[file.localID!] = _allBackups[file.localID]!.copyWith( + status: BackupItemStatus.uploading, + ); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); + } if ((file.localID ?? '') == '') { _logger.severe('Trying to upload file with missing localID'); return file; @@ -442,7 +504,7 @@ class FileUploader { } final String lockKey = file.localID!; - bool _isMultipartUpload = false; + bool isMultipartUpload = false; try { await _uploadLocks.acquireLock( @@ -589,7 +651,7 @@ class FileUploader { final fileUploadURL = await _getUploadURL(); fileObjectKey = await _putFile(fileUploadURL, encryptedFile); } else { - _isMultipartUpload = true; + isMultipartUpload = true; _logger.finest( "Init multipartUpload $multipartEntryExists, isUpdate $isUpdatedFile", ); @@ -757,7 +819,7 @@ class FileUploader { encryptedFilePath, encryptedThumbnailPath, lockKey: lockKey, - isMultiPartUpload: _isMultipartUpload, + isMultiPartUpload: isMultipartUpload, ); } } @@ -1280,10 +1342,17 @@ class FileUploader { if (dbFile?.uploadedFileID != null) { _logger.info("Background upload success detected"); completer?.complete(dbFile); + _allBackups[upload.key] = _allBackups[upload.key]! + .copyWith(status: BackupItemStatus.completed); } else { _logger.info("Background upload failure detected"); completer?.completeError(SilentlyCancelUploadsError()); + _allBackups[upload.key] = _allBackups[upload.key]!.copyWith( + status: BackupItemStatus.retry, + error: SilentlyCancelUploadsError(), + ); } + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); } } Future.delayed(kBlockedUploadsPollFrequency, () async { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 0223bc5236..7388ae92f2 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1297,18 +1297,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: @@ -1441,10 +1441,10 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.8.0" media_extension: dependency: "direct main" description: @@ -1529,10 +1529,10 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.12.0" mgrs_dart: dependency: transitive description: @@ -1901,10 +1901,10 @@ packages: dependency: transitive description: name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.4" plugin_platform_interface: dependency: transitive description: @@ -2410,26 +2410,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e" + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" url: "https://pub.dev" source: hosted - version: "1.25.7" + version: "1.25.2" test_api: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.0" test_core: dependency: transitive description: name: test_core - sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696" + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "0.6.0" timezone: dependency: transitive description: @@ -2708,10 +2708,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.2.1" volume_controller: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b13a3237f0..9188a87ea9 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.9.30+930 +version: 0.9.32+932 publish_to: none environment: diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index 234b0435bf..531b720ee3 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/ente/base" "github.com/ente-io/museum/pkg/controller/file_copy" "github.com/ente-io/museum/pkg/controller/filedata" "net/http" @@ -361,7 +362,14 @@ func main() { server.Use(p.HandlerFunc()) // note: the recover middleware must be in the last - server.Use(requestid.New(), middleware.Logger(urlSanitizer), cors(), gzip.Gzip(gzip.DefaultCompression), middleware.PanicRecover()) + + server.Use(requestid.New( + requestid.Config{ + Generator: func() string { + return base.ServerReqID() + }, + }), + middleware.Logger(urlSanitizer), cors(), gzip.Gzip(gzip.DefaultCompression), middleware.PanicRecover()) publicAPI := server.Group("/") publicAPI.Use(rateLimiter.GlobalRateLimiter(), rateLimiter.APIRateLimitMiddleware(urlSanitizer)) diff --git a/server/ente/base/id.go b/server/ente/base/id.go index f6579a05c6..559bc41542 100644 --- a/server/ente/base/id.go +++ b/server/ente/base/id.go @@ -3,6 +3,7 @@ package base import ( "errors" "fmt" + "github.com/google/uuid" "github.com/matoous/go-nanoid/v2" ) @@ -28,3 +29,12 @@ func NewID(prefix string) (*string, error) { result := fmt.Sprintf("%s_%s", prefix, id) return &result, nil } + +func ServerReqID() string { + // Generate a nanoid with a custom alphabet and length of 22 + id, err := NewID("ser") + if err != nil { + return "ser_" + uuid.New().String() + } + return *id +} diff --git a/web/apps/photos/src/components/Collections/CollectionSelector/AddCollectionButton.tsx b/web/apps/photos/src/components/Collections/CollectionSelector/AddCollectionButton.tsx index 87e698361d..0b2245744c 100644 --- a/web/apps/photos/src/components/Collections/CollectionSelector/AddCollectionButton.tsx +++ b/web/apps/photos/src/components/Collections/CollectionSelector/AddCollectionButton.tsx @@ -23,9 +23,7 @@ export default function AddCollectionButton({ showNextModal }: Iprops) { onClick={() => showNextModal()} coverFile={null} > - - {t("CREATE_COLLECTION")} - + {t("create_albums")} + diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index c6abe7226f..d2f921b532 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -4,10 +4,10 @@ import { faceCrop, wipClusterDebugPageContents, type ClusterDebugPageContents, - type FaceFileNeighbour, - type FaceFileNeighbours, } from "@/new/photos/services/ml"; -import type { Face } from "@/new/photos/services/ml/face"; +import { type ClusteringOpts } from "@/new/photos/services/ml/cluster"; +import { faceDirection, type Face } from "@/new/photos/services/ml/face"; +import type { EnteFile } from "@/new/photos/types/file"; import { FlexWrapper, FluidContainer, @@ -15,56 +15,52 @@ import { } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; import BackButton from "@mui/icons-material/ArrowBackOutlined"; -import { Box, IconButton, styled, Typography } from "@mui/material"; +import { + Box, + Button, + IconButton, + MenuItem, + Stack, + styled, + TextField, + Typography, +} from "@mui/material"; +import { useFormik } from "formik"; import { useRouter } from "next/router"; import { AppContext } from "pages/_app"; import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; import AutoSizer from "react-virtualized-auto-sizer"; -import { VariableSizeList } from "react-window"; +import { + areEqual, + VariableSizeList, + type ListChildComponentProps, +} from "react-window"; // TODO-Cluster Temporary component for debugging export default function ClusterDebug() { const { startLoading, finishLoading, showNavBar } = useContext(AppContext); + const [clusterRes, setClusterRes] = useState< ClusterDebugPageContents | undefined >(); - useEffect(() => { - showNavBar(true); - cluster(); - }, []); - - const cluster = async () => { + const cluster = async (opts: ClusteringOpts) => { + setClusterRes(undefined); startLoading(); - setClusterRes(await wipClusterDebugPageContents()); + setClusterRes(await wipClusterDebugPageContents(opts)); finishLoading(); }; - if (!clusterRes) { - return ( - - - - ); - } + useEffect(() => showNavBar(true), []); + return ( <> - - {`${clusterRes.clusters.length} clusters`} - - - Showing only upto first 30 faces (and only upto 30 nearest - neighbours of each). - -
{({ height, width }) => ( - + + + )} @@ -84,7 +80,7 @@ const Options: React.FC = () => { - {pt("Faces")} + {pt("Face Clusters")} ); @@ -101,19 +97,122 @@ const Container = styled("div")` } `; -interface ClusterPhotoListProps { - height: number; - width: number; - clusterRes: ClusterDebugPageContents; +interface OptionsFormProps { + onCluster: (opts: ClusteringOpts) => Promise; } -const ClusterPhotoList: React.FC = ({ - height, +const OptionsForm: React.FC = ({ onCluster }) => { + // Formik converts nums to a string on edit. + const toFloat = (n: number | string) => + typeof n == "string" ? parseFloat(n) : n; + + const { values, handleSubmit, handleChange, isSubmitting } = + useFormik({ + initialValues: { + method: "linear", + minBlur: 10, + minScore: 0.8, + minClusterSize: 2, + joinThreshold: 0.7, + batchSize: 12500, + }, + onSubmit: (values) => + onCluster({ + method: values.method, + minBlur: toFloat(values.minBlur), + minScore: toFloat(values.minScore), + minClusterSize: toFloat(values.minClusterSize), + joinThreshold: toFloat(values.joinThreshold), + batchSize: toFloat(values.batchSize), + }), + }); + + return ( +
+ + Parameters + + + {["hdbscan", "linear"].map((v) => ( + + {v} + + ))} + + + + + + + + + + + {isSubmitting && } + +
+ ); +}; + +type ClusterListProps = ClusterResHeaderProps & { + height: number; + width: number; +}; + +const ClusterList: React.FC> = ({ width, + height, clusterRes, + children, }) => { - const { faceFNs, clusterIDForFaceID } = clusterRes; - const [itemList, setItemList] = useState([]); + const [items, setItems] = useState([]); const listRef = useRef(null); const columns = useMemo( @@ -125,78 +224,67 @@ const ClusterPhotoList: React.FC = ({ const listItemHeight = 120 * shrinkRatio + 24 + 4; useEffect(() => { - setItemList(itemListFromFaceFNs(faceFNs, columns)); - }, [columns, faceFNs]); + setItems(clusterRes ? itemsFromClusterRes(clusterRes, columns) : []); + }, [columns, clusterRes]); useEffect(() => { listRef.current?.resetAfterIndex(0); - }, [itemList]); + }, [items]); - const getItemSize = (i: number) => - Array.isArray(itemList[i]) ? listItemHeight : 36; - - const generateKey = (i: number) => - Array.isArray(itemList[i]) - ? `${itemList[i][0].enteFile.id}/${itemList[i][0].face.faceID}-${itemList[i].slice(-1)[0].enteFile.id}/${itemList[i].slice(-1)[0].face.faceID}-${i}` - : `${itemList[i].faceID}-${i}`; + const itemSize = (index: number) => + index === 0 + ? 140 + : index === 1 + ? 130 + : Array.isArray(items[index - 2]) + ? listItemHeight + : 36; return ( - {({ index, style, data }) => { - const { itemList, columns, shrinkRatio } = data; - const item = itemList[index]; - return ( - - - {!Array.isArray(item) ? ( - - {`score ${item.score.toFixed(2)} blur ${item.blur.toFixed(0)}`} - - ) : ( - item.map((faceFN, i) => ( - - )) - )} - - - ); - }} + {ClusterListItemRenderer} ); }; -type ItemListItem = Face | FaceFileNeighbour[]; +type Item = string | FaceWithFile[]; -const itemListFromFaceFNs = ( - faceFNs: FaceFileNeighbours[], +const itemsFromClusterRes = ( + clusterRes: ClusterDebugPageContents, columns: number, ) => { - const result: ItemListItem[] = []; - for (let index = 0; index < faceFNs.length; index++) { - const { face, neighbours } = faceFNs[index]; - result.push(face); + const { clusterPreviewsWithFile, unclusteredFacesWithFile } = clusterRes; + + const result: Item[] = []; + for (let index = 0; index < clusterPreviewsWithFile.length; index++) { + const { clusterSize, faces } = clusterPreviewsWithFile[index]; + result.push(`cluster size ${clusterSize.toFixed(2)}`); let lastIndex = 0; - while (lastIndex < neighbours.length) { - result.push(neighbours.slice(lastIndex, lastIndex + columns)); + while (lastIndex < faces.length) { + result.push(faces.slice(lastIndex, lastIndex + columns)); lastIndex += columns; } } + + if (unclusteredFacesWithFile.length) { + result.push(`•• unclustered faces ${unclusteredFacesWithFile.length}`); + let lastIndex = 0; + while (lastIndex < unclusteredFacesWithFile.length) { + result.push( + unclusteredFacesWithFile.slice(lastIndex, lastIndex + columns), + ); + lastIndex += columns; + } + } + return result; }; @@ -209,67 +297,91 @@ const getShrinkRatio = (width: number, columns: number) => (width - 2 * getGapFromScreenEdge(width) - (columns - 1) * 4) / (columns * 120); -interface FaceItemProps { - faceFN: FaceFileNeighbour; - clusterIDForFaceID: Map; +// It in necessary to define the item renderer otherwise it gets recreated every +// time the parent rerenders, causing the form to lose its submitting state. +const ClusterListItemRenderer = React.memo( + ({ index, style, data }) => { + const { clusterRes, columns, shrinkRatio, items, children } = data; + + if (index == 0) return
{children}
; + + if (index == 1) + return ( +
+ +
+ ); + + const item = items[index - 2]; + return ( + + + {!Array.isArray(item) ? ( + {item} + ) : ( + item.map((f, i) => ( + + )) + )} + + + ); + }, + areEqual, +); + +interface ClusterResHeaderProps { + clusterRes: ClusterDebugPageContents | undefined; } -const FaceItem: React.FC = ({ faceFN, clusterIDForFaceID }) => { - const { face, enteFile, cosineSimilarity } = faceFN; - const { faceID } = face; +const ClusterResHeader: React.FC = ({ clusterRes }) => { + if (!clusterRes) return null; - const [objectURL, setObjectURL] = useState(); - - useEffect(() => { - let didCancel = false; - let thisObjectURL: string | undefined; - - void faceCrop(faceID, enteFile).then((blob) => { - if (blob && !didCancel) - setObjectURL((thisObjectURL = URL.createObjectURL(blob))); - }); - - return () => { - didCancel = true; - if (thisObjectURL) URL.revokeObjectURL(thisObjectURL); - }; - }, [faceID, enteFile]); + const { + totalFaceCount, + filteredFaceCount, + clusteredFaceCount, + unclusteredFaceCount, + timeTakenMs, + clusters, + } = clusterRes; return ( - - {objectURL && ( - - )} - - {cosineSimilarity.toFixed(2)} + + + {`${clusters.length} clusters in ${(timeTakenMs / 1000).toFixed(0)} seconds • ${totalFaceCount} faces ${filteredFaceCount} filtered ${clusteredFaceCount} clustered ${unclusteredFaceCount} unclustered`} - + + Showing only top 30 clusters, bottom 30 clusters, and + unclustered faces. + + + For each cluster showing only up to 50 faces, sorted by cosine + similarity to highest scoring face in the cluster. + + + Below each face is its{" "} + blur - score - cosineSimilarity - direction. + + + Faces added to the cluster as a result of next batch merging are + outlined. + + ); }; -const FaceChip = styled(Box)` - width: 120px; - height: 120px; +const Loader = () => ( + + + +); + +const ListItem = styled("div")` + display: flex; + justify-content: center; `; -const outlineForCluster = (clusterID: string | undefined) => - clusterID ? `1px solid oklch(0.8 0.2 ${hForID(clusterID)})` : undefined; - -const hForID = (id: string) => - ([...id].reduce((s, c) => s + c.charCodeAt(0), 0) % 10) * 36; - const ListContainer = styled(Box, { shouldForwardProp: (propName) => propName != "shrinkRatio", })<{ @@ -293,7 +405,78 @@ const LabelContainer = styled(ListItemContainer)` height: 32px; `; -const ListItem = styled("div")` - display: flex; - justify-content: center; +interface FaceItemProps { + faceWithFile: FaceWithFile; +} + +interface FaceWithFile { + face: Face; + enteFile: EnteFile; + cosineSimilarity?: number; + wasMerged?: boolean; +} + +const FaceItem: React.FC = ({ faceWithFile }) => { + const { face, enteFile, cosineSimilarity, wasMerged } = faceWithFile; + const { faceID } = face; + + const [objectURL, setObjectURL] = useState(); + + useEffect(() => { + let didCancel = false; + let thisObjectURL: string | undefined; + + void faceCrop(faceID, enteFile).then((blob) => { + if (blob && !didCancel) + setObjectURL((thisObjectURL = URL.createObjectURL(blob))); + }); + + return () => { + didCancel = true; + if (thisObjectURL) URL.revokeObjectURL(thisObjectURL); + }; + }, [faceID, enteFile]); + + const fd = faceDirection(face.detection); + const d = fd == "straight" ? "•" : fd == "left" ? "←" : "→"; + return ( + + {objectURL && ( + + )} + + + {`b${face.blur.toFixed(0)} `} + + + {`s${face.score.toFixed(1)}`} + + {cosineSimilarity && ( + + {`c${cosineSimilarity.toFixed(1)}`} + + )} + + {`d${d}`} + + + + ); +}; + +const FaceChip = styled(Box)` + width: 120px; + height: 120px; `; diff --git a/web/apps/photos/src/pages/gallery/index.tsx b/web/apps/photos/src/pages/gallery/index.tsx index 1876fca7f4..74273eb014 100644 --- a/web/apps/photos/src/pages/gallery/index.tsx +++ b/web/apps/photos/src/pages/gallery/index.tsx @@ -6,10 +6,7 @@ import { getLocalFiles, getLocalTrashedFiles, } from "@/new/photos/services/files"; -import { - wipClusterEnable, - wipHasSwitchedOnceCmpAndSet, -} from "@/new/photos/services/ml"; +import { wipHasSwitchedOnceCmpAndSet } from "@/new/photos/services/ml"; import { EnteFile } from "@/new/photos/types/file"; import { mergeMetadata } from "@/new/photos/utils/file"; import { CenteredFlex } from "@ente/shared/components/Container"; @@ -677,11 +674,8 @@ export default function Gallery() { // TODO-Cluster if (process.env.NEXT_PUBLIC_ENTE_WIP_CL_AUTO) { setTimeout(() => { - if (!wipHasSwitchedOnceCmpAndSet()) { - void wipClusterEnable().then( - (y) => y && router.push("cluster-debug"), - ); - } + if (!wipHasSwitchedOnceCmpAndSet()) + router.push("cluster-debug"); }, 2000); } }, []); diff --git a/web/packages/base/locales/ar-SA/translation.json b/web/packages/base/locales/ar-SA/translation.json index 968e1627bd..d4d7e31068 100644 --- a/web/packages/base/locales/ar-SA/translation.json +++ b/web/packages/base/locales/ar-SA/translation.json @@ -33,14 +33,15 @@ "ENTER_ENC_PASSPHRASE": "الرجاء إدخال كلمة المرور التي يمكننا استخدامها لتشفير بياناتك", "PASSPHRASE_DISCLAIMER": "نحن لا نخزن كلمة مرورك، لذا إذا نسيتها، لن نتمكن من مساعدتك في استرداد بياناتك دون مفتاح الاسترداد.", "WELCOME_TO_ENTE_HEADING": "مرحبا بك في ", - "WELCOME_TO_ENTE_SUBHEADING": "", - "WHERE_YOUR_BEST_PHOTOS_LIVE": "", + "WELCOME_TO_ENTE_SUBHEADING": "تخزين الصور ومشاركتها بشكل مشفر من طرف إلى طرف", + "WHERE_YOUR_BEST_PHOTOS_LIVE": "أين تعيش أفضل صورك", "KEY_GENERATION_IN_PROGRESS_MESSAGE": "جار توليد مفاتيح التشفير...", "PASSPHRASE_HINT": "كلمة المرور", "CONFIRM_PASSPHRASE": "تأكيد كلمة المرور", "REFERRAL_CODE_HINT": "كيف سمعت عن Ente؟ (اختياري)", - "REFERRAL_INFO": "", + "REFERRAL_INFO": "نحن لا نتتبع عمليات تثبيت التطبيق، سيكون من المفيد لنا أن تخبرنا أين وجدتنا!", "PASSPHRASE_MATCH_ERROR": "كلمات المرور غير متطابقة", + "create_albums": "", "CREATE_COLLECTION": "ألبوم جديد", "ENTER_ALBUM_NAME": "اسم الألبوم", "CLOSE_OPTION": "إغلاق (Esc)", @@ -58,10 +59,10 @@ "FILE_UPLOAD": "تحميل الملف", "UPLOAD_STAGE_MESSAGE": { "0": "الإعداد للتحميل", - "1": "", - "2": "", - "3": "", - "4": "", + "1": "قراءة ملفات بيانات تعريف جوجل", + "2": "{{uploadCounter.finished, number}} / {{uploadCounter.total, number}} البيانات الملفات الوصفية المستخرجة", + "3": "{{uploadCounter.finished, number}} / {{uploadCounter.total, number}} ملفات معالجة", + "4": "إلغاء التحميلات المتبقية", "5": "اكتمل النسخ الاحتياطي" }, "FILE_NOT_UPLOADED_LIST": "لم يتم تحميل الملفات التالية", @@ -71,227 +72,227 @@ "ACCOUNT_EXISTS": "لديك حساب بالفعل", "CREATE": "إنشاء", "DOWNLOAD": "تنزيل", - "DOWNLOAD_OPTION": "", - "DOWNLOAD_FAVORITES": "", - "DOWNLOAD_UNCATEGORIZED": "", - "DOWNLOAD_HIDDEN_ITEMS": "", - "COPY_OPTION": "", - "TOGGLE_FULLSCREEN": "", - "ZOOM_IN_OUT": "", - "PREVIOUS": "", - "NEXT": "", - "title_photos": "", - "title_auth": "", - "title_accounts": "", - "UPLOAD_FIRST_PHOTO": "", - "IMPORT_YOUR_FOLDERS": "", - "UPLOAD_DROPZONE_MESSAGE": "", + "DOWNLOAD_OPTION": "تنزيل (D)", + "DOWNLOAD_FAVORITES": "تنزيل المفضلات", + "DOWNLOAD_UNCATEGORIZED": "التنزيل غير المصنف", + "DOWNLOAD_HIDDEN_ITEMS": "تنزيل العناصر المخفية", + "COPY_OPTION": "نسخ كـ PNG (Ctrl/Cmd - C)", + "TOGGLE_FULLSCREEN": "تبديل ملء الشاشة (F)", + "ZOOM_IN_OUT": "تكبير/تصغير", + "PREVIOUS": "السابق (←)", + "NEXT": "التالي (→)", + "title_photos": "صور Ente", + "title_auth": "مصادقة Ente", + "title_accounts": "حسابات Ente", + "UPLOAD_FIRST_PHOTO": "تحميل صورتك الأولى", + "IMPORT_YOUR_FOLDERS": "استيراد مجلداتك", + "UPLOAD_DROPZONE_MESSAGE": "إسقاط للنسخ الاحتياطي للملفاتك", "WATCH_FOLDER_DROPZONE_MESSAGE": "", - "TRASH_FILES_TITLE": "", - "TRASH_FILE_TITLE": "", - "DELETE_FILES_TITLE": "", - "DELETE_FILES_MESSAGE": "", - "DELETE": "", - "DELETE_OPTION": "", - "FAVORITE_OPTION": "", - "UNFAVORITE_OPTION": "", - "MULTI_FOLDER_UPLOAD": "", - "UPLOAD_STRATEGY_CHOICE": "", - "UPLOAD_STRATEGY_SINGLE_COLLECTION": "", - "OR": "", - "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "", - "SESSION_EXPIRED_MESSAGE": "", - "SESSION_EXPIRED": "", - "PASSWORD_GENERATION_FAILED": "", - "CHANGE_PASSWORD": "", - "password_changed_elsewhere": "", - "password_changed_elsewhere_message": "", - "GO_BACK": "", - "RECOVERY_KEY": "", - "SAVE_LATER": "", - "SAVE": "", + "TRASH_FILES_TITLE": "حذف الملفات؟", + "TRASH_FILE_TITLE": "حذف الملف؟", + "DELETE_FILES_TITLE": "حذف فورا؟", + "DELETE_FILES_MESSAGE": "سيتم حذف الملفات المحددة نهائيا من حساب Ente الخاص بك.", + "DELETE": "حذف", + "DELETE_OPTION": "حذف (DEL)", + "FAVORITE_OPTION": "مفضلة (L)", + "UNFAVORITE_OPTION": "غير مفضلة (L)", + "MULTI_FOLDER_UPLOAD": "تم اكتشاف مجلدات متعددة", + "UPLOAD_STRATEGY_CHOICE": "هل ترغب في تحميلهم إلى", + "UPLOAD_STRATEGY_SINGLE_COLLECTION": "ألبوم واحد", + "OR": "أو", + "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "ألبومات منفصلة", + "SESSION_EXPIRED_MESSAGE": "لقد انتهت صلاحية جلستك، يرجى تسجيل الدخول مرة أخرى للمتابعة", + "SESSION_EXPIRED": "انتهت صلاحية الجلسة", + "PASSWORD_GENERATION_FAILED": "لم يتمكن متصفحك من إنشاء مفتاح قوي يفي بمعايير تشفير Ente، يرجى المحاولة باستخدام تطبيق الهاتف المحمول أو متصفح آخر", + "CHANGE_PASSWORD": "تغيير كلمة المرور", + "password_changed_elsewhere": "تم تغيير كلمة المرور في مكان آخر", + "password_changed_elsewhere_message": "يرجى تسجيل الدخول مرة أخرى على هذا الجهاز لاستخدام كلمة المرور الجديدة للمصادقة.", + "GO_BACK": "رجوع", + "RECOVERY_KEY": "مفتاح الاستعادة", + "SAVE_LATER": "قم بهذا لاحقا", + "SAVE": "حفظ المفتاح", "RECOVERY_KEY_DESCRIPTION": "", "RECOVER_KEY_GENERATION_FAILED": "", "KEY_NOT_STORED_DISCLAIMER": "", - "FORGOT_PASSWORD": "", - "RECOVER_ACCOUNT": "", - "RECOVERY_KEY_HINT": "", - "RECOVER": "", - "NO_RECOVERY_KEY": "", - "INCORRECT_RECOVERY_KEY": "", - "SORRY": "", + "FORGOT_PASSWORD": "نسيت كلمة المرور", + "RECOVER_ACCOUNT": "إستعادة الحساب", + "RECOVERY_KEY_HINT": "مفتاح الاستعادة", + "RECOVER": "استعادة", + "NO_RECOVERY_KEY": "ما من مفتاح استعادة؟", + "INCORRECT_RECOVERY_KEY": "مفتاح استعادة غير صحيح", + "SORRY": "عذرا", "NO_RECOVERY_KEY_MESSAGE": "", "NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE": "", - "CONTACT_SUPPORT": "", - "REQUEST_FEATURE": "", - "SUPPORT": "", - "CONFIRM": "", - "cancel": "", - "LOGOUT": "", - "delete_account": "", + "CONTACT_SUPPORT": "الاتصال بالدعم", + "REQUEST_FEATURE": "طلب ميزة", + "SUPPORT": "الدعم", + "CONFIRM": "تأكيد", + "cancel": "إلغاء", + "LOGOUT": "تسجيل الخروج", + "delete_account": "حذف الحساب", "delete_account_manually_message": "", - "LOGOUT_MESSAGE": "", - "CHANGE_EMAIL": "", - "OK": "", - "SUCCESS": "", - "ERROR": "", - "MESSAGE": "", + "LOGOUT_MESSAGE": "هل أنت متأكد من أنك تريد تسجيل الخروج؟", + "CHANGE_EMAIL": "تغيير البريد الإلكتروني", + "OK": "حسنا", + "SUCCESS": "تم بنجاح", + "ERROR": "خطأ", + "MESSAGE": "رسالة", "OFFLINE_MSG": "", "INSTALL_MOBILE_APP": "", "DOWNLOAD_APP_MESSAGE": "", - "DOWNLOAD_APP": "", - "EXPORT": "", - "SUBSCRIPTION": "", - "SUBSCRIBE": "", - "MANAGEMENT_PORTAL": "", - "MANAGE_FAMILY_PORTAL": "", - "LEAVE_FAMILY_PLAN": "", - "LEAVE": "", - "LEAVE_FAMILY_CONFIRM": "", - "CHOOSE_PLAN": "", - "MANAGE_PLAN": "", - "CURRENT_USAGE": "", - "TWO_MONTHS_FREE": "", - "POPULAR": "", - "free_plan_option": "", - "free_plan_description": "", - "active": "", - "subscription_info_free": "", + "DOWNLOAD_APP": "تنزيل تطبيق سطح المكتب", + "EXPORT": "تصدير البيانات", + "SUBSCRIPTION": "اشتراك", + "SUBSCRIBE": "اشترك", + "MANAGEMENT_PORTAL": "إدارة طريقة الدفع", + "MANAGE_FAMILY_PORTAL": "إدارة العائلة", + "LEAVE_FAMILY_PLAN": "مغادرة خطة العائلة", + "LEAVE": "مغادرة", + "LEAVE_FAMILY_CONFIRM": "هل أنت متأكد من أنك تريد مغادرة الخطة العائلية؟", + "CHOOSE_PLAN": "اختر خطتك", + "MANAGE_PLAN": "إدارة اشتراكك", + "CURRENT_USAGE": "الاستخدام الحالي هو {{usage}}", + "TWO_MONTHS_FREE": "احصل على شهرين مجانا في الخطط السنوية", + "POPULAR": "رائج", + "free_plan_option": "المتابعة مع الخطة المجانية", + "free_plan_description": "{{storage}} مجاني للأبد", + "active": "نشط", + "subscription_info_free": "أنت في الخطة المجانية", "subscription_info_family": "", "subscription_info_expired": "", "subscription_info_renewal_cancelled": "", "subscription_info_storage_quota_exceeded": "", "subscription_status_renewal_active": "", - "subscription_status_renewal_cancelled": "", + "subscription_status_renewal_cancelled": "ينتهي في {{date, date}}", "add_on_valid_till": "", - "subscription_expired": "", - "storage_quota_exceeded": "", - "SUBSCRIPTION_PURCHASE_SUCCESS": "", + "subscription_expired": "إنتهت صلاحية الاشتراك", + "storage_quota_exceeded": "تم تجاوز حد التخزين", + "SUBSCRIPTION_PURCHASE_SUCCESS": "

لقد تلقينا دفعتك

اشتراكك صالح حتى {{date, date}}

", "SUBSCRIPTION_PURCHASE_CANCELLED": "", "SUBSCRIPTION_PURCHASE_FAILED": "", "SUBSCRIPTION_UPDATE_FAILED": "", "UPDATE_PAYMENT_METHOD_MESSAGE": "", "STRIPE_AUTHENTICATION_FAILED": "", "UPDATE_PAYMENT_METHOD": "", - "MONTHLY": "", - "YEARLY": "", - "MONTH_SHORT": "", - "YEAR": "", - "update_subscription_title": "", - "UPDATE_SUBSCRIPTION_MESSAGE": "", - "UPDATE_SUBSCRIPTION": "", - "CANCEL_SUBSCRIPTION": "", + "MONTHLY": "شهريا", + "YEARLY": "سنويا", + "MONTH_SHORT": "شهر", + "YEAR": "سنة", + "update_subscription_title": "تأكيد تغيير الخطة", + "UPDATE_SUBSCRIPTION_MESSAGE": "هل أنت متأكد من أنك تريد تغيير خطتك؟", + "UPDATE_SUBSCRIPTION": "تغيير الخطة", + "CANCEL_SUBSCRIPTION": "إلغاء الاشتراك", "CANCEL_SUBSCRIPTION_MESSAGE": "", "CANCEL_SUBSCRIPTION_WITH_ADDON_MESSAGE": "", - "SUBSCRIPTION_CANCEL_FAILED": "", - "SUBSCRIPTION_CANCEL_SUCCESS": "", - "REACTIVATE_SUBSCRIPTION": "", + "SUBSCRIPTION_CANCEL_FAILED": "فشل في إلغاء الاشتراك", + "SUBSCRIPTION_CANCEL_SUCCESS": "تم إلغاء الاشتراك بنجاح", + "REACTIVATE_SUBSCRIPTION": "إعادة تنشيط الاشتراك", "REACTIVATE_SUBSCRIPTION_MESSAGE": "", "SUBSCRIPTION_ACTIVATE_SUCCESS": "", "SUBSCRIPTION_ACTIVATE_FAILED": "", - "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "", + "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "شكرا لك", "CANCEL_SUBSCRIPTION_ON_MOBILE": "", "CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE": "", "MAIL_TO_MANAGE_SUBSCRIPTION": "", - "RENAME": "", - "RENAME_FILE": "", - "RENAME_COLLECTION": "", - "DELETE_COLLECTION_TITLE": "", - "DELETE_COLLECTION": "", + "RENAME": "اعادة تسمية", + "RENAME_FILE": "إعادة تسمية ملف", + "RENAME_COLLECTION": "إعادة تسمية ألبوم", + "DELETE_COLLECTION_TITLE": "حذف ألبوم؟", + "DELETE_COLLECTION": "حذف ألبوم", "DELETE_COLLECTION_MESSAGE": "", - "DELETE_PHOTOS": "", - "KEEP_PHOTOS": "", - "SHARE_COLLECTION": "", - "SHARE_WITH_SELF": "", + "DELETE_PHOTOS": "حذف الصور", + "KEEP_PHOTOS": "الاحتفاظ بالصور", + "SHARE_COLLECTION": "مشاركة الألبوم", + "SHARE_WITH_SELF": "عفوا، لا يمكنك المشاركة مع نفسك", "ALREADY_SHARED": "", - "SHARING_BAD_REQUEST_ERROR": "", - "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "", - "DOWNLOAD_COLLECTION": "", + "SHARING_BAD_REQUEST_ERROR": "لا يسمح بمشاركة الألبوم", + "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "المشاركة معطلة للحسابات المجانية", + "DOWNLOAD_COLLECTION": "تنزيل الألبوم", "CREATE_ALBUM_FAILED": "", - "SEARCH": "", - "SEARCH_RESULTS": "", - "NO_RESULTS": "", - "SEARCH_HINT": "", + "SEARCH": "بحث", + "SEARCH_RESULTS": "نتائج البحث", + "NO_RESULTS": "لا توجد نتائج", + "SEARCH_HINT": "البحث عن الألبومات، التواريخ، والأوصاف...", "SEARCH_TYPE": { - "COLLECTION": "", - "LOCATION": "", - "CITY": "", - "DATE": "", - "FILE_NAME": "", - "THING": "", - "FILE_CAPTION": "", - "FILE_TYPE": "", - "CLIP": "" + "COLLECTION": "ألبوم", + "LOCATION": "الموقع", + "CITY": "الموقع", + "DATE": "تاريخ", + "FILE_NAME": "إسم الملف", + "THING": "المحتوى", + "FILE_CAPTION": "وصف", + "FILE_TYPE": "نوع الملف", + "CLIP": "سحر" }, - "photos_count_zero": "", - "photos_count_one": "", - "photos_count": "", - "TERMS_AND_CONDITIONS": "", - "ADD_TO_COLLECTION": "", - "SELECTED": "", + "photos_count_zero": "لا توجد ذكريات", + "photos_count_one": "ذكرى واحدة", + "photos_count": "{{count, number}} ذكريات", + "TERMS_AND_CONDITIONS": "أوافق على
شروط الخدمة وسياسة الخصوصية", + "ADD_TO_COLLECTION": "إضافة إلى الألبوم", + "SELECTED": "محدد", "PEOPLE": "", - "indexing_scheduled": "", + "indexing_scheduled": "الفهرسة مجدولة...", "indexing_photos": "", "indexing_fetching": "", "indexing_people": "", "indexing_done": "", - "UNIDENTIFIED_FACES": "", + "UNIDENTIFIED_FACES": "وجوه غير محددة", "OBJECTS": "", - "TEXT": "", - "INFO": "", - "INFO_OPTION": "", - "FILE_NAME": "", - "CAPTION_PLACEHOLDER": "", - "LOCATION": "", - "SHOW_ON_MAP": "", - "MAP": "", - "MAP_SETTINGS": "", - "ENABLE_MAPS": "", - "ENABLE_MAP": "", - "DISABLE_MAPS": "", + "TEXT": "نص", + "INFO": "معلومات ", + "INFO_OPTION": "معلومات (I)", + "FILE_NAME": "إسم الملف", + "CAPTION_PLACEHOLDER": "إضافة وصف", + "LOCATION": "الموقع", + "SHOW_ON_MAP": "عرض على OpenStreetMap", + "MAP": "خريطة", + "MAP_SETTINGS": "إعدادات الخريطة", + "ENABLE_MAPS": "تمكين الخرائط ؟", + "ENABLE_MAP": "تمكين الخريطة", + "DISABLE_MAPS": "تعطيل الخرائط؟", "ENABLE_MAP_DESCRIPTION": "", "DISABLE_MAP_DESCRIPTION": "", - "DISABLE_MAP": "", - "DETAILS": "", - "view_exif": "", - "no_exif": "", - "exif": "", - "ISO": "", + "DISABLE_MAP": "تعطيل الخريطة", + "DETAILS": "تفاصيل", + "view_exif": "عرض جميع بيانات Exif", + "no_exif": "لا توجد بيانات Exif", + "exif": "Exif", + "ISO": "ISO", "TWO_FACTOR": "", - "TWO_FACTOR_AUTHENTICATION": "", + "TWO_FACTOR_AUTHENTICATION": "المصادقة الثنائية", "TWO_FACTOR_QR_INSTRUCTION": "", - "ENTER_CODE_MANUALLY": "", + "ENTER_CODE_MANUALLY": "أدخل الرمز يدويا", "TWO_FACTOR_MANUAL_CODE_INSTRUCTION": "", - "SCAN_QR_CODE": "", + "SCAN_QR_CODE": "مسح رمز QR بدلاً من ذلك", "ENABLE_TWO_FACTOR": "", - "enable": "", - "enabled": "", + "enable": "تفعيل", + "enabled": "مفعل", "LOST_DEVICE": "", - "INCORRECT_CODE": "", + "INCORRECT_CODE": "رمز غير صحيح", "TWO_FACTOR_INFO": "", "DISABLE_TWO_FACTOR_LABEL": "", "UPDATE_TWO_FACTOR_LABEL": "", - "disable": "", - "reconfigure": "", + "disable": "تعطيل", + "reconfigure": "إعادة التهيئة", "UPDATE_TWO_FACTOR": "", "UPDATE_TWO_FACTOR_MESSAGE": "", - "UPDATE": "", + "UPDATE": "تحديث", "DISABLE_TWO_FACTOR": "", "DISABLE_TWO_FACTOR_MESSAGE": "", "TWO_FACTOR_DISABLE_FAILED": "", - "EXPORT_DATA": "", + "EXPORT_DATA": "تصدير البيانات", "select_folder": "", "select_zips": "", - "faq": "", + "faq": "الأسئلة الشائعة", "takeout_hint": "", - "DESTINATION": "", - "START": "", - "LAST_EXPORT_TIME": "", + "DESTINATION": "الوجهة", + "START": "بدء", + "LAST_EXPORT_TIME": "آخر وقت تصدير", "EXPORT_AGAIN": "", - "LOCAL_STORAGE_NOT_ACCESSIBLE": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE": "التخزين المحلي غير قابل للوصول", "LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "", "SEND_OTT": "", - "EMAIl_ALREADY_OWNED": "", + "EMAIl_ALREADY_OWNED": "البريد الإلكتروني مأخوذ بالفعل", "ETAGS_BLOCKED": "", "LIVE_PHOTOS_DETECTED": "", "RETRY_FAILED": "", @@ -321,22 +322,22 @@ "MOVE_TO_COLLECTION": "", "UNARCHIVE": "", "UNARCHIVE_COLLECTION": "", - "HIDE_COLLECTION": "", - "UNHIDE_COLLECTION": "", - "MOVE": "", - "ADD": "", - "REMOVE": "", - "YES_REMOVE": "", + "HIDE_COLLECTION": "إخفاء الألبوم", + "UNHIDE_COLLECTION": "إلغاء إخفاء الألبوم", + "MOVE": "نقل", + "ADD": "إضافة", + "REMOVE": "ازالة", + "YES_REMOVE": "نعم، إزالة", "REMOVE_FROM_COLLECTION": "", - "TRASH": "", - "MOVE_TO_TRASH": "", + "TRASH": "سلة المهملات", + "MOVE_TO_TRASH": "نقل إلى سلة المهملات", "TRASH_FILES_MESSAGE": "", "TRASH_FILE_MESSAGE": "", - "DELETE_PERMANENTLY": "", - "RESTORE": "", + "DELETE_PERMANENTLY": "حذف بشكل دائم", + "RESTORE": "استعادة", "RESTORE_TO_COLLECTION": "", - "EMPTY_TRASH": "", - "EMPTY_TRASH_TITLE": "", + "EMPTY_TRASH": "إفراغ سلة المهملات", + "EMPTY_TRASH_TITLE": "إفراغ سلة المهملات؟", "EMPTY_TRASH_MESSAGE": "", "LEAVE_SHARED_ALBUM": "", "LEAVE_ALBUM": "", diff --git a/web/packages/base/locales/bg-BG/translation.json b/web/packages/base/locales/bg-BG/translation.json index 7df973eb83..787567c446 100644 --- a/web/packages/base/locales/bg-BG/translation.json +++ b/web/packages/base/locales/bg-BG/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/ca-ES/translation.json b/web/packages/base/locales/ca-ES/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/ca-ES/translation.json +++ b/web/packages/base/locales/ca-ES/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/de-DE/translation.json b/web/packages/base/locales/de-DE/translation.json index 6c83a4dd72..fdad284a60 100644 --- a/web/packages/base/locales/de-DE/translation.json +++ b/web/packages/base/locales/de-DE/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Wie hast du von Ente erfahren? (optional)", "REFERRAL_INFO": "Wir tracken keine App-Installationen. Es würde uns jedoch helfen, wenn du uns mitteilst, wie du von uns erfahren hast!", "PASSPHRASE_MATCH_ERROR": "Die Passwörter stimmen nicht überein", + "create_albums": "", "CREATE_COLLECTION": "Neues Album", "ENTER_ALBUM_NAME": "Albumname", "CLOSE_OPTION": "Schließen (Esc)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Das Vorschaubild konnte nicht erzeugt werden", "UNSUPPORTED_FILES": "Nicht unterstützte Dateien", "SUCCESSFUL_UPLOADS": "Erfolgreiche Uploads", - "SKIPPED_INFO": "Diese wurden übersprungen, da es Dateien mit gleichen Namen im selben Album gibt", + "SKIPPED_INFO": "", "UNSUPPORTED_INFO": "Ente unterstützt diese Dateiformate noch nicht", "BLOCKED_UPLOADS": "Blockierte Uploads", "INPROGRESS_METADATA_EXTRACTION": "In Bearbeitung", diff --git a/web/packages/base/locales/el-GR/translation.json b/web/packages/base/locales/el-GR/translation.json index 5f93899450..9ecc3b137f 100644 --- a/web/packages/base/locales/el-GR/translation.json +++ b/web/packages/base/locales/el-GR/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Πώς ακούσατε για το Ente; (προαιρετικό)", "REFERRAL_INFO": "Δεν παρακολουθούμε τις εγκαταστάσεις εφαρμογών. Θα μας βοηθούσε αν μας λέγατε που μας βρήκατε!", "PASSPHRASE_MATCH_ERROR": "Οι κωδικοί πρόσβασης δεν ταιριάζουν", + "create_albums": "", "CREATE_COLLECTION": "Νέο άλμπουμ", "ENTER_ALBUM_NAME": "Όνομα άλμπουμ", "CLOSE_OPTION": "Κλείσιμο (Esc)", diff --git a/web/packages/base/locales/en-US/translation.json b/web/packages/base/locales/en-US/translation.json index 3adb4f3673..2e984af93b 100644 --- a/web/packages/base/locales/en-US/translation.json +++ b/web/packages/base/locales/en-US/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "How did you hear about Ente? (optional)", "REFERRAL_INFO": "We don't track app installs, It'd help us if you told us where you found us!", "PASSPHRASE_MATCH_ERROR": "Passwords don't match", + "create_albums": "Create albums", "CREATE_COLLECTION": "New album", "ENTER_ALBUM_NAME": "Album name", "CLOSE_OPTION": "Close (Esc)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Thumbnail generation failed", "UNSUPPORTED_FILES": "Unsupported files", "SUCCESSFUL_UPLOADS": "Successful uploads", - "SKIPPED_INFO": "Skipped these as there are files with matching names in the same album", + "SKIPPED_INFO": "Skipped these as there are files with matching name and content in the same album", "UNSUPPORTED_INFO": "Ente does not support these file formats yet", "BLOCKED_UPLOADS": "Blocked uploads", "INPROGRESS_METADATA_EXTRACTION": "In progress", diff --git a/web/packages/base/locales/es-ES/translation.json b/web/packages/base/locales/es-ES/translation.json index 6fee153eb7..3254dc154c 100644 --- a/web/packages/base/locales/es-ES/translation.json +++ b/web/packages/base/locales/es-ES/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "¿Cómo escuchaste acerca de Ente? (opcional)", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "Las contraseñas no coinciden", + "create_albums": "", "CREATE_COLLECTION": "Nuevo álbum", "ENTER_ALBUM_NAME": "Nombre del álbum", "CLOSE_OPTION": "Cerrar (Esc)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Generación de miniaturas fallida", "UNSUPPORTED_FILES": "Archivos no soportados", "SUCCESSFUL_UPLOADS": "Subidas exitosas", - "SKIPPED_INFO": "Se han omitido ya que hay archivos con nombres coincidentes en el mismo álbum", + "SKIPPED_INFO": "", "UNSUPPORTED_INFO": "ente no soporta estos formatos de archivo aún", "BLOCKED_UPLOADS": "Subidas bloqueadas", "INPROGRESS_METADATA_EXTRACTION": "En proceso", diff --git a/web/packages/base/locales/et-EE/translation.json b/web/packages/base/locales/et-EE/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/et-EE/translation.json +++ b/web/packages/base/locales/et-EE/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/fa-IR/translation.json b/web/packages/base/locales/fa-IR/translation.json index c3d6c8159a..b6d481c36a 100644 --- a/web/packages/base/locales/fa-IR/translation.json +++ b/web/packages/base/locales/fa-IR/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/fi-FI/translation.json b/web/packages/base/locales/fi-FI/translation.json index d0f899abef..8794455ddc 100644 --- a/web/packages/base/locales/fi-FI/translation.json +++ b/web/packages/base/locales/fi-FI/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Miten kuulit Entestä? (valinnainen)", "REFERRAL_INFO": "Emme seuraa sovelluksen asennuksia. Se auttaisi meitä, jos kertoisit mistä löysit meidät!", "PASSPHRASE_MATCH_ERROR": "Salasanat eivät täsmää", + "create_albums": "", "CREATE_COLLECTION": "Uusi albumi", "ENTER_ALBUM_NAME": "Albumin nimi", "CLOSE_OPTION": "Sulje (Esc)", diff --git a/web/packages/base/locales/fr-FR/translation.json b/web/packages/base/locales/fr-FR/translation.json index b315c99929..639b8a29b6 100644 --- a/web/packages/base/locales/fr-FR/translation.json +++ b/web/packages/base/locales/fr-FR/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Comment avez-vous entendu parler de Ente? (facultatif)", "REFERRAL_INFO": "Nous ne suivons pas les installations d'applications. Il serait utile que vous nous disiez comment vous nous avez trouvés !", "PASSPHRASE_MATCH_ERROR": "Les mots de passe ne correspondent pas", + "create_albums": "", "CREATE_COLLECTION": "Nouvel album", "ENTER_ALBUM_NAME": "Nom de l'album", "CLOSE_OPTION": "Fermer (Échap)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Échec de création d'une miniature", "UNSUPPORTED_FILES": "Fichiers non supportés", "SUCCESSFUL_UPLOADS": "Chargements réussis", - "SKIPPED_INFO": "Ignorés car il y a des fichiers avec des noms identiques dans le même album", + "SKIPPED_INFO": "", "UNSUPPORTED_INFO": "Ente ne supporte pas encore ces formats de fichiers", "BLOCKED_UPLOADS": "Chargements bloqués", "INPROGRESS_METADATA_EXTRACTION": "En cours", diff --git a/web/packages/base/locales/gu-IN/translation.json b/web/packages/base/locales/gu-IN/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/gu-IN/translation.json +++ b/web/packages/base/locales/gu-IN/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/hi-IN/translation.json b/web/packages/base/locales/hi-IN/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/hi-IN/translation.json +++ b/web/packages/base/locales/hi-IN/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/id-ID/translation.json b/web/packages/base/locales/id-ID/translation.json index 9bf887d7be..3f0ae89793 100644 --- a/web/packages/base/locales/id-ID/translation.json +++ b/web/packages/base/locales/id-ID/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Dari mana Anda menemukan Ente? (opsional)", "REFERRAL_INFO": "Kami tidak melacak pemasangan aplikasi, Ini akan membantu kami jika Anda memberi tahu kami di mana Anda menemukan kami!", "PASSPHRASE_MATCH_ERROR": "Kata sandi tidak cocok", + "create_albums": "", "CREATE_COLLECTION": "Album baru", "ENTER_ALBUM_NAME": "Nama album", "CLOSE_OPTION": "Tutup (Esc)", diff --git a/web/packages/base/locales/is-IS/translation.json b/web/packages/base/locales/is-IS/translation.json index 609dff0ef7..326ed688ec 100644 --- a/web/packages/base/locales/is-IS/translation.json +++ b/web/packages/base/locales/is-IS/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/it-IT/translation.json b/web/packages/base/locales/it-IT/translation.json index e32e814fda..63d1d3fee1 100644 --- a/web/packages/base/locales/it-IT/translation.json +++ b/web/packages/base/locales/it-IT/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Come hai conosciuto Ente? (opzionale)", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "Le password non corrispondono", + "create_albums": "", "CREATE_COLLECTION": "Nuovo album", "ENTER_ALBUM_NAME": "Nome album", "CLOSE_OPTION": "Chiudi (Esc)", diff --git a/web/packages/base/locales/ja-JP/translation.json b/web/packages/base/locales/ja-JP/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/ja-JP/translation.json +++ b/web/packages/base/locales/ja-JP/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/km-KH/translation.json b/web/packages/base/locales/km-KH/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/km-KH/translation.json +++ b/web/packages/base/locales/km-KH/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/ko-KR/translation.json b/web/packages/base/locales/ko-KR/translation.json index 0afc0224fd..0a973ce7ae 100644 --- a/web/packages/base/locales/ko-KR/translation.json +++ b/web/packages/base/locales/ko-KR/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "어떻게 Ente에 대해 들으셨나요? (선택사항)", "REFERRAL_INFO": "우리는 앱 설치를 추적하지 않습니다. 우리를 알게 된 곳을 남겨주시면 우리에게 도움이 될꺼에요!", "PASSPHRASE_MATCH_ERROR": "비밀번호가 일치하지 않습니다", + "create_albums": "", "CREATE_COLLECTION": "새 앨범", "ENTER_ALBUM_NAME": "앨범 이름", "CLOSE_OPTION": "닫기 (Esc)", diff --git a/web/packages/base/locales/nl-NL/translation.json b/web/packages/base/locales/nl-NL/translation.json index 1dd8934a3c..d8cba2d986 100644 --- a/web/packages/base/locales/nl-NL/translation.json +++ b/web/packages/base/locales/nl-NL/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Hoe hoorde je over Ente? (optioneel)", "REFERRAL_INFO": "Wij gebruiken geen tracking. Het zou helpen als je ons vertelt waar je ons gevonden hebt!", "PASSPHRASE_MATCH_ERROR": "Wachtwoorden komen niet overeen", + "create_albums": "", "CREATE_COLLECTION": "Nieuw album", "ENTER_ALBUM_NAME": "Albumnaam", "CLOSE_OPTION": "Sluiten (Esc)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Thumbnail generatie mislukt", "UNSUPPORTED_FILES": "Niet-ondersteunde bestanden", "SUCCESSFUL_UPLOADS": "Succesvolle uploads", - "SKIPPED_INFO": "Deze zijn overgeslagen omdat er bestanden zijn met overeenkomende namen in hetzelfde album", + "SKIPPED_INFO": "", "UNSUPPORTED_INFO": "Ente ondersteunt deze bestandsformaten nog niet", "BLOCKED_UPLOADS": "Geblokkeerde uploads", "INPROGRESS_METADATA_EXTRACTION": "In behandeling", diff --git a/web/packages/base/locales/pl-PL/translation.json b/web/packages/base/locales/pl-PL/translation.json index 2bf6775c9a..61e464e0af 100644 --- a/web/packages/base/locales/pl-PL/translation.json +++ b/web/packages/base/locales/pl-PL/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Jak usłyszałeś/aś o Ente? (opcjonalnie)", "REFERRAL_INFO": "Nie śledzimy instalacji aplikacji. Pomogłyby nam, gdybyś powiedział/a nam, gdzie nas znalazłeś/aś!", "PASSPHRASE_MATCH_ERROR": "Hasła nie pasują do siebie", + "create_albums": "Utwórz albumy", "CREATE_COLLECTION": "Nowy album", "ENTER_ALBUM_NAME": "Nazwa albumu", "CLOSE_OPTION": "Zamknij (Esc)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Generowanie miniatur nie powiodło się", "UNSUPPORTED_FILES": "Nieobsługiwane pliki", "SUCCESSFUL_UPLOADS": "Pomyślne przesłania", - "SKIPPED_INFO": "Pominięto te pliki, ponieważ są pliki z pasującymi nazwami w tym samym albumie", + "SKIPPED_INFO": "Pominięto te pliki, ponieważ są pliki z pasującymi nazwami i zawartością w tym samym albumie", "UNSUPPORTED_INFO": "Ente nie obsługuje jeszcze tych formatów plików", "BLOCKED_UPLOADS": "Zablokowane przesłania", "INPROGRESS_METADATA_EXTRACTION": "W toku", diff --git a/web/packages/base/locales/pt-BR/translation.json b/web/packages/base/locales/pt-BR/translation.json index 7cb8e2e3ca..6e640539b1 100644 --- a/web/packages/base/locales/pt-BR/translation.json +++ b/web/packages/base/locales/pt-BR/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Como você ouviu sobre o Ente? (opcional)", "REFERRAL_INFO": "Não rastreamos instalações do aplicativo. Seria útil se você nos contasse onde nos encontrou!", "PASSPHRASE_MATCH_ERROR": "As senhas não coincidem", + "create_albums": "Criar álbuns", "CREATE_COLLECTION": "Novo álbum", "ENTER_ALBUM_NAME": "Nome do álbum", "CLOSE_OPTION": "Fechar (Esc)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Falha ao gerar miniaturas", "UNSUPPORTED_FILES": "Arquivos não suportados", "SUCCESSFUL_UPLOADS": "Envios bem sucedidos", - "SKIPPED_INFO": "Ignorar estes como existem arquivos com nomes correspondentes no mesmo álbum", + "SKIPPED_INFO": "Estes foram pulados, pois há arquivos com nome e conteúdo correspondentes no mesmo álbum", "UNSUPPORTED_INFO": "ente ainda não suporta estes formatos de arquivo", "BLOCKED_UPLOADS": "Envios bloqueados", "INPROGRESS_METADATA_EXTRACTION": "Em andamento", diff --git a/web/packages/base/locales/pt-PT/translation.json b/web/packages/base/locales/pt-PT/translation.json index b1d4f2f26e..e5f318a3be 100644 --- a/web/packages/base/locales/pt-PT/translation.json +++ b/web/packages/base/locales/pt-PT/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "Novo álbum", "ENTER_ALBUM_NAME": "Nome do álbum", "CLOSE_OPTION": "Fechar (Esc)", diff --git a/web/packages/base/locales/ru-RU/translation.json b/web/packages/base/locales/ru-RU/translation.json index f15cc4a8f0..26a8fb055c 100644 --- a/web/packages/base/locales/ru-RU/translation.json +++ b/web/packages/base/locales/ru-RU/translation.json @@ -1,11 +1,11 @@ { - "HERO_SLIDE_1_TITLE": "
Личные резервные копии
для твоих воспоминаний
", + "HERO_SLIDE_1_TITLE": "
Приватные резервные копии
для ваших воспоминаний
", "HERO_SLIDE_1": "Сквозное шифрование по умолчанию", "HERO_SLIDE_2_TITLE": "
Надежно хранится
в убежище от радиоактивных осадков
", "HERO_SLIDE_2": "Созданный для того, чтобы пережить", "HERO_SLIDE_3_TITLE": "
Доступно
везде
", "HERO_SLIDE_3": "Android, iOS, Веб, ПК", - "LOGIN": "Авторизоваться", + "LOGIN": "Войти", "SIGN_UP": "Регистрация", "NEW_USER": "Новенький в Ente", "EXISTING_USER": "Существующий пользователь", @@ -19,7 +19,7 @@ "ENTER_OTT": "Проверочный код", "RESEND_MAIL": "Отправить код еще раз", "VERIFY": "Подтвердить", - "UNKNOWN_ERROR": "Что-то пошло не так, Попробуйте еще раз", + "UNKNOWN_ERROR": "Что-то пошло не так, попробуйте еще раз", "INVALID_CODE": "Неверный код подтверждения", "EXPIRED_CODE": "Срок действия вашего проверочного кода истек", "SENDING": "Отправка...", @@ -28,7 +28,7 @@ "link_password_description": "Введите пароль, чтобы разблокировать альбом", "unlock": "Разблокировать", "SET_PASSPHRASE": "Установить пароль", - "VERIFY_PASSPHRASE": "Войти", + "VERIFY_PASSPHRASE": "Зарегистрироваться", "INCORRECT_PASSPHRASE": "Неверный пароль", "ENTER_ENC_PASSPHRASE": "Пожалуйста, введите пароль, который мы можем использовать для шифрования ваших данных", "PASSPHRASE_DISCLAIMER": "Мы не храним ваш пароль, поэтому, если вы его забудете,\nмы ничем не сможем вам помочь\nвосстановите ваши данные без ключа восстановления.", @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Как вы узнали о Ente? (необязательно)", "REFERRAL_INFO": "Будет полезно, если вы укажете, где нашли нас, так как мы не отслеживаем установки приложения!", "PASSPHRASE_MATCH_ERROR": "Пароли не совпадают", + "create_albums": "Создать альбомы", "CREATE_COLLECTION": "Новый альбом", "ENTER_ALBUM_NAME": "Название альбома", "CLOSE_OPTION": "Закрыть (Esc)", @@ -175,7 +176,7 @@ "UPDATE_PAYMENT_METHOD": "Обновить платёжную информацию", "MONTHLY": "Ежемесячно", "YEARLY": "Ежегодно", - "MONTH_SHORT": "мо", + "MONTH_SHORT": "мес", "YEAR": "год", "update_subscription_title": "Подтвердить изменение плана", "UPDATE_SUBSCRIPTION_MESSAGE": "Хотите сменить текущий план?", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Не удалось создать миниатюру", "UNSUPPORTED_FILES": "Неподдерживаемые файлы", "SUCCESSFUL_UPLOADS": "Успешные загрузки", - "SKIPPED_INFO": "Пропустил их, так как в одном альбоме есть файлы с одинаковыми названиями", + "SKIPPED_INFO": "Пропущено, так как в альбоме есть файлы с совпадающими именем и содержимым", "UNSUPPORTED_INFO": "Ente пока не поддерживает эти форматы файлов", "BLOCKED_UPLOADS": "Заблокированные загрузки", "INPROGRESS_METADATA_EXTRACTION": "В процессе", @@ -335,8 +336,8 @@ "DELETE_PERMANENTLY": "Удалить навсегда", "RESTORE": "Восстанавливать", "RESTORE_TO_COLLECTION": "Восстановить в альбом", - "EMPTY_TRASH": "Пустой мусор", - "EMPTY_TRASH_TITLE": "Пустой мусор?", + "EMPTY_TRASH": "Очистить корзину", + "EMPTY_TRASH_TITLE": "Очистить корзину?", "EMPTY_TRASH_MESSAGE": "Эти файлы будут безвозвратно удалены из вашей учетной записи Ente.", "LEAVE_SHARED_ALBUM": "Да, уходи", "LEAVE_ALBUM": "Оставить альбом", @@ -485,15 +486,15 @@ "indexing": "Индексирование", "processed": "Обработано", "indexing_status_running": "Выполняется", - "indexing_status_fetching": "", + "indexing_status_fetching": "Получение", "indexing_status_scheduled": "Запланировано", "indexing_status_done": "Готово", "ml_search_disable": "Отключить машинное обучение", "ml_search_disable_confirm": "Вы хотите отключить машинное обучение на всех ваших устройствах?", - "ml_consent": "", - "ml_consent_title": "", - "ml_consent_description": "", - "ml_consent_confirmation": "", + "ml_consent": "Включить машинное обучение", + "ml_consent_title": "Включить машинное обучение?", + "ml_consent_description": "

Если вы включите машинное обучение, Ente будет извлекать информацию из файлов (например, геометрию лица), включая те, которыми с вами поделились.

Это будет происходить на вашем устройстве, и любая сгенерированная биометрическая информация будет зашифрована с использованием сквозного (End-to-End) шифрования

Пожалуйста нажмите здесь для получения дополнительной информации об этой функции в нашей политике конфиденциальности

", + "ml_consent_confirmation": "Я понимаю, и хочу разрешить машинное обучение", "labs": "Лаборатории", "YOURS": "твой", "passphrase_strength_weak": "Надежность пароля: слабая", @@ -532,8 +533,8 @@ "EXPORT_PROGRESS": "{{progress.success, number}} / {{progress.total, number}} синхронизированные элементы", "MIGRATING_EXPORT": "Подготовка...", "RENAMING_COLLECTION_FOLDERS": "Переименование папок альбомов...", - "TRASHING_DELETED_FILES": "Удаление удаленных файлов...", - "TRASHING_DELETED_COLLECTIONS": "Удаление удаленных альбомов...", + "TRASHING_DELETED_FILES": "Очистка удаленных файлов...", + "TRASHING_DELETED_COLLECTIONS": "Очистка удаленных альбомов...", "CONTINUOUS_EXPORT": "Непрерывная синхронизация", "PENDING_ITEMS": "Отложенные пункты", "EXPORT_STARTING": "Запуск экспорта...", diff --git a/web/packages/base/locales/sv-SE/translation.json b/web/packages/base/locales/sv-SE/translation.json index 42cdf3e79a..48bea6d012 100644 --- a/web/packages/base/locales/sv-SE/translation.json +++ b/web/packages/base/locales/sv-SE/translation.json @@ -7,7 +7,7 @@ "HERO_SLIDE_3": "Android, iOS, webb, skrivbord", "LOGIN": "Logga in", "SIGN_UP": "Registrera", - "NEW_USER": "", + "NEW_USER": "Ny hos Ente", "EXISTING_USER": "Befintlig användare", "ENTER_NAME": "Ange namn", "PUBLIC_UPLOADER_NAME_MESSAGE": "Lägg till ett namn så att dina vänner vet vem de ska tacka för dessa fantastiska bilder!", @@ -26,7 +26,7 @@ "SENT": "Skickat!", "password": "Lösenord", "link_password_description": "Ange lösenord för att låsa upp albumet", - "unlock": "", + "unlock": "Lås upp", "SET_PASSPHRASE": "Välj lösenord", "VERIFY_PASSPHRASE": "Logga in", "INCORRECT_PASSPHRASE": "Felaktigt lösenord", @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Hur hörde du talas om Ente? (valfritt)", "REFERRAL_INFO": "Vi spårar inte appinstallationer, Det skulle hjälpa oss om du berättade var du hittade oss!", "PASSPHRASE_MATCH_ERROR": "Lösenorden matchar inte", + "create_albums": "Skapa album", "CREATE_COLLECTION": "Nytt album", "ENTER_ALBUM_NAME": "Albumnamn", "CLOSE_OPTION": "Stäng (Esc)", @@ -60,11 +61,11 @@ "0": "Förbereder att ladda upp", "1": "Läser Google metadatafiler", "2": "Metadata för {{uploadCounter.finished, number}} / {{uploadCounter.total, number}} filer extraherat", - "3": "", - "4": "", - "5": "" + "3": "{{uploadCounter.finished, number}} / {{uploadCounter.total, number}} filer behandlade", + "4": "Avbryter återstående uppladdningar", + "5": "Säkerhetskopiering slutförd" }, - "FILE_NOT_UPLOADED_LIST": "", + "FILE_NOT_UPLOADED_LIST": "Följande filer laddades ej upp", "INITIAL_LOAD_DELAY_WARNING": "", "USER_DOES_NOT_EXIST": "", "NO_ACCOUNT": "", @@ -650,7 +651,7 @@ "redirect_close_instructions": "", "redirect_again": "", "autogenerated_first_album_name": "", - "autogenerated_default_album_name": "", + "autogenerated_default_album_name": "Nytt album", "developer_settings": "Utvecklarinställningar", "server_endpoint": "", "more_information": "", diff --git a/web/packages/base/locales/ta-IN/translation.json b/web/packages/base/locales/ta-IN/translation.json new file mode 100644 index 0000000000..f90267b710 --- /dev/null +++ b/web/packages/base/locales/ta-IN/translation.json @@ -0,0 +1,659 @@ +{ + "HERO_SLIDE_1_TITLE": "", + "HERO_SLIDE_1": "", + "HERO_SLIDE_2_TITLE": "", + "HERO_SLIDE_2": "", + "HERO_SLIDE_3_TITLE": "", + "HERO_SLIDE_3": "", + "LOGIN": "", + "SIGN_UP": "", + "NEW_USER": "", + "EXISTING_USER": "", + "ENTER_NAME": "", + "PUBLIC_UPLOADER_NAME_MESSAGE": "", + "ENTER_EMAIL": "", + "EMAIL_ERROR": "", + "REQUIRED": "", + "EMAIL_SENT": "", + "CHECK_INBOX": "", + "ENTER_OTT": "", + "RESEND_MAIL": "", + "VERIFY": "", + "UNKNOWN_ERROR": "", + "INVALID_CODE": "", + "EXPIRED_CODE": "", + "SENDING": "", + "SENT": "", + "password": "", + "link_password_description": "", + "unlock": "", + "SET_PASSPHRASE": "", + "VERIFY_PASSPHRASE": "", + "INCORRECT_PASSPHRASE": "", + "ENTER_ENC_PASSPHRASE": "", + "PASSPHRASE_DISCLAIMER": "", + "WELCOME_TO_ENTE_HEADING": "", + "WELCOME_TO_ENTE_SUBHEADING": "", + "WHERE_YOUR_BEST_PHOTOS_LIVE": "", + "KEY_GENERATION_IN_PROGRESS_MESSAGE": "", + "PASSPHRASE_HINT": "", + "CONFIRM_PASSPHRASE": "", + "REFERRAL_CODE_HINT": "", + "REFERRAL_INFO": "", + "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", + "CREATE_COLLECTION": "", + "ENTER_ALBUM_NAME": "", + "CLOSE_OPTION": "", + "ENTER_FILE_NAME": "", + "CLOSE": "", + "NO": "", + "NOTHING_HERE": "", + "upload": "", + "import": "", + "ADD_PHOTOS": "", + "ADD_MORE_PHOTOS": "", + "add_photos_count_one": "", + "add_photos_count": "", + "select_photos": "", + "FILE_UPLOAD": "", + "UPLOAD_STAGE_MESSAGE": { + "0": "", + "1": "", + "2": "", + "3": "", + "4": "", + "5": "" + }, + "FILE_NOT_UPLOADED_LIST": "", + "INITIAL_LOAD_DELAY_WARNING": "", + "USER_DOES_NOT_EXIST": "", + "NO_ACCOUNT": "", + "ACCOUNT_EXISTS": "", + "CREATE": "", + "DOWNLOAD": "", + "DOWNLOAD_OPTION": "", + "DOWNLOAD_FAVORITES": "", + "DOWNLOAD_UNCATEGORIZED": "", + "DOWNLOAD_HIDDEN_ITEMS": "", + "COPY_OPTION": "", + "TOGGLE_FULLSCREEN": "", + "ZOOM_IN_OUT": "", + "PREVIOUS": "", + "NEXT": "", + "title_photos": "", + "title_auth": "", + "title_accounts": "", + "UPLOAD_FIRST_PHOTO": "", + "IMPORT_YOUR_FOLDERS": "", + "UPLOAD_DROPZONE_MESSAGE": "", + "WATCH_FOLDER_DROPZONE_MESSAGE": "", + "TRASH_FILES_TITLE": "", + "TRASH_FILE_TITLE": "", + "DELETE_FILES_TITLE": "", + "DELETE_FILES_MESSAGE": "", + "DELETE": "", + "DELETE_OPTION": "", + "FAVORITE_OPTION": "", + "UNFAVORITE_OPTION": "", + "MULTI_FOLDER_UPLOAD": "", + "UPLOAD_STRATEGY_CHOICE": "", + "UPLOAD_STRATEGY_SINGLE_COLLECTION": "", + "OR": "", + "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "", + "SESSION_EXPIRED_MESSAGE": "", + "SESSION_EXPIRED": "", + "PASSWORD_GENERATION_FAILED": "", + "CHANGE_PASSWORD": "", + "password_changed_elsewhere": "", + "password_changed_elsewhere_message": "", + "GO_BACK": "", + "RECOVERY_KEY": "", + "SAVE_LATER": "", + "SAVE": "", + "RECOVERY_KEY_DESCRIPTION": "", + "RECOVER_KEY_GENERATION_FAILED": "", + "KEY_NOT_STORED_DISCLAIMER": "", + "FORGOT_PASSWORD": "", + "RECOVER_ACCOUNT": "", + "RECOVERY_KEY_HINT": "", + "RECOVER": "", + "NO_RECOVERY_KEY": "", + "INCORRECT_RECOVERY_KEY": "", + "SORRY": "", + "NO_RECOVERY_KEY_MESSAGE": "", + "NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE": "", + "CONTACT_SUPPORT": "", + "REQUEST_FEATURE": "", + "SUPPORT": "", + "CONFIRM": "", + "cancel": "", + "LOGOUT": "", + "delete_account": "", + "delete_account_manually_message": "", + "LOGOUT_MESSAGE": "", + "CHANGE_EMAIL": "", + "OK": "", + "SUCCESS": "", + "ERROR": "", + "MESSAGE": "", + "OFFLINE_MSG": "", + "INSTALL_MOBILE_APP": "", + "DOWNLOAD_APP_MESSAGE": "", + "DOWNLOAD_APP": "", + "EXPORT": "", + "SUBSCRIPTION": "", + "SUBSCRIBE": "", + "MANAGEMENT_PORTAL": "", + "MANAGE_FAMILY_PORTAL": "", + "LEAVE_FAMILY_PLAN": "", + "LEAVE": "", + "LEAVE_FAMILY_CONFIRM": "", + "CHOOSE_PLAN": "", + "MANAGE_PLAN": "", + "CURRENT_USAGE": "", + "TWO_MONTHS_FREE": "", + "POPULAR": "", + "free_plan_option": "", + "free_plan_description": "", + "active": "", + "subscription_info_free": "", + "subscription_info_family": "", + "subscription_info_expired": "", + "subscription_info_renewal_cancelled": "", + "subscription_info_storage_quota_exceeded": "", + "subscription_status_renewal_active": "", + "subscription_status_renewal_cancelled": "", + "add_on_valid_till": "", + "subscription_expired": "", + "storage_quota_exceeded": "", + "SUBSCRIPTION_PURCHASE_SUCCESS": "", + "SUBSCRIPTION_PURCHASE_CANCELLED": "", + "SUBSCRIPTION_PURCHASE_FAILED": "", + "SUBSCRIPTION_UPDATE_FAILED": "", + "UPDATE_PAYMENT_METHOD_MESSAGE": "", + "STRIPE_AUTHENTICATION_FAILED": "", + "UPDATE_PAYMENT_METHOD": "", + "MONTHLY": "", + "YEARLY": "", + "MONTH_SHORT": "", + "YEAR": "", + "update_subscription_title": "", + "UPDATE_SUBSCRIPTION_MESSAGE": "", + "UPDATE_SUBSCRIPTION": "", + "CANCEL_SUBSCRIPTION": "", + "CANCEL_SUBSCRIPTION_MESSAGE": "", + "CANCEL_SUBSCRIPTION_WITH_ADDON_MESSAGE": "", + "SUBSCRIPTION_CANCEL_FAILED": "", + "SUBSCRIPTION_CANCEL_SUCCESS": "", + "REACTIVATE_SUBSCRIPTION": "", + "REACTIVATE_SUBSCRIPTION_MESSAGE": "", + "SUBSCRIPTION_ACTIVATE_SUCCESS": "", + "SUBSCRIPTION_ACTIVATE_FAILED": "", + "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "", + "CANCEL_SUBSCRIPTION_ON_MOBILE": "", + "CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE": "", + "MAIL_TO_MANAGE_SUBSCRIPTION": "", + "RENAME": "", + "RENAME_FILE": "", + "RENAME_COLLECTION": "", + "DELETE_COLLECTION_TITLE": "", + "DELETE_COLLECTION": "", + "DELETE_COLLECTION_MESSAGE": "", + "DELETE_PHOTOS": "", + "KEEP_PHOTOS": "", + "SHARE_COLLECTION": "", + "SHARE_WITH_SELF": "", + "ALREADY_SHARED": "", + "SHARING_BAD_REQUEST_ERROR": "", + "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "", + "DOWNLOAD_COLLECTION": "", + "CREATE_ALBUM_FAILED": "", + "SEARCH": "", + "SEARCH_RESULTS": "", + "NO_RESULTS": "", + "SEARCH_HINT": "", + "SEARCH_TYPE": { + "COLLECTION": "", + "LOCATION": "", + "CITY": "", + "DATE": "", + "FILE_NAME": "", + "THING": "", + "FILE_CAPTION": "", + "FILE_TYPE": "", + "CLIP": "" + }, + "photos_count_zero": "", + "photos_count_one": "", + "photos_count": "", + "TERMS_AND_CONDITIONS": "", + "ADD_TO_COLLECTION": "", + "SELECTED": "", + "PEOPLE": "", + "indexing_scheduled": "", + "indexing_photos": "", + "indexing_fetching": "", + "indexing_people": "", + "indexing_done": "", + "UNIDENTIFIED_FACES": "", + "OBJECTS": "", + "TEXT": "", + "INFO": "", + "INFO_OPTION": "", + "FILE_NAME": "", + "CAPTION_PLACEHOLDER": "", + "LOCATION": "", + "SHOW_ON_MAP": "", + "MAP": "", + "MAP_SETTINGS": "", + "ENABLE_MAPS": "", + "ENABLE_MAP": "", + "DISABLE_MAPS": "", + "ENABLE_MAP_DESCRIPTION": "", + "DISABLE_MAP_DESCRIPTION": "", + "DISABLE_MAP": "", + "DETAILS": "", + "view_exif": "", + "no_exif": "", + "exif": "", + "ISO": "", + "TWO_FACTOR": "", + "TWO_FACTOR_AUTHENTICATION": "", + "TWO_FACTOR_QR_INSTRUCTION": "", + "ENTER_CODE_MANUALLY": "", + "TWO_FACTOR_MANUAL_CODE_INSTRUCTION": "", + "SCAN_QR_CODE": "", + "ENABLE_TWO_FACTOR": "", + "enable": "", + "enabled": "", + "LOST_DEVICE": "", + "INCORRECT_CODE": "", + "TWO_FACTOR_INFO": "", + "DISABLE_TWO_FACTOR_LABEL": "", + "UPDATE_TWO_FACTOR_LABEL": "", + "disable": "", + "reconfigure": "", + "UPDATE_TWO_FACTOR": "", + "UPDATE_TWO_FACTOR_MESSAGE": "", + "UPDATE": "", + "DISABLE_TWO_FACTOR": "", + "DISABLE_TWO_FACTOR_MESSAGE": "", + "TWO_FACTOR_DISABLE_FAILED": "", + "EXPORT_DATA": "", + "select_folder": "", + "select_zips": "", + "faq": "", + "takeout_hint": "", + "DESTINATION": "", + "START": "", + "LAST_EXPORT_TIME": "", + "EXPORT_AGAIN": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "", + "SEND_OTT": "", + "EMAIl_ALREADY_OWNED": "", + "ETAGS_BLOCKED": "", + "LIVE_PHOTOS_DETECTED": "", + "RETRY_FAILED": "", + "FAILED_UPLOADS": "", + "failed_uploads_hint": "", + "SKIPPED_FILES": "", + "THUMBNAIL_GENERATION_FAILED_UPLOADS": "", + "UNSUPPORTED_FILES": "", + "SUCCESSFUL_UPLOADS": "", + "SKIPPED_INFO": "", + "UNSUPPORTED_INFO": "", + "BLOCKED_UPLOADS": "", + "INPROGRESS_METADATA_EXTRACTION": "", + "INPROGRESS_UPLOADS": "", + "TOO_LARGE_UPLOADS": "", + "LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "", + "LARGER_THAN_AVAILABLE_STORAGE_INFO": "", + "TOO_LARGE_INFO": "", + "THUMBNAIL_GENERATION_FAILED_INFO": "", + "UPLOAD_TO_COLLECTION": "", + "UNCATEGORIZED": "", + "ARCHIVE": "", + "FAVORITES": "", + "ARCHIVE_COLLECTION": "", + "ARCHIVE_SECTION_NAME": "", + "ALL_SECTION_NAME": "", + "MOVE_TO_COLLECTION": "", + "UNARCHIVE": "", + "UNARCHIVE_COLLECTION": "", + "HIDE_COLLECTION": "", + "UNHIDE_COLLECTION": "", + "MOVE": "", + "ADD": "", + "REMOVE": "", + "YES_REMOVE": "", + "REMOVE_FROM_COLLECTION": "", + "TRASH": "", + "MOVE_TO_TRASH": "", + "TRASH_FILES_MESSAGE": "", + "TRASH_FILE_MESSAGE": "", + "DELETE_PERMANENTLY": "", + "RESTORE": "", + "RESTORE_TO_COLLECTION": "", + "EMPTY_TRASH": "", + "EMPTY_TRASH_TITLE": "", + "EMPTY_TRASH_MESSAGE": "", + "LEAVE_SHARED_ALBUM": "", + "LEAVE_ALBUM": "", + "LEAVE_SHARED_ALBUM_TITLE": "", + "LEAVE_SHARED_ALBUM_MESSAGE": "", + "NOT_FILE_OWNER": "", + "CONFIRM_SELF_REMOVE_MESSAGE": "", + "CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "", + "SORT_BY_CREATION_TIME_ASCENDING": "", + "SORT_BY_UPDATION_TIME_DESCENDING": "", + "SORT_BY_NAME": "", + "FIX_CREATION_TIME": "", + "FIX_CREATION_TIME_IN_PROGRESS": "", + "CREATION_TIME_UPDATED": "", + "UPDATE_CREATION_TIME_NOT_STARTED": "", + "UPDATE_CREATION_TIME_COMPLETED": "", + "UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "", + "CAPTION_CHARACTER_LIMIT": "", + "DATE_TIME_ORIGINAL": "", + "DATE_TIME_DIGITIZED": "", + "METADATA_DATE": "", + "CUSTOM_TIME": "", + "REOPEN_PLAN_SELECTOR_MODAL": "", + "OPEN_PLAN_SELECTOR_MODAL_FAILED": "", + "INSTALL": "", + "SHARING_DETAILS": "", + "MODIFY_SHARING": "", + "ADD_COLLABORATORS": "", + "ADD_NEW_EMAIL": "", + "shared_with_people_count_zero": "", + "shared_with_people_count_one": "", + "shared_with_people_count": "", + "participants_count_zero": "", + "participants_count_one": "", + "participants_count": "", + "ADD_VIEWERS": "", + "CHANGE_PERMISSIONS_TO_VIEWER": "", + "CHANGE_PERMISSIONS_TO_COLLABORATOR": "", + "CONVERT_TO_VIEWER": "", + "CONVERT_TO_COLLABORATOR": "", + "CHANGE_PERMISSION": "", + "REMOVE_PARTICIPANT": "", + "CONFIRM_REMOVE": "", + "MANAGE": "", + "ADDED_AS": "", + "COLLABORATOR_RIGHTS": "", + "REMOVE_PARTICIPANT_HEAD": "", + "OWNER": "", + "COLLABORATORS": "", + "ADD_MORE": "", + "VIEWERS": "", + "OR_ADD_EXISTING": "", + "REMOVE_PARTICIPANT_MESSAGE": "", + "NOT_FOUND": "", + "LINK_EXPIRED": "", + "LINK_EXPIRED_MESSAGE": "", + "MANAGE_LINK": "", + "LINK_TOO_MANY_REQUESTS": "", + "FILE_DOWNLOAD": "", + "link_password_lock": "", + "PUBLIC_COLLECT": "", + "LINK_DEVICE_LIMIT": "", + "NO_DEVICE_LIMIT": "", + "LINK_EXPIRY": "", + "NEVER": "", + "DISABLE_FILE_DOWNLOAD": "", + "DISABLE_FILE_DOWNLOAD_MESSAGE": "", + "SHARED_USING": "", + "SHARING_REFERRAL_CODE": "", + "LIVE": "", + "DISABLE_PASSWORD": "", + "DISABLE_PASSWORD_MESSAGE": "", + "PASSWORD_LOCK": "", + "LOCK": "", + "DOWNLOAD_UPLOAD_LOGS": "", + "file": "", + "folder": "", + "google_takeout": "", + "DEDUPLICATE_FILES": "", + "NO_DUPLICATES_FOUND": "", + "FILES": "", + "EACH": "", + "DEDUPLICATE_BASED_ON_SIZE": "", + "STOP_ALL_UPLOADS_MESSAGE": "", + "STOP_UPLOADS_HEADER": "", + "YES_STOP_UPLOADS": "", + "STOP_DOWNLOADS_HEADER": "", + "YES_STOP_DOWNLOADS": "", + "STOP_ALL_DOWNLOADS_MESSAGE": "", + "albums_count_one": "", + "albums_count": "", + "ALL_ALBUMS": "", + "ALBUMS": "", + "ALL_HIDDEN_ALBUMS": "", + "HIDDEN_ALBUMS": "", + "HIDDEN_ITEMS": "", + "ENTER_TWO_FACTOR_OTP": "", + "CREATE_ACCOUNT": "", + "COPIED": "", + "WATCH_FOLDERS": "", + "upgrade_now": "", + "renew_now": "", + "STORAGE": "", + "USED": "", + "YOU": "", + "FAMILY": "", + "FREE": "", + "OF": "", + "WATCHED_FOLDERS": "", + "NO_FOLDERS_ADDED": "", + "FOLDERS_AUTOMATICALLY_MONITORED": "", + "UPLOAD_NEW_FILES_TO_ENTE": "", + "REMOVE_DELETED_FILES_FROM_ENTE": "", + "ADD_FOLDER": "", + "STOP_WATCHING": "", + "STOP_WATCHING_FOLDER": "", + "STOP_WATCHING_DIALOG_MESSAGE": "", + "YES_STOP": "", + "CHANGE_FOLDER": "", + "FAMILY_PLAN": "", + "DOWNLOAD_LOGS": "", + "DOWNLOAD_LOGS_MESSAGE": "", + "WEAK_DEVICE": "", + "drag_and_drop_hint": "", + "AUTHENTICATE": "", + "UPLOADED_TO_SINGLE_COLLECTION": "", + "UPLOADED_TO_SEPARATE_COLLECTIONS": "", + "NEVERMIND": "", + "UPDATE_AVAILABLE": "", + "UPDATE_INSTALLABLE_MESSAGE": "", + "INSTALL_NOW": "", + "INSTALL_ON_NEXT_LAUNCH": "", + "UPDATE_AVAILABLE_MESSAGE": "", + "DOWNLOAD_AND_INSTALL": "", + "IGNORE_THIS_VERSION": "", + "TODAY": "", + "YESTERDAY": "", + "NAME_PLACEHOLDER": "", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "", + "CHOSE_THEME": "", + "more_details": "", + "ml_search": "", + "ml_search_description": "", + "ml_search_footnote": "", + "indexing": "", + "processed": "", + "indexing_status_running": "", + "indexing_status_fetching": "", + "indexing_status_scheduled": "", + "indexing_status_done": "", + "ml_search_disable": "", + "ml_search_disable_confirm": "", + "ml_consent": "", + "ml_consent_title": "", + "ml_consent_description": "", + "ml_consent_confirmation": "", + "labs": "", + "YOURS": "", + "passphrase_strength_weak": "", + "passphrase_strength_moderate": "", + "passphrase_strength_strong": "", + "preferences": "", + "language": "", + "advanced": "", + "EXPORT_DIRECTORY_DOES_NOT_EXIST": "", + "EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "", + "SUBSCRIPTION_VERIFICATION_ERROR": "", + "storage_unit": { + "b": "", + "kb": "", + "mb": "", + "gb": "", + "tb": "" + }, + "AFTER_TIME": { + "HOUR": "", + "DAY": "", + "WEEK": "", + "MONTH": "", + "YEAR": "" + }, + "COPY_LINK": "", + "DONE": "", + "LINK_SHARE_TITLE": "", + "REMOVE_LINK": "", + "CREATE_PUBLIC_SHARING": "", + "PUBLIC_LINK_CREATED": "", + "PUBLIC_LINK_ENABLED": "", + "COLLECT_PHOTOS": "", + "PUBLIC_COLLECT_SUBTEXT": "", + "STOP_EXPORT": "", + "EXPORT_PROGRESS": "", + "MIGRATING_EXPORT": "", + "RENAMING_COLLECTION_FOLDERS": "", + "TRASHING_DELETED_FILES": "", + "TRASHING_DELETED_COLLECTIONS": "", + "CONTINUOUS_EXPORT": "", + "PENDING_ITEMS": "", + "EXPORT_STARTING": "", + "delete_account_reason_label": "", + "delete_account_reason_placeholder": "", + "delete_reason": { + "missing_feature": "", + "behaviour": "", + "found_another_service": "", + "not_listed": "" + }, + "delete_account_feedback_label": "", + "delete_account_feedback_placeholder": "", + "delete_account_confirm_checkbox_label": "", + "delete_account_confirm": "", + "delete_account_confirm_message": "", + "feedback_required": "", + "feedback_required_found_another_service": "", + "RECOVER_TWO_FACTOR": "", + "at": "", + "AUTH_NEXT": "", + "AUTH_DOWNLOAD_MOBILE_APP": "", + "HIDDEN": "", + "HIDE": "", + "UNHIDE": "", + "UNHIDE_TO_COLLECTION": "", + "SORT_BY": "", + "NEWEST_FIRST": "", + "OLDEST_FIRST": "", + "CONVERSION_FAILED_NOTIFICATION_MESSAGE": "", + "SELECT_COLLECTION": "", + "PIN_ALBUM": "", + "UNPIN_ALBUM": "", + "DOWNLOAD_COMPLETE": "", + "DOWNLOADING_COLLECTION": "", + "DOWNLOAD_FAILED": "", + "DOWNLOAD_PROGRESS": "", + "CHRISTMAS": "", + "CHRISTMAS_EVE": "", + "NEW_YEAR": "", + "NEW_YEAR_EVE": "", + "IMAGE": "", + "VIDEO": "", + "LIVE_PHOTO": "", + "editor": { + "crop": "" + }, + "CONVERT": "", + "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": "", + "DOWNLOAD_EDITED": "", + "SAVE_A_COPY_TO_ENTE": "", + "RESTORE_ORIGINAL": "", + "TRANSFORM": "", + "COLORS": "", + "FLIP": "", + "ROTATION": "", + "RESET": "", + "PHOTO_EDITOR": "", + "FASTER_UPLOAD": "", + "FASTER_UPLOAD_DESCRIPTION": "", + "CAST_ALBUM_TO_TV": "", + "ENTER_CAST_PIN_CODE": "", + "PAIR_DEVICE_TO_TV": "", + "TV_NOT_FOUND": "", + "AUTO_CAST_PAIR": "", + "AUTO_CAST_PAIR_DESC": "", + "PAIR_WITH_PIN": "", + "CHOOSE_DEVICE_FROM_BROWSER": "", + "PAIR_WITH_PIN_DESC": "", + "VISIT_CAST_ENTE_IO": "", + "CAST_AUTO_PAIR_FAILED": "", + "FREEHAND": "", + "APPLY_CROP": "", + "PHOTO_EDIT_REQUIRED_TO_SAVE": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", + "CREATED_AT": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", + "autogenerated_first_album_name": "", + "autogenerated_default_album_name": "", + "developer_settings": "", + "server_endpoint": "", + "more_information": "", + "save": "" +} diff --git a/web/packages/base/locales/te-IN/translation.json b/web/packages/base/locales/te-IN/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/te-IN/translation.json +++ b/web/packages/base/locales/te-IN/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/th-TH/translation.json b/web/packages/base/locales/th-TH/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/th-TH/translation.json +++ b/web/packages/base/locales/th-TH/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/ti-ER/translation.json b/web/packages/base/locales/ti-ER/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/ti-ER/translation.json +++ b/web/packages/base/locales/ti-ER/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/tr-TR/translation.json b/web/packages/base/locales/tr-TR/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/tr-TR/translation.json +++ b/web/packages/base/locales/tr-TR/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/zh-CN/translation.json b/web/packages/base/locales/zh-CN/translation.json index 6064de6295..66fc6fa6f9 100644 --- a/web/packages/base/locales/zh-CN/translation.json +++ b/web/packages/base/locales/zh-CN/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "您是如何知道Ente的? (可选的)", "REFERRAL_INFO": "我们不跟踪应用程序安装情况,如果您告诉我们您是在哪里找到我们的,将会有所帮助!", "PASSPHRASE_MATCH_ERROR": "两次输入的密码不一致", + "create_albums": "创建相册", "CREATE_COLLECTION": "新建相册", "ENTER_ALBUM_NAME": "相册名称", "CLOSE_OPTION": "关闭 (或按Esc键)", @@ -98,7 +99,7 @@ "MULTI_FOLDER_UPLOAD": "检测到多个文件夹", "UPLOAD_STRATEGY_CHOICE": "你想要上传他们到", "UPLOAD_STRATEGY_SINGLE_COLLECTION": "单个相册", - "OR": "或者", + "OR": "还是", "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "独立相册", "SESSION_EXPIRED_MESSAGE": "您的会话已过期,请重新登录以继续", "SESSION_EXPIRED": "会话已过期", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "缩略图生成失败", "UNSUPPORTED_FILES": "不支持的文件", "SUCCESSFUL_UPLOADS": "上传成功", - "SKIPPED_INFO": "跳过这些,因为在同一相册中有具有匹配名称的文件", + "SKIPPED_INFO": "跳过这些文件,因为同一相册中有名称和内容相匹配的文件", "UNSUPPORTED_INFO": "Ente 尚不支持这些文件格式", "BLOCKED_UPLOADS": "已阻止上传", "INPROGRESS_METADATA_EXTRACTION": "进行中", diff --git a/web/packages/new/photos/components/MLSettings.tsx b/web/packages/new/photos/components/MLSettings.tsx index eeff4d1be8..dde90b5368 100644 --- a/web/packages/new/photos/components/MLSettings.tsx +++ b/web/packages/new/photos/components/MLSettings.tsx @@ -8,7 +8,6 @@ import { enableML, mlStatusSnapshot, mlStatusSubscribe, - wipCluster, wipClusterEnable, type MLStatus, } from "@/new/photos/services/ml"; @@ -341,8 +340,7 @@ const ManageML: React.FC = ({ // TODO-Cluster const router = useRouter(); - const wipClusterNow = () => wipCluster(); - const wipClusterShowNow = () => router.push("/cluster-debug"); + const wipClusterDebug = () => router.push("/cluster-debug"); return ( @@ -391,28 +389,15 @@ const ManageML: React.FC = ({ - - )} - {showClusterOpt && ( - - - - - diff --git a/web/packages/new/photos/services/ml/cluster-hdb.ts b/web/packages/new/photos/services/ml/cluster-hdb.ts new file mode 100644 index 0000000000..3ecda4b5bc --- /dev/null +++ b/web/packages/new/photos/services/ml/cluster-hdb.ts @@ -0,0 +1,35 @@ +import { Hdbscan, type DebugInfo } from "hdbscan"; + +/** + * Each "cluster" is a list of indexes of the embeddings belonging to that + * particular cluster. + */ +export type EmbeddingCluster = number[]; + +export interface ClusterHdbscanResult { + clusters: EmbeddingCluster[]; + noise: number[]; + debugInfo?: DebugInfo; +} + +/** + * Cluster the given {@link embeddings} using hdbscan. + */ +export const clusterHdbscan = ( + embeddings: number[][], +): ClusterHdbscanResult => { + const hdbscan = new Hdbscan({ + input: embeddings, + minClusterSize: 3, + minSamples: 5, + clusterSelectionEpsilon: 0.6, + clusterSelectionMethod: "leaf", + debug: false, + }); + + return { + clusters: hdbscan.getClusters(), + noise: hdbscan.getNoise(), + debugInfo: hdbscan.getDebugInfo(), + }; +}; diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts deleted file mode 100644 index 9e07b2812c..0000000000 --- a/web/packages/new/photos/services/ml/cluster-new.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { newNonSecureID } from "@/base/id-worker"; -import log from "@/base/log"; -import { ensure } from "@/utils/ensure"; -import { clusterFacesHdbscan } from "./cluster"; -import { clusterGroups, faceClusters } from "./db"; -import type { Face, FaceIndex } from "./face"; -import { dotProduct } from "./math"; - -/** - * A face cluster is an set of faces. - * - * Each cluster has an id so that a {@link CGroup} can refer to it. - * - * The cluster is not directly synced to remote. Only clusters that the user - * interacts with get synced to remote, as part of a {@link CGroup}. - */ -export interface FaceCluster { - /** - * A nanoid for this cluster. - */ - id: string; - /** - * An unordered set of ids of the faces that belong to this cluster. - * - * For ergonomics of transportation and persistence this is an array, but it - * should conceptually be thought of as a set. - */ - faceIDs: string[]; -} - -/** - * A cgroup ("cluster group") is a group of clusters (possibly containing a - * single cluster) that the user has interacted with. - * - * Interactions include hiding, merging and giving a name and/or a cover photo. - * - * The most frequent interaction is naming a {@link FaceCluster}, which promotes - * it to a become a {@link CGroup}. The promotion comes with the ability to be - * synced with remote (as a "cgroup" user entity). - * - * There after, the user may attach more clusters to the same {@link CGroup}. - * - * > A named cluster group can be thought of as a "person", though this is not - * > necessarily an accurate characterization. e.g. there can be a named cluster - * > group that contains face clusters of pets. - * - * The other form of interaction is hiding. The user may hide a single (unnamed) - * cluster, or they may hide an named {@link CGroup}. In both cases, we promote - * the cluster to a CGroup if needed so that their request to hide gets synced. - * - * While in our local representation we separately maintain clusters and link to - * them from within CGroups by their clusterID, in the remote representation - * clusters themselves don't get synced. Instead, the "cgroup" entities synced - * with remote contain the clusters within themselves. So a group that gets - * synced with remote looks something like: - * - * { id, name, clusters: [{ clusterID, faceIDs }] } - * - */ -export interface CGroup { - /** - * A nanoid for this cluster group. - * - * This is the ID of the "cgroup" user entity (the envelope), and it is not - * contained as part of the group entity payload itself. - */ - id: string; - /** - * A name assigned by the user to this cluster group. - * - * The client should handle both empty strings and undefined as indicating a - * cgroup without a name. When the client needs to set this to an "empty" - * value, which happens when hiding an unnamed cluster, it should it to an - * empty string. That is, expect `"" | undefined`, but set `""`. - */ - name: string | undefined; - /** - * An unordered set of ids of the clusters that belong to this group. - * - * For ergonomics of transportation and persistence this is an array, but it - * should conceptually be thought of as a set. - */ - clusterIDs: string[]; - /** - * True if this cluster group should be hidden. - * - * The user can hide both named cluster groups and single unnamed clusters. - * If the user hides a single cluster that was offered as a suggestion to - * them on a client, the client will create a new unnamed cgroup containing - * it, and set its hidden flag to sync it with remote (so that other clients - * can also stop showing this cluster). - */ - isHidden: boolean; - /** - * The ID of the face that should be used as the cover photo for this - * cluster group (if the user has set one). - * - * This is similar to the [@link displayFaceID}, the difference being: - * - * - {@link avatarFaceID} is the face selected by the user. - * - * - {@link displayFaceID} is the automatic placeholder, and only comes - * into effect if the user has not explicitly selected a face. - */ - avatarFaceID: string | undefined; - /** - * Locally determined ID of the "best" face that should be used as the - * display face, to represent this cluster group in the UI. - * - * This property is not synced with remote. For more details, see - * {@link avatarFaceID}. - */ - displayFaceID: string | undefined; -} - -// TODO-Cluster -export interface FaceNeighbours { - face: Face; - neighbours: FaceNeighbour[]; -} - -interface FaceNeighbour { - face: Face; - cosineSimilarity: number; -} - -/** - * Cluster faces into groups. - * - * [Note: Face clustering algorithm] - * - * A cgroup (cluster group) consists of clusters, each of which itself is a set - * of faces. - * - * cgroup << cluster << face - * - * The clusters are generated locally by clients using the following algorithm: - * - * 1. clusters = [] initially, or fetched from remote. - * - * 2. For each face, find its nearest neighbour in the embedding space. - * - * 3. If no such neighbour is found within our threshold, create a new cluster. - * - * 4. Otherwise assign this face to the same cluster as its nearest neighbour. - * - * This user can then tweak the output of the algorithm by performing the - * following actions to the list of clusters that they can see: - * - * - They can provide a name for a cluster ("name a person"). This upgrades a - * cluster into a "cgroup", which is an entity that gets synced via remote - * to the user's other clients. - * - * - They can attach more clusters to a cgroup ("merge clusters") - * - * - They can remove a cluster from a cgroup ("break clusters"). - * - * After clustering, we also do some routine cleanup. Faces belonging to files - * that have been deleted (including those in Trash) should be pruned off. - * - * We should not make strict assumptions about the clusters we get from remote. - * In particular, the same face ID can be in different clusters. In such cases - * we should assign it arbitrarily assign it to the last cluster we find it in. - * Such leeway is intentionally provided to allow clients some slack in how they - * implement the sync without needing to make an blocking API request for every - * user interaction. - */ -export const clusterFaces = async (faceIndexes: FaceIndex[]) => { - const t = Date.now(); - - // A flattened array of faces. - // TODO-Cluster note the 2k slice - const faces = [...enumerateFaces(faceIndexes)].slice(0, 2000); - - // Start with the clusters we already have (either from a previous indexing, - // or fetched from remote). - const clusters = await faceClusters(); - - // For fast reverse lookup - map from cluster ids to their index in the - // clusters array. - const clusterIndexForClusterID = new Map(clusters.map((c, i) => [c.id, i])); - - // For fast reverse lookup - map from face ids to the id of the cluster to - // which they belong. - const clusterIDForFaceID = new Map( - clusters.flatMap((c) => c.faceIDs.map((id) => [id, c.id] as const)), - ); - - // A function to generate new cluster IDs. - const newClusterID = () => newNonSecureID("cluster_"); - - const faceAndNeigbours: FaceNeighbours[] = []; - - // For each face, - for (const [i, fi] of faces.entries()) { - // If the face is already part of a cluster, then skip it. - if (clusterIDForFaceID.get(fi.faceID)) continue; - - // Find the nearest neighbour from among all the other faces. - let nn: Face | undefined; - let nnCosineSimilarity = 0; - let neighbours: FaceNeighbour[] = []; - for (let j = 0; j < faces.length; j++) { - // ! This is an O(n^2) loop, be careful when adding more code here. - - // TODO-Cluster Commenting this here and moving it downward - // // Skip ourselves. - // if (i == j) continue; - - // Can't find a way of avoiding the null assertion here. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const fj = faces[j]!; - - // The vectors are already normalized, so we can directly use their - // dot product as their cosine similarity. - const csim = dotProduct(fi.embedding, fj.embedding); - - // TODO-Cluster Delete me and uncomment the check above - // Skip ourselves. - if (i == j) { - neighbours.push({ face: fj, cosineSimilarity: csim }); - continue; - } - - const threshold = fi.blur < 100 || fj.blur < 100 ? 0.7 : 0.6; - if (csim > threshold && csim > nnCosineSimilarity) { - nn = fj; - nnCosineSimilarity = csim; - } - - neighbours.push({ face: fj, cosineSimilarity: csim }); - } - - neighbours = neighbours.sort( - (a, b) => b.cosineSimilarity - a.cosineSimilarity, - ); - faceAndNeigbours.push({ face: fi, neighbours }); - - const { faceID } = fi; - - if (nn) { - // Found a neighbour near enough. - const nnFaceID = nn.faceID; - - // Find the cluster the nearest neighbour belongs to, if any. - const nnClusterID = clusterIDForFaceID.get(nn.faceID); - - if (nnClusterID) { - // If the neighbour is already part of a cluster, also add - // ourselves to that cluster. - - const nnClusterIndex = ensure( - clusterIndexForClusterID.get(nnClusterID), - ); - clusters[nnClusterIndex]?.faceIDs.push(faceID); - clusterIDForFaceID.set(faceID, nnClusterID); - } else { - // Otherwise create a new cluster with us and our nearest - // neighbour. - - const cluster = { - id: newClusterID(), - faceIDs: [faceID, nnFaceID], - }; - clusterIndexForClusterID.set(cluster.id, clusters.length); - clusterIDForFaceID.set(faceID, cluster.id); - clusterIDForFaceID.set(nnFaceID, cluster.id); - clusters.push(cluster); - } - } else { - // We didn't find a neighbour within the threshold. Create a new - // cluster with only this face. - - const cluster = { id: newClusterID(), faceIDs: [faceID] }; - clusterIndexForClusterID.set(cluster.id, clusters.length); - clusterIDForFaceID.set(faceID, cluster.id); - clusters.push(cluster); - } - } - - // Prune too small clusters. - const validClusters = clusters.filter(({ faceIDs }) => faceIDs.length > 1); - - let cgroups = await clusterGroups(); - - // TODO-Cluster - Currently we're not syncing with remote or saving anything - // locally, so cgroups will be empty. Create a temporary (unsaved, unsynced) - // cgroup, one per cluster. - cgroups = cgroups.concat( - validClusters.map((c) => ({ - id: c.id, - name: undefined, - clusterIDs: [c.id], - isHidden: false, - avatarFaceID: undefined, - displayFaceID: undefined, - })), - ); - - // For each cluster group, use the highest scoring face in any of its - // clusters as its display face. - const faceForFaceID = new Map(faces.map((f) => [f.faceID, f])); - for (const cgroup of cgroups) { - cgroup.displayFaceID = cgroup.clusterIDs - .map((clusterID) => clusterIndexForClusterID.get(clusterID)) - .filter((i) => i !== undefined) /* 0 is a valid index */ - .flatMap((i) => clusters[i]?.faceIDs ?? []) - .map((faceID) => faceForFaceID.get(faceID)) - .filter((face) => !!face) - .reduce((max, face) => - max.score > face.score ? max : face, - ).faceID; - } - - log.info("ml/cluster", { - faces, - validClusters, - clusterIndexForClusterID: Object.fromEntries(clusterIndexForClusterID), - clusterIDForFaceID: Object.fromEntries(clusterIDForFaceID), - cgroups, - }); - log.info( - `Clustered ${faces.length} faces into ${validClusters.length} clusters (${Date.now() - t} ms)`, - ); - - return { faces, clusters: validClusters, cgroups, faceAndNeigbours }; -}; - -/** - * A generator function that returns a stream of {faceID, embedding} values, - * flattening all the the faces present in the given {@link faceIndices}. - */ -function* enumerateFaces(faceIndices: FaceIndex[]) { - for (const fi of faceIndices) { - for (const f of fi.faces) { - yield f; - } - } -} - -export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { - const t = Date.now(); - - // A flattened array of faces. - // TODO-Cluster note the 2k slice - const faces = [...enumerateFaces(faceIndexes)].slice(0, 2000); - - const faceEmbeddings = faces.map(({ embedding }) => embedding); - - const { - clusters: clusterIndices, - noise, - debugInfo, - } = clusterFacesHdbscan(faceEmbeddings); - - log.info({ method: "hdbscan", clusterIndices, noise, debugInfo }); - log.info( - `Clustered ${faces.length} faces into ${clusterIndices.length} clusters (${Date.now() - t} ms)`, - ); - - // For fast reverse lookup - map from cluster ids to their index in the - // clusters array. - const clusterIndexForClusterID = new Map(); - - // For fast reverse lookup - map from face ids to the id of the cluster to - // which they belong. - const clusterIDForFaceID = new Map(); - - // A function to generate new cluster IDs. - const newClusterID = () => newNonSecureID("cluster_"); - - // Convert the numerical face indices into the result. - const clusters: FaceCluster[] = []; - for (const [ci, faceIndices] of clusterIndices.entries()) { - const clusterID = newClusterID(); - const faceIDs: string[] = []; - clusterIndexForClusterID.set(clusterID, ci); - for (const fi of faceIndices) { - // Can't find a way of avoiding the null assertion here. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const face = faces[fi]!; - clusterIDForFaceID.set(face.faceID, clusterID); - faceIDs.push(face.faceID); - } - clusters.push({ id: clusterID, faceIDs }); - } - - // Convert into the data structure we're using to debug/visualize. - const faceAndNeigbours: FaceNeighbours[] = []; - for (const fi of faces) { - let neighbours: FaceNeighbour[] = []; - for (const fj of faces) { - // The vectors are already normalized, so we can directly use their - // dot product as their cosine similarity. - const csim = dotProduct(fi.embedding, fj.embedding); - neighbours.push({ face: fj, cosineSimilarity: csim }); - } - - neighbours = neighbours.sort( - (a, b) => b.cosineSimilarity - a.cosineSimilarity, - ); - - faceAndNeigbours.push({ face: fi, neighbours }); - } - - // Prune too small clusters. - const validClusters = clusters.filter(({ faceIDs }) => faceIDs.length > 1); - - let cgroups = await clusterGroups(); - - // TODO-Cluster - Currently we're not syncing with remote or saving anything - // locally, so cgroups will be empty. Create a temporary (unsaved, unsynced) - // cgroup, one per cluster. - cgroups = cgroups.concat( - validClusters.map((c) => ({ - id: c.id, - name: undefined, - clusterIDs: [c.id], - isHidden: false, - avatarFaceID: undefined, - displayFaceID: undefined, - })), - ); - - // For each cluster group, use the highest scoring face in any of its - // clusters as its display face. - const faceForFaceID = new Map(faces.map((f) => [f.faceID, f])); - for (const cgroup of cgroups) { - cgroup.displayFaceID = cgroup.clusterIDs - .map((clusterID) => clusterIndexForClusterID.get(clusterID)) - .filter((i) => i !== undefined) /* 0 is a valid index */ - .flatMap((i) => clusters[i]?.faceIDs ?? []) - .map((faceID) => faceForFaceID.get(faceID)) - .filter((face) => !!face) - .reduce((max, face) => - max.score > face.score ? max : face, - ).faceID; - } - - log.info("ml/cluster", { - faces, - validClusters, - clusterIndexForClusterID: Object.fromEntries(clusterIndexForClusterID), - clusterIDForFaceID: Object.fromEntries(clusterIDForFaceID), - cgroups, - }); - log.info( - `Clustered ${faces.length} faces into ${validClusters.length} clusters (${Date.now() - t} ms)`, - ); - - return { faces, clusters: validClusters, cgroups, faceAndNeigbours }; -}; diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts index ff62f466a9..254988dea9 100644 --- a/web/packages/new/photos/services/ml/cluster.ts +++ b/web/packages/new/photos/services/ml/cluster.ts @@ -1,35 +1,551 @@ -import { Hdbscan, type DebugInfo } from "hdbscan"; +import { newNonSecureID } from "@/base/id-worker"; +import log from "@/base/log"; +import { ensure } from "@/utils/ensure"; +import { type EmbeddingCluster, clusterHdbscan } from "./cluster-hdb"; +import type { Face, FaceIndex } from "./face"; +import { dotProduct } from "./math"; -export type Cluster = number[]; - -export interface ClusterFacesResult { - clusters: Cluster[]; - noise: Cluster; - debugInfo?: DebugInfo; +/** + * A face cluster is an set of faces. + * + * Each cluster has an id so that a {@link CGroup} can refer to it. + * + * The cluster is not directly synced to remote. Only clusters that the user + * interacts with get synced to remote, as part of a {@link CGroup}. + */ +export interface FaceCluster { + /** + * A nanoid for this cluster. + */ + id: string; + /** + * An unordered set of ids of the faces that belong to this cluster. + * + * For ergonomics of transportation and persistence this is an array, but it + * should conceptually be thought of as a set. + */ + faceIDs: string[]; } /** - * Cluster the given {@link faceEmbeddings}. + * A cgroup ("cluster group") is a group of clusters (possibly containing a + * single cluster) that the user has interacted with. + * + * Interactions include hiding, merging and giving a name and/or a cover photo. + * + * The most frequent interaction is naming a {@link FaceCluster}, which promotes + * it to a become a {@link CGroup}. The promotion comes with the ability to be + * synced with remote (as a "cgroup" user entity). + * + * There after, the user may attach more clusters to the same {@link CGroup}. + * + * > A named cluster group can be thought of as a "person", though this is not + * > necessarily an accurate characterization. e.g. there can be a named cluster + * > group that contains face clusters of pets. + * + * The other form of interaction is hiding. The user may hide a single (unnamed) + * cluster, or they may hide an named {@link CGroup}. In both cases, we promote + * the cluster to a CGroup if needed so that their request to hide gets synced. + * + * While in our local representation we separately maintain clusters and link to + * them from within CGroups by their clusterID, in the remote representation + * clusters themselves don't get synced. Instead, the "cgroup" entities synced + * with remote contain the clusters within themselves. So a group that gets + * synced with remote looks something like: + * + * { id, name, clusters: [{ clusterID, faceIDs }] } * - * @param faceEmbeddings An array of embeddings produced by our face indexing - * pipeline. Each embedding is for a face detected in an image (a single image - * may have multiple faces detected within it). */ -export const clusterFacesHdbscan = ( - faceEmbeddings: number[][], -): ClusterFacesResult => { - const hdbscan = new Hdbscan({ - input: faceEmbeddings, - minClusterSize: 3, - minSamples: 5, - clusterSelectionEpsilon: 0.6, - clusterSelectionMethod: "leaf", - debug: true, +export interface CGroup { + /** + * A nanoid for this cluster group. + * + * This is the ID of the "cgroup" user entity (the envelope), and it is not + * contained as part of the group entity payload itself. + */ + id: string; + /** + * A name assigned by the user to this cluster group. + * + * The client should handle both empty strings and undefined as indicating a + * cgroup without a name. When the client needs to set this to an "empty" + * value, which happens when hiding an unnamed cluster, it should it to an + * empty string. That is, expect `"" | undefined`, but set `""`. + */ + name: string | undefined; + /** + * An unordered set of ids of the clusters that belong to this group. + * + * For ergonomics of transportation and persistence this is an array, but it + * should conceptually be thought of as a set. + */ + clusterIDs: string[]; + /** + * True if this cluster group should be hidden. + * + * The user can hide both named cluster groups and single unnamed clusters. + * If the user hides a single cluster that was offered as a suggestion to + * them on a client, the client will create a new unnamed cgroup containing + * it, and set its hidden flag to sync it with remote (so that other clients + * can also stop showing this cluster). + */ + isHidden: boolean; + /** + * The ID of the face that should be used as the cover photo for this + * cluster group (if the user has set one). + * + * This is similar to the [@link displayFaceID}, the difference being: + * + * - {@link avatarFaceID} is the face selected by the user. + * + * - {@link displayFaceID} is the automatic placeholder, and only comes + * into effect if the user has not explicitly selected a face. + */ + avatarFaceID: string | undefined; + /** + * Locally determined ID of the "best" face that should be used as the + * display face, to represent this cluster group in the UI. + * + * This property is not synced with remote. For more details, see + * {@link avatarFaceID}. + */ + displayFaceID: string | undefined; +} + +export interface ClusteringOpts { + method: "linear" | "hdbscan"; + minBlur: number; + minScore: number; + minClusterSize: number; + batchSize: number; + joinThreshold: number; +} + +export interface ClusterPreview { + clusterSize: number; + faces: ClusterPreviewFace[]; +} + +export interface ClusterPreviewFace { + face: Face; + cosineSimilarity: number; + wasMerged: boolean; +} + +/** + * Cluster faces into groups. + * + * [Note: Face clustering algorithm] + * + * A cgroup (cluster group) consists of clusters, each of which itself is a set + * of faces. + * + * cgroup << cluster << face + * + * The clusters are generated locally by clients using the following algorithm: + * + * 1. clusters = [] initially, or fetched from remote. + * + * 2. For each face, find its nearest neighbour in the embedding space. + * + * 3. If no such neighbour is found within our threshold, create a new cluster. + * + * 4. Otherwise assign this face to the same cluster as its nearest neighbour. + * + * This user can then tweak the output of the algorithm by performing the + * following actions to the list of clusters that they can see: + * + * - They can provide a name for a cluster ("name a person"). This upgrades a + * cluster into a "cgroup", which is an entity that gets synced via remote + * to the user's other clients. + * + * - They can attach more clusters to a cgroup ("merge clusters") + * + * - They can remove a cluster from a cgroup ("break clusters"). + * + * After clustering, we also do some routine cleanup. Faces belonging to files + * that have been deleted (including those in Trash) should be pruned off. + * + * We should not make strict assumptions about the clusters we get from remote. + * In particular, the same face ID can be in different clusters. In such cases + * we should assign it arbitrarily assign it to the last cluster we find it in. + * Such leeway is intentionally provided to allow clients some slack in how they + * implement the sync without needing to make an blocking API request for every + * user interaction. + */ +export const clusterFaces = ( + faceIndexes: FaceIndex[], + opts: ClusteringOpts, +) => { + const { + method, + batchSize, + minBlur, + minScore, + minClusterSize, + joinThreshold, + } = opts; + const t = Date.now(); + + // A flattened array of faces. + const allFaces = [...enumerateFaces(faceIndexes)]; + const faces = allFaces + .filter((f) => f.blur > minBlur) + .filter((f) => f.score > minScore); + // .slice(0, 2000); + + // For fast reverse lookup - map from face ids to the face. + const faceForFaceID = new Map(faces.map((f) => [f.faceID, f])); + + const faceEmbeddings = faces.map(({ embedding }) => embedding); + + // For fast reverse lookup - map from cluster ids to their index in the + // clusters array. + const clusterIndexForClusterID = new Map(); + + // For fast reverse lookup - map from the id of a face to the id of the + // cluster to which it belongs. + const clusterIDForFaceID = new Map(); + + // Keeps track of which faces were found by the OG clustering algorithm, and + // which were sublimated in from a later match. + const wasMergedFaceIDs = new Set(); + + // A function to chain two reverse lookup. + const firstFaceOfCluster = (cluster: FaceCluster) => + ensure(faceForFaceID.get(ensure(cluster.faceIDs[0]))); + + // A function to generate new cluster IDs. + const newClusterID = () => newNonSecureID("cluster_"); + + // The resultant clusters. + // TODO-Cluster Later on, instead of starting from a blank slate, this will + // be list of existing clusters we fetch from remote. + const clusters: FaceCluster[] = []; + + // Process the faces in batches. + for (let i = 0; i < faceEmbeddings.length; i += batchSize) { + const it = Date.now(); + + const embeddingBatch = faceEmbeddings.slice(i, i + batchSize); + let embeddingClusters: EmbeddingCluster[]; + if (method == "hdbscan") { + ({ clusters: embeddingClusters } = clusterHdbscan(embeddingBatch)); + } else { + ({ clusters: embeddingClusters } = clusterLinear( + embeddingBatch, + joinThreshold, + )); + } + + log.info( + `${method} produced ${embeddingClusters.length} clusters from ${embeddingBatch.length} faces (${Date.now() - it} ms)`, + ); + + // Merge the new clusters we got from this batch into the existing + // clusters if they are "near" enough (using some heuristic). + // + // We need to ensure we don't change any of the existing cluster IDs, + // since these might be existing clusters we got from remote. + + // Create a copy so that we don't modify existing clusters as we're + // iterating. + const existingClusters = [...clusters]; + + for (const newCluster of embeddingClusters) { + // Find the existing cluster whose (arbitrarily chosen) first face + // is the nearest neighbour of the (arbitrarily chosen) first face + // of the cluster produced in this batch. + + const newFace = ensure(faces[i + ensure(newCluster[0])]); + + let nnCluster: FaceCluster | undefined; + let nnCosineSimilarity = 0; + for (const existingCluster of existingClusters) { + const existingFace = firstFaceOfCluster(existingCluster); + + // The vectors are already normalized, so we can directly use their + // dot product as their cosine similarity. + const csim = dotProduct( + existingFace.embedding, + newFace.embedding, + ); + + // Use a higher cosine similarity threshold if either of the two + // faces are blurry. + const threshold = + existingFace.blur < 200 || newFace.blur < 200 + ? 0.9 + : joinThreshold; + if (csim > threshold && csim > nnCosineSimilarity) { + nnCluster = existingCluster; + nnCosineSimilarity = csim; + } + } + + // If we found an existing cluster that is near enough, merge the + // new cluster into the existing cluster. + if (nnCluster) { + for (const j of newCluster) { + const { faceID } = ensure(faces[i + j]); + wasMergedFaceIDs.add(faceID); + nnCluster.faceIDs.push(faceID); + clusterIDForFaceID.set(faceID, nnCluster.id); + } + } else { + // Otherwise retain the new cluster. + const clusterID = newClusterID(); + const faceIDs: string[] = []; + for (const j of newCluster) { + const { faceID } = ensure(faces[i + j]); + faceIDs.push(faceID); + clusterIDForFaceID.set(faceID, clusterID); + } + clusterIndexForClusterID.set(clusterID, clusters.length); + clusters.push({ id: clusterID, faceIDs }); + } + } + } + + // Prune clusters that are smaller than the threshold. + const validClusters = clusters.filter( + (cs) => cs.faceIDs.length > minClusterSize, + ); + + const sortedClusters = validClusters.sort( + (a, b) => b.faceIDs.length - a.faceIDs.length, + ); + + // Convert into the data structure we're using to debug/visualize. + const clusterPreviewClusters = + sortedClusters.length < 60 + ? sortedClusters + : sortedClusters.slice(0, 30).concat(sortedClusters.slice(-30)); + const clusterPreviews = clusterPreviewClusters.map((cluster) => { + const faces = cluster.faceIDs.map((id) => + ensure(faceForFaceID.get(id)), + ); + const topFace = faces.reduce((top, face) => + top.score > face.score ? top : face, + ); + const previewFaces: ClusterPreviewFace[] = faces.map((face) => { + const csim = dotProduct(topFace.embedding, face.embedding); + const wasMerged = wasMergedFaceIDs.has(face.faceID); + return { face, cosineSimilarity: csim, wasMerged }; + }); + return { + clusterSize: cluster.faceIDs.length, + faces: previewFaces + .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity) + .slice(0, 50), + }; }); + // TODO-Cluster - Currently we're not syncing with remote or saving anything + // locally, so cgroups will be empty. Create a temporary (unsaved, unsynced) + // cgroup, one per cluster. + + const cgroups: CGroup[] = []; + for (const cluster of sortedClusters) { + const faces = cluster.faceIDs.map((id) => + ensure(faceForFaceID.get(id)), + ); + const topFace = faces.reduce((top, face) => + top.score > face.score ? top : face, + ); + cgroups.push({ + id: cluster.id, + name: undefined, + clusterIDs: [cluster.id], + isHidden: false, + avatarFaceID: undefined, + displayFaceID: topFace.faceID, + }); + } + + const totalFaceCount = allFaces.length; + const filteredFaceCount = faces.length; + const clusteredFaceCount = clusterIDForFaceID.size; + const unclusteredFaceCount = filteredFaceCount - clusteredFaceCount; + + const unclusteredFaces = faces.filter( + ({ faceID }) => !clusterIDForFaceID.has(faceID), + ); + + const timeTakenMs = Date.now() - t; + log.info( + `Clustered ${faces.length} faces into ${sortedClusters.length} clusters, ${faces.length - clusterIDForFaceID.size} faces remain unclustered (${timeTakenMs} ms)`, + ); + return { - clusters: hdbscan.getClusters(), - noise: hdbscan.getNoise(), - debugInfo: hdbscan.getDebugInfo(), + totalFaceCount, + filteredFaceCount, + clusteredFaceCount, + unclusteredFaceCount, + clusterPreviews, + clusters: sortedClusters, + cgroups, + unclusteredFaces: unclusteredFaces, + timeTakenMs, }; }; + +/** + * A generator function that returns a stream of {faceID, embedding} values, + * flattening all the the faces present in the given {@link faceIndices}. + */ +function* enumerateFaces(faceIndices: FaceIndex[]) { + for (const fi of faceIndices) { + for (const f of fi.faces) { + yield f; + } + } +} + +interface ClusterLinearResult { + clusters: EmbeddingCluster[]; +} + +// TODO-Cluster remove me +export const clusterLinear_Direct = ( + embeddings: number[][], + threshold: number, +): ClusterLinearResult => { + const clusters: EmbeddingCluster[] = []; + const clusterIndexForEmbeddingIndex = new Map(); + // For each embedding + for (const [i, ei] of embeddings.entries()) { + // If the embedding is already part of a cluster, then skip it. + if (clusterIndexForEmbeddingIndex.get(i)) continue; + + // Find the nearest neighbour from among all the other embeddings. + let nnIndex: number | undefined; + let nnCosineSimilarity = 0; + for (const [j, ej] of embeddings.entries()) { + // ! This is an O(n^2) loop, be careful when adding more code here. + + // Skip ourselves. + if (i == j) continue; + + // The vectors are already normalized, so we can directly use their + // dot product as their cosine similarity. + const csim = dotProduct(ei, ej); + if (csim > threshold && csim > nnCosineSimilarity) { + nnIndex = j; + nnCosineSimilarity = csim; + } + } + + if (nnIndex) { + // Find the cluster the nearest neighbour belongs to, if any. + const nnClusterIndex = clusterIndexForEmbeddingIndex.get(nnIndex); + + if (nnClusterIndex) { + // If the neighbour is already part of a cluster, also add + // ourselves to that cluster. + + ensure(clusters[nnClusterIndex]).push(i); + clusterIndexForEmbeddingIndex.set(i, nnClusterIndex); + } else { + // Otherwise create a new cluster with us and our nearest + // neighbour. + + clusterIndexForEmbeddingIndex.set(i, clusters.length); + clusterIndexForEmbeddingIndex.set(nnIndex, clusters.length); + clusters.push([i, nnIndex]); + } + } else { + // We didn't find a neighbour within the threshold. Create a new + // cluster with only this embedding. + + clusterIndexForEmbeddingIndex.set(i, clusters.length); + clusters.push([i]); + } + } + + // Prune singleton clusters. + const validClusters = clusters.filter((cs) => cs.length > 1); + + return { clusters: validClusters }; +}; + +const clusterLinear = ( + embeddings: number[][], + threshold: number, +): ClusterLinearResult => { + const clusters: EmbeddingCluster[] = []; + const clusterIndexForEmbeddingIndex = new Map(); + // For each embedding + for (const [i, ei] of embeddings.entries()) { + // If the embedding is already part of a cluster, then skip it. + if (clusterIndexForEmbeddingIndex.get(i)) continue; + + // Find the nearest neighbour from among all the other embeddings. + let nnIndex: number | undefined; + let nnCosineSimilarity = 0; + // Find the nearest cluster from among all the existing clusters. + let nClusterIndex: number | undefined; + let nClusterCosineSimilarity = 0; + for (const [j, ej] of embeddings.entries()) { + // ! This is an O(n^2) loop, be careful when adding more code here. + + // Skip ourselves. + if (i == j) continue; + + // The vectors are already normalized, so we can directly use their + // dot product as their cosine similarity. + const csim = dotProduct(ei, ej); + if (csim > threshold) { + if (csim > nnCosineSimilarity) { + nnIndex = j; + nnCosineSimilarity = csim; + } + if (csim > nClusterCosineSimilarity) { + const jClusterIndex = clusterIndexForEmbeddingIndex.get(j); + if (jClusterIndex) { + nClusterIndex = jClusterIndex; + nClusterCosineSimilarity = csim; + } + } + } + } + + if (nClusterIndex) { + // Found a neighbouring cluster close enough, add ourselves to that. + + ensure(clusters[nClusterIndex]).push(i); + clusterIndexForEmbeddingIndex.set(i, nClusterIndex); + } else if (nnIndex) { + // Find the cluster the nearest neighbour belongs to, if any. + const nnClusterIndex = clusterIndexForEmbeddingIndex.get(nnIndex); + + if (nnClusterIndex) { + // TODO-Cluster remove this case. + // If the neighbour is already part of a cluster, also add + // ourselves to that cluster. + + // ensure(clusters[nnClusterIndex]).push(i); + // clusterIndexForEmbeddingIndex.set(i, nnClusterIndex); + throw new Error("We shouldn't have reached here"); + } else { + // Otherwise create a new cluster with us and our nearest + // neighbour. + + clusterIndexForEmbeddingIndex.set(i, clusters.length); + clusterIndexForEmbeddingIndex.set(nnIndex, clusters.length); + clusters.push([i, nnIndex]); + } + } else { + // We didn't find a neighbour within the threshold. Create a new + // cluster with only this embedding. + + clusterIndexForEmbeddingIndex.set(i, clusters.length); + clusters.push([i]); + } + } + + // Prune singleton clusters. + const validClusters = clusters.filter((cs) => cs.length > 1); + + return { clusters: validClusters }; +}; diff --git a/web/packages/new/photos/services/ml/db.ts b/web/packages/new/photos/services/ml/db.ts index f6d2043752..5f57ea30e1 100644 --- a/web/packages/new/photos/services/ml/db.ts +++ b/web/packages/new/photos/services/ml/db.ts @@ -3,7 +3,7 @@ import log from "@/base/log"; import localForage from "@ente/shared/storage/localForage"; import { deleteDB, openDB, type DBSchema } from "idb"; import type { LocalCLIPIndex } from "./clip"; -import type { CGroup, FaceCluster } from "./cluster-new"; +import type { CGroup, FaceCluster } from "./cluster"; import type { LocalFaceIndex } from "./face"; /** diff --git a/web/packages/new/photos/services/ml/face.ts b/web/packages/new/photos/services/ml/face.ts index 891b605db2..d8616b7426 100644 --- a/web/packages/new/photos/services/ml/face.ts +++ b/web/packages/new/photos/services/ml/face.ts @@ -714,7 +714,7 @@ const detectBlur = ( type FaceDirection = "left" | "right" | "straight"; -const faceDirection = ({ landmarks }: FaceDetection): FaceDirection => { +export const faceDirection = ({ landmarks }: FaceDetection): FaceDirection => { const leftEye = landmarks[0]!; const rightEye = landmarks[1]!; const nose = landmarks[2]!; diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 43d90578b8..3bb6bb3eaf 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -20,14 +20,13 @@ import { getAllLocalFiles } from "../files"; import { getRemoteFlag, updateRemoteFlag } from "../remote-store"; import type { SearchPerson } from "../search/types"; import type { UploadItem } from "../upload/types"; -import { clusterFacesHdb, type CGroup, type FaceCluster } from "./cluster-new"; -import { regenerateFaceCrops } from "./crop"; import { - clearMLDB, - faceIndex, - faceIndexes, - indexableAndIndexedCounts, -} from "./db"; + type ClusteringOpts, + type ClusterPreviewFace, + type FaceCluster, +} from "./cluster"; +import { regenerateFaceCrops } from "./crop"; +import { clearMLDB, faceIndex, indexableAndIndexedCounts } from "./db"; import type { Face } from "./face"; import { MLWorker } from "./worker"; import type { CLIPMatches } from "./worker-types"; @@ -349,118 +348,99 @@ export const wipSearchPersons = async () => { return _wip_searchPersons ?? []; }; -export interface FaceFileNeighbours { - face: Face; - neighbours: FaceFileNeighbour[]; +export interface ClusterPreviewWithFile { + clusterSize: number; + faces: ClusterPreviewFaceWithFile[]; } -export interface FaceFileNeighbour { - face: Face; +export type ClusterPreviewFaceWithFile = ClusterPreviewFace & { enteFile: EnteFile; - cosineSimilarity: number; -} +}; export interface ClusterDebugPageContents { - faceFNs: FaceFileNeighbours[]; + totalFaceCount: number; + filteredFaceCount: number; + clusteredFaceCount: number; + unclusteredFaceCount: number; + timeTakenMs: number; clusters: FaceCluster[]; - clusterIDForFaceID: Map; + clusterPreviewsWithFile: ClusterPreviewWithFile[]; + unclusteredFacesWithFile: { + face: Face; + enteFile: EnteFile; + }[]; } -export const wipClusterDebugPageContents = async (): Promise< - ClusterDebugPageContents | undefined -> => { - if (!(await wipClusterEnable())) return undefined; +export const wipClusterDebugPageContents = async ( + opts: ClusteringOpts, +): Promise => { + if (!(await wipClusterEnable())) throw new Error("Not implemented"); - log.info("clustering"); + log.info("clustering", opts); _wip_isClustering = true; _wip_searchPersons = undefined; triggerStatusUpdate(); - // const { faceAndNeigbours, clusters, cgroups } = await clusterFaces( - const { faceAndNeigbours, clusters, cgroups } = await clusterFacesHdb( - await faceIndexes(), - ); - const searchPersons = await convertToSearchPersons(clusters, cgroups); + const { clusterPreviews, clusters, cgroups, unclusteredFaces, ...rest } = + await worker().then((w) => w.clusterFaces(opts)); const localFiles = await getAllLocalFiles(); const localFileByID = new Map(localFiles.map((f) => [f.id, f])); const fileForFace = ({ faceID }: Face) => ensure(localFileByID.get(ensure(fileIDFromFaceID(faceID)))); - const faceFNs = faceAndNeigbours - .map(({ face, neighbours }) => ({ - face, - neighbours: neighbours.map(({ face, cosineSimilarity }) => ({ + const clusterPreviewsWithFile = clusterPreviews.map( + ({ clusterSize, faces }) => ({ + clusterSize, + faces: faces.map(({ face, ...rest }) => ({ face, enteFile: fileForFace(face), - cosineSimilarity, + ...rest, })), - })) - .sort((a, b) => b.face.score - a.face.score); - - const clusterIDForFaceID = new Map( - clusters.flatMap((cluster) => - cluster.faceIDs.map((id) => [id, cluster.id]), - ), + }), ); + const unclusteredFacesWithFile = unclusteredFaces.map((face) => ({ + face, + enteFile: fileForFace(face), + })); + + const clusterByID = new Map(clusters.map((c) => [c.id, c])); + + const searchPersons = cgroups + .map((cgroup) => { + const faceID = ensure(cgroup.displayFaceID); + const fileID = ensure(fileIDFromFaceID(faceID)); + const file = ensure(localFileByID.get(fileID)); + + const faceIDs = cgroup.clusterIDs + .map((id) => ensure(clusterByID.get(id))) + .flatMap((cluster) => cluster.faceIDs); + const fileIDs = faceIDs + .map((faceID) => fileIDFromFaceID(faceID)) + .filter((fileID) => fileID !== undefined); + + return { + id: cgroup.id, + name: cgroup.name, + faceIDs, + files: [...new Set(fileIDs)], + displayFaceID: faceID, + displayFaceFile: file, + }; + }) + .sort((a, b) => b.faceIDs.length - a.faceIDs.length); + _wip_isClustering = false; _wip_searchPersons = searchPersons; triggerStatusUpdate(); - const prunedFaceFNs = faceFNs.slice(0, 30).map(({ face, neighbours }) => ({ - face, - neighbours: neighbours.slice(0, 30), - })); - - return { faceFNs: prunedFaceFNs, clusters, clusterIDForFaceID }; -}; - -export const wipCluster = () => void wipClusterDebugPageContents(); - -const convertToSearchPersons = async ( - clusters: FaceCluster[], - cgroups: CGroup[], -) => { - const clusterByID = new Map(clusters.map((c) => [c.id, c])); - - const localFiles = await getAllLocalFiles(); - const localFileByID = new Map(localFiles.map((f) => [f.id, f])); - - const result: SearchPerson[] = []; - for (const cgroup of cgroups) { - const displayFaceID = cgroup.displayFaceID; - if (!displayFaceID) { - // TODO-Cluster - assertionFailed(`cgroup ${cgroup.id} without displayFaceID`); - continue; - } - - const displayFaceFileID = fileIDFromFaceID(displayFaceID); - if (!displayFaceFileID) continue; - - const displayFaceFile = localFileByID.get(displayFaceFileID); - if (!displayFaceFile) { - assertionFailed(`Face ID ${displayFaceFileID} without local file`); - continue; - } - - const fileIDs = cgroup.clusterIDs - .map((id) => clusterByID.get(id)) - .flatMap((cluster) => cluster?.faceIDs ?? []) - .map((faceID) => fileIDFromFaceID(faceID)) - .filter((fileID) => fileID !== undefined); - - result.push({ - id: cgroup.id, - name: cgroup.name, - files: [...new Set(fileIDs)], - displayFaceID, - displayFaceFile, - }); - } - - return result.sort((a, b) => b.files.length - a.files.length); + return { + clusters, + clusterPreviewsWithFile, + unclusteredFacesWithFile, + ...rest, + }; }; export type MLStatus = diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index f21f58d85a..c663abc2c9 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -24,8 +24,10 @@ import { indexCLIP, type CLIPIndex, } from "./clip"; +import { clusterFaces, type ClusteringOpts } from "./cluster"; import { saveFaceCrops } from "./crop"; import { + faceIndexes, indexableFileIDs, markIndexingFailed, saveIndexes, @@ -272,6 +274,11 @@ export class MLWorker { remoteMLData: mlDataByID.get(id), })); } + + // TODO-Cluster + async clusterFaces(opts: ClusteringOpts) { + return clusterFaces(await faceIndexes(), opts); + } } expose(MLWorker); diff --git a/web/packages/new/photos/services/user-entity.ts b/web/packages/new/photos/services/user-entity.ts index 7e26726dd5..121171d214 100644 --- a/web/packages/new/photos/services/user-entity.ts +++ b/web/packages/new/photos/services/user-entity.ts @@ -12,7 +12,7 @@ import { ensure } from "@/utils/ensure"; import { nullToUndefined } from "@/utils/transform"; import { z } from "zod"; import { gunzip } from "./gzip"; -import type { CGroup } from "./ml/cluster-new"; +import type { CGroup } from "./ml/cluster"; import { applyCGroupDiff } from "./ml/db"; /**