Merge branch 'main' into demo

This commit is contained in:
Abhinav 2022-09-17 18:22:28 +05:30
commit e7262cfccb
89 changed files with 3073 additions and 838 deletions

View file

@ -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.
<br/><br/><br/>
![App Screenshots](https://user-images.githubusercontent.com/1161789/154797467-a2c14f13-6b04-4282-ab61-f6a9f60c2026.png)
![App Screenshots](https://user-images.githubusercontent.com/24503581/189914045-9d4e9c44-37c6-4ac6-9e17-d8c37aee1e08.png)
## ✨ 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
<br/>
## 💻 Deployed Application
The deployed application is accessible @ [web.ente.io](https://web.ente.io).
<br/>
## 🧑‍💻 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.
<br/>
## 🙋 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.
<br/>
## 🧭 Roadmap
We maintain a public roadmap, that's driven by our community @ [roadmap.ente.io](https://roadmap.ente.io).
<br/>
## 🤗 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.
<br/>
## ❤️ 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.
<br/>
---
Cross-browser testing provided by

View file

@ -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"
},

View file

@ -1,7 +1,8 @@
{
"webcredentials": {
"apps": [
"6Z68YJY9Q2.io.ente.frame",
"2BUSYC7FN9.io.ente.frame"
]
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

View file

@ -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<User>();
const [keyAttributes, setKeyAttributes] = useState<KeyAttributes>();
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 (
<Dialog
open={open}
onClose={onClose}
sx={{ position: 'absolute' }}
PaperProps={{ sx: { p: 1, maxWidth: '346px' } }}>
<Stack spacing={3} p={1.5}>
<Typography variant="h3" px={1} py={0.5} fontWeight={'bold'}>
{constants.PASSWORD}
</Typography>
<VerifyMasterPasswordForm
buttonText={constants.AUTHENTICATE}
callback={useMasterPassword}
user={user}
keyAttributes={keyAttributes}
/>
</Stack>
</Dialog>
);
}

View file

@ -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);
}
};

View file

@ -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<Map<number, Collection>>(new Map());
const activeCollection = useRef<Collection>(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: (
<CollectionInfoWithOptions
collectionSummary={collectionSummaries.get(
activeCollectionID
)}
activeCollection={activeCollection.current}
activeCollectionID={activeCollectionID}
setCollectionNamerAttributes={
setCollectionNamerAttributes
}
redirectToAll={() => setActiveCollectionID(ALL_SECTION)}
showCollectionShareModal={() =>
setCollectionShareModalView(true)
}
/>
),
itemType: ITEM_TYPE.OTHER,
height: 68,
}),
[collectionSummaries, activeCollectionID, shouldBeHidden]
);
useEffect(() => {
setPhotoListHeader({
item: (
<CollectionInfoWithOptions
collectionSummary={collectionSummaries.get(
activeCollectionID
)}
activeCollection={activeCollection.current}
activeCollectionID={activeCollectionID}
setCollectionNamerAttributes={setCollectionNamerAttributes}
redirectToAll={() => setActiveCollectionID(ALL_SECTION)}
showCollectionShareModal={() =>
setCollectionShareModalView(true)
}
/>
),
itemType: ITEM_TYPE.OTHER,
height: 68,
});
}, [collectionSummaries, activeCollectionID]);
if (shouldBeHidden) {
return <></>;

View file

@ -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',
});

View file

@ -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 (
<>
<Dialog
fullWidth
open={open}
onClose={onClose}
maxWidth="xs"
fullScreen={isMobile}>
<DialogTitleWithCloseButton onClose={onClose}>
<Typography variant="h3" fontWeight={'bold'}>
{constants.DELETE_ACCOUNT}
</Typography>
</DialogTitleWithCloseButton>
<DialogContent>
<VerticallyCentered>
<img
height={256}
src="/images/delete-account/1x.png"
srcSet="/images/delete-account/2x.png 2x,
/images/delete-account/3x.png 3x"
/>
</VerticallyCentered>
<Typography color="text.secondary" px={1.5}>
{constants.ASK_FOR_FEEDBACK}
</Typography>
<Stack spacing={1} px={2} sx={{ width: '100%' }}>
<Button
size="large"
color="accent"
onClick={sendFeedbackMail}
startIcon={<TickIcon />}>
{constants.SEND_FEEDBACK}
</Button>
<Button
size="large"
variant="outlined"
color="danger"
onClick={initiateDelete}
startIcon={<NoAccountsIcon />}>
{constants.DELETE_ACCOUNT}
</Button>
</Stack>
</DialogContent>
</Dialog>
<AuthenticateUserModal
open={authenticateUserModalView}
onClose={closeAuthenticateUserModal}
onAuthenticate={confirmAccountDeletion}
/>
</>
);
};
export default DeleteAccountModal;

View file

@ -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;

View file

@ -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()}
</Typography>
<Button
color="accent"
onClick={openUploader}
sx={{ mt: 4 }}>
{constants.UPLOAD_FIRST_PHOTO}
</Button>
<span
style={{
cursor:
!uploadManager.shouldAllowNewUpload() &&
'not-allowed',
}}>
<Button
color="accent"
onClick={openUploader}
disabled={!uploadManager.shouldAllowNewUpload()}
sx={{ mt: 4 }}>
{constants.UPLOAD_FIRST_PHOTO}
</Button>
</span>
</>
)}
</Wrapper>

View file

@ -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
);
}, []);

View file

@ -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 (
<DropDiv
{...props.getDragAndDropRootProps({
@ -62,7 +86,9 @@ export default function FullScreenDropZone(props: Props) {
<CloseButtonWrapper onClick={onDragLeave}>
<CloseIcon />
</CloseButtonWrapper>
{constants.UPLOAD_DROPZONE_MESSAGE}
{appContext.watchFolderView
? constants.WATCH_FOLDER_DROPZONE_MESSAGE
: constants.UPLOAD_DROPZONE_MESSAGE}
</Overlay>
)}
{props.children}

View file

@ -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) {

View file

@ -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 <></>;
}

View file

@ -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 (
<>
<SidebarButton onClick={confirmLogout} color="danger">
{constants.LOGOUT}
</SidebarButton>
<SidebarButton onClick={showDeleteAccountDirections} color="danger">
<SidebarButton onClick={openDeleteAccountModal} color="danger">
{constants.DELETE_ACCOUNT}
</SidebarButton>
<DeleteAccountModal
open={deleteAccountModalView}
onClose={closeDeleteAccountModal}
/>
</>
);
}

View file

@ -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({
<Typography
variant="body2"
color={'text.secondary'}
onClick={showPlanSelectorModal}
sx={{ cursor: 'pointer' }}>
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
)}
</Typography>
</Box>

View file

@ -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() && (
<SidebarButton onClick={openWatchFolder}>
{constants.WATCH_FOLDERS}
</SidebarButton>
)}
<SidebarButton onClick={openRecoveryKeyModal}>
{constants.RECOVERY_KEY}
</SidebarButton>
<SidebarButton onClick={openTwoFactorModalView}>
<SidebarButton onClick={openTwoFactorModal}>
{constants.TWO_FACTOR}
</SidebarButton>
<SidebarButton onClick={redirectToChangePasswordPage}>
@ -157,10 +176,11 @@ export default function UtilitySection({ closeSidebar }) {
/>
<TwoFactorModal
show={twoFactorModalView}
onHide={closeTwoFactorModalView}
onHide={closeTwoFactorModal}
closeSidebar={closeSidebar}
setLoading={startLoading}
/>
<WatchFolder open={watchFolderView} onClose={closeWatchFolder} />
{/* <FixLargeThumbnails
isOpen={fixLargeThumbsView}

View file

@ -2,7 +2,7 @@ import React, { useContext, useEffect, useMemo, useState } from 'react';
import SubscriptionCard from './SubscriptionCard';
import { getUserDetailsV2 } from 'services/userService';
import { UserDetails } from 'types/user';
import { LS_KEYS, setData } from 'utils/storage/localStorage';
import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
import { useLocalState } from 'hooks/useLocalState';
import Typography from '@mui/material/Typography';
import SubscriptionStatus from './SubscriptionStatus';
@ -34,6 +34,10 @@ export default function UserDetailsSection({ sidebarView }) {
setUserDetails(userDetails);
setData(LS_KEYS.SUBSCRIPTION, userDetails.subscription);
setData(LS_KEYS.FAMILY_DATA, userDetails.familyData);
setData(LS_KEYS.USER, {
...getData(LS_KEYS.USER),
email: userDetails.email,
});
};
main();
}, [sidebarView]);

View file

@ -3,6 +3,7 @@ import { IconButton, styled } from '@mui/material';
import FileUploadOutlinedIcon from '@mui/icons-material/FileUploadOutlined';
import { Button } from '@mui/material';
import constants from 'utils/strings/constants';
import uploadManager from 'services/upload/uploadManager';
const Wrapper = styled('div')`
display: flex;
@ -28,14 +29,23 @@ interface Iprops {
}
function UploadButton({ openUploader }: Iprops) {
return (
<Wrapper onClick={openUploader}>
<Wrapper
style={{
cursor: !uploadManager.shouldAllowNewUpload() && 'not-allowed',
}}>
<Button
onClick={openUploader}
disabled={!uploadManager.shouldAllowNewUpload()}
className="desktop-button"
color="secondary"
startIcon={<FileUploadOutlinedIcon />}>
{constants.UPLOAD}
</Button>
<IconButton className="mobile-button">
<IconButton
onClick={openUploader}
disabled={!uploadManager.shouldAllowNewUpload()}
className="mobile-button">
<FileUploadOutlinedIcon />
</IconButton>
</Wrapper>

View file

@ -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]);

View file

@ -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,

View file

@ -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 (
<Snackbar
open={!props.expanded}
open
anchorOrigin={{
horizontal: 'right',
vertical: 'bottom',

View file

@ -6,7 +6,7 @@ import UploadProgress from './UploadProgress';
import UploadStrategyChoiceModal from './UploadStrategyChoiceModal';
import { SetCollectionNamerAttributes } from '../Collections/CollectionNamer';
import { SetCollectionSelectorAttributes } from 'types/gallery';
import { SetCollections, SetCollectionSelectorAttributes } from 'types/gallery';
import { GalleryContext } from 'pages/gallery';
import { AppContext } from 'pages/_app';
import { logError } from 'utils/sentry';
@ -14,14 +14,17 @@ import UploadManager from 'services/upload/uploadManager';
import uploadManager from 'services/upload/uploadManager';
import ImportService from 'services/importService';
import isElectron from 'is-electron';
import { METADATA_FOLDER_NAME } from 'constants/export';
import { CustomError } from 'utils/error';
import { Collection } from 'types/collection';
import { SetLoading, SetFiles } from 'types/gallery';
import { ElectronFile, FileWithCollection } from 'types/upload';
import Router from 'next/router';
import {
ImportSuggestion,
ElectronFile,
FileWithCollection,
} from 'types/upload';
import { isCanvasBlocked } from 'utils/upload/isCanvasBlocked';
import { downloadApp } from 'utils/common';
import { downloadApp, waitAndRun } from 'utils/common';
import watchFolderService from 'services/watchFolder/watchFolderService';
import DiscFullIcon from '@mui/icons-material/DiscFull';
import { NotificationAttributes } from 'types/Notification';
import {
@ -30,59 +33,51 @@ import {
SegregatedFinishedUploads,
InProgressUpload,
} from 'types/upload/ui';
import { UPLOAD_STAGES } from 'constants/upload';
import {
DEFAULT_IMPORT_SUGGESTION,
UPLOAD_STAGES,
UPLOAD_STRATEGY,
PICKED_UPLOAD_TYPE,
} from 'constants/upload';
import importService from 'services/importService';
import { getDownloadAppMessage } from 'utils/ui';
import UploadTypeSelector from './UploadTypeSelector';
import {
filterOutSystemFiles,
getImportSuggestion,
groupFilesBasedOnParentFolder,
} from 'utils/upload';
import { getUserOwnedCollections } from 'utils/collection';
import billingService from 'services/billingService';
const FIRST_ALBUM_NAME = 'My First Album';
interface Props {
syncWithRemote: (force?: boolean, silent?: boolean) => Promise<void>;
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<UPLOAD_STAGES>();
const [uploadStage, setUploadStage] = useState<UPLOAD_STAGES>(
UPLOAD_STAGES.START
);
const [uploadFileNames, setUploadFileNames] = useState<UploadFileNames>();
const [uploadCounter, setUploadCounter] = useState<UploadCounter>({
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<AnalysisResult>(NULL_ANALYSIS_RESULT);
const [importSuggestion, setImportSuggestion] = useState<ImportSuggestion>(
DEFAULT_IMPORT_SUGGESTION
);
const appContext = useContext(AppContext);
const galleryContext = useContext(GalleryContext);
const toUploadFiles = useRef<File[] | ElectronFile[]>(null);
const isPendingDesktopUpload = useRef(false);
const pendingDesktopUploadCollectionName = useRef<string>('');
const uploadType = useRef<UPLOAD_TYPE>(null);
// This is set when the user choses a type to upload from the upload type selector dialog
const pickedUploadType = useRef<PICKED_UPLOAD_TYPE>(null);
const zipPaths = useRef<string[]>(null);
const currentUploadPromise = useRef<Promise<void>>(null);
const [electronFiles, setElectronFiles] = useState<ElectronFile[]>(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<string, (File | ElectronFile)[]>();
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: <DiscFullIcon fontSize="large" />,
@ -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) {
/>
<UploadTypeSelector
show={props.uploadTypeSelectorView}
onHide={closeUploadTypeSelector}
onHide={props.closeUploadTypeSelector}
uploadFiles={handleFileUpload}
uploadFolders={handleFolderUpload}
uploadGoogleTakeoutZips={handleZipUpload}

View file

@ -0,0 +1,89 @@
import React from 'react';
import constants from 'utils/strings/constants';
import CryptoWorker from 'utils/crypto';
import SingleInputForm, {
SingleInputFormProps,
} from 'components/SingleInputForm';
import { logError } from 'utils/sentry';
import { CustomError } from 'utils/error';
import { Input } from '@mui/material';
import { KeyAttributes, User } from 'types/user';
export interface VerifyMasterPasswordFormProps {
user: User;
keyAttributes: KeyAttributes;
callback: (key: string, passphrase: string) => 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 (
<SingleInputForm
callback={verifyPassphrase}
placeholder={constants.RETURN_PASSPHRASE_HINT}
buttonText={buttonText}
hiddenPreInput={
<Input
id="email"
name="email"
autoComplete="username"
type="email"
hidden
value={user?.email}
/>
}
autoComplete={'current-password'}
fieldType="password"
/>
);
}

View file

@ -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<WatchMapping[]>([]);
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 (
<>
<Dialog
open={open}
onClose={onClose}
PaperProps={{ sx: { height: '448px', maxWidth: '414px' } }}>
<DialogTitleWithCloseButton
onClose={onClose}
sx={{ '&&&': { padding: '32px 16px 16px 24px' } }}>
{constants.WATCHED_FOLDERS}
</DialogTitleWithCloseButton>
<DialogContent sx={{ flex: 1 }}>
<Stack spacing={1} p={1.5} height={'100%'}>
<MappingList
mappings={mappings}
handleRemoveWatchMapping={handleRemoveWatchMapping}
/>
<Button
fullWidth
color="accent"
onClick={handleAddFolderClick}>
<span>+</span>
<span
style={{
marginLeft: '8px',
}}></span>
{constants.ADD_FOLDER}
</Button>
</Stack>
</DialogContent>
</Dialog>
<UploadStrategyChoiceModal
open={choiceModalOpen}
onClose={closeChoiceModal}
uploadToSingleCollection={uploadToSingleCollection}
uploadToMultipleCollection={uploadToMultipleCollection}
/>
</>
);
}

View file

@ -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 (
<FlexWrapper gap={1}>
<Typography>{mapping.rootFolderName}</Typography>
{appContext.isFolderSyncRunning &&
watchFolderService.isMappingSyncInProgress(mapping) && (
<CircularProgress size={12} />
)}
</FlexWrapper>
);
}

View file

@ -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 (
<SpaceBetweenFlex>
<HorizontalFlex>
{mapping &&
mapping.uploadStrategy === UPLOAD_STRATEGY.SINGLE_COLLECTION ? (
<Tooltip title={constants.UPLOADED_TO_SINGLE_COLLECTION}>
<FolderOpenIcon />
</Tooltip>
) : (
<Tooltip title={constants.UPLOADED_TO_SEPARATE_COLLECTIONS}>
<FolderCopyOutlinedIcon />
</Tooltip>
)}
<EntryContainer>
<EntryHeading mapping={mapping} />
<Typography color="text.secondary" variant="body2">
{mapping.folderPath}
</Typography>
</EntryContainer>
</HorizontalFlex>
<MappingEntryOptions confirmStopWatching={confirmStopWatching} />
</SpaceBetweenFlex>
);
}

View file

@ -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 (
<OverflowMenu
menuPaperProps={{
sx: {
backgroundColor: (theme) =>
theme.palette.background.overPaper,
},
}}
ariaControls={'watch-mapping-option'}
triggerButtonIcon={<MoreHorizIcon />}>
<OverflowMenuOption
color="danger"
onClick={confirmStopWatching}
startIcon={<DoNotDisturbOutlinedIcon />}>
{constants.STOP_WATCHING}
</OverflowMenuOption>
</OverflowMenu>
);
}

View file

@ -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 ? (
<NoMappingsContent />
) : (
<MappingsContainer>
{mappings.map((mapping) => {
return (
<MappingEntry
key={mapping.rootFolderName}
mapping={mapping}
handleRemoveMapping={handleRemoveWatchMapping}
/>
);
})}
</MappingsContainer>
);
}

View file

@ -0,0 +1,16 @@
import React from 'react';
import CheckIcon from '@mui/icons-material/Check';
export function CheckmarkIcon() {
return (
<CheckIcon
fontSize="small"
sx={{
display: 'inline',
fontSize: '15px',
color: (theme) => theme.palette.secondary.main,
}}
/>
);
}

View file

@ -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 (
<NoMappingsContainer>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={'bold'}>
{constants.NO_FOLDERS_ADDED}
</Typography>
<Typography py={0.5} variant={'body2'} color="text.secondary">
{constants.FOLDERS_AUTOMATICALLY_MONITORED}
</Typography>
<Typography variant={'body2'} color="text.secondary">
<FlexWrapper gap={1}>
<CheckmarkIcon />
{constants.UPLOAD_NEW_FILES_TO_ENTE}
</FlexWrapper>
</Typography>
<Typography variant={'body2'} color="text.secondary">
<FlexWrapper gap={1}>
<CheckmarkIcon />
{constants.REMOVE_DELETED_FILES_FROM_ENTE}
</FlexWrapper>
</Typography>
</Stack>
</NoMappingsContainer>
);
}

View file

@ -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',
});

View file

@ -39,8 +39,12 @@ const LinkButton: FC<LinkProps<'button', { color?: ButtonProps['color'] }>> = ({
<Link
component="button"
sx={{
color: 'text.primary',
textDecoration: 'underline rgba(255, 255, 255, 0.4)',
'&:hover': {
color: `${color}.main`,
textDecoration: `underline `,
textDecorationColor: `${color}.main`,
},
...sx,
}}

View file

@ -9,7 +9,6 @@ import {
isOnFreePlan,
planForSubscription,
hasMobileSubscription,
hasPaypalSubscription,
getLocalUserSubscription,
hasPaidSubscription,
getTotalFamilyUsage,
@ -105,23 +104,20 @@ function PlanSelectorCard(props: Props) {
async function onPlanSelect(plan: Plan) {
if (
hasMobileSubscription(subscription) &&
!isSubscriptionCancelled(subscription)
!hasPaidSubscription(subscription) ||
isSubscriptionCancelled(subscription)
) {
appContext.setDialogMessage({
title: constants.ERROR,
content: constants.CANCEL_SUBSCRIPTION_ON_MOBILE,
close: { variant: 'danger' },
});
} else if (
hasPaypalSubscription(subscription) &&
!isSubscriptionCancelled(subscription)
) {
appContext.setDialogMessage({
title: constants.MANAGE_PLAN,
content: constants.PAYPAL_MANAGE_NOT_SUPPORTED_MESSAGE(),
close: { variant: 'danger' },
});
try {
props.setLoading(true);
await billingService.buySubscription(plan.stripeID);
} catch (e) {
props.setLoading(false);
appContext.setDialogMessage({
title: constants.ERROR,
content: constants.SUBSCRIPTION_PURCHASE_FAILED,
close: { variant: 'danger' },
});
}
} else if (hasStripeSubscription(subscription)) {
appContext.setDialogMessage({
title: `${constants.CONFIRM} ${reverseString(
@ -141,18 +137,18 @@ function PlanSelectorCard(props: Props) {
},
close: { text: constants.CANCEL },
});
} else if (hasMobileSubscription(subscription)) {
appContext.setDialogMessage({
title: constants.CANCEL_SUBSCRIPTION_ON_MOBILE,
content: constants.CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE,
close: { variant: 'secondary' },
});
} else {
try {
props.setLoading(true);
await billingService.buySubscription(plan.stripeID);
} catch (e) {
props.setLoading(false);
appContext.setDialogMessage({
title: constants.ERROR,
content: constants.SUBSCRIPTION_PURCHASE_FAILED,
close: { variant: 'danger' },
});
}
appContext.setDialogMessage({
title: constants.MANAGE_PLAN,
content: constants.MAIL_TO_MANAGE_SUBSCRIPTION,
close: { variant: 'secondary' },
});
}
}

View file

@ -4,7 +4,7 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { SpaceBetweenFlex } from 'components/Container';
import React from 'react';
import { convertBytesToGBs } from 'utils/billing';
import { convertBytesToGBs, isSubscriptionCancelled } from 'utils/billing';
import constants from 'utils/strings/constants';
import { ManageSubscription } from '../manageSubscription';
import { PeriodToggler } from '../periodToggler';
@ -76,9 +76,13 @@ export default function PaidSubscriptionPlanSelectorCard({
<Box py={1} px={1.5}>
<Typography color={'text.secondary'}>
{constants.RENEWAL_ACTIVE_SUBSCRIPTION_INFO(
subscription.expiryTime
)}
{!isSubscriptionCancelled(subscription)
? constants.RENEWAL_ACTIVE_SUBSCRIPTION_STATUS(
subscription.expiryTime
)
: constants.RENEWAL_CANCELLED_SUBSCRIPTION_STATUS(
subscription.expiryTime
)}
</Typography>
</Box>
</Box>

View file

@ -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) ? (
<ManageSubscriptionButton
color="secondary"
onClick={confirmActivation}>
{constants.ACTIVATE_SUBSCRIPTION}
onClick={confirmReactivation}>
{constants.REACTIVATE_SUBSCRIPTION}
</ManageSubscriptionButton>
) : (
<ManageSubscriptionButton

View file

@ -0,0 +1 @@
export const DESKTOP_REDIRECT_URL = 'https://payments.ente.io/desktop-redirect';

View file

@ -1,4 +1,4 @@
export const METADATA_FOLDER_NAME = 'metadata';
export const ENTE_METADATA_FOLDER = 'metadata';
export enum ExportNotification {
START = 'export started',

View file

@ -1,6 +1,10 @@
import { ENCRYPTION_CHUNK_SIZE } from 'constants/crypto';
import { FILE_TYPE } from 'constants/file';
import { Location, ParsedExtractedMetadata } from 'types/upload';
import {
ImportSuggestion,
Location,
ParsedExtractedMetadata,
} from 'types/upload';
// list of format that were missed by type-detection for some files.
export const FORMAT_MISSED_BY_FILE_TYPE_LIB = [
@ -28,9 +32,15 @@ export enum UPLOAD_STAGES {
READING_GOOGLE_METADATA_FILES,
EXTRACTING_METADATA,
UPLOADING,
CANCELLING,
FINISH,
}
export enum UPLOAD_STRATEGY {
SINGLE_COLLECTION,
COLLECTION_PER_FOLDER,
}
export enum UPLOAD_RESULT {
FAILED,
ALREADY_UPLOADED,
@ -40,6 +50,14 @@ export enum UPLOAD_RESULT {
LARGER_THAN_AVAILABLE_STORAGE,
UPLOADED,
UPLOADED_WITH_STATIC_THUMBNAIL,
ADDED_SYMLINK,
CANCELLED,
}
export enum PICKED_UPLOAD_TYPE {
FILES = 'files',
FOLDERS = 'folders',
ZIPS = 'zips',
}
export const MAX_FILE_SIZE_SUPPORTED = 4 * 1024 * 1024 * 1024; // 4 GB
@ -55,6 +73,11 @@ export const A_SEC_IN_MICROSECONDS = 1e6;
export const USE_CF_PROXY = false;
export const DEFAULT_IMPORT_SUGGESTION: ImportSuggestion = {
rootFolderName: '',
hasNestedFolders: false,
};
export const BLACK_THUMBNAIL_BASE64 =
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB' +
'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQ' +

View file

@ -60,6 +60,12 @@ type AppContextType = {
finishLoading: () => 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<DialogBoxAttributes>();
const [messageDialogView, setMessageDialogView] = useState(false);
const [isFolderSyncRunning, setIsFolderSyncRunning] = useState(false);
const [watchFolderView, setWatchFolderView] = useState(false);
const [watchFolderFiles, setWatchFolderFiles] = useState<FileList>(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 ? (

View file

@ -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<KeyAttributes>();
const appContext = useContext(AppContext);
const [user, setUser] = useState<User>();
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() {
<FormContainer>
<FormPaper style={{ minWidth: '320px' }}>
<FormPaperTitle>{constants.PASSWORD}</FormPaperTitle>
<SingleInputForm
callback={verifyPassphrase}
placeholder={constants.RETURN_PASSPHRASE_HINT}
buttonText={constants.VERIFY_PASSPHRASE}
hiddenPreInput={
<Input
id="email"
name="email"
autoComplete="username"
type="email"
hidden
value={user?.email}
/>
}
autoComplete={'current-password'}
fieldType="password"
/>
<VerifyMasterPasswordForm
buttonText={constants.VERIFY_PASSPHRASE}
callback={useMasterPassword}
user={user}
keyAttributes={keyAttributes}
/>
<FormPaperFooter style={{ justifyContent: 'space-between' }}>
<LinkButton onClick={redirectToRecoverPage}>
{constants.FORGOT_PASSWORD}

View file

@ -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<CollectionNamerAttributes>(null);
const [collectionNamerView, setCollectionNamerView] = useState(false);
const [search, setSearch] = useState<Search>(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<ElectronFile[]>(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 (
<GalleryContext.Provider
@ -663,6 +653,10 @@ export default function Gallery() {
null,
true
)}
closeUploadTypeSelector={setUploadTypeSelectorView.bind(
null,
false
)}
setCollectionSelectorAttributes={
setCollectionSelectorAttributes
}
@ -672,16 +666,16 @@ export default function Gallery() {
)}
setLoading={setBlockingLoad}
setCollectionNamerAttributes={setCollectionNamerAttributes}
uploadInProgress={uploadInProgress}
setUploadInProgress={setUploadInProgress}
setShouldDisableDropzone={setShouldDisableDropzone}
setFiles={setFiles}
isFirstUpload={hasNonEmptyCollections(collectionSummaries)}
electronFiles={electronFiles}
setElectronFiles={setElectronFiles}
webFiles={webFiles}
setWebFiles={setWebFiles}
setCollections={setCollections}
isFirstUpload={
!hasNonSystemCollections(collectionSummaries)
}
webFileSelectorFiles={webFileSelectorFiles}
webFolderSelectorFiles={webFolderSelectorFiles}
dragAndDropFiles={dragAndDropFiles}
uploadTypeSelectorView={uploadTypeSelectorView}
setUploadTypeSelectorView={setUploadTypeSelectorView}
showUploadFilesDialog={openFileSelector}
showUploadDirsDialog={openFolderSelector}
showSessionExpiredMessage={showSessionExpiredMessage}

View file

@ -5,6 +5,8 @@ import HTTPService from './HTTPService';
import { logError } from 'utils/sentry';
import { getPaymentToken } from './userService';
import { Plan, Subscription } from 'types/billing';
import isElectron from 'is-electron';
import { DESKTOP_REDIRECT_URL } from 'constants/billing';
const ENDPOINT = getEndpoint();
@ -131,7 +133,7 @@ class billingService {
{
paymentProvider: 'stripe',
productID: null,
VerificationData: sessionID,
verificationData: sessionID,
},
null,
{
@ -168,9 +170,13 @@ class billingService {
action: string
) {
try {
window.location.href = `${getPaymentsURL()}?productID=${productID}&paymentToken=${paymentToken}&action=${action}&redirectURL=${
window.location.origin
}/gallery`;
let redirectURL;
if (isElectron()) {
redirectURL = DESKTOP_REDIRECT_URL;
} else {
redirectURL = `${window.location.origin}/gallery`;
}
window.location.href = `${getPaymentsURL()}?productID=${productID}&paymentToken=${paymentToken}&action=${action}&redirectURL=${redirectURL}`;
} catch (e) {
logError(e, 'unable to get payments url');
throw e;

View file

@ -10,7 +10,11 @@ import HTTPService from './HTTPService';
import { EnteFile } from 'types/file';
import { logError } from 'utils/sentry';
import { CustomError } from 'utils/error';
import { sortFiles, sortFilesIntoCollections } from 'utils/file';
import {
isSharedFile,
sortFiles,
groupFilesBasedOnCollectionID,
} from 'utils/file';
import {
Collection,
CollectionLatestFiles,
@ -359,7 +363,7 @@ export const removeFromFavorites = async (file: EnteFile) => {
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<number>
): CollectionFilesCount {
const collectionIDToFileMap = groupFilesBasedOnCollectionID(files);
const collectionFilesCount = new Map<number, number>();
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<number>();
const uniqueArchivedFileIDs = new Set<number>();
const uniqueAllSectionFileIDs = new Set<number>();
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<number>
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,
};
}

View file

@ -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);
});
}

View file

@ -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<LimitedCache> {
if (this.allElectronAPIsExist) {
return await this.ElectronAPIs.openDiskCache(cacheName);
return await this.electronAPIs.openDiskCache(cacheName);
}
}
async delete(cacheName: string): Promise<boolean> {
if (this.allElectronAPIsExist) {
return await this.ElectronAPIs.deleteDiskCache(cacheName);
return await this.electronAPIs.deleteDiskCache(cacheName);
}
}
}

View file

@ -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() {

View file

@ -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();

View file

@ -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');

View file

@ -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<void>(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<any>
) {
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
);

View file

@ -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',

View file

@ -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<any>(1);

View file

@ -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;
}
};

View file

@ -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<ElectronFile[]> {
if (this.allElectronAPIsExist) {
return this.ElectronAPIs.getElectronFilesFromGoogleZip(zipPath);
return this.electronAPIs.getElectronFilesFromGoogleZip(zipPath);
}
}
@ -35,26 +36,26 @@ class ImportService {
async showUploadFilesDialog(): Promise<ElectronFile[]> {
if (this.allElectronAPIsExist) {
return this.ElectronAPIs.showUploadFilesDialog();
return this.electronAPIs.showUploadFilesDialog();
}
}
async showUploadDirsDialog(): Promise<ElectronFile[]> {
if (this.allElectronAPIsExist) {
return this.ElectronAPIs.showUploadDirsDialog();
return this.electronAPIs.showUploadDirsDialog();
}
}
async showUploadZipDialog(): Promise<selectZipResult> {
if (this.allElectronAPIsExist) {
return this.ElectronAPIs.showUploadZipDialog();
return this.electronAPIs.showUploadZipDialog();
}
}
async getPendingUploads(): Promise<PendingUploads> {
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, []);
}
}
}

View file

@ -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();

View file

@ -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);

View file

@ -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 {

View file

@ -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,

View file

@ -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<number, number>();
@ -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;
}

View file

@ -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();

View file

@ -92,18 +92,22 @@ class UploadHttpClient {
progressTracker
): Promise<string> {
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;
}
}

View file

@ -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<number, EnteFile[]>;
private existingFiles: EnteFile[];
private userOwnedNonTrashedExistingFiles: EnteFile[];
private setFiles: SetFiles;
private collections: Map<number, Collection>;
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();

View file

@ -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;
}
}

View file

@ -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<UploadResponse> {
@ -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:

View file

@ -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;
}
};

View file

@ -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;
};

View file

@ -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');
}
}

View file

@ -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<string, EnteFile>();
private unUploadableFilePaths = new Set<string>();
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<WatchMapping[]> {
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<number, WatchMappingSyncedFile>();
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<string> {
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();

View file

@ -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)',
},

View file

@ -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<void>;
checkExistsAndRename: (
oldDirPath: string,
newDirPath: string
) => Promise<void>;
saveStreamToDisk: (path: string, fileStream: ReadableStream<any>) => void;
saveFileToDisk: (path: string, file: any) => Promise<void>;
selectRootDirectory: () => Promise<string>;
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<string>;
setExportRecord: (filePath: string, data: string) => Promise<void>;
showUploadFilesDialog: () => Promise<ElectronFile[]>;
showUploadDirsDialog: () => Promise<ElectronFile[]>;
getPendingUploads: () => Promise<{
files: ElectronFile[];
collectionName: string;
type: string;
}>;
setToUploadFiles: (type: string, filePaths: string[]) => void;
showUploadZipDialog: () => Promise<{
zipPaths: string[];
files: ElectronFile[];
}>;
getElectronFilesFromGoogleZip: (
filePath: string
) => Promise<ElectronFile[]>;
setToUploadCollection: (collectionName: string) => void;
getDirFiles: (dirPath: string) => Promise<ElectronFile[]>;
getWatchMappings: () => WatchMapping[];
updateWatchMappingSyncedFiles: (
folderPath: string,
files: WatchMapping['syncedFiles']
) => void;
updateWatchMappingIgnoredFiles: (
folderPath: string,
files: WatchMapping['ignoredFiles']
) => void;
addWatchMapping: (
collectionName: string,
folderPath: string,
uploadStrategy: number
) => Promise<void>;
removeWatchMapping: (folderPath: string) => Promise<void>;
registerWatcherFunctions: (
addFile: (file: ElectronFile) => Promise<void>,
removeFile: (path: string) => Promise<void>,
removeFolder: (folderPath: string) => Promise<void>
) => void;
isFolder: (dirPath: string) => Promise<boolean>;
clearElectronStore: () => void;
setEncryptionKey: (encryptionKey: string) => Promise<void>;
getEncryptionKey: () => Promise<string>;
openDiskCache: (cacheName: string) => Promise<LimitedCache>;
deleteDiskCache: (cacheName: string) => Promise<boolean>;
}

View file

@ -9,6 +9,7 @@ export interface fileAttribute {
export interface FileMagicMetadataProps {
visibility?: VISIBILITY_STATE;
filePaths?: string[];
}
export interface FileMagicMetadata extends Omit<MagicMetadataCore, 'data'> {

View file

@ -92,6 +92,7 @@ export interface FileWithCollection extends UploadAsset {
export interface MetadataAndFileTypeInfo {
metadata: Metadata;
fileTypeInfo: FileTypeInfo;
filePath: string;
}
export type MetadataAndFileTypeInfoMap = Map<number, MetadataAndFileTypeInfo>;
@ -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;
}

View file

@ -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;
}

View file

@ -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[];
}

View file

@ -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);

View file

@ -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);
};

View file

@ -129,3 +129,21 @@ export function openLink(href: string, newTab?: boolean) {
a.rel = 'noreferrer noopener';
a.click();
}
export async function waitAndRun(
waitPromise: Promise<void>,
task: () => Promise<void>
) {
if (waitPromise && isPromise(waitPromise)) {
await waitPromise;
}
await task();
}
function isPromise(p: any) {
if (typeof p === 'object' && typeof p.then === 'function') {
return true;
}
return false;
}

View file

@ -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;

View file

@ -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;

View file

@ -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`;

View file

@ -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),
];
}

View file

@ -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<number, EnteFile[]>([
[ARCHIVE_SECTION, []],
[TRASH_SECTION, []],
]);
export function groupFilesBasedOnCollectionID(files: EnteFile[]) {
const collectionWiseFiles = new Map<number, EnteFile[]>();
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);
};

View file

@ -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}`

View file

@ -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{' '}
<LinkButton onClick={onClick}> upgrade </LinkButton>
</>
),
SUBSCRIPTION_PURCHASE_SUCCESS: (expiryTime) => (
<>
<p>We've received your payment</p>
<p>
your subscription is valid till{' '}
Your subscription is valid till{' '}
<strong>{dateString(expiryTime)}</strong>
</p>
</>
@ -327,30 +338,29 @@ const englishConstants = {
All of your data will be deleted from our servers at the end of
this billing period.
</p>
<p>are you sure that you want to cancel your subscription?</p>
<p>Are you sure that you want to cancel your subscription?</p>
</>
),
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{' '}
<a href="mailto:paypal@ente.io">paypal@ente.io</a> to manage your
subscription
<Link href={`mailto:support@ente.io`}>support@ente.io</Link> 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 = {
</p>
</>
),
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: (
<>
<p>We'll be sorry to see you go. Are you facing some issue?</p>
<p>
Please write to us at{' '}
<Link href="mailto:feedback@ente.io">feedback@ente.io</Link>,
maybe there is a way we can help.
</p>
</>
),
SEND_FEEDBACK: 'Yes, send feedback',
CONFIRM_ACCOUNT_DELETION_TITLE:
'Are you sure you want to delete your account?',
CONFIRM_ACCOUNT_DELETION_MESSAGE: (
<>
<p>
Your uploaded data will be scheduled for deletion, and your
account will be permanently deleted.
</p>
<p>This action is not reversible.</p>
</>
),
AUTHENTICATE: 'Authenticate',
UPLOADED_TO_SINGLE_COLLECTION: 'Uploaded to single collection',
UPLOADED_TO_SEPARATE_COLLECTIONS: 'Uploaded to separate collections',
NEVERMIND: 'Nevermind',
};
export default englishConstants;

View file

@ -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<string, (File | ElectronFile)[]>();
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('.');
}

26
src/utils/watch/index.ts Normal file
View file

@ -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<string>();
return files.filter((file) => {
if (!isSystemFile(file) && !isSyncedOrIgnoredFile(file, mapping)) {
if (!uniqueFilePaths.has(file.path)) {
uniqueFilePaths.add(file.path);
return true;
}
}
return false;
});
}