diff --git a/README.md b/README.md
index d90d2e1340c0796559bf7883bda64263eaf9607a..ea27599daa815a02861d4c8d57167a0e7d813f5e 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,8 @@ We have open-source apps across [Android](https://github.com/ente-io/frame), [iO
This repository contains the code for our web app, built with a lot of ❤️, and a little bit of JavaScript.
-
+
+
## ✨ Features
@@ -19,10 +20,14 @@ This repository contains the code for our web app, built with a lot of ❤️, a
- EXIF viewer
- Zero third-party tracking / analytics
+
+
## 💻 Deployed Application
The deployed application is accessible @ [web.ente.io](https://web.ente.io).
+
+
## 🧑💻 Building from source
1. Clone this repository with `git clone git@github.com:ente-io/bada-frame.git`
@@ -32,26 +37,36 @@ The deployed application is accessible @ [web.ente.io](https://web.ente.io).
Open [http://localhost:3000](http://localhost:3000) on your browser to see the live application.
+
+
## 🙋 Help
We provide human support to our customers. Please write to [support@ente.io](mailto:support@ente.io) sharing as many details as possible about whatever it is that you need help with, and we will get back to you as soon as possible.
+
+
## 🧭 Roadmap
We maintain a public roadmap, that's driven by our community @ [roadmap.ente.io](https://roadmap.ente.io).
+
+
## 🤗 Support
If you like this project, please consider upgrading to a paid subscription.
If you would like to motivate us to keep building, you can do so by [starring](https://github.com/ente-io/bada-frame/stargazers) this project.
+
+
## ❤️ Join the Community
Follow us on [Twitter](https://twitter.com/enteio) and join [r/enteio](https://reddit.com/r/enteio) to get regular updates, connect with other customers, and discuss your ideas.
An important part of our journey is to build better software by consistently listening to community feedback. Please feel free to [share your thoughts](mailto:feedback@ente.io) with us at any time.
+
+
---
Cross-browser testing provided by
diff --git a/package.json b/package.json
index 55ea31c8f7087cbee758b531c34ad4955312d3ec..b454d8592d678dcf915c142fe5f9b4253ada4cb7 100644
--- a/package.json
+++ b/package.json
@@ -5,10 +5,11 @@
"scripts": {
"dev": "next dev",
"albums": "next dev -p 3002",
- "prebuild": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
+ "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
+ "prebuild": "yarn lint",
"build": "next build",
- "build-analyze": "ANALYZE=true next build",
"postbuild": "next export",
+ "build-analyze": "ANALYZE=true next build",
"start": "next start",
"prepare": "husky install"
},
diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association
index 8e87b1279af566de44998822ae88d6438662147b..265f647daaf232aa2600dbc4079da53d009bd34f 100644
--- a/public/.well-known/apple-app-site-association
+++ b/public/.well-known/apple-app-site-association
@@ -1,7 +1,8 @@
{
"webcredentials": {
"apps": [
+ "6Z68YJY9Q2.io.ente.frame",
"2BUSYC7FN9.io.ente.frame"
]
}
-}
\ No newline at end of file
+}
diff --git a/public/images/delete-account/1x.png b/public/images/delete-account/1x.png
new file mode 100644
index 0000000000000000000000000000000000000000..b8288cee8e750d871e3ba52d53c7d79e1aba61c6
Binary files /dev/null and b/public/images/delete-account/1x.png differ
diff --git a/public/images/delete-account/2x.png b/public/images/delete-account/2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..31c9014bc6f0f164f8a4b7824d009df7f56cd988
Binary files /dev/null and b/public/images/delete-account/2x.png differ
diff --git a/public/images/delete-account/3x.png b/public/images/delete-account/3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..7be1e70dedf9abeeb0f5ce726f330a0d65ccc87a
Binary files /dev/null and b/public/images/delete-account/3x.png differ
diff --git a/src/components/AuthenticateUserModal.tsx b/src/components/AuthenticateUserModal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..092119d3465528190daf7fc44d8bd1e3b205ad5f
--- /dev/null
+++ b/src/components/AuthenticateUserModal.tsx
@@ -0,0 +1,88 @@
+import React, { useContext, useEffect, useState } from 'react';
+
+import constants from 'utils/strings/constants';
+import { getData, LS_KEYS } from 'utils/storage/localStorage';
+import { AppContext } from 'pages/_app';
+import { KeyAttributes, User } from 'types/user';
+import VerifyMasterPasswordForm, {
+ VerifyMasterPasswordFormProps,
+} from 'components/VerifyMasterPasswordForm';
+import { Dialog, Stack, Typography } from '@mui/material';
+import { logError } from 'utils/sentry';
+
+interface Iprops {
+ open: boolean;
+ onClose: () => void;
+ onAuthenticate: () => void;
+}
+
+export default function AuthenticateUserModal({
+ open,
+ onClose,
+ onAuthenticate,
+}: Iprops) {
+ const { setDialogMessage } = useContext(AppContext);
+ const [user, setUser] = useState();
+ const [keyAttributes, setKeyAttributes] = useState();
+
+ const somethingWentWrong = () =>
+ setDialogMessage({
+ title: constants.ERROR,
+ close: { variant: 'danger' },
+ content: constants.UNKNOWN_ERROR,
+ });
+
+ useEffect(() => {
+ const main = async () => {
+ try {
+ const user = getData(LS_KEYS.USER);
+ if (!user) {
+ throw Error('User not found');
+ }
+ setUser(user);
+ const keyAttributes = getData(LS_KEYS.KEY_ATTRIBUTES);
+ if (
+ (!user?.token && !user?.encryptedToken) ||
+ (keyAttributes && !keyAttributes.memLimit)
+ ) {
+ throw Error('User not logged in');
+ } else if (!keyAttributes) {
+ throw Error('Key attributes not found');
+ } else {
+ setKeyAttributes(keyAttributes);
+ }
+ } catch (e) {
+ logError(e, 'AuthenticateUserModal initialization failed');
+ onClose();
+ somethingWentWrong();
+ }
+ };
+ main();
+ }, []);
+
+ const useMasterPassword: VerifyMasterPasswordFormProps['callback'] =
+ async () => {
+ onClose();
+ onAuthenticate();
+ };
+
+ return (
+
+
+
+ {constants.PASSWORD}
+
+
+
+
+ );
+}
diff --git a/src/components/Collections/CollectionOptions/index.tsx b/src/components/Collections/CollectionOptions/index.tsx
index e89bc5eadf74756650d296a618d904c7335268a2..cfaa96ce114ca9eb38b34ee0a03899a89c9e7402 100644
--- a/src/components/Collections/CollectionOptions/index.tsx
+++ b/src/components/Collections/CollectionOptions/index.tsx
@@ -119,9 +119,9 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
};
};
- const renameCollection = (newName: string) => {
+ const renameCollection = async (newName: string) => {
if (activeCollection.name !== newName) {
- CollectionAPI.renameCollection(activeCollection, newName);
+ await CollectionAPI.renameCollection(activeCollection, newName);
}
};
diff --git a/src/components/Collections/index.tsx b/src/components/Collections/index.tsx
index 06d16798d200319bcae52c5f2bf4919ddfb8ec6a..b60e93e0510137e35408861710cf6a046469e860 100644
--- a/src/components/Collections/index.tsx
+++ b/src/components/Collections/index.tsx
@@ -8,7 +8,7 @@ import CollectionShare from 'components/Collections/CollectionShare';
import { SetCollectionNamerAttributes } from 'components/Collections/CollectionNamer';
import { ITEM_TYPE, TimeStampListItem } from 'components/PhotoList';
import {
- hasNonEmptyCollections,
+ hasNonSystemCollections,
isSystemCollection,
shouldBeShownOnCollectionBar,
} from 'utils/collection';
@@ -49,8 +49,13 @@ export default function Collections(props: Iprops) {
const collectionsMap = useRef>(new Map());
const activeCollection = useRef(null);
- const shouldBeHidden =
- isInSearchMode || hasNonEmptyCollections(collectionSummaries);
+ const shouldBeHidden = useMemo(
+ () =>
+ isInSearchMode ||
+ (!hasNonSystemCollections(collectionSummaries) &&
+ activeCollectionID === ALL_SECTION),
+ [isInSearchMode, collectionSummaries, activeCollectionID]
+ );
useEffect(() => {
collectionsMap.current = new Map(
@@ -72,31 +77,26 @@ export default function Collections(props: Iprops) {
[collectionSortBy, collectionSummaries]
);
- useEffect(
- () =>
- !shouldBeHidden &&
- setPhotoListHeader({
- item: (
- setActiveCollectionID(ALL_SECTION)}
- showCollectionShareModal={() =>
- setCollectionShareModalView(true)
- }
- />
- ),
- itemType: ITEM_TYPE.OTHER,
- height: 68,
- }),
- [collectionSummaries, activeCollectionID, shouldBeHidden]
- );
+ useEffect(() => {
+ setPhotoListHeader({
+ item: (
+ setActiveCollectionID(ALL_SECTION)}
+ showCollectionShareModal={() =>
+ setCollectionShareModalView(true)
+ }
+ />
+ ),
+ itemType: ITEM_TYPE.OTHER,
+ height: 68,
+ });
+ }, [collectionSummaries, activeCollectionID]);
if (shouldBeHidden) {
return <>>;
diff --git a/src/components/Container.ts b/src/components/Container.ts
index 76844f93b2bf9db227c19f9b25bcce2b9dc495cb..bcb7464ca67f244a1058f2e9f4aebc51a7027cc6 100644
--- a/src/components/Container.ts
+++ b/src/components/Container.ts
@@ -73,3 +73,7 @@ export const Overlay = styled(Box)`
export const IconButtonWithBG = styled(IconButton)(({ theme }) => ({
backgroundColor: theme.palette.fill.dark,
}));
+
+export const HorizontalFlex = styled(Box)({
+ display: 'flex',
+});
diff --git a/src/components/DeleteAccountModal.tsx b/src/components/DeleteAccountModal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..94478135c91e0b90fcaec1775e81e78faba8687e
--- /dev/null
+++ b/src/components/DeleteAccountModal.tsx
@@ -0,0 +1,165 @@
+import NoAccountsIcon from '@mui/icons-material/NoAccountsOutlined';
+import TickIcon from '@mui/icons-material/Done';
+import {
+ Dialog,
+ DialogContent,
+ Typography,
+ Button,
+ Stack,
+} from '@mui/material';
+import { AppContext } from 'pages/_app';
+import React, { useContext, useEffect, useState } from 'react';
+import { preloadImage, initiateEmail } from 'utils/common';
+import constants from 'utils/strings/constants';
+import VerticallyCentered from './Container';
+import DialogTitleWithCloseButton from './DialogBox/TitleWithCloseButton';
+import {
+ deleteAccount,
+ getAccountDeleteChallenge,
+ logoutUser,
+} from 'services/userService';
+import AuthenticateUserModal from './AuthenticateUserModal';
+import { logError } from 'utils/sentry';
+import { decryptDeleteAccountChallenge } from 'utils/crypto';
+
+interface Iprops {
+ onClose: () => void;
+ open: boolean;
+}
+const DeleteAccountModal = ({ open, onClose }: Iprops) => {
+ const { setDialogMessage, isMobile } = useContext(AppContext);
+ const [authenticateUserModalView, setAuthenticateUserModalView] =
+ useState(false);
+ const [deleteAccountChallenge, setDeleteAccountChallenge] = useState('');
+
+ const openAuthenticateUserModal = () => setAuthenticateUserModalView(true);
+ const closeAuthenticateUserModal = () =>
+ setAuthenticateUserModalView(false);
+
+ useEffect(() => {
+ preloadImage('/images/delete-account');
+ }, []);
+
+ const sendFeedbackMail = () => initiateEmail('feedback@ente.io');
+
+ const somethingWentWrong = () =>
+ setDialogMessage({
+ title: constants.ERROR,
+ close: { variant: 'danger' },
+ content: constants.UNKNOWN_ERROR,
+ });
+
+ const initiateDelete = async () => {
+ try {
+ const deleteChallengeResponse = await getAccountDeleteChallenge();
+ setDeleteAccountChallenge(
+ deleteChallengeResponse.encryptedChallenge
+ );
+ if (deleteChallengeResponse.allowDelete) {
+ openAuthenticateUserModal();
+ } else {
+ askToMailForDeletion();
+ }
+ } catch (e) {
+ logError(e, 'Error while initiating account deletion');
+ somethingWentWrong();
+ }
+ };
+
+ const confirmAccountDeletion = () => {
+ setDialogMessage({
+ title: constants.CONFIRM_ACCOUNT_DELETION_TITLE,
+ content: constants.CONFIRM_ACCOUNT_DELETION_MESSAGE,
+ proceed: {
+ text: constants.DELETE,
+ action: solveChallengeAndDeleteAccount,
+ variant: 'danger',
+ },
+ close: { text: constants.CANCEL },
+ });
+ };
+
+ const askToMailForDeletion = () => {
+ setDialogMessage({
+ title: constants.DELETE_ACCOUNT,
+ content: constants.DELETE_ACCOUNT_MESSAGE(),
+ proceed: {
+ text: constants.DELETE,
+ action: () => {
+ initiateEmail('account-deletion@ente.io');
+ },
+ variant: 'danger',
+ },
+ close: { text: constants.CANCEL },
+ });
+ };
+
+ const solveChallengeAndDeleteAccount = async () => {
+ try {
+ const decryptedChallenge = await decryptDeleteAccountChallenge(
+ deleteAccountChallenge
+ );
+ await deleteAccount(decryptedChallenge);
+ logoutUser();
+ } catch (e) {
+ logError(e, 'solveChallengeAndDeleteAccount failed');
+ somethingWentWrong();
+ }
+ };
+
+ return (
+ <>
+
+
+
+ {constants.DELETE_ACCOUNT}
+
+
+
+
+
+
+
+
+ {constants.ASK_FOR_FEEDBACK}
+
+
+
+ }>
+ {constants.SEND_FEEDBACK}
+
+ }>
+ {constants.DELETE_ACCOUNT}
+
+
+
+
+
+ >
+ );
+};
+
+export default DeleteAccountModal;
diff --git a/src/components/DialogBox/base.tsx b/src/components/DialogBox/base.tsx
index 6db198c753ee35f695740d6f169d07b86af221cc..395c55cc6f1e7a62e9c021332d44fba73e1e3b86 100644
--- a/src/components/DialogBox/base.tsx
+++ b/src/components/DialogBox/base.tsx
@@ -18,6 +18,12 @@ const DialogBoxBase = styled(Dialog)(({ theme }) => ({
'.MuiDialogTitle-root + .MuiDialogActions-root': {
paddingTop: theme.spacing(3),
},
+ '& .MuiDialogActions-root': {
+ flexWrap: 'wrap-reverse',
+ },
+ '& .MuiButton-root': {
+ margin: theme.spacing(0.5, 0),
+ },
}));
export default DialogBoxBase;
diff --git a/src/components/EmptyScreen.tsx b/src/components/EmptyScreen.tsx
index 1d0e328ef95ad6ced3cd2f49eec6fde8bd0edf32..4609f63fdc5ea8e8f71cd64427d38a0a01dee225 100644
--- a/src/components/EmptyScreen.tsx
+++ b/src/components/EmptyScreen.tsx
@@ -3,6 +3,7 @@ import { Button, styled, Typography } from '@mui/material';
import constants from 'utils/strings/constants';
import { DeduplicateContext } from 'pages/deduplicate';
import VerticallyCentered from './Container';
+import uploadManager from 'services/upload/uploadManager';
const Wrapper = styled(VerticallyCentered)`
& > svg {
@@ -34,12 +35,20 @@ export default function EmptyScreen({ openUploader }) {
{constants.UPLOAD_FIRST_PHOTO_DESCRIPTION()}
-
- {constants.UPLOAD_FIRST_PHOTO}
-
+
+
+ {constants.UPLOAD_FIRST_PHOTO}
+
+
>
)}
diff --git a/src/components/ExportModal.tsx b/src/components/ExportModal.tsx
index 4f0e96da5483e567faed70bcee9efc5af33ecd50..0450b43fef2590ed1e500f077aa542aab6d91b77 100644
--- a/src/components/ExportModal.tsx
+++ b/src/components/ExportModal.tsx
@@ -72,10 +72,10 @@ export default function ExportModal(props: Props) {
}
setExportFolder(getData(LS_KEYS.EXPORT)?.folder);
- exportService.ElectronAPIs.registerStopExportListener(stopExport);
- exportService.ElectronAPIs.registerPauseExportListener(pauseExport);
- exportService.ElectronAPIs.registerResumeExportListener(resumeExport);
- exportService.ElectronAPIs.registerRetryFailedExportListener(
+ exportService.electronAPIs.registerStopExportListener(stopExport);
+ exportService.electronAPIs.registerPauseExportListener(pauseExport);
+ exportService.electronAPIs.registerResumeExportListener(resumeExport);
+ exportService.electronAPIs.registerRetryFailedExportListener(
retryFailedExport
);
}, []);
diff --git a/src/components/FullScreenDropZone.tsx b/src/components/FullScreenDropZone.tsx
index 41ad96fb10b62bd244224e44aefbac530baacc5e..e48f41cf393f8ef8f18725b4e1429e2b6262faa2 100644
--- a/src/components/FullScreenDropZone.tsx
+++ b/src/components/FullScreenDropZone.tsx
@@ -1,7 +1,8 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useContext } from 'react';
import { styled } from '@mui/material';
import constants from 'utils/strings/constants';
import CloseIcon from '@mui/icons-material/Close';
+import { AppContext } from 'pages/_app';
const CloseButtonWrapper = styled('div')`
position: absolute;
@@ -41,6 +42,8 @@ type Props = React.PropsWithChildren<{
}>;
export default function FullScreenDropZone(props: Props) {
+ const appContext = useContext(AppContext);
+
const [isDragActive, setIsDragActive] = useState(false);
const onDragEnter = () => setIsDragActive(true);
const onDragLeave = () => setIsDragActive(false);
@@ -52,6 +55,27 @@ export default function FullScreenDropZone(props: Props) {
}
});
}, []);
+
+ useEffect(() => {
+ const handleWatchFolderDrop = (e: DragEvent) => {
+ if (!appContext.watchFolderView) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+ const files = e.dataTransfer.files;
+ if (files.length > 0) {
+ appContext.setWatchFolderFiles(files);
+ }
+ };
+
+ addEventListener('drop', handleWatchFolderDrop);
+ return () => {
+ removeEventListener('drop', handleWatchFolderDrop);
+ };
+ }, [appContext.watchFolderView]);
+
return (
- {constants.UPLOAD_DROPZONE_MESSAGE}
+ {appContext.watchFolderView
+ ? constants.WATCH_FOLDER_DROPZONE_MESSAGE
+ : constants.UPLOAD_DROPZONE_MESSAGE}
)}
{props.children}
diff --git a/src/components/PhotoFrame.tsx b/src/components/PhotoFrame.tsx
index 41f4b556743a83d34f39d7673535d0ef7546c8e9..11ccc40366a9ff65a607c54187bdcde29671eb1f 100644
--- a/src/components/PhotoFrame.tsx
+++ b/src/components/PhotoFrame.tsx
@@ -28,6 +28,8 @@ import { isSameDayAnyYear, isInsideBox } from 'utils/search';
import { Search } from 'types/search';
import { logError } from 'utils/sentry';
import { CustomError } from 'utils/error';
+import { User } from 'types/user';
+import { getData, LS_KEYS } from 'utils/storage/localStorage';
const Container = styled('div')`
display: block;
@@ -161,6 +163,7 @@ const PhotoFrame = ({
useEffect(() => {
const idSet = new Set();
+ const user: User = getData(LS_KEYS.USER);
filteredDataRef.current = files
.map((item, index) => ({
...item,
@@ -218,7 +221,8 @@ const PhotoFrame = ({
if (activeCollection === ARCHIVE_SECTION && !IsArchived(item)) {
return false;
}
- if (isSharedFile(item) && !isSharedCollection) {
+
+ if (isSharedFile(user, item) && !isSharedCollection) {
return false;
}
if (activeCollection === TRASH_SECTION && !item.isTrashed) {
diff --git a/src/components/PhotoList.tsx b/src/components/PhotoList.tsx
index 72a428af1e0f86344b2626fcb10f3eb9e2e9d319..b2157f12cce6fb338bc22fec7c5cf311a5cc1cf4 100644
--- a/src/components/PhotoList.tsx
+++ b/src/components/PhotoList.tsx
@@ -220,7 +220,7 @@ export function PhotoList({
if (!skipMerge) {
timeStampList = mergeTimeStampList(timeStampList, columns);
}
- if (timeStampList.length === 0) {
+ if (timeStampList.length === 1) {
timeStampList.push(getEmptyListItem());
}
if (
@@ -573,6 +573,7 @@ export function PhotoList({
return listItem.item;
}
};
+
if (!timeStampList?.length) {
return <>>;
}
diff --git a/src/components/Sidebar/ExitSection.tsx b/src/components/Sidebar/ExitSection.tsx
index 6ecbb72a7ebb1bb769e067e96582e0fc97d75aae..b77813bb2228842f7b82e03d09d79e81c2ac24cf 100644
--- a/src/components/Sidebar/ExitSection.tsx
+++ b/src/components/Sidebar/ExitSection.tsx
@@ -1,13 +1,18 @@
-import React, { useContext } from 'react';
+import React, { useContext, useState } from 'react';
import SidebarButton from './Button';
import constants from 'utils/strings/constants';
-import { initiateEmail } from 'utils/common';
import { logoutUser } from 'services/userService';
import { AppContext } from 'pages/_app';
+import DeleteAccountModal from 'components/DeleteAccountModal';
export default function ExitSection() {
const { setDialogMessage } = useContext(AppContext);
+ const [deleteAccountModalView, setDeleteAccountModalView] = useState(false);
+
+ const closeDeleteAccountModal = () => setDeleteAccountModalView(false);
+ const openDeleteAccountModal = () => setDeleteAccountModalView(true);
+
const confirmLogout = () => {
setDialogMessage({
title: constants.LOGOUT_MESSAGE,
@@ -20,29 +25,18 @@ export default function ExitSection() {
});
};
- const showDeleteAccountDirections = () => {
- setDialogMessage({
- title: constants.DELETE_ACCOUNT,
- content: constants.DELETE_ACCOUNT_MESSAGE(),
- proceed: {
- text: constants.DELETE,
- action: () => {
- initiateEmail('account-deletion@ente.io');
- },
- variant: 'danger',
- },
- close: { text: constants.CANCEL },
- });
- };
-
return (
<>
{constants.LOGOUT}
-
+
{constants.DELETE_ACCOUNT}
+
>
);
}
diff --git a/src/components/Sidebar/SubscriptionStatus/index.tsx b/src/components/Sidebar/SubscriptionStatus/index.tsx
index 4531df6a99f9b0b8cb2d48dffb28b61610784d3d..d62739a29061f10d417cfcb98d84c4c082d001cf 100644
--- a/src/components/Sidebar/SubscriptionStatus/index.tsx
+++ b/src/components/Sidebar/SubscriptionStatus/index.tsx
@@ -8,11 +8,13 @@ import {
hasExceededStorageQuota,
isSubscriptionActive,
isSubscriptionCancelled,
+ hasStripeSubscription,
} from 'utils/billing';
import Box from '@mui/material/Box';
import { UserDetails } from 'types/user';
import constants from 'utils/strings/constants';
import { Typography } from '@mui/material';
+import billingService from 'services/billingService';
export default function SubscriptionStatus({
userDetails,
@@ -33,13 +35,29 @@ export default function SubscriptionStatus({
}
if (
hasPaidSubscription(userDetails.subscription) &&
- isSubscriptionActive(userDetails.subscription)
+ !isSubscriptionCancelled(userDetails.subscription)
) {
return false;
}
return true;
}, [userDetails]);
+ const handleClick = useMemo(() => {
+ if (userDetails) {
+ if (isSubscriptionActive(userDetails.subscription)) {
+ if (hasExceededStorageQuota(userDetails)) {
+ return showPlanSelectorModal;
+ }
+ } else {
+ if (hasStripeSubscription(userDetails.subscription)) {
+ return billingService.redirectToCustomerPortal;
+ } else {
+ return showPlanSelectorModal;
+ }
+ }
+ }
+ }, [userDetails]);
+
if (!hasAMessage) {
return <>>;
}
@@ -49,8 +67,8 @@ export default function SubscriptionStatus({
+ onClick={handleClick && handleClick}
+ sx={{ cursor: handleClick && 'pointer' }}>
{isSubscriptionActive(userDetails.subscription)
? isOnFreePlan(userDetails.subscription)
? constants.FREE_SUBSCRIPTION_INFO(
@@ -61,9 +79,13 @@ export default function SubscriptionStatus({
userDetails.subscription?.expiryTime
)
: hasExceededStorageQuota(userDetails) &&
- constants.STORAGE_QUOTA_EXCEEDED_SUBSCRIPTION_INFO
+ constants.STORAGE_QUOTA_EXCEEDED_SUBSCRIPTION_INFO(
+ showPlanSelectorModal
+ )
: constants.SUBSCRIPTION_EXPIRED_MESSAGE(
- showPlanSelectorModal
+ hasStripeSubscription(userDetails.subscription)
+ ? billingService.redirectToCustomerPortal
+ : showPlanSelectorModal
)}
diff --git a/src/components/Sidebar/UtilitySection.tsx b/src/components/Sidebar/UtilitySection.tsx
index f0d2160f1f88be5fcd9763319f358d735cdc945d..37f5fd6767694ebf5e798bd0b2f79a43dcba70de 100644
--- a/src/components/Sidebar/UtilitySection.tsx
+++ b/src/components/Sidebar/UtilitySection.tsx
@@ -9,6 +9,9 @@ import { useRouter } from 'next/router';
import { AppContext } from 'pages/_app';
import { canEnableMlSearch } from 'utils/machineLearning/compatibility';
import mlIDbStorage from 'utils/storage/mlIDbStorage';
+import isElectron from 'is-electron';
+import WatchFolder from 'components/WatchFolder';
+import { getDownloadAppMessage } from 'utils/ui';
export default function UtilitySection({ closeSidebar }) {
const router = useRouter();
@@ -17,6 +20,8 @@ export default function UtilitySection({ closeSidebar }) {
startLoading,
mlSearchEnabled,
updateMlSearchEnabled,
+ watchFolderView,
+ setWatchFolderView,
} = useContext(AppContext);
const [recoverModalView, setRecoveryModalView] = useState(false);
@@ -26,8 +31,17 @@ export default function UtilitySection({ closeSidebar }) {
const openRecoveryKeyModal = () => setRecoveryModalView(true);
const closeRecoveryKeyModal = () => setRecoveryModalView(false);
- const openTwoFactorModalView = () => setTwoFactorModalView(true);
- const closeTwoFactorModalView = () => setTwoFactorModalView(false);
+ const openTwoFactorModal = () => setTwoFactorModalView(true);
+ const closeTwoFactorModal = () => setTwoFactorModalView(false);
+
+ const openWatchFolder = () => {
+ if (isElectron()) {
+ setWatchFolderView(true);
+ } else {
+ setDialogMessage(getDownloadAppMessage());
+ }
+ };
+ const closeWatchFolder = () => setWatchFolderView(false);
const redirectToChangePasswordPage = () => {
closeSidebar();
@@ -92,10 +106,15 @@ export default function UtilitySection({ closeSidebar }) {
};
return (
<>
+ {isElectron() && (
+
+ {constants.WATCH_FOLDERS}
+
+ )}
{constants.RECOVERY_KEY}
-
+
{constants.TWO_FACTOR}
@@ -157,10 +176,11 @@ export default function UtilitySection({ closeSidebar }) {
/>
+
{/*
+
}>
{constants.UPLOAD}
-
+
+
diff --git a/src/components/Upload/UploadProgress/dialog.tsx b/src/components/Upload/UploadProgress/dialog.tsx
index e802a83a0c85652f419b3e13cc47bce9a22929fa..72667ae1ca25eac71ccee2097dd741900e1cf910 100644
--- a/src/components/Upload/UploadProgress/dialog.tsx
+++ b/src/components/Upload/UploadProgress/dialog.tsx
@@ -19,19 +19,18 @@ export function UploadProgressDialog() {
const [hasUnUploadedFiles, setHasUnUploadedFiles] = useState(false);
useEffect(() => {
- if (!hasUnUploadedFiles) {
- if (
- finishedUploads.get(UPLOAD_RESULT.ALREADY_UPLOADED)?.length >
- 0 ||
- finishedUploads.get(UPLOAD_RESULT.BLOCKED)?.length > 0 ||
- finishedUploads.get(UPLOAD_RESULT.FAILED)?.length > 0 ||
- finishedUploads.get(UPLOAD_RESULT.LARGER_THAN_AVAILABLE_STORAGE)
- ?.length > 0 ||
- finishedUploads.get(UPLOAD_RESULT.TOO_LARGE)?.length > 0 ||
- finishedUploads.get(UPLOAD_RESULT.UNSUPPORTED)?.length > 0
- ) {
- setHasUnUploadedFiles(true);
- }
+ if (
+ finishedUploads.get(UPLOAD_RESULT.ALREADY_UPLOADED)?.length > 0 ||
+ finishedUploads.get(UPLOAD_RESULT.BLOCKED)?.length > 0 ||
+ finishedUploads.get(UPLOAD_RESULT.FAILED)?.length > 0 ||
+ finishedUploads.get(UPLOAD_RESULT.LARGER_THAN_AVAILABLE_STORAGE)
+ ?.length > 0 ||
+ finishedUploads.get(UPLOAD_RESULT.TOO_LARGE)?.length > 0 ||
+ finishedUploads.get(UPLOAD_RESULT.UNSUPPORTED)?.length > 0
+ ) {
+ setHasUnUploadedFiles(true);
+ } else {
+ setHasUnUploadedFiles(false);
}
}, [finishedUploads]);
diff --git a/src/components/Upload/UploadProgress/index.tsx b/src/components/Upload/UploadProgress/index.tsx
index 24bbe30bd3126a1cf90ca4d540362beedd388eda..5d538f0cb4f72d1c9e5f0166ce19bf365d7cd4e6 100644
--- a/src/components/Upload/UploadProgress/index.tsx
+++ b/src/components/Upload/UploadProgress/index.tsx
@@ -1,6 +1,6 @@
import { UploadProgressDialog } from './dialog';
import { MinimizedUploadProgress } from './minimized';
-import React, { useContext, useState } from 'react';
+import React, { useContext, useEffect, useState } from 'react';
import constants from 'utils/strings/constants';
import { UPLOAD_STAGES } from 'constants/upload';
@@ -12,6 +12,7 @@ import {
InProgressUpload,
} from 'types/upload/ui';
import UploadProgressContext from 'contexts/uploadProgress';
+import watchFolderService from 'services/watchFolder/watchFolderService';
interface Props {
open: boolean;
@@ -42,6 +43,16 @@ export default function UploadProgress({
const appContext = useContext(AppContext);
const [expanded, setExpanded] = useState(true);
+ // run watch folder minimized by default
+ useEffect(() => {
+ if (
+ appContext.isFolderSyncRunning &&
+ watchFolderService.isUploadRunning()
+ ) {
+ setExpanded(false);
+ }
+ }, [appContext.isFolderSyncRunning]);
+
function confirmCancelUpload() {
appContext.setDialogMessage({
title: constants.STOP_UPLOADS_HEADER,
diff --git a/src/components/Upload/UploadProgress/minimized.tsx b/src/components/Upload/UploadProgress/minimized.tsx
index c314dcd98e2e7098835e84259dfd558a6bd0917c..1d860083ffff05cf22a65598b984659130378225 100644
--- a/src/components/Upload/UploadProgress/minimized.tsx
+++ b/src/components/Upload/UploadProgress/minimized.tsx
@@ -1,10 +1,10 @@
import { Snackbar, Paper } from '@mui/material';
import React from 'react';
import { UploadProgressHeader } from './header';
-export function MinimizedUploadProgress(props) {
+export function MinimizedUploadProgress() {
return (
Promise;
closeCollectionSelector: () => void;
+ closeUploadTypeSelector: () => void;
setCollectionSelectorAttributes: SetCollectionSelectorAttributes;
setCollectionNamerAttributes: SetCollectionNamerAttributes;
setLoading: SetLoading;
- uploadInProgress: boolean;
- setUploadInProgress: (value: boolean) => void;
+ setShouldDisableDropzone: (value: boolean) => void;
showCollectionSelector: () => void;
setFiles: SetFiles;
+ setCollections: SetCollections;
isFirstUpload: boolean;
- electronFiles: ElectronFile[];
- setElectronFiles: (files: ElectronFile[]) => void;
- webFiles: File[];
- setWebFiles: (files: File[]) => void;
uploadTypeSelectorView: boolean;
- setUploadTypeSelectorView: (open: boolean) => void;
showSessionExpiredMessage: () => void;
showUploadFilesDialog: () => void;
showUploadDirsDialog: () => void;
+ webFolderSelectorFiles: File[];
+ webFileSelectorFiles: File[];
+ dragAndDropFiles: File[];
}
-enum UPLOAD_STRATEGY {
- SINGLE_COLLECTION,
- COLLECTION_PER_FOLDER,
-}
-
-export enum UPLOAD_TYPE {
- FILES = 'files',
- FOLDERS = 'folders',
- ZIPS = 'zips',
-}
-
-interface AnalysisResult {
- suggestedCollectionName: string;
- multipleFolders: boolean;
-}
-
-const NULL_ANALYSIS_RESULT = {
- suggestedCollectionName: '',
- multipleFolders: false,
-};
-
export default function Uploader(props: Props) {
const [uploadProgressView, setUploadProgressView] = useState(false);
- const [uploadStage, setUploadStage] = useState();
+ const [uploadStage, setUploadStage] = useState(
+ UPLOAD_STAGES.START
+ );
const [uploadFileNames, setUploadFileNames] = useState();
const [uploadCounter, setUploadCounter] = useState({
finished: 0,
@@ -97,19 +92,33 @@ export default function Uploader(props: Props) {
const [hasLivePhotos, setHasLivePhotos] = useState(false);
const [choiceModalView, setChoiceModalView] = useState(false);
- const [analysisResult, setAnalysisResult] =
- useState(NULL_ANALYSIS_RESULT);
+ const [importSuggestion, setImportSuggestion] = useState(
+ DEFAULT_IMPORT_SUGGESTION
+ );
const appContext = useContext(AppContext);
const galleryContext = useContext(GalleryContext);
const toUploadFiles = useRef(null);
const isPendingDesktopUpload = useRef(false);
const pendingDesktopUploadCollectionName = useRef('');
- const uploadType = useRef(null);
+ // This is set when the user choses a type to upload from the upload type selector dialog
+ const pickedUploadType = useRef(null);
const zipPaths = useRef(null);
+ const currentUploadPromise = useRef>(null);
+ const [electronFiles, setElectronFiles] = useState(null);
+ const [webFiles, setWebFiles] = useState([]);
+
+ const closeUploadProgress = () => setUploadProgressView(false);
+
+ const setCollectionName = (collectionName: string) => {
+ isPendingDesktopUpload.current = true;
+ pendingDesktopUploadCollectionName.current = collectionName;
+ };
+
+ const uploadRunning = useRef(false);
useEffect(() => {
- UploadManager.initUploader(
+ UploadManager.init(
{
setPercentComplete,
setUploadCounter,
@@ -128,19 +137,60 @@ export default function Uploader(props: Props) {
resumeDesktopUpload(type, electronFiles, collectionName);
}
);
+ watchFolderService.init(
+ setElectronFiles,
+ setCollectionName,
+ props.syncWithRemote,
+ appContext.setIsFolderSyncRunning
+ );
}
}, []);
+ // this handles the change of selectorFiles changes on web when user selects
+ // files for upload through the opened file/folder selector or dragAndDrop them
+ // the webFiles state is update which triggers the upload of those files
+ useEffect(() => {
+ if (appContext.watchFolderView) {
+ // if watch folder dialog is open don't catch the dropped file
+ // as they are folder being dropped for watching
+ return;
+ }
+ if (
+ pickedUploadType.current === PICKED_UPLOAD_TYPE.FOLDERS &&
+ props.webFolderSelectorFiles?.length > 0
+ ) {
+ setWebFiles(props.webFolderSelectorFiles);
+ } else if (
+ pickedUploadType.current === PICKED_UPLOAD_TYPE.FILES &&
+ props.webFileSelectorFiles?.length > 0
+ ) {
+ setWebFiles(props.webFileSelectorFiles);
+ } else if (props.dragAndDropFiles?.length > 0) {
+ setWebFiles(props.dragAndDropFiles);
+ }
+ }, [
+ props.dragAndDropFiles,
+ props.webFileSelectorFiles,
+ props.webFolderSelectorFiles,
+ ]);
+
useEffect(() => {
if (
- props.electronFiles?.length > 0 ||
- props.webFiles?.length > 0 ||
+ electronFiles?.length > 0 ||
+ webFiles?.length > 0 ||
appContext.sharedFiles?.length > 0
) {
- if (props.uploadInProgress) {
- // no-op
- // a upload is already in progress
- } else if (isCanvasBlocked()) {
+ if (uploadRunning.current) {
+ if (watchFolderService.isUploadRunning()) {
+ // pause watch folder service on user upload
+ watchFolderService.pauseRunningSync();
+ } else {
+ // no-op
+ // a user upload is already in progress
+ return;
+ }
+ }
+ if (isCanvasBlocked()) {
appContext.setDialogMessage({
title: constants.CANVAS_BLOCKED_TITLE,
@@ -152,126 +202,79 @@ export default function Uploader(props: Props) {
variant: 'accent',
},
});
- } else {
- props.setLoading(true);
- if (props.webFiles?.length > 0) {
- // File selection by drag and drop or selection of file.
- toUploadFiles.current = props.webFiles;
- props.setWebFiles([]);
- } else if (appContext.sharedFiles?.length > 0) {
- toUploadFiles.current = appContext.sharedFiles;
- appContext.resetSharedFiles();
- } else if (props.electronFiles?.length > 0) {
- // File selection from desktop app
- toUploadFiles.current = props.electronFiles;
- props.setElectronFiles([]);
- }
- const analysisResult = analyseUploadFiles();
- setAnalysisResult(analysisResult);
+ return;
+ }
+ uploadRunning.current = true;
+ props.closeUploadTypeSelector();
+ props.setLoading(true);
+ if (webFiles?.length > 0) {
+ // File selection by drag and drop or selection of file.
+ toUploadFiles.current = webFiles;
+ setWebFiles([]);
+ } else if (appContext.sharedFiles?.length > 0) {
+ toUploadFiles.current = appContext.sharedFiles;
+ appContext.resetSharedFiles();
+ } else if (electronFiles?.length > 0) {
+ // File selection from desktop app
+ toUploadFiles.current = electronFiles;
+ setElectronFiles([]);
+ }
- handleCollectionCreationAndUpload(
- analysisResult,
- props.isFirstUpload
- );
+ toUploadFiles.current = filterOutSystemFiles(toUploadFiles.current);
+ if (toUploadFiles.current.length === 0) {
props.setLoading(false);
+ return;
}
- }
- }, [props.webFiles, appContext.sharedFiles, props.electronFiles]);
- const uploadInit = function () {
- setUploadStage(UPLOAD_STAGES.START);
- setUploadCounter({ finished: 0, total: 0 });
- setInProgressUploads([]);
- setFinishedUploads(new Map());
- setPercentComplete(0);
- props.closeCollectionSelector();
- setUploadProgressView(true);
- };
+ const importSuggestion = getImportSuggestion(
+ pickedUploadType.current,
+ toUploadFiles.current
+ );
+ setImportSuggestion(importSuggestion);
+
+ handleCollectionCreationAndUpload(
+ importSuggestion,
+ props.isFirstUpload,
+ pickedUploadType.current
+ );
+ pickedUploadType.current = null;
+ props.setLoading(false);
+ }
+ }, [webFiles, appContext.sharedFiles, electronFiles]);
const resumeDesktopUpload = async (
- type: UPLOAD_TYPE,
+ type: PICKED_UPLOAD_TYPE,
electronFiles: ElectronFile[],
collectionName: string
) => {
if (electronFiles && electronFiles?.length > 0) {
isPendingDesktopUpload.current = true;
pendingDesktopUploadCollectionName.current = collectionName;
- uploadType.current = type;
- props.setElectronFiles(electronFiles);
+ pickedUploadType.current = type;
+ setElectronFiles(electronFiles);
}
};
- function analyseUploadFiles(): AnalysisResult {
- if (isElectron() && uploadType.current === UPLOAD_TYPE.FILES) {
- return NULL_ANALYSIS_RESULT;
- }
-
- const paths: string[] = toUploadFiles.current.map(
- (file) => file['path']
- );
- const getCharCount = (str: string) => (str.match(/\//g) ?? []).length;
- paths.sort((path1, path2) => getCharCount(path1) - getCharCount(path2));
- const firstPath = paths[0];
- const lastPath = paths[paths.length - 1];
-
- const L = firstPath.length;
- let i = 0;
- const firstFileFolder = firstPath.substring(
- 0,
- firstPath.lastIndexOf('/')
- );
- const lastFileFolder = lastPath.substring(0, lastPath.lastIndexOf('/'));
- while (i < L && firstPath.charAt(i) === lastPath.charAt(i)) i++;
- let commonPathPrefix = firstPath.substring(0, i);
-
- if (commonPathPrefix) {
- commonPathPrefix = commonPathPrefix.substring(
- 0,
- commonPathPrefix.lastIndexOf('/')
- );
- if (commonPathPrefix) {
- commonPathPrefix = commonPathPrefix.substring(
- commonPathPrefix.lastIndexOf('/') + 1
- );
- }
- }
- return {
- suggestedCollectionName: commonPathPrefix || null,
- multipleFolders: firstFileFolder !== lastFileFolder,
- };
- }
- function getCollectionWiseFiles() {
- const collectionWiseFiles = new Map();
- for (const file of toUploadFiles.current) {
- const filePath = file['path'] as string;
-
- let folderPath = filePath.substring(0, filePath.lastIndexOf('/'));
- if (folderPath.endsWith(METADATA_FOLDER_NAME)) {
- folderPath = folderPath.substring(
- 0,
- folderPath.lastIndexOf('/')
- );
- }
- const folderName = folderPath.substring(
- folderPath.lastIndexOf('/') + 1
- );
- if (!collectionWiseFiles.has(folderName)) {
- collectionWiseFiles.set(folderName, []);
- }
- collectionWiseFiles.get(folderName).push(file);
- }
- return collectionWiseFiles;
- }
+ const preCollectionCreationAction = async () => {
+ props.closeCollectionSelector();
+ props.setShouldDisableDropzone(!uploadManager.shouldAllowNewUpload());
+ setUploadStage(UPLOAD_STAGES.START);
+ setUploadProgressView(true);
+ };
const uploadFilesToExistingCollection = async (collection: Collection) => {
try {
+ await preCollectionCreationAction();
const filesWithCollectionToUpload: FileWithCollection[] =
toUploadFiles.current.map((file, index) => ({
file,
localID: index,
collectionID: collection.id,
}));
- await uploadFiles(filesWithCollectionToUpload, [collection]);
+ waitInQueueAndUploadFiles(filesWithCollectionToUpload, [
+ collection,
+ ]);
+ toUploadFiles.current = null;
} catch (e) {
logError(e, 'Failed to upload files to existing collections');
}
@@ -282,27 +285,41 @@ export default function Uploader(props: Props) {
collectionName?: string
) => {
try {
+ await preCollectionCreationAction();
const filesWithCollectionToUpload: FileWithCollection[] = [];
const collections: Collection[] = [];
- let collectionWiseFiles = new Map<
+ let collectionNameToFilesMap = new Map<
string,
(File | ElectronFile)[]
>();
if (strategy === UPLOAD_STRATEGY.SINGLE_COLLECTION) {
- collectionWiseFiles.set(collectionName, toUploadFiles.current);
+ collectionNameToFilesMap.set(
+ collectionName,
+ toUploadFiles.current
+ );
} else {
- collectionWiseFiles = getCollectionWiseFiles();
+ collectionNameToFilesMap = groupFilesBasedOnParentFolder(
+ toUploadFiles.current
+ );
}
try {
- const existingCollection = await syncCollections();
+ const existingCollection = getUserOwnedCollections(
+ await syncCollections()
+ );
let index = 0;
- for (const [collectionName, files] of collectionWiseFiles) {
+ for (const [
+ collectionName,
+ files,
+ ] of collectionNameToFilesMap) {
const collection = await createAlbum(
collectionName,
existingCollection
);
collections.push(collection);
-
+ props.setCollections([
+ ...existingCollection,
+ ...collections,
+ ]);
filesWithCollectionToUpload.push(
...files.map((file) => ({
localID: index++,
@@ -312,7 +329,7 @@ export default function Uploader(props: Props) {
);
}
} catch (e) {
- setUploadProgressView(false);
+ closeUploadProgress();
logError(e, 'Failed to create album');
appContext.setDialogMessage({
title: constants.ERROR,
@@ -322,64 +339,105 @@ export default function Uploader(props: Props) {
});
throw e;
}
- await uploadFiles(filesWithCollectionToUpload, collections);
+ waitInQueueAndUploadFiles(filesWithCollectionToUpload, collections);
+ toUploadFiles.current = null;
} catch (e) {
logError(e, 'Failed to upload files to new collections');
}
};
+ const waitInQueueAndUploadFiles = (
+ filesWithCollectionToUploadIn: FileWithCollection[],
+ collections: Collection[]
+ ) => {
+ const currentPromise = currentUploadPromise.current;
+ currentUploadPromise.current = waitAndRun(
+ currentPromise,
+ async () =>
+ await uploadFiles(filesWithCollectionToUploadIn, collections)
+ );
+ };
+
+ const preUploadAction = async () => {
+ uploadManager.prepareForNewUpload();
+ setUploadProgressView(true);
+ await props.syncWithRemote(true, true);
+ };
+
+ function postUploadAction() {
+ props.setShouldDisableDropzone(false);
+ uploadRunning.current = false;
+ props.syncWithRemote();
+ }
+
const uploadFiles = async (
- filesWithCollectionToUpload: FileWithCollection[],
+ filesWithCollectionToUploadIn: FileWithCollection[],
collections: Collection[]
) => {
try {
- uploadInit();
- props.setUploadInProgress(true);
- props.closeCollectionSelector();
- await props.syncWithRemote(true, true);
- if (isElectron() && !isPendingDesktopUpload.current) {
+ preUploadAction();
+ if (
+ isElectron() &&
+ !isPendingDesktopUpload.current &&
+ !watchFolderService.isUploadRunning()
+ ) {
await ImportService.setToUploadCollection(collections);
if (zipPaths.current) {
await ImportService.setToUploadFiles(
- UPLOAD_TYPE.ZIPS,
+ PICKED_UPLOAD_TYPE.ZIPS,
zipPaths.current
);
zipPaths.current = null;
}
await ImportService.setToUploadFiles(
- UPLOAD_TYPE.FILES,
- filesWithCollectionToUpload.map(
+ PICKED_UPLOAD_TYPE.FILES,
+ filesWithCollectionToUploadIn.map(
({ file }) => (file as ElectronFile).path
)
);
}
- await uploadManager.queueFilesForUpload(
- filesWithCollectionToUpload,
- collections
- );
+ const shouldCloseUploadProgress =
+ await uploadManager.queueFilesForUpload(
+ filesWithCollectionToUploadIn,
+ collections
+ );
+ if (shouldCloseUploadProgress) {
+ closeUploadProgress();
+ }
+ if (isElectron()) {
+ if (watchFolderService.isUploadRunning()) {
+ await watchFolderService.allFileUploadsDone(
+ filesWithCollectionToUploadIn,
+ collections
+ );
+ } else if (watchFolderService.isSyncPaused()) {
+ // resume the service after user upload is done
+ watchFolderService.resumePausedSync();
+ }
+ }
} catch (err) {
showUserFacingError(err.message);
- setUploadProgressView(false);
+ closeUploadProgress();
throw err;
} finally {
- props.setUploadInProgress(false);
- props.syncWithRemote();
+ postUploadAction();
}
};
const retryFailed = async () => {
try {
- props.setUploadInProgress(true);
- uploadInit();
- await props.syncWithRemote(true, true);
- await uploadManager.retryFailedFiles();
+ const filesWithCollections =
+ await uploadManager.getFailedFilesWithCollections();
+ await preUploadAction();
+ await uploadManager.queueFilesForUpload(
+ filesWithCollections.files,
+ filesWithCollections.collections
+ );
} catch (err) {
showUserFacingError(err.message);
-
- setUploadProgressView(false);
+ closeUploadProgress();
} finally {
- props.setUploadInProgress(false);
- props.syncWithRemote();
+ postUploadAction();
}
};
@@ -393,8 +451,8 @@ export default function Uploader(props: Props) {
variant: 'danger',
message: constants.SUBSCRIPTION_EXPIRED,
action: {
- text: constants.UPGRADE_NOW,
- callback: galleryContext.showPlanSelectorModal,
+ text: constants.RENEW_NOW,
+ callback: billingService.redirectToCustomerPortal,
},
};
break;
@@ -403,7 +461,7 @@ export default function Uploader(props: Props) {
variant: 'danger',
message: constants.STORAGE_QUOTA_EXCEEDED,
action: {
- text: constants.RENEW_NOW,
+ text: constants.UPGRADE_NOW,
callback: galleryContext.showPlanSelectorModal,
},
icon: ,
@@ -438,8 +496,9 @@ export default function Uploader(props: Props) {
};
const handleCollectionCreationAndUpload = (
- analysisResult: AnalysisResult,
- isFirstUpload: boolean
+ importSuggestion: ImportSuggestion,
+ isFirstUpload: boolean,
+ pickedUploadType: PICKED_UPLOAD_TYPE
) => {
if (isPendingDesktopUpload.current) {
isPendingDesktopUpload.current = false;
@@ -455,21 +514,19 @@ export default function Uploader(props: Props) {
}
return;
}
- if (isElectron() && uploadType.current === UPLOAD_TYPE.ZIPS) {
+ if (isElectron() && pickedUploadType === PICKED_UPLOAD_TYPE.ZIPS) {
uploadFilesToNewCollections(UPLOAD_STRATEGY.COLLECTION_PER_FOLDER);
return;
}
- if (isFirstUpload && !analysisResult.suggestedCollectionName) {
- analysisResult.suggestedCollectionName = FIRST_ALBUM_NAME;
+ if (isFirstUpload && !importSuggestion.rootFolderName) {
+ importSuggestion.rootFolderName = FIRST_ALBUM_NAME;
}
let showNextModal = () => {};
- if (analysisResult.multipleFolders) {
+ if (importSuggestion.hasNestedFolders) {
showNextModal = () => setChoiceModalView(true);
} else {
showNextModal = () =>
- uploadToSingleNewCollection(
- analysisResult.suggestedCollectionName
- );
+ uploadToSingleNewCollection(importSuggestion.rootFolderName);
}
props.setCollectionSelectorAttributes({
callback: uploadFilesToExistingCollection,
@@ -477,12 +534,12 @@ export default function Uploader(props: Props) {
title: constants.UPLOAD_TO_COLLECTION,
});
};
- const handleDesktopUpload = async (type: UPLOAD_TYPE) => {
+ const handleDesktopUpload = async (type: PICKED_UPLOAD_TYPE) => {
let files: ElectronFile[];
- uploadType.current = type;
- if (type === UPLOAD_TYPE.FILES) {
+ pickedUploadType.current = type;
+ if (type === PICKED_UPLOAD_TYPE.FILES) {
files = await ImportService.showUploadFilesDialog();
- } else if (type === UPLOAD_TYPE.FOLDERS) {
+ } else if (type === PICKED_UPLOAD_TYPE.FOLDERS) {
files = await ImportService.showUploadDirsDialog();
} else {
const response = await ImportService.showUploadZipDialog();
@@ -490,33 +547,26 @@ export default function Uploader(props: Props) {
zipPaths.current = response.zipPaths;
}
if (files?.length > 0) {
- props.setElectronFiles(files);
- props.setUploadTypeSelectorView(false);
+ setElectronFiles(files);
+ props.closeUploadTypeSelector();
}
};
- const handleWebUpload = async (type: UPLOAD_TYPE) => {
- uploadType.current = type;
- if (type === UPLOAD_TYPE.FILES) {
+ const handleWebUpload = async (type: PICKED_UPLOAD_TYPE) => {
+ pickedUploadType.current = type;
+ if (type === PICKED_UPLOAD_TYPE.FILES) {
props.showUploadFilesDialog();
- } else if (type === UPLOAD_TYPE.FOLDERS) {
+ } else if (type === PICKED_UPLOAD_TYPE.FOLDERS) {
props.showUploadDirsDialog();
} else {
appContext.setDialogMessage(getDownloadAppMessage());
}
};
- const cancelUploads = async () => {
- setUploadProgressView(false);
- if (isElectron()) {
- ImportService.cancelRemainingUploads();
- }
- props.setUploadInProgress(false);
- Router.reload();
+ const cancelUploads = () => {
+ uploadManager.cancelRunningUpload();
};
- const closeUploadProgress = () => setUploadProgressView(false);
-
const handleUpload = (type) => () => {
if (isElectron() && importService.checkAllElectronAPIsExists()) {
handleDesktopUpload(type);
@@ -525,11 +575,9 @@ export default function Uploader(props: Props) {
}
};
- const handleFileUpload = handleUpload(UPLOAD_TYPE.FILES);
- const handleFolderUpload = handleUpload(UPLOAD_TYPE.FOLDERS);
- const handleZipUpload = handleUpload(UPLOAD_TYPE.ZIPS);
- const closeUploadTypeSelector = () =>
- props.setUploadTypeSelectorView(false);
+ const handleFileUpload = handleUpload(PICKED_UPLOAD_TYPE.FILES);
+ const handleFolderUpload = handleUpload(PICKED_UPLOAD_TYPE.FOLDERS);
+ const handleZipUpload = handleUpload(PICKED_UPLOAD_TYPE.ZIPS);
return (
<>
@@ -537,9 +585,7 @@ export default function Uploader(props: Props) {
open={choiceModalView}
onClose={() => setChoiceModalView(false)}
uploadToSingleCollection={() =>
- uploadToSingleNewCollection(
- analysisResult.suggestedCollectionName
- )
+ uploadToSingleNewCollection(importSuggestion.rootFolderName)
}
uploadToMultipleCollection={() =>
uploadFilesToNewCollections(
@@ -549,7 +595,7 @@ export default function Uploader(props: Props) {
/>
void;
+ buttonText: string;
+}
+
+export default function VerifyMasterPasswordForm({
+ user,
+ keyAttributes,
+ callback,
+ buttonText,
+}: VerifyMasterPasswordFormProps) {
+ const verifyPassphrase: SingleInputFormProps['callback'] = async (
+ passphrase,
+ setFieldError
+ ) => {
+ try {
+ const cryptoWorker = await new CryptoWorker();
+ let kek: string = null;
+ try {
+ kek = await cryptoWorker.deriveKey(
+ passphrase,
+ keyAttributes.kekSalt,
+ keyAttributes.opsLimit,
+ keyAttributes.memLimit
+ );
+ } catch (e) {
+ logError(e, 'failed to derive key');
+ throw Error(CustomError.WEAK_DEVICE);
+ }
+ try {
+ const key: string = await cryptoWorker.decryptB64(
+ keyAttributes.encryptedKey,
+ keyAttributes.keyDecryptionNonce,
+ kek
+ );
+ callback(key, passphrase);
+ } catch (e) {
+ logError(e, 'user entered a wrong password');
+ throw Error(CustomError.INCORRECT_PASSWORD);
+ }
+ } catch (e) {
+ switch (e.message) {
+ case CustomError.WEAK_DEVICE:
+ setFieldError(constants.WEAK_DEVICE);
+ break;
+ case CustomError.INCORRECT_PASSWORD:
+ setFieldError(constants.INCORRECT_PASSPHRASE);
+ break;
+ default:
+ setFieldError(`${constants.UNKNOWN_ERROR} ${e.message}`);
+ }
+ }
+ };
+
+ return (
+
+ }
+ autoComplete={'current-password'}
+ fieldType="password"
+ />
+ );
+}
diff --git a/src/components/WatchFolder/index.tsx b/src/components/WatchFolder/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a3a93c2c138f23b6f48ae48157e3e944b98af37d
--- /dev/null
+++ b/src/components/WatchFolder/index.tsx
@@ -0,0 +1,145 @@
+import { MappingList } from './mappingList';
+import React, { useContext, useEffect, useState } from 'react';
+import { Button, Dialog, DialogContent, Stack } from '@mui/material';
+import watchFolderService from 'services/watchFolder/watchFolderService';
+import { WatchMapping } from 'types/watchFolder';
+import { AppContext } from 'pages/_app';
+import constants from 'utils/strings/constants';
+import DialogTitleWithCloseButton from 'components/DialogBox/TitleWithCloseButton';
+import UploadStrategyChoiceModal from 'components/Upload/UploadStrategyChoiceModal';
+import { UPLOAD_STRATEGY } from 'constants/upload';
+import { getImportSuggestion } from 'utils/upload';
+import electronFSService from 'services/electron/fs';
+import { PICKED_UPLOAD_TYPE } from 'constants/upload';
+
+interface Iprops {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function WatchFolder({ open, onClose }: Iprops) {
+ const [mappings, setMappings] = useState([]);
+ const [inputFolderPath, setInputFolderPath] = useState('');
+ const [choiceModalOpen, setChoiceModalOpen] = useState(false);
+ const appContext = useContext(AppContext);
+
+ useEffect(() => {
+ setMappings(watchFolderService.getWatchMappings());
+ }, []);
+
+ useEffect(() => {
+ if (
+ appContext.watchFolderFiles &&
+ appContext.watchFolderFiles.length > 0
+ ) {
+ handleFolderDrop(appContext.watchFolderFiles);
+ appContext.setWatchFolderFiles(null);
+ }
+ }, [appContext.watchFolderFiles]);
+
+ const handleFolderDrop = async (folders: FileList) => {
+ for (let i = 0; i < folders.length; i++) {
+ const folder: any = folders[i];
+ const path = (folder.path as string).replace(/\\/g, '/');
+ if (await watchFolderService.isFolder(path)) {
+ await addFolderForWatching(path);
+ }
+ }
+ };
+
+ const addFolderForWatching = async (path: string) => {
+ setInputFolderPath(path);
+ const files = await electronFSService.getDirFiles(path);
+ const analysisResult = getImportSuggestion(
+ PICKED_UPLOAD_TYPE.FOLDERS,
+ files
+ );
+ if (analysisResult.hasNestedFolders) {
+ setChoiceModalOpen(true);
+ } else {
+ handleAddWatchMapping(UPLOAD_STRATEGY.SINGLE_COLLECTION, path);
+ }
+ };
+
+ const handleAddFolderClick = async () => {
+ await handleFolderSelection();
+ };
+
+ const handleFolderSelection = async () => {
+ const folderPath = await watchFolderService.selectFolder();
+ if (folderPath) {
+ await addFolderForWatching(folderPath);
+ }
+ };
+
+ const handleAddWatchMapping = async (
+ uploadStrategy: UPLOAD_STRATEGY,
+ folderPath?: string
+ ) => {
+ folderPath = folderPath || inputFolderPath;
+ await watchFolderService.addWatchMapping(
+ folderPath.substring(folderPath.lastIndexOf('/') + 1),
+ folderPath,
+ uploadStrategy
+ );
+ setInputFolderPath('');
+ setMappings(watchFolderService.getWatchMappings());
+ };
+
+ const handleRemoveWatchMapping = async (mapping: WatchMapping) => {
+ await watchFolderService.removeWatchMapping(mapping.folderPath);
+ setMappings(watchFolderService.getWatchMappings());
+ };
+
+ const closeChoiceModal = () => setChoiceModalOpen(false);
+
+ const uploadToSingleCollection = () => {
+ closeChoiceModal();
+ handleAddWatchMapping(UPLOAD_STRATEGY.SINGLE_COLLECTION);
+ };
+
+ const uploadToMultipleCollection = () => {
+ closeChoiceModal();
+ handleAddWatchMapping(UPLOAD_STRATEGY.COLLECTION_PER_FOLDER);
+ };
+
+ return (
+ <>
+
+
+ {constants.WATCHED_FOLDERS}
+
+
+
+
+
+ +
+
+ {constants.ADD_FOLDER}
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/WatchFolder/mappingEntry/entryHeading.tsx b/src/components/WatchFolder/mappingEntry/entryHeading.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6d1912de7fd31f53fec8b87d7044d674195b357c
--- /dev/null
+++ b/src/components/WatchFolder/mappingEntry/entryHeading.tsx
@@ -0,0 +1,23 @@
+import React, { useContext } from 'react';
+import { CircularProgress, Typography } from '@mui/material';
+import watchFolderService from 'services/watchFolder/watchFolderService';
+import { AppContext } from 'pages/_app';
+import { FlexWrapper } from 'components/Container';
+import { WatchMapping } from 'types/watchFolder';
+
+interface Iprops {
+ mapping: WatchMapping;
+}
+
+export function EntryHeading({ mapping }: Iprops) {
+ const appContext = useContext(AppContext);
+ return (
+
+ {mapping.rootFolderName}
+ {appContext.isFolderSyncRunning &&
+ watchFolderService.isMappingSyncInProgress(mapping) && (
+
+ )}
+
+ );
+}
diff --git a/src/components/WatchFolder/mappingEntry/index.tsx b/src/components/WatchFolder/mappingEntry/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c1251f5ba56f5749f0741822431d0bb5c3c18bcc
--- /dev/null
+++ b/src/components/WatchFolder/mappingEntry/index.tsx
@@ -0,0 +1,65 @@
+import { EntryContainer } from '../styledComponents';
+import React from 'react';
+import { Tooltip, Typography } from '@mui/material';
+import { HorizontalFlex, SpaceBetweenFlex } from 'components/Container';
+import { WatchMapping } from 'types/watchFolder';
+import { AppContext } from 'pages/_app';
+import FolderOpenIcon from '@mui/icons-material/FolderOpen';
+import FolderCopyOutlinedIcon from '@mui/icons-material/FolderCopyOutlined';
+import constants from 'utils/strings/constants';
+import MappingEntryOptions from './mappingEntryOptions';
+import { EntryHeading } from './entryHeading';
+import { UPLOAD_STRATEGY } from 'constants/upload';
+
+interface Iprops {
+ mapping: WatchMapping;
+ handleRemoveMapping: (mapping: WatchMapping) => void;
+}
+
+export function MappingEntry({ mapping, handleRemoveMapping }: Iprops) {
+ const appContext = React.useContext(AppContext);
+
+ const stopWatching = () => {
+ handleRemoveMapping(mapping);
+ };
+
+ const confirmStopWatching = () => {
+ appContext.setDialogMessage({
+ title: constants.STOP_WATCHING_FOLDER,
+ content: constants.STOP_WATCHING_DIALOG_MESSAGE,
+ close: {
+ text: constants.CANCEL,
+ variant: 'secondary',
+ },
+ proceed: {
+ action: stopWatching,
+ text: constants.YES_STOP,
+ variant: 'danger',
+ },
+ });
+ };
+
+ return (
+
+
+ {mapping &&
+ mapping.uploadStrategy === UPLOAD_STRATEGY.SINGLE_COLLECTION ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {mapping.folderPath}
+
+
+
+
+
+ );
+}
diff --git a/src/components/WatchFolder/mappingEntry/mappingEntryOptions.tsx b/src/components/WatchFolder/mappingEntry/mappingEntryOptions.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8189a4c3d01b937f52dd003736d43c8ba47c27f4
--- /dev/null
+++ b/src/components/WatchFolder/mappingEntry/mappingEntryOptions.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import constants from 'utils/strings/constants';
+import DoNotDisturbOutlinedIcon from '@mui/icons-material/DoNotDisturbOutlined';
+import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
+import OverflowMenu from 'components/OverflowMenu/menu';
+import { OverflowMenuOption } from 'components/OverflowMenu/option';
+
+interface Iprops {
+ confirmStopWatching: () => void;
+}
+
+export default function MappingEntryOptions({ confirmStopWatching }: Iprops) {
+ return (
+
+ theme.palette.background.overPaper,
+ },
+ }}
+ ariaControls={'watch-mapping-option'}
+ triggerButtonIcon={ }>
+ }>
+ {constants.STOP_WATCHING}
+
+
+ );
+}
diff --git a/src/components/WatchFolder/mappingList/index.tsx b/src/components/WatchFolder/mappingList/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..940059c7c6d4af827d6d7db98a2d3331442cf4ad
--- /dev/null
+++ b/src/components/WatchFolder/mappingList/index.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { WatchMapping } from 'types/watchFolder';
+import { MappingEntry } from '../mappingEntry';
+import { NoMappingsContent } from './noMappingsContent/noMappingsContent';
+import { MappingsContainer } from '../styledComponents';
+interface Iprops {
+ mappings: WatchMapping[];
+ handleRemoveWatchMapping: (value: WatchMapping) => void;
+}
+
+export function MappingList({ mappings, handleRemoveWatchMapping }: Iprops) {
+ return mappings.length === 0 ? (
+
+ ) : (
+
+ {mappings.map((mapping) => {
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/WatchFolder/mappingList/noMappingsContent/checkmarkIcon.tsx b/src/components/WatchFolder/mappingList/noMappingsContent/checkmarkIcon.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6317363290fabca6f72305f6ffb5343acabfa0cf
--- /dev/null
+++ b/src/components/WatchFolder/mappingList/noMappingsContent/checkmarkIcon.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import CheckIcon from '@mui/icons-material/Check';
+
+export function CheckmarkIcon() {
+ return (
+ theme.palette.secondary.main,
+ }}
+ />
+ );
+}
diff --git a/src/components/WatchFolder/mappingList/noMappingsContent/noMappingsContent.tsx b/src/components/WatchFolder/mappingList/noMappingsContent/noMappingsContent.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..00e157d2ea9e265730c7e43998490572d2befc72
--- /dev/null
+++ b/src/components/WatchFolder/mappingList/noMappingsContent/noMappingsContent.tsx
@@ -0,0 +1,33 @@
+import { Stack, Typography } from '@mui/material';
+import React from 'react';
+import constants from 'utils/strings/constants';
+import { NoMappingsContainer } from '../../styledComponents';
+import { FlexWrapper } from 'components/Container';
+import { CheckmarkIcon } from './checkmarkIcon';
+
+export function NoMappingsContent() {
+ return (
+
+
+
+ {constants.NO_FOLDERS_ADDED}
+
+
+ {constants.FOLDERS_AUTOMATICALLY_MONITORED}
+
+
+
+
+ {constants.UPLOAD_NEW_FILES_TO_ENTE}
+
+
+
+
+
+ {constants.REMOVE_DELETED_FILES_FROM_ENTE}
+
+
+
+
+ );
+}
diff --git a/src/components/WatchFolder/styledComponents.tsx b/src/components/WatchFolder/styledComponents.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3e6d595c1416cc1dbbb393bca79a275cc821d149
--- /dev/null
+++ b/src/components/WatchFolder/styledComponents.tsx
@@ -0,0 +1,27 @@
+import {} from './../Container';
+import { Box } from '@mui/material';
+import { styled } from '@mui/material/styles';
+import VerticallyCentered from 'components/Container';
+
+export const MappingsContainer = styled(Box)(({ theme }) => ({
+ height: '278px',
+ overflow: 'auto',
+ '&::-webkit-scrollbar': {
+ width: '4px',
+ },
+ '&::-webkit-scrollbar-thumb': {
+ backgroundColor: theme.palette.secondary.main,
+ },
+}));
+
+export const NoMappingsContainer = styled(VerticallyCentered)({
+ textAlign: 'left',
+ alignItems: 'flex-start',
+ marginBottom: '32px',
+});
+
+export const EntryContainer = styled(Box)({
+ marginLeft: '12px',
+ marginRight: '6px',
+ marginBottom: '12px',
+});
diff --git a/src/components/pages/gallery/LinkButton.tsx b/src/components/pages/gallery/LinkButton.tsx
index 3bea7e64cc4fcf70b3ed8d83c7e726ab0765f0ac..a56d68214b9c78238d7223fad1af387aa0684bc6 100644
--- a/src/components/pages/gallery/LinkButton.tsx
+++ b/src/components/pages/gallery/LinkButton.tsx
@@ -39,8 +39,12 @@ const LinkButton: FC> = ({
- {constants.RENEWAL_ACTIVE_SUBSCRIPTION_INFO(
- subscription.expiryTime
- )}
+ {!isSubscriptionCancelled(subscription)
+ ? constants.RENEWAL_ACTIVE_SUBSCRIPTION_STATUS(
+ subscription.expiryTime
+ )
+ : constants.RENEWAL_CANCELLED_SUBSCRIPTION_STATUS(
+ subscription.expiryTime
+ )}
diff --git a/src/components/pages/gallery/PlanSelector/manageSubscription/index.tsx b/src/components/pages/gallery/PlanSelector/manageSubscription/index.tsx
index 89771f8f9e028b24bd04d04bbd59d149379b2018..5b9166e780e365a61f8db750ea1e3834b078d4b2 100644
--- a/src/components/pages/gallery/PlanSelector/manageSubscription/index.tsx
+++ b/src/components/pages/gallery/PlanSelector/manageSubscription/index.tsx
@@ -52,14 +52,14 @@ function StripeSubscriptionOptions({
}: Iprops) {
const appContext = useContext(AppContext);
- const confirmActivation = () =>
+ const confirmReactivation = () =>
appContext.setDialogMessage({
- title: constants.CONFIRM_ACTIVATE_SUBSCRIPTION,
- content: constants.ACTIVATE_SUBSCRIPTION_MESSAGE(
+ title: constants.REACTIVATE_SUBSCRIPTION,
+ content: constants.REACTIVATE_SUBSCRIPTION_MESSAGE(
subscription.expiryTime
),
proceed: {
- text: constants.ACTIVATE_SUBSCRIPTION,
+ text: constants.REACTIVATE_SUBSCRIPTION,
action: activateSubscription.bind(
null,
appContext.setDialogMessage,
@@ -87,7 +87,7 @@ function StripeSubscriptionOptions({
variant: 'danger',
},
close: {
- text: constants.CANCEL,
+ text: constants.NEVERMIND,
},
});
const openManagementPortal = updatePaymentMethod.bind(
@@ -100,8 +100,8 @@ function StripeSubscriptionOptions({
{isSubscriptionCancelled(subscription) ? (
- {constants.ACTIVATE_SUBSCRIPTION}
+ onClick={confirmReactivation}>
+ {constants.REACTIVATE_SUBSCRIPTION}
) : (
void;
closeMessageDialog: () => void;
setDialogMessage: SetDialogBoxAttributes;
+ isFolderSyncRunning: boolean;
+ setIsFolderSyncRunning: (isRunning: boolean) => void;
+ watchFolderView: boolean;
+ setWatchFolderView: (isOpen: boolean) => void;
+ watchFolderFiles: FileList;
+ setWatchFolderFiles: (files: FileList) => void;
isMobile: boolean;
};
@@ -97,6 +103,9 @@ export default function App({ Component, err }) {
const loadingBar = useRef(null);
const [dialogMessage, setDialogMessage] = useState();
const [messageDialogView, setMessageDialogView] = useState(false);
+ const [isFolderSyncRunning, setIsFolderSyncRunning] = useState(false);
+ const [watchFolderView, setWatchFolderView] = useState(false);
+ const [watchFolderFiles, setWatchFolderFiles] = useState(null);
const isMobile = useMediaQuery('(max-width:428px)');
useEffect(() => {
@@ -170,8 +179,7 @@ export default function App({ Component, err }) {
typeof redirectMap.get(redirect) === 'function'
) {
const redirectAction = redirectMap.get(redirect);
- const url = await redirectAction();
- window.location.href = url;
+ window.location.href = await redirectAction();
} else {
logError(CustomError.BAD_REQUEST, 'invalid redirection', {
redirect,
@@ -311,6 +319,12 @@ export default function App({ Component, err }) {
finishLoading,
closeMessageDialog,
setDialogMessage,
+ isFolderSyncRunning,
+ setIsFolderSyncRunning,
+ watchFolderView,
+ setWatchFolderView,
+ watchFolderFiles,
+ setWatchFolderFiles,
isMobile,
}}>
{loading ? (
diff --git a/src/pages/credentials/index.tsx b/src/pages/credentials/index.tsx
index 59407498afa64fa2c8c70c2516b47fc5c737c515..bd45a448146e5e8911443d701544eb83420d094b 100644
--- a/src/pages/credentials/index.tsx
+++ b/src/pages/credentials/index.tsx
@@ -5,16 +5,13 @@ import { clearData, getData, LS_KEYS } from 'utils/storage/localStorage';
import { useRouter } from 'next/router';
import { PAGES } from 'constants/pages';
import { SESSION_KEYS, getKey } from 'utils/storage/sessionStorage';
-import CryptoWorker, {
+import {
decryptAndStoreToken,
generateAndSaveIntermediateKeyAttributes,
saveKeyInSessionStore,
} from 'utils/crypto';
import { logoutUser } from 'services/userService';
import { isFirstLogin } from 'utils/storage';
-import SingleInputForm, {
- SingleInputFormProps,
-} from 'components/SingleInputForm';
import { AppContext } from 'pages/_app';
import { logError } from 'utils/sentry';
import { eventBus, Events } from 'services/events';
@@ -24,18 +21,20 @@ import FormPaper from 'components/Form/FormPaper';
import FormPaperTitle from 'components/Form/FormPaper/Title';
import FormPaperFooter from 'components/Form/FormPaper/Footer';
import LinkButton from 'components/pages/gallery/LinkButton';
-import { CustomError } from 'utils/error';
import isElectron from 'is-electron';
import safeStorageService from 'services/electron/safeStorage';
import VerticallyCentered from 'components/Container';
import EnteSpinner from 'components/EnteSpinner';
-import { Input } from '@mui/material';
+import VerifyMasterPasswordForm, {
+ VerifyMasterPasswordFormProps,
+} from 'components/VerifyMasterPasswordForm';
export default function Credentials() {
const router = useRouter();
const [keyAttributes, setKeyAttributes] = useState();
const appContext = useContext(AppContext);
const [user, setUser] = useState();
+
useEffect(() => {
router.prefetch(PAGES.GALLERY);
const main = async () => {
@@ -71,66 +70,32 @@ export default function Credentials() {
appContext.showNavBar(true);
}, []);
- const verifyPassphrase: SingleInputFormProps['callback'] = async (
- passphrase,
- setFieldError
+ const useMasterPassword: VerifyMasterPasswordFormProps['callback'] = async (
+ key,
+ passphrase
) => {
try {
- const cryptoWorker = await new CryptoWorker();
- let kek: string = null;
- try {
- kek = await cryptoWorker.deriveKey(
+ if (isFirstLogin()) {
+ await generateAndSaveIntermediateKeyAttributes(
passphrase,
- keyAttributes.kekSalt,
- keyAttributes.opsLimit,
- keyAttributes.memLimit
+ keyAttributes,
+ key
);
- } catch (e) {
- logError(e, 'failed to derive key');
- throw Error(CustomError.WEAK_DEVICE);
+ // TODO: not required after reseting appContext on first login
+ appContext.updateMlSearchEnabled(false);
}
try {
- const key: string = await cryptoWorker.decryptB64(
- keyAttributes.encryptedKey,
- keyAttributes.keyDecryptionNonce,
- kek
- );
-
- if (isFirstLogin()) {
- await generateAndSaveIntermediateKeyAttributes(
- passphrase,
- keyAttributes,
- key
- );
- // TODO: not required after reseting appContext on first login
- appContext.updateMlSearchEnabled(false);
- }
- await saveKeyInSessionStore(SESSION_KEYS.ENCRYPTION_KEY, key);
- await decryptAndStoreToken(key);
- const redirectURL = appContext.redirectURL;
- appContext.setRedirectURL(null);
- router.push(redirectURL ?? PAGES.GALLERY);
-
- try {
- eventBus.emit(Events.LOGIN);
- } catch (e) {
- logError(e, 'Error in login handlers');
- }
+ eventBus.emit(Events.LOGIN);
} catch (e) {
- logError(e, 'user entered a wrong password');
- throw Error(CustomError.INCORRECT_PASSWORD);
+ logError(e, 'Error in login handlers');
}
+ await saveKeyInSessionStore(SESSION_KEYS.ENCRYPTION_KEY, key);
+ await decryptAndStoreToken(key);
+ const redirectURL = appContext.redirectURL;
+ appContext.setRedirectURL(null);
+ router.push(redirectURL ?? PAGES.GALLERY);
} catch (e) {
- switch (e.message) {
- case CustomError.WEAK_DEVICE:
- setFieldError(constants.WEAK_DEVICE);
- break;
- case CustomError.INCORRECT_PASSWORD:
- setFieldError(constants.INCORRECT_PASSPHRASE);
- break;
- default:
- setFieldError(`${constants.UNKNOWN_ERROR} ${e.message}`);
- }
+ logError(e, 'useMasterPassword failed');
}
};
@@ -148,24 +113,13 @@ export default function Credentials() {
{constants.PASSWORD}
-
- }
- autoComplete={'current-password'}
- fieldType="password"
+ callback={useMasterPassword}
+ user={user}
+ keyAttributes={keyAttributes}
/>
-
{constants.FORGOT_PASSWORD}
diff --git a/src/pages/gallery/index.tsx b/src/pages/gallery/index.tsx
index 9b3a4db090851e13a80d60b557b3adcdbf5c5f51..08baf3c359c3023596ed66787014167c91271372 100644
--- a/src/pages/gallery/index.tsx
+++ b/src/pages/gallery/index.tsx
@@ -73,7 +73,7 @@ import {
getSelectedCollection,
isFavoriteCollection,
getArchivedCollections,
- hasNonEmptyCollections,
+ hasNonSystemCollections,
} from 'utils/collection';
import { logError } from 'utils/sentry';
import {
@@ -90,7 +90,6 @@ import { EnteFile } from 'types/file';
import { GalleryContextType, SelectedState } from 'types/gallery';
import { VISIBILITY_STATE } from 'types/magicMetadata';
import Notification from 'components/Notification';
-import { ElectronFile } from 'types/upload';
import Collections from 'components/Collections';
import { GalleryNavbar } from 'components/pages/gallery/Navbar';
import { Search, SearchResultSummary, UpdateSearch } from 'types/search';
@@ -150,7 +149,8 @@ export default function Gallery() {
useState(null);
const [collectionNamerView, setCollectionNamerView] = useState(false);
const [search, setSearch] = useState(null);
- const [uploadInProgress, setUploadInProgress] = useState(false);
+ const [shouldDisableDropzone, setShouldDisableDropzone] = useState(false);
+
const {
getRootProps: getDragAndDropRootProps,
getInputProps: getDragAndDropInputProps,
@@ -158,17 +158,17 @@ export default function Gallery() {
} = useDropzone({
noClick: true,
noKeyboard: true,
- disabled: uploadInProgress,
+ disabled: shouldDisableDropzone,
});
const {
- selectedFiles: fileSelectorFiles,
+ selectedFiles: webFileSelectorFiles,
open: openFileSelector,
getInputProps: getFileSelectorInputProps,
} = useFileInput({
directory: false,
});
const {
- selectedFiles: folderSelectorFiles,
+ selectedFiles: webFolderSelectorFiles,
open: openFolderSelector,
getInputProps: getFolderSelectorInputProps,
} = useFileInput({
@@ -202,8 +202,6 @@ export default function Gallery() {
const showPlanSelectorModal = () => setPlanModalView(true);
- const [electronFiles, setElectronFiles] = useState(null);
- const [webFiles, setWebFiles] = useState([]);
const [uploadTypeSelectorView, setUploadTypeSelectorView] = useState(false);
const [sidebarView, setSidebarView] = useState(false);
@@ -285,16 +283,6 @@ export default function Gallery() {
[notificationAttributes]
);
- useEffect(() => {
- if (dragAndDropFiles?.length > 0) {
- setWebFiles(dragAndDropFiles);
- } else if (folderSelectorFiles?.length > 0) {
- setWebFiles(folderSelectorFiles);
- } else if (fileSelectorFiles?.length > 0) {
- setWebFiles(fileSelectorFiles);
- }
- }, [dragAndDropFiles, fileSelectorFiles, folderSelectorFiles]);
-
useEffect(() => {
if (typeof activeCollection === 'undefined') {
return;
@@ -575,7 +563,9 @@ export default function Gallery() {
setSetSearchResultSummary(null);
};
- const openUploader = () => setUploadTypeSelectorView(true);
+ const openUploader = () => {
+ setUploadTypeSelectorView(true);
+ };
return (
{
if (!favCollection) {
throw Error(CustomError.FAV_COLLECTION_MISSING);
}
- await removeFromCollection(favCollection, [file]);
+ await removeFromCollection(favCollection.id, [file]);
} catch (e) {
logError(e, 'remove from favorite failed');
}
@@ -470,13 +474,13 @@ const encryptWithNewCollectionKey = async (
return fileKeysEncryptedWithNewCollection;
};
export const removeFromCollection = async (
- collection: Collection,
+ collectionID: number,
files: EnteFile[]
) => {
try {
const token = getToken();
const request: RemoveFromCollectionRequest = {
- collectionID: collection.id,
+ collectionID: collectionID,
fileIDs: files.map((file) => file.id),
};
@@ -780,8 +784,10 @@ export function getCollectionSummaries(
files,
archivedCollections
);
- const collectionFilesCount = getCollectionsFileCount(files);
- const uniqueFileCount = new Set(files.map((file) => file.id)).size;
+ const collectionFilesCount = getCollectionsFileCount(
+ files,
+ archivedCollections
+ );
for (const collection of collections) {
if (collectionFilesCount.get(collection.id)) {
@@ -802,12 +808,7 @@ export function getCollectionSummaries(
}
collectionSummaries.set(
ALL_SECTION,
- getAllCollectionSummaries(
- collectionFilesCount,
- collectionLatestFiles,
- uniqueFileCount,
- archivedCollections
- )
+ getAllCollectionSummaries(collectionFilesCount, collectionLatestFiles)
);
collectionSummaries.set(
ARCHIVE_SECTION,
@@ -827,44 +828,47 @@ export function getCollectionSummaries(
return collectionSummaries;
}
-function getCollectionsFileCount(files: EnteFile[]): CollectionFilesCount {
- const collectionWiseFiles = sortFilesIntoCollections(files);
+function getCollectionsFileCount(
+ files: EnteFile[],
+ archivedCollections: Set
+): CollectionFilesCount {
+ const collectionIDToFileMap = groupFilesBasedOnCollectionID(files);
const collectionFilesCount = new Map();
- for (const [id, files] of collectionWiseFiles) {
+ for (const [id, files] of collectionIDToFileMap) {
collectionFilesCount.set(id, files.length);
}
+ const user: User = getData(LS_KEYS.USER);
+ const uniqueTrashedFileIDs = new Set();
+ const uniqueArchivedFileIDs = new Set();
+ const uniqueAllSectionFileIDs = new Set();
+ for (const file of files) {
+ if (isSharedFile(user, file)) {
+ continue;
+ }
+ if (file.isTrashed) {
+ uniqueTrashedFileIDs.add(file.id);
+ } else if (IsArchived(file)) {
+ uniqueArchivedFileIDs.add(file.id);
+ } else if (!archivedCollections.has(file.collectionID)) {
+ uniqueAllSectionFileIDs.add(file.id);
+ }
+ }
+ collectionFilesCount.set(TRASH_SECTION, uniqueTrashedFileIDs.size);
+ collectionFilesCount.set(ARCHIVE_SECTION, uniqueArchivedFileIDs.size);
+ collectionFilesCount.set(ALL_SECTION, uniqueAllSectionFileIDs.size);
return collectionFilesCount;
}
function getAllCollectionSummaries(
collectionFilesCount: CollectionFilesCount,
- collectionsLatestFile: CollectionLatestFiles,
- uniqueFileCount: number,
- archivedCollections: Set
+ collectionsLatestFile: CollectionLatestFiles
): CollectionSummary {
- const archivedSectionFileCount =
- collectionFilesCount.get(ARCHIVE_SECTION) ?? 0;
- const trashSectionFileCount = collectionFilesCount.get(TRASH_SECTION) ?? 0;
-
- const archivedCollectionsFileCount = 0;
- for (const [id, fileCount] of collectionFilesCount.entries()) {
- if (archivedCollections.has(id)) {
- archivedCollectionsFileCount + fileCount;
- }
- }
-
- const allSectionFileCount =
- uniqueFileCount -
- (archivedSectionFileCount +
- trashSectionFileCount +
- archivedCollectionsFileCount);
-
return {
id: ALL_SECTION,
name: constants.ALL_SECTION_NAME,
type: CollectionSummaryType.all,
latestFile: collectionsLatestFile.get(ALL_SECTION),
- fileCount: allSectionFileCount,
+ fileCount: collectionFilesCount.get(ALL_SECTION) || 0,
updationTime: collectionsLatestFile.get(ALL_SECTION)?.updationTime,
};
}
diff --git a/src/services/deduplicationService.ts b/src/services/deduplicationService.ts
index 4a9d5311ad5c94ce39a833fbce772bbeafaa2dae..9ab0e0b12e086556aa96f751da8d0667bc51b4b2 100644
--- a/src/services/deduplicationService.ts
+++ b/src/services/deduplicationService.ts
@@ -16,16 +16,6 @@ interface DuplicatesResponse {
}>;
}
-const DuplicateItemSortingOrderDescBasedOnCollectionName = Object.fromEntries([
- ['icloud library', 0],
- ['icloudlibrary', 1],
- ['recents', 2],
- ['recently added', 3],
- ['my photo stream', 4],
-]);
-
-const OtherCollectionNameRanking = 5;
-
interface DuplicateFiles {
files: EnteFile[];
size: number;
@@ -228,15 +218,7 @@ async function sortDuplicateFiles(
const secondCollectionName = collectionNameMap
.get(secondFile.collectionID)
.toLocaleLowerCase();
- const firstFileRanking =
- DuplicateItemSortingOrderDescBasedOnCollectionName[
- firstCollectionName
- ] ?? OtherCollectionNameRanking;
- const secondFileRanking =
- DuplicateItemSortingOrderDescBasedOnCollectionName[
- secondCollectionName
- ] ?? OtherCollectionNameRanking;
- return secondFileRanking - firstFileRanking;
+ return firstCollectionName.localeCompare(secondCollectionName);
});
}
diff --git a/src/services/electron/cache.ts b/src/services/electron/cache.ts
index d1bc519915abe668542700597be3632955c7eebc..46e1aefa874dd8fbc685c397ade972429755b60f 100644
--- a/src/services/electron/cache.ts
+++ b/src/services/electron/cache.ts
@@ -1,23 +1,24 @@
import { LimitedCache, LimitedCacheStorage } from 'types/cache';
+import { ElectronAPIs } from 'types/electron';
class ElectronCacheStorageService implements LimitedCacheStorage {
- private ElectronAPIs: any;
+ private electronAPIs: ElectronAPIs;
private allElectronAPIsExist: boolean = false;
constructor() {
- this.ElectronAPIs = globalThis['ElectronAPIs'];
- this.allElectronAPIsExist = !!this.ElectronAPIs?.openDiskCache;
+ this.electronAPIs = globalThis['ElectronAPIs'];
+ this.allElectronAPIsExist = !!this.electronAPIs?.openDiskCache;
}
async open(cacheName: string): Promise {
if (this.allElectronAPIsExist) {
- return await this.ElectronAPIs.openDiskCache(cacheName);
+ return await this.electronAPIs.openDiskCache(cacheName);
}
}
async delete(cacheName: string): Promise {
if (this.allElectronAPIsExist) {
- return await this.ElectronAPIs.deleteDiskCache(cacheName);
+ return await this.electronAPIs.deleteDiskCache(cacheName);
}
}
}
diff --git a/src/services/electron/common.ts b/src/services/electron/common.ts
index f1d6d7eb202673f44dc9d10201e50898922c3541..7b0d65854ff11481518fab3a6891afb4edbbf70c 100644
--- a/src/services/electron/common.ts
+++ b/src/services/electron/common.ts
@@ -1,12 +1,13 @@
import isElectron from 'is-electron';
+import { ElectronAPIs } from 'types/electron';
class ElectronService {
- private ElectronAPIs: any;
+ private electronAPIs: ElectronAPIs;
private isBundledApp: boolean = false;
constructor() {
- this.ElectronAPIs = globalThis['ElectronAPIs'];
- this.isBundledApp = !!this.ElectronAPIs?.openDiskCache;
+ this.electronAPIs = globalThis['ElectronAPIs'];
+ this.isBundledApp = !!this.electronAPIs?.openDiskCache;
}
checkIsBundledApp() {
diff --git a/src/services/electron/fs.ts b/src/services/electron/fs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a7b639dd84189e46e3cd2257c3a902c7a4bd4bf5
--- /dev/null
+++ b/src/services/electron/fs.ts
@@ -0,0 +1,28 @@
+import { ElectronAPIs } from 'types/electron';
+import { runningInBrowser } from 'utils/common';
+import { logError } from 'utils/sentry';
+
+class ElectronFSService {
+ private electronAPIs: ElectronAPIs;
+
+ constructor() {
+ this.electronAPIs = runningInBrowser() && window['ElectronAPIs'];
+ }
+
+ getDirFiles(dirPath: string) {
+ if (this.electronAPIs.getDirFiles) {
+ return this.electronAPIs.getDirFiles(dirPath);
+ }
+ }
+
+ async isFolder(folderPath: string) {
+ try {
+ const isFolder = await this.electronAPIs.isFolder(folderPath);
+ return isFolder;
+ } catch (e) {
+ logError(e, 'error while checking if is Folder');
+ }
+ }
+}
+
+export default new ElectronFSService();
diff --git a/src/services/electron/safeStorage.tsx b/src/services/electron/safeStorage.tsx
index 321c774c0b5dacd6a249380d38b1bac874137d37..3d80ef79ca14104737eebe76d0862544f80e5f5c 100644
--- a/src/services/electron/safeStorage.tsx
+++ b/src/services/electron/safeStorage.tsx
@@ -1,17 +1,18 @@
+import { ElectronAPIs } from 'types/electron';
import { logError } from 'utils/sentry';
class SafeStorageService {
- private ElectronAPIs: any;
+ private electronAPIs: ElectronAPIs;
private allElectronAPIsExist: boolean = false;
constructor() {
- this.ElectronAPIs = globalThis['ElectronAPIs'];
- this.allElectronAPIsExist = !!this.ElectronAPIs?.getEncryptionKey;
+ this.electronAPIs = globalThis['ElectronAPIs'];
+ this.allElectronAPIsExist = !!this.electronAPIs?.getEncryptionKey;
}
async getEncryptionKey() {
try {
if (this.allElectronAPIsExist) {
- return (await this.ElectronAPIs.getEncryptionKey()) as string;
+ return (await this.electronAPIs.getEncryptionKey()) as string;
}
} catch (e) {
logError(e, 'getEncryptionKey failed');
@@ -21,7 +22,7 @@ class SafeStorageService {
async setEncryptionKey(encryptionKey: string) {
try {
if (this.allElectronAPIsExist) {
- return await this.ElectronAPIs.setEncryptionKey(encryptionKey);
+ return await this.electronAPIs.setEncryptionKey(encryptionKey);
}
} catch (e) {
logError(e, 'setEncryptionKey failed');
@@ -31,7 +32,7 @@ class SafeStorageService {
async clearElectronStore() {
try {
if (this.allElectronAPIsExist) {
- return await this.ElectronAPIs.clearElectronStore();
+ return this.electronAPIs.clearElectronStore();
}
} catch (e) {
logError(e, 'clearElectronStore failed');
diff --git a/src/services/exportService.ts b/src/services/exportService.ts
index 5dde53f1a7a071431590622b5cede619c9d03aba..3058858a0707163d0931f29f35284b4bb0218bb4 100644
--- a/src/services/exportService.ts
+++ b/src/services/exportService.ts
@@ -49,12 +49,13 @@ import {
import { User } from 'types/user';
import { FILE_TYPE, TYPE_JPEG, TYPE_JPG } from 'constants/file';
import { ExportType, ExportNotification, RecordType } from 'constants/export';
+import { ElectronAPIs } from 'types/electron';
const LATEST_EXPORT_VERSION = 1;
const EXPORT_RECORD_FILE_NAME = 'export_status.json';
class ExportService {
- ElectronAPIs: any;
+ electronAPIs: ElectronAPIs;
private exportInProgress: Promise<{ paused: boolean }> = null;
private exportRecordUpdater = new QueueProcessor(1);
@@ -64,12 +65,12 @@ class ExportService {
private fileReader: FileReader = null;
constructor() {
- this.ElectronAPIs = runningInBrowser() && window['ElectronAPIs'];
- this.allElectronAPIsExist = !!this.ElectronAPIs?.exists;
+ this.electronAPIs = runningInBrowser() && window['ElectronAPIs'];
+ this.allElectronAPIsExist = !!this.electronAPIs?.exists;
}
async selectExportDirectory() {
try {
- return await this.ElectronAPIs.selectRootDirectory();
+ return await this.electronAPIs.selectRootDirectory();
} catch (e) {
logError(e, 'failed to selectExportDirectory ');
throw e;
@@ -88,12 +89,12 @@ class ExportService {
) {
try {
if (this.exportInProgress) {
- this.ElectronAPIs.sendNotification(
+ this.electronAPIs.sendNotification(
ExportNotification.IN_PROGRESS
);
return await this.exportInProgress;
}
- this.ElectronAPIs.showOnTray('starting export');
+ this.electronAPIs.showOnTray('starting export');
const exportDir = getData(LS_KEYS.EXPORT)?.folder;
if (!exportDir) {
// no-export folder set
@@ -198,7 +199,7 @@ class ExportService {
);
}
if (!files?.length) {
- this.ElectronAPIs.sendNotification(
+ this.electronAPIs.sendNotification(
ExportNotification.UP_TO_DATE
);
return { paused: false };
@@ -208,19 +209,19 @@ class ExportService {
this.addFilesQueuedRecord(exportDir, files);
const failedFileCount = 0;
- this.ElectronAPIs.showOnTray({
+ this.electronAPIs.showOnTray({
export_progress: `0 / ${files.length} files exported`,
});
updateProgress({
current: 0,
total: files.length,
});
- this.ElectronAPIs.sendNotification(ExportNotification.START);
+ this.electronAPIs.sendNotification(ExportNotification.START);
for (const [index, file] of files.entries()) {
if (this.stopExport || this.pauseExport) {
if (this.pauseExport) {
- this.ElectronAPIs.showOnTray({
+ this.electronAPIs.showOnTray({
export_progress: `${index} / ${files.length} files exported (paused)`,
paused: true,
});
@@ -252,7 +253,7 @@ class ExportService {
'download and save failed for file during export'
);
}
- this.ElectronAPIs.showOnTray({
+ this.electronAPIs.showOnTray({
export_progress: `${index + 1} / ${
files.length
} files exported`,
@@ -260,19 +261,19 @@ class ExportService {
updateProgress({ current: index + 1, total: files.length });
}
if (this.stopExport) {
- this.ElectronAPIs.sendNotification(ExportNotification.ABORT);
- this.ElectronAPIs.showOnTray();
+ this.electronAPIs.sendNotification(ExportNotification.ABORT);
+ this.electronAPIs.showOnTray();
} else if (this.pauseExport) {
- this.ElectronAPIs.sendNotification(ExportNotification.PAUSE);
+ this.electronAPIs.sendNotification(ExportNotification.PAUSE);
return { paused: true };
} else if (failedFileCount > 0) {
- this.ElectronAPIs.sendNotification(ExportNotification.FAILED);
- this.ElectronAPIs.showOnTray({
+ this.electronAPIs.sendNotification(ExportNotification.FAILED);
+ this.electronAPIs.showOnTray({
retry_export: `export failed - retry export`,
});
} else {
- this.ElectronAPIs.sendNotification(ExportNotification.FINISH);
- this.ElectronAPIs.showOnTray();
+ this.electronAPIs.sendNotification(ExportNotification.FINISH);
+ this.electronAPIs.showOnTray();
}
return { paused: false };
} catch (e) {
@@ -349,7 +350,7 @@ class ExportService {
}
const exportRecord = await this.getExportRecord(folder);
const newRecord = { ...exportRecord, ...newData };
- await this.ElectronAPIs.setExportRecord(
+ await this.electronAPIs.setExportRecord(
`${folder}/${EXPORT_RECORD_FILE_NAME}`,
JSON.stringify(newRecord, null, 2)
);
@@ -363,7 +364,7 @@ class ExportService {
if (!folder) {
folder = getData(LS_KEYS.EXPORT)?.folder;
}
- const recordFile = await this.ElectronAPIs.getExportRecord(
+ const recordFile = await this.electronAPIs.getExportRecord(
`${folder}/${EXPORT_RECORD_FILE_NAME}`
);
if (recordFile) {
@@ -386,10 +387,10 @@ class ExportService {
exportFolder,
collection
);
- await this.ElectronAPIs.checkExistsAndCreateCollectionDir(
+ await this.electronAPIs.checkExistsAndCreateCollectionDir(
collectionFolderPath
);
- await this.ElectronAPIs.checkExistsAndCreateCollectionDir(
+ await this.electronAPIs.checkExistsAndCreateCollectionDir(
getMetadataFolderPath(collectionFolderPath)
);
await this.addCollectionExportedRecord(
@@ -414,7 +415,7 @@ class ExportService {
exportFolder,
collection
);
- await this.ElectronAPIs.checkExistsAndRename(
+ await this.electronAPIs.checkExistsAndRename(
oldCollectionFolderPath,
newCollectionFolderPath
);
@@ -505,7 +506,7 @@ class ExportService {
fileSaveName: string,
fileStream: ReadableStream
) {
- this.ElectronAPIs.saveStreamToDisk(
+ this.electronAPIs.saveStreamToDisk(
getFileSavePath(collectionFolderPath, fileSaveName),
fileStream
);
@@ -515,7 +516,7 @@ class ExportService {
fileSaveName: string,
metadata: Metadata
) {
- await this.ElectronAPIs.saveFileToDisk(
+ await this.electronAPIs.saveFileToDisk(
getFileMetadataSavePath(collectionFolderPath, fileSaveName),
getGoogleLikeMetadataFile(fileSaveName, metadata)
);
@@ -526,7 +527,7 @@ class ExportService {
};
exists = (path: string) => {
- return this.ElectronAPIs.exists(path);
+ return this.electronAPIs.exists(path);
};
checkAllElectronAPIsExists = () => this.allElectronAPIsExist;
@@ -583,8 +584,8 @@ class ExportService {
collection
);
collectionIDPathMap.set(collection.id, newCollectionFolderPath);
- if (this.ElectronAPIs.exists(oldCollectionFolderPath)) {
- await this.ElectronAPIs.checkExistsAndRename(
+ if (this.electronAPIs.exists(oldCollectionFolderPath)) {
+ await this.electronAPIs.checkExistsAndRename(
oldCollectionFolderPath,
newCollectionFolderPath
);
@@ -630,12 +631,12 @@ class ExportService {
collectionIDPathMap.get(file.collectionID),
newFileSaveName
);
- await this.ElectronAPIs.checkExistsAndRename(
+ await this.electronAPIs.checkExistsAndRename(
oldFileSavePath,
newFileSavePath
);
console.log(oldFileMetadataSavePath, newFileMetadataSavePath);
- await this.ElectronAPIs.checkExistsAndRename(
+ await this.electronAPIs.checkExistsAndRename(
oldFileMetadataSavePath,
newFileMetadataSavePath
);
diff --git a/src/services/ffmpeg/ffmpegClient.ts b/src/services/ffmpeg/ffmpegClient.ts
index 7aa7a29b042eeb9cd8504d0165e1150a1751c48b..cd48487446a92193445ae6231567cb4d341992dc 100644
--- a/src/services/ffmpeg/ffmpegClient.ts
+++ b/src/services/ffmpeg/ffmpegClient.ts
@@ -1,6 +1,9 @@
import { createFFmpeg, FFmpeg } from 'ffmpeg-wasm';
import { getUint8ArrayView } from 'services/readerService';
-import { parseFFmpegExtractedMetadata } from 'utils/ffmpeg';
+import {
+ parseFFmpegExtractedMetadata,
+ splitFilenameAndExtension,
+} from 'utils/ffmpeg';
class FFmpegClient {
private ffmpeg: FFmpeg;
@@ -22,7 +25,9 @@ class FFmpegClient {
async generateThumbnail(file: File) {
await this.ready;
- const inputFileName = `${Date.now().toString()}-${file.name}`;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [_, ext] = splitFilenameAndExtension(file.name);
+ const inputFileName = `${Date.now().toString()}-input.${ext}`;
const thumbFileName = `${Date.now().toString()}-thumb.jpeg`;
this.ffmpeg.FS(
'writeFile',
@@ -57,7 +62,9 @@ class FFmpegClient {
async extractVideoMetadata(file: File) {
await this.ready;
- const inputFileName = `${Date.now().toString()}-${file.name}`;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [_, ext] = splitFilenameAndExtension(file.name);
+ const inputFileName = `${Date.now().toString()}-input.${ext}`;
const outFileName = `${Date.now().toString()}-metadata.txt`;
this.ffmpeg.FS(
'writeFile',
diff --git a/src/services/ffmpeg/ffmpegService.ts b/src/services/ffmpeg/ffmpegService.ts
index cddb2075d5752f8b1787549452db6fdcb91a7ced..889eabda153d9cbcb8ad9bc1bc645251476b2f6a 100644
--- a/src/services/ffmpeg/ffmpegService.ts
+++ b/src/services/ffmpeg/ffmpegService.ts
@@ -6,7 +6,8 @@ import { ParsedExtractedMetadata } from 'types/upload';
import { FFmpegWorker } from 'utils/comlink';
import { promiseWithTimeout } from 'utils/common';
-const FFMPEG_EXECUTION_WAIT_TIME = 10 * 1000;
+const FFMPEG_EXECUTION_WAIT_TIME = 30 * 1000;
+
class FFmpegService {
private ffmpegWorker = null;
private ffmpegTaskQueue = new QueueProcessor(1);
diff --git a/src/services/fileService.ts b/src/services/fileService.ts
index 4fc2f3e4773e3d7e50fe0a83efabd67ee43fb240..1bafa70be7b6b40be682697017172fdecaf67dbd 100644
--- a/src/services/fileService.ts
+++ b/src/services/fileService.ts
@@ -29,7 +29,7 @@ export const getLocalFiles = async () => {
return files;
};
-export const setLocalFiles = async (files: EnteFile[]) => {
+const setLocalFiles = async (files: EnteFile[]) => {
try {
await localForage.setItem(FILES_TABLE, files);
try {
@@ -211,16 +211,35 @@ export const deleteFromTrash = async (filesToDelete: number[]) => {
if (!token) {
return;
}
+ let deleteBatch: number[] = [];
+ for (const fileID of filesToDelete) {
+ deleteBatch.push(fileID);
+ if (deleteBatch.length >= MAX_TRASH_BATCH_SIZE) {
+ await deleteBatchFromTrash(token, deleteBatch);
+ deleteBatch = [];
+ }
+ }
+ if (deleteBatch.length > 0) {
+ await deleteBatchFromTrash(token, deleteBatch);
+ }
+ } catch (e) {
+ logError(e, 'deleteFromTrash failed');
+ throw e;
+ }
+};
+
+const deleteBatchFromTrash = async (token: string, deleteBatch: number[]) => {
+ try {
await HTTPService.post(
`${ENDPOINT}/trash/delete`,
- { fileIDs: filesToDelete },
+ { fileIDs: deleteBatch },
null,
{
'X-Auth-Token': token,
}
);
} catch (e) {
- logError(e, 'delete from trash failed');
+ logError(e, 'deleteBatchFromTrash failed');
throw e;
}
};
diff --git a/src/services/importService.ts b/src/services/importService.ts
index 511599a28d0d1ddc19c0c22f7fc4843dfa1fe443..e9d35eb231e0ce8d1bfbf207335413c7820ac701 100644
--- a/src/services/importService.ts
+++ b/src/services/importService.ts
@@ -1,5 +1,6 @@
-import { UPLOAD_TYPE } from 'components/Upload/Uploader';
+import { PICKED_UPLOAD_TYPE } from 'constants/upload';
import { Collection } from 'types/collection';
+import { ElectronAPIs } from 'types/electron';
import { ElectronFile, FileWithCollection } from 'types/upload';
import { runningInBrowser } from 'utils/common';
import { logError } from 'utils/sentry';
@@ -7,7 +8,7 @@ import { logError } from 'utils/sentry';
interface PendingUploads {
files: ElectronFile[];
collectionName: string;
- type: UPLOAD_TYPE;
+ type: PICKED_UPLOAD_TYPE;
}
interface selectZipResult {
@@ -15,19 +16,19 @@ interface selectZipResult {
zipPaths: string[];
}
class ImportService {
- ElectronAPIs: any;
+ electronAPIs: ElectronAPIs;
private allElectronAPIsExist: boolean = false;
constructor() {
- this.ElectronAPIs = runningInBrowser() && window['ElectronAPIs'];
- this.allElectronAPIsExist = !!this.ElectronAPIs?.getPendingUploads;
+ this.electronAPIs = runningInBrowser() && window['ElectronAPIs'];
+ this.allElectronAPIsExist = !!this.electronAPIs?.getPendingUploads;
}
async getElectronFilesFromGoogleZip(
zipPath: string
): Promise {
if (this.allElectronAPIsExist) {
- return this.ElectronAPIs.getElectronFilesFromGoogleZip(zipPath);
+ return this.electronAPIs.getElectronFilesFromGoogleZip(zipPath);
}
}
@@ -35,26 +36,26 @@ class ImportService {
async showUploadFilesDialog(): Promise {
if (this.allElectronAPIsExist) {
- return this.ElectronAPIs.showUploadFilesDialog();
+ return this.electronAPIs.showUploadFilesDialog();
}
}
async showUploadDirsDialog(): Promise {
if (this.allElectronAPIsExist) {
- return this.ElectronAPIs.showUploadDirsDialog();
+ return this.electronAPIs.showUploadDirsDialog();
}
}
async showUploadZipDialog(): Promise {
if (this.allElectronAPIsExist) {
- return this.ElectronAPIs.showUploadZipDialog();
+ return this.electronAPIs.showUploadZipDialog();
}
}
async getPendingUploads(): Promise {
try {
if (this.allElectronAPIsExist) {
const pendingUploads =
- (await this.ElectronAPIs.getPendingUploads()) as PendingUploads;
+ (await this.electronAPIs.getPendingUploads()) as PendingUploads;
return pendingUploads;
}
} catch (e) {
@@ -82,16 +83,16 @@ class ImportService {
if (collections.length === 1) {
collectionName = collections[0].name;
}
- this.ElectronAPIs.setToUploadCollection(collectionName);
+ this.electronAPIs.setToUploadCollection(collectionName);
}
}
async setToUploadFiles(
- type: UPLOAD_TYPE.FILES | UPLOAD_TYPE.ZIPS,
+ type: PICKED_UPLOAD_TYPE.FILES | PICKED_UPLOAD_TYPE.ZIPS,
filePaths: string[]
) {
if (this.allElectronAPIsExist) {
- this.ElectronAPIs.setToUploadFiles(type, filePaths);
+ this.electronAPIs.setToUploadFiles(type, filePaths);
}
}
@@ -116,14 +117,14 @@ class ImportService {
);
}
}
- this.setToUploadFiles(UPLOAD_TYPE.FILES, filePaths);
+ this.setToUploadFiles(PICKED_UPLOAD_TYPE.FILES, filePaths);
}
}
cancelRemainingUploads() {
if (this.allElectronAPIsExist) {
- this.ElectronAPIs.setToUploadCollection(null);
- this.ElectronAPIs.setToUploadFiles(UPLOAD_TYPE.ZIPS, []);
- this.ElectronAPIs.setToUploadFiles(UPLOAD_TYPE.FILES, []);
+ this.electronAPIs.setToUploadCollection(null);
+ this.electronAPIs.setToUploadFiles(PICKED_UPLOAD_TYPE.ZIPS, []);
+ this.electronAPIs.setToUploadFiles(PICKED_UPLOAD_TYPE.FILES, []);
}
}
}
diff --git a/src/services/publicCollectionDownloadManager.ts b/src/services/publicCollectionDownloadManager.ts
index 9b56b3bbbce4f998f03a1f0defe5c1c48f059414..d649fced6785db7d6c79777ae9cdaf36da4b5942 100644
--- a/src/services/publicCollectionDownloadManager.ts
+++ b/src/services/publicCollectionDownloadManager.ts
@@ -176,6 +176,9 @@ class PublicCollectionDownloadManager {
const resp = await fetch(getPublicCollectionFileURL(file.id), {
headers: {
'X-Auth-Access-Token': token,
+ ...(passwordToken && {
+ 'X-Auth-Access-Token-JWT': passwordToken,
+ }),
},
});
const reader = resp.body.getReader();
diff --git a/src/services/updateCreationTimeWithExif.ts b/src/services/updateCreationTimeWithExif.ts
index 1a45e337747ae4f9b1288e271a7496912fe1ef0e..fc6dd54fcbd55cfae460feb31616c5b6660bc5f1 100644
--- a/src/services/updateCreationTimeWithExif.ts
+++ b/src/services/updateCreationTimeWithExif.ts
@@ -29,13 +29,13 @@ export async function updateCreationTimeWithExif(
setProgressTracker({ current: 0, total: filesToBeUpdated.length });
for (const [index, file] of filesToBeUpdated.entries()) {
try {
- if (file.metadata.fileType !== FILE_TYPE.IMAGE) {
- continue;
- }
let correctCreationTime: number;
if (fixOption === FIX_OPTIONS.CUSTOM_TIME) {
correctCreationTime = getUnixTimeInMicroSeconds(customTime);
} else {
+ if (file.metadata.fileType !== FILE_TYPE.IMAGE) {
+ continue;
+ }
const fileURL = await downloadManager.getFile(file)[0];
const fileObject = await getFileFromURL(fileURL);
const fileTypeInfo = await getFileType(fileObject);
diff --git a/src/services/upload/livePhotoService.ts b/src/services/upload/livePhotoService.ts
index 7fbf44a2b06292e5e5336b38a673aa99cb816258..1b2000cc1bc03452f80cad9e453080dc53c07978 100644
--- a/src/services/upload/livePhotoService.ts
+++ b/src/services/upload/livePhotoService.ts
@@ -61,6 +61,10 @@ export function getLivePhotoMetadata(
};
}
+export function getLivePhotoFilePath(imageAsset: Asset): string {
+ return getLivePhotoName((imageAsset.file as any).path);
+}
+
export function getLivePhotoSize(livePhotoAssets: LivePhotoAssets) {
return livePhotoAssets.image.size + livePhotoAssets.video.size;
}
@@ -189,9 +193,11 @@ export function clusterLivePhotoFiles(mediaFiles: FileWithCollection[]) {
imageAsset.metadata,
videoAsset.metadata
);
+ const livePhotoPath = getLivePhotoFilePath(imageAsset);
uploadService.setFileMetadataAndFileTypeInfo(livePhotoLocalID, {
fileTypeInfo: { ...livePhotoFileTypeInfo },
metadata: { ...livePhotoMetadata },
+ filePath: livePhotoPath,
});
index += 2;
} else {
diff --git a/src/services/upload/multiPartUploadService.ts b/src/services/upload/multiPartUploadService.ts
index 4b7070398fe2ef736dc9cf4e3f7c6b0a461c559b..b02dbbf5d0a274f5a6f9d0e99d8284f8c90b6fa6 100644
--- a/src/services/upload/multiPartUploadService.ts
+++ b/src/services/upload/multiPartUploadService.ts
@@ -8,6 +8,7 @@ import UploadHttpClient from './uploadHttpClient';
import * as convert from 'xml-js';
import { CustomError } from 'utils/error';
import { DataStream, MultipartUploadURLs } from 'types/upload';
+import uploadCancelService from './uploadCancelService';
interface PartEtag {
PartNumber: number;
@@ -51,6 +52,9 @@ export async function uploadStreamInParts(
index,
fileUploadURL,
] of multipartUploadURLs.partURLs.entries()) {
+ if (uploadCancelService.isUploadCancelationRequested()) {
+ throw Error(CustomError.UPLOAD_CANCELLED);
+ }
const uploadChunk = await combineChunksToFormUploadPart(streamReader);
const progressTracker = UIService.trackUploadProgress(
fileLocalID,
diff --git a/src/services/upload/uiService.ts b/src/services/upload/uiService.ts
index b5828c7438b2148f90dda64967b0d0ca7ed76f09..70c0b036efe757b08545a2861a26dffea6eebb9f 100644
--- a/src/services/upload/uiService.ts
+++ b/src/services/upload/uiService.ts
@@ -1,3 +1,4 @@
+import { Canceler } from 'axios';
import {
UPLOAD_RESULT,
RANDOM_PERCENTAGE_PROGRESS_FOR_PUT,
@@ -10,7 +11,10 @@ import {
ProgressUpdater,
SegregatedFinishedUploads,
} from 'types/upload/ui';
+import { CustomError } from 'utils/error';
+import uploadCancelService from './uploadCancelService';
+const REQUEST_TIMEOUT_TIME = 30 * 1000; // 30 sec;
class UIService {
private perFileProgress: number;
private filesUploaded: number;
@@ -23,7 +27,7 @@ class UIService {
this.progressUpdater = progressUpdater;
}
- reset(count: number) {
+ reset(count = 0) {
this.setTotalFileCount(count);
this.filesUploaded = 0;
this.inProgressUploads = new Map();
@@ -33,7 +37,11 @@ class UIService {
setTotalFileCount(count: number) {
this.totalFileCount = count;
- this.perFileProgress = 100 / this.totalFileCount;
+ if (count > 0) {
+ this.perFileProgress = 100 / this.totalFileCount;
+ } else {
+ this.perFileProgress = 0;
+ }
}
setFileProgress(key: number, progress: number) {
@@ -68,14 +76,26 @@ class UIService {
this.updateProgressBarUI();
}
- updateProgressBarUI() {
+ hasFilesInResultList() {
+ const finishedUploadsList = segregatedFinishedUploadsToList(
+ this.finishedUploads
+ );
+ for (const x of finishedUploadsList.values()) {
+ if (x.length > 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private updateProgressBarUI() {
const {
setPercentComplete,
- setUploadCounter: setFileCounter,
+ setUploadCounter,
setInProgressUploads,
setFinishedUploads,
} = this.progressUpdater;
- setFileCounter({
+ setUploadCounter({
finished: this.filesUploaded,
total: this.totalFileCount,
});
@@ -95,10 +115,10 @@ class UIService {
setPercentComplete(percentComplete);
setInProgressUploads(
- this.convertInProgressUploadsToList(this.inProgressUploads)
+ convertInProgressUploadsToList(this.inProgressUploads)
);
setFinishedUploads(
- this.segregatedFinishedUploadsToList(this.finishedUploads)
+ segregatedFinishedUploadsToList(this.finishedUploads)
);
}
@@ -107,13 +127,19 @@ class UIService {
percentPerPart = RANDOM_PERCENTAGE_PROGRESS_FOR_PUT(),
index = 0
) {
- const cancel = { exec: null };
+ const cancel: { exec: Canceler } = { exec: () => {} };
+ const cancelTimedOutRequest = () =>
+ cancel.exec(CustomError.REQUEST_TIMEOUT);
+
+ const cancelCancelledUploadRequest = () =>
+ cancel.exec(CustomError.UPLOAD_CANCELLED);
+
let timeout = null;
const resetTimeout = () => {
if (timeout) {
clearTimeout(timeout);
}
- timeout = setTimeout(() => cancel.exec(), 30 * 1000);
+ timeout = setTimeout(cancelTimedOutRequest, REQUEST_TIMEOUT_TIME);
};
return {
cancel,
@@ -134,31 +160,33 @@ class UIService {
} else {
resetTimeout();
}
+ if (uploadCancelService.isUploadCancelationRequested()) {
+ cancelCancelledUploadRequest();
+ }
},
};
}
+}
- convertInProgressUploadsToList(inProgressUploads) {
- return [...inProgressUploads.entries()].map(
- ([localFileID, progress]) =>
- ({
- localFileID,
- progress,
- } as InProgressUpload)
- );
- }
+export default new UIService();
- segregatedFinishedUploadsToList(finishedUploads: FinishedUploads) {
- const segregatedFinishedUploads =
- new Map() as SegregatedFinishedUploads;
- for (const [localID, result] of finishedUploads) {
- if (!segregatedFinishedUploads.has(result)) {
- segregatedFinishedUploads.set(result, []);
- }
- segregatedFinishedUploads.get(result).push(localID);
+function convertInProgressUploadsToList(inProgressUploads) {
+ return [...inProgressUploads.entries()].map(
+ ([localFileID, progress]) =>
+ ({
+ localFileID,
+ progress,
+ } as InProgressUpload)
+ );
+}
+
+function segregatedFinishedUploadsToList(finishedUploads: FinishedUploads) {
+ const segregatedFinishedUploads = new Map() as SegregatedFinishedUploads;
+ for (const [localID, result] of finishedUploads) {
+ if (!segregatedFinishedUploads.has(result)) {
+ segregatedFinishedUploads.set(result, []);
}
- return segregatedFinishedUploads;
+ segregatedFinishedUploads.get(result).push(localID);
}
+ return segregatedFinishedUploads;
}
-
-export default new UIService();
diff --git a/src/services/upload/uploadCancelService.ts b/src/services/upload/uploadCancelService.ts
new file mode 100644
index 0000000000000000000000000000000000000000..790245784b4d97831b1268ba370d2e89de9185e3
--- /dev/null
+++ b/src/services/upload/uploadCancelService.ts
@@ -0,0 +1,23 @@
+interface UploadCancelStatus {
+ value: boolean;
+}
+
+class UploadCancelService {
+ private shouldUploadBeCancelled: UploadCancelStatus = {
+ value: false,
+ };
+
+ reset() {
+ this.shouldUploadBeCancelled.value = false;
+ }
+
+ requestUploadCancelation() {
+ this.shouldUploadBeCancelled.value = true;
+ }
+
+ isUploadCancelationRequested(): boolean {
+ return this.shouldUploadBeCancelled.value;
+ }
+}
+
+export default new UploadCancelService();
diff --git a/src/services/upload/uploadHttpClient.ts b/src/services/upload/uploadHttpClient.ts
index c46364a0320e9e3da0fd179a8082877d33ce1873..ecf3d81e8d76c14023f45dd7501000c7698ce2da 100644
--- a/src/services/upload/uploadHttpClient.ts
+++ b/src/services/upload/uploadHttpClient.ts
@@ -92,18 +92,22 @@ class UploadHttpClient {
progressTracker
): Promise {
try {
- await retryHTTPCall(() =>
- HTTPService.put(
- fileUploadURL.url,
- file,
- null,
- null,
- progressTracker
- )
+ await retryHTTPCall(
+ () =>
+ HTTPService.put(
+ fileUploadURL.url,
+ file,
+ null,
+ null,
+ progressTracker
+ ),
+ handleUploadError
);
return fileUploadURL.objectKey;
} catch (e) {
- logError(e, 'putFile to dataStore failed ');
+ if (e.message !== CustomError.UPLOAD_CANCELLED) {
+ logError(e, 'putFile to dataStore failed ');
+ }
throw e;
}
}
@@ -127,7 +131,9 @@ class UploadHttpClient {
);
return fileUploadURL.objectKey;
} catch (e) {
- logError(e, 'putFile to dataStore failed ');
+ if (e.message !== CustomError.UPLOAD_CANCELLED) {
+ logError(e, 'putFile to dataStore failed ');
+ }
throw e;
}
}
@@ -152,10 +158,12 @@ class UploadHttpClient {
throw err;
}
return resp;
- });
+ }, handleUploadError);
return response.headers.etag as string;
} catch (e) {
- logError(e, 'put filePart failed');
+ if (e.message !== CustomError.UPLOAD_CANCELLED) {
+ logError(e, 'put filePart failed');
+ }
throw e;
}
}
@@ -185,7 +193,9 @@ class UploadHttpClient {
});
return response.data.etag as string;
} catch (e) {
- logError(e, 'put filePart failed');
+ if (e.message !== CustomError.UPLOAD_CANCELLED) {
+ logError(e, 'put filePart failed');
+ }
throw e;
}
}
diff --git a/src/services/upload/uploadManager.ts b/src/services/upload/uploadManager.ts
index 75a3f272271a325e8c3e8ccf7cf1293d4cae763c..846003997f1efc9d5176a379d8af56c6a60234f9 100644
--- a/src/services/upload/uploadManager.ts
+++ b/src/services/upload/uploadManager.ts
@@ -1,11 +1,11 @@
-import { getLocalFiles, setLocalFiles } from '../fileService';
+import { getLocalFiles } from '../fileService';
import { SetFiles } from 'types/gallery';
import { getDedicatedCryptoWorker } from 'utils/crypto';
import {
- sortFilesIntoCollections,
sortFiles,
preservePhotoswipeProps,
decryptFile,
+ getUserOwnedNonTrashedFiles,
} from 'utils/file';
import { logError } from 'utils/sentry';
import { getMetadataJSONMapKey, parseMetadataJSON } from './metadataService';
@@ -40,7 +40,9 @@ import uiService from './uiService';
import { addLogLine, getFileNameSize } from 'utils/logging';
import isElectron from 'is-electron';
import ImportService from 'services/importService';
+import watchFolderService from 'services/watchFolder/watchFolderService';
import { ProgressUpdater } from 'types/upload/ui';
+import uploadCancelService from './uploadCancelService';
const MAX_CONCURRENT_UPLOADS = 4;
const FILE_UPLOAD_COMPLETED = 100;
@@ -52,15 +54,23 @@ class UploadManager {
private filesToBeUploaded: FileWithCollection[];
private remainingFiles: FileWithCollection[] = [];
private failedFiles: FileWithCollection[];
- private existingFilesCollectionWise: Map;
private existingFiles: EnteFile[];
+ private userOwnedNonTrashedExistingFiles: EnteFile[];
private setFiles: SetFiles;
private collections: Map;
- public initUploader(progressUpdater: ProgressUpdater, setFiles: SetFiles) {
+ private uploadInProgress: boolean;
+
+ public async init(progressUpdater: ProgressUpdater, setFiles: SetFiles) {
+ UIService.init(progressUpdater);
+ this.setFiles = setFiles;
UIService.init(progressUpdater);
this.setFiles = setFiles;
}
+ public isUploadRunning() {
+ return this.uploadInProgress;
+ }
+
private resetState() {
this.filesToBeUploaded = [];
this.remainingFiles = [];
@@ -72,10 +82,16 @@ class UploadManager {
>();
}
- private async init(collections: Collection[]) {
+ prepareForNewUpload() {
this.resetState();
+ UIService.reset();
+ uploadCancelService.reset();
+ UIService.setUploadStage(UPLOAD_STAGES.START);
+ }
+
+ async updateExistingFilesAndCollections(collections: Collection[]) {
this.existingFiles = await getLocalFiles();
- this.existingFilesCollectionWise = sortFilesIntoCollections(
+ this.userOwnedNonTrashedExistingFiles = getUserOwnedNonTrashedFiles(
this.existingFiles
);
this.collections = new Map(
@@ -84,16 +100,20 @@ class UploadManager {
}
public async queueFilesForUpload(
- fileWithCollectionToBeUploaded: FileWithCollection[],
+ filesWithCollectionToUploadIn: FileWithCollection[],
collections: Collection[]
) {
try {
- await this.init(collections);
+ if (this.uploadInProgress) {
+ throw Error("can't run multiple uploads at once");
+ }
+ this.uploadInProgress = true;
+ await this.updateExistingFilesAndCollections(collections);
addLogLine(
- `received ${fileWithCollectionToBeUploaded.length} files to upload`
+ `received ${filesWithCollectionToUploadIn.length} files to upload`
);
const { metadataJSONFiles, mediaFiles } =
- segregateMetadataAndMediaFiles(fileWithCollectionToBeUploaded);
+ segregateMetadataAndMediaFiles(filesWithCollectionToUploadIn);
addLogLine(`has ${metadataJSONFiles.length} metadata json files`);
addLogLine(`has ${mediaFiles.length} media files`);
if (metadataJSONFiles.length) {
@@ -101,6 +121,7 @@ class UploadManager {
UPLOAD_STAGES.READING_GOOGLE_METADATA_FILES
);
await this.parseMetadataJSONFiles(metadataJSONFiles);
+
UploadService.setParsedMetadataJSONMap(
this.parsedMetadataJSONMap
);
@@ -108,11 +129,11 @@ class UploadManager {
if (mediaFiles.length) {
UIService.setUploadStage(UPLOAD_STAGES.EXTRACTING_METADATA);
await this.extractMetadataFromFiles(mediaFiles);
+
UploadService.setMetadataAndFileTypeInfoMap(
this.metadataAndFileTypeInfoMap
);
- UIService.setUploadStage(UPLOAD_STAGES.START);
addLogLine(`clusterLivePhotoFiles called`);
// filter out files whose metadata detection failed or those that have been skipped because the files are too large,
@@ -162,19 +183,36 @@ class UploadManager {
await this.uploadMediaFiles(allFiles);
}
- UIService.setUploadStage(UPLOAD_STAGES.FINISH);
- UIService.setPercentComplete(FILE_UPLOAD_COMPLETED);
} catch (e) {
- logError(e, 'uploading failed with error');
- addLogLine(
- `uploading failed with error -> ${e.message}
+ if (e.message === CustomError.UPLOAD_CANCELLED) {
+ if (isElectron()) {
+ ImportService.cancelRemainingUploads();
+ }
+ } else {
+ logError(e, 'uploading failed with error');
+ addLogLine(
+ `uploading failed with error -> ${e.message}
${(e as Error).stack}`
- );
- throw e;
+ );
+ throw e;
+ }
} finally {
+ UIService.setUploadStage(UPLOAD_STAGES.FINISH);
+ UIService.setPercentComplete(FILE_UPLOAD_COMPLETED);
for (let i = 0; i < MAX_CONCURRENT_UPLOADS; i++) {
this.cryptoWorkers[i]?.worker.terminate();
}
+ this.uploadInProgress = false;
+ }
+ try {
+ if (!UIService.hasFilesInResultList()) {
+ return true;
+ } else {
+ return false;
+ }
+ } catch (e) {
+ logError(e, ' failed to return shouldCloseProgressBar');
+ return false;
}
}
@@ -186,6 +224,9 @@ class UploadManager {
for (const { file, collectionID } of metadataFiles) {
try {
+ if (uploadCancelService.isUploadCancelationRequested()) {
+ throw Error(CustomError.UPLOAD_CANCELLED);
+ }
addLogLine(
`parsing metadata json file ${getFileNameSize(file)}`
);
@@ -208,7 +249,12 @@ class UploadManager {
)}`
);
} catch (e) {
- logError(e, 'parsing failed for a file');
+ if (e.message === CustomError.UPLOAD_CANCELLED) {
+ throw e;
+ } else {
+ // and don't break for subsequent files just log and move on
+ logError(e, 'parsing failed for a file');
+ }
addLogLine(
`failed to parse metadata json file ${getFileNameSize(
file
@@ -217,8 +263,10 @@ class UploadManager {
}
}
} catch (e) {
- logError(e, 'error seeding MetadataMap');
- // silently ignore the error
+ if (e.message !== CustomError.UPLOAD_CANCELLED) {
+ logError(e, 'error seeding MetadataMap');
+ }
+ throw e;
}
}
@@ -227,8 +275,12 @@ class UploadManager {
addLogLine(`extractMetadataFromFiles executed`);
UIService.reset(mediaFiles.length);
for (const { file, localID, collectionID } of mediaFiles) {
+ if (uploadCancelService.isUploadCancelationRequested()) {
+ throw Error(CustomError.UPLOAD_CANCELLED);
+ }
let fileTypeInfo = null;
let metadata = null;
+ let filePath = null;
try {
addLogLine(
`metadata extraction started ${getFileNameSize(file)} `
@@ -239,13 +291,19 @@ class UploadManager {
);
fileTypeInfo = result.fileTypeInfo;
metadata = result.metadata;
+ filePath = result.filePath;
addLogLine(
`metadata extraction successful${getFileNameSize(
file
)} `
);
} catch (e) {
- logError(e, 'extractFileTypeAndMetadata failed');
+ if (e.message === CustomError.UPLOAD_CANCELLED) {
+ throw e;
+ } else {
+ // and don't break for subsequent files just log and move on
+ logError(e, 'extractFileTypeAndMetadata failed');
+ }
addLogLine(
`metadata extraction failed ${getFileNameSize(
file
@@ -255,11 +313,14 @@ class UploadManager {
this.metadataAndFileTypeInfoMap.set(localID, {
fileTypeInfo: fileTypeInfo && { ...fileTypeInfo },
metadata: metadata && { ...metadata },
+ filePath: filePath,
});
UIService.increaseFileUploaded();
}
} catch (e) {
- logError(e, 'error extracting metadata');
+ if (e.message !== CustomError.UPLOAD_CANCELLED) {
+ logError(e, 'error extracting metadata');
+ }
throw e;
}
}
@@ -292,11 +353,12 @@ class UploadManager {
collectionID,
fileTypeInfo
);
+ const filePath = (file as any).path as string;
+ return { fileTypeInfo, metadata, filePath };
} catch (e) {
logError(e, 'failed to extract file metadata');
- return { fileTypeInfo, metadata: null };
+ return { fileTypeInfo, metadata: null, filePath: null };
}
- return { fileTypeInfo, metadata };
}
private async uploadMediaFiles(mediaFiles: FileWithCollection[]) {
@@ -335,25 +397,25 @@ class UploadManager {
private async uploadNextFileInQueue(worker: any) {
while (this.filesToBeUploaded.length > 0) {
+ if (uploadCancelService.isUploadCancelationRequested()) {
+ throw Error(CustomError.UPLOAD_CANCELLED);
+ }
let fileWithCollection = this.filesToBeUploaded.pop();
const { collectionID } = fileWithCollection;
- const existingFilesInCollection =
- this.existingFilesCollectionWise.get(collectionID) ?? [];
const collection = this.collections.get(collectionID);
fileWithCollection = { ...fileWithCollection, collection };
- const { fileUploadResult, uploadedFile, skipDecryption } =
- await uploader(
- worker,
- existingFilesInCollection,
- this.existingFiles,
- fileWithCollection
- );
+ const { fileUploadResult, uploadedFile } = await uploader(
+ worker,
+ this.userOwnedNonTrashedExistingFiles,
+ fileWithCollection
+ );
+
const finalUploadResult = await this.postUploadTask(
fileUploadResult,
uploadedFile,
- skipDecryption,
fileWithCollection
);
+
UIService.moveFileToResultList(
fileWithCollection.localID,
finalUploadResult
@@ -364,40 +426,47 @@ class UploadManager {
async postUploadTask(
fileUploadResult: UPLOAD_RESULT,
- uploadedFile: EnteFile,
- skipDecryption: boolean,
+ uploadedFile: EnteFile | null,
fileWithCollection: FileWithCollection
) {
try {
+ let decryptedFile: EnteFile;
addLogLine(`uploadedFile ${JSON.stringify(uploadedFile)}`);
-
+ this.updateElectronRemainingFiles(fileWithCollection);
+ switch (fileUploadResult) {
+ case UPLOAD_RESULT.FAILED:
+ case UPLOAD_RESULT.BLOCKED:
+ this.failedFiles.push(fileWithCollection);
+ break;
+ case UPLOAD_RESULT.ALREADY_UPLOADED:
+ decryptedFile = uploadedFile;
+ break;
+ case UPLOAD_RESULT.ADDED_SYMLINK:
+ decryptedFile = uploadedFile;
+ fileUploadResult = UPLOAD_RESULT.UPLOADED;
+ break;
+ case UPLOAD_RESULT.UPLOADED:
+ case UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL:
+ decryptedFile = await decryptFile(
+ uploadedFile,
+ fileWithCollection.collection.key
+ );
+ break;
+ case UPLOAD_RESULT.UNSUPPORTED:
+ case UPLOAD_RESULT.TOO_LARGE:
+ case UPLOAD_RESULT.CANCELLED:
+ // no-op
+ break;
+ default:
+ throw Error('Invalid Upload Result' + fileUploadResult);
+ }
if (
- (fileUploadResult === UPLOAD_RESULT.UPLOADED ||
- fileUploadResult ===
- UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL) &&
- !skipDecryption
+ [
+ UPLOAD_RESULT.ADDED_SYMLINK,
+ UPLOAD_RESULT.UPLOADED,
+ UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL,
+ ].includes(fileUploadResult)
) {
- const decryptedFile = await decryptFile(
- uploadedFile,
- fileWithCollection.collection.key
- );
- this.existingFiles.push(decryptedFile);
- this.existingFiles = sortFiles(this.existingFiles);
- await setLocalFiles(this.existingFiles);
- this.setFiles(preservePhotoswipeProps(this.existingFiles));
- if (
- !this.existingFilesCollectionWise.has(
- decryptedFile.collectionID
- )
- ) {
- this.existingFilesCollectionWise.set(
- decryptedFile.collectionID,
- []
- );
- }
- this.existingFilesCollectionWise
- .get(decryptedFile.collectionID)
- .push(decryptedFile);
try {
eventBus.emit(Events.FILE_UPLOADED, {
enteFile: decryptedFile,
@@ -406,21 +475,13 @@ class UploadManager {
} catch (e) {
logError(e, 'Error in fileUploaded handlers');
}
+ this.updateExistingFiles(decryptedFile);
}
- if (
- fileUploadResult === UPLOAD_RESULT.FAILED ||
- fileUploadResult === UPLOAD_RESULT.BLOCKED
- ) {
- this.failedFiles.push(fileWithCollection);
- }
-
- if (isElectron()) {
- this.remainingFiles = this.remainingFiles.filter(
- (file) =>
- !areFileWithCollectionsSame(file, fileWithCollection)
- );
- ImportService.updatePendingUploads(this.remainingFiles);
- }
+ await this.watchFolderCallback(
+ fileUploadResult,
+ fileWithCollection,
+ uploadedFile
+ );
return fileUploadResult;
} catch (e) {
logError(e, 'failed to do post file upload action');
@@ -432,11 +493,60 @@ class UploadManager {
}
}
- async retryFailedFiles() {
- await this.queueFilesForUpload(this.failedFiles, [
- ...this.collections.values(),
- ]);
+ private async watchFolderCallback(
+ fileUploadResult: UPLOAD_RESULT,
+ fileWithCollection: FileWithCollection,
+ uploadedFile: EnteFile
+ ) {
+ if (isElectron()) {
+ await watchFolderService.onFileUpload(
+ fileUploadResult,
+ fileWithCollection,
+ uploadedFile
+ );
+ }
+ }
+
+ public cancelRunningUpload() {
+ UIService.setUploadStage(UPLOAD_STAGES.CANCELLING);
+ uploadCancelService.requestUploadCancelation();
+ }
+
+ async getFailedFilesWithCollections() {
+ return {
+ files: this.failedFiles,
+ collections: [...this.collections.values()],
+ };
}
+
+ private updateExistingFiles(decryptedFile: EnteFile) {
+ if (!decryptedFile) {
+ throw Error("decrypted file can't be undefined");
+ }
+ this.userOwnedNonTrashedExistingFiles.push(decryptedFile);
+ this.updateUIFiles(decryptedFile);
+ }
+
+ private updateUIFiles(decryptedFile: EnteFile) {
+ this.existingFiles.push(decryptedFile);
+ this.existingFiles = sortFiles(this.existingFiles);
+ this.setFiles(preservePhotoswipeProps(this.existingFiles));
+ }
+
+ private updateElectronRemainingFiles(
+ fileWithCollection: FileWithCollection
+ ) {
+ if (isElectron()) {
+ this.remainingFiles = this.remainingFiles.filter(
+ (file) => !areFileWithCollectionsSame(file, fileWithCollection)
+ );
+ ImportService.updatePendingUploads(this.remainingFiles);
+ }
+ }
+
+ public shouldAllowNewUpload = () => {
+ return !this.uploadInProgress || watchFolderService.isUploadRunning();
+ };
}
export default new UploadManager();
diff --git a/src/services/upload/uploadService.ts b/src/services/upload/uploadService.ts
index 3234dd95138e5be5effd8ba402bdbb55c9f5b97f..429dfde98711d0c451df2eb58a10452edb740c1f 100644
--- a/src/services/upload/uploadService.ts
+++ b/src/services/upload/uploadService.ts
@@ -3,7 +3,7 @@ import { logError } from 'utils/sentry';
import UploadHttpClient from './uploadHttpClient';
import { extractFileMetadata, getFilename } from './fileService';
import { getFileType } from '../typeDetectionService';
-import { handleUploadError } from 'utils/error';
+import { CustomError, handleUploadError } from 'utils/error';
import {
B64EncryptionResult,
BackupedFile,
@@ -44,6 +44,7 @@ class UploadService {
number,
MetadataAndFileTypeInfo
>();
+
private pendingUploadCount: number = 0;
async setFileCount(fileCount: number) {
@@ -185,7 +186,9 @@ class UploadService {
};
return backupedFile;
} catch (e) {
- logError(e, 'error uploading to bucket');
+ if (e.message !== CustomError.UPLOAD_CANCELLED) {
+ logError(e, 'error uploading to bucket');
+ }
throw e;
}
}
diff --git a/src/services/upload/uploader.ts b/src/services/upload/uploader.ts
index 4e929bf4c7e30a4f0fb1c50c5779e9ee55b46258..40cdad7ac042c6c75b08e5b35ff5056d5222e235 100644
--- a/src/services/upload/uploader.ts
+++ b/src/services/upload/uploader.ts
@@ -1,30 +1,26 @@
import { EnteFile } from 'types/file';
import { handleUploadError, CustomError } from 'utils/error';
import { logError } from 'utils/sentry';
-import {
- fileAlreadyInCollection,
- findSameFileInOtherCollection,
- shouldDedupeAcrossCollection,
-} from 'utils/upload';
+import { findMatchingExistingFiles } from 'utils/upload';
import UploadHttpClient from './uploadHttpClient';
import UIService from './uiService';
import UploadService from './uploadService';
import { FILE_TYPE } from 'constants/file';
import { UPLOAD_RESULT, MAX_FILE_SIZE_SUPPORTED } from 'constants/upload';
import { FileWithCollection, BackupedFile, UploadFile } from 'types/upload';
-import { addLogLine } from 'utils/logging';
+import { addLocalLog, addLogLine } from 'utils/logging';
import { convertBytesToHumanReadable } from 'utils/file/size';
import { sleep } from 'utils/common';
import { addToCollection } from 'services/collectionService';
+import uploadCancelService from './uploadCancelService';
interface UploadResponse {
fileUploadResult: UPLOAD_RESULT;
uploadedFile?: EnteFile;
- skipDecryption?: boolean;
}
+
export default async function uploader(
worker: any,
- existingFilesInCollection: EnteFile[],
existingFiles: EnteFile[],
fileWithCollection: FileWithCollection
): Promise {
@@ -50,39 +46,52 @@ export default async function uploader(
throw Error(CustomError.NO_METADATA);
}
- if (fileAlreadyInCollection(existingFilesInCollection, metadata)) {
- addLogLine(`skipped upload for ${fileNameSize}`);
- return { fileUploadResult: UPLOAD_RESULT.ALREADY_UPLOADED };
- }
-
- const sameFileInOtherCollection = findSameFileInOtherCollection(
+ const matchingExistingFiles = findMatchingExistingFiles(
existingFiles,
metadata
);
-
- if (sameFileInOtherCollection) {
- addLogLine(
- `same file in other collection found for ${fileNameSize}`
+ addLocalLog(
+ () =>
+ `matchedFileList: ${matchingExistingFiles
+ .map((f) => `${f.id}-${f.metadata.title}`)
+ .join(',')}`
+ );
+ if (matchingExistingFiles?.length) {
+ const matchingExistingFilesCollectionIDs =
+ matchingExistingFiles.map((e) => e.collectionID);
+ addLocalLog(
+ () =>
+ `matched file collectionIDs:${matchingExistingFilesCollectionIDs}
+ and collectionID:${collection.id}`
);
- const resultFile = Object.assign({}, sameFileInOtherCollection);
- resultFile.collectionID = collection.id;
- await addToCollection(collection, [resultFile]);
- return {
- fileUploadResult: UPLOAD_RESULT.UPLOADED,
- uploadedFile: resultFile,
- skipDecryption: true,
- };
+ if (matchingExistingFilesCollectionIDs.includes(collection.id)) {
+ addLogLine(
+ `file already present in the collection , skipped upload for ${fileNameSize}`
+ );
+ const sameCollectionMatchingExistingFile =
+ matchingExistingFiles.find(
+ (f) => f.collectionID === collection.id
+ );
+ return {
+ fileUploadResult: UPLOAD_RESULT.ALREADY_UPLOADED,
+ uploadedFile: sameCollectionMatchingExistingFile,
+ };
+ } else {
+ addLogLine(
+ `same file in ${matchingExistingFilesCollectionIDs.length} collection found for ${fileNameSize}`
+ );
+ // any of the matching file can used to add a symlink
+ const resultFile = Object.assign({}, matchingExistingFiles[0]);
+ resultFile.collectionID = collection.id;
+ await addToCollection(collection, [resultFile]);
+ return {
+ fileUploadResult: UPLOAD_RESULT.ADDED_SYMLINK,
+ uploadedFile: resultFile,
+ };
+ }
}
-
- // iOS exports via album doesn't export files without collection and if user exports all photos, album info is not preserved.
- // This change allow users to export by albums, upload to ente. And export all photos -> upload files which are not already uploaded
- // as part of the albums
- if (
- shouldDedupeAcrossCollection(fileWithCollection.collection.name) &&
- fileAlreadyInCollection(existingFiles, metadata)
- ) {
- addLogLine(`deduped upload for ${fileNameSize}`);
- return { fileUploadResult: UPLOAD_RESULT.ALREADY_UPLOADED };
+ if (uploadCancelService.isUploadCancelationRequested()) {
+ throw Error(CustomError.UPLOAD_CANCELLED);
}
addLogLine(`reading asset ${fileNameSize}`);
@@ -98,6 +107,9 @@ export default async function uploader(
metadata,
};
+ if (uploadCancelService.isUploadCancelationRequested()) {
+ throw Error(CustomError.UPLOAD_CANCELLED);
+ }
addLogLine(`encryptAsset ${fileNameSize}`);
const encryptedFile = await UploadService.encryptAsset(
worker,
@@ -105,6 +117,9 @@ export default async function uploader(
collection.key
);
+ if (uploadCancelService.isUploadCancelationRequested()) {
+ throw Error(CustomError.UPLOAD_CANCELLED);
+ }
addLogLine(`uploadToBucket ${fileNameSize}`);
const backupedFile: BackupedFile = await UploadService.uploadToBucket(
@@ -131,12 +146,15 @@ export default async function uploader(
};
} catch (e) {
addLogLine(`upload failed for ${fileNameSize} ,error: ${e.message}`);
-
- logError(e, 'file upload failed', {
- fileFormat: fileTypeInfo?.exactType,
- });
+ if (e.message !== CustomError.UPLOAD_CANCELLED) {
+ logError(e, 'file upload failed', {
+ fileFormat: fileTypeInfo?.exactType,
+ });
+ }
const error = handleUploadError(e);
switch (error.message) {
+ case CustomError.UPLOAD_CANCELLED:
+ return { fileUploadResult: UPLOAD_RESULT.CANCELLED };
case CustomError.ETAG_MISSING:
return { fileUploadResult: UPLOAD_RESULT.BLOCKED };
case CustomError.UNSUPPORTED_FILE_FORMAT:
diff --git a/src/services/userService.ts b/src/services/userService.ts
index 88c67da5b085ec5cb12c21afb2f8961da816e2e6..afb50bd8ac28ed15e7915847d4457b428abf17b7 100644
--- a/src/services/userService.ts
+++ b/src/services/userService.ts
@@ -17,6 +17,7 @@ import {
TwoFactorVerificationResponse,
TwoFactorRecoveryResponse,
UserDetails,
+ DeleteChallengeResponse,
} from 'types/user';
import { getLocalFamilyData, isPartOfFamily } from 'utils/billing';
import { ServerErrorCodes } from 'utils/error';
@@ -327,3 +328,42 @@ export const getFamilyPortalRedirectURL = async () => {
throw e;
}
};
+
+export const getAccountDeleteChallenge = async () => {
+ try {
+ const token = getToken();
+
+ const resp = await HTTPService.get(
+ `${ENDPOINT}/users/delete-challenge`,
+ null,
+ {
+ 'X-Auth-Token': token,
+ }
+ );
+ return resp.data as DeleteChallengeResponse;
+ } catch (e) {
+ logError(e, 'failed to get account delete challenge');
+ throw e;
+ }
+};
+
+export const deleteAccount = async (challenge: string) => {
+ try {
+ const token = getToken();
+ if (!token) {
+ return;
+ }
+
+ await HTTPService.delete(
+ `${ENDPOINT}/users/delete`,
+ { challenge },
+ null,
+ {
+ 'X-Auth-Token': token,
+ }
+ );
+ } catch (e) {
+ logError(e, 'deleteAccount api call failed');
+ throw e;
+ }
+};
diff --git a/src/services/watchFolder/utils.ts b/src/services/watchFolder/utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bce90a2c80dceaba5c6578f528af4cb73d6e0208
--- /dev/null
+++ b/src/services/watchFolder/utils.ts
@@ -0,0 +1,5 @@
+export const getParentFolderName = (filePath: string) => {
+ const folderPath = filePath.substring(0, filePath.lastIndexOf('/'));
+ const folderName = folderPath.substring(folderPath.lastIndexOf('/') + 1);
+ return folderName;
+};
diff --git a/src/services/watchFolder/watchFolderEventHandlers.ts b/src/services/watchFolder/watchFolderEventHandlers.ts
new file mode 100644
index 0000000000000000000000000000000000000000..93896ea38c28fce0c53cb3e5950bbf125902bf2c
--- /dev/null
+++ b/src/services/watchFolder/watchFolderEventHandlers.ts
@@ -0,0 +1,70 @@
+import { ElectronFile } from 'types/upload';
+import { EventQueueItem } from 'types/watchFolder';
+import { addLogLine } from 'utils/logging';
+import { logError } from 'utils/sentry';
+import watchFolderService from './watchFolderService';
+
+export async function diskFileAddedCallback(file: ElectronFile) {
+ try {
+ const collectionNameAndFolderPath =
+ await watchFolderService.getCollectionNameAndFolderPath(file.path);
+
+ if (!collectionNameAndFolderPath) {
+ return;
+ }
+
+ const { collectionName, folderPath } = collectionNameAndFolderPath;
+
+ const event: EventQueueItem = {
+ type: 'upload',
+ collectionName,
+ folderPath,
+ files: [file],
+ };
+ watchFolderService.pushEvent(event);
+ addLogLine(`added (upload) to event queue, ${JSON.stringify(event)}`);
+ } catch (e) {
+ logError(e, 'error while calling diskFileAddedCallback');
+ }
+}
+
+export async function diskFileRemovedCallback(filePath: string) {
+ try {
+ const collectionNameAndFolderPath =
+ await watchFolderService.getCollectionNameAndFolderPath(filePath);
+
+ if (!collectionNameAndFolderPath) {
+ return;
+ }
+
+ const { collectionName, folderPath } = collectionNameAndFolderPath;
+
+ const event: EventQueueItem = {
+ type: 'trash',
+ collectionName,
+ folderPath,
+ paths: [filePath],
+ };
+ watchFolderService.pushEvent(event);
+ addLogLine(`added (trash) to event queue, ${JSON.stringify(event)}`);
+ } catch (e) {
+ logError(e, 'error while calling diskFileRemovedCallback');
+ }
+}
+
+export async function diskFolderRemovedCallback(folderPath: string) {
+ try {
+ const mappings = watchFolderService.getWatchMappings();
+ const mapping = mappings.find(
+ (mapping) => mapping.folderPath === folderPath
+ );
+ if (!mapping) {
+ addLogLine(`folder not found in mappings, ${folderPath}`);
+ throw Error(`Watch mapping not found`);
+ }
+ watchFolderService.pushTrashedDir(folderPath);
+ addLogLine(`added trashedDir, ${folderPath}`);
+ } catch (e) {
+ logError(e, 'error while calling diskFolderRemovedCallback');
+ }
+}
diff --git a/src/services/watchFolder/watchFolderService.ts b/src/services/watchFolder/watchFolderService.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4ded8cf6da94a58013006436f99e4d2945d39e59
--- /dev/null
+++ b/src/services/watchFolder/watchFolderService.ts
@@ -0,0 +1,664 @@
+import { Collection } from 'types/collection';
+import { EnteFile } from 'types/file';
+import { ElectronFile, FileWithCollection } from 'types/upload';
+import { runningInBrowser } from 'utils/common';
+import { removeFromCollection } from '../collectionService';
+import { getLocalFiles } from '../fileService';
+import { logError } from 'utils/sentry';
+import {
+ EventQueueItem,
+ WatchMapping,
+ WatchMappingSyncedFile,
+} from 'types/watchFolder';
+import { ElectronAPIs } from 'types/electron';
+import debounce from 'debounce-promise';
+import {
+ diskFileAddedCallback,
+ diskFileRemovedCallback,
+ diskFolderRemovedCallback,
+} from './watchFolderEventHandlers';
+import { getParentFolderName } from './utils';
+import { UPLOAD_RESULT, UPLOAD_STRATEGY } from 'constants/upload';
+import uploadManager from 'services/upload/uploadManager';
+import { addLocalLog, addLogLine } from 'utils/logging';
+import { getValidFilesToUpload } from 'utils/watch';
+import { groupFilesBasedOnCollectionID } from 'utils/file';
+
+class watchFolderService {
+ private electronAPIs: ElectronAPIs;
+ private allElectronAPIsExist: boolean = false;
+ private eventQueue: EventQueueItem[] = [];
+ private currentEvent: EventQueueItem;
+ private currentlySyncedMapping: WatchMapping;
+ private trashingDirQueue: string[] = [];
+ private isEventRunning: boolean = false;
+ private uploadRunning: boolean = false;
+ private filePathToUploadedFileIDMap = new Map();
+ private unUploadableFilePaths = new Set();
+ private isPaused = false;
+ private setElectronFiles: (files: ElectronFile[]) => void;
+ private setCollectionName: (collectionName: string) => void;
+ private syncWithRemote: () => void;
+ private setWatchFolderServiceIsRunning: (isRunning: boolean) => void;
+
+ constructor() {
+ this.electronAPIs = (runningInBrowser() &&
+ window['ElectronAPIs']) as ElectronAPIs;
+ this.allElectronAPIsExist = !!this.electronAPIs?.getWatchMappings;
+ }
+
+ isUploadRunning() {
+ return this.uploadRunning;
+ }
+
+ isSyncPaused() {
+ return this.isPaused;
+ }
+
+ async init(
+ setElectronFiles: (files: ElectronFile[]) => void,
+ setCollectionName: (collectionName: string) => void,
+ syncWithRemote: () => void,
+ setWatchFolderServiceIsRunning: (isRunning: boolean) => void
+ ) {
+ if (this.allElectronAPIsExist) {
+ try {
+ this.setElectronFiles = setElectronFiles;
+ this.setCollectionName = setCollectionName;
+ this.syncWithRemote = syncWithRemote;
+ this.setWatchFolderServiceIsRunning =
+ setWatchFolderServiceIsRunning;
+ this.setupWatcherFunctions();
+ await this.getAndSyncDiffOfFiles();
+ } catch (e) {
+ logError(e, 'error while initializing watch service');
+ }
+ }
+ }
+
+ async getAndSyncDiffOfFiles() {
+ try {
+ let mappings = this.getWatchMappings();
+
+ addLogLine(`mappings, ${mappings.map((m) => JSON.stringify(m))}`);
+
+ if (!mappings?.length) {
+ return;
+ }
+
+ mappings = await this.filterOutDeletedMappings(mappings);
+
+ this.eventQueue = [];
+
+ for (const mapping of mappings) {
+ const filesOnDisk: ElectronFile[] =
+ await this.electronAPIs.getDirFiles(mapping.folderPath);
+
+ this.uploadDiffOfFiles(mapping, filesOnDisk);
+ this.trashDiffOfFiles(mapping, filesOnDisk);
+ }
+ } catch (e) {
+ logError(e, 'error while getting and syncing diff of files');
+ }
+ }
+
+ isMappingSyncInProgress(mapping: WatchMapping) {
+ return this.currentEvent?.folderPath === mapping.folderPath;
+ }
+
+ private uploadDiffOfFiles(
+ mapping: WatchMapping,
+ filesOnDisk: ElectronFile[]
+ ) {
+ const filesToUpload = getValidFilesToUpload(filesOnDisk, mapping);
+
+ if (filesToUpload.length > 0) {
+ for (const file of filesToUpload) {
+ const event: EventQueueItem = {
+ type: 'upload',
+ collectionName: this.getCollectionNameForMapping(
+ mapping,
+ file.path
+ ),
+ folderPath: mapping.folderPath,
+ files: [file],
+ };
+ this.pushEvent(event);
+ }
+ }
+ }
+
+ private trashDiffOfFiles(
+ mapping: WatchMapping,
+ filesOnDisk: ElectronFile[]
+ ) {
+ const filesToRemove = mapping.syncedFiles.filter((file) => {
+ return !filesOnDisk.find(
+ (electronFile) => electronFile.path === file.path
+ );
+ });
+
+ if (filesToRemove.length > 0) {
+ for (const file of filesToRemove) {
+ const event: EventQueueItem = {
+ type: 'trash',
+ collectionName: this.getCollectionNameForMapping(
+ mapping,
+ file.path
+ ),
+ folderPath: mapping.folderPath,
+ paths: [file.path],
+ };
+ this.pushEvent(event);
+ }
+ }
+ }
+
+ private async filterOutDeletedMappings(
+ mappings: WatchMapping[]
+ ): Promise {
+ const notDeletedMappings = [];
+ for (const mapping of mappings) {
+ const mappingExists = await this.electronAPIs.isFolder(
+ mapping.folderPath
+ );
+ if (!mappingExists) {
+ this.electronAPIs.removeWatchMapping(mapping.folderPath);
+ } else {
+ notDeletedMappings.push(mapping);
+ }
+ }
+ return notDeletedMappings;
+ }
+
+ pushEvent(event: EventQueueItem) {
+ this.eventQueue.push(event);
+ debounce(this.runNextEvent.bind(this), 300)();
+ }
+
+ async pushTrashedDir(path: string) {
+ this.trashingDirQueue.push(path);
+ }
+
+ private setupWatcherFunctions() {
+ if (this.allElectronAPIsExist) {
+ this.electronAPIs.registerWatcherFunctions(
+ diskFileAddedCallback,
+ diskFileRemovedCallback,
+ diskFolderRemovedCallback
+ );
+ }
+ }
+
+ async addWatchMapping(
+ rootFolderName: string,
+ folderPath: string,
+ uploadStrategy: UPLOAD_STRATEGY
+ ) {
+ if (this.allElectronAPIsExist) {
+ try {
+ await this.electronAPIs.addWatchMapping(
+ rootFolderName,
+ folderPath,
+ uploadStrategy
+ );
+ this.getAndSyncDiffOfFiles();
+ } catch (e) {
+ logError(e, 'error while adding watch mapping');
+ }
+ }
+ }
+
+ async removeWatchMapping(folderPath: string) {
+ if (this.allElectronAPIsExist) {
+ try {
+ await this.electronAPIs.removeWatchMapping(folderPath);
+ } catch (e) {
+ logError(e, 'error while removing watch mapping');
+ }
+ }
+ }
+
+ getWatchMappings(): WatchMapping[] {
+ if (this.allElectronAPIsExist) {
+ try {
+ return this.electronAPIs.getWatchMappings() ?? [];
+ } catch (e) {
+ logError(e, 'error while getting watch mappings');
+ return [];
+ }
+ }
+ return [];
+ }
+
+ private setIsEventRunning(isEventRunning: boolean) {
+ this.isEventRunning = isEventRunning;
+ this.setWatchFolderServiceIsRunning(isEventRunning);
+ }
+
+ private async runNextEvent() {
+ try {
+ addLogLine(
+ `mappings,
+ ${this.getWatchMappings().map((m) => JSON.stringify(m))}`
+ );
+
+ if (
+ this.eventQueue.length === 0 ||
+ this.isEventRunning ||
+ this.isPaused
+ ) {
+ return;
+ }
+
+ const event = this.clubSameCollectionEvents();
+ const mappings = this.getWatchMappings();
+ const mapping = mappings.find(
+ (mapping) => mapping.folderPath === event.folderPath
+ );
+ if (!mapping) {
+ throw Error('no Mapping found for event');
+ }
+ if (event.type === 'upload') {
+ event.files = getValidFilesToUpload(event.files, mapping);
+ if (event.files.length === 0) {
+ return;
+ }
+ }
+ this.currentEvent = event;
+ this.currentlySyncedMapping = mapping;
+ addLogLine(`running event', ${JSON.stringify(event)}`);
+ this.setIsEventRunning(true);
+ if (event.type === 'upload') {
+ this.processUploadEvent();
+ } else {
+ await this.processTrashEvent();
+ this.setIsEventRunning(false);
+ this.runNextEvent();
+ }
+ } catch (e) {
+ logError(e, 'runNextEvent failed');
+ }
+ }
+
+ private async processUploadEvent() {
+ try {
+ this.uploadRunning = true;
+
+ this.setCollectionName(this.currentEvent.collectionName);
+ this.setElectronFiles(this.currentEvent.files);
+ } catch (e) {
+ logError(e, 'error while running next upload');
+ }
+ }
+
+ async onFileUpload(
+ fileUploadResult: UPLOAD_RESULT,
+ fileWithCollection: FileWithCollection,
+ file: EnteFile
+ ) {
+ addLocalLog(() => `onFileUpload called`);
+ if (!this.isUploadRunning) {
+ return;
+ }
+ if (
+ [
+ UPLOAD_RESULT.ADDED_SYMLINK,
+ UPLOAD_RESULT.UPLOADED,
+ UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL,
+ UPLOAD_RESULT.ALREADY_UPLOADED,
+ ].includes(fileUploadResult)
+ ) {
+ if (fileWithCollection.isLivePhoto) {
+ this.filePathToUploadedFileIDMap.set(
+ (fileWithCollection.livePhotoAssets.image as ElectronFile)
+ .path,
+ file
+ );
+ this.filePathToUploadedFileIDMap.set(
+ (fileWithCollection.livePhotoAssets.video as ElectronFile)
+ .path,
+ file
+ );
+ } else {
+ this.filePathToUploadedFileIDMap.set(
+ (fileWithCollection.file as ElectronFile).path,
+ file
+ );
+ }
+ } else if (
+ [UPLOAD_RESULT.UNSUPPORTED, UPLOAD_RESULT.TOO_LARGE].includes(
+ fileUploadResult
+ )
+ ) {
+ if (fileWithCollection.isLivePhoto) {
+ this.unUploadableFilePaths.add(
+ (fileWithCollection.livePhotoAssets.image as ElectronFile)
+ .path
+ );
+ this.unUploadableFilePaths.add(
+ (fileWithCollection.livePhotoAssets.video as ElectronFile)
+ .path
+ );
+ } else {
+ this.unUploadableFilePaths.add(
+ (fileWithCollection.file as ElectronFile).path
+ );
+ }
+ }
+ }
+
+ async allFileUploadsDone(
+ filesWithCollection: FileWithCollection[],
+ collections: Collection[]
+ ) {
+ if (this.allElectronAPIsExist) {
+ try {
+ addLocalLog(
+ () =>
+ `allFileUploadsDone,${JSON.stringify(
+ filesWithCollection
+ )} ${JSON.stringify(collections)}`
+ );
+ const collection = collections.find(
+ (collection) =>
+ collection.id === filesWithCollection[0].collectionID
+ );
+ addLocalLog(() => `got collection ${!!collection}`);
+ addLocalLog(
+ () =>
+ `${this.isEventRunning} ${this.currentEvent.collectionName} ${collection?.name}`
+ );
+ if (
+ !this.isEventRunning ||
+ this.currentEvent.collectionName !== collection?.name
+ ) {
+ return;
+ }
+
+ const syncedFiles: WatchMapping['syncedFiles'] = [];
+ const ignoredFiles: WatchMapping['ignoredFiles'] = [];
+
+ for (const fileWithCollection of filesWithCollection) {
+ this.handleUploadedFile(
+ fileWithCollection,
+ syncedFiles,
+ ignoredFiles
+ );
+ }
+
+ addLocalLog(() => `syncedFiles ${JSON.stringify(syncedFiles)}`);
+ addLocalLog(
+ () => `ignoredFiles ${JSON.stringify(ignoredFiles)}`
+ );
+
+ if (syncedFiles.length > 0) {
+ this.currentlySyncedMapping.syncedFiles = [
+ ...this.currentlySyncedMapping.syncedFiles,
+ ...syncedFiles,
+ ];
+ this.electronAPIs.updateWatchMappingSyncedFiles(
+ this.currentlySyncedMapping.folderPath,
+ this.currentlySyncedMapping.syncedFiles
+ );
+ }
+ if (ignoredFiles.length > 0) {
+ this.currentlySyncedMapping.ignoredFiles = [
+ ...this.currentlySyncedMapping.ignoredFiles,
+ ...ignoredFiles,
+ ];
+ this.electronAPIs.updateWatchMappingIgnoredFiles(
+ this.currentlySyncedMapping.folderPath,
+ this.currentlySyncedMapping.ignoredFiles
+ );
+ }
+
+ this.runPostUploadsAction();
+ } catch (e) {
+ logError(e, 'error while running all file uploads done');
+ }
+ }
+ }
+
+ private runPostUploadsAction() {
+ this.setIsEventRunning(false);
+ this.uploadRunning = false;
+ this.runNextEvent();
+ }
+
+ private handleUploadedFile(
+ fileWithCollection: FileWithCollection,
+ syncedFiles: WatchMapping['syncedFiles'],
+ ignoredFiles: WatchMapping['ignoredFiles']
+ ) {
+ if (fileWithCollection.isLivePhoto) {
+ const imagePath = (
+ fileWithCollection.livePhotoAssets.image as ElectronFile
+ ).path;
+ const videoPath = (
+ fileWithCollection.livePhotoAssets.video as ElectronFile
+ ).path;
+
+ if (
+ this.filePathToUploadedFileIDMap.has(imagePath) &&
+ this.filePathToUploadedFileIDMap.has(videoPath)
+ ) {
+ const imageFile = {
+ path: imagePath,
+ uploadedFileID:
+ this.filePathToUploadedFileIDMap.get(imagePath).id,
+ collectionID:
+ this.filePathToUploadedFileIDMap.get(imagePath)
+ .collectionID,
+ };
+ const videoFile = {
+ path: videoPath,
+ uploadedFileID:
+ this.filePathToUploadedFileIDMap.get(videoPath).id,
+ collectionID:
+ this.filePathToUploadedFileIDMap.get(videoPath)
+ .collectionID,
+ };
+ syncedFiles.push(imageFile);
+ syncedFiles.push(videoFile);
+ addLocalLog(
+ () =>
+ `added image ${JSON.stringify(
+ imageFile
+ )} and video file ${JSON.stringify(
+ videoFile
+ )} to uploadedFiles`
+ );
+ } else if (
+ this.unUploadableFilePaths.has(imagePath) &&
+ this.unUploadableFilePaths.has(videoPath)
+ ) {
+ ignoredFiles.push(imagePath);
+ ignoredFiles.push(videoPath);
+ addLocalLog(
+ () =>
+ `added image ${imagePath} and video file ${videoPath} to rejectedFiles`
+ );
+ }
+ this.filePathToUploadedFileIDMap.delete(imagePath);
+ this.filePathToUploadedFileIDMap.delete(videoPath);
+ } else {
+ const filePath = (fileWithCollection.file as ElectronFile).path;
+
+ if (this.filePathToUploadedFileIDMap.has(filePath)) {
+ const file = {
+ path: filePath,
+ uploadedFileID:
+ this.filePathToUploadedFileIDMap.get(filePath).id,
+ collectionID:
+ this.filePathToUploadedFileIDMap.get(filePath)
+ .collectionID,
+ };
+ syncedFiles.push(file);
+ addLocalLog(() => `added file ${JSON.stringify(file)} `);
+ } else if (this.unUploadableFilePaths.has(filePath)) {
+ ignoredFiles.push(filePath);
+ addLocalLog(() => `added file ${filePath} to rejectedFiles`);
+ }
+ this.filePathToUploadedFileIDMap.delete(filePath);
+ }
+ }
+
+ private async processTrashEvent() {
+ try {
+ if (this.checkAndIgnoreIfFileEventsFromTrashedDir()) {
+ return;
+ }
+
+ const { paths } = this.currentEvent;
+ const filePathsToRemove = new Set(paths);
+
+ const files = this.currentlySyncedMapping.syncedFiles.filter(
+ (file) => filePathsToRemove.has(file.path)
+ );
+
+ await this.trashByIDs(files);
+
+ this.currentlySyncedMapping.syncedFiles =
+ this.currentlySyncedMapping.syncedFiles.filter(
+ (file) => !filePathsToRemove.has(file.path)
+ );
+ this.electronAPIs.updateWatchMappingSyncedFiles(
+ this.currentlySyncedMapping.folderPath,
+ this.currentlySyncedMapping.syncedFiles
+ );
+ } catch (e) {
+ logError(e, 'error while running next trash');
+ }
+ }
+
+ private async trashByIDs(toTrashFiles: WatchMapping['syncedFiles']) {
+ try {
+ const files = await getLocalFiles();
+ const toTrashFilesMap = new Map();
+ for (const file of toTrashFiles) {
+ toTrashFilesMap.set(file.uploadedFileID, file);
+ }
+ const filesToTrash = files.filter((file) => {
+ if (toTrashFilesMap.has(file.id)) {
+ const fileToTrash = toTrashFilesMap.get(file.id);
+ if (fileToTrash.collectionID === file.collectionID) {
+ return true;
+ }
+ }
+ });
+ const groupFilesByCollectionId =
+ groupFilesBasedOnCollectionID(filesToTrash);
+
+ for (const [
+ collectionID,
+ filesToTrash,
+ ] of groupFilesByCollectionId.entries()) {
+ await removeFromCollection(collectionID, filesToTrash);
+ }
+ this.syncWithRemote();
+ } catch (e) {
+ logError(e, 'error while trashing by IDs');
+ }
+ }
+
+ private checkAndIgnoreIfFileEventsFromTrashedDir() {
+ if (this.trashingDirQueue.length !== 0) {
+ this.ignoreFileEventsFromTrashedDir(this.trashingDirQueue[0]);
+ this.trashingDirQueue.shift();
+ return true;
+ }
+ return false;
+ }
+
+ private ignoreFileEventsFromTrashedDir(trashingDir: string) {
+ this.eventQueue = this.eventQueue.filter((event) =>
+ event.paths.every((path) => !path.startsWith(trashingDir))
+ );
+ }
+
+ async getCollectionNameAndFolderPath(filePath: string) {
+ try {
+ const mappings = this.getWatchMappings();
+
+ const mapping = mappings.find(
+ (mapping) =>
+ filePath.length > mapping.folderPath.length &&
+ filePath.startsWith(mapping.folderPath) &&
+ filePath[mapping.folderPath.length] === '/'
+ );
+
+ if (!mapping) {
+ throw Error(`no mapping found`);
+ }
+
+ return {
+ collectionName: this.getCollectionNameForMapping(
+ mapping,
+ filePath
+ ),
+ folderPath: mapping.folderPath,
+ };
+ } catch (e) {
+ logError(e, 'error while getting collection name');
+ }
+ }
+
+ private getCollectionNameForMapping(
+ mapping: WatchMapping,
+ filePath: string
+ ) {
+ return mapping.uploadStrategy === UPLOAD_STRATEGY.COLLECTION_PER_FOLDER
+ ? getParentFolderName(filePath)
+ : mapping.rootFolderName;
+ }
+
+ async selectFolder(): Promise {
+ try {
+ const folderPath = await this.electronAPIs.selectRootDirectory();
+ return folderPath;
+ } catch (e) {
+ logError(e, 'error while selecting folder');
+ }
+ }
+
+ // Batches all the files to be uploaded (or trashed) from the
+ // event queue of same collection as the next event
+ private clubSameCollectionEvents(): EventQueueItem {
+ const event = this.eventQueue.shift();
+ while (
+ this.eventQueue.length > 0 &&
+ event.collectionName === this.eventQueue[0].collectionName &&
+ event.type === this.eventQueue[0].type
+ ) {
+ if (event.type === 'trash') {
+ event.paths = [...event.paths, ...this.eventQueue[0].paths];
+ } else {
+ event.files = [...event.files, ...this.eventQueue[0].files];
+ }
+ this.eventQueue.shift();
+ }
+ return event;
+ }
+
+ async isFolder(folderPath: string) {
+ try {
+ const isFolder = await this.electronAPIs.isFolder(folderPath);
+ return isFolder;
+ } catch (e) {
+ logError(e, 'error while checking if folder exists');
+ }
+ }
+
+ pauseRunningSync() {
+ this.isPaused = true;
+ uploadManager.cancelRunningUpload();
+ }
+
+ resumePausedSync() {
+ this.isPaused = false;
+ this.getAndSyncDiffOfFiles();
+ }
+}
+
+export default new watchFolderService();
diff --git a/src/themes/darkThemeOptions.tsx b/src/themes/darkThemeOptions.tsx
index fe05e8ca0f634ef6723916b116e712f2a94bc826..2688f581257054fde9dcee750f819b7ecf806ec9 100644
--- a/src/themes/darkThemeOptions.tsx
+++ b/src/themes/darkThemeOptions.tsx
@@ -130,11 +130,13 @@ const darkThemeOptions = createTheme({
},
MuiLink: {
defaultProps: {
- underline: 'always',
+ color: '#1dba54',
+ underline: 'none',
},
styleOverrides: {
root: {
'&:hover': {
+ underline: 'always',
color: '#1dba54',
},
},
@@ -285,13 +287,14 @@ const darkThemeOptions = createTheme({
},
background: {
default: '#000000',
- paper: '#141414',
- overPaper: '#1b1b1b',
+ paper: '#1b1b1b',
+ overPaper: '#252525',
},
grey: {
A100: '#ccc',
A200: 'rgba(256, 256, 256, 0.24)',
A400: '#434343',
+ 500: 'rgba(256, 256, 256, 0.5)',
},
divider: 'rgba(256, 256, 256, 0.16)',
},
diff --git a/src/types/electron/index.ts b/src/types/electron/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6c67b5fd14078f6a446fcbbd8bbc19b7c209eb1d
--- /dev/null
+++ b/src/types/electron/index.ts
@@ -0,0 +1,66 @@
+import { LimitedCache } from 'types/cache';
+import { ElectronFile } from 'types/upload';
+import { WatchMapping } from 'types/watchFolder';
+
+export interface ElectronAPIs {
+ exists: (path: string) => boolean;
+ checkExistsAndCreateCollectionDir: (dirPath: string) => Promise;
+ checkExistsAndRename: (
+ oldDirPath: string,
+ newDirPath: string
+ ) => Promise;
+ saveStreamToDisk: (path: string, fileStream: ReadableStream) => void;
+ saveFileToDisk: (path: string, file: any) => Promise;
+ selectRootDirectory: () => Promise;
+ sendNotification: (content: string) => void;
+ showOnTray: (content?: any) => void;
+ registerResumeExportListener: (resumeExport: () => void) => void;
+ registerStopExportListener: (abortExport: () => void) => void;
+ registerPauseExportListener: (pauseExport: () => void) => void;
+ registerRetryFailedExportListener: (retryFailedExport: () => void) => void;
+ getExportRecord: (filePath: string) => Promise;
+ setExportRecord: (filePath: string, data: string) => Promise;
+ showUploadFilesDialog: () => Promise;
+ showUploadDirsDialog: () => Promise;
+ getPendingUploads: () => Promise<{
+ files: ElectronFile[];
+ collectionName: string;
+ type: string;
+ }>;
+ setToUploadFiles: (type: string, filePaths: string[]) => void;
+ showUploadZipDialog: () => Promise<{
+ zipPaths: string[];
+ files: ElectronFile[];
+ }>;
+ getElectronFilesFromGoogleZip: (
+ filePath: string
+ ) => Promise;
+ setToUploadCollection: (collectionName: string) => void;
+ getDirFiles: (dirPath: string) => Promise;
+ getWatchMappings: () => WatchMapping[];
+ updateWatchMappingSyncedFiles: (
+ folderPath: string,
+ files: WatchMapping['syncedFiles']
+ ) => void;
+ updateWatchMappingIgnoredFiles: (
+ folderPath: string,
+ files: WatchMapping['ignoredFiles']
+ ) => void;
+ addWatchMapping: (
+ collectionName: string,
+ folderPath: string,
+ uploadStrategy: number
+ ) => Promise;
+ removeWatchMapping: (folderPath: string) => Promise;
+ registerWatcherFunctions: (
+ addFile: (file: ElectronFile) => Promise,
+ removeFile: (path: string) => Promise,
+ removeFolder: (folderPath: string) => Promise
+ ) => void;
+ isFolder: (dirPath: string) => Promise;
+ clearElectronStore: () => void;
+ setEncryptionKey: (encryptionKey: string) => Promise;
+ getEncryptionKey: () => Promise;
+ openDiskCache: (cacheName: string) => Promise;
+ deleteDiskCache: (cacheName: string) => Promise;
+}
diff --git a/src/types/file/index.ts b/src/types/file/index.ts
index b55afbbbf327b9f746ecf612edad4bb9e12e7136..1bc7a8d0ab2acc23841874de93064bbeb8e477b0 100644
--- a/src/types/file/index.ts
+++ b/src/types/file/index.ts
@@ -9,6 +9,7 @@ export interface fileAttribute {
export interface FileMagicMetadataProps {
visibility?: VISIBILITY_STATE;
+ filePaths?: string[];
}
export interface FileMagicMetadata extends Omit {
diff --git a/src/types/upload/index.ts b/src/types/upload/index.ts
index 076ac7fff8d2826b493e09e75f70f163f486b71a..871e03e0aef510330640a586ac7d9b5aa295e644 100644
--- a/src/types/upload/index.ts
+++ b/src/types/upload/index.ts
@@ -92,6 +92,7 @@ export interface FileWithCollection extends UploadAsset {
export interface MetadataAndFileTypeInfo {
metadata: Metadata;
fileTypeInfo: FileTypeInfo;
+ filePath: string;
}
export type MetadataAndFileTypeInfoMap = Map;
@@ -142,3 +143,9 @@ export interface ParsedExtractedMetadata {
location: Location;
creationTime: number;
}
+
+// This is used to prompt the user the make upload strategy choice
+export interface ImportSuggestion {
+ rootFolderName: string;
+ hasNestedFolders: boolean;
+}
diff --git a/src/types/user/index.ts b/src/types/user/index.ts
index b8554defa74d089c80f82072ab4fc6fad9ab5d4e..c47dca3fbcfcc4acbc9f5cec73cd1eaa4b00cc7e 100644
--- a/src/types/user/index.ts
+++ b/src/types/user/index.ts
@@ -35,15 +35,11 @@ export interface RecoveryKey {
}
export interface User {
id: number;
- name: string;
email: string;
token: string;
encryptedToken: string;
isTwoFactorEnabled: boolean;
twoFactorSessionID: string;
- usage: number;
- fileCount: number;
- sharedCollectionCount: number;
}
export interface EmailVerificationResponse {
id: number;
@@ -91,3 +87,8 @@ export interface UserDetails {
subscription: Subscription;
familyData?: FamilyData;
}
+
+export interface DeleteChallengeResponse {
+ allowDelete: boolean;
+ encryptedChallenge: string;
+}
diff --git a/src/types/watchFolder/index.ts b/src/types/watchFolder/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e03897b105f4fcd753f52c8dcba69425145aee29
--- /dev/null
+++ b/src/types/watchFolder/index.ts
@@ -0,0 +1,24 @@
+import { UPLOAD_STRATEGY } from 'constants/upload';
+import { ElectronFile } from 'types/upload';
+
+export interface WatchMappingSyncedFile {
+ path: string;
+ uploadedFileID: number;
+ collectionID: number;
+}
+
+export interface WatchMapping {
+ rootFolderName: string;
+ folderPath: string;
+ uploadStrategy: UPLOAD_STRATEGY;
+ syncedFiles: WatchMappingSyncedFile[];
+ ignoredFiles: string[];
+}
+
+export interface EventQueueItem {
+ type: 'upload' | 'trash';
+ folderPath: string;
+ collectionName?: string;
+ paths?: string[];
+ files?: ElectronFile[];
+}
diff --git a/src/utils/billing/index.ts b/src/utils/billing/index.ts
index b1301c1a4cfc17d0b44cd5a91c165f4c908abb1c..2394eb487fde894dd0ce369db4528f36591fb044 100644
--- a/src/utils/billing/index.ts
+++ b/src/utils/billing/index.ts
@@ -14,7 +14,6 @@ import { openLink } from 'utils/common';
const PAYMENT_PROVIDER_STRIPE = 'stripe';
const PAYMENT_PROVIDER_APPSTORE = 'appstore';
const PAYMENT_PROVIDER_PLAYSTORE = 'playstore';
-const PAYMENT_PROVIDER_PAYPAL = 'paypal';
const FREE_PLAN = 'free';
enum FAILURE_REASON {
@@ -169,14 +168,6 @@ export function hasMobileSubscription(subscription: Subscription) {
);
}
-export function hasPaypalSubscription(subscription: Subscription) {
- return (
- hasPaidSubscription(subscription) &&
- subscription.paymentProvider.length > 0 &&
- subscription.paymentProvider === PAYMENT_PROVIDER_PAYPAL
- );
-}
-
export function hasExceededStorageQuota(userDetails: UserDetails) {
if (isPartOfFamily(userDetails.familyData)) {
const usage = getTotalFamilyUsage(userDetails.familyData);
diff --git a/src/utils/collection/index.ts b/src/utils/collection/index.ts
index 572fe5a43905e3b10eaa85abc819be84be380f0a..1743e2c91cc8d0c952ffc21e8225660c25e97fb8 100644
--- a/src/utils/collection/index.ts
+++ b/src/utils/collection/index.ts
@@ -58,7 +58,7 @@ export async function handleCollectionOps(
);
break;
case COLLECTION_OPS_TYPE.REMOVE:
- await removeFromCollection(collection, selectedFiles);
+ await removeFromCollection(collection.id, selectedFiles);
break;
case COLLECTION_OPS_TYPE.RESTORE:
await restoreToCollection(collection, selectedFiles);
@@ -197,10 +197,10 @@ export const getArchivedCollections = (collections: Collection[]) => {
);
};
-export const hasNonEmptyCollections = (
+export const hasNonSystemCollections = (
collectionSummaries: CollectionSummaries
) => {
- return collectionSummaries?.size <= 3;
+ return collectionSummaries?.size > 3;
};
export const isUploadAllowedCollection = (type: CollectionSummaryType) => {
@@ -218,3 +218,11 @@ export const shouldShowOptions = (type: CollectionSummaryType) => {
export const shouldBeShownOnCollectionBar = (type: CollectionSummaryType) => {
return !HIDE_FROM_COLLECTION_BAR_TYPES.has(type);
};
+
+export const getUserOwnedCollections = (collections: Collection[]) => {
+ const user: User = getData(LS_KEYS.USER);
+ if (!user?.id) {
+ throw Error('user missing');
+ }
+ return collections.filter((collection) => collection.owner.id === user.id);
+};
diff --git a/src/utils/common/index.ts b/src/utils/common/index.ts
index c28c0cbd7f824ddf0debfe112d037ee26dd646a3..5fc9e170acc7d68bd014ea0380a56566f793a04d 100644
--- a/src/utils/common/index.ts
+++ b/src/utils/common/index.ts
@@ -129,3 +129,21 @@ export function openLink(href: string, newTab?: boolean) {
a.rel = 'noreferrer noopener';
a.click();
}
+
+export async function waitAndRun(
+ waitPromise: Promise,
+ task: () => Promise
+) {
+ if (waitPromise && isPromise(waitPromise)) {
+ await waitPromise;
+ }
+ await task();
+}
+
+function isPromise(p: any) {
+ if (typeof p === 'object' && typeof p.then === 'function') {
+ return true;
+ }
+
+ return false;
+}
diff --git a/src/utils/crypto/index.ts b/src/utils/crypto/index.ts
index 38f303b24bbec92d5c5b5ee4e212123154f366db..f1511ec54747286660d32f1ebddc900ac206b0c7 100644
--- a/src/utils/crypto/index.ts
+++ b/src/utils/crypto/index.ts
@@ -201,4 +201,24 @@ export async function encryptWithRecoveryKey(key: string) {
);
return encryptedKey;
}
+
+export async function decryptDeleteAccountChallenge(
+ encryptedChallenge: string
+) {
+ const cryptoWorker = await new CryptoWorker();
+ const masterKey = await getActualKey();
+ const keyAttributes = getData(LS_KEYS.KEY_ATTRIBUTES);
+ const secretKey = await cryptoWorker.decryptB64(
+ keyAttributes.encryptedSecretKey,
+ keyAttributes.secretKeyDecryptionNonce,
+ masterKey
+ );
+ const b64DecryptedChallenge = await cryptoWorker.boxSealOpen(
+ encryptedChallenge,
+ keyAttributes.publicKey,
+ secretKey
+ );
+ const utf8DecryptedChallenge = atob(b64DecryptedChallenge);
+ return utf8DecryptedChallenge;
+}
export default CryptoWorker;
diff --git a/src/utils/error/index.ts b/src/utils/error/index.ts
index 32d5cec57fdb686a994cbec9bdc5182fe5a4a830..5a02dbbd3b72056076dee69b4089ff5cb5b52380 100644
--- a/src/utils/error/index.ts
+++ b/src/utils/error/index.ts
@@ -47,6 +47,8 @@ export enum CustomError {
FILE_ID_NOT_FOUND = 'file with id not found',
WEAK_DEVICE = 'password decryption failed on the device',
INCORRECT_PASSWORD = 'incorrect password',
+ UPLOAD_CANCELLED = 'upload cancelled',
+ REQUEST_TIMEOUT = 'request taking too long',
}
export function parseServerError(error: AxiosResponse): string {
@@ -106,6 +108,7 @@ export function handleUploadError(error): Error {
case CustomError.SUBSCRIPTION_EXPIRED:
case CustomError.STORAGE_QUOTA_EXCEEDED:
case CustomError.SESSION_EXPIRED:
+ case CustomError.UPLOAD_CANCELLED:
throw parsedError;
}
return parsedError;
diff --git a/src/utils/export/index.ts b/src/utils/export/index.ts
index ed58f4420b9baf82958fab9c5f05623da899e0df..7b40b2ce1c3a966bcc27014f4f18e834e3a5c964 100644
--- a/src/utils/export/index.ts
+++ b/src/utils/export/index.ts
@@ -6,7 +6,7 @@ import { EnteFile } from 'types/file';
import { Metadata } from 'types/upload';
import { formatDate, splitFilenameAndExtension } from 'utils/file';
-import { METADATA_FOLDER_NAME } from 'constants/export';
+import { ENTE_METADATA_FOLDER } from 'constants/export';
export const getExportRecordFileUID = (file: EnteFile) =>
`${file.id}_${file.collectionID}_${file.updationTime}`;
@@ -179,7 +179,7 @@ export const getUniqueCollectionFolderPath = (
};
export const getMetadataFolderPath = (collectionFolderPath: string) =>
- `${collectionFolderPath}/${METADATA_FOLDER_NAME}`;
+ `${collectionFolderPath}/${ENTE_METADATA_FOLDER}`;
export const getUniqueFileSaveName = (
collectionPath: string,
@@ -211,7 +211,7 @@ export const getOldFileSaveName = (filename: string, fileID: number) =>
export const getFileMetadataSavePath = (
collectionFolderPath: string,
fileSaveName: string
-) => `${collectionFolderPath}/${METADATA_FOLDER_NAME}/${fileSaveName}.json`;
+) => `${collectionFolderPath}/${ENTE_METADATA_FOLDER}/${fileSaveName}.json`;
export const getFileSavePath = (
collectionFolderPath: string,
@@ -235,6 +235,6 @@ export const getOldFileMetadataSavePath = (
collectionFolderPath: string,
file: EnteFile
) =>
- `${collectionFolderPath}/${METADATA_FOLDER_NAME}/${
+ `${collectionFolderPath}/${ENTE_METADATA_FOLDER}/${
file.id
}_${oldSanitizeName(file.metadata.title)}.json`;
diff --git a/src/utils/ffmpeg/index.ts b/src/utils/ffmpeg/index.ts
index c61d3f89fac406f3252e6942b9e75c6f1d87c331..916806c3e7d918864dbaf110d1440d5b7a6ffe65 100644
--- a/src/utils/ffmpeg/index.ts
+++ b/src/utils/ffmpeg/index.ts
@@ -61,3 +61,13 @@ function parseCreationTime(creationTime: string) {
}
return dateTime;
}
+
+export function splitFilenameAndExtension(filename: string): [string, string] {
+ const lastDotPosition = filename.lastIndexOf('.');
+ if (lastDotPosition === -1) return [filename, null];
+ else
+ return [
+ filename.slice(0, lastDotPosition),
+ filename.slice(lastDotPosition + 1),
+ ];
+}
diff --git a/src/utils/file/index.ts b/src/utils/file/index.ts
index 879fbeb6d993b608e8b763452c92608ef20a8c5c..3d29c026f79e600ac5362305452997cf953f1b1a 100644
--- a/src/utils/file/index.ts
+++ b/src/utils/file/index.ts
@@ -25,7 +25,7 @@ import HEICConverter from 'services/heicConverter/heicConverterService';
import ffmpegService from 'services/ffmpeg/ffmpegService';
import { NEW_FILE_MAGIC_METADATA, VISIBILITY_STATE } from 'types/magicMetadata';
import { IsArchived, updateMagicMetadataProps } from 'utils/magicMetadata';
-import { ARCHIVE_SECTION, TRASH_SECTION } from 'constants/collection';
+
import { addLogLine } from 'utils/logging';
import { makeHumanReadableStorage } from 'utils/billing';
export function downloadAsFile(filename: string, content: string) {
@@ -131,22 +131,14 @@ function downloadUsingAnchor(link: string, name: string) {
a.remove();
}
-export function sortFilesIntoCollections(files: EnteFile[]) {
- const collectionWiseFiles = new Map([
- [ARCHIVE_SECTION, []],
- [TRASH_SECTION, []],
- ]);
+export function groupFilesBasedOnCollectionID(files: EnteFile[]) {
+ const collectionWiseFiles = new Map();
for (const file of files) {
if (!collectionWiseFiles.has(file.collectionID)) {
collectionWiseFiles.set(file.collectionID, []);
}
- if (file.isTrashed) {
- collectionWiseFiles.get(TRASH_SECTION).push(file);
- } else {
+ if (!file.isTrashed) {
collectionWiseFiles.get(file.collectionID).push(file);
- if (IsArchived(file)) {
- collectionWiseFiles.get(ARCHIVE_SECTION).push(file);
- }
}
}
return collectionWiseFiles;
@@ -229,14 +221,20 @@ export async function decryptFile(file: EnteFile, collectionKey: string) {
encryptedMetadata.decryptionHeader,
file.key
);
- if (file.magicMetadata?.data) {
+ if (
+ file.magicMetadata?.data &&
+ typeof file.magicMetadata.data === 'string'
+ ) {
file.magicMetadata.data = await worker.decryptMetadata(
file.magicMetadata.data,
file.magicMetadata.header,
file.key
);
}
- if (file.pubMagicMetadata?.data) {
+ if (
+ file.pubMagicMetadata?.data &&
+ typeof file.pubMagicMetadata.data === 'string'
+ ) {
file.pubMagicMetadata.data = await worker.decryptMetadata(
file.pubMagicMetadata.data,
file.pubMagicMetadata.header,
@@ -416,9 +414,7 @@ export async function changeFileName(file: EnteFile, editedName: string) {
return file;
}
-export function isSharedFile(file: EnteFile) {
- const user: User = getData(LS_KEYS.USER);
-
+export function isSharedFile(user: User, file: EnteFile) {
if (!user?.id || !file?.ownerID) {
return false;
}
@@ -516,3 +512,11 @@ export const createTypedObjectURL = async (blob: Blob, fileName: string) => {
const type = await getFileType(new File([blob], fileName));
return URL.createObjectURL(new Blob([blob], { type: type.mimeType }));
};
+
+export const getUserOwnedNonTrashedFiles = (files: EnteFile[]) => {
+ const user: User = getData(LS_KEYS.USER);
+ if (!user?.id) {
+ throw Error('user missing');
+ }
+ return files.filter((file) => file.isTrashed || file.ownerID === user.id);
+};
diff --git a/src/utils/logging/index.ts b/src/utils/logging/index.ts
index 409a46e573d450731ba3abd204eb403220ffbefc..0630080def0ce58a0526f9cc96a7f10e4a9a2ade 100644
--- a/src/utils/logging/index.ts
+++ b/src/utils/logging/index.ts
@@ -12,12 +12,21 @@ export function pipeConsoleLogsToDebugLogs() {
}
export function addLogLine(log: string) {
+ if (!process.env.NEXT_PUBLIC_SENTRY_ENV) {
+ console.log(log);
+ }
saveLogLine({
timestamp: Date.now(),
logLine: log,
});
}
+export const addLocalLog = (getLog: () => string) => {
+ if (!process.env.NEXT_PUBLIC_SENTRY_ENV) {
+ console.log(getLog());
+ }
+};
+
export function getDebugLogs() {
return getLogs().map(
(log) => `[${formatDateTime(log.timestamp)}] ${log.logLine}`
diff --git a/src/utils/strings/englishConstants.tsx b/src/utils/strings/englishConstants.tsx
index 943d10d8cd5ef2f0ee811febcb215c779110b16a..bbd381e10f532b351d6400f1e052a05e4b3fb3e7 100644
--- a/src/utils/strings/englishConstants.tsx
+++ b/src/utils/strings/englishConstants.tsx
@@ -111,8 +111,8 @@ const englishConstants = {
2: 'Reading file metadata',
3: (fileCounter) =>
`${fileCounter.finished} / ${fileCounter.total} files backed up`,
- 4: 'Backup complete',
- 5: 'Cancelling remaining uploads',
+ 4: 'Cancelling remaining uploads',
+ 5: 'Backup complete',
},
UPLOADING_FILES: 'File upload',
FILE_NOT_UPLOADED_LIST: 'The following files were not uploaded',
@@ -146,6 +146,9 @@ const englishConstants = {
),
UPLOAD_FIRST_PHOTO: 'Preserve',
UPLOAD_DROPZONE_MESSAGE: 'Drop to backup your files',
+ WATCH_FOLDER_DROPZONE_MESSAGE: 'Drop to add watched folder',
+ CONFIRM_DELETE: 'Confirm deletion',
+ DELETE_MESSAGE: `The selected files will be permanently deleted and can't be restored `,
TRASH_FILES_TITLE: 'Delete files?',
DELETE_FILES_TITLE: 'Delete immediately?',
DELETE_FILES_MESSAGE:
@@ -227,7 +230,7 @@ const englishConstants = {
CHANGE: 'Change',
CHANGE_EMAIL: 'Change email',
OK: 'Ok',
- SUCCESS: 'success',
+ SUCCESS: 'Success',
ERROR: 'Error',
MESSAGE: 'Message',
INSTALL_MOBILE_APP: () => (
@@ -284,20 +287,28 @@ const englishConstants = {
FAMILY_SUBSCRIPTION_INFO: 'You are on a family plan managed by',
- RENEWAL_ACTIVE_SUBSCRIPTION_INFO: (expiryTime) => (
+ RENEWAL_ACTIVE_SUBSCRIPTION_STATUS: (expiryTime) => (
<>Renews on {dateString(expiryTime)}>
),
+ RENEWAL_CANCELLED_SUBSCRIPTION_STATUS: (expiryTime) => (
+ <>Ends on {dateString(expiryTime)}>
+ ),
RENEWAL_CANCELLED_SUBSCRIPTION_INFO: (expiryTime) => (
<>Your subscription will be cancelled on {dateString(expiryTime)}>
),
- STORAGE_QUOTA_EXCEEDED_SUBSCRIPTION_INFO: `You have exceeded your storage quota, please upgrade your plan.`,
+ STORAGE_QUOTA_EXCEEDED_SUBSCRIPTION_INFO: (onClick) => (
+ <>
+ You have exceeded your storage quota,, please{' '}
+ upgrade
+ >
+ ),
SUBSCRIPTION_PURCHASE_SUCCESS: (expiryTime) => (
<>
We've received your payment
- your subscription is valid till{' '}
+ Your subscription is valid till{' '}
{dateString(expiryTime)}
>
@@ -327,30 +338,29 @@ const englishConstants = {
All of your data will be deleted from our servers at the end of
this billing period.
- are you sure that you want to cancel your subscription?
+ Are you sure that you want to cancel your subscription?
>
),
SUBSCRIPTION_CANCEL_FAILED: 'Failed to cancel subscription',
SUBSCRIPTION_CANCEL_SUCCESS: 'Subscription canceled successfully',
- ACTIVATE_SUBSCRIPTION: 'Reactivate subscription',
- CONFIRM_ACTIVATE_SUBSCRIPTION: 'Activate subscription ',
- ACTIVATE_SUBSCRIPTION_MESSAGE: (expiryTime) =>
+ REACTIVATE_SUBSCRIPTION: 'Reactivate subscription',
+ REACTIVATE_SUBSCRIPTION_MESSAGE: (expiryTime) =>
`Once reactivated, you will be billed on ${dateString(expiryTime)}`,
SUBSCRIPTION_ACTIVATE_SUCCESS: 'Subscription activated successfully ',
SUBSCRIPTION_ACTIVATE_FAILED: 'Failed to reactivate subscription renewals',
SUBSCRIPTION_PURCHASE_SUCCESS_TITLE: 'Thank you',
- CANCEL_SUBSCRIPTION_ON_MOBILE:
+ CANCEL_SUBSCRIPTION_ON_MOBILE: 'Cancel mobile subscription',
+ CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE:
'Please cancel your subscription from the mobile app to activate a subscription here',
- PAYPAL_MANAGE_NOT_SUPPORTED_MESSAGE: () => (
+ MAIL_TO_MANAGE_SUBSCRIPTION: (
<>
Please contact us at{' '}
- paypal@ente.io to manage your
- subscription
+ support@ente.io to
+ manage your subscription
>
),
- PAYPAL_MANAGE_NOT_SUPPORTED: 'Manage paypal plan',
RENAME: 'Rename',
RENAME_COLLECTION: 'Rename album',
DELETE_COLLECTION_TITLE: 'Delete album?',
@@ -517,7 +527,7 @@ const englishConstants = {
SUCCESSFULLY_EXPORTED_FILES: 'Successful exports',
FAILED_EXPORTED_FILES: 'Failed exports',
EXPORT_AGAIN: 'Resync',
- RETRY_EXPORT_: 'Tetry failed exports',
+ RETRY_EXPORT_: 'Retry failed exports',
LOCAL_STORAGE_NOT_ACCESSIBLE: 'Local storage not accessible',
LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE:
'Your browser or an addon is blocking ente from saving data into local storage. please try loading this page after switching your browsing mode.',
@@ -760,6 +770,7 @@ const englishConstants = {
>
),
+ WATCH_FOLDERS: 'Watch folders',
UPGRADE_NOW: 'Upgrade now',
RENEW_NOW: 'Renew now',
STORAGE: 'Storage',
@@ -768,6 +779,18 @@ const englishConstants = {
FAMILY: 'Family',
FREE: 'free',
OF: 'of',
+ WATCHED_FOLDERS: 'Watched folders',
+ NO_FOLDERS_ADDED: 'No folders added yet!',
+ FOLDERS_AUTOMATICALLY_MONITORED:
+ 'The folders you add here will monitored to automatically',
+ UPLOAD_NEW_FILES_TO_ENTE: 'Upload new files to ente',
+ REMOVE_DELETED_FILES_FROM_ENTE: 'Remove deleted files from ente',
+ ADD_FOLDER: 'Add folder',
+ STOP_WATCHING: 'Stop watching',
+ STOP_WATCHING_FOLDER: 'Stop watching folder?',
+ STOP_WATCHING_DIALOG_MESSAGE:
+ 'Your existing files will not be deleted, but ente will stop automatically updating the linked ente album on changes in this folder.',
+ YES_STOP: 'Yes, stop',
MONTH_SHORT: 'mo',
YEAR: 'year',
FAMILY_PLAN: 'Family plan',
@@ -798,6 +821,32 @@ const englishConstants = {
WEAK_DEVICE:
"The web browser you're using is not powerful enough to encrypt your photos. Please try to log in to ente on your computer, or download the ente mobile/desktop app.",
DRAG_AND_DROP_HINT: 'Or drag and drop into the ente window',
+ ASK_FOR_FEEDBACK: (
+ <>
+ We'll be sorry to see you go. Are you facing some issue?
+
+ Please write to us at{' '}
+ feedback@ente.io,
+ maybe there is a way we can help.
+
+ >
+ ),
+ SEND_FEEDBACK: 'Yes, send feedback',
+ CONFIRM_ACCOUNT_DELETION_TITLE:
+ 'Are you sure you want to delete your account?',
+ CONFIRM_ACCOUNT_DELETION_MESSAGE: (
+ <>
+
+ Your uploaded data will be scheduled for deletion, and your
+ account will be permanently deleted.
+
+ This action is not reversible.
+ >
+ ),
+ AUTHENTICATE: 'Authenticate',
+ UPLOADED_TO_SINGLE_COLLECTION: 'Uploaded to single collection',
+ UPLOADED_TO_SEPARATE_COLLECTIONS: 'Uploaded to separate collections',
+ NEVERMIND: 'Nevermind',
};
export default englishConstants;
diff --git a/src/utils/upload/index.ts b/src/utils/upload/index.ts
index e3034ff96a3f4a463fbaa27eafcb1628d5babdf7..73ad879223a6eb5de41671d2c8136efdcb50dbc8 100644
--- a/src/utils/upload/index.ts
+++ b/src/utils/upload/index.ts
@@ -1,40 +1,33 @@
-import { FileWithCollection, Metadata } from 'types/upload';
+import {
+ ImportSuggestion,
+ ElectronFile,
+ FileWithCollection,
+ Metadata,
+} from 'types/upload';
import { EnteFile } from 'types/file';
-import { A_SEC_IN_MICROSECONDS } from 'constants/upload';
+import {
+ A_SEC_IN_MICROSECONDS,
+ DEFAULT_IMPORT_SUGGESTION,
+ PICKED_UPLOAD_TYPE,
+} from 'constants/upload';
import { FILE_TYPE } from 'constants/file';
+import { ENTE_METADATA_FOLDER } from 'constants/export';
+import isElectron from 'is-electron';
const TYPE_JSON = 'json';
const DEDUPE_COLLECTION = new Set(['icloud library', 'icloudlibrary']);
-export function fileAlreadyInCollection(
- existingFilesInCollection: EnteFile[],
- newFileMetadata: Metadata
-): boolean {
- for (const existingFile of existingFilesInCollection) {
- if (areFilesSame(existingFile.metadata, newFileMetadata)) {
- return true;
- }
- }
- return false;
-}
-
-export function findSameFileInOtherCollection(
+export function findMatchingExistingFiles(
existingFiles: EnteFile[],
newFileMetadata: Metadata
-) {
- if (!hasFileHash(newFileMetadata)) {
- return null;
- }
-
+): EnteFile[] {
+ const matchingFiles: EnteFile[] = [];
for (const existingFile of existingFiles) {
- if (
- hasFileHash(existingFile.metadata) &&
- areFilesWithFileHashSame(existingFile.metadata, newFileMetadata)
- ) {
- return existingFile;
+ if (areFilesSame(existingFile.metadata, newFileMetadata)) {
+ matchingFiles.push(existingFile);
}
}
- return null;
+ return matchingFiles;
}
export function shouldDedupeAcrossCollection(collectionName: string): boolean {
@@ -101,10 +94,6 @@ export function segregateMetadataAndMediaFiles(
const mediaFiles: FileWithCollection[] = [];
filesWithCollectionToUpload.forEach((fileWithCollection) => {
const file = fileWithCollection.file;
- if (file.name.startsWith('.')) {
- // ignore files with name starting with . (hidden files)
- return;
- }
if (file.name.toLowerCase().endsWith(TYPE_JSON)) {
metadataJSONFiles.push(fileWithCollection);
} else {
@@ -120,3 +109,101 @@ export function areFileWithCollectionsSame(
): boolean {
return firstFile.localID === secondFile.localID;
}
+
+export function getImportSuggestion(
+ uploadType: PICKED_UPLOAD_TYPE,
+ toUploadFiles: File[] | ElectronFile[]
+): ImportSuggestion {
+ if (isElectron() && uploadType === PICKED_UPLOAD_TYPE.FILES) {
+ return DEFAULT_IMPORT_SUGGESTION;
+ }
+
+ const paths: string[] = toUploadFiles.map((file) => file['path']);
+ const getCharCount = (str: string) => (str.match(/\//g) ?? []).length;
+ paths.sort((path1, path2) => getCharCount(path1) - getCharCount(path2));
+ const firstPath = paths[0];
+ const lastPath = paths[paths.length - 1];
+
+ const L = firstPath.length;
+ let i = 0;
+ const firstFileFolder = firstPath.substring(0, firstPath.lastIndexOf('/'));
+ const lastFileFolder = lastPath.substring(0, lastPath.lastIndexOf('/'));
+ while (i < L && firstPath.charAt(i) === lastPath.charAt(i)) i++;
+ let commonPathPrefix = firstPath.substring(0, i);
+
+ if (commonPathPrefix) {
+ commonPathPrefix = commonPathPrefix.substring(
+ 0,
+ commonPathPrefix.lastIndexOf('/')
+ );
+ if (commonPathPrefix) {
+ commonPathPrefix = commonPathPrefix.substring(
+ commonPathPrefix.lastIndexOf('/') + 1
+ );
+ }
+ }
+ return {
+ rootFolderName: commonPathPrefix || null,
+ hasNestedFolders: firstFileFolder !== lastFileFolder,
+ };
+}
+
+// This function groups files that are that have the same parent folder into collections
+// For Example, for user files have a directory structure like this
+// a
+// / | \
+// b j c
+// /|\ / \
+// e f g h i
+//
+// The files will grouped into 3 collections.
+// [a => [j],
+// b => [e,f,g],
+// c => [h, i]]
+export function groupFilesBasedOnParentFolder(
+ toUploadFiles: File[] | ElectronFile[]
+) {
+ const collectionNameToFilesMap = new Map();
+ for (const file of toUploadFiles) {
+ const filePath = file['path'] as string;
+
+ let folderPath = filePath.substring(0, filePath.lastIndexOf('/'));
+ // If the parent folder of a file is "metadata"
+ // we consider it to be part of the parent folder
+ // For Eg,For FileList -> [a/x.png, a/metadata/x.png.json]
+ // they will both we grouped into the collection "a"
+ // This is cluster the metadata json files in the same collection as the file it is for
+ if (folderPath.endsWith(ENTE_METADATA_FOLDER)) {
+ folderPath = folderPath.substring(0, folderPath.lastIndexOf('/'));
+ }
+ const folderName = folderPath.substring(
+ folderPath.lastIndexOf('/') + 1
+ );
+ if (!folderName?.length) {
+ throw Error("folderName can't be null");
+ }
+ if (!collectionNameToFilesMap.has(folderName)) {
+ collectionNameToFilesMap.set(folderName, []);
+ }
+ collectionNameToFilesMap.get(folderName).push(file);
+ }
+ return collectionNameToFilesMap;
+}
+
+export function filterOutSystemFiles(files: File[] | ElectronFile[]) {
+ if (files[0] instanceof File) {
+ const browserFiles = files as File[];
+ return browserFiles.filter((file) => {
+ return !isSystemFile(file);
+ });
+ } else {
+ const electronFiles = files as ElectronFile[];
+ return electronFiles.filter((file) => {
+ return !isSystemFile(file);
+ });
+ }
+}
+
+export function isSystemFile(file: File | ElectronFile) {
+ return file.name.startsWith('.');
+}
diff --git a/src/utils/watch/index.ts b/src/utils/watch/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9e11a1e1df6185175d7f331646d2af42164fa32f
--- /dev/null
+++ b/src/utils/watch/index.ts
@@ -0,0 +1,26 @@
+import { ElectronFile } from 'types/upload';
+import { WatchMapping } from 'types/watchFolder';
+import { isSystemFile } from 'utils/upload';
+
+function isSyncedOrIgnoredFile(file: ElectronFile, mapping: WatchMapping) {
+ return (
+ mapping.ignoredFiles.includes(file.path) ||
+ mapping.syncedFiles.find((f) => f.path === file.path)
+ );
+}
+
+export function getValidFilesToUpload(
+ files: ElectronFile[],
+ mapping: WatchMapping
+) {
+ const uniqueFilePaths = new Set();
+ return files.filter((file) => {
+ if (!isSystemFile(file) && !isSyncedOrIgnoredFile(file, mapping)) {
+ if (!uniqueFilePaths.has(file.path)) {
+ uniqueFilePaths.add(file.path);
+ return true;
+ }
+ }
+ return false;
+ });
+}