diff --git a/README.md b/README.md
index d90d2e134..ea27599da 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 55ea31c8f..b454d8592 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 8e87b1279..265f647da 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 000000000..b8288cee8
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 000000000..31c9014bc
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 000000000..7be1e70de
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 000000000..092119d34
--- /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 e89bc5ead..cfaa96ce1 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 06d16798d..b60e93e05 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 76844f93b..bcb7464ca 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 000000000..94478135c
--- /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 6db198c75..395c55cc6 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 1d0e328ef..4609f63fd 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 4f0e96da5..0450b43fe 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 41ad96fb1..e48f41cf3 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 41f4b5567..11ccc4036 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 72a428af1..b2157f12c 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 6ecbb72a7..b77813bb2 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 4531df6a9..d62739a29 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 f0d2160f1..37f5fd676 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 e802a83a0..72667ae1c 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 24bbe30bd..5d538f0cb 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 c314dcd98..1d860083f 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);
-
- handleCollectionCreationAndUpload(
- analysisResult,
- props.isFirstUpload
- );
- props.setLoading(false);
+ 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([]);
}
- }
- }, [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);
- };
+ toUploadFiles.current = filterOutSystemFiles(toUploadFiles.current);
+ if (toUploadFiles.current.length === 0) {
+ props.setLoading(false);
+ return;
+ }
+
+ 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 000000000..a3a93c2c1
--- /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 000000000..6d1912de7
--- /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 000000000..c1251f5ba
--- /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 000000000..8189a4c3d
--- /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 000000000..940059c7c
--- /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 000000000..631736329
--- /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 000000000..00e157d2e
--- /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 000000000..3e6d595c1
--- /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 3bea7e64c..a56d68214 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 89771f8f9..5b9166e78 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 59407498a..bd45a4481 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"
- />
+
{constants.FORGOT_PASSWORD}
diff --git a/src/pages/gallery/index.tsx b/src/pages/gallery/index.tsx
index 9b3a4db09..08baf3c35 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 4a9d5311a..9ab0e0b12 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 d1bc51991..46e1aefa8 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 f1d6d7eb2..7b0d65854 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 000000000..a7b639dd8
--- /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 321c774c0..3d80ef79c 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 5dde53f1a..3058858a0 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 7aa7a29b0..cd4848744 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 cddb2075d..889eabda1 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 4fc2f3e47..1bafa70be 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 511599a28..e9d35eb23 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 9b56b3bbb..d649fced6 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 1a45e3377..fc6dd54fc 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 7fbf44a2b..1b2000cc1 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 4b7070398..b02dbbf5d 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 b5828c743..70c0b036e 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)
- );
- }
-
- 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);
- }
- return segregatedFinishedUploads;
- }
}
export default new UIService();
+
+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, []);
+ }
+ segregatedFinishedUploads.get(result).push(localID);
+ }
+ return segregatedFinishedUploads;
+}
diff --git a/src/services/upload/uploadCancelService.ts b/src/services/upload/uploadCancelService.ts
new file mode 100644
index 000000000..790245784
--- /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 c46364a03..ecf3d81e8 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 75a3f2722..846003997 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,13 +54,21 @@ 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() {
@@ -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);
}
+ } catch (e) {
+ 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;
+ }
+ } finally {
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}
- ${(e as Error).stack}`
- );
- throw e;
- } finally {
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)}`);
-
- if (
- (fileUploadResult === UPLOAD_RESULT.UPLOADED ||
- fileUploadResult ===
- UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL) &&
- !skipDecryption
- ) {
- 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.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
);
- }
- this.existingFilesCollectionWise
- .get(decryptedFile.collectionID)
- .push(decryptedFile);
+ 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 (
+ [
+ UPLOAD_RESULT.ADDED_SYMLINK,
+ UPLOAD_RESULT.UPLOADED,
+ UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL,
+ ].includes(fileUploadResult)
+ ) {
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 3234dd951..429dfde98 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 4e929bf4c..40cdad7ac 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 88c67da5b..afb50bd8a 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 000000000..bce90a2c8
--- /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 000000000..93896ea38
--- /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 000000000..4ded8cf6d
--- /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 fe05e8ca0..2688f5812 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 000000000..6c67b5fd1
--- /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 b55afbbbf..1bc7a8d0a 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 076ac7fff..871e03e0a 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 b8554defa..c47dca3fb 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 000000000..e03897b10
--- /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 b1301c1a4..2394eb487 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 572fe5a43..1743e2c91 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 c28c0cbd7..5fc9e170a 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 38f303b24..f1511ec54 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 32d5cec57..5a02dbbd3 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 ed58f4420..7b40b2ce1 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 c61d3f89f..916806c3e 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 879fbeb6d..3d29c026f 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 409a46e57..0630080de 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 943d10d8c..bbd381e10 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 e3034ff96..73ad87922 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 000000000..9e11a1e1d
--- /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;
+ });
+}