From 8ce0775514f111ab0a2914c27afc63140c44b511 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 09:47:08 +0530 Subject: [PATCH 01/45] Remove unused --- .../photos/src/components/icons/ObjectIcon.tsx | 18 ------------------ .../photos/src/components/icons/TextIcon.tsx | 18 ------------------ 2 files changed, 36 deletions(-) delete mode 100644 web/apps/photos/src/components/icons/ObjectIcon.tsx delete mode 100644 web/apps/photos/src/components/icons/TextIcon.tsx diff --git a/web/apps/photos/src/components/icons/ObjectIcon.tsx b/web/apps/photos/src/components/icons/ObjectIcon.tsx deleted file mode 100644 index 9971cd395a..0000000000 --- a/web/apps/photos/src/components/icons/ObjectIcon.tsx +++ /dev/null @@ -1,18 +0,0 @@ -export default function ObjectIcon(props) { - return ( - - - - ); -} - -ObjectIcon.defaultProps = { - height: 20, - width: 20, - viewBox: "0 0 24 24", -}; diff --git a/web/apps/photos/src/components/icons/TextIcon.tsx b/web/apps/photos/src/components/icons/TextIcon.tsx deleted file mode 100644 index 62d37fbe28..0000000000 --- a/web/apps/photos/src/components/icons/TextIcon.tsx +++ /dev/null @@ -1,18 +0,0 @@ -export default function TextIcon(props) { - return ( - - - - ); -} - -TextIcon.defaultProps = { - height: 16, - width: 16, - viewBox: "0 0 28 28", -}; From 4df1e16b903f5d29a37d92ea781bce2f6af6fb55 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 09:55:43 +0530 Subject: [PATCH 02/45] Use the mui built in icons The ad-hoc variation did not fit in with the rest of the icons (e.g it had a different stroke width, and general vibe) --- .../Collections/CollectionHeader.tsx | 8 +- .../photos/src/components/icons/UnPinIcon.tsx | 80 ------------------- 2 files changed, 4 insertions(+), 84 deletions(-) delete mode 100644 web/apps/photos/src/components/icons/UnPinIcon.tsx diff --git a/web/apps/photos/src/components/Collections/CollectionHeader.tsx b/web/apps/photos/src/components/Collections/CollectionHeader.tsx index 188d3dbdc4..2647a31a82 100644 --- a/web/apps/photos/src/components/Collections/CollectionHeader.tsx +++ b/web/apps/photos/src/components/Collections/CollectionHeader.tsx @@ -37,7 +37,8 @@ import LinkIcon from "@mui/icons-material/Link"; import LogoutIcon from "@mui/icons-material/Logout"; import MoreHoriz from "@mui/icons-material/MoreHoriz"; import PeopleIcon from "@mui/icons-material/People"; -import PushPinOutlined from "@mui/icons-material/PushPinOutlined"; +import PushPinIcon from "@mui/icons-material/PushPin"; +import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined"; import SortIcon from "@mui/icons-material/Sort"; import TvIcon from "@mui/icons-material/Tv"; import Unarchive from "@mui/icons-material/Unarchive"; @@ -45,7 +46,6 @@ import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined"; import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"; import { Box, IconButton, Stack, Tooltip } from "@mui/material"; import { SetCollectionNamerAttributes } from "components/Collections/CollectionNamer"; -import { UnPinIcon } from "components/icons/UnPinIcon"; import { t } from "i18next"; import { GalleryContext } from "pages/gallery"; import React, { useCallback, useContext, useRef } from "react"; @@ -654,14 +654,14 @@ const AlbumCollectionOptions: React.FC = ({ {isPinned ? ( } + startIcon={} > {t("unpin_album")} ) : ( } + startIcon={} > {t("pin_album")} diff --git a/web/apps/photos/src/components/icons/UnPinIcon.tsx b/web/apps/photos/src/components/icons/UnPinIcon.tsx deleted file mode 100644 index da50e0a1a0..0000000000 --- a/web/apps/photos/src/components/icons/UnPinIcon.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import SvgIcon from "@mui/material/SvgIcon"; - -export const UnPinIcon = (props) => { - return ( - - - - - - - - - - - - - - ); -}; - -UnPinIcon.defaultProps = { - height: 20, - width: 20, -}; From 06a0a8177bad5a0f4a2feb4a93d1fa1aa0d3345a Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 10:44:15 +0530 Subject: [PATCH 03/45] Unused css Best I can tell, it seems a leftover from 7df731ed2c0c6df52f8616fe2d513f59363a24d6 --- web/apps/photos/src/styles/global.css | 6 ------ 1 file changed, 6 deletions(-) diff --git a/web/apps/photos/src/styles/global.css b/web/apps/photos/src/styles/global.css index f9adccd3d5..f28b5934d1 100644 --- a/web/apps/photos/src/styles/global.css +++ b/web/apps/photos/src/styles/global.css @@ -162,9 +162,3 @@ div.otp-input input:focus { transition: 0.5s; outline: none; } - -.flash-message { - padding: 16px; - display: flex; - align-items: center; -} From a0ea952932299fcbec3498f684a1f27ee831921d Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 10:54:13 +0530 Subject: [PATCH 04/45] Doc --- web/docs/dependencies.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/docs/dependencies.md b/web/docs/dependencies.md index ab544e2004..26bcbbac5d 100644 --- a/web/docs/dependencies.md +++ b/web/docs/dependencies.md @@ -140,6 +140,11 @@ For more details, see [translations.md](translations.md). - [react-select](https://react-select.com/) is used for search dropdowns. +- [react-top-loading-bar](https://github.com/klendi/react-top-loading-bar) is + used for showing a progress indicator for global actions (This shouldn't be + used always, it is only meant as a fallback when there isn't an otherwise + suitable place for showing a local activity indicator). + ## Utilities - [comlink](https://github.com/GoogleChromeLabs/comlink) provides a minimal From 145dd4d50bf97d6ec2ed915c701cc05ec6489098 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 10:54:30 +0530 Subject: [PATCH 05/45] Prune --- web/apps/photos/src/styles/global.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/web/apps/photos/src/styles/global.css b/web/apps/photos/src/styles/global.css index f28b5934d1..66e23828ce 100644 --- a/web/apps/photos/src/styles/global.css +++ b/web/apps/photos/src/styles/global.css @@ -125,10 +125,6 @@ body { display: none; } -.bg-upload-progress-bar { - background-color: #51cd7c; -} - .carousel-inner { padding-bottom: 50px !important; } From 6f576bdae6fc4e5e0c89362fe5844c92d97ee940 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 10:56:04 +0530 Subject: [PATCH 06/45] Update --- web/apps/photos/package.json | 2 +- web/yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/apps/photos/package.json b/web/apps/photos/package.json index 76f9bf1d8e..b725914b21 100644 --- a/web/apps/photos/package.json +++ b/web/apps/photos/package.json @@ -29,7 +29,7 @@ "react-dropzone": "^14.2", "react-otp-input": "^2.3.1", "react-select": "^5.8.0", - "react-top-loading-bar": "^2.0.1", + "react-top-loading-bar": "^2.3.1", "react-virtualized-auto-sizer": "^1.0", "react-window": "^1.8.10", "sanitize-filename": "^1.6.3", diff --git a/web/yarn.lock b/web/yarn.lock index a3485ab6ec..411a432aab 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -3768,7 +3768,7 @@ react-select@^5.8.0: react-transition-group "^4.3.0" use-isomorphic-layout-effect "^1.1.2" -react-top-loading-bar@^2.0.1: +react-top-loading-bar@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/react-top-loading-bar/-/react-top-loading-bar-2.3.1.tgz#d727eb6aaa412eae52a990e5de9f33e9136ac714" integrity sha512-rQk2Nm+TOBrM1C4E3e6KwT65iXyRSgBHjCkr2FNja1S51WaPulRA5nKj/xazuQ3x89wDDdGsrqkqy0RBIfd0xg== From 04d07fc94ff1d78f0234df3b449469f76c122006 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 11:06:37 +0530 Subject: [PATCH 07/45] Should be fixed upstream https://github.com/klendi/react-top-loading-bar/issues/52 --- web/apps/auth/src/pages/_app.tsx | 6 ++---- web/apps/photos/src/pages/_app.tsx | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/web/apps/auth/src/pages/_app.tsx b/web/apps/auth/src/pages/_app.tsx index cb63dc1a5f..3fc2a2948d 100644 --- a/web/apps/auth/src/pages/_app.tsx +++ b/web/apps/auth/src/pages/_app.tsx @@ -119,10 +119,8 @@ const App: React.FC = ({ Component, pageProps }) => { }; const finishLoading = () => { - setTimeout(() => { - isLoadingBarRunning.current && loadingBar.current?.complete(); - isLoadingBarRunning.current = false; - }, 100); + isLoadingBarRunning.current && loadingBar.current?.complete(); + isLoadingBarRunning.current = false; }; const somethingWentWrong = () => diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 35b316b55c..ff9996b407 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -218,10 +218,8 @@ export default function App({ Component, pageProps }: AppProps) { isLoadingBarRunning.current = true; }; const finishLoading = () => { - setTimeout(() => { - isLoadingBarRunning.current && loadingBar.current?.complete(); - isLoadingBarRunning.current = false; - }, 100); + isLoadingBarRunning.current && loadingBar.current?.complete(); + isLoadingBarRunning.current = false; }; // Use `onGenericError` instead. From 75c280d86b0fd0e6e74a1f02138cd23b2a3f0e51 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 11:10:52 +0530 Subject: [PATCH 08/45] Auth app doesn't use it --- web/apps/auth/src/pages/_app.tsx | 27 +-------------------------- web/docs/dependencies.md | 16 ++++++---------- 2 files changed, 7 insertions(+), 36 deletions(-) diff --git a/web/apps/auth/src/pages/_app.tsx b/web/apps/auth/src/pages/_app.tsx index 3fc2a2948d..5557ace23d 100644 --- a/web/apps/auth/src/pages/_app.tsx +++ b/web/apps/auth/src/pages/_app.tsx @@ -32,14 +32,7 @@ import { ThemeProvider } from "@mui/material/styles"; import { t } from "i18next"; import type { AppProps } from "next/app"; import { useRouter } from "next/router"; -import React, { - createContext, - useContext, - useEffect, - useRef, - useState, -} from "react"; -import LoadingBar, { type LoadingBarRef } from "react-top-loading-bar"; +import React, { createContext, useContext, useEffect, useState } from "react"; import "../../public/css/global.css"; @@ -47,8 +40,6 @@ import "../../public/css/global.css"; * Properties available via {@link AppContext} to the Auth app's React tree. */ type AppContextT = AccountsContextT & { - startLoading: () => void; - finishLoading: () => void; themeColor: THEME_COLOR; setThemeColor: (themeColor: THEME_COLOR) => void; somethingWentWrong: () => void; @@ -68,8 +59,6 @@ const App: React.FC = ({ Component, pageProps }) => { typeof window !== "undefined" && !window.navigator.onLine, ); const [showNavbar, setShowNavBar] = useState(false); - const isLoadingBarRunning = useRef(false); - const loadingBar = useRef(null); const { showMiniDialog, miniDialogProps } = useAttributedMiniDialog(); const [themeColor, setThemeColor] = useLocalState( @@ -113,16 +102,6 @@ const App: React.FC = ({ Component, pageProps }) => { const showNavBar = (show: boolean) => setShowNavBar(show); - const startLoading = () => { - !isLoadingBarRunning.current && loadingBar.current?.continuousStart(); - isLoadingBarRunning.current = true; - }; - - const finishLoading = () => { - isLoadingBarRunning.current && loadingBar.current?.complete(); - isLoadingBarRunning.current = false; - }; - const somethingWentWrong = () => showMiniDialog(genericErrorDialogAttributes()); @@ -134,8 +113,6 @@ const App: React.FC = ({ Component, pageProps }) => { logout, showNavBar, showMiniDialog, - startLoading, - finishLoading, themeColor, setThemeColor, somethingWentWrong, @@ -154,8 +131,6 @@ const App: React.FC = ({ Component, pageProps }) => { {isI18nReady && offline && t("OFFLINE_MSG")} - - diff --git a/web/docs/dependencies.md b/web/docs/dependencies.md index 26bcbbac5d..0f5a8a3d1a 100644 --- a/web/docs/dependencies.md +++ b/web/docs/dependencies.md @@ -140,11 +140,6 @@ For more details, see [translations.md](translations.md). - [react-select](https://react-select.com/) is used for search dropdowns. -- [react-top-loading-bar](https://github.com/klendi/react-top-loading-bar) is - used for showing a progress indicator for global actions (This shouldn't be - used always, it is only meant as a fallback when there isn't an otherwise - suitable place for showing a local activity indicator). - ## Utilities - [comlink](https://github.com/GoogleChromeLabs/comlink) provides a minimal @@ -186,8 +181,6 @@ For more details, see [translations.md](translations.md). ## Photos app specific -### General - - [react-dropzone](https://github.com/react-dropzone/react-dropzone/) is a React hook to create a drag-and-drop input zone. @@ -198,10 +191,13 @@ For more details, see [translations.md](translations.md). - [chrono-node](https://github.com/wanasit/chrono) is used for parsing natural language queries into dates for showing search results. -### Face search +- [react-top-loading-bar](https://github.com/klendi/react-top-loading-bar) is + used for showing a progress indicator for global actions (This shouldn't be + used always, it is only meant as a fallback when there isn't an otherwise + suitable place for showing a local activity indicator). -- [matrix](https://github.com/mljs/matrix) is mathematical matrix abstraction. - It is used alongwith +- [matrix](https://github.com/mljs/matrix) is mathematical matrix abstraction + by the machine learning code. It is used alongwith [similarity-transformation](https://github.com/shaileshpandit/similarity-transformation-js) during face alignment. From d15f8451fef3911387c33fcbec493d21beac7a41 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 11:58:09 +0530 Subject: [PATCH 09/45] Stable identity of context functions to prevent unnecessary rerenders --- .../Collections/CollectionHeader.tsx | 8 +- .../emailShare/ManageEmailShare.tsx | 6 +- .../src/components/PhotoViewer/index.tsx | 18 ++-- .../photos/src/components/Sidebar/index.tsx | 5 +- .../src/components/TwoFactor/Modal/index.tsx | 2 - web/apps/photos/src/pages/_app.tsx | 21 ++--- web/apps/photos/src/pages/deduplicate.tsx | 86 ++++++++++--------- web/apps/photos/src/pages/gallery.tsx | 33 +++---- .../components/utils/use-loading-bar.ts | 26 ++++++ .../photos/components/utils/use-wrap-async.ts | 8 +- web/packages/new/photos/types/context.ts | 9 +- 11 files changed, 119 insertions(+), 103 deletions(-) create mode 100644 web/packages/new/photos/components/utils/use-loading-bar.ts diff --git a/web/apps/photos/src/components/Collections/CollectionHeader.tsx b/web/apps/photos/src/components/Collections/CollectionHeader.tsx index 2647a31a82..505a557c84 100644 --- a/web/apps/photos/src/components/Collections/CollectionHeader.tsx +++ b/web/apps/photos/src/components/Collections/CollectionHeader.tsx @@ -141,7 +141,7 @@ const CollectionOptions: React.FC = ({ setFilesDownloadProgressAttributesCreator, isActiveCollectionDownloadInProgress, }) => { - const { startLoading, finishLoading, setDialogMessage } = + const { showLoadingBar, hideLoadingBar, setDialogMessage } = useContext(AppContext); const { syncWithRemote } = useContext(GalleryContext); const overFlowMenuIconRef = useRef(null); @@ -169,19 +169,19 @@ const CollectionOptions: React.FC = ({ const wrap = useCallback( (f: () => Promise) => { const wrapped = async () => { - startLoading(); + showLoadingBar(); try { await f(); } catch (e) { handleError(e); } finally { void syncWithRemote(false, true); - finishLoading(); + hideLoadingBar(); } }; return (): void => void wrapped(); }, - [handleError, syncWithRemote, startLoading, finishLoading], + [handleError, syncWithRemote, showLoadingBar, hideLoadingBar], ); const showRenameCollectionModal = () => { diff --git a/web/apps/photos/src/components/Collections/CollectionShare/emailShare/ManageEmailShare.tsx b/web/apps/photos/src/components/Collections/CollectionShare/emailShare/ManageEmailShare.tsx index caf6e732a5..cd2cb17e75 100644 --- a/web/apps/photos/src/components/Collections/CollectionShare/emailShare/ManageEmailShare.tsx +++ b/web/apps/photos/src/components/Collections/CollectionShare/emailShare/ManageEmailShare.tsx @@ -41,7 +41,7 @@ export default function ManageEmailShare({ onRootClose, peopleCount, }: Iprops) { - const appContext = useContext(AppContext); + const { showLoadingBar, hideLoadingBar } = useContext(AppContext); const galleryContext = useContext(GalleryContext); const [addParticipantView, setAddParticipantView] = useState(false); @@ -80,11 +80,11 @@ export default function ManageEmailShare({ const collectionUnshare = async (email: string) => { try { - appContext.startLoading(); + showLoadingBar(); await unshareCollection(collection, email); await galleryContext.syncWithRemote(false, true); } finally { - appContext.finishLoading(); + hideLoadingBar(); } }; diff --git a/web/apps/photos/src/components/PhotoViewer/index.tsx b/web/apps/photos/src/components/PhotoViewer/index.tsx index 961201b46d..366a377545 100644 --- a/web/apps/photos/src/components/PhotoViewer/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/index.tsx @@ -118,7 +118,8 @@ export interface PhotoViewerProps { function PhotoViewer(props: PhotoViewerProps) { const galleryContext = useContext(GalleryContext); - const appContext = useContext(AppContext); + const { showLoadingBar, hideLoadingBar, setDialogMessage } = + useContext(AppContext); const publicCollectionGalleryContext = useContext( PublicCollectionGalleryContext, ); @@ -537,9 +538,12 @@ function PhotoViewer(props: PhotoViewerProps) { const trashFile = async (file: EnteFile) => { try { - appContext.startLoading(); - await trashFiles([file]); - appContext.finishLoading(); + showLoadingBar(); + try { + await trashFiles([file]); + } finally { + hideLoadingBar(); + } markTempDeleted?.([file]); updateItems(props.items.filter((item) => item.id !== file.id)); needUpdate.current = true; @@ -552,7 +556,7 @@ function PhotoViewer(props: PhotoViewerProps) { if (!file || !isOwnFile || props.isTrashCollection) { return; } - appContext.setDialogMessage(getTrashFileMessage(() => trashFile(file))); + setDialogMessage(getTrashFileMessage(() => trashFile(file))); }; const handleArrowClick = ( @@ -683,9 +687,9 @@ function PhotoViewer(props: PhotoViewerProps) { const copyToClipboardHelper = async (file: EnteFile) => { if (file && props.enableDownload && shouldShowCopyOption) { - appContext.startLoading(); + showLoadingBar(); await copyFileToClipboard(file.src); - appContext.finishLoading(); + hideLoadingBar(); } }; diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx index 1ae907e4d1..d5ca9fb601 100644 --- a/web/apps/photos/src/components/Sidebar/index.tsx +++ b/web/apps/photos/src/components/Sidebar/index.tsx @@ -426,15 +426,13 @@ interface UtilitySectionProps { const UtilitySection: React.FC = ({ closeSidebar }) => { const router = useRouter(); - const appContext = useContext(AppContext); const { - startLoading, watchFolderView, setWatchFolderView, themeColor, setThemeColor, showMiniDialog, - } = appContext; + } = useAppContext(); const { show: showRecoveryKey, props: recoveryKeyVisibilityProps } = useModalVisibility(); @@ -536,7 +534,6 @@ const UtilitySection: React.FC = ({ closeSidebar }) => { {isElectron() && ( ({ })); type Props = ModalVisibilityProps & { - setLoading: SetLoading; closeSidebar: () => void; }; diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index ff9996b407..36c43c7420 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -18,6 +18,7 @@ import { updateAvailableForDownloadDialogAttributes, updateReadyToInstallDialogAttributes, } from "@/new/photos/components/utils/download"; +import { useLoadingBar } from "@/new/photos/components/utils/use-loading-bar"; import { photosDialogZIndex } from "@/new/photos/components/utils/z-index"; import DownloadManager from "@/new/photos/services/download"; import { runMigrations } from "@/new/photos/services/migrations"; @@ -51,7 +52,7 @@ import isElectron from "is-electron"; import type { AppProps } from "next/app"; import { useRouter } from "next/router"; import "photoswipe/dist/photoswipe.css"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import LoadingBar from "react-top-loading-bar"; import { resumeExportsIfNeeded } from "services/export"; import { photosLogout } from "services/logout"; @@ -71,8 +72,6 @@ export default function App({ Component, pageProps }: AppProps) { ); const [showNavbar, setShowNavBar] = useState(false); const [mapEnabled, setMapEnabled] = useState(false); - const isLoadingBarRunning = useRef(false); - const loadingBar = useRef(null); const [dialogMessage, setDialogMessage] = useState(); const [messageDialogView, setMessageDialogView] = useState(false); const [watchFolderView, setWatchFolderView] = useState(false); @@ -83,6 +82,7 @@ export default function App({ Component, pageProps }: AppProps) { useState(null); const { showMiniDialog, miniDialogProps } = useAttributedMiniDialog(); + const { loadingBarRef, showLoadingBar, hideLoadingBar } = useLoadingBar(); const [themeColor, setThemeColor] = useLocalState( LS_KEYS.THEME, THEME_COLOR.DARK, @@ -213,15 +213,6 @@ export default function App({ Component, pageProps }: AppProps) { setMapEnabled(enabled); }; - const startLoading = () => { - !isLoadingBarRunning.current && loadingBar.current?.continuousStart(); - isLoadingBarRunning.current = true; - }; - const finishLoading = () => { - isLoadingBarRunning.current && loadingBar.current?.complete(); - isLoadingBarRunning.current = false; - }; - // Use `onGenericError` instead. const somethingWentWrong = useCallback( () => @@ -244,8 +235,8 @@ export default function App({ Component, pageProps }: AppProps) { const appContext = { showNavBar, - startLoading, // <- changes on each render (TODO Fix) - finishLoading, // <- changes on each render + showLoadingBar, + hideLoadingBar, setDialogMessage, watchFolderView, setWatchFolderView, @@ -276,7 +267,7 @@ export default function App({ Component, pageProps }: AppProps) { {isI18nReady && offline && t("OFFLINE_MSG")} - + (null); const [collectionNameMap, setCollectionNameMap] = useState( new Map(), @@ -70,42 +70,48 @@ export default function Deduplicate() { }, []); const syncWithRemote = async () => { - startLoading(); - const collections = await getLocalCollections(); - const collectionNameMap = new Map(); - for (const collection of collections) { - collectionNameMap.set(collection.id, collection.name); - } - setCollectionNameMap(collectionNameMap); - const files = await getLocalFiles(); - const duplicateFiles = await getDuplicates(files, collectionNameMap); - const currFileSizeMap = new Map(); - let toSelectFileIDs: number[] = []; - let count = 0; - for (const dupe of duplicateFiles) { - // select all except first file - toSelectFileIDs = [ - ...toSelectFileIDs, - ...dupe.files.slice(1).map((f) => f.id), - ]; - count += dupe.files.length - 1; - - for (const file of dupe.files) { - currFileSizeMap.set(file.id, dupe.size); + showLoadingBar(); + try { + const collections = await getLocalCollections(); + const collectionNameMap = new Map(); + for (const collection of collections) { + collectionNameMap.set(collection.id, collection.name); } + setCollectionNameMap(collectionNameMap); + const files = await getLocalFiles(); + const duplicateFiles = await getDuplicates( + files, + collectionNameMap, + ); + const currFileSizeMap = new Map(); + let toSelectFileIDs: number[] = []; + let count = 0; + for (const dupe of duplicateFiles) { + // select all except first file + toSelectFileIDs = [ + ...toSelectFileIDs, + ...dupe.files.slice(1).map((f) => f.id), + ]; + count += dupe.files.length - 1; + + for (const file of dupe.files) { + currFileSizeMap.set(file.id, dupe.size); + } + } + setDuplicates(duplicateFiles); + const selectedFiles = { + count: count, + ownCount: count, + collectionID: ALL_SECTION, + context: undefined, + }; + for (const fileID of toSelectFileIDs) { + selectedFiles[fileID] = true; + } + setSelected(selectedFiles); + } finally { + hideLoadingBar(); } - setDuplicates(duplicateFiles); - const selectedFiles = { - count: count, - ownCount: count, - collectionID: ALL_SECTION, - context: undefined, - }; - for (const fileID of toSelectFileIDs) { - selectedFiles[fileID] = true; - } - setSelected(selectedFiles); - finishLoading(); }; const duplicateFiles = useMemoSingleThreaded(() => { @@ -120,7 +126,7 @@ export default function Deduplicate() { const deleteFileHelper = async () => { try { - startLoading(); + showLoadingBar(); const selectedFiles = getSelectedFiles(selected, duplicateFiles); await trashFiles(selectedFiles); @@ -160,7 +166,7 @@ export default function Deduplicate() { } } finally { await syncWithRemote(); - finishLoading(); + hideLoadingBar(); } }; diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index 48081a92c1..044e99046d 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -43,7 +43,7 @@ import { setSearchCollectionsAndFiles, } from "@/new/photos/services/search"; import type { SearchOption } from "@/new/photos/services/search/types"; -import { AppContext } from "@/new/photos/types/context"; +import { useAppContext } from "@/new/photos/types/context"; import { splitByPredicate } from "@/utils/array"; import { ensure } from "@/utils/ensure"; import { @@ -100,14 +100,7 @@ import PlanSelector from "components/pages/gallery/PlanSelector"; import SelectedFileOptions from "components/pages/gallery/SelectedFileOptions"; import { t } from "i18next"; import { useRouter } from "next/router"; -import { - createContext, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from "react"; +import { createContext, useCallback, useEffect, useRef, useState } from "react"; import { useDropzone } from "react-dropzone"; import { constructEmailList, @@ -231,12 +224,12 @@ export default function Gallery() { const resync = useRef<{ force: boolean; silent: boolean }>(); const { - startLoading, - finishLoading, + showLoadingBar, + hideLoadingBar, setDialogMessage, logout, ...appContext - } = useContext(AppContext); + } = useAppContext(); const [userIDToEmailMap, setUserIDToEmailMap] = useState>(null); const [emailList, setEmailList] = useState(null); @@ -574,7 +567,7 @@ export default function Gallery() { if (!tokenValid) { throw new Error(CustomError.SESSION_EXPIRED); } - !silent && startLoading(); + !silent && showLoadingBar(); await preFileInfoSync(); const allCollections = await getAllLatestCollections(); const [hiddenCollections, collections] = splitByPredicate( @@ -626,7 +619,7 @@ export default function Gallery() { } finally { dispatch({ type: "clearTempDeleted" }); dispatch({ type: "clearTempHidden" }); - !silent && finishLoading(); + !silent && hideLoadingBar(); } syncInProgress.current = false; if (resync.current) { @@ -690,7 +683,7 @@ export default function Gallery() { const collectionOpsHelper = (ops: COLLECTION_OPS_TYPE) => async (collection: Collection) => { - startLoading(); + showLoadingBar(); try { setOpenCollectionSelector(false); const selectedFiles = getSelectedFiles(selected, filteredFiles); @@ -719,12 +712,12 @@ export default function Gallery() { content: t("generic_error_retry"), }); } finally { - finishLoading(); + hideLoadingBar(); } }; const fileOpsHelper = (ops: FILE_OPS_TYPE) => async () => { - startLoading(); + showLoadingBar(); try { // passing files here instead of filteredData for hide ops because we want to move all files copies to hidden collection const selectedFiles = getSelectedFiles( @@ -758,14 +751,14 @@ export default function Gallery() { content: t("generic_error_retry"), }); } finally { - finishLoading(); + hideLoadingBar(); } }; const showCreateCollectionModal = (ops: COLLECTION_OPS_TYPE) => { const callback = async (collectionName: string) => { try { - startLoading(); + showLoadingBar(); const collection = await createAlbum(collectionName); await collectionOpsHelper(ops)(collection); } catch (e) { @@ -777,7 +770,7 @@ export default function Gallery() { content: t("generic_error_retry"), }); } finally { - finishLoading(); + hideLoadingBar(); } }; return () => diff --git a/web/packages/new/photos/components/utils/use-loading-bar.ts b/web/packages/new/photos/components/utils/use-loading-bar.ts new file mode 100644 index 0000000000..4c83a42349 --- /dev/null +++ b/web/packages/new/photos/components/utils/use-loading-bar.ts @@ -0,0 +1,26 @@ +import { useCallback, useRef } from "react"; +import { type LoadingBarRef } from "react-top-loading-bar"; + +/** + * A convenience hook for returning stable functions tied to a + * {@link LoadingBar} ref. + * + * The {@link LoadingBar} component comes from the "react-top-loading-bar" + * library. To control it, we keep a ref. We want to allow components in our + * React tree to be able to also control the loading bar, but instead of + * exposing the ref directly, we export wrapper functions to start and stop the + * loading bar. This hook returns these functions (and the ref). + */ +export const useLoadingBar = () => { + const loadingBarRef = useRef(); + + const showLoadingBar = useCallback(() => { + loadingBarRef.current?.continuousStart(); + }, []); + + const hideLoadingBar = useCallback(() => { + loadingBarRef.current?.complete(); + }, []); + + return { loadingBarRef, showLoadingBar, hideLoadingBar }; +}; diff --git a/web/packages/new/photos/components/utils/use-wrap-async.ts b/web/packages/new/photos/components/utils/use-wrap-async.ts index 473cd78dd6..bd77821586 100644 --- a/web/packages/new/photos/components/utils/use-wrap-async.ts +++ b/web/packages/new/photos/components/utils/use-wrap-async.ts @@ -15,18 +15,18 @@ import { useAppContext } from "../../types/context"; export const useWrapAsyncOperation = ( f: (...args: T) => Promise, ) => { - const { startLoading, finishLoading, onGenericError } = useAppContext(); + const { showLoadingBar, hideLoadingBar, onGenericError } = useAppContext(); return useCallback( async (...args: T) => { - startLoading(); + showLoadingBar(); try { await f(...args); } catch (e) { onGenericError(e); } finally { - finishLoading(); + hideLoadingBar(); } }, - [f, startLoading, finishLoading, onGenericError], + [f, showLoadingBar, hideLoadingBar, onGenericError], ); }; diff --git a/web/packages/new/photos/types/context.ts b/web/packages/new/photos/types/context.ts index 1d156e1cce..40c679583b 100644 --- a/web/packages/new/photos/types/context.ts +++ b/web/packages/new/photos/types/context.ts @@ -10,13 +10,14 @@ import type { SetNotificationAttributes } from "./notification"; */ export type AppContextT = AccountsContextT & { /** - * Show the global activity indicator (a green bar at the top of the page). + * Show the global activity indicator (a loading bar at the top of the + * page). */ - startLoading: () => void; + showLoadingBar: () => void; /** - * Hide the global activity indicator. + * Hide the global activity indicator bar. */ - finishLoading: () => void; + hideLoadingBar: () => void; /** * Show a generic error dialog, and log the given error. */ From eb0af57a84734beba4fe23ce24b4b6932bb7d7ae Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 12:13:30 +0530 Subject: [PATCH 10/45] Autofocus on the delete action only in the file viewer context --- web/apps/photos/src/utils/ui/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/apps/photos/src/utils/ui/index.tsx b/web/apps/photos/src/utils/ui/index.tsx index 497948d52d..3cc4e6ce4f 100644 --- a/web/apps/photos/src/utils/ui/index.tsx +++ b/web/apps/photos/src/utils/ui/index.tsx @@ -13,7 +13,6 @@ export const getTrashFilesMessage = ( action: deleteFileHelper, text: t("MOVE_TO_TRASH"), variant: "critical", - autoFocus: true, }, close: { text: t("cancel") }, }); From c6bcd7ccf0f178220ddf99841c400062e0e2dd81 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 12:21:47 +0530 Subject: [PATCH 11/45] Fin --- web/apps/photos/src/pages/shared-albums.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/web/apps/photos/src/pages/shared-albums.tsx b/web/apps/photos/src/pages/shared-albums.tsx index 82a8ea3a83..24ca230b2f 100644 --- a/web/apps/photos/src/pages/shared-albums.tsx +++ b/web/apps/photos/src/pages/shared-albums.tsx @@ -17,7 +17,7 @@ import { } from "@/new/photos/services/collection"; import downloadManager from "@/new/photos/services/download"; import { sortFiles } from "@/new/photos/services/files"; -import { AppContext } from "@/new/photos/types/context"; +import { useAppContext } from "@/new/photos/types/context"; import { CenteredFlex, FluidContainer, @@ -54,7 +54,7 @@ import Uploader from "components/Upload/Uploader"; import { UploadSelectorInputs } from "components/UploadSelectorInputs"; import { t } from "i18next"; import { useRouter } from "next/router"; -import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useDropzone } from "react-dropzone"; import { getLocalPublicCollection, @@ -90,7 +90,8 @@ export default function PublicCollectionGallery() { const [publicFiles, setPublicFiles] = useState(null); const [publicCollection, setPublicCollection] = useState(null); const [errorMessage, setErrorMessage] = useState(null); - const appContext = useContext(AppContext); + const { showLoadingBar, hideLoadingBar, setDialogMessage } = + useAppContext(); const [loading, setLoading] = useState(true); const router = useRouter(); const [isPasswordProtected, setIsPasswordProtected] = @@ -185,7 +186,7 @@ export default function PublicCollectionGallery() { }; const showPublicLinkExpiredMessage = () => - appContext.setDialogMessage({ + setDialogMessage({ title: t("LINK_EXPIRED"), content: t("LINK_EXPIRED_MESSAGE"), @@ -316,7 +317,7 @@ export default function PublicCollectionGallery() { const syncWithRemote = async () => { const collectionUID = getPublicCollectionUID(token.current); try { - appContext.startLoading(); + showLoadingBar(); setLoading(true); const [collection, userReferralCode] = await getPublicCollection( token.current, @@ -381,7 +382,7 @@ export default function PublicCollectionGallery() { log.error("failed to sync public album with remote", e); } } finally { - appContext.finishLoading(); + hideLoadingBar(); setLoading(false); } }; @@ -427,7 +428,7 @@ export default function PublicCollectionGallery() { throw e; } await syncWithRemote(); - appContext.finishLoading(); + hideLoadingBar(); } catch (e) { log.error("failed to verifyLinkPassword", e); setFieldError(`${t("generic_error_retry")} ${e.message}`); From 28691784bf901c53d76954b9efc5b485f4b0d9fe Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 12:45:53 +0530 Subject: [PATCH 12/45] Unused CSS --- web/apps/photos/src/styles/global.css | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/web/apps/photos/src/styles/global.css b/web/apps/photos/src/styles/global.css index 66e23828ce..e8cf798bce 100644 --- a/web/apps/photos/src/styles/global.css +++ b/web/apps/photos/src/styles/global.css @@ -125,21 +125,6 @@ body { display: none; } -.carousel-inner { - padding-bottom: 50px !important; -} - -.carousel-indicators li { - width: 10px; - height: 10px; - border-radius: 50%; - margin-right: 12px; -} - -.carousel-indicators .active { - background-color: #51cd7c; -} - div.otp-input input { width: 36px !important; height: 36px; From da6b0c920a6d818e6122b54567f474f0a29940a6 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 12:54:20 +0530 Subject: [PATCH 13/45] Doc --- web/docs/dependencies.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/web/docs/dependencies.md b/web/docs/dependencies.md index 0f5a8a3d1a..1eae21a578 100644 --- a/web/docs/dependencies.md +++ b/web/docs/dependencies.md @@ -191,15 +191,23 @@ For more details, see [translations.md](translations.md). - [chrono-node](https://github.com/wanasit/chrono) is used for parsing natural language queries into dates for showing search results. +- [matrix](https://github.com/mljs/matrix) is mathematical matrix abstraction + by the machine learning code. It is used alongwith + [similarity-transformation](https://github.com/shaileshpandit/similarity-transformation-js) + during face alignment. + +### UI + - [react-top-loading-bar](https://github.com/klendi/react-top-loading-bar) is used for showing a progress indicator for global actions (This shouldn't be used always, it is only meant as a fallback when there isn't an otherwise suitable place for showing a local activity indicator). -- [matrix](https://github.com/mljs/matrix) is mathematical matrix abstraction - by the machine learning code. It is used alongwith - [similarity-transformation](https://github.com/shaileshpandit/similarity-transformation-js) - during face alignment. +- [pure-react-carousel](https://github.com/express-labs/pure-react-carousel) + is used for the feature carousel on the welcome (login / signup) screen. + +- [react-otp-input](https://github.com/devfolioco/react-otp-input) is used to + render a segmented OTP input field. ## Auth app specific From 3244f9d37e431b3436462ebff2f4707c7ba3980c Mon Sep 17 00:00:00 2001 From: mangesh <82205152+mngshm@users.noreply.github.com> Date: Tue, 29 Oct 2024 13:15:26 +0530 Subject: [PATCH 14/45] minor fix: describing markdown syntax type. --- docs/docs/self-hosting/guides/standalone-ente.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/self-hosting/guides/standalone-ente.md b/docs/docs/self-hosting/guides/standalone-ente.md index d3e136fb04..a017c5d17f 100644 --- a/docs/docs/self-hosting/guides/standalone-ente.md +++ b/docs/docs/self-hosting/guides/standalone-ente.md @@ -70,7 +70,7 @@ createuser -s postgres ## Start Museum -``` +```sh export ENTE_DB_USER=postgres cd ente/server go run cmd/museum/main.go @@ -78,7 +78,7 @@ go run cmd/museum/main.go For live reloads, install [air](https://github.com/air-verse/air#installation). Then you can just call air after declaring the required environment variables. For example, -``` +```sh ENTE_DB_USER=ente_user air ``` From 61936029e812b4f1616c89e20053535079295ccc Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 18:34:52 +0530 Subject: [PATCH 15/45] Update --- web/apps/photos/package.json | 2 +- .../components/two-factor/VerifyForm.tsx | 35 +++++++++++++++---- web/yarn.lock | 8 ++--- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/web/apps/photos/package.json b/web/apps/photos/package.json index b725914b21..303d6cdf2c 100644 --- a/web/apps/photos/package.json +++ b/web/apps/photos/package.json @@ -27,7 +27,7 @@ "piexifjs": "^1.0.6", "pure-react-carousel": "^1.30.1", "react-dropzone": "^14.2", - "react-otp-input": "^2.3.1", + "react-otp-input": "^3.1.1", "react-select": "^5.8.0", "react-top-loading-bar": "^2.3.1", "react-virtualized-auto-sizer": "^1.0", diff --git a/web/packages/accounts/components/two-factor/VerifyForm.tsx b/web/packages/accounts/components/two-factor/VerifyForm.tsx index 3a3d7ac7f3..2881397ef8 100644 --- a/web/packages/accounts/components/two-factor/VerifyForm.tsx +++ b/web/packages/accounts/components/two-factor/VerifyForm.tsx @@ -4,7 +4,7 @@ import { CenteredFlex, VerticallyCentered, } from "@ente/shared/components/Container"; -import { Box, Typography } from "@mui/material"; +import { Box, Typography, styled } from "@mui/material"; import { Formik, type FormikHelpers } from "formik"; import { t } from "i18next"; import { useRef, useState } from "react"; @@ -71,17 +71,21 @@ export default function VerifyTwoFactor(props: Props) { -} + renderInput={(props) => ( + + )} + shouldAutoFocus + // separator={"-"} + // isInputNum + // className={"otp-input"} /> {errors.otp && ( @@ -107,3 +111,22 @@ export default function VerifyTwoFactor(props: Props) { ); } + +const IndividualInput = styled("input")( + ({ theme }) => ` + font-size: 2rem; + padding: 4px 12px; + min-width: 3rem; + margin-inline: 8px; + border: 1px solid ${theme.colors.accent.A700}; + border-radius: 1px; + outline-color: ${theme.colors.accent.A300}; + transition: 0.5s; + + @media (width < 30em) { + font-size: 1rem; + padding: 4px; + min-width: 2rem; + } +`, +); diff --git a/web/yarn.lock b/web/yarn.lock index 411a432aab..a1dc11d308 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -3743,10 +3743,10 @@ react-is@^18.3.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react-otp-input@^2.3.1: - version "2.4.0" - resolved "https://registry.yarnpkg.com/react-otp-input/-/react-otp-input-2.4.0.tgz#0f0a3de1d8c8d564e2e4fbe5d6b7b56e29e3a6e6" - integrity sha512-AIgl7u4sS9BTNCxX1xlaS5fPWay/Zml8Ho5LszXZKXrH1C/TiFsTQGmtl13UecQYO3mSF3HUzG2rrDf0sjEFmg== +react-otp-input@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/react-otp-input/-/react-otp-input-3.1.1.tgz#910169629812c40a614e6c175cc2c5f36102bb61" + integrity sha512-bjPavgJ0/Zmf/AYi4onj8FbH93IjeD+e8pWwxIJreDEWsU1ILR5fs8jEJmMGWSBe/yyvPP6X/W6Mk9UkOCkTPw== react-refresh@^0.14.2: version "0.14.2" From d9e6ff2fee14e0a0284a9dee1a45f3c952e54781 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 19:21:20 +0530 Subject: [PATCH 16/45] Autofocus back to first on error --- web/apps/photos/src/styles/global.css | 19 ------------------ .../components/two-factor/VerifyForm.tsx | 20 +++++++++---------- 2 files changed, 9 insertions(+), 30 deletions(-) diff --git a/web/apps/photos/src/styles/global.css b/web/apps/photos/src/styles/global.css index e8cf798bce..c30221d644 100644 --- a/web/apps/photos/src/styles/global.css +++ b/web/apps/photos/src/styles/global.css @@ -124,22 +124,3 @@ body { .pswp__caption--empty { display: none; } - -div.otp-input input { - width: 36px !important; - height: 36px; - margin: 0 10px; -} - -div.otp-input input::placeholder { - opacity: 0; -} - -div.otp-input input:not(:placeholder-shown), -div.otp-input input:focus { - border: 2px solid #51cd7c; - border-radius: 1px; - -webkit-transition: 0.5s; - transition: 0.5s; - outline: none; -} diff --git a/web/packages/accounts/components/two-factor/VerifyForm.tsx b/web/packages/accounts/components/two-factor/VerifyForm.tsx index 2881397ef8..1561568f35 100644 --- a/web/packages/accounts/components/two-factor/VerifyForm.tsx +++ b/web/packages/accounts/components/two-factor/VerifyForm.tsx @@ -7,7 +7,7 @@ import { import { Box, Typography, styled } from "@mui/material"; import { Formik, type FormikHelpers } from "formik"; import { t } from "i18next"; -import { useRef, useState } from "react"; +import { useState } from "react"; import OtpInput from "react-otp-input"; interface formValues { @@ -25,7 +25,7 @@ export type VerifyTwoFactorCallback = ( export default function VerifyTwoFactor(props: Props) { const [waiting, setWaiting] = useState(false); - const otpInputRef = useRef(null); + const [shouldAutoFocus, setShouldAutoFocus] = useState(true); const markSuccessful = async () => { setWaiting(false); @@ -40,11 +40,13 @@ export default function VerifyTwoFactor(props: Props) { await props.onSubmit(otp, markSuccessful); } catch (e) { resetForm(); - for (let i = 0; i < 6; i++) { - otpInputRef.current?.focusPrevInput(); - } const message = e instanceof Error ? e.message : ""; setFieldError("otp", `${t("generic_error_retry")} ${message}`); + // Workaround (toggling shouldAutoFocus) to reset the focus back to + // the first input field in case of errors. + // https://github.com/devfolioco/react-otp-input/issues/420 + setShouldAutoFocus(false); + setTimeout(() => setShouldAutoFocus(true), 100); } setWaiting(false); }; @@ -71,21 +73,17 @@ export default function VerifyTwoFactor(props: Props) { -} renderInput={(props) => ( )} - shouldAutoFocus - // separator={"-"} - // isInputNum - // className={"otp-input"} /> {errors.otp && ( @@ -123,7 +121,7 @@ const IndividualInput = styled("input")( outline-color: ${theme.colors.accent.A300}; transition: 0.5s; - @media (width < 30em) { + ${theme.breakpoints.down("sm")} { font-size: 1rem; padding: 4px; min-width: 2rem; From d2db27d4eeb02e1116ccfb61508bf69d4553aec0 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 19:27:33 +0530 Subject: [PATCH 17/45] Style to fit during the login flow --- .../accounts/components/two-factor/VerifyForm.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/web/packages/accounts/components/two-factor/VerifyForm.tsx b/web/packages/accounts/components/two-factor/VerifyForm.tsx index 1561568f35..765b8aa235 100644 --- a/web/packages/accounts/components/two-factor/VerifyForm.tsx +++ b/web/packages/accounts/components/two-factor/VerifyForm.tsx @@ -112,9 +112,10 @@ export default function VerifyTwoFactor(props: Props) { const IndividualInput = styled("input")( ({ theme }) => ` - font-size: 2rem; - padding: 4px 12px; - min-width: 3rem; + font-size: 1.5rem; + padding: 4px; + width: 40px !important; + aspect-ratio: 1; margin-inline: 8px; border: 1px solid ${theme.colors.accent.A700}; border-radius: 1px; @@ -124,7 +125,7 @@ const IndividualInput = styled("input")( ${theme.breakpoints.down("sm")} { font-size: 1rem; padding: 4px; - min-width: 2rem; + width: 32px !important; } `, ); From 8e5fc76ef1572acbd96049da0fdbbcc43fae99c1 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 29 Oct 2024 19:29:12 +0530 Subject: [PATCH 18/45] Move dep to correct place --- web/apps/photos/package.json | 1 - web/docs/dependencies.md | 8 ++++---- web/packages/accounts/package.json | 3 ++- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/apps/photos/package.json b/web/apps/photos/package.json index 303d6cdf2c..e2b6b828e3 100644 --- a/web/apps/photos/package.json +++ b/web/apps/photos/package.json @@ -27,7 +27,6 @@ "piexifjs": "^1.0.6", "pure-react-carousel": "^1.30.1", "react-dropzone": "^14.2", - "react-otp-input": "^3.1.1", "react-select": "^5.8.0", "react-top-loading-bar": "^2.3.1", "react-virtualized-auto-sizer": "^1.0", diff --git a/web/docs/dependencies.md b/web/docs/dependencies.md index 1eae21a578..5c43b11966 100644 --- a/web/docs/dependencies.md +++ b/web/docs/dependencies.md @@ -132,7 +132,7 @@ with Next.js. For more details, see [translations.md](translations.md). -### Others +### Other UI components - [formik](https://github.com/jaredpalmer/formik) provides an easier to use abstraction for dealing with form state, validation and submission states @@ -140,6 +140,9 @@ For more details, see [translations.md](translations.md). - [react-select](https://react-select.com/) is used for search dropdowns. +- [react-otp-input](https://github.com/devfolioco/react-otp-input) is used to + render a segmented OTP input field for 2FA authentication. + ## Utilities - [comlink](https://github.com/GoogleChromeLabs/comlink) provides a minimal @@ -206,9 +209,6 @@ For more details, see [translations.md](translations.md). - [pure-react-carousel](https://github.com/express-labs/pure-react-carousel) is used for the feature carousel on the welcome (login / signup) screen. -- [react-otp-input](https://github.com/devfolioco/react-otp-input) is used to - render a segmented OTP input field. - ## Auth app specific - [otpauth](https://github.com/hectorm/otpauth) is used for the generation of diff --git a/web/packages/accounts/package.json b/web/packages/accounts/package.json index 574276df1a..c5f7b0b881 100644 --- a/web/packages/accounts/package.json +++ b/web/packages/accounts/package.json @@ -5,6 +5,7 @@ "dependencies": { "@/base": "*", "@ente/eslint-config": "*", - "@ente/shared": "*" + "@ente/shared": "*", + "react-otp-input": "^3.1.1" } } From f97952298dbce2a4e0950a18c8d41120071d6de1 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 10:20:45 +0530 Subject: [PATCH 19/45] [web] [desktop] Retain JPEG originals even on date modifications --- desktop/CHANGELOG.md | 1 + docs/docs/photos/faq/photo-dates.md | 39 ++++- web/apps/photos/package.json | 1 - web/apps/photos/src/services/export/index.ts | 9 +- web/apps/photos/src/utils/file/index.ts | 9 +- web/docs/dependencies.md | 3 +- .../new/photos/services/exif-update.ts | 147 ------------------ web/packages/new/photos/types/piexifjs.d.ts | 42 ----- web/yarn.lock | 5 - 9 files changed, 38 insertions(+), 218 deletions(-) delete mode 100644 web/packages/new/photos/services/exif-update.ts delete mode 100644 web/packages/new/photos/types/piexifjs.d.ts diff --git a/desktop/CHANGELOG.md b/desktop/CHANGELOG.md index 2b0ed2975c..e544b644b1 100644 --- a/desktop/CHANGELOG.md +++ b/desktop/CHANGELOG.md @@ -2,6 +2,7 @@ ## v1.7.7 (Unreleased) +- Retain JPEG originals even on date modifications. - . ## v1.7.6 diff --git a/docs/docs/photos/faq/photo-dates.md b/docs/docs/photos/faq/photo-dates.md index 10710caa80..1918093dc7 100644 --- a/docs/docs/photos/faq/photo-dates.md +++ b/docs/docs/photos/faq/photo-dates.md @@ -64,12 +64,14 @@ videos that you imported. The modifications (e.g. date changes) you make within Ente will be written into a separate metadata JSON file during export so as to not modify the original. -> There is one exception to this. For JPEG files, the Exif DateTimeOriginal is -> changed during export from web or desktop apps. This was done on a customer -> request, but in hindsight this has been an incorrect move. We are going to -> deprecate this behavior, and will instead provide separate tools (or -> functionality within the app itself) for customers who intentionally wish to -> modify their originals to reflect the associated metadata JSON. +> [!WARNING] +> +> There used to be one exception to this - for JPEG files, the Exif +> DateTimeOriginal was changed during export from web or desktop apps. This was +> done on a customer request, but in hindsight this was an incorrect change. +> +> We have deprecated this behaviour, and the desktop version 1.7.6 is going to +> be the last version with this exception. As an example: suppose you have `flower.png`. When you export your library, you will end up with: @@ -81,13 +83,36 @@ metadata/flower.png.json Ente writes this JSON in the same format as Google Takeout so that if a tool supports Google Takeout import, it should be able to read the JSON written by -Ente too +Ente too. > One small difference is that, to avoid clutter, Ente puts the JSON in the > `metadata/` subfolder, while Google puts it next to the file.
> >
Ente itself will read it from either place. +Here is a sample of how the JSON would look: + +```json +{ + "description": "This will be imported as the caption", + "creationTime": { + "timestamp": "1613532136", + "formatted": "17 Feb 2021, 03:22:16 UTC" + }, + "modificationTime": { + "timestamp": "1640225957", + "formatted": "23 Dec 2021, 02:19:17 UTC" + }, + "geoData": { + "latitude": 12.004170700000001, + "longitude": 79.8013945 + } +} +``` + +`photoTakenTime` will be considered as an alias for `creationTime`, and +`geoDataExif` will be considered as a fallback for `geoData`. + ### File creation time. The photo's data will be preserved verbatim, however when it is written out to diff --git a/web/apps/photos/package.json b/web/apps/photos/package.json index e2b6b828e3..1bfcec9104 100644 --- a/web/apps/photos/package.json +++ b/web/apps/photos/package.json @@ -24,7 +24,6 @@ "ml-matrix": "^6.11", "p-debounce": "^4.0.0", "photoswipe": "file:./thirdparty/photoswipe", - "piexifjs": "^1.0.6", "pure-react-carousel": "^1.30.1", "react-dropzone": "^14.2", "react-select": "^5.8.0", diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index e31e5d0494..53192409a7 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -14,7 +14,6 @@ import { getCollectionUserFacingName, } from "@/new/photos/services/collection"; import downloadManager from "@/new/photos/services/download"; -import { updateExifIfNeededAndPossible } from "@/new/photos/services/exif-update"; import { exportMetadataDirectoryName, exportTrashDirectoryName, @@ -939,16 +938,12 @@ class ExportService { try { const fileUID = getExportRecordFileUID(file); const originalFileStream = await downloadManager.getFile(file); - const updatedFileStream = await updateExifIfNeededAndPossible( - file, - originalFileStream, - ); if (file.metadata.fileType === FileType.livePhoto) { await this.exportLivePhoto( exportDir, fileUID, collectionExportPath, - updatedFileStream, + originalFileStream, file, ); } else { @@ -965,7 +960,7 @@ class ExportService { await writeStream( electron, `${collectionExportPath}/${fileExportName}`, - updatedFileStream, + originalFileStream, ); await this.addFileExportedRecord( exportDir, diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index c9f2280c7c..79588f5170 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -16,7 +16,6 @@ import { ItemVisibility } from "@/media/file-metadata"; import { FileType } from "@/media/file-type"; import { decodeLivePhoto } from "@/media/live-photo"; import DownloadManager from "@/new/photos/services/download"; -import { updateExifIfNeededAndPossible } from "@/new/photos/services/exif-update"; import { isArchivedFile, updateMagicMetadata, @@ -79,9 +78,6 @@ export async function downloadFile(file: EnteFile) { const fileType = await detectFileTypeInfo( new File([fileBlob], file.metadata.title), ); - fileBlob = await new Response( - await updateExifIfNeededAndPossible(file, fileBlob.stream()), - ).blob(); fileBlob = new Blob([fileBlob], { type: fileType.mimeType }); const tempURL = URL.createObjectURL(fileBlob); downloadAndRevokeObjectURL(tempURL, file.metadata.title); @@ -397,10 +393,9 @@ async function downloadFileDesktop( const fs = electron.fs; const stream = await DownloadManager.getFile(file); - const updatedStream = await updateExifIfNeededAndPossible(file, stream); if (file.metadata.fileType === FileType.livePhoto) { - const fileBlob = await new Response(updatedStream).blob(); + const fileBlob = await new Response(stream).blob(); const { imageFileName, imageData, videoFileName, videoData } = await decodeLivePhoto(file.metadata.title, fileBlob); const imageExportName = await safeFileName( @@ -439,7 +434,7 @@ async function downloadFileDesktop( await writeStream( electron, `${downloadDir}/${fileExportName}`, - updatedStream, + stream, ); } } diff --git a/web/docs/dependencies.md b/web/docs/dependencies.md index 5c43b11966..ba6d2f677b 100644 --- a/web/docs/dependencies.md +++ b/web/docs/dependencies.md @@ -167,8 +167,7 @@ For more details, see [translations.md](translations.md). ## Media - [ExifReader](https://github.com/mattiasw/ExifReader) is used for Exif - parsing. [piexifjs](https://github.com/hMatoba/piexifjs) is used for writing - back Exif (only supports JPEG). + parsing. - [jszip](https://github.com/Stuk/jszip) is used for reading zip files in the web code (Live photos are zip files under the hood). Note that the desktop diff --git a/web/packages/new/photos/services/exif-update.ts b/web/packages/new/photos/services/exif-update.ts deleted file mode 100644 index 64a343ad35..0000000000 --- a/web/packages/new/photos/services/exif-update.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { lowercaseExtension } from "@/base/file"; -import log from "@/base/log"; -import type { EnteFile } from "@/media/file"; -import { FileType } from "@/media/file-type"; -import piexif from "piexifjs"; - -/** - * Return a new stream after applying Exif updates if applicable to the given - * stream, otherwise return the original. - * - * This function is meant to provide a stream that can be used to download (or - * export) a file to the user's computer after applying any Exif updates to the - * original file's data. - * - * - This only updates JPEG files. - * - * - For JPEG files, the DateTimeOriginal Exif entry is updated to reflect the - * time that the user edited within Ente. - * - * @param file The {@link EnteFile} whose data we want. - * - * @param stream A {@link ReadableStream} containing the original data for - * {@link file}. - * - * @returns A new {@link ReadableStream} with updates if any updates were - * needed, otherwise return the original stream. - */ -export const updateExifIfNeededAndPossible = async ( - file: EnteFile, - stream: ReadableStream, -): Promise> => { - // Not needed: Not an image. - if (file.metadata.fileType != FileType.image) return stream; - - // Not needed: Time was not edited. - if (!file.pubMagicMetadata?.data.editedTime) return stream; - - const fileName = file.metadata.title; - const extension = lowercaseExtension(fileName); - // Not possible: Not a JPEG (likely). - if (extension != "jpeg" && extension != "jpg") return stream; - - const blob = await new Response(stream).blob(); - try { - const updatedBlob = await setJPEGExifDateTimeOriginal( - blob, - new Date(file.pubMagicMetadata.data.editedTime / 1000), - ); - return updatedBlob.stream(); - } catch (e) { - log.error(`Failed to modify Exif date for ${fileName}`, e); - // Ignore errors and use the original - we don't want to block the whole - // download or export for an errant file. TODO: This is not always going - // to be the correct choice, but instead trying further hack around with - // the Exif modifications (and all the caveats that come with it), a - // more principled approach is to put our metadata in a sidecar and - // never touch the original. We can then and provide additional tools to - // update the original if the user so wishes from the sidecar. - return blob.stream(); - } -}; - -/** - * Return a new blob with the "DateTimeOriginal" Exif tag set to the given - * {@link date}. - * - * @param jpegBlob A {@link Blob} containing JPEG data. - * - * @param date A {@link Date} to use as the value for the Exif - * "DateTimeOriginal" tag. - * - * @returns A new blob derived from {@link jpegBlob} but with the updated date. - */ -const setJPEGExifDateTimeOriginal = async (jpegBlob: Blob, date: Date) => { - let dataURL = await blobToDataURL(jpegBlob); - // Since we pass a Blob without an associated type, we get back a generic - // data URL of the form "data:application/octet-stream;base64,...". - // - // Modify it to have a `image/jpeg` MIME type. - dataURL = "data:image/jpeg;base64" + dataURL.slice(dataURL.indexOf(",")); - - const exifObj = piexif.load(dataURL); - if (!exifObj.Exif) exifObj.Exif = {}; - exifObj.Exif[piexif.ExifIFD.DateTimeOriginal] = - convertToExifDateFormat(date); - const exifBytes = piexif.dump(exifObj); - const exifInsertedFile = piexif.insert(exifBytes, dataURL); - - return dataURLToBlob(exifInsertedFile); -}; - -/** - * Convert a blob to a `data:` URL. - */ -const blobToDataURL = (blob: Blob) => - new Promise((resolve) => { - const reader = new FileReader(); - // We need to cast to a string here. This should be safe since MDN says: - // - // > the result attribute contains the data as a data: URL representing - // > the file's data as a base64 encoded string. - // > - // > https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL - reader.onload = () => resolve(reader.result as string); - reader.readAsDataURL(blob); - }); - -/** - * Convert a `data:` URL to a blob. - * - * Requires `connect-src data:` in the CSP (since it internally uses `fetch` to - * perform the conversion). - */ -const dataURLToBlob = (dataURI: string) => - fetch(dataURI).then((res) => res.blob()); - -/** - * Convert the given {@link Date} to a format that is expected by Exif for the - * DateTimeOriginal tag. - * - * See: [Note: Exif dates] - * - * --- - * - * TODO: This functionality is deprecated. The library we use here is - * unmaintained and there are no comprehensive other JS libs. - * - * Instead of doing this in this selective way, we should provide a CLI tool - * with better format support and more comprehensive handling of Exif and other - * metadata fields (like captions) that can be used by the user to modify their - * original from the Ente sidecar if they so wish. - */ -const convertToExifDateFormat = (date: Date) => { - const YYYY = zeroPad(date.getFullYear(), 4); - // JavaScript getMonth is zero-indexed, we want one-indexed. - const MM = zeroPad(date.getMonth() + 1, 2); - // JavaScript getDate is NOT zero-indexed, it is already one-indexed. - const DD = zeroPad(date.getDate(), 2); - const HH = zeroPad(date.getHours(), 2); - const mm = zeroPad(date.getMinutes(), 2); - const ss = zeroPad(date.getSeconds(), 2); - - return `${YYYY}:${MM}:${DD} ${HH}:${mm}:${ss}`; -}; - -/** Zero pad the given number to {@link d} digits. */ -const zeroPad = (n: number, d: number) => n.toString().padStart(d, "0"); diff --git a/web/packages/new/photos/types/piexifjs.d.ts b/web/packages/new/photos/types/piexifjs.d.ts deleted file mode 100644 index 211ee9754e..0000000000 --- a/web/packages/new/photos/types/piexifjs.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Types for [piexifjs](https://github.com/hMatoba/piexifjs). - * - * Non exhaustive, only the function we need. - */ -declare module "piexifjs" { - interface ExifObj { - Exif?: Record; - } - - interface Piexifjs { - /** - * Get exif data as object. - * - * @param jpegData a string that starts with "data:image/jpeg;base64," - * (a data URL), "\xff\xd8", or "Exif". - */ - load: (jpegData: string) => ExifObj; - /** - * Get exif as string to insert into JPEG. - * - * @param exifObj An object obtained using {@link load}. - */ - dump: (exifObj: ExifObj) => string; - /** - * Insert exif into JPEG. - * - * If {@link jpegData} is a data URL, returns the modified JPEG as a - * data URL. Else if {@link jpegData} is binary as string, returns JPEG - * as binary as string. - */ - insert: (exifStr: string, jpegData: string) => string; - /** - * Keys for the tags in {@link ExifObj}. - */ - ExifIFD: { - DateTimeOriginal: number; - }; - } - const piexifjs: Piexifjs; - export default piexifjs; -} diff --git a/web/yarn.lock b/web/yarn.lock index a1dc11d308..ab0c8a1aa9 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -3602,11 +3602,6 @@ picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -piexifjs@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/piexifjs/-/piexifjs-1.0.6.tgz#883811d73f447218d0d06e9ed7866d04533e59e0" - integrity sha512-0wVyH0cKohzBQ5Gi2V1BuxYpxWfxF3cSqfFXfPIpl5tl9XLS5z4ogqhUCD20AbHi0h9aJkqXNJnkVev6gwh2ag== - pngjs@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821" From 28cb942e6ce63e2e511394b7ea0cf8191206db26 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 10:53:02 +0530 Subject: [PATCH 20/45] Fix formatting lint issue --- web/apps/photos/src/utils/file/index.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 79588f5170..01680217db 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -431,11 +431,7 @@ async function downloadFileDesktop( file.metadata.title, fs.exists, ); - await writeStream( - electron, - `${downloadDir}/${fileExportName}`, - stream, - ); + await writeStream(electron, `${downloadDir}/${fileExportName}`, stream); } } From 258d1768fd9b158a23cc1f96e4341cd8b444f5f2 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 11:31:02 +0530 Subject: [PATCH 21/45] Inline --- .../src/components/TwoFactor/Modal/Manage.tsx | 112 ------------- .../src/components/TwoFactor/Modal/Setup.tsx | 34 ---- .../src/components/TwoFactor/Modal/index.tsx | 152 +++++++++++++++++- 3 files changed, 148 insertions(+), 150 deletions(-) delete mode 100644 web/apps/photos/src/components/TwoFactor/Modal/Manage.tsx delete mode 100644 web/apps/photos/src/components/TwoFactor/Modal/Setup.tsx diff --git a/web/apps/photos/src/components/TwoFactor/Modal/Manage.tsx b/web/apps/photos/src/components/TwoFactor/Modal/Manage.tsx deleted file mode 100644 index 241164653f..0000000000 --- a/web/apps/photos/src/components/TwoFactor/Modal/Manage.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { t } from "i18next"; -import { useContext } from "react"; - -import { disableTwoFactor } from "@/accounts/api/user"; -import { AppContext } from "@/new/photos/types/context"; -import { PHOTOS_PAGES as PAGES } from "@ente/shared/constants/pages"; -import { LS_KEYS, getData, setLSUser } from "@ente/shared/storage/localStorage"; -import { Button, Grid } from "@mui/material"; -import router from "next/router"; - -interface Iprops { - closeDialog: () => void; -} - -export default function TwoFactorModalManageSection(props: Iprops) { - const { closeDialog } = props; - const { setDialogMessage } = useContext(AppContext); - - const warnTwoFactorDisable = async () => { - setDialogMessage({ - title: t("DISABLE_TWO_FACTOR"), - - content: t("DISABLE_TWO_FACTOR_MESSAGE"), - close: { text: t("cancel") }, - proceed: { - variant: "critical", - text: t("disable"), - action: twoFactorDisable, - }, - }); - }; - - const twoFactorDisable = async () => { - try { - await disableTwoFactor(); - await setLSUser({ - ...getData(LS_KEYS.USER), - isTwoFactorEnabled: false, - }); - closeDialog(); - } catch (e) { - setDialogMessage({ - title: t("TWO_FACTOR_DISABLE_FAILED"), - close: {}, - }); - } - }; - - const warnTwoFactorReconfigure = async () => { - setDialogMessage({ - title: t("UPDATE_TWO_FACTOR"), - - content: t("UPDATE_TWO_FACTOR_MESSAGE"), - close: { text: t("cancel") }, - proceed: { - variant: "accent", - text: t("UPDATE"), - action: reconfigureTwoFactor, - }, - }); - }; - - const reconfigureTwoFactor = async () => { - closeDialog(); - router.push(PAGES.TWO_FACTOR_SETUP); - }; - - return ( - <> - - - {t("UPDATE_TWO_FACTOR_LABEL")} - - - - - - - - {t("DISABLE_TWO_FACTOR_LABEL")}{" "} - - - - - - - - ); -} diff --git a/web/apps/photos/src/components/TwoFactor/Modal/Setup.tsx b/web/apps/photos/src/components/TwoFactor/Modal/Setup.tsx deleted file mode 100644 index dbe724b469..0000000000 --- a/web/apps/photos/src/components/TwoFactor/Modal/Setup.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { PHOTOS_PAGES as PAGES } from "@ente/shared/constants/pages"; -import LockIcon from "@mui/icons-material/Lock"; -import { t } from "i18next"; -import { useRouter } from "next/router"; - -import { VerticallyCentered } from "@ente/shared/components/Container"; -import { Button, Typography } from "@mui/material"; - -interface Iprops { - closeDialog: () => void; -} - -export default function TwoFactorModalSetupSection({ closeDialog }: Iprops) { - const router = useRouter(); - const redirectToTwoFactorSetup = () => { - closeDialog(); - router.push(PAGES.TWO_FACTOR_SETUP); - }; - - return ( - - theme.spacing(5), mb: 2 }} /> - {t("TWO_FACTOR_INFO")} - - - ); -} diff --git a/web/apps/photos/src/components/TwoFactor/Modal/index.tsx b/web/apps/photos/src/components/TwoFactor/Modal/index.tsx index da65c7e73b..69afd8fcbc 100644 --- a/web/apps/photos/src/components/TwoFactor/Modal/index.tsx +++ b/web/apps/photos/src/components/TwoFactor/Modal/index.tsx @@ -1,12 +1,23 @@ +import { disableTwoFactor } from "@/accounts/api/user"; import type { ModalVisibilityProps } from "@/base/components/utils/modal"; +import { AppContext } from "@/new/photos/types/context"; +import { VerticallyCentered } from "@ente/shared/components/Container"; import DialogTitleWithCloseButton from "@ente/shared/components/DialogBox/TitleWithCloseButton"; +import { PHOTOS_PAGES as PAGES } from "@ente/shared/constants/pages"; import { LS_KEYS, getData, setLSUser } from "@ente/shared/storage/localStorage"; -import { Dialog, DialogContent, styled } from "@mui/material"; +import LockIcon from "@mui/icons-material/Lock"; +import { + Button, + Dialog, + DialogContent, + Grid, + Typography, + styled, +} from "@mui/material"; import { t } from "i18next"; -import { useEffect, useState } from "react"; +import router, { useRouter } from "next/router"; +import { useContext, useEffect, useState } from "react"; import { getTwoFactorStatus } from "services/userService"; -import TwoFactorModalManageSection from "./Manage"; -import TwoFactorModalSetupSection from "./Setup"; const TwoFactorDialog = styled(Dialog)(({ theme }) => ({ "& .MuiDialogContent-root": { @@ -66,4 +77,137 @@ function TwoFactorModal(props: Props) { ); } + export default TwoFactorModal; + +interface TwoFactorModalSetupSectionProps { + closeDialog: () => void; +} + +function TwoFactorModalSetupSection({ + closeDialog, +}: TwoFactorModalSetupSectionProps) { + const router = useRouter(); + const redirectToTwoFactorSetup = () => { + closeDialog(); + router.push(PAGES.TWO_FACTOR_SETUP); + }; + + return ( + + theme.spacing(5), mb: 2 }} /> + {t("TWO_FACTOR_INFO")} + + + ); +} + +interface TwoFactorModalManageSectionProps { + closeDialog: () => void; +} + +function TwoFactorModalManageSection(props: TwoFactorModalManageSectionProps) { + const { closeDialog } = props; + const { setDialogMessage } = useContext(AppContext); + + const warnTwoFactorDisable = async () => { + setDialogMessage({ + title: t("DISABLE_TWO_FACTOR"), + + content: t("DISABLE_TWO_FACTOR_MESSAGE"), + close: { text: t("cancel") }, + proceed: { + variant: "critical", + text: t("disable"), + action: twoFactorDisable, + }, + }); + }; + + const twoFactorDisable = async () => { + try { + await disableTwoFactor(); + await setLSUser({ + ...getData(LS_KEYS.USER), + isTwoFactorEnabled: false, + }); + closeDialog(); + } catch (e) { + setDialogMessage({ + title: t("TWO_FACTOR_DISABLE_FAILED"), + close: {}, + }); + } + }; + + const warnTwoFactorReconfigure = async () => { + setDialogMessage({ + title: t("UPDATE_TWO_FACTOR"), + + content: t("UPDATE_TWO_FACTOR_MESSAGE"), + close: { text: t("cancel") }, + proceed: { + variant: "accent", + text: t("UPDATE"), + action: reconfigureTwoFactor, + }, + }); + }; + + const reconfigureTwoFactor = async () => { + closeDialog(); + router.push(PAGES.TWO_FACTOR_SETUP); + }; + + return ( + <> + + + {t("UPDATE_TWO_FACTOR_LABEL")} + + + + + + + + {t("DISABLE_TWO_FACTOR_LABEL")}{" "} + + + + + + + + ); +} From 56bac2160e3993292fa58b47413755bea7c4bfb4 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 12:08:48 +0530 Subject: [PATCH 22/45] Combine --- .../photos/src/components/Sidebar/index.tsx | 2 +- web/apps/photos/src/services/logout.ts | 2 +- web/apps/photos/src/services/sync.ts | 2 +- .../new/photos/services/feature-flags.ts | 128 ------------------ web/packages/new/photos/services/ml/index.ts | 4 +- .../new/photos/services/remote-store.ts | 125 +++++++++++++++++ 6 files changed, 130 insertions(+), 133 deletions(-) delete mode 100644 web/packages/new/photos/services/feature-flags.ts diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx index d5ca9fb601..a053a42f14 100644 --- a/web/apps/photos/src/components/Sidebar/index.tsx +++ b/web/apps/photos/src/components/Sidebar/index.tsx @@ -675,7 +675,7 @@ const DebugSection: React.FC = () => { ); }; -// TODO: Legacy synchronous check, use the one for feature-flags.ts instead. +// TODO: Legacy synchronous check, use the one from remote-store.ts instead. const isInternalUserViaEmailCheck = () => { const userEmail = getData(LS_KEYS.USER)?.email; if (!userEmail) return false; diff --git a/web/apps/photos/src/services/logout.ts b/web/apps/photos/src/services/logout.ts index fa36e4f8fa..238dbe5565 100644 --- a/web/apps/photos/src/services/logout.ts +++ b/web/apps/photos/src/services/logout.ts @@ -1,7 +1,7 @@ import { accountLogout } from "@/accounts/services/logout"; import log from "@/base/log"; import DownloadManager from "@/new/photos/services/download"; -import { clearFeatureFlagSessionState } from "@/new/photos/services/feature-flags"; +import { clearFeatureFlagSessionState } from "@/new/photos/services/remote-store"; import { logoutML, terminateMLWorker } from "@/new/photos/services/ml"; import { logoutSearch } from "@/new/photos/services/search"; import exportService from "./export"; diff --git a/web/apps/photos/src/services/sync.ts b/web/apps/photos/src/services/sync.ts index 012af2fe11..77ac72081a 100644 --- a/web/apps/photos/src/services/sync.ts +++ b/web/apps/photos/src/services/sync.ts @@ -1,4 +1,4 @@ -import { triggerFeatureFlagsFetchIfNeeded } from "@/new/photos/services/feature-flags"; +import { triggerFeatureFlagsFetchIfNeeded } from "@/new/photos/services/remote-store"; import { isMLSupported, mlStatusSync, mlSync } from "@/new/photos/services/ml"; import { searchDataSync } from "@/new/photos/services/search"; import { syncMapEnabled } from "services/userService"; diff --git a/web/packages/new/photos/services/feature-flags.ts b/web/packages/new/photos/services/feature-flags.ts deleted file mode 100644 index d694522228..0000000000 --- a/web/packages/new/photos/services/feature-flags.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { authenticatedRequestHeaders, ensureOk } from "@/base/http"; -import { localUser } from "@/base/local-user"; -import log from "@/base/log"; -import { apiURL } from "@/base/origins"; -import { nullToUndefined } from "@/utils/transform"; -import { z } from "zod"; - -let _fetchTimeout: ReturnType | undefined; -let _haveFetched = false; - -/** - * Fetch feature flags (potentially user specific) from remote and save them in - * local storage for subsequent lookup. - * - * It fetches only once per session, and so is safe to call as arbitrarily many - * times. Remember to call {@link clearFeatureFlagSessionState} on logout to - * clear any in memory state so that these can be fetched again on the - * subsequent login. - * - * [Note: Feature Flags] - * - * The workflow with feature flags is: - * - * 1. On app start feature flags are fetched once and saved in local storage. If - * this fetch fails, we try again periodically (on every "sync") until - * success. - * - * 2. Attempts to access any individual feature flage (e.g. - * {@link isInternalUser}) returns the corresponding value from local storage - * (substituting a default if needed). - * - * 3. However, if perchance the fetch-on-app-start hasn't completed yet (or had - * failed), then a new fetch is tried. If even this fetch fails, we return - * the default. Otherwise the now fetched result is saved to local storage - * and the corresponding value returned. - */ -export const triggerFeatureFlagsFetchIfNeeded = () => { - if (_haveFetched) return; - if (_fetchTimeout) return; - // Not critical, so fetch these after some delay. - _fetchTimeout = setTimeout(() => { - _fetchTimeout = undefined; - void fetchAndSaveFeatureFlags().then(() => { - _haveFetched = true; - }); - }, 2000); -}; - -export const clearFeatureFlagSessionState = () => { - if (_fetchTimeout) { - clearTimeout(_fetchTimeout); - _fetchTimeout = undefined; - } - _haveFetched = false; -}; - -/** - * Fetch feature flags (potentially user specific) from remote and save them in - * local storage for subsequent lookup. - */ -const fetchAndSaveFeatureFlags = () => - fetchFeatureFlags() - .then((res) => res.text()) - .then(saveFlagJSONString); - -const fetchFeatureFlags = async () => { - const res = await fetch(await apiURL("/remote-store/feature-flags"), { - headers: await authenticatedRequestHeaders(), - }); - ensureOk(res); - return res; -}; - -const saveFlagJSONString = (s: string) => - localStorage.setItem("remoteFeatureFlags", s); - -const remoteFeatureFlags = () => { - const s = localStorage.getItem("remoteFeatureFlags"); - if (!s) return undefined; - return FeatureFlags.parse(JSON.parse(s)); -}; - -const FeatureFlags = z.object({ - internalUser: z.boolean().nullish().transform(nullToUndefined), - betaUser: z.boolean().nullish().transform(nullToUndefined), -}); - -type FeatureFlags = z.infer; - -const remoteFeatureFlagsFetchingIfNeeded = async () => { - let ff = remoteFeatureFlags(); - if (!ff) { - try { - await fetchAndSaveFeatureFlags(); - } catch (e) { - log.warn("Ignoring error when fetching feature flags", e); - } - ff = remoteFeatureFlags(); - } - return ff; -}; - -/** - * Return `true` if the current user is marked as an "internal" user. - * - * 1. Emails that end in `@ente.io` are considered as internal users. - * 2. If the "internalUser" remote feature flag is set, the user is internal. - * 3. Otherwise false. - * - * See also: [Note: Feature Flags]. - */ -export const isInternalUser = async () => { - const user = localUser(); - if (user?.email.endsWith("@ente.io")) return true; - - const flags = await remoteFeatureFlagsFetchingIfNeeded(); - return flags?.internalUser ?? false; -}; - -/** - * Return `true` if the current user is marked as a "beta" user. - * - * See also: [Note: Feature Flags]. - */ -export const isBetaUser = async () => { - const flags = await remoteFeatureFlagsFetchingIfNeeded(); - return flags?.betaUser ?? false; -}; diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 5e091f9f6a..73280c0f70 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -41,9 +41,9 @@ import type { CLIPMatches } from "./worker-types"; /** * Internal state of the ML subsystem. * - * This are essentially cached values used by the functions of this module. + * These are essentially cached values used by the functions of this module. * - * This should be cleared on logout. + * They will be cleared on logout. */ class MLState { /** diff --git a/web/packages/new/photos/services/remote-store.ts b/web/packages/new/photos/services/remote-store.ts index 875f0dd3bc..d37db83be1 100644 --- a/web/packages/new/photos/services/remote-store.ts +++ b/web/packages/new/photos/services/remote-store.ts @@ -1,7 +1,132 @@ import { authenticatedRequestHeaders, ensureOk } from "@/base/http"; +import { localUser } from "@/base/local-user"; +import log from "@/base/log"; import { apiURL } from "@/base/origins"; +import { nullToUndefined } from "@/utils/transform"; import { z } from "zod"; +let _fetchTimeout: ReturnType | undefined; +let _haveFetched = false; + +/** + * Fetch feature flags (potentially user specific) from remote and save them in + * local storage for subsequent lookup. + * + * It fetches only once per session, and so is safe to call as arbitrarily many + * times. Remember to call {@link clearFeatureFlagSessionState} on logout to + * clear any in memory state so that these can be fetched again on the + * subsequent login. + * + * [Note: Feature Flags] + * + * The workflow with feature flags is: + * + * 1. On app start feature flags are fetched once and saved in local storage. If + * this fetch fails, we try again periodically (on every "sync") until + * success. + * + * 2. Attempts to access any individual feature flage (e.g. + * {@link isInternalUser}) returns the corresponding value from local storage + * (substituting a default if needed). + * + * 3. However, if perchance the fetch-on-app-start hasn't completed yet (or had + * failed), then a new fetch is tried. If even this fetch fails, we return + * the default. Otherwise the now fetched result is saved to local storage + * and the corresponding value returned. + */ +export const triggerFeatureFlagsFetchIfNeeded = () => { + if (_haveFetched) return; + if (_fetchTimeout) return; + // Not critical, so fetch these after some delay. + _fetchTimeout = setTimeout(() => { + _fetchTimeout = undefined; + void fetchAndSaveFeatureFlags().then(() => { + _haveFetched = true; + }); + }, 2000); +}; + +export const clearFeatureFlagSessionState = () => { + if (_fetchTimeout) { + clearTimeout(_fetchTimeout); + _fetchTimeout = undefined; + } + _haveFetched = false; +}; + +/** + * Fetch feature flags (potentially user specific) from remote and save them in + * local storage for subsequent lookup. + */ +const fetchAndSaveFeatureFlags = () => + fetchFeatureFlags() + .then((res) => res.text()) + .then(saveFlagJSONString); + +const fetchFeatureFlags = async () => { + const res = await fetch(await apiURL("/remote-store/feature-flags"), { + headers: await authenticatedRequestHeaders(), + }); + ensureOk(res); + return res; +}; + +const saveFlagJSONString = (s: string) => + localStorage.setItem("remoteFeatureFlags", s); + +const remoteFeatureFlags = () => { + const s = localStorage.getItem("remoteFeatureFlags"); + if (!s) return undefined; + return FeatureFlags.parse(JSON.parse(s)); +}; + +const FeatureFlags = z.object({ + internalUser: z.boolean().nullish().transform(nullToUndefined), + betaUser: z.boolean().nullish().transform(nullToUndefined), +}); + +type FeatureFlags = z.infer; + +const remoteFeatureFlagsFetchingIfNeeded = async () => { + let ff = remoteFeatureFlags(); + if (!ff) { + try { + await fetchAndSaveFeatureFlags(); + } catch (e) { + log.warn("Ignoring error when fetching feature flags", e); + } + ff = remoteFeatureFlags(); + } + return ff; +}; + +/** + * Return `true` if the current user is marked as an "internal" user. + * + * 1. Emails that end in `@ente.io` are considered as internal users. + * 2. If the "internalUser" remote feature flag is set, the user is internal. + * 3. Otherwise false. + * + * See also: [Note: Feature Flags]. + */ +export const isInternalUser = async () => { + const user = localUser(); + if (user?.email.endsWith("@ente.io")) return true; + + const flags = await remoteFeatureFlagsFetchingIfNeeded(); + return flags?.internalUser ?? false; +}; + +/** + * Return `true` if the current user is marked as a "beta" user. + * + * See also: [Note: Feature Flags]. + */ +export const isBetaUser = async () => { + const flags = await remoteFeatureFlagsFetchingIfNeeded(); + return flags?.betaUser ?? false; +}; + /** * Fetch the value for the given {@link key} from remote store. * From b8dea0f296fbd57b6e9d5717f0002c448085e67a Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 12:27:11 +0530 Subject: [PATCH 23/45] Outline --- .../new/photos/services/remote-store.ts | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/web/packages/new/photos/services/remote-store.ts b/web/packages/new/photos/services/remote-store.ts index d37db83be1..7f78f08251 100644 --- a/web/packages/new/photos/services/remote-store.ts +++ b/web/packages/new/photos/services/remote-store.ts @@ -9,23 +9,46 @@ let _fetchTimeout: ReturnType | undefined; let _haveFetched = false; /** - * Fetch feature flags (potentially user specific) from remote and save them in - * local storage for subsequent lookup. + * Fetch remote flags (feature flags and other user specific preferences) from + * remote and save them in local storage for subsequent lookup. * - * It fetches only once per session, and so is safe to call as arbitrarily many - * times. Remember to call {@link clearFeatureFlagSessionState} on logout to - * clear any in memory state so that these can be fetched again on the + * It fetches only once per app lifetime, and so is safe to call as arbitrarily + * many times. Remember to call {@link clearFeatureFlagSessionState} on logout + * to clear any in memory state so that these can be fetched again on the * subsequent login. * - * [Note: Feature Flags] + * The local cache will also be updated if an individual flag is changed. * - * The workflow with feature flags is: + * [Note: Remote flags] * - * 1. On app start feature flags are fetched once and saved in local storage. If - * this fetch fails, we try again periodically (on every "sync") until - * success. + * The remote store provides a unified interface for persisting varied "remote + * flags": * - * 2. Attempts to access any individual feature flage (e.g. + * - User preferences like "mapEnabled" + * + * - Feature flags like "isInternalUser" + * + * There are two APIs to get the current state from remote: + * + * 1. GET /remote-store/feature-flags fetches the combined state (nb: even + * though the name of the endpoint has the word feature-flags, it also + * includes user preferences). + * + * 2. GET /remote-store fetches individual values. + * + * Usually 1 is what we use, since it gets us everything in a single go, and + * which we can also easily cache in local storage by saving the entire response + * JSON blob. + * + * There is a single API (/remote-store/update) to update the state on remote. + * + * At a high level, this is how the app manages this state: + * + * 1. On app start remote flags are fetched once and saved in local storage. If + * this fetch fails, we try again periodically (on every "sync") until + * success. + * + * 2. Attempts to access any individual feature flag (e.g. * {@link isInternalUser}) returns the corresponding value from local storage * (substituting a default if needed). * From 072c472f1c760143959051e60086a672299f5dd8 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 13:11:44 +0530 Subject: [PATCH 24/45] Outline --- web/apps/photos/src/services/sync.ts | 4 +-- .../new/photos/services/remote-store.ts | 36 +++++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/web/apps/photos/src/services/sync.ts b/web/apps/photos/src/services/sync.ts index 77ac72081a..830d043cad 100644 --- a/web/apps/photos/src/services/sync.ts +++ b/web/apps/photos/src/services/sync.ts @@ -1,5 +1,5 @@ -import { triggerFeatureFlagsFetchIfNeeded } from "@/new/photos/services/remote-store"; import { isMLSupported, mlStatusSync, mlSync } from "@/new/photos/services/ml"; +import { triggerRemoteFlagsFetchIfNeeded } from "@/new/photos/services/remote-store"; import { searchDataSync } from "@/new/photos/services/search"; import { syncMapEnabled } from "services/userService"; @@ -7,7 +7,7 @@ import { syncMapEnabled } from "services/userService"; * Part 1 of {@link sync}. See TODO below for why this is split. */ export const preFileInfoSync = async () => { - triggerFeatureFlagsFetchIfNeeded(); + triggerRemoteFlagsFetchIfNeeded(); await Promise.all([isMLSupported && mlStatusSync()]); }; diff --git a/web/packages/new/photos/services/remote-store.ts b/web/packages/new/photos/services/remote-store.ts index 7f78f08251..ae7f3a3df5 100644 --- a/web/packages/new/photos/services/remote-store.ts +++ b/web/packages/new/photos/services/remote-store.ts @@ -5,6 +5,30 @@ import { apiURL } from "@/base/origins"; import { nullToUndefined } from "@/utils/transform"; import { z } from "zod"; +/** + * Internal in-memory state shared by the functions in this module. + * + * This entire object will be reset on logout. + */ +class SettingsState { + /** + * In-memory flag that tracks if maps are enabled. + * + * - On app start, this is read from local storage in + * {@link initSettings}. + * + * - It gets updated when we sync with remote (once per app start in + * {@link triggerRemoteFlagsFetchIfNeeded}, and whenever the user opens + * the preferences panel). + * + * - It gets updated when the user toggles the corresponding setting on + * this device. + * + * - It is cleared in {@link logoutML}. + */ + isMapEnabled = false; +} + let _fetchTimeout: ReturnType | undefined; let _haveFetched = false; @@ -57,13 +81,13 @@ let _haveFetched = false; * the default. Otherwise the now fetched result is saved to local storage * and the corresponding value returned. */ -export const triggerFeatureFlagsFetchIfNeeded = () => { +export const triggerRemoteFlagsFetchIfNeeded = () => { if (_haveFetched) return; if (_fetchTimeout) return; // Not critical, so fetch these after some delay. _fetchTimeout = setTimeout(() => { _fetchTimeout = undefined; - void fetchAndSaveFeatureFlags().then(() => { + void fetchAndSaveRemoteFlags().then(() => { _haveFetched = true; }); }, 2000); @@ -78,10 +102,10 @@ export const clearFeatureFlagSessionState = () => { }; /** - * Fetch feature flags (potentially user specific) from remote and save them in - * local storage for subsequent lookup. + * Fetch remote flags from remote and save them in local storage for subsequent + * lookup. */ -const fetchAndSaveFeatureFlags = () => +const fetchAndSaveRemoteFlags = () => fetchFeatureFlags() .then((res) => res.text()) .then(saveFlagJSONString); @@ -114,7 +138,7 @@ const remoteFeatureFlagsFetchingIfNeeded = async () => { let ff = remoteFeatureFlags(); if (!ff) { try { - await fetchAndSaveFeatureFlags(); + await fetchAndSaveRemoteFlags(); } catch (e) { log.warn("Ignoring error when fetching feature flags", e); } From 5609778ca1f8c30fbac0fdd32dd6a880ce8b3b43 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 13:25:14 +0530 Subject: [PATCH 25/45] Transition to settings --- web/apps/photos/src/services/logout.ts | 6 +-- .../new/photos/services/remote-store.ts | 47 ++++++++++--------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/web/apps/photos/src/services/logout.ts b/web/apps/photos/src/services/logout.ts index 238dbe5565..604cdaed20 100644 --- a/web/apps/photos/src/services/logout.ts +++ b/web/apps/photos/src/services/logout.ts @@ -1,7 +1,7 @@ import { accountLogout } from "@/accounts/services/logout"; import log from "@/base/log"; import DownloadManager from "@/new/photos/services/download"; -import { clearFeatureFlagSessionState } from "@/new/photos/services/remote-store"; +import { logoutSettings } from "@/new/photos/services/remote-store"; import { logoutML, terminateMLWorker } from "@/new/photos/services/ml"; import { logoutSearch } from "@/new/photos/services/search"; import exportService from "./export"; @@ -37,9 +37,9 @@ export const photosLogout = async () => { log.info("logout (photos)"); try { - clearFeatureFlagSessionState(); + logoutSettings(); } catch (e) { - ignoreError("feature-flag", e); + ignoreError("settings", e); } try { diff --git a/web/packages/new/photos/services/remote-store.ts b/web/packages/new/photos/services/remote-store.ts index ae7f3a3df5..b06d99278c 100644 --- a/web/packages/new/photos/services/remote-store.ts +++ b/web/packages/new/photos/services/remote-store.ts @@ -11,6 +11,19 @@ import { z } from "zod"; * This entire object will be reset on logout. */ class SettingsState { + /** + * The ID of the user whose settings these are. + * + * It is used to discard stale completions. + */ + userID: string | undefined = undefined; + + /** + * True if we have performed a fetch for the logged in user since the app + * started. + */ + haveFetched = false; + /** * In-memory flag that tracks if maps are enabled. * @@ -29,8 +42,8 @@ class SettingsState { isMapEnabled = false; } -let _fetchTimeout: ReturnType | undefined; -let _haveFetched = false; +/** State shared by the functions in this module. See {@link SettingsState}. */ +let _state = new SettingsState(); /** * Fetch remote flags (feature flags and other user specific preferences) from @@ -82,33 +95,25 @@ let _haveFetched = false; * and the corresponding value returned. */ export const triggerRemoteFlagsFetchIfNeeded = () => { - if (_haveFetched) return; - if (_fetchTimeout) return; - // Not critical, so fetch these after some delay. - _fetchTimeout = setTimeout(() => { - _fetchTimeout = undefined; - void fetchAndSaveRemoteFlags().then(() => { - _haveFetched = true; - }); - }, 2000); + if (!_state.haveFetched) void fetchAndSaveRemoteFlags(); }; -export const clearFeatureFlagSessionState = () => { - if (_fetchTimeout) { - clearTimeout(_fetchTimeout); - _fetchTimeout = undefined; - } - _haveFetched = false; +export const logoutSettings = () => { + _state = new SettingsState(); }; /** * Fetch remote flags from remote and save them in local storage for subsequent * lookup. */ -const fetchAndSaveRemoteFlags = () => - fetchFeatureFlags() - .then((res) => res.text()) - .then(saveFlagJSONString); +const fetchAndSaveRemoteFlags = async () => { + const userID = _state.userID; + const jsonString = await fetchFeatureFlags().then((res) => res.text()); + if (jsonString && _state.userID == userID) { + saveFlagJSONString(jsonString); + _state.haveFetched = true; + } +}; const fetchFeatureFlags = async () => { const res = await fetch(await apiURL("/remote-store/feature-flags"), { From 3483466391924b2e8fcc432d479ed1316385d9fc Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 13:25:27 +0530 Subject: [PATCH 26/45] Rename --- web/packages/new/photos/services/ml/index.ts | 2 +- .../new/photos/services/{remote-store.ts => settings.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename web/packages/new/photos/services/{remote-store.ts => settings.ts} (100%) diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 73280c0f70..b1f1c883bb 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -14,7 +14,7 @@ import { FileType } from "@/media/file-type"; import { ensure } from "@/utils/ensure"; import { throttled } from "@/utils/promise"; import { proxy, transfer } from "comlink"; -import { getRemoteFlag, updateRemoteFlag } from "../remote-store"; +import { getRemoteFlag, updateRemoteFlag } from "../settings"; import { setSearchPeople } from "../search"; import type { UploadItem } from "../upload/types"; import { diff --git a/web/packages/new/photos/services/remote-store.ts b/web/packages/new/photos/services/settings.ts similarity index 100% rename from web/packages/new/photos/services/remote-store.ts rename to web/packages/new/photos/services/settings.ts From 6b71ce2cf947a4e8b18ee5f12359f083b3563996 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 13:27:43 +0530 Subject: [PATCH 27/45] To settings --- web/apps/photos/src/services/logout.ts | 2 +- web/apps/photos/src/services/sync.ts | 4 ++-- web/packages/new/photos/services/settings.ts | 10 +++++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/web/apps/photos/src/services/logout.ts b/web/apps/photos/src/services/logout.ts index 604cdaed20..b4856ec5b3 100644 --- a/web/apps/photos/src/services/logout.ts +++ b/web/apps/photos/src/services/logout.ts @@ -1,9 +1,9 @@ import { accountLogout } from "@/accounts/services/logout"; import log from "@/base/log"; import DownloadManager from "@/new/photos/services/download"; -import { logoutSettings } from "@/new/photos/services/remote-store"; import { logoutML, terminateMLWorker } from "@/new/photos/services/ml"; import { logoutSearch } from "@/new/photos/services/search"; +import { logoutSettings } from "@/new/photos/services/settings"; import exportService from "./export"; /** diff --git a/web/apps/photos/src/services/sync.ts b/web/apps/photos/src/services/sync.ts index 830d043cad..9a3d71b35f 100644 --- a/web/apps/photos/src/services/sync.ts +++ b/web/apps/photos/src/services/sync.ts @@ -1,5 +1,5 @@ import { isMLSupported, mlStatusSync, mlSync } from "@/new/photos/services/ml"; -import { triggerRemoteFlagsFetchIfNeeded } from "@/new/photos/services/remote-store"; +import { triggerSettingsSyncIfNeeded } from "@/new/photos/services/settings"; import { searchDataSync } from "@/new/photos/services/search"; import { syncMapEnabled } from "services/userService"; @@ -7,7 +7,7 @@ import { syncMapEnabled } from "services/userService"; * Part 1 of {@link sync}. See TODO below for why this is split. */ export const preFileInfoSync = async () => { - triggerRemoteFlagsFetchIfNeeded(); + triggerSettingsSyncIfNeeded(); await Promise.all([isMLSupported && mlStatusSync()]); }; diff --git a/web/packages/new/photos/services/settings.ts b/web/packages/new/photos/services/settings.ts index b06d99278c..45aefb9248 100644 --- a/web/packages/new/photos/services/settings.ts +++ b/web/packages/new/photos/services/settings.ts @@ -1,3 +1,7 @@ +/** + * @file Storage (in-memory, local, remote) and update of various settings. + */ + import { authenticatedRequestHeaders, ensureOk } from "@/base/http"; import { localUser } from "@/base/local-user"; import log from "@/base/log"; @@ -31,8 +35,8 @@ class SettingsState { * {@link initSettings}. * * - It gets updated when we sync with remote (once per app start in - * {@link triggerRemoteFlagsFetchIfNeeded}, and whenever the user opens - * the preferences panel). + * {@link triggerSettingsSyncIfNeeded}, and whenever the user opens the + * preferences panel). * * - It gets updated when the user toggles the corresponding setting on * this device. @@ -94,7 +98,7 @@ let _state = new SettingsState(); * the default. Otherwise the now fetched result is saved to local storage * and the corresponding value returned. */ -export const triggerRemoteFlagsFetchIfNeeded = () => { +export const triggerSettingsSyncIfNeeded = () => { if (!_state.haveFetched) void fetchAndSaveRemoteFlags(); }; From 9b04de216cf24bd9d1fe67f01cea0833202b69b0 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 14:02:43 +0530 Subject: [PATCH 28/45] wip: checkpoint --- web/packages/new/photos/services/settings.ts | 117 ++++++++++--------- 1 file changed, 61 insertions(+), 56 deletions(-) diff --git a/web/packages/new/photos/services/settings.ts b/web/packages/new/photos/services/settings.ts index 45aefb9248..9a42906a89 100644 --- a/web/packages/new/photos/services/settings.ts +++ b/web/packages/new/photos/services/settings.ts @@ -16,32 +16,30 @@ import { z } from "zod"; */ class SettingsState { /** - * The ID of the user whose settings these are. + * An arbitrary token to identify the current login. * * It is used to discard stale completions. */ - userID: string | undefined = undefined; + id: number; + + constructor() { + this.id = Math.random(); + } /** * True if we have performed a fetch for the logged in user since the app * started. */ - haveFetched = false; + haveSynced = false; + + /** + * In-memory flag that tracks if the current user is an internal user. + */ + isInternalUser = false; /** * In-memory flag that tracks if maps are enabled. * - * - On app start, this is read from local storage in - * {@link initSettings}. - * - * - It gets updated when we sync with remote (once per app start in - * {@link triggerSettingsSyncIfNeeded}, and whenever the user opens the - * preferences panel). - * - * - It gets updated when the user toggles the corresponding setting on - * this device. - * - * - It is cleared in {@link logoutML}. */ isMapEnabled = false; } @@ -83,23 +81,35 @@ let _state = new SettingsState(); * * There is a single API (/remote-store/update) to update the state on remote. * - * At a high level, this is how the app manages this state: + * [Note: Remote flag lifecycle] * - * 1. On app start remote flags are fetched once and saved in local storage. If - * this fetch fails, we try again periodically (on every "sync") until - * success. + * At a high level, this is how the app manages remote flags: * - * 2. Attempts to access any individual feature flag (e.g. - * {@link isInternalUser}) returns the corresponding value from local storage - * (substituting a default if needed). + * 1. On app start, the initial are read from local storage in + * {@link initSettings}. + * + * 2. On app start, as part of the normal sync with remote, remote flags are + * fetched once and saved in local storage, and the in-memory state updated + * to reflect the latest values ({@link triggerSettingsSyncIfNeeded}). If + * this fetch fails, we try again periodically (on every sync with remote) + * until success. + * + * 3. Some operations like opening the preferences panel or updating a value + * also cause an unconditional fetch and update ({@link syncSettings}). + * + * 4. The individual getter functions for the flags (e.g. + * {@link isInternalUser}) return the in-memory values, and so are suitable + * for frequent use during UI rendering. + * + * 5. Everything gets reset to the default state on {@link logoutSettings}. * - * 3. However, if perchance the fetch-on-app-start hasn't completed yet (or had - * failed), then a new fetch is tried. If even this fetch fails, we return - * the default. Otherwise the now fetched result is saved to local storage - * and the corresponding value returned. */ export const triggerSettingsSyncIfNeeded = () => { - if (!_state.haveFetched) void fetchAndSaveRemoteFlags(); + if (!_state.haveSynced) void syncSettings(); +}; + +export const initSettings = () => { + readInMemoryFlagsFromLocalStorage(); }; export const logoutSettings = () => { @@ -108,15 +118,18 @@ export const logoutSettings = () => { /** * Fetch remote flags from remote and save them in local storage for subsequent - * lookup. + * lookup. Then use the results to update our in memory state if needed. */ -const fetchAndSaveRemoteFlags = async () => { - const userID = _state.userID; +export const syncSettings = async () => { + const id = _state.id; const jsonString = await fetchFeatureFlags().then((res) => res.text()); - if (jsonString && _state.userID == userID) { - saveFlagJSONString(jsonString); - _state.haveFetched = true; + if (_state.id != id) { + log.info("Discarding stale settings sync not for the current login"); + return; } + saveRemoteFeatureFlagsJSONString(jsonString); + readInMemoryFlagsFromLocalStorage(); + _state.haveSynced = true; }; const fetchFeatureFlags = async () => { @@ -127,10 +140,10 @@ const fetchFeatureFlags = async () => { return res; }; -const saveFlagJSONString = (s: string) => +const saveRemoteFeatureFlagsJSONString = (s: string) => localStorage.setItem("remoteFeatureFlags", s); -const remoteFeatureFlags = () => { +const savedRemoteFeatureFlags = () => { const s = localStorage.getItem("remoteFeatureFlags"); if (!s) return undefined; return FeatureFlags.parse(JSON.parse(s)); @@ -144,44 +157,36 @@ const FeatureFlags = z.object({ type FeatureFlags = z.infer; const remoteFeatureFlagsFetchingIfNeeded = async () => { - let ff = remoteFeatureFlags(); + let ff = savedRemoteFeatureFlags(); if (!ff) { try { - await fetchAndSaveRemoteFlags(); + await syncSettings(); } catch (e) { log.warn("Ignoring error when fetching feature flags", e); } - ff = remoteFeatureFlags(); + ff = savedRemoteFeatureFlags(); } return ff; }; +const readInMemoryFlagsFromLocalStorage = () => { + const flags = savedRemoteFeatureFlags(); + _state.isInternalUser = flags?.internalUser ?? isInternalUserViaEmail(); +}; + +export const isInternalUserViaEmail = () => { + const user = localUser(); + return !!user?.email.endsWith("@ente.io"); +}; + /** * Return `true` if the current user is marked as an "internal" user. * * 1. Emails that end in `@ente.io` are considered as internal users. * 2. If the "internalUser" remote feature flag is set, the user is internal. * 3. Otherwise false. - * - * See also: [Note: Feature Flags]. */ -export const isInternalUser = async () => { - const user = localUser(); - if (user?.email.endsWith("@ente.io")) return true; - - const flags = await remoteFeatureFlagsFetchingIfNeeded(); - return flags?.internalUser ?? false; -}; - -/** - * Return `true` if the current user is marked as a "beta" user. - * - * See also: [Note: Feature Flags]. - */ -export const isBetaUser = async () => { - const flags = await remoteFeatureFlagsFetchingIfNeeded(); - return flags?.betaUser ?? false; -}; +export const isInternalUser = () => _state.isInternalUser; /** * Fetch the value for the given {@link key} from remote store. From 98ad12b4154d5d699637c3a24c6d3731920a91a4 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 14:10:22 +0530 Subject: [PATCH 29/45] Start using --- .../src/components/Sidebar/Preferences.tsx | 7 +++++- .../photos/src/components/Sidebar/index.tsx | 13 +++-------- web/apps/photos/src/pages/_app.tsx | 2 ++ web/packages/new/photos/services/settings.ts | 22 +++++-------------- 4 files changed, 17 insertions(+), 27 deletions(-) diff --git a/web/apps/photos/src/components/Sidebar/Preferences.tsx b/web/apps/photos/src/components/Sidebar/Preferences.tsx index f133e96a4b..975012bd01 100644 --- a/web/apps/photos/src/components/Sidebar/Preferences.tsx +++ b/web/apps/photos/src/components/Sidebar/Preferences.tsx @@ -13,13 +13,14 @@ import { } from "@/base/i18n"; import { MLSettings } from "@/new/photos/components/MLSettings"; import { isMLSupported } from "@/new/photos/services/ml"; +import { syncSettings } from "@/new/photos/services/settings"; import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; import ChevronRight from "@mui/icons-material/ChevronRight"; import ScienceIcon from "@mui/icons-material/Science"; import { Box, DialogProps, Stack } from "@mui/material"; import DropdownInput from "components/DropdownInput"; import { t } from "i18next"; -import React from "react"; +import React, { useEffect } from "react"; import { AdvancedSettings } from "./AdvancedSettings"; import { MapSettings } from "./MapSetting"; @@ -37,6 +38,10 @@ export const Preferences: React.FC = ({ const { show: showMLSettings, props: mlSettingsVisibilityProps } = useModalVisibility(); + useEffect(() => { + if (open) void syncSettings(); + }, [open]); + const handleRootClose = () => { onClose(); onRootClose(); diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx index a053a42f14..1d090d52b4 100644 --- a/web/apps/photos/src/components/Sidebar/index.tsx +++ b/web/apps/photos/src/components/Sidebar/index.tsx @@ -16,6 +16,7 @@ import { TRASH_SECTION, } from "@/new/photos/services/collection"; import type { CollectionSummaries } from "@/new/photos/services/collection/ui"; +import { isInternalUser } from "@/new/photos/services/settings"; import { AppContext, useAppContext } from "@/new/photos/types/context"; import { initiateEmail, openURL } from "@/new/photos/utils/web"; import { SpaceBetweenFlex } from "@ente/shared/components/Container"; @@ -482,7 +483,7 @@ const UtilitySection: React.FC = ({ closeSidebar }) => { onClick={showRecoveryKey} label={t("recovery_key")} /> - {isInternalUserViaEmailCheck() && ( + {isInternalUser() && ( { return ( <> - {isInternalUserViaEmailCheck() && ( + {isInternalUser() && ( { ); }; - -// TODO: Legacy synchronous check, use the one from remote-store.ts instead. -const isInternalUserViaEmailCheck = () => { - const userEmail = getData(LS_KEYS.USER)?.email; - if (!userEmail) return false; - - return userEmail.endsWith("@ente.io"); -}; diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 36c43c7420..3cb82d3a94 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -23,6 +23,7 @@ import { photosDialogZIndex } from "@/new/photos/components/utils/z-index"; import DownloadManager from "@/new/photos/services/download"; import { runMigrations } from "@/new/photos/services/migrations"; import { initML, isMLSupported } from "@/new/photos/services/ml"; +import { initSettings } from "@/new/photos/services/settings"; import { AppContext } from "@/new/photos/types/context"; import { Overlay } from "@ente/shared/components/Container"; import DialogBox from "@ente/shared/components/DialogBox"; @@ -100,6 +101,7 @@ export default function App({ Component, pageProps }: AppProps) { HTTPService.setHeaders({ "X-Client-Package": clientPackageName }); logUnhandledErrorsAndRejections(true); void runMigrations(); + initSettings(); return () => logUnhandledErrorsAndRejections(false); }, []); diff --git a/web/packages/new/photos/services/settings.ts b/web/packages/new/photos/services/settings.ts index 9a42906a89..c759b4c4dd 100644 --- a/web/packages/new/photos/services/settings.ts +++ b/web/packages/new/photos/services/settings.ts @@ -34,12 +34,15 @@ class SettingsState { /** * In-memory flag that tracks if the current user is an internal user. + * + * See: [Note: Remote flag lifecycle]. */ isInternalUser = false; /** * In-memory flag that tracks if maps are enabled. * + * See: [Note: Remote flag lifecycle]. */ isMapEnabled = false; } @@ -102,7 +105,6 @@ let _state = new SettingsState(); * for frequent use during UI rendering. * * 5. Everything gets reset to the default state on {@link logoutSettings}. - * */ export const triggerSettingsSyncIfNeeded = () => { if (!_state.haveSynced) void syncSettings(); @@ -156,25 +158,13 @@ const FeatureFlags = z.object({ type FeatureFlags = z.infer; -const remoteFeatureFlagsFetchingIfNeeded = async () => { - let ff = savedRemoteFeatureFlags(); - if (!ff) { - try { - await syncSettings(); - } catch (e) { - log.warn("Ignoring error when fetching feature flags", e); - } - ff = savedRemoteFeatureFlags(); - } - return ff; -}; - const readInMemoryFlagsFromLocalStorage = () => { const flags = savedRemoteFeatureFlags(); - _state.isInternalUser = flags?.internalUser ?? isInternalUserViaEmail(); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + _state.isInternalUser = flags?.internalUser || isInternalUserViaEmail(); }; -export const isInternalUserViaEmail = () => { +const isInternalUserViaEmail = () => { const user = localUser(); return !!user?.email.endsWith("@ente.io"); }; From 774227c14e54c14a4fa650f4722363ad13f8cf1b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 14:36:31 +0530 Subject: [PATCH 30/45] Split again --- web/packages/new/photos/services/ml/index.ts | 2 +- .../new/photos/services/remote-store.ts | 76 +++++++++++++++++++ web/packages/new/photos/services/settings.ts | 75 +----------------- 3 files changed, 78 insertions(+), 75 deletions(-) create mode 100644 web/packages/new/photos/services/remote-store.ts diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index b1f1c883bb..73280c0f70 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -14,7 +14,7 @@ import { FileType } from "@/media/file-type"; import { ensure } from "@/utils/ensure"; import { throttled } from "@/utils/promise"; import { proxy, transfer } from "comlink"; -import { getRemoteFlag, updateRemoteFlag } from "../settings"; +import { getRemoteFlag, updateRemoteFlag } from "../remote-store"; import { setSearchPeople } from "../search"; import type { UploadItem } from "../upload/types"; import { diff --git a/web/packages/new/photos/services/remote-store.ts b/web/packages/new/photos/services/remote-store.ts new file mode 100644 index 0000000000..2981e87bdd --- /dev/null +++ b/web/packages/new/photos/services/remote-store.ts @@ -0,0 +1,76 @@ +import { authenticatedRequestHeaders, ensureOk } from "@/base/http"; +import { apiURL } from "@/base/origins"; +import { z } from "zod"; + +/** + * [Note: Remote store] + * + * The remote store provides a unified interface for persisting varied "remote + * flags": + * + * - User preferences like "mapEnabled" + * + * - Feature flags like "isInternalUser" + * + * There are two APIs to get the current state from remote: + * + * 1. GET /remote-store/feature-flags fetches the combined state (nb: even + * though the name of the endpoint has the word feature-flags, it also + * includes user preferences). + * + * 2. GET /remote-store fetches individual values. + * + * Usually 1 is what we use, since it gets us everything in a single go, and + * which we can also easily cache in local storage by saving the entire response + * JSON blob. + * + * There is a single API (/remote-store/update) to update the state on remote. + */ +export const fetchFeatureFlags = async () => { + const res = await fetch(await apiURL("/remote-store/feature-flags"), { + headers: await authenticatedRequestHeaders(), + }); + ensureOk(res); + return res; +}; + +/** + * Fetch the value for the given {@link key} from remote store. + * + * If the key is not present in the remote store, return {@link defaultValue}. + */ +export const getRemoteValue = async (key: string, defaultValue: string) => { + const url = await apiURL("/remote-store"); + const params = new URLSearchParams({ key, defaultValue }); + const res = await fetch(`${url}?${params.toString()}`, { + headers: await authenticatedRequestHeaders(), + }); + ensureOk(res); + return GetRemoteStoreResponse.parse(await res.json())?.value; +}; + +const GetRemoteStoreResponse = z.object({ value: z.string() }).nullable(); + +/** + * Convenience wrapper over {@link getRemoteValue} that returns booleans. + */ +export const getRemoteFlag = async (key: string) => + (await getRemoteValue(key, "false")) == "true"; + +/** + * Update or insert {@link value} for the given {@link key} into remote store. + */ +export const updateRemoteValue = async (key: string, value: string) => + ensureOk( + await fetch(await apiURL("/remote-store/update"), { + method: "POST", + headers: await authenticatedRequestHeaders(), + body: JSON.stringify({ key, value }), + }), + ); + +/** + * Convenience wrapper over {@link updateRemoteValue} that sets booleans. + */ +export const updateRemoteFlag = (key: string, value: boolean) => + updateRemoteValue(key, JSON.stringify(value)); diff --git a/web/packages/new/photos/services/settings.ts b/web/packages/new/photos/services/settings.ts index c759b4c4dd..0203099120 100644 --- a/web/packages/new/photos/services/settings.ts +++ b/web/packages/new/photos/services/settings.ts @@ -2,12 +2,11 @@ * @file Storage (in-memory, local, remote) and update of various settings. */ -import { authenticatedRequestHeaders, ensureOk } from "@/base/http"; import { localUser } from "@/base/local-user"; import log from "@/base/log"; -import { apiURL } from "@/base/origins"; import { nullToUndefined } from "@/utils/transform"; import { z } from "zod"; +import { fetchFeatureFlags } from "./remote-store"; /** * Internal in-memory state shared by the functions in this module. @@ -61,29 +60,6 @@ let _state = new SettingsState(); * * The local cache will also be updated if an individual flag is changed. * - * [Note: Remote flags] - * - * The remote store provides a unified interface for persisting varied "remote - * flags": - * - * - User preferences like "mapEnabled" - * - * - Feature flags like "isInternalUser" - * - * There are two APIs to get the current state from remote: - * - * 1. GET /remote-store/feature-flags fetches the combined state (nb: even - * though the name of the endpoint has the word feature-flags, it also - * includes user preferences). - * - * 2. GET /remote-store fetches individual values. - * - * Usually 1 is what we use, since it gets us everything in a single go, and - * which we can also easily cache in local storage by saving the entire response - * JSON blob. - * - * There is a single API (/remote-store/update) to update the state on remote. - * * [Note: Remote flag lifecycle] * * At a high level, this is how the app manages remote flags: @@ -134,14 +110,6 @@ export const syncSettings = async () => { _state.haveSynced = true; }; -const fetchFeatureFlags = async () => { - const res = await fetch(await apiURL("/remote-store/feature-flags"), { - headers: await authenticatedRequestHeaders(), - }); - ensureOk(res); - return res; -}; - const saveRemoteFeatureFlagsJSONString = (s: string) => localStorage.setItem("remoteFeatureFlags", s); @@ -177,44 +145,3 @@ const isInternalUserViaEmail = () => { * 3. Otherwise false. */ export const isInternalUser = () => _state.isInternalUser; - -/** - * Fetch the value for the given {@link key} from remote store. - * - * If the key is not present in the remote store, return {@link defaultValue}. - */ -export const getRemoteValue = async (key: string, defaultValue: string) => { - const url = await apiURL("/remote-store"); - const params = new URLSearchParams({ key, defaultValue }); - const res = await fetch(`${url}?${params.toString()}`, { - headers: await authenticatedRequestHeaders(), - }); - ensureOk(res); - return GetRemoteStoreResponse.parse(await res.json())?.value; -}; - -const GetRemoteStoreResponse = z.object({ value: z.string() }).nullable(); - -/** - * Convenience wrapper over {@link getRemoteValue} that returns booleans. - */ -export const getRemoteFlag = async (key: string) => - (await getRemoteValue(key, "false")) == "true"; - -/** - * Update or insert {@link value} for the given {@link key} into remote store. - */ -export const updateRemoteValue = async (key: string, value: string) => - ensureOk( - await fetch(await apiURL("/remote-store/update"), { - method: "POST", - headers: await authenticatedRequestHeaders(), - body: JSON.stringify({ key, value }), - }), - ); - -/** - * Convenience wrapper over {@link updateRemoteValue} that sets booleans. - */ -export const updateRemoteFlag = (key: string, value: boolean) => - updateRemoteValue(key, JSON.stringify(value)); From f502246cded22684cec4762d7c80d812cddcd77b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 14:42:24 +0530 Subject: [PATCH 31/45] LF --- web/apps/photos/src/services/sync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/apps/photos/src/services/sync.ts b/web/apps/photos/src/services/sync.ts index 9a3d71b35f..5914d898ee 100644 --- a/web/apps/photos/src/services/sync.ts +++ b/web/apps/photos/src/services/sync.ts @@ -1,6 +1,6 @@ import { isMLSupported, mlStatusSync, mlSync } from "@/new/photos/services/ml"; -import { triggerSettingsSyncIfNeeded } from "@/new/photos/services/settings"; import { searchDataSync } from "@/new/photos/services/search"; +import { triggerSettingsSyncIfNeeded } from "@/new/photos/services/settings"; import { syncMapEnabled } from "services/userService"; /** From 5a37760cf12fec4a658a08b9fa53aff708edd923 Mon Sep 17 00:00:00 2001 From: mangeshrex Date: Wed, 30 Oct 2024 20:50:54 +0530 Subject: [PATCH 32/45] add: resource links for running museum as a bg service --- docs/docs/self-hosting/guides/standalone-ente.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/docs/self-hosting/guides/standalone-ente.md b/docs/docs/self-hosting/guides/standalone-ente.md index d3e136fb04..d5359ddcf9 100644 --- a/docs/docs/self-hosting/guides/standalone-ente.md +++ b/docs/docs/self-hosting/guides/standalone-ente.md @@ -83,6 +83,13 @@ ENTE_DB_USER=ente_user air ``` +## Museum as a background service + +Please check the below links if you want to run Museum as a service, both of them are battle tested. + +1. [How to run museum as a systemd service](https://gist.github.com/mngshm/a0edb097c91d1dc45aeed755af310323) +2. [Museum.service](https://github.com/ente-io/ente/blob/23e678889189157ecc389c258267685934b83631/server/scripts/deploy/museum.service#L4) + Once you are done with setting and running Museum, all you are left to do is run the web app and reverse_proxy it with a webserver. You can check the following resources for Deploying your web app. 1. [Hosting the Web App](https://help.ente.io/self-hosting/guides/web-app). From 8685222472e69f202164ee2d8996dbc936ac3415 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 21:59:28 +0530 Subject: [PATCH 33/45] Only attempt to init settings after login --- web/apps/photos/src/pages/_app.tsx | 1 - web/apps/photos/src/pages/gallery.tsx | 2 ++ web/packages/new/photos/services/settings.ts | 6 ++++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 3cb82d3a94..69e954e370 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -101,7 +101,6 @@ export default function App({ Component, pageProps }: AppProps) { HTTPService.setHeaders({ "X-Client-Package": clientPackageName }); logUnhandledErrorsAndRejections(true); void runMigrations(); - initSettings(); return () => logUnhandledErrorsAndRejections(false); }, []); diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index 044e99046d..9b5c433337 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -43,6 +43,7 @@ import { setSearchCollectionsAndFiles, } from "@/new/photos/services/search"; import type { SearchOption } from "@/new/photos/services/search/types"; +import { initSettings } from "@/new/photos/services/settings"; import { useAppContext } from "@/new/photos/types/context"; import { splitByPredicate } from "@/utils/array"; import { ensure } from "@/utils/ensure"; @@ -341,6 +342,7 @@ export default function Gallery() { if (!valid) { return; } + initSettings(); await downloadManager.init(token); setupSelectAllKeyBoardShortcutHandler(); dispatch({ type: "showAll" }); diff --git a/web/packages/new/photos/services/settings.ts b/web/packages/new/photos/services/settings.ts index 0203099120..d0503bda98 100644 --- a/web/packages/new/photos/services/settings.ts +++ b/web/packages/new/photos/services/settings.ts @@ -86,6 +86,12 @@ export const triggerSettingsSyncIfNeeded = () => { if (!_state.haveSynced) void syncSettings(); }; +/** + * Read in the locally persisted settings into memory, but otherwise do not + * initiate any network requests to fetch the latest values. + * + * This assumes that the user is already logged in. + */ export const initSettings = () => { readInMemoryFlagsFromLocalStorage(); }; From b7af7be2dade0b7bfa95a36b57e87026c0284642 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 22:20:20 +0530 Subject: [PATCH 34/45] LF --- web/apps/photos/src/pages/_app.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 69e954e370..36c43c7420 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -23,7 +23,6 @@ import { photosDialogZIndex } from "@/new/photos/components/utils/z-index"; import DownloadManager from "@/new/photos/services/download"; import { runMigrations } from "@/new/photos/services/migrations"; import { initML, isMLSupported } from "@/new/photos/services/ml"; -import { initSettings } from "@/new/photos/services/settings"; import { AppContext } from "@/new/photos/types/context"; import { Overlay } from "@ente/shared/components/Container"; import DialogBox from "@ente/shared/components/DialogBox"; From c2514bc3364bc0fb822562a825e81bb94c132ac8 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 22:33:59 +0530 Subject: [PATCH 35/45] Default anchor is already left --- web/apps/photos/src/components/Sidebar/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx index 1d090d52b4..c56ec5e497 100644 --- a/web/apps/photos/src/components/Sidebar/index.tsx +++ b/web/apps/photos/src/components/Sidebar/index.tsx @@ -121,8 +121,6 @@ const DrawerSidebar = styled(EnteDrawer)(({ theme }) => ({ }, })); -DrawerSidebar.defaultProps = { anchor: "left" }; - interface HeaderSectionProps { closeSidebar: () => void; } From 4e47d856bf01e94c9c5df261421533dd17b14362 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 22:52:26 +0530 Subject: [PATCH 36/45] Rename and move --- .../accounts/src/pages/passkeys/index.tsx | 6 +- .../emailShare/AddParticipant.tsx | 66 ++++---- .../emailShare/ManageEmailShare.tsx | 10 +- .../emailShare/ManageParticipant.tsx | 123 +++++++------- .../Collections/CollectionShare/index.tsx | 6 +- .../publicShare/manage/deviceLimit.tsx | 6 +- .../publicShare/manage/index.tsx | 150 +++++++++--------- .../publicShare/manage/linkExpiry.tsx | 6 +- .../components/PhotoViewer/FileInfo/index.tsx | 4 +- .../PhotoViewer/ImageEditorOverlay/index.tsx | 6 +- .../components/Sidebar/AdvancedSettings.tsx | 6 +- .../src/components/Sidebar/MapSetting.tsx | 10 +- .../src/components/Sidebar/Preferences.tsx | 6 +- .../photos/src/components/Sidebar/index.tsx | 8 +- web/packages/base/components/EnteDrawer.tsx | 10 -- .../base/components/mui/SidebarDrawer.tsx | 17 ++ .../new/photos/components/MLSettings.tsx | 10 +- 17 files changed, 227 insertions(+), 223 deletions(-) delete mode 100644 web/packages/base/components/EnteDrawer.tsx create mode 100644 web/packages/base/components/mui/SidebarDrawer.tsx diff --git a/web/apps/accounts/src/pages/passkeys/index.tsx b/web/apps/accounts/src/pages/passkeys/index.tsx index 485fdf841d..ce1b928ddc 100644 --- a/web/apps/accounts/src/pages/passkeys/index.tsx +++ b/web/apps/accounts/src/pages/passkeys/index.tsx @@ -1,5 +1,5 @@ -import { EnteDrawer } from "@/base/components/EnteDrawer"; import { MenuItemDivider, MenuItemGroup } from "@/base/components/Menu"; +import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { Titlebar } from "@/base/components/Titlebar"; import { errorDialogAttributes } from "@/base/components/utils/dialog"; import log from "@/base/log"; @@ -283,7 +283,7 @@ const ManagePasskeyDrawer: React.FC = ({ return ( <> - + {token && passkey && ( = ({ )} - + {token && passkey && ( - - - - - - - + + + + + + ); } diff --git a/web/apps/photos/src/components/Collections/CollectionShare/emailShare/ManageEmailShare.tsx b/web/apps/photos/src/components/Collections/CollectionShare/emailShare/ManageEmailShare.tsx index cd2cb17e75..23cb8a4d62 100644 --- a/web/apps/photos/src/components/Collections/CollectionShare/emailShare/ManageEmailShare.tsx +++ b/web/apps/photos/src/components/Collections/CollectionShare/emailShare/ManageEmailShare.tsx @@ -1,9 +1,9 @@ -import { EnteDrawer } from "@/base/components/EnteDrawer"; import { MenuItemDivider, MenuItemGroup, MenuSectionTitle, } from "@/base/components/Menu"; +import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { Titlebar } from "@/base/components/Titlebar"; import { COLLECTION_ROLE, @@ -116,7 +116,11 @@ export default function ManageEmailShare({ return ( <> - + - + - - - + + + - - + + + + {t("ADDED_AS")} + + + + } + endIcon={ + selectedParticipant.role === + "COLLABORATOR" && + } + /> + + + } + endIcon={ + selectedParticipant.role === "VIEWER" && ( + + ) + } + /> + + + + {t("COLLABORATOR_RIGHTS")} + + + - {t("ADDED_AS")} + {t("REMOVE_PARTICIPANT_HEAD")} } - endIcon={ - selectedParticipant.role === - "COLLABORATOR" && - } - /> - - - } - endIcon={ - selectedParticipant.role === - "VIEWER" && - } + onClick={removeParticipant} + label={"Remove"} + startIcon={} /> - - - {t("COLLABORATOR_RIGHTS")} - - - - - {t("REMOVE_PARTICIPANT_HEAD")} - - - - } - /> - - - - + + ); } diff --git a/web/apps/photos/src/components/Collections/CollectionShare/index.tsx b/web/apps/photos/src/components/Collections/CollectionShare/index.tsx index 5fda2e00c8..a1804f8652 100644 --- a/web/apps/photos/src/components/Collections/CollectionShare/index.tsx +++ b/web/apps/photos/src/components/Collections/CollectionShare/index.tsx @@ -1,4 +1,4 @@ -import { EnteDrawer } from "@/base/components/EnteDrawer"; +import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { Titlebar } from "@/base/components/Titlebar"; import type { Collection } from "@/media/collection"; import type { CollectionSummary } from "@/new/photos/services/collection/ui"; @@ -32,7 +32,7 @@ function CollectionShare({ collectionSummary, ...props }: Props) { const { type } = collectionSummary; return ( - - + ); } export default CollectionShare; diff --git a/web/apps/photos/src/components/Collections/CollectionShare/publicShare/manage/deviceLimit.tsx b/web/apps/photos/src/components/Collections/CollectionShare/publicShare/manage/deviceLimit.tsx index 99517c4cd2..60e3198550 100644 --- a/web/apps/photos/src/components/Collections/CollectionShare/publicShare/manage/deviceLimit.tsx +++ b/web/apps/photos/src/components/Collections/CollectionShare/publicShare/manage/deviceLimit.tsx @@ -1,5 +1,5 @@ -import { EnteDrawer } from "@/base/components/EnteDrawer"; import { MenuItemDivider, MenuItemGroup } from "@/base/components/Menu"; +import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { Titlebar } from "@/base/components/Titlebar"; import type { Collection, @@ -68,7 +68,7 @@ export function ManageDeviceLimit({ endIcon={} /> - - + ); } diff --git a/web/apps/photos/src/components/Collections/CollectionShare/publicShare/manage/index.tsx b/web/apps/photos/src/components/Collections/CollectionShare/publicShare/manage/index.tsx index 79f46d1dc8..801b77a5ae 100644 --- a/web/apps/photos/src/components/Collections/CollectionShare/publicShare/manage/index.tsx +++ b/web/apps/photos/src/components/Collections/CollectionShare/publicShare/manage/index.tsx @@ -1,5 +1,5 @@ -import { EnteDrawer } from "@/base/components/EnteDrawer"; import { MenuItemDivider, MenuItemGroup } from "@/base/components/Menu"; +import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { Titlebar } from "@/base/components/Titlebar"; import type { Collection, @@ -86,24 +86,32 @@ export default function ManagePublicShareOptions({ navigator.clipboard.writeText(text); }; return ( - <> - - - - - - - + + + + + + + + - - - - - - - - - } - onClick={copyToClipboardHelper( - publicShareUrl, - )} - label={t("COPY_LINK")} - /> - - - } - onClick={disablePublicSharing} - label={t("REMOVE_LINK")} - /> - - - {sharableLinkError && ( - theme.colors.danger.A700, - mt: 0.5, - }} - > - {sharableLinkError} - - )} + + + + + + + } + onClick={copyToClipboardHelper(publicShareUrl)} + label={t("COPY_LINK")} + /> + + + } + onClick={disablePublicSharing} + label={t("REMOVE_LINK")} + /> + + {sharableLinkError && ( + theme.colors.danger.A700, + mt: 0.5, + }} + > + {sharableLinkError} + + )} - - + + ); } diff --git a/web/apps/photos/src/components/Collections/CollectionShare/publicShare/manage/linkExpiry.tsx b/web/apps/photos/src/components/Collections/CollectionShare/publicShare/manage/linkExpiry.tsx index 8487240274..3bc1c7f2c5 100644 --- a/web/apps/photos/src/components/Collections/CollectionShare/publicShare/manage/linkExpiry.tsx +++ b/web/apps/photos/src/components/Collections/CollectionShare/publicShare/manage/linkExpiry.tsx @@ -1,5 +1,5 @@ -import { EnteDrawer } from "@/base/components/EnteDrawer"; import { MenuItemDivider, MenuItemGroup } from "@/base/components/Menu"; +import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { Titlebar } from "@/base/components/Titlebar"; import type { Collection, @@ -84,7 +84,7 @@ export function ManageLinkExpiry({ } /> - - + ); } diff --git a/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx b/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx index 875fd404bb..e5664cd013 100644 --- a/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx @@ -1,6 +1,6 @@ -import { EnteDrawer } from "@/base/components/EnteDrawer"; import type { MiniDialogAttributes } from "@/base/components/MiniDialog"; import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; +import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { Titlebar } from "@/base/components/Titlebar"; import { EllipsizedTypography } from "@/base/components/Typography"; import { useModalVisibility } from "@/base/components/utils/modal"; @@ -400,7 +400,7 @@ const confirmDisableMapsDialogAttributes = ( }); const FileInfoSidebar = styled((props: DialogProps) => ( - + ))({ zIndex: fileInfoDrawerZIndex, "& .MuiPaper-root": { diff --git a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx index 931b70d690..de5afca30b 100644 --- a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx @@ -1,10 +1,10 @@ -import { EnteDrawer } from "@/base/components/EnteDrawer"; import { MenuItemDivider, MenuItemGroup, MenuSectionTitle, } from "@/base/components/Menu"; import type { MiniDialogAttributes } from "@/base/components/MiniDialog"; +import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { nameAndExtension } from "@/base/file"; import log from "@/base/log"; import { downloadAndRevokeObjectURL } from "@/base/utils/web"; @@ -614,7 +614,7 @@ const ImageEditorOverlay = (props: IProps) => {
- { title={t("PHOTO_EDIT_REQUIRED_TO_SAVE")} /> )} - + ); diff --git a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx index 24efe9a000..be0a499555 100644 --- a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx +++ b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx @@ -1,5 +1,5 @@ -import { EnteDrawer } from "@/base/components/EnteDrawer"; import { MenuItemGroup, MenuSectionTitle } from "@/base/components/Menu"; +import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { Titlebar } from "@/base/components/Titlebar"; import type { NestedDrawerVisibilityProps } from "@/base/components/utils/modal"; import { AppContext } from "@/new/photos/types/context"; @@ -33,7 +33,7 @@ export const AdvancedSettings: React.FC = ({ }; return ( - = ({ - + ); }; diff --git a/web/apps/photos/src/components/Sidebar/MapSetting.tsx b/web/apps/photos/src/components/Sidebar/MapSetting.tsx index 6fe1851eb1..f33b1f04a7 100644 --- a/web/apps/photos/src/components/Sidebar/MapSetting.tsx +++ b/web/apps/photos/src/components/Sidebar/MapSetting.tsx @@ -1,5 +1,5 @@ -import { EnteDrawer } from "@/base/components/EnteDrawer"; import { MenuItemGroup } from "@/base/components/Menu"; +import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { Titlebar } from "@/base/components/Titlebar"; import type { NestedDrawerVisibilityProps } from "@/base/components/utils/modal"; import log from "@/base/log"; @@ -54,7 +54,7 @@ export const MapSettings: React.FC = ({ }; return ( - = ({ onClose={closeModifyMapEnabled} onRootClose={handleRootClose} /> - + ); }; @@ -132,7 +132,7 @@ const ModifyMapEnabled = ({ open, onClose, onRootClose, mapEnabled }) => { return ( - { onRootClose={handleRootClose} /> )} - + ); }; diff --git a/web/apps/photos/src/components/Sidebar/Preferences.tsx b/web/apps/photos/src/components/Sidebar/Preferences.tsx index 975012bd01..c95751d17b 100644 --- a/web/apps/photos/src/components/Sidebar/Preferences.tsx +++ b/web/apps/photos/src/components/Sidebar/Preferences.tsx @@ -1,5 +1,5 @@ -import { EnteDrawer } from "@/base/components/EnteDrawer"; import { MenuItemGroup, MenuSectionTitle } from "@/base/components/Menu"; +import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { Titlebar } from "@/base/components/Titlebar"; import { useModalVisibility, @@ -56,7 +56,7 @@ export const Preferences: React.FC = ({ }; return ( - = ({ {...mlSettingsVisibilityProps} onRootClose={handleRootClose} /> - + ); }; diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx index c56ec5e497..8e8110e65a 100644 --- a/web/apps/photos/src/components/Sidebar/index.tsx +++ b/web/apps/photos/src/components/Sidebar/index.tsx @@ -1,9 +1,9 @@ import { RecoveryKey } from "@/accounts/components/RecoveryKey"; import { openAccountsManagePasskeysPage } from "@/accounts/services/passkey"; import { isDesktop } from "@/base/app"; -import { EnteDrawer } from "@/base/components/EnteDrawer"; import { EnteLogo } from "@/base/components/EnteLogo"; import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; +import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { useModalVisibility } from "@/base/components/utils/modal"; import log from "@/base/log"; import { savedLogs } from "@/base/log-web"; @@ -94,7 +94,7 @@ export default function Sidebar({ closeSidebar, }: Iprops) { return ( - + @@ -111,11 +111,11 @@ export default function Sidebar({ - + ); } -const DrawerSidebar = styled(EnteDrawer)(({ theme }) => ({ +const RootSidebarDrawer = styled(SidebarDrawer)(({ theme }) => ({ "& .MuiPaper-root": { padding: theme.spacing(1.5), }, diff --git a/web/packages/base/components/EnteDrawer.tsx b/web/packages/base/components/EnteDrawer.tsx deleted file mode 100644 index e6fc35bb15..0000000000 --- a/web/packages/base/components/EnteDrawer.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Drawer, styled } from "@mui/material"; - -export const EnteDrawer = styled(Drawer)(({ theme }) => ({ - "& .MuiPaper-root": { - maxWidth: "375px", - width: "100%", - scrollbarWidth: "thin", - padding: theme.spacing(1), - }, -})); diff --git a/web/packages/base/components/mui/SidebarDrawer.tsx b/web/packages/base/components/mui/SidebarDrawer.tsx new file mode 100644 index 0000000000..93ef27599c --- /dev/null +++ b/web/packages/base/components/mui/SidebarDrawer.tsx @@ -0,0 +1,17 @@ +import { Drawer, styled } from "@mui/material"; + +/** + * A MUI {@link Drawer} with a standard set of styling that we use for our left + * and right sidebar panels. + * + * It is width limited to 375px, and always at full width. It also has a default + * padding. + */ +export const SidebarDrawer = styled(Drawer)(({ theme }) => ({ + "& .MuiPaper-root": { + maxWidth: "375px", + width: "100%", + scrollbarWidth: "thin", + padding: theme.spacing(1), + }, +})); diff --git a/web/packages/new/photos/components/MLSettings.tsx b/web/packages/new/photos/components/MLSettings.tsx index 8959be3c6a..3b2c43f44d 100644 --- a/web/packages/new/photos/components/MLSettings.tsx +++ b/web/packages/new/photos/components/MLSettings.tsx @@ -1,6 +1,6 @@ -import { EnteDrawer } from "@/base/components/EnteDrawer"; import { MenuItemGroup } from "@/base/components/Menu"; import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; +import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { Titlebar } from "@/base/components/Titlebar"; import type { NestedDrawerVisibilityProps } from "@/base/components/utils/modal"; import { disableML, enableML, type MLStatus } from "@/new/photos/services/ml"; @@ -67,7 +67,7 @@ export const MLSettings: React.FC = ({ return ( - = ({ /> {component} - + = ({ ); return ( - = ({ - + ); }; From caf0374f805c90880575065a7b63eb3278af5287 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 4 Nov 2024 00:35:43 +0000 Subject: [PATCH 37/45] New Crowdin translations by GitHub Action --- .../base/locales/be-BY/translation.json | 32 +- .../base/locales/lt-LT/translation.json | 4 +- .../base/locales/pt-PT/translation.json | 600 +++++++++--------- 3 files changed, 318 insertions(+), 318 deletions(-) diff --git a/web/packages/base/locales/be-BY/translation.json b/web/packages/base/locales/be-BY/translation.json index 8e990166e6..b3dc7f9b9b 100644 --- a/web/packages/base/locales/be-BY/translation.json +++ b/web/packages/base/locales/be-BY/translation.json @@ -4,18 +4,18 @@ "intro_slide_2_title": "", "intro_slide_2": "", "intro_slide_3_title": "", - "intro_slide_3": "", + "intro_slide_3": "Android, iOS, Інтэрнэт, Камп’ютар", "login": "Увайсці", "sign_up": "Рэгістрацыя", - "NEW_USER": "", - "EXISTING_USER": "", - "enter_name": "", + "NEW_USER": "Новы ў Ente", + "EXISTING_USER": "Існуючы карыстальнік", + "enter_name": "Увядзіце імя", "PUBLIC_UPLOADER_NAME_MESSAGE": "", - "ENTER_EMAIL": "", - "EMAIL_ERROR": "", - "required": "", + "ENTER_EMAIL": "Увядзіце адрас электроннай пошты", + "EMAIL_ERROR": "Увядзіце сапраўдны адрас электроннай пошты", + "required": "патрабуецца", "EMAIL_SENT": "", - "CHECK_INBOX": "", + "CHECK_INBOX": "Праверце свае ўваходныя лісты (і спам) для завяршэння праверкі", "ENTER_OTT": "Код пацвярджэння", "RESEND_MAIL": "Паўторна адправіць код", "VERIFY": "", @@ -28,14 +28,14 @@ "password": "Пароль", "link_password_description": "", "unlock": "Разблакіраваць", - "SET_PASSPHRASE": "", + "SET_PASSPHRASE": "Задаць пароль", "VERIFY_PASSPHRASE": "Увайсці", "INCORRECT_PASSPHRASE": "Няправільны пароль", "ENTER_ENC_PASSPHRASE": "", "PASSPHRASE_DISCLAIMER": "", "key_generation_in_progress": "", "PASSPHRASE_HINT": "Пароль", - "CONFIRM_PASSPHRASE": "", + "CONFIRM_PASSPHRASE": "Пацвердзіць пароль", "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", @@ -45,17 +45,17 @@ "create_albums": "", "enter_album_name": "", "close_key": "", - "enter_file_name": "", + "enter_file_name": "Назва файла", "close": "Закрыць", "no": "Не", "nothing_here": "", - "upload": "", - "import": "", - "add_photos": "", - "add_more_photos": "", + "upload": "Запампаваць", + "import": "Імпартаваць", + "add_photos": "Дадаць фота", + "add_more_photos": "Дадаць больш фота", "add_photos_count_one": "", "add_photos_count": "", - "select_photos": "", + "select_photos": "Абраць фота", "FILE_UPLOAD": "", "UPLOAD_STAGE_MESSAGE": { "0": "", diff --git a/web/packages/base/locales/lt-LT/translation.json b/web/packages/base/locales/lt-LT/translation.json index 5d3c4252a4..94cd1f1c52 100644 --- a/web/packages/base/locales/lt-LT/translation.json +++ b/web/packages/base/locales/lt-LT/translation.json @@ -7,14 +7,14 @@ "intro_slide_3": "„Android“, „iOS“, internete, darbalaukyje", "login": "Prisijungti", "sign_up": "Registruotis", - "NEW_USER": "Naujas platformoje „Ente“", + "NEW_USER": "Naujas sistemoje „Ente“", "EXISTING_USER": "Esamas naudotojas", "enter_name": "Įveskite vardą", "PUBLIC_UPLOADER_NAME_MESSAGE": "Pridėkite vardą, kad draugai žinotų, kam padėkoti už šias puikias nuotraukas.", "ENTER_EMAIL": "Įveskite el. pašto adresą", "EMAIL_ERROR": "Įveskite tinkamą el. paštą.", "required": "Privaloma", - "EMAIL_SENT": "Patvirtinimo kodas išsiųstas į {{email}}", + "EMAIL_SENT": "Patvirtinimo kodas išsiųstas adresu {{email}}", "CHECK_INBOX": "Patikrinkite savo gautieją (ir šlamštą), kad užbaigtumėte patvirtinimą", "ENTER_OTT": "Patvirtinimo kodas", "RESEND_MAIL": "Siųsti kodą iš naujo", diff --git a/web/packages/base/locales/pt-PT/translation.json b/web/packages/base/locales/pt-PT/translation.json index 307ed735d1..d8335ccf09 100644 --- a/web/packages/base/locales/pt-PT/translation.json +++ b/web/packages/base/locales/pt-PT/translation.json @@ -222,8 +222,8 @@ "photos_count": "{{count, number}} memórias", "terms_and_conditions": "Eu concordo com os termos de serviço e política de privacidade", "SELECTED": "selecionado", - "people": "", - "indexing_scheduled": "", + "people": "Pessoas", + "indexing_scheduled": "Indexação está programada...", "indexing_photos": "Indexar fotos ({{nSyncedFiles, number}} / {{nTotalFiles, number}})", "indexing_fetching": "Obtendo índices ({{nSyncedFiles, number}} / {{nTotalFiles, number}})", "indexing_people": "Indexar pessoas em {{nSyncedFiles, number}} fotos...", @@ -288,312 +288,312 @@ "ETAGS_BLOCKED": "

Não foi possível fazer o envio dos seguintes arquivos devido à configuração do seu navegador.

Por favor, desative quaisquer complementos que possam estar impedindo o ente de utilizar eTags para enviar arquivos grandes, ou utilize nosso aplicativo para computador para uma experiência de importação mais confiável.

", "LIVE_PHOTOS_DETECTED": "Os ficheiros de fotografia e vídeo das suas Live Photos foram fundidos num único ficheiro", "RETRY_FAILED": "Repetir envios com falha", - "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_album": "", - "add_to_album": "", - "move_to_album": "", - "unhide_to_album": "", - "restore_to_album": "", - "section_all": "", - "section_uncategorized": "", - "section_archive": "", - "section_hidden": "", - "section_trash": "", - "favorites": "", - "archive": "", - "archive_album": "", - "unarchive": "", - "unarchive_album": "", - "hide_collection": "", - "unhide_collection": "", - "MOVE": "", - "add": "", - "REMOVE": "", - "YES_REMOVE": "", - "REMOVE_FROM_COLLECTION": "", - "MOVE_TO_TRASH": "", - "TRASH_FILES_MESSAGE": "", - "TRASH_FILE_MESSAGE": "", - "DELETE_PERMANENTLY": "", - "RESTORE": "", - "empty_trash": "", - "empty_trash_title": "", - "empty_trash_message": "", - "leave_album": "", - "leave_shared_album_title": "", - "leave_shared_album_message": "", - "leave_shared_album": "", - "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": "", - "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": "", - "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": "", - "albums_count_one": "", - "albums_count": "", - "all_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": "", - "debug_logs": "", - "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": "", + "FAILED_UPLOADS": "Upload falhou ", + "failed_uploads_hint": "Haverá uma opção para tentar novamente quando o upload terminar", + "SKIPPED_FILES": "Uploads ignorados", + "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Falha ao gerar miniaturas", + "UNSUPPORTED_FILES": "Arquivos não suportados", + "SUCCESSFUL_UPLOADS": "Envios bem sucedidos", + "SKIPPED_INFO": "Saltou estes ficheiros porque existem ficheiros com o mesmo nome e conteúdo no mesmo álbum", + "UNSUPPORTED_INFO": "Ente ainda não suporta estes formatos de arquivo", + "BLOCKED_UPLOADS": "Uploads bloqueados", + "INPROGRESS_METADATA_EXTRACTION": "Em andamento", + "INPROGRESS_UPLOADS": "Uploads em andamento", + "TOO_LARGE_UPLOADS": "Arquivos grandes", + "LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "Armazenamento insuficiente", + "LARGER_THAN_AVAILABLE_STORAGE_INFO": "Estes ficheiros não foram carregados porque excedem o limite máximo de tamanho do seu plano de armazenamento", + "TOO_LARGE_INFO": "Estes ficheiros não foram carregados porque excedem o nosso limite máximo de tamanho de ficheiro", + "THUMBNAIL_GENERATION_FAILED_INFO": "Estes ficheiros foram carregados, mas infelizmente não foi possível gerar as respectivas miniaturas.", + "upload_to_album": "Carregar para o álbum", + "add_to_album": "Adicionar ao álbum", + "move_to_album": "Mover para álbum", + "unhide_to_album": "Mostrar para o álbum", + "restore_to_album": "Restaurar para álbum", + "section_all": "Todos", + "section_uncategorized": "Sem categoria", + "section_archive": "Arquivado", + "section_hidden": "Oculto", + "section_trash": "Lixo", + "favorites": "Favoritos", + "archive": "Arquivar", + "archive_album": "Arquivar álbum", + "unarchive": "Desarquivar", + "unarchive_album": "Desarquivar álbum", + "hide_collection": "Ocultar álbum", + "unhide_collection": "Mostrar álbum", + "MOVE": "Mover", + "add": "Adicionar", + "REMOVE": "Remover", + "YES_REMOVE": "Sim, remover", + "REMOVE_FROM_COLLECTION": "Remover do álbum", + "MOVE_TO_TRASH": "Mover para o lixo", + "TRASH_FILES_MESSAGE": "Os ficheiros selecionados serão removidos de todos os álbuns e movidos para o lixo.", + "TRASH_FILE_MESSAGE": "O ficheiro será removido de todos os álbuns e movido para o lixo.", + "DELETE_PERMANENTLY": "Apagar permanentemente", + "RESTORE": "Restaurar", + "empty_trash": "Esvaziar lixo", + "empty_trash_title": "Esvaziar lixo?", + "empty_trash_message": "Estes ficheiros serão permanentemente eliminados da sua conta Ente.", + "leave_album": "Sair do álbum", + "leave_shared_album_title": "Sair do álbum compartilhado?", + "leave_shared_album_message": "Sairá do álbum e este deixará de ser visível para si.", + "leave_shared_album": "Sim, sair", + "NOT_FILE_OWNER": "Não é possível apagar ficheiros de um álbum partilhado", + "CONFIRM_SELF_REMOVE_MESSAGE": "Os itens selecionados serão removidos deste álbum. Os itens que estão apenas neste álbum serão movidos para Uncategorized.", + "CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "Alguns dos itens que está a remover foram adicionados por outras pessoas, pelo que perderá o acesso aos mesmos.", + "sort_by_creation_time_ascending": "Mais antigo", + "sort_by_updation_time_descending": "Última atualização", + "sort_by_name": "Nome", + "FIX_CREATION_TIME": "Corrigir hora", + "FIX_CREATION_TIME_IN_PROGRESS": "Corrigindo horário", + "CREATION_TIME_UPDATED": "Hora do arquivo atualizado", + "UPDATE_CREATION_TIME_NOT_STARTED": "Selecione a opção que deseja usar", + "UPDATE_CREATION_TIME_COMPLETED": "Todos os arquivos atualizados com sucesso", + "UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "A atualização do horário falhou para alguns arquivos, por favor, tente novamente", + "CAPTION_CHARACTER_LIMIT": "5000 caracteres no máximo", + "DATE_TIME_ORIGINAL": "Exif: Data e Hora Original", + "DATE_TIME_DIGITIZED": "Exif: Data e Hora Digitalizada", + "METADATA_DATE": "Exif: Data de Metadados", + "CUSTOM_TIME": "Tempo personalizado", + "sharing_details": "Detalhes de compartilhamento", + "modify_sharing": "Modificar compartilhamento", + "ADD_COLLABORATORS": "Adicionar colaboradores", + "ADD_NEW_EMAIL": "Adicionar um novo email", + "shared_with_people_count_zero": "Partilhar com pessoas específicas", + "shared_with_people_count_one": "Partilhado com 1 pessoa", + "shared_with_people_count": "Partilhado com {{count, number}} pessoas", + "participants_count_zero": "Nenhum participante", + "participants_count_one": "1 participante", + "participants_count": "{{count, number}} participantes", + "ADD_VIEWERS": "Adicionar visualizações", + "CHANGE_PERMISSIONS_TO_VIEWER": "

{{selectedEmail}} não poderá adicionar mais fotografias ao álbum

Ainda poderão remover fotografias adicionadas por eles

", + "CHANGE_PERMISSIONS_TO_COLLABORATOR": "{{selectedEmail}} poderá adicionar fotografias ao álbum", + "CONVERT_TO_VIEWER": "Sim, converter para visualizador", + "CONVERT_TO_COLLABORATOR": "Sim, converter para colaborador", + "CHANGE_PERMISSION": "Alterar permissões?", + "REMOVE_PARTICIPANT": "Remover?", + "CONFIRM_REMOVE": "Sim, remover", + "MANAGE": "Gerenciar", + "ADDED_AS": "Adicionado como", + "COLLABORATOR_RIGHTS": "Os colaboradores podem adicionar fotografias e vídeos ao álbum partilhado", + "REMOVE_PARTICIPANT_HEAD": "Remover participante", + "OWNER": "Proprietário", + "COLLABORATORS": "Colaboradores", + "ADD_MORE": "Adicionar mais", + "VIEWERS": "Visualizadores", + "OR_ADD_EXISTING": "Ou escolher um já existente", + "REMOVE_PARTICIPANT_MESSAGE": "

{{selectedEmail}} será removido do álbum

Quaisquer fotografias adicionadas por ele também serão removidas do álbum

", + "NOT_FOUND": "404 Página não encontrada", + "LINK_EXPIRED": "Link expirado", + "LINK_EXPIRED_MESSAGE": "Este link expirou ou foi desativado!", + "MANAGE_LINK": "Gerir link", + "LINK_TOO_MANY_REQUESTS": "Desculpe, este álbum foi visualizado em muitos dispositivos!", + "FILE_DOWNLOAD": "Permitir downloads", + "link_password_lock": "Bloqueio da palavra-passe", + "PUBLIC_COLLECT": "Permitir adicionar fotos", + "LINK_DEVICE_LIMIT": "Limite de dispositivos", + "NO_DEVICE_LIMIT": "Nenhum", + "LINK_EXPIRY": "Link expirado", + "NEVER": "Nunca", + "DISABLE_FILE_DOWNLOAD": "Desativar download", + "DISABLE_FILE_DOWNLOAD_MESSAGE": "

Tem a certeza de que pretende desativar o botão de transferência de ficheiros?

Os espectadores podem ainda tirar capturas de ecrã ou guardar uma cópia das suas fotografias utilizando ferramentas externas.

", + "SHARED_USING": "Partilhado utilizando ", + "SHARING_REFERRAL_CODE": "Use o código {{referralCode}} para obter 10 GB de graça", + "LIVE": "EM DIRETO", + "DISABLE_PASSWORD": "Desativar o bloqueio da palavra-passe", + "DISABLE_PASSWORD_MESSAGE": "Tem a certeza de que pretende desativar o bloqueio de palavra-passe?", + "PASSWORD_LOCK": "Bloqueio da palavra-passe", + "LOCK": "Bloquear", + "file": "Arquivo", + "folder": "Pasta", + "google_takeout": "Google Takeout", + "DEDUPLICATE_FILES": "Arquivos duplicados", + "NO_DUPLICATES_FOUND": "Não existem ficheiros duplicados que possam ser eliminados", + "FILES": "arquivos", + "EACH": "cada", + "DEDUPLICATE_BASED_ON_SIZE": "Os seguintes ficheiros foram agrupados com base nos seus tamanhos. Reveja e elimine os itens que considera duplicados", + "STOP_ALL_UPLOADS_MESSAGE": "Tem a certeza de que pretende parar todos os carregamentos em curso?", + "STOP_UPLOADS_HEADER": "Parar uploads?", + "YES_STOP_UPLOADS": "Sim, parar uploads", + "STOP_DOWNLOADS_HEADER": "Parar downloads?", + "YES_STOP_DOWNLOADS": "Sim, parar downloads", + "STOP_ALL_DOWNLOADS_MESSAGE": "Tem a certeza de que pretende parar todas as transferências em curso?", + "albums": "Álbuns", + "albums_count_one": "1 Álbum", + "albums_count": "{{count, number}} Álbuns", + "all_albums": "Todos os álbuns", + "all_hidden_albums": "Todos os álbuns ocultos", + "hidden_albums": "Álbuns ocultos", + "hidden_items": "Itens ocultos", + "ENTER_TWO_FACTOR_OTP": "Introduzir o código de 6 dígitos da\nsua aplicação de autenticação.", + "create_account": "Criar conta", + "COPIED": "Copiado", + "WATCH_FOLDERS": "Pastas monitoradas", + "upgrade_now": "Atualizar agora", + "renew_now": "Renovar agora", + "STORAGE": "Armazenamento", + "USED": "utilizado", + "YOU": "Tu", + "FAMILY": "Família", + "FREE": "grátis", + "OF": "de", + "WATCHED_FOLDERS": "Pastas monitoradas", + "NO_FOLDERS_ADDED": "Nenhuma pasta adicionada ainda!", + "FOLDERS_AUTOMATICALLY_MONITORED": "As pastas que adicionar aqui serão monitorizadas automaticamente", + "UPLOAD_NEW_FILES_TO_ENTE": "Carregar novos ficheiros para o Ente", + "REMOVE_DELETED_FILES_FROM_ENTE": "Remover ficheiros eliminados do Ente", + "ADD_FOLDER": "Adicionar pasta", + "STOP_WATCHING": "Parar de assistir", + "STOP_WATCHING_FOLDER": "Deixar de ver a pasta?", + "STOP_WATCHING_DIALOG_MESSAGE": "Os seus ficheiros existentes não serão eliminados, mas o Ente deixará de atualizar automaticamente o álbum Ente associado às alterações nesta pasta.", + "YES_STOP": "Sim, parar", + "CHANGE_FOLDER": "Alterar pasta", + "FAMILY_PLAN": "Plano familiar", + "debug_logs": "Logs de depuração", + "download_logs": "Descarregar logs", + "download_logs_message": "

Isto irá descarregar registos de depuração, que pode enviar-nos por correio eletrónico para ajudar a depurar o seu problema.

Por favor, note que os nomes dos ficheiros serão incluídos para ajudar a localizar problemas com ficheiros específicos.

", + "WEAK_DEVICE": "O navegador Web que está a utilizar não é suficientemente potente para encriptar as suas fotografias. Tente iniciar sessão no Ente no seu computador ou descarregue a aplicação móvel/desktop do Ente.", + "drag_and_drop_hint": "Ou arrastar e largar na janela Ente", + "AUTHENTICATE": "Autenticar", + "UPLOADED_TO_SINGLE_COLLECTION": "Carregado para uma coleção única", + "UPLOADED_TO_SEPARATE_COLLECTIONS": "Carregado para colecções separadas", + "NEVERMIND": "Esquecer", + "update_available": "Atualização disponível", + "update_installable_message": "Uma nova versão do Ente está pronta para ser instalada.", + "install_now": "Instalar agora", + "install_on_next_launch": "Instalar na próxima inicialização", + "update_available_message": "Foi lançada uma nova versão do Ente, mas não pode ser descarregada e instalada automaticamente.", + "download_and_install": "Descarregar e instalar", + "ignore_this_version": "Ignorar esta versão", + "TODAY": "Hoje", + "YESTERDAY": "Ontem", + "NAME_PLACEHOLDER": "Nome...", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "Não foi possível criar álbuns a partir da mistura de arquivos/pastas", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "

Arrastou e largou uma mistura de ficheiros e pastas.

Por favor, forneça apenas ficheiros ou apenas pastas quando selecionar a opção para criar álbuns separados

", + "CHOSE_THEME": "Escolher tema", + "more_details": "Mais detalhes", + "ml_search": "Aprendizagem automática", + "ml_search_description": "O Ente suporta a aprendizagem automática no dispositivo para reconhecimento facial, pesquisa mágica e outras funcionalidades de pesquisa avançadas", + "ml_search_footnote": "A pesquisa mágica permite pesquisar fotografias pelo seu conteúdo, por exemplo, “carro”, “carro vermelho”, “Ferrari", + "indexing": "Indexar", + "processed": "Processado", + "indexing_status_running": "Em execução", + "indexing_status_fetching": "A procurar", + "indexing_status_scheduled": "Agendado", + "indexing_status_done": "Concluído", + "ml_search_disable": "Desativar aprendizado automático", + "ml_search_disable_confirm": "Pretende desativar a aprendizagem automática em todos os seus dispositivos?", + "ml_consent": "Ativar aprendizagem automática", + "ml_consent_title": "Ativar aprendizagem automática?", + "ml_consent_description": "

Se ativar a aprendizagem automática, o Ente extrairá informações como a geometria do rosto de ficheiros, incluindo os partilhados consigo.

Isto acontecerá no seu dispositivo e qualquer informação biométrica gerada será encriptada de ponta a ponta.

Clique aqui para obter mais detalhes sobre esta funcionalidade na nossa política de privacidade

", + "ml_consent_confirmation": "Eu entendo, e desejo ativar a aprendizagem automática", + "labs": "Laboratórios", + "YOURS": "seu", + "passphrase_strength_weak": "Força da palavra-passe: Fraca", + "passphrase_strength_moderate": "Força da palavra-passe: Moderada", + "passphrase_strength_strong": "Força da palavra-passe: Forte", + "preferences": "Preferências", + "language": "Idioma", + "advanced": "Avançado", + "EXPORT_DIRECTORY_DOES_NOT_EXIST": "Diretório de exportação inválido", + "EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "

O diretório de exportação que selecionou não existe.

Por favor, selecione um diretório válido.

", + "SUBSCRIPTION_VERIFICATION_ERROR": "Falha na verificação da subscrição", "storage_unit": { - "b": "", - "kb": "", - "mb": "", - "gb": "", - "tb": "" + "b": "B", + "kb": "KB", + "mb": "MB", + "gb": "GB", + "tb": "TB" }, "AFTER_TIME": { - "HOUR": "", - "DAY": "", - "WEEK": "", - "MONTH": "", - "YEAR": "" + "HOUR": "após uma hora", + "DAY": "após um dia", + "WEEK": "após uma semana", + "MONTH": "após um mês", + "YEAR": "após um ano" }, - "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": "", + "COPY_LINK": "Copiar link", + "DONE": "Concluído", + "LINK_SHARE_TITLE": "Ou partilhar uma link", + "REMOVE_LINK": "Remover link", + "CREATE_PUBLIC_SHARING": "Criar link público", + "PUBLIC_LINK_CREATED": "Link público criado", + "PUBLIC_LINK_ENABLED": "Link público ativado", + "COLLECT_PHOTOS": "Recolher fotos", + "PUBLIC_COLLECT_SUBTEXT": "Permitir que as pessoas com a ligação também adicionem fotos ao álbum partilhado.", + "STOP_EXPORT": "Parar", + "EXPORT_PROGRESS": "{{progress.success, number}} / {{progress.total, number}} itens sincronizados", + "MIGRATING_EXPORT": "Preparar...", + "RENAMING_COLLECTION_FOLDERS": "Renomear pastas de álbuns...", + "TRASHING_DELETED_FILES": "Eliminar arquivos apagados...", + "TRASHING_DELETED_COLLECTIONS": "Eliminar álbuns apagados...", + "CONTINUOUS_EXPORT": "Sincronização contínua", + "PENDING_ITEMS": "Itens pendentes", + "EXPORT_STARTING": "Iniciar a exportação...", + "delete_account_reason_label": "Qual o principal motivo pelo qual está a eliminar a conta?", + "delete_account_reason_placeholder": "Selecione um motivo", "delete_reason": { - "missing_feature": "", - "behaviour": "", - "found_another_service": "", - "not_listed": "" + "missing_feature": "Falta uma chave que eu preciso", + "behaviour": "O aplicativo ou um determinado recurso não se comportou como era suposto", + "found_another_service": "Encontrei outro serviço que gosto mais", + "not_listed": "O motivo não está na lista" }, - "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": "", - "HIDE": "", - "UNHIDE": "", - "sort_by": "", - "newest_first": "", - "oldest_first": "", - "CONVERSION_FAILED_NOTIFICATION_MESSAGE": "", - "pin_album": "", - "unpin_album": "", - "DOWNLOAD_COMPLETE": "", - "DOWNLOADING_COLLECTION": "", - "DOWNLOAD_FAILED": "", - "DOWNLOAD_PROGRESS": "", - "CHRISTMAS": "", - "CHRISTMAS_EVE": "", - "NEW_YEAR": "", - "NEW_YEAR_EVE": "", - "IMAGE": "", - "VIDEO": "", - "LIVE_PHOTO": "", + "delete_account_feedback_label": "Lamentamos a sua partida. Indique-nos a razão para podermos melhorar o serviço.", + "delete_account_feedback_placeholder": "Feedback", + "delete_account_confirm_checkbox_label": "Sim, quero apagar permanentemente esta conta e todos os seus dados", + "delete_account_confirm": "Confirmar eliminação da conta", + "delete_account_confirm_message": "

Esta conta está ligada a outras aplicações Ente, se utilizar alguma.

Os seus dados carregados, em todas as aplicações Ente, serão agendados para eliminação e a sua conta será permanentemente eliminada.

", + "feedback_required": "Por favor, ajude-nos com esta informação", + "feedback_required_found_another_service": "O que o outro serviço faz melhor?", + "RECOVER_TWO_FACTOR": "Recuperar dois fatores", + "at": "em", + "AUTH_NEXT": "seguinte", + "AUTH_DOWNLOAD_MOBILE_APP": "Descarregue a nossa aplicação móvel para gerir os seus segredos", + "HIDE": "Ocultar", + "UNHIDE": "Mostrar", + "sort_by": "Ordenar por", + "newest_first": "Mais recentes primeiro", + "oldest_first": "Mais antigo primeiro", + "CONVERSION_FAILED_NOTIFICATION_MESSAGE": "Este arquivo não pôde ser visualizado. Clique aqui para fazer o download original.", + "pin_album": "Fixar álbum", + "unpin_album": "Desafixar álbum", + "DOWNLOAD_COMPLETE": "Download concluído", + "DOWNLOADING_COLLECTION": "Fazer download de {{name}}", + "DOWNLOAD_FAILED": "Falha no download", + "DOWNLOAD_PROGRESS": "{{progress.current}} / {{progress.total}} arquivos", + "CHRISTMAS": "Natal", + "CHRISTMAS_EVE": "Véspera de Natal", + "NEW_YEAR": "Ano Novo", + "NEW_YEAR_EVE": "Véspera de Ano Novo", + "IMAGE": "Imagem", + "VIDEO": "Vídeo", + "LIVE_PHOTO": "Fotos em movimento", "editor": { - "crop": "" + "crop": "Recortar" }, - "CONVERT": "", - "confirm_editor_close": "", - "confirm_editor_close_message": "", - "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": "", + "CONVERT": "Converter", + "confirm_editor_close": "Tem certeza de que deseja fechar o editor?", + "confirm_editor_close_message": "Descarregue a imagem editada ou guarde uma cópia no Ente para manter as alterações.", + "BRIGHTNESS": "Brilho", + "CONTRAST": "Contraste", + "SATURATION": "Saturação", + "BLUR": "Desfoque", + "INVERT_COLORS": "Inverter Cores", + "ASPECT_RATIO": "Proporção da imagem", + "SQUARE": "Quadrado", + "ROTATE_LEFT": "Rodar para a esquerda", + "ROTATE_RIGHT": "Rodar para a direita", + "FLIP_VERTICALLY": "Inverter verticalmente", + "FLIP_HORIZONTALLY": "Inverter horizontalmente", + "DOWNLOAD_EDITED": "Descarregar Editado", + "SAVE_A_COPY_TO_ENTE": "Salvar uma cópia para o Ente", + "RESTORE_ORIGINAL": "Restaurar original", + "TRANSFORM": "Transformar", + "COLORS": "Cores", + "FLIP": "Inverter", + "ROTATION": "Rotação", + "reset": "Restaurar", + "PHOTO_EDITOR": "Editor de Fotos", "FASTER_UPLOAD": "", "FASTER_UPLOAD_DESCRIPTION": "", "cast_album_to_tv": "", From 46f7d14964abcc05388f10552e2244688e2dabbc Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 4 Nov 2024 01:17:23 +0000 Subject: [PATCH 38/45] New Crowdin translations by GitHub Action --- auth/lib/l10n/arb/app_lt.arb | 56 ++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/auth/lib/l10n/arb/app_lt.arb b/auth/lib/l10n/arb/app_lt.arb index a0a72c859f..c74c01139d 100644 --- a/auth/lib/l10n/arb/app_lt.arb +++ b/auth/lib/l10n/arb/app_lt.arb @@ -12,13 +12,13 @@ "importScanQrCode": "Skenuoti QR kodą", "qrCode": "QR kodas", "importEnterSetupKey": "Įvesti sąrankos raktą", - "importAccountPageTitle": "Įveskite paskyros duomenis", + "importAccountPageTitle": "Įvesti paskyros duomenis", "secretCanNotBeEmpty": "Paslaptis negali būti tuščia.", "bothIssuerAndAccountCanNotBeEmpty": "Tiek išdavėjas ir paskyra negali būti tušti.", "incorrectDetails": "Neteisingi duomenys", "pleaseVerifyDetails": "Patikrinkite duomenis ir bandykite dar kartą.", "codeIssuerHint": "Išdavėjas", - "codeSecretKeyHint": "Slaptas raktas", + "codeSecretKeyHint": "Slaptasis raktas", "secret": "Paslaptis", "all": "Viskas", "notes": "Pastabos", @@ -50,7 +50,7 @@ "deleteCodeMessage": "Ar tikrai norite ištrinti šį kodą? Šis veiksmas negrįžtamas.", "trashCode": "Ištuštinti kodą?", "trashCodeMessage": "Ar tikrai norite ištuštinti {account} kodą?", - "trash": "Šiukšlinė", + "trash": "Ištuštinti", "viewLogsAction": "Peržiūrėti žurnalus", "sendLogsDescription": "Tai nusiųs žurnalo įrašus, kurie padės mums išspręsti jūsų problemą. Nors imamės atsargumo priemonių, kad slaptos informacijos nebūtų įrašoma, raginame jus peržiūrėti šiuos žurnalus prieš bendrinant juos.", "preparingLogsTitle": "Ruošiami žurnalai...", @@ -84,11 +84,11 @@ "pleaseWait": "Palaukite...", "generatingEncryptionKeysTitle": "Generuojami šifravimo raktai...", "recreatePassword": "Iš naujo sukurti slaptažodį", - "recreatePasswordMessage": "Dabartinis įrenginys nėra pakankamai galingas, kad patvirtintų jūsų slaptažodį, todėl turime jį vieną kartą regeneruoti taip, kad jis veiktų visuose įrenginiuose. \n\nPrisijunkite naudojant atkūrimo raktą ir regeneruokite slaptažodį (jei norite, galite vėl naudoti tą patį).", + "recreatePasswordMessage": "Dabartinis įrenginys nėra pakankamai galingas, kad patvirtintų jūsų slaptažodį, todėl turime jį vieną kartą iš naujo sugeneruoti taip, kad jis veiktų visuose įrenginiuose. \n\nPrisijunkite naudodami atkūrimo raktą ir sugeneruokite iš naujo slaptažodį (jei norite, galite vėl naudoti tą patį).", "useRecoveryKey": "Naudoti atkūrimo raktą", "incorrectPasswordTitle": "Neteisingas slaptažodis.", "welcomeBack": "Sveiki sugrįžę!", - "madeWithLoveAtPrefix": "sukurta su ❤️ ", + "madeWithLoveAtPrefix": "sukurta su ❤️ vietoje ", "supportDevs": "Prenumeruokite „ente“, kad palaikytumėte mus", "supportDiscount": "Naudokite kupono kodą „AUTH“, kad gautumėte 10 % nuolaida pirmiesiems metams", "changeEmail": "Keisti el. paštą", @@ -100,14 +100,14 @@ "passwordForDecryptingExport": "Slaptažodis eksportui iššifruoti", "passwordEmptyError": "Slaptažodis negali būti tuščias.", "importFromApp": "Importuoti kodus iš „{appName}“", - "importGoogleAuthGuide": "Eksportuokite paskyras iš „Google Authenticator“ į QR kodą naudojant parinktį Perkelti paskyras. Tada naudojant kitą įrenginį nuskenuokite QR kodą.\n\nPatarimas: QR kodą galite nufotografuoti naudojant nešiojamojo kompiuterio internetinę vaizdo kamerą.", + "importGoogleAuthGuide": "Eksportuokite paskyras iš „Google Authenticator“ į QR kodą naudodami parinktį Perkelti paskyras. Tada naudojant kitą įrenginį nuskenuokite QR kodą.\n\nPatarimas: QR kodą galite nufotografuoti naudojant nešiojamojo kompiuterio internetinę vaizdo kamerą.", "importSelectJsonFile": "Pasirinkti JSON failą", "importSelectAppExport": "Pasirinkti „{appName}“ eksporto failą", "importEnteEncGuide": "Pasirinkite užšifruotą JSON failą, eksportuotą iš „Ente“", "importRaivoGuide": "Naudokite „Raivo“ nustatymuose esančią parinktį „Export OTPs to Zip archive“ (eksportuoti OTP į ZIP archyvą).\n\nIšskleiskite ZIP failą ir importuokite JSON failą.", "importBitwardenGuide": "Naudokite „Bitwarden“ įrankiuose esančią parinktį Eksportuoti saugyklą ir importuokite nešifruotą JSON failą.", - "importAegisGuide": "Naudokite „Aegis“ nustatymuose esančią parinktį Eksportuoti slėptuvę.\n\nJei jūsų saugykla yra užšifruota, turėsite įvesti saugyklos slaptažodį, kad iššifruotumėte saugyklą.", - "import2FasGuide": "Naudokite 2FAS parinktį „Settings->2FAS Backup->Export to file“.\n\nJei atsarginė kopija užšifruota, turėsite įvesti slaptažodį, kad iššifruotumėte atsarginę kopiją.", + "importAegisGuide": "Naudokite „Aegis“ nustatymuose esančią parinktį Eksportuoti slėptuvę.\n\nJei jūsų saugykla užšifruota, turėsite įvesti saugyklos slaptažodį, kad iššifruotumėte saugyklą.", + "import2FasGuide": "Naudokite programoje 2FAS esančią parinktį „Settings->2FAS Backup->Export to file“.\n\nJei atsarginė kopija užšifruota, turėsite įvesti slaptažodį, kad iššifruotumėte atsarginę kopiją.", "importLastpassGuide": "Naudokite „Lastpass Authenticator“ nustatymuose esančią parinktį „Transfer accounts“ (perkelti paskyras) ir paspauskite „Export accounts to file“ (eksportuoti paskyras į failą). Importuokite atsisiųstą JSON failą.", "exportCodes": "Eksportuoti kodus", "importLabel": "Importuoti", @@ -122,7 +122,7 @@ "authToChangeYourEmail": "Nustatykite tapatybę, kad pakeistumėte savo el. paštą", "authToChangeYourPassword": "Nustatykite tapatybę, kad pakeistumėte slaptažodį", "authToViewSecrets": "Nustatykite tapatybę, kad peržiūrėtumėte savo paslaptis", - "authToInitiateSignIn": "Nustatykite tapatybę, kad pradėtumėte prisijungti prie atsarginės kopijos.", + "authToInitiateSignIn": "Nustatykite tapatybę, kad pradėtumėte prisijungti norint kurti atsargines kopijas.", "ok": "Gerai", "cancel": "Atšaukti", "yes": "Taip", @@ -134,7 +134,7 @@ "copied": "Nukopijuota", "pleaseTryAgain": "Bandykite dar kartą.", "existingUser": "Esamas naudotojas", - "newUser": "Naujas platformoje „Ente“", + "newUser": "Naujas sistemoje „Ente“", "delete": "Ištrinti", "enterYourPasswordHint": "Įveskite savo slaptažodį", "forgotPassword": "Pamiršau slaptažodį", @@ -170,7 +170,7 @@ "invalidQRCode": "Netinkamas QR kodas.", "noRecoveryKeyTitle": "Neturite atkūrimo rakto?", "enterEmailHint": "Įveskite savo el. pašto adresą", - "invalidEmailTitle": "Netinkamas el. pašto adresas.", + "invalidEmailTitle": "Netinkamas el. pašto adresas", "invalidEmailMessage": "Įveskite tinkamą el. pašto adresą.", "deleteAccount": "Ištrinti paskyrą", "deleteAccountQuery": "Apgailestausime, kad išeinate. Ar susiduriate su kažkokiomis problemomis?", @@ -195,7 +195,7 @@ "viewActiveSessions": "Peržiūrėti aktyvius seansus", "authToViewYourActiveSessions": "Nustatykite tapatybę, kad peržiūrėtumėte savo aktyvius seansus", "searchHint": "Ieškokite...", - "search": "Ieškoti", + "search": "Paieška", "sorryUnableToGenCode": "Atsiprašome, nepavyksta sugeneruoti {issuerName} kodo.", "noResult": "Nėra rezultatų", "addCode": "Pridėti kodą", @@ -242,15 +242,15 @@ "resetPasswordTitle": "Nustatyti slaptažodį iš naujo", "encryptionKeys": "Šifravimo raktai", "passwordWarning": "Šio slaptažodžio nesaugome, todėl jei jį pamiršite, negalėsime iššifruoti jūsų duomenų", - "enterPasswordToEncrypt": "Įveskite slaptažodį, kurį galime naudoti jūsų duomenims šifruoti", - "enterNewPasswordToEncrypt": "Įveskite naują slaptažodį, kurį galime naudoti jūsų duomenims šifruoti", + "enterPasswordToEncrypt": "Įveskite slaptažodį, kurį galime naudoti jūsų duomenims užšifruoti", + "enterNewPasswordToEncrypt": "Įveskite naują slaptažodį, kurį galime naudoti jūsų duomenims užšifruoti", "passwordChangedSuccessfully": "Slaptažodis sėkmingai pakeistas", "generatingEncryptionKeys": "Generuojami šifravimo raktai...", "continueLabel": "Tęsti", "insecureDevice": "Nesaugus įrenginys", "sorryWeCouldNotGenerateSecureKeysOnThisDevicennplease": "Atsiprašome, šiame įrenginyje nepavyko sugeneruoti saugių raktų.\n\nRegistruokitės iš kito įrenginio.", "howItWorks": "Kaip tai veikia", - "ackPasswordLostWarning": "Suprantu, kad jei prarasiu slaptažodį, galiu prarasti savo duomenis, kadangi mano duomenys yra visapusiškai užšifruota.", + "ackPasswordLostWarning": "Suprantu, kad jei prarasiu slaptažodį, galiu prarasti savo duomenis, kadangi duomenys yra visapusiškai užšifruota.", "loginTerms": "Spustelėjus Prisijungti sutinku su paslaugų sąlygomis ir privatumo politika", "logInLabel": "Prisijungti", "logout": "Atsijungti", @@ -262,7 +262,7 @@ "recoveryKeySuccessBody": "Puiku! Jūsų atkūrimo raktas tinkamas. Dėkojame už patvirtinimą.\n\nNepamirškite sukurti saugią atkūrimo rakto atsarginę kopiją.", "invalidRecoveryKey": "Įvestas atkūrimo raktas yra netinkamas. Įsitikinkite, kad jame yra 24 žodžiai, ir patikrinkite kiekvieno iš jų rašybą.\n\nJei įvedėte senesnį atkūrimo kodą, įsitikinkite, kad jis yra 64 simbolių ilgio, ir patikrinkite kiekvieną iš jų.", "recreatePasswordTitle": "Iš naujo sukurti slaptažodį", - "recreatePasswordBody": "Dabartinis įrenginys nėra pakankamai galingas, kad patvirtintų jūsų slaptažodį, bet mes galime iš naujo sugeneruoti taip, kad jis veiktų su visais įrenginiais.\n\nPrisijunkite naudojant atkūrimo raktą ir sugeneruokite iš naujo slaptažodį (jei norite, galite vėl naudoti tą patį).", + "recreatePasswordBody": "Dabartinis įrenginys nėra pakankamai galingas, kad patvirtintų jūsų slaptažodį, bet mes galime iš naujo sugeneruoti taip, kad jis veiktų su visais įrenginiais.\n\nPrisijunkite naudodami atkūrimo raktą ir sugeneruokite iš naujo slaptažodį (jei norite, galite vėl naudoti tą patį).", "invalidKey": "Netinkamas raktas.", "tryAgain": "Bandyti dar kartą", "viewRecoveryKey": "Peržiūrėti atkūrimo raktą", @@ -299,7 +299,7 @@ }, "authToExportCodes": "Nustatykite tapatybę, kad eksportuotumėte savo kodus", "importSuccessTitle": "Valio!", - "importSuccessDesc": "Importavote {count} kodų!", + "importSuccessDesc": "Importavote {count} kodų.", "@importSuccessDesc": { "placeholders": { "count": { @@ -316,7 +316,7 @@ "checkInboxAndSpamFolder": "Patikrinkite savo gautieją (ir šlamštą), kad užbaigtumėte patvirtinimą", "tapToEnterCode": "Palieskite, kad įvestumėte kodą", "resendEmail": "Iš naujo siųsti el. laišką", - "weHaveSendEmailTo": "Išsiuntėme laišką į {email}", + "weHaveSendEmailTo": "Išsiuntėme laišką adresu {email}", "@weHaveSendEmailTo": { "description": "Text to indicate that we have sent a mail to the user", "placeholders": { @@ -338,16 +338,16 @@ "thisEmailIsAlreadyInUse": "Šis el. paštas jau naudojamas.", "verificationFailedPleaseTryAgain": "Patvirtinimas nepavyko. Bandykite dar kartą.", "yourVerificationCodeHasExpired": "Jūsų patvirtinimo kodo laikas nebegaliojantis.", - "incorrectCode": "Neteisingas kodas.", + "incorrectCode": "Neteisingas kodas", "sorryTheCodeYouveEnteredIsIncorrect": "Atsiprašome, įvestas kodas yra neteisingas.", "emailChangedTo": "El. paštas pakeistas į {newEmail}", "authenticationFailedPleaseTryAgain": "Tapatybės nustatymas nepavyko. Bandykite dar kartą.", "authenticationSuccessful": "Tapatybės nustatymas sėkmingas!", - "twofactorAuthenticationSuccessfullyReset": "Dvigubas tapatybės nustatymas sėkmingai iš naujo nustatytas", - "incorrectRecoveryKey": "Neteisingas atkūrimo raktas.", + "twofactorAuthenticationSuccessfullyReset": "Dvigubas tapatybės nustatymas sėkmingai iš naujo nustatytas.", + "incorrectRecoveryKey": "Neteisingas atkūrimo raktas", "theRecoveryKeyYouEnteredIsIncorrect": "Įvestas atkūrimo raktas yra neteisingas.", "enterPassword": "Įveskite slaptažodį", - "selectExportFormat": "Pasirinkti eksporto formatą", + "selectExportFormat": "Pasirinkite eksporto formatą", "exportDialogDesc": "Užšifruoti eksportai bus apsaugoti jūsų pasirinktu slaptažodžiu.", "encrypted": "Užšifruota", "plainText": "Paprastasis tekstas", @@ -361,14 +361,14 @@ "showLargeIcons": "Rodyti dideles piktogramas", "compactMode": "Kompaktinis režimas", "shouldHideCode": "Slėpti kodus", - "doubleTapToViewHiddenCode": "Galite dvigubai paliesti elementą, kad peržiūrėtumėte kodą", + "doubleTapToViewHiddenCode": "Galite dukart paliesti elementą, kad peržiūrėtumėte kodą", "focusOnSearchBar": "Fokusuoti paiešką paleidžiant programą", "confirmUpdatingkey": "Ar tikrai norite atnaujinti slaptąjį raktą?", "minimizeAppOnCopy": "Sumažinti programą kopijuojant", "editCodeAuthMessage": "Nustatykite tapatybę, kad redaguotumėte kodą", "deleteCodeAuthMessage": "Nustatykite tapatybę, kad ištrintumėte kodą", "showQRAuthMessage": "Nustatykite tapatybę, kad būtų rodomas QR kodas", - "confirmAccountDeleteTitle": "Patvirtinti paskyros ištrynimą", + "confirmAccountDeleteTitle": "Patvirtinkite paskyros ištrynimą", "confirmAccountDeleteMessage": "Ši paskyra susieta su kitomis „Ente“ programomis, jei jas naudojate.\n\nJūsų įkelti duomenys per visas „Ente“ programas bus planuojama ištrinti, o jūsų paskyra bus ištrinta negrįžtamai.", "androidBiometricHint": "Patvirtinkite tapatybę", "@androidBiometricHint": { @@ -406,7 +406,7 @@ "@goToSettings": { "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." }, - "androidGoToSettingsDescription": "Biometrinis tapatybės nustatymas jūsų įrenginyje nenustatytas. Eikite į Nustatymai > Sauga ir pridėkite biometrinį tapatybės nustatymą.", + "androidGoToSettingsDescription": "Biometrinis tapatybės nustatymas jūsų įrenginyje nenustatytas. Eikite į Nustatymai > Saugumas ir pridėkite biometrinį tapatybės nustatymą.", "@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." }, @@ -441,7 +441,7 @@ "developerSettings": "Kūrėjo nustatymai", "serverEndpoint": "Serverio galutinis taškas", "invalidEndpoint": "Netinkamas galutinis taškas", - "invalidEndpointMessage": "Atsiprašome. Jūsų įvestas galutinis taškas yra netinkamas. Įveskite tinkamą galutinį tašką ir bandykite dar kartą.", + "invalidEndpointMessage": "Atsiprašome, įvestas galutinis taškas netinkamas. Įveskite tinkamą galutinį tašką ir bandykite dar kartą.", "endpointUpdatedMessage": "Galutinis taškas sėkmingai atnaujintas", "customEndpoint": "Prijungta prie {endpoint}", "pinText": "Prisegti", @@ -467,7 +467,7 @@ "immediately": "Iš karto", "reEnterPassword": "Įveskite slaptažodį iš naujo", "reEnterPin": "Įveskite PIN iš naujo", - "next": "Sekantis", + "next": "Toliau", "tooManyIncorrectAttempts": "Per daug neteisingų bandymų.", "tapToUnlock": "Palieskite, kad atrakintumėte", "setNewPassword": "Nustatykite naują slaptažodį", @@ -477,7 +477,7 @@ "hideContentDescriptioniOS": "Paslepia programos turinį programos perjungiklyje", "autoLockFeatureDescription": "Laikas, po kurio programa užrakinama perkėlus ją į foną", "appLockDescription": "Pasirinkite tarp numatytojo įrenginio užrakinimo ekrano ir pasirinktinio užrakinimo ekrano su PIN kodu arba slaptažodžiu.", - "pinLock": "PIN užrakinimas", + "pinLock": "PIN užraktas", "enterPin": "Įveskite PIN", "setNewPin": "Nustatykite naują PIN", "importFailureDescNew": "Nepavyko išanalizuoti pasirinkto failo.", From 432acfbeb6e71a116bd8d33ef6d2ec710b5ed5a4 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 4 Nov 2024 08:19:12 +0530 Subject: [PATCH 39/45] [web] Fix capitalization for the uk-UA lang https://github.com/ente-io/ente/issues/3634#issuecomment-2448388285 --- web/apps/photos/src/components/Sidebar/Preferences.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/apps/photos/src/components/Sidebar/Preferences.tsx b/web/apps/photos/src/components/Sidebar/Preferences.tsx index c95751d17b..0466109c2a 100644 --- a/web/apps/photos/src/components/Sidebar/Preferences.tsx +++ b/web/apps/photos/src/components/Sidebar/Preferences.tsx @@ -171,6 +171,6 @@ const localeName = (locale: SupportedLocale) => { case "lt-LT": return "Lietuvių kalba"; case "uk-UA": - return "українська"; + return "Українська"; } }; From a1bb2ff0c1a844f5f7de964d2ff6af498502d589 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 4 Nov 2024 09:32:47 +0530 Subject: [PATCH 40/45] [desktop] Fix build failures due to apt failures https://github.com/actions/runner-images/issues/6039#issuecomment-1209531257 --- desktop/.github/workflows/desktop-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/.github/workflows/desktop-release.yml b/desktop/.github/workflows/desktop-release.yml index fbaac39546..9c8222f69b 100644 --- a/desktop/.github/workflows/desktop-release.yml +++ b/desktop/.github/workflows/desktop-release.yml @@ -88,7 +88,7 @@ jobs: if: startsWith(matrix.os, 'ubuntu') # See: # https://github.com/electron-userland/electron-builder/issues/4181 - run: sudo apt-get install libarchive-tools + run: sudo apt-get update && apt-get install libarchive-tools - name: Build uses: ente-io/action-electron-builder@eff78a1d33bdab4c54ede0e5cdc71e0c2cf803e2 From 7e7e1983b769239efbdad48ae7e7d6d8b35150cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:41:56 +0530 Subject: [PATCH 41/45] [mobile] New translations (#3917) New translations from [Crowdin](https://crowdin.com/project/ente-photos-app) Co-authored-by: Crowdin Bot --- mobile/lib/l10n/intl_be.arb | 84 ++++++++++++- mobile/lib/l10n/intl_lt.arb | 228 ++++++++++++++++++++++++++++++++++-- mobile/lib/l10n/intl_pt.arb | 68 +++++------ mobile/lib/l10n/intl_uk.arb | 47 ++++---- 4 files changed, 357 insertions(+), 70 deletions(-) diff --git a/mobile/lib/l10n/intl_be.arb b/mobile/lib/l10n/intl_be.arb index 9951685a7b..d6b7f03339 100644 --- a/mobile/lib/l10n/intl_be.arb +++ b/mobile/lib/l10n/intl_be.arb @@ -117,5 +117,87 @@ "saveKey": "Захаваць ключ", "recoveryKeyCopiedToClipboard": "Ключ аднаўлення скапіяваны ў буфер абмену", "recoverAccount": "Аднавіць уліковы запіс", - "recover": "Аднавіць" + "recover": "Аднавіць", + "enterCode": "Увядзіце код", + "scanCode": "Сканіраваць код", + "confirm": "Пацвердзіць", + "setupComplete": "Наладжванне завершана", + "twofactorAuthenticationPageTitle": "Двухфактарная аўтэнтыфікацыя", + "albumOwner": "Уладальнік", + "@albumOwner": { + "description": "Role of the album owner" + }, + "you": "Вы", + "addMore": "Дадаць яшчэ", + "@addMore": { + "description": "Button text to add more collaborators/viewers" + }, + "viewer": "Праглядальнік", + "remove": "Выдаліць", + "removeParticipant": "Выдаліць удзельніка", + "@removeParticipant": { + "description": "menuSectionTitle for removing a participant" + }, + "manage": "Кіраванне", + "never": "Ніколі", + "after1Hour": "Праз 1 гадзіну", + "after1Day": "Праз 1 дзень", + "after1Week": "Праз 1 тыдзень", + "after1Month": "Праз 1 месяц", + "after1Year": "Праз 1 год", + "manageParticipants": "Кіраванне", + "sendLink": "Адправіць спасылку", + "copyLink": "Скапіяваць спасылку", + "done": "Гатова", + "apply": "Ужыць", + "codeAppliedPageTitle": "Код ужыты", + "change": "Змяніць", + "storageInGB": "{storageAmountInGB} Гб", + "details": "Падрабязнасці", + "deleteAlbum": "Выдаліць альбом", + "yesRemove": "Так, выдаліць", + "removeWithQuestionMark": "Выдаліць?", + "deletePhotos": "Выдаліць фота", + "trash": "Сметніца", + "uncategorized": "Без катэгорыі", + "videoSmallCase": "відэа", + "photoSmallCase": "фота", + "deleteFromEnte": "Выдаліць з Ente", + "yesDelete": "Так, выдаліць", + "magicSearch": "Магічны пошук", + "discover_screenshots": "Скрыншоты", + "discover_receipts": "Чэкі", + "discover_notes": "Нататкі", + "discover_pets": "Хатнія жывёлы", + "discover_selfies": "Сэлфi", + "discover_wallpapers": "Шпалеры", + "discover_food": "Ежа", + "status": "Стан", + "selectAll": "Абраць усё", + "skip": "Прапусціць", + "about": "Пра праграму", + "logout": "Выйсці", + "yesLogout": "Так, выйсці", + "update": "Абнавіць", + "installManually": "Усталяваць уручную", + "updateAvailable": "Даступна абнаўленне", + "ignoreUpdate": "Iгнараваць", + "retry": "Паўтарыць", + "backup": "Рэзервовая копія", + "removeDuplicates": "Выдаліць дублікаты", + "viewLargeFiles": "Вялікія файлы", + "noDuplicates": "✨ Няма дублікатаў", + "rateUs": "Ацаніце нас", + "familyPlans": "Сямейныя тарыфныя планы", + "notifications": "Апавяшчэнні", + "general": "Асноўныя", + "security": "Бяспека", + "lockscreen": "Экран блакіроўкі", + "support": "Падтрымка", + "theme": "Тема", + "lightTheme": "Светлая", + "darkTheme": "Цёмная", + "systemTheme": "Сістэма", + "freeTrial": "Бясплатная пробная версія", + "faqs": "Частыя пытанні" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_lt.arb b/mobile/lib/l10n/intl_lt.arb index c71fa72413..a3b47ea793 100644 --- a/mobile/lib/l10n/intl_lt.arb +++ b/mobile/lib/l10n/intl_lt.arb @@ -13,9 +13,9 @@ "feedback": "Atsiliepimai", "kindlyHelpUsWithThisInformation": "Maloniai padėkite mums su šia informacija", "confirmDeletePrompt": "Taip, noriu negrįžtamai ištrinti šią paskyrą ir jos duomenis per visas programas.", - "confirmAccountDeletion": "Patvirtinti paskyros ištrynimą", + "confirmAccountDeletion": "Patvirtinkite paskyros ištrynimą", "deleteAccountPermanentlyButton": "Ištrinti paskyrą negrįžtamai", - "yourAccountHasBeenDeleted": "Jūsų paskyra buvo ištrinta", + "yourAccountHasBeenDeleted": "Jūsų paskyra ištrinta", "selectReason": "Pasirinkite priežastį", "deleteReason1": "Trūksta pagrindinės funkcijos, kurios man reikia", "deleteReason2": "Programa arba tam tikra funkcija nesielgia taip, kaip, mano manymu, turėtų elgtis", @@ -53,7 +53,7 @@ "checkInboxAndSpamFolder": "Patikrinkite savo gautieją (ir šlamštą), kad užbaigtumėte patvirtinimą", "tapToEnterCode": "Palieskite, kad įvestumėte kodą", "resendEmail": "Iš naujo siųsti el. laišką", - "weHaveSendEmailTo": "Išsiuntėme laišką į {email}", + "weHaveSendEmailTo": "Išsiuntėme laišką adresu {email}", "@weHaveSendEmailTo": { "description": "Text to indicate that we have sent a mail to the user", "placeholders": { @@ -94,7 +94,7 @@ "sorryWeCouldNotGenerateSecureKeysOnThisDevicennplease": "Atsiprašome, šiame įrenginyje nepavyko sugeneruoti saugių raktų.\n\nRegistruokitės iš kito įrenginio.", "howItWorks": "Kaip tai veikia", "encryption": "Šifravimas", - "ackPasswordLostWarning": "Suprantu, kad jei prarasiu slaptažodį, galiu prarasti savo duomenis, kadangi mano duomenys yra visapusiškai užšifruota.", + "ackPasswordLostWarning": "Suprantu, kad jei prarasiu slaptažodį, galiu prarasti savo duomenis, kadangi mano duomenys yra visapusiškai užšifruoti.", "privacyPolicyTitle": "Privatumo politika", "termsOfServicesTitle": "Sąlygos", "signUpTerms": "Sutinku su paslaugų sąlygomis ir privatumo politika", @@ -104,7 +104,7 @@ "enterYourPassword": "Įveskite savo slaptažodį", "welcomeBack": "Sveiki sugrįžę!", "contactSupport": "Susisiekti su palaikymo komanda", - "incorrectPasswordTitle": "Neteisingas slaptažodis.", + "incorrectPasswordTitle": "Neteisingas slaptažodis", "pleaseTryAgain": "Bandykite dar kartą.", "recreatePasswordTitle": "Iš naujo sukurti slaptažodį", "useRecoveryKey": "Naudoti atkūrimo raktą", @@ -129,11 +129,12 @@ } }, "twofactorSetup": "Dvigubo tapatybės nustatymo sąranka", - "enterCode": "Įveskite kodą", + "enterCode": "Įvesti kodą", "scanCode": "Skenuoti kodą", "codeCopiedToClipboard": "Nukopijuotas kodas į iškarpinę", "copypasteThisCodentoYourAuthenticatorApp": "Nukopijuokite ir įklijuokite šį kodą\nį autentifikatoriaus programą", - "scanThisBarcodeWithnyourAuthenticatorApp": "Skenuokite šį brūkšninį kodą\nsu autentifikatoriaus programa", + "tapToCopy": "palieskite, kad nukopijuotumėte", + "scanThisBarcodeWithnyourAuthenticatorApp": "Skenuokite šį QR kodą\nsu autentifikatoriaus programa", "enterThe6digitCodeFromnyourAuthenticatorApp": "Įveskite 6 skaitmenų kodą\niš autentifikatoriaus programos", "confirm": "Patvirtinti", "setupComplete": "Sąranka baigta", @@ -157,25 +158,79 @@ "orPickAnExistingOne": "Arba pasirinkite esamą", "collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": "Bendradarbiai gali pridėti nuotraukų ir vaizdo įrašų į bendrintą albumą.", "enterEmail": "Įveskite el. paštą", + "addMore": "Pridėti daugiau", + "@addMore": { + "description": "Button text to add more collaborators/viewers" + }, + "remove": "Šalinti", + "removeParticipant": "Šalinti dalyvį", + "@removeParticipant": { + "description": "menuSectionTitle for removing a participant" + }, + "changePermissions": "Keisti leidimus?", + "yesConvertToViewer": "Taip, keisti į žiūrėtoją", + "cannotAddMorePhotosAfterBecomingViewer": "{user} negalės pridėti daugiau nuotraukų į šį albumą\n\nJie vis tiek galės pašalinti esamas pridėtas nuotraukas", + "allowAddingPhotos": "Leisti pridėti nuotraukų", + "@allowAddingPhotos": { + "description": "Switch button to enable uploading photos to a public link" + }, + "allowAddPhotosDescription": "Leiskite nuorodą turintiems asmenims taip pat pridėti nuotraukų į bendrinamą albumą.", + "passwordLock": "Slaptažodžio užraktas", + "disableDownloadWarningTitle": "Atkreipkite dėmesį", + "disableDownloadWarningBody": "Žiūrėtojai vis tiek gali daryti ekrano kopijas arba išsaugoti nuotraukų kopijas naudojant išorinius įrankius", + "allowDownloads": "Leisti atsisiuntimus", + "linkDeviceLimit": "Įrenginių riba", + "noDeviceLimit": "Jokio", + "@noDeviceLimit": { + "description": "Text to indicate that there is limit on number of devices" + }, + "linkExpiry": "Nuorodos galiojimo laikas", + "linkEnabled": "Įjungta", + "linkNeverExpires": "Niekada", + "setAPassword": "Nustatyti slaptažodį", "lockButtonLabel": "Užrakinti", "enterPassword": "Įveskite slaptažodį", + "removeLink": "Šalinti nuorodą", + "manageLink": "Tvarkyti nuorodą", + "albumUpdated": "Atnaujintas albumas", + "never": "Niekada", "custom": "Pasirinktinis", "@custom": { "description": "Label for setting custom value for link expiry" }, + "after1Hour": "Po 1 valandos", + "after1Day": "Po 1 dienos", + "after1Week": "Po 1 savaitės", + "after1Month": "Po 1 mėnesio", + "after1Year": "Po 1 metų", + "manageParticipants": "Tvarkyti", + "collabLinkSectionDescription": "Sukurkite nuorodą, kad asmenys galėtų pridėti ir peržiūrėti nuotraukas bendrinamame albume, nereikalaujant „Ente“ programos ar paskyros. Puikiai tinka renginių nuotraukoms rinkti.", + "sendLink": "Siųsti nuorodą", + "copyLink": "Kopijuoti nuorodą", + "emailNoEnteAccount": "{email} neturi „Ente“ paskyros.\n\nSiųskite jiems kvietimą bendrinti nuotraukas.", + "applyCodeTitle": "Taikyti kodą", + "apply": "Taikyti", + "codeAppliedPageTitle": "Pritaikytas kodas", "change": "Keisti", "unavailableReferralCode": "Atsiprašome, šis kodas nepasiekiamas.", "codeChangeLimitReached": "Atsiprašome, pasiekėte kodo pakeitimų ribą.", "storageInGB": "{storageAmountInGB} GB", "faq": "DUK", "total": "iš viso", + "removeFromAlbumTitle": "Pašalinti iš albumo?", + "removeFromAlbum": "Šalinti iš albumo", + "itemsWillBeRemovedFromAlbum": "Pasirinkti elementai bus pašalinti iš šio albumo", + "removeShareItemsWarning": "Kai kuriuos elementus, kuriuos šalinate, pridėjo kiti asmenys, todėl prarasite prieigą prie jų", + "sorryCouldNotRemoveFromFavorites": "Atsiprašome, nepavyko pašalinti iš mėgstamų.", "subscribeToEnableSharing": "Kad įjungtumėte bendrinimą, reikia aktyvios mokamos prenumeratos.", "subscribe": "Prenumeruoti", + "canOnlyRemoveFilesOwnedByYou": "Galima pašalinti tik jums priklausančius failus", "deleteAlbum": "Ištrinti albumą", "deleteAlbumDialog": "Taip pat ištrinti šiame albume esančias nuotraukas (ir vaizdo įrašus) iš visų kitų albumų, kuriuose jos yra dalis?", "yesRemove": "Taip, šalinti", "creatingLink": "Kuriama nuoroda...", "removeWithQuestionMark": "Šalinti?", + "removeParticipantBody": "{userEmail} bus pašalintas iš šio bendrinamo albumo\n\nVisos jų pridėtos nuotraukos taip pat bus pašalintos iš albumo", "keepPhotos": "Palikti nuotraukas", "deletePhotos": "Ištrinti nuotraukas", "inviteToEnte": "Kviesti į „Ente“", @@ -254,6 +309,21 @@ "indexedItems": "Indeksuoti elementai", "pendingItems": "Laukiami elementai", "skip": "Praleisti", + "duplicateItemsGroup": "{count} failai (-ų), kiekvienas {formattedSize}", + "@duplicateItemsGroup": { + "description": "Display the number of duplicate files and their size", + "type": "text", + "placeholders": { + "count": { + "example": "12", + "type": "int" + }, + "formattedSize": { + "example": "2.3 MB", + "type": "String" + } + } + }, "about": "Apie", "weAreOpenSource": "Esame atviro kodo!", "privacy": "Privatumas", @@ -274,6 +344,8 @@ "authToInitiateAccountDeletion": "Nustatykite tapatybę, kad pradėtumėte paskyros ištrynimą", "areYouSureYouWantToLogout": "Ar tikrai norite atsijungti?", "yesLogout": "Taip, atsijungti", + "removeDuplicates": "Šalinti dublikatus", + "youveNoDuplicateFilesThatCanBeCleared": "Neturite dubliuotų failų, kuriuos būtų galima išvalyti", "no": "Ne", "yes": "Taip", "social": "Socialinės", @@ -294,7 +366,25 @@ "lightTheme": "Šviesi", "darkTheme": "Tamsi", "systemTheme": "Sistemos", + "freeTrial": "Nemokamas bandomasis laikotarpis", + "selectYourPlan": "Pasirinkite planą", + "enteSubscriptionPitch": "„Ente“ išsaugo jūsų prisiminimus, todėl jie visada bus pasiekiami, net jei prarasite įrenginį.", + "currentUsageIs": "Dabartinis naudojimas – ", + "@currentUsageIs": { + "description": "This text is followed by storage usage", + "examples": { + "0": "Current usage is 1.2 GB" + }, + "type": "text" + }, "faqs": "DUK", + "freeTrialValidTill": "Nemokamas bandomasis laikotarpis galioja iki {endDate}", + "validTill": "Galioja iki {endDate}", + "subscription": "Prenumerata", + "paymentDetails": "Mokėjimo duomenys", + "manageFamily": "Tvarkyti šeimą", + "renewSubscription": "Atnaujinti prenumeratą", + "cancelSubscription": "Atsisakyti prenumeratos", "yesCancel": "Taip, atsisakyti", "failedToCancel": "Nepavyko atsisakyti", "twoMonthsFreeOnYearlyPlans": "2 mėnesiai nemokamai metiniuose planuose", @@ -310,11 +400,56 @@ }, "confirmPlanChange": "Patvirtinkite plano pakeitimą", "areYouSureYouWantToChangeYourPlan": "Ar tikrai norite keisti planą?", + "youCannotDowngradeToThisPlan": "Negalite pakeisti į šį planą", + "cancelOtherSubscription": "Pirmiausia atsisakykite esamos prenumeratos iš {paymentProvider}", + "@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": "Nebūtina, trumpai, kaip jums patinka...", + "send": "Siųsti", "googlePlayId": "„Google Play“ ID", "appleId": "„Apple ID“", "playstoreSubscription": "„PlayStore“ prenumerata", + "subAlreadyLinkedErrMessage": "Jūsų {id} jau susietas su kita „Ente“ paskyra.\nJei norite naudoti savo {id} su šia paskyra, susisiekite su mūsų palaikymo komanda.", + "visitWebToManage": "Aplankykite web.ente.io, kad tvarkytumėte savo prenumeratą", + "paymentFailed": "Mokėjimas nepavyko", + "paymentFailedTalkToProvider": "Kreipkitės į {providerName} palaikymo komandą, jei jums buvo nuskaičiuota.", + "@paymentFailedTalkToProvider": { + "description": "The text to display when the payment failed", + "type": "text", + "placeholders": { + "providerName": { + "example": "AppStore|PlayStore", + "type": "String" + } + } + }, + "continueOnFreeTrial": "Tęsti nemokame bandomajame laikotarpyje", + "areYouSureYouWantToExit": "Ar tikrai norite išeiti?", + "thankYou": "Dėkojame", + "failedToVerifyPaymentStatus": "Nepavyko patvirtinti mokėjimo būsenos", + "paymentFailedMessage": "Deja, jūsų mokėjimas nepavyko. Susisiekite su palaikymo komanda ir mes jums padėsime!", + "leaveFamily": "Palikti šeimą", + "areYouSureThatYouWantToLeaveTheFamily": "Ar tikrai norite palikti šeimos planą?", + "leave": "Palikti", + "rateTheApp": "Vertinti programą", + "startBackup": "Pradėti kurti atsarginę kopiją", "existingUser": "Esamas naudotojas", + "available": "Prieinama", + "everywhere": "visur", + "androidIosWebDesktop": "„Android“, „iOS“, internete ir darbalaukyje", + "mobileWebDesktop": "Mobiliuosiuose, internete ir darbalaukyje", "newToEnte": "Naujas platformoje „Ente“", + "pleaseLoginAgain": "Prisijunkite iš naujo.", + "autoLogoutMessage": "Dėl techninio trikdžio buvote atjungti. Atsiprašome už nepatogumus.", + "yourSubscriptionHasExpired": "Jūsų prenumerata baigėsi.", "storageLimitExceeded": "Viršyta saugyklos riba.", "upgrade": "Keisti planą", "raiseTicket": "Sukurti paraišką", @@ -327,6 +462,36 @@ "type": "text" }, "onDevice": "Įrenginyje", + "@onEnte": { + "description": "The text displayed above albums backed up to Ente", + "type": "text" + }, + "onEnte": "Saugykloje ente", + "name": "Pavadinimą", + "newest": "Naujausią", + "lastUpdated": "Paskutinį kartą atnaujintą", + "removeFromFavorite": "Šalinti iš mėgstamų", + "addToEnte": "Pridėti į „Ente“", + "addToAlbum": "Pridėti į albumą", + "delete": "Ištrinti", + "hide": "Slėpti", + "share": "Bendrinti", + "restoreToAlbum": "Atkurti į albumą", + "moveItem": "{count, plural, one {Perkelti elementą} few {Perkelti elementus} many {Perkelti elemento} other {Perkelti elementų}}", + "@moveItem": { + "description": "Page title while moving one or more items to an album" + }, + "shareAlbumHint": "Atidarykite albumą ir palieskite bendrinimo mygtuką viršuje dešinėje, kad bendrintumėte.", + "setCover": "Nustatyti viršelį", + "@setCover": { + "description": "Text to set cover photo for an album" + }, + "sortAlbumsBy": "Rikiuoti pagal", + "sortNewestFirst": "Naujausią pirmiausiai", + "sortOldestFirst": "Seniausią pirmiausiai", + "rename": "Pervadinti", + "leaveAlbum": "Palikti albumą", + "photosAddedByYouWillBeRemovedFromTheAlbum": "Jūsų pridėtos nuotraukos bus pašalintos iš albumo", "noExifData": "Nėra EXIF duomenų", "thisImageHasNoExifData": "Šis vaizdas neturi Exif duomenų", "exif": "EXIF", @@ -334,7 +499,15 @@ "close": "Uždaryti", "setAs": "Nustatyti kaip", "download": "Atsisiųsti", + "pressAndHoldToPlayVideo": "Paspauskite ir palaikykite, kad paleistumėte vaizdo įrašą", + "downloadFailed": "Atsisiuntimas nepavyko.", + "deduplicateFiles": "Atdubliuoti failus", + "reviewDeduplicateItems": "Peržiūrėkite ir ištrinkite elementus, kurie, jūsų manymu, yra dublikatai.", + "unlock": "Atrakinti", "freeUpAmount": "Atlaisvinti {sizeInMBorGB}", + "verificationFailedPleaseTryAgain": "Patvirtinimas nepavyko. Bandykite dar kartą.", + "pleaseVerifyTheCodeYouHaveEntered": "Patvirtinkite įvestą kodą.", + "yourVerificationCodeHasExpired": "Jūsų patvirtinimo kodo laikas nebegaliojantis.", "verifying": "Patvirtinama...", "loadingGallery": "Įkeliama galerija...", "syncing": "Sinchronizuojama...", @@ -380,6 +553,10 @@ "addLocationButton": "Pridėti", "locationTagFeatureDescription": "Vietos žymė grupuoja visas nuotraukas, kurios buvo padarytos tam tikru spinduliu nuo nuotraukos", "centerPoint": "Vidurio taškas", + "resetToDefault": "Atkurti numatytąsias reikšmes", + "@resetToDefault": { + "description": "Button text to reset cover photo to default" + }, "edit": "Redaguoti", "deleteLocation": "Ištrinti vietovę", "light": "Šviesi", @@ -403,6 +580,26 @@ "@setLabel": { "description": "Label of confirm button to add a new custom radius to the radius selector of a location tag" }, + "androidBiometricHint": "Patvirtinkite tapatybę", + "@androidBiometricHint": { + "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." + }, + "androidCancelButton": "Atšaukti", + "@androidCancelButton": { + "description": "Message showed on a button that the user can click to leave the current dialog. It is used on Android side. Maximum 30 characters." + }, + "androidSignInTitle": "Privalomas tapatybės nustatymas", + "@androidSignInTitle": { + "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters." + }, + "goToSettings": "Eiti į nustatymus", + "@goToSettings": { + "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." + }, + "iOSOkButton": "Gerai", + "@iOSOkButton": { + "description": "Message showed on a button that the user can click to leave the current dialog. It is used on iOS side. Maximum 30 characters." + }, "map": "Žemėlapis", "@map": { "description": "Label for the map view" @@ -413,6 +610,8 @@ "pinAlbum": "Prisegti albumą", "create": "Kurti", "viewAll": "Peržiūrėti viską", + "deleteConfirmDialogBody": "Ši paskyra susieta su kitomis „Ente“ programomis, jei jas naudojate. Jūsų įkelti duomenys per visas „Ente“ programas bus planuojama ištrinti, o jūsų paskyra bus ištrinta negrįžtamai.", + "viewAddOnButton": "Peržiūrėti priedus", "searchHint4": "Vietovė", "searchResultCount": "{count, plural, one{Rastas {count} rezultatas} few {Rasti {count} rezultatai} many {Rasta {count} rezultato} other{Rasta {count} rezultatų}}", "@searchResultCount": { @@ -439,7 +638,7 @@ "selectALocation": "Pasirinkite vietovę", "selectALocationFirst": "Pirmiausia pasirinkite vietovę", "changeLocationOfSelectedItems": "Keisti pasirinktų elementų vietovę?", - "editsToLocationWillOnlyBeSeenWithinEnte": "Vietovės pakeitimai bus matomi tik platformoje „Ente“", + "editsToLocationWillOnlyBeSeenWithinEnte": "Vietovės pakeitimai bus matomi tik per „Ente“", "cleanUncategorized": "Valyti nekategorizuotą", "cleanUncategorizedDescription": "Pašalinkite iš nekategorizuotą visus failus, esančius kituose albumuose", "waitingForVerification": "Laukiama patvirtinimo...", @@ -460,6 +659,8 @@ "addAName": "Pridėti vardą", "findPeopleByName": "Greitai suraskite žmones pagal vardą", "addViewers": "{count, plural, one {Pridėti žiūrėtoją} few {Pridėti žiūrėtojus} many {Pridėti žiūrėtojo} other {Pridėti žiūrėtojų}}", + "addCollaborators": "{count, plural, one {Pridėti bendradarbį} few {Pridėti bendradarbius} many {Pridėti bendradarbio} other {Pridėti bendradarbių}}", + "longPressAnEmailToVerifyEndToEndEncryption": "Ilgai paspauskite el. paštą, kad patvirtintumėte visapusį šifravimą.", "developerSettingsWarning": "Ar tikrai norite modifikuoti kūrėjo nustatymus?", "developerSettings": "Kūrėjo nustatymai", "serverEndpoint": "Serverio galutinis taškas", @@ -470,13 +671,16 @@ "createCollaborativeLink": "Kurti bendradarbiavimo nuorodą", "search": "Ieškoti", "enterPersonName": "Įveskite asmens vardą", - "removePersonLabel": "Pašalinti asmens žymą", + "removePersonLabel": "Šalinti asmens žymą", "autoPairDesc": "Automatinis susiejimas veikia tik su įrenginiais, kurie palaiko „Chromecast“.", "manualPairDesc": "Susieti su PIN kodu veikia bet kuriame ekrane, kuriame norite peržiūrėti albumą.", "connectToDevice": "Prijungti prie įrenginio", + "autoCastDialogBody": "Čia matysite pasiekiamus perdavimo įrenginius.", "noDeviceFound": "Įrenginys nerastas", "stopCastingTitle": "Stabdyti perdavimą", "stopCastingBody": "Ar norite sustabdyti perdavimą?", + "castIPMismatchTitle": "Nepavyko perduoti albumo", + "castIPMismatchBody": "Įsitikinkite, kad esate tame pačiame tinkle kaip ir televizorius.", "pairingComplete": "Susiejimas baigtas", "savingEdits": "Išsaugomi redagavimai...", "autoPair": "Automatiškai susieti", @@ -507,13 +711,13 @@ "enabled": "Įjungta", "moreDetails": "Daugiau išsamios informacijos", "enableMLIndexingDesc": "„Ente“ palaiko įrenginyje mašininį mokymąsi, skirtą veidų atpažinimui, magiškai paieškai ir kitoms išplėstinėms paieškos funkcijoms", - "magicSearchHint": "Magiška paieška leidžia ieškoti nuotraukų pagal jų turinį, pvz., „\"gėlė“, „raudonas automobilis“, „tapatybės dokumentai“", + "magicSearchHint": "Magiška paieška leidžia ieškoti nuotraukų pagal jų turinį, pvz., „gėlė“, „raudonas automobilis“, „tapatybės dokumentai“", "panorama": "Panorama", "reenterPassword": "Įveskite slaptažodį iš naujo", "reenterPin": "Įveskite PIN iš naujo", "deviceLock": "Įrenginio užraktas", "pinLock": "PIN užrakinimas", - "next": "Sekantis", + "next": "Toliau", "setNewPassword": "Nustatykite naują slaptažodį", "enterPin": "Įveskite PIN", "setNewPin": "Nustatykite naują PIN", @@ -531,7 +735,7 @@ "passwordStrengthInfo": "Slaptažodžio stiprumas apskaičiuojamas atsižvelgiant į slaptažodžio ilgį, naudotus simbolius ir į tai, ar slaptažodis patenka į 10 000 dažniausiai naudojamų slaptažodžių.", "noQuickLinksSelected": "Nėra pasirinktų sparčiųjų nuorodų", "pleaseSelectQuickLinksToRemove": "Pasirinkite sparčiąsias nuorodas, kad pašalintumėte", - "removePublicLinks": "Pašalinti viešąsias nuorodas", + "removePublicLinks": "Šalinti viešąsias nuorodas", "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "Tai pašalins visų pasirinktų sparčiųjų nuorodų viešąsias nuorodas.", "guestView": "Svečio peržiūra", "guestViewEnablePreSteps": "Kad įjungtumėte svečio peržiūrą, sistemos nustatymuose nustatykite įrenginio prieigos kodą arba ekrano užraktą.", diff --git a/mobile/lib/l10n/intl_pt.arb b/mobile/lib/l10n/intl_pt.arb index 648869d3ce..e9b4633dc4 100644 --- a/mobile/lib/l10n/intl_pt.arb +++ b/mobile/lib/l10n/intl_pt.arb @@ -1031,27 +1031,27 @@ "searchPeopleEmptySection": "Convide pessoas e você verá todas as fotos compartilhadas por elas aqui", "searchAlbumsEmptySection": "Álbuns", "searchFileTypesAndNamesEmptySection": "Tipos de arquivo e nomes", - "searchCaptionEmptySection": "Adicione descrições como \"#trip\" nas informações das fotos para encontrá-las aqui rapidamente", + "searchCaptionEmptySection": "Adicione marcações como \"#viagem\" nas informações das fotos para encontrá-las aqui com facilidade", "language": "Idioma", - "selectLanguage": "Selecionar Idioma", - "locationName": "Nome do Local", - "addLocation": "Adicionar local", + "selectLanguage": "Selecionar idioma", + "locationName": "Nome da localização", + "addLocation": "Adicionar localização", "groupNearbyPhotos": "Agrupar fotos próximas", "kiloMeterUnit": "km", "addLocationButton": "Adicionar", "radius": "Raio", - "locationTagFeatureDescription": "Uma tag em grupo de todas as fotos que foram tiradas dentro de algum raio de uma foto", - "galleryMemoryLimitInfo": "Até 1000 memórias mostradas na galeria", + "locationTagFeatureDescription": "Uma etiqueta de localização agrupa todas as fotos fotografadas em algum raio de uma foto", + "galleryMemoryLimitInfo": "Até 1.000 memórias exibidas na galeria", "save": "Salvar", "centerPoint": "Ponto central", "pickCenterPoint": "Escolha o ponto central", - "useSelectedPhoto": "Utilizar foto selecionada", + "useSelectedPhoto": "Usar foto selecionada", "resetToDefault": "Redefinir para o padrão", "@resetToDefault": { "description": "Button text to reset cover photo to default" }, "edit": "Editar", - "deleteLocation": "Excluir Local", + "deleteLocation": "Excluir localização", "rotateLeft": "Girar para a esquerda", "flip": "Inverter", "rotateRight": "Girar para a direita", @@ -1062,7 +1062,7 @@ "doYouWantToDiscardTheEditsYouHaveMade": "Você quer descartar as edições que você fez?", "saving": "Salvando...", "editsSaved": "Edições salvas", - "oopsCouldNotSaveEdits": "Ops, não foi possível salvar edições", + "oopsCouldNotSaveEdits": "Opa! Não foi possível salvar as edições", "distanceInKMUnit": "km", "@distanceInKMUnit": { "description": "Unit for distance in km" @@ -1070,7 +1070,7 @@ "dayToday": "Hoje", "dayYesterday": "Ontem", "storage": "Armazenamento", - "usedSpace": "Espaço em uso", + "usedSpace": "Espaço usado", "storageBreakupFamily": "Família", "storageBreakupYou": "Você", "@storageBreakupYou": { @@ -1084,14 +1084,14 @@ "appVersion": "Versão: {versionValue}", "verifyIDLabel": "Verificar", "fileInfoAddDescHint": "Adicionar descrição...", - "editLocationTagTitle": "Editar local", + "editLocationTagTitle": "Editar localização", "setLabel": "Definir", "@setLabel": { "description": "Label of confirm button to add a new custom radius to the radius selector of a location tag" }, "setRadius": "Definir raio", "familyPlanPortalTitle": "Família", - "familyPlanOverview": "Adicione 5 membros da família ao seu plano existente sem pagar a mais.\n\nCada membro recebe seu próprio espaço privado, e nenhum membro pode ver os arquivos uns dos outros a menos que sejam compartilhados.\n\nPlanos de família estão disponíveis para os clientes que têm uma assinatura do Ente paga.\n\nAssine agora para começar!", + "familyPlanOverview": "Adicione 5 familiares para seu plano existente sem pagar nenhum custo adicional.\n\nCada membro ganha seu espaço privado, significando que eles não podem ver os arquivos dos outros a menos que eles sejam compartilhados.\n\nOs planos familiares estão disponíveis para clientes que já tem uma assinatura paga do Ente.\n\nAssine agora para iniciar!", "androidBiometricHint": "Verificar identidade", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -1116,27 +1116,27 @@ "@androidBiometricRequiredTitle": { "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters." }, - "androidDeviceCredentialsRequiredTitle": "Credenciais do dispositivo necessárias", + "androidDeviceCredentialsRequiredTitle": "Credenciais necessários", "@androidDeviceCredentialsRequiredTitle": { "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters." }, - "androidDeviceCredentialsSetupDescription": "Credenciais do dispositivo necessárias", + "androidDeviceCredentialsSetupDescription": "Credenciais necessários", "@androidDeviceCredentialsSetupDescription": { "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side." }, - "goToSettings": "Ir para Configurações", + "goToSettings": "Ir às opções", "@goToSettings": { "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." }, - "androidGoToSettingsDescription": "A autenticação biométrica não está configurada no seu dispositivo. Vá em 'Configurações > Segurança' para adicionar autenticação biométrica.", + "androidGoToSettingsDescription": "A autenticação biométrica não está definida no dispositivo. Vá em 'Opções > Segurança' para adicionar a autenticação biométrica.", "@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": "A Autenticação Biométrica está desativada. Por favor, bloqueie e desbloqueie sua tela para ativá-la.", + "iOSLockOut": "A autenticação biométrica está desativada. Bloqueie e desbloqueie sua tela para ativá-la.", "@iOSLockOut": { "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side." }, - "iOSGoToSettingsDescription": "A autenticação biométrica não está configurada no seu dispositivo. Por favor, ative o Touch ID ou o Face ID no seu telefone.", + "iOSGoToSettingsDescription": "A autenticação biométrica não está definida no dispositivo. Ative o Touch ID ou Face ID no dispositivo.", "@iOSGoToSettingsDescription": { "description": "Message advising the user to go to the settings and configure Biometrics for their device. It shows in a dialog on iOS side." }, @@ -1145,22 +1145,22 @@ "description": "Message showed on a button that the user can click to leave the current dialog. It is used on iOS side. Maximum 30 characters." }, "openstreetmapContributors": "Contribuidores do OpenStreetMap", - "hostedAtOsmFrance": "Hospedado na OSM France", + "hostedAtOsmFrance": "Hospedado em OSM France", "map": "Mapa", "@map": { "description": "Label for the map view" }, "maps": "Mapas", - "enableMaps": "Habilitar Mapa", - "enableMapsDesc": "Isto mostrará suas fotos em um mapa do mundo.\n\nEste mapa é hospedado pelo OpenStreetMap, e os exatos locais de suas fotos nunca são compartilhados.\n\nVocê pode desativar esse recurso a qualquer momento nas Configurações.", + "enableMaps": "Ativar mapas", + "enableMapsDesc": "Isso exibirá suas fotos em um mapa mundial.\n\nEste mapa é hospedado por Open Street Map, e as exatas localizações das fotos nunca serão compartilhadas.\n\nVocê pode desativar esta função a qualquer momento em Opções.", "quickLinks": "Links rápidos", "selectItemsToAdd": "Selecionar itens para adicionar", "addSelected": "Adicionar selecionado", - "addFromDevice": "Adicionar a partir do dispositivo", + "addFromDevice": "Adicionar do dispositivo", "addPhotos": "Adicionar fotos", "noPhotosFoundHere": "Nenhuma foto encontrada aqui", - "zoomOutToSeePhotos": "Diminuir o zoom para ver fotos", - "noImagesWithLocation": "Nenhuma imagem com local", + "zoomOutToSeePhotos": "Reduzir ampliação para ver as fotos", + "noImagesWithLocation": "Nenhuma imagem com localização", "unpinAlbum": "Desafixar álbum", "pinAlbum": "Fixar álbum", "create": "Criar", @@ -1170,19 +1170,19 @@ "sharedWithYou": "Compartilhado com você", "sharedByYou": "Compartilhado por você", "inviteYourFriendsToEnte": "Convide seus amigos ao Ente", - "failedToDownloadVideo": "Falha ao fazer download do vídeo", + "failedToDownloadVideo": "Falhou ao baixar vídeo", "hiding": "Ocultando...", "unhiding": "Reexibindo...", "successfullyHid": "Ocultado com sucesso", - "successfullyUnhid": "Reexibido com sucesso", - "crashReporting": "Relatório de falhas", + "successfullyUnhid": "Desocultado com sucesso", + "crashReporting": "Relatório de erros", "resumableUploads": "Envios retomáveis", - "addToHiddenAlbum": "Adicionar a álbum oculto", - "moveToHiddenAlbum": "Mover para álbum oculto", + "addToHiddenAlbum": "Adicionar ao álbum oculto", + "moveToHiddenAlbum": "Mover ao álbum oculto", "fileTypes": "Tipos de arquivo", - "deleteConfirmDialogBody": "Esta conta está vinculada a outros aplicativos Ente, se você usar algum. Seus dados enviados, em todos os aplicativos Ente, serão agendados para exclusão, e sua conta será excluída permanentemente.", - "hearUsWhereTitle": "Como você ouviu sobre o Ente? (opcional)", - "hearUsExplanation": "Não rastreamos instalações do aplicativo. Seria útil se você nos contasse onde nos encontrou!", + "deleteConfirmDialogBody": "Esta conta está vinculada aos outros aplicativos do Ente, se você usar algum. Seus dados baixados, entre todos os aplicativos do Ente, serão programados para exclusão, e sua conta será permanentemente excluída.", + "hearUsWhereTitle": "Como você soube do Ente? (opcional)", + "hearUsExplanation": "Não rastreamos instalações de aplicativo. Seria útil se você contasse onde nos encontrou!", "viewAddOnButton": "Ver complementos", "addOns": "Complementos", "addOnPageSubtitle": "Detalhes dos complementos", @@ -1297,8 +1297,8 @@ } } }, - "enable": "Habilitar", - "enabled": "Habilitado", + "enable": "Ativar", + "enabled": "Ativado", "moreDetails": "Mais detalhes", "enableMLIndexingDesc": "Ente suporta aprendizado de máquina no dispositivo para reconhecimento facial, busca mágica e outros recursos avançados de busca", "magicSearchHint": "A busca mágica permite pesquisar fotos por seu conteúdo, por exemplo, 'carro', 'carro vermelho', 'Ferrari'", diff --git a/mobile/lib/l10n/intl_uk.arb b/mobile/lib/l10n/intl_uk.arb index c0ffc5bc0a..65e453e4bc 100644 --- a/mobile/lib/l10n/intl_uk.arb +++ b/mobile/lib/l10n/intl_uk.arb @@ -286,13 +286,13 @@ "details": "Подробиці", "claimMore": "Отримайте більше!", "theyAlsoGetXGb": "Вони також отримують {storageAmountInGB} ГБ", - "freeStorageOnReferralSuccess": "{storageAmountInGB} ГБ щоразу, коли хтось підписується на платний тариф і застосовує ваш код", + "freeStorageOnReferralSuccess": "{storageAmountInGB} ГБ щоразу, коли хтось оформлює передплату і застосовує ваш код", "shareTextReferralCode": "Реферальний код Ente: {referralCode} \n\nЗастосуйте його в «Налаштування» → «Загальні» → «Реферали», щоб отримати {referralStorageInGB} ГБ безплатно після переходу на платний тариф\n\nhttps://ente.io", "claimFreeStorage": "Отримайте безплатне сховище", "inviteYourFriends": "Запросити своїх друзів", "failedToFetchReferralDetails": "Не вдається отримати відомості про реферала. Спробуйте ще раз пізніше.", "referralStep1": "1. Дайте цей код друзям", - "referralStep2": "2. Вони підписуються на платний план", + "referralStep2": "2. Вони оформлюють передплату", "referralStep3": "3. Ви обоє отримуєте {storageInGB} ГБ* безплатно", "referralsAreCurrentlyPaused": "Реферали зараз призупинені", "youCanAtMaxDoubleYourStorage": "* Ви можете максимально подвоїти своє сховище", @@ -327,8 +327,8 @@ "removingFromFavorites": "Видалення з обраного...", "sorryCouldNotAddToFavorites": "Неможливо додати до обраного!", "sorryCouldNotRemoveFromFavorites": "Не вдалося видалити з обраного!", - "subscribeToEnableSharing": "Вам потрібна активна платна підписка, щоб увімкнути спільне поширення.", - "subscribe": "Підписатися", + "subscribeToEnableSharing": "Вам потрібна активна передплата, щоб увімкнути спільне поширення.", + "subscribe": "Передплачувати", "canOnlyRemoveFilesOwnedByYou": "Ви можете видалити лише файли, що належать вам", "deleteSharedAlbum": "Видалити спільний альбом?", "deleteAlbum": "Видалити альбом", @@ -487,7 +487,7 @@ "checking": "Перевірка...", "youAreOnTheLatestVersion": "Ви використовуєте останню версію", "account": "Обліковий запис", - "manageSubscription": "Керування підпискою", + "manageSubscription": "Керування передплатою", "authToChangeYourEmail": "Авторизуйтесь, щоб змінити поштову адресу", "changePassword": "Змінити пароль", "authToChangeYourPassword": "Авторизуйтесь, щоб змінити пароль", @@ -601,18 +601,18 @@ "type": "text" }, "faqs": "ЧаПи", - "renewsOn": "Підписка поновиться {endDate}", + "renewsOn": "Передплата поновиться {endDate}", "freeTrialValidTill": "Безплатна пробна версія діє до {endDate}", "validTill": "Діє до {endDate}", "addOnValidTill": "Ваше доповнення {storageAmount} діє до {endDate}", "playStoreFreeTrialValidTill": "Безплатна пробна версія діє до {endDate}.\nПісля цього ви можете обрати платний план.", - "subWillBeCancelledOn": "Вашу підписку буде скасовано {endDate}", - "subscription": "Підписка", + "subWillBeCancelledOn": "Вашу передплату буде скасовано {endDate}", + "subscription": "Передплата", "paymentDetails": "Деталі платежу", "manageFamily": "Керування сім'єю", - "contactToManageSubscription": "Зв'яжіться з нами за адресою support@ente.io для управління вашою підпискою {provider}.", - "renewSubscription": "Поновити підписку", - "cancelSubscription": "Скасувати підписку", + "contactToManageSubscription": "Зв'яжіться з нами за адресою support@ente.io для управління вашою передплатою {provider}.", + "renewSubscription": "Поновити передплату", + "cancelSubscription": "Скасувати передплату", "areYouSureYouWantToRenew": "Ви впевнені, що хочете поновити?", "yesRenew": "Так, поновити", "areYouSureYouWantToCancel": "Ви дійсно хочете скасувати?", @@ -633,7 +633,7 @@ "confirmPlanChange": "Підтвердити зміну плану", "areYouSureYouWantToChangeYourPlan": "Ви впевнені, що хочете змінити свій план?", "youCannotDowngradeToThisPlan": "Ви не можете перейти до цього плану", - "cancelOtherSubscription": "Спочатку скасуйте вашу підписку від {paymentProvider}", + "cancelOtherSubscription": "Спочатку скасуйте вашу передплату від {paymentProvider}", "@cancelOtherSubscription": { "description": "The text to display when the user has an existing subscription from a different payment provider", "type": "text", @@ -646,19 +646,19 @@ }, "optionalAsShortAsYouLike": "Необов'язково, так коротко, як ви хочете...", "send": "Надіслати", - "askCancelReason": "Підписку було скасовано. Ви хотіли б поділитися причиною?", - "thankYouForSubscribing": "Спасибі за підписку!", + "askCancelReason": "Передплату було скасовано. Ви хотіли б поділитися причиною?", + "thankYouForSubscribing": "Спасибі за передплату!", "yourPurchaseWasSuccessful": "Ваша покупка пройшла успішно", "yourPlanWasSuccessfullyUpgraded": "Ваш план успішно покращено", "yourPlanWasSuccessfullyDowngraded": "Ваш план був успішно знижено", - "yourSubscriptionWasUpdatedSuccessfully": "Вашу підписку успішно оновлено", + "yourSubscriptionWasUpdatedSuccessfully": "Вашу передплату успішно оновлено", "googlePlayId": "Google Play ID", "appleId": "Apple ID", - "playstoreSubscription": "Підписка Play Store", - "appstoreSubscription": "Підписка App Store", + "playstoreSubscription": "Передплата Play Store", + "appstoreSubscription": "Передплата App Store", "subAlreadyLinkedErrMessage": "Ваш {id} вже пов'язаний з іншим обліковим записом Ente.\nЯкщо ви хочете використовувати свій {id} з цим обліковим записом, зверніться до нашої служби підтримки", - "visitWebToManage": "Відвідайте web.ente.io, щоб керувати підпискою", - "couldNotUpdateSubscription": "Не вдалося оновити підписку", + "visitWebToManage": "Відвідайте web.ente.io, щоб керувати передплатою", + "couldNotUpdateSubscription": "Не вдалося оновити передплату", "pleaseContactSupportAndWeWillBeHappyToHelp": "Зв'яжіться з support@ente.io і ми будемо раді допомогти!", "paymentFailed": "Не вдалося оплатити", "paymentFailedTalkToProvider": "Зверніться до {providerName}, якщо було знято платіж", @@ -679,7 +679,7 @@ "pleaseWaitForSometimeBeforeRetrying": "Зачекайте деякий час перед повторною спробою", "paymentFailedMessage": "На жаль, ваш платіж не вдався. Зв'яжіться зі службою підтримки і ми вам допоможемо!", "youAreOnAFamilyPlan": "Ви на сімейному плані!", - "contactFamilyAdmin": "Зв'яжіться з {familyAdminEmail} для керування вашою підпискою", + "contactFamilyAdmin": "Зв'яжіться з {familyAdminEmail} для керування вашою передплатою", "leaveFamily": "Покинути сім'ю", "areYouSureThatYouWantToLeaveTheFamily": "Ви впевнені, що хочете залишити сімейний план?", "leave": "Покинути", @@ -704,7 +704,7 @@ "newToEnte": "Уперше на Ente", "pleaseLoginAgain": "Увійдіть знову", "autoLogoutMessage": "Через технічні збої ви вийшли з системи. Перепрошуємо за незручності.", - "yourSubscriptionHasExpired": "Термін дії вашої підписки скінчився", + "yourSubscriptionHasExpired": "Термін дії вашої передплати скінчився", "storageLimitExceeded": "Перевищено ліміт сховища", "upgrade": "Покращити", "raiseTicket": "Подати заявку", @@ -923,7 +923,7 @@ "@freeUpSpaceSaving": { "description": "Text to tell user how much space they can free up by deleting items from the device" }, - "freeUpAccessPostDelete": "Ви все ще можете отримати доступ до {count, plural, one {нього} other {них}} в Ente, доки у вас активна підписка", + "freeUpAccessPostDelete": "Ви все ще можете отримати доступ до {count, plural, one {нього} other {них}} в Ente, доки у вас активна передплата", "@freeUpAccessPostDelete": { "placeholders": { "count": { @@ -956,6 +956,7 @@ "encryptingBackup": "Шифруємо резервну копію...", "syncStopped": "Синхронізацію зупинено", "syncProgress": "{completed} / {total} спогадів збережено", + "uploadingMultipleMemories": "Збереження {count} спогадів...", "uploadingSingleMemory": "Зберігаємо 1 спогад...", "@syncProgress": { "description": "Text to tell user how many memories have been preserved", @@ -1011,7 +1012,7 @@ "dismiss": "Відхилити", "didYouKnow": "Чи знали ви?", "loadingMessage": "Завантажуємо ваші фотографії...", - "loadMessage1": "Ви можете поділитися своєю підпискою з родиною", + "loadMessage1": "Ви можете поділитися своєю передплатою з родиною", "loadMessage2": "На цей час ми зберегли понад 30 мільйонів спогадів", "loadMessage3": "Ми зберігаємо 3 копії ваших даних, одну в підземному бункері", "loadMessage4": "Всі наші застосунки мають відкритий код", From a11dd01d4a58b1374b294e2dde79bd67b23fc840 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 4 Nov 2024 09:46:28 +0530 Subject: [PATCH 42/45] [desktop] Fix build failures due to apt failures https://github.com/ente-io/ente/pull/3921/files --- desktop/.github/workflows/desktop-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/.github/workflows/desktop-release.yml b/desktop/.github/workflows/desktop-release.yml index 9c8222f69b..610b9343d4 100644 --- a/desktop/.github/workflows/desktop-release.yml +++ b/desktop/.github/workflows/desktop-release.yml @@ -88,7 +88,7 @@ jobs: if: startsWith(matrix.os, 'ubuntu') # See: # https://github.com/electron-userland/electron-builder/issues/4181 - run: sudo apt-get update && apt-get install libarchive-tools + run: sudo apt-get update && sudo apt-get install libarchive-tools - name: Build uses: ente-io/action-electron-builder@eff78a1d33bdab4c54ede0e5cdc71e0c2cf803e2 From c58dffd5c9e74ca447ed84e62fda6f620c8f5961 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta Date: Mon, 4 Nov 2024 11:29:04 +0530 Subject: [PATCH 43/45] [mob] Handle 404 for multipart failure (#3923) ## Description ## Tests --- .../lib/module/upload/service/multipart.dart | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/mobile/lib/module/upload/service/multipart.dart b/mobile/lib/module/upload/service/multipart.dart index f31e30248c..a1c83059a1 100644 --- a/mobile/lib/module/upload/service/multipart.dart +++ b/mobile/lib/module/upload/service/multipart.dart @@ -135,11 +135,21 @@ class MultiPartUploader { if (multipartInfo.status != MultipartStatus.completed) { // complete the multipart upload - await _completeMultipartUpload( - multipartInfo.urls.objectKey, - etags, - multipartInfo.urls.completeURL, - ); + try { + await _completeMultipartUpload( + multipartInfo.urls.objectKey, + etags, + multipartInfo.urls.completeURL, + ); + } on DioError catch (e) { + if (e.response?.statusCode == 404) { + _logger.severe( + "Multipart upload not found for key ${multipartInfo.urls.objectKey}", + ); + await _db.deleteMultipartTrack(localId); + } + rethrow; + } } return multipartInfo.urls.objectKey; @@ -263,7 +273,7 @@ class MultiPartUploader { MultipartStatus.completed, ); } catch (e) { - Logger("MultipartUpload").severe(e); + Logger("MultipartUpload").severe("upload failed for key $objectKey}", e); rethrow; } } From fcb966f649890da78831cf4926e55a0ded76e988 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 4 Nov 2024 11:37:08 +0530 Subject: [PATCH 44/45] [docs] Add note about leafmost folder --- docs/docs/photos/features/albums.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/docs/photos/features/albums.md b/docs/docs/photos/features/albums.md index 2163efde38..bc010f4150 100644 --- a/docs/docs/photos/features/albums.md +++ b/docs/docs/photos/features/albums.md @@ -48,6 +48,10 @@ albums**. result in the creation of a new album – empty folders (or folders that only contain other folders) will be ignored. +- In separate album mode, only the leafmost folder name is considered. For + example, both `A/B/C/D/x.png` and `1/2/3/D/y.png` will get uploaded into the + same Ente album named "D". + > [!NOTE] > > Ente albums cannot be nested currently. That is, in the **separate album** From fd301ff116b1247dd82322ae7ba1d7e96961a6f5 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 4 Nov 2024 13:40:41 +0530 Subject: [PATCH 45/45] [infra] Add data-puller CF worker --- infra/workers/data-puller/package.json | 5 +++++ infra/workers/data-puller/src/index.ts | 30 +++++++++++++++++++++++++ infra/workers/data-puller/tsconfig.json | 1 + infra/workers/data-puller/wrangler.toml | 5 +++++ 4 files changed, 41 insertions(+) create mode 100644 infra/workers/data-puller/package.json create mode 100644 infra/workers/data-puller/src/index.ts create mode 100644 infra/workers/data-puller/tsconfig.json create mode 100644 infra/workers/data-puller/wrangler.toml diff --git a/infra/workers/data-puller/package.json b/infra/workers/data-puller/package.json new file mode 100644 index 0000000000..183b2b6bf1 --- /dev/null +++ b/infra/workers/data-puller/package.json @@ -0,0 +1,5 @@ +{ + "name": "data-puller", + "version": "0.0.0", + "private": true +} diff --git a/infra/workers/data-puller/src/index.ts b/infra/workers/data-puller/src/index.ts new file mode 100644 index 0000000000..38cdad000d --- /dev/null +++ b/infra/workers/data-puller/src/index.ts @@ -0,0 +1,30 @@ +/** + * Proxy requests for downloading files from object storage. + * + * Used by museum when replicating. + */ + +export default { + async fetch(request: Request) { + switch (request.method) { + case "GET": + return handleGET(request); + default: + console.log(`Unsupported HTTP method ${request.method}`); + return new Response(null, { status: 405 }); + } + }, +} satisfies ExportedHandler; + +const handleGET = async (request: Request) => { + const url = new URL(request.url); + + // Random bots keep trying to pentest causing noise in the logs. If the + // request doesn't have a src, we can just safely ignore it. + const src = url.searchParams.get("src"); + if (!src) return new Response(null, { status: 400 }); + + const source = atob(src); + + return fetch(source); +}; diff --git a/infra/workers/data-puller/tsconfig.json b/infra/workers/data-puller/tsconfig.json new file mode 100644 index 0000000000..a65b752070 --- /dev/null +++ b/infra/workers/data-puller/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "../tsconfig.base.json", "include": ["src"] } diff --git a/infra/workers/data-puller/wrangler.toml b/infra/workers/data-puller/wrangler.toml new file mode 100644 index 0000000000..7c254ec480 --- /dev/null +++ b/infra/workers/data-puller/wrangler.toml @@ -0,0 +1,5 @@ +name = "data-puller" +main = "src/index.ts" +compatibility_date = "2024-06-14" + +tail_consumers = [{ service = "tail" }]