Merge branch 'main' into demo
This commit is contained in:
commit
e7262cfccb
89 changed files with 3073 additions and 838 deletions
17
README.md
17
README.md
|
@ -6,7 +6,8 @@ We have open-source apps across [Android](https://github.com/ente-io/frame), [iO
|
|||
|
||||
This repository contains the code for our web app, built with a lot of ❤️, and a little bit of JavaScript.
|
||||
<br/><br/><br/>
|
||||

|
||||
|
||||

|
||||
|
||||
## ✨ 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
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"6Z68YJY9Q2.io.ente.frame",
|
||||
"2BUSYC7FN9.io.ente.frame"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
BIN
public/images/delete-account/1x.png
Normal file
BIN
public/images/delete-account/1x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 92 KiB |
BIN
public/images/delete-account/2x.png
Normal file
BIN
public/images/delete-account/2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 367 KiB |
BIN
public/images/delete-account/3x.png
Normal file
BIN
public/images/delete-account/3x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 680 KiB |
88
src/components/AuthenticateUserModal.tsx
Normal file
88
src/components/AuthenticateUserModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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 <></>;
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
165
src/components/DeleteAccountModal.tsx
Normal file
165
src/components/DeleteAccountModal.tsx
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}, []);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 <></>;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}
|
||||
|
|
89
src/components/VerifyMasterPasswordForm.tsx
Normal file
89
src/components/VerifyMasterPasswordForm.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
145
src/components/WatchFolder/index.tsx
Normal file
145
src/components/WatchFolder/index.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
23
src/components/WatchFolder/mappingEntry/entryHeading.tsx
Normal file
23
src/components/WatchFolder/mappingEntry/entryHeading.tsx
Normal 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>
|
||||
);
|
||||
}
|
65
src/components/WatchFolder/mappingEntry/index.tsx
Normal file
65
src/components/WatchFolder/mappingEntry/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
27
src/components/WatchFolder/mappingList/index.tsx
Normal file
27
src/components/WatchFolder/mappingList/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
27
src/components/WatchFolder/styledComponents.tsx
Normal file
27
src/components/WatchFolder/styledComponents.tsx
Normal 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',
|
||||
});
|
|
@ -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,
|
||||
}}
|
||||
|
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
1
src/constants/billing/index.ts
Normal file
1
src/constants/billing/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const DESKTOP_REDIRECT_URL = 'https://payments.ente.io/desktop-redirect';
|
|
@ -1,4 +1,4 @@
|
|||
export const METADATA_FOLDER_NAME = 'metadata';
|
||||
export const ENTE_METADATA_FOLDER = 'metadata';
|
||||
|
||||
export enum ExportNotification {
|
||||
START = 'export started',
|
||||
|
|
|
@ -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' +
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
28
src/services/electron/fs.ts
Normal file
28
src/services/electron/fs.ts
Normal 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();
|
|
@ -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');
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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, []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
23
src/services/upload/uploadCancelService.ts
Normal file
23
src/services/upload/uploadCancelService.ts
Normal 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();
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
5
src/services/watchFolder/utils.ts
Normal file
5
src/services/watchFolder/utils.ts
Normal 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;
|
||||
};
|
70
src/services/watchFolder/watchFolderEventHandlers.ts
Normal file
70
src/services/watchFolder/watchFolderEventHandlers.ts
Normal 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');
|
||||
}
|
||||
}
|
664
src/services/watchFolder/watchFolderService.ts
Normal file
664
src/services/watchFolder/watchFolderService.ts
Normal 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();
|
|
@ -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)',
|
||||
},
|
||||
|
|
66
src/types/electron/index.ts
Normal file
66
src/types/electron/index.ts
Normal 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>;
|
||||
}
|
|
@ -9,6 +9,7 @@ export interface fileAttribute {
|
|||
|
||||
export interface FileMagicMetadataProps {
|
||||
visibility?: VISIBILITY_STATE;
|
||||
filePaths?: string[];
|
||||
}
|
||||
|
||||
export interface FileMagicMetadata extends Omit<MagicMetadataCore, 'data'> {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
24
src/types/watchFolder/index.ts
Normal file
24
src/types/watchFolder/index.ts
Normal 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[];
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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}`
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
26
src/utils/watch/index.ts
Normal 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;
|
||||
});
|
||||
}
|
Loading…
Add table
Reference in a new issue