Merge branch 'main' into cast

This commit is contained in:
Neeraj Gupta 2024-01-29 11:09:58 +05:30
commit 36cad03c71
62 changed files with 2075 additions and 854 deletions

View file

@ -82,7 +82,9 @@ An important part of our journey is to build better software by consistently lis
<br/>
---
## 🙇 Attributions
Cross-browser testing provided by
- Cross-browser testing provided by
[<img src="https://d98b8t1nnulk5.cloudfront.net/production/images/layout/logo-header.png?1469004780" width="115" height="25">](https://www.browserstack.com/open-source)
- Location search powered by [Simple Maps](https://simplemaps.com/data/world-cities)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -206,6 +206,7 @@
"SEARCH_TYPE": {
"COLLECTION": "Album",
"LOCATION": "Location",
"CITY": "Location",
"DATE": "Date",
"FILE_NAME": "File name",
"THING": "Content",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "Album",
"LOCATION": "Standort",
"CITY": "",
"DATE": "Datum",
"FILE_NAME": "Dateiname",
"THING": "Inhalt",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "",
"FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "",
"STATUS": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "Album",
"LOCATION": "Location",
"CITY": "Location",
"DATE": "Date",
"FILE_NAME": "File name",
"THING": "Content",
@ -495,16 +496,16 @@
"ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "Cannot create albums from file/folder mix",
"ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "<p>You have dragged and dropped a mixture of files and folders.</p><p>Please provide either only files, or only folders when selecting option to create separate albums</p>",
"CHOSE_THEME": "Choose theme",
"ML_SEARCH": "ML search (beta)",
"ML_SEARCH": "Face recognition",
"ENABLE_ML_SEARCH_DESCRIPTION": "<p>This will enable on-device machine learning and face search which will start analyzing your uploaded photos locally.</p><p>For the first run after login or enabling this feature, it will download all images on local device to analyze them. So please only enable this if you are ok with bandwidth and local processing of all images in your photo library.</p><p>If this is the first time you're enabling this, we'll also ask your permission to process face data.</p>",
"ML_MORE_DETAILS": "More details",
"ENABLE_FACE_SEARCH": "Enable face search",
"ENABLE_FACE_SEARCH_TITLE": "Enable face search?",
"ENABLE_FACE_SEARCH_DESCRIPTION": "<p>If you enable face search, ente will extract face geometry from your photos. This will happen on your device, and any generated biometric data will be end-to-encrypted.<p/><p><a>Please click here for more details about this feature in our privacy policy</a></p>",
"DISABLE_BETA": "Disable beta",
"DISABLE_FACE_SEARCH": "Disable face search",
"DISABLE_FACE_SEARCH_TITLE": "Disable face search?",
"DISABLE_FACE_SEARCH_DESCRIPTION": "<p>ente will stop processing face geometry, and will also disable ML search (beta)</p><p>You can reenable face search again if you wish, so this operation is safe.</p>",
"ENABLE_FACE_SEARCH": "Enable face recognition",
"ENABLE_FACE_SEARCH_TITLE": "Enable face recognition?",
"ENABLE_FACE_SEARCH_DESCRIPTION": "<p>If you enable face recognition, ente will extract face geometry from your photos. This will happen on your device, and any generated biometric data will be end-to-encrypted.<p/><p><a>Please click here for more details about this feature in our privacy policy</a></p>",
"DISABLE_BETA": "Pause recognition",
"DISABLE_FACE_SEARCH": "Disable face recognition",
"DISABLE_FACE_SEARCH_TITLE": "Disable face recognition?",
"DISABLE_FACE_SEARCH_DESCRIPTION": "<p>Ente will stop processing face geometry.</p><p>You can reenable face recognition again if you wish, so this operation is safe.</p>",
"ADVANCED": "Advanced",
"FACE_SEARCH_CONFIRMATION": "I understand, and wish to allow ente to process face geometry",
"LABS": "Labs",
@ -622,8 +623,9 @@
"PHOTO_EDITOR": "Photo Editor",
"FASTER_UPLOAD": "Faster uploads",
"FASTER_UPLOAD_DESCRIPTION": "Route uploads through nearby servers",
"STATUS": "Status",
"MAGIC_SEARCH_STATUS": "Magic Search Status",
"INDEXED_ITEMS": "Indexed items",
<<<<<<< HEAD
"CAST_ALBUM_TO_TV": "Play album on TV",
"ENTER_CAST_PIN_CODE": "Enter the code you see on the TV below to pair this device.",
"PAIR_DEVICE_TO_TV": "Pair devices",
@ -635,5 +637,8 @@
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "Pair with PIN works for any large screen device you want to play your album on.",
"VISIT_CAST_ENTE_IO": "Visit cast.ente.io on the device you want to pair.",
"CAST_AUTO_PAIR_FAILED": "Chromecast Auto Pair failed. Please try again.",
"CACHE_DIRECTORY": "Cache folder"
"CACHE_DIRECTORY": "Cache folder",
"FREEHAND": "Freehand",
"APPLY_CROP": "Apply Crop",
"PHOTO_EDIT_REQUIRED_TO_SAVE": "At least one transformation or color adjustment must be performed before saving."
}

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "Álbum",
"LOCATION": "Localización",
"CITY": "",
"DATE": "Fecha",
"FILE_NAME": "Nombre del archivo",
"THING": "Contenido",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "",
"FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "",
"STATUS": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "",
"LOCATION": "",
"CITY": "",
"DATE": "",
"FILE_NAME": "",
"THING": "",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "",
"FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "",
"STATUS": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "",
"LOCATION": "",
"CITY": "",
"DATE": "",
"FILE_NAME": "",
"THING": "",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "",
"FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "",
"STATUS": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "l'album",
"LOCATION": "Emplacement",
"CITY": "",
"DATE": "Date",
"FILE_NAME": "Nom de fichier",
"THING": "Chose",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "Éditeur de photos",
"FASTER_UPLOAD": "Chargements plus rapides",
"FASTER_UPLOAD_DESCRIPTION": "Router les chargements vers les serveurs à proximité",
"STATUS": "État",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "Éléments indexés",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "Album",
"LOCATION": "Posizione",
"CITY": "",
"DATE": "Data",
"FILE_NAME": "Nome file",
"THING": "",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "",
"FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "",
"STATUS": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -85,9 +85,9 @@
"ZOOM_IN_OUT": "In/uitzoomen",
"PREVIOUS": "Vorige (←)",
"NEXT": "Volgende (→)",
"TITLE_PHOTOS": "",
"TITLE_ALBUMS": "",
"TITLE_AUTH": "",
"TITLE_PHOTOS": "Ente Foto's",
"TITLE_ALBUMS": "Ente Foto's",
"TITLE_AUTH": "Ente Auth",
"UPLOAD_FIRST_PHOTO": "Je eerste foto uploaden",
"IMPORT_YOUR_FOLDERS": "Importeer uw mappen",
"UPLOAD_DROPZONE_MESSAGE": "Sleep om een back-up van je bestanden te maken",
@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "Album",
"LOCATION": "Locatie",
"CITY": "",
"DATE": "Datum",
"FILE_NAME": "Bestandsnaam",
"THING": "Inhoud",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "Fotobewerker",
"FASTER_UPLOAD": "Snellere uploads",
"FASTER_UPLOAD_DESCRIPTION": "Uploaden door nabije servers",
"STATUS": "Status",
"MAGIC_SEARCH_STATUS": "Magische Zoekfunctie Status",
"INDEXED_ITEMS": "Geïndexeerde bestanden",
"CACHE_DIRECTORY": "Cache map"
"CACHE_DIRECTORY": "Cache map",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "",
"LOCATION": "",
"CITY": "",
"DATE": "",
"FILE_NAME": "",
"THING": "",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "",
"FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "",
"STATUS": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "",
"LOCATION": "",
"CITY": "",
"DATE": "",
"FILE_NAME": "",
"THING": "",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "",
"FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "",
"STATUS": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "",
"LOCATION": "",
"CITY": "",
"DATE": "",
"FILE_NAME": "",
"THING": "",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "",
"FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "",
"STATUS": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "相册",
"LOCATION": "地理位置",
"CITY": "位置",
"DATE": "日期",
"FILE_NAME": "文件名",
"THING": "内容",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "照片编辑器",
"FASTER_UPLOAD": "更快上传",
"FASTER_UPLOAD_DESCRIPTION": "通过附近的服务器路由上传",
"STATUS": "状态",
"MAGIC_SEARCH_STATUS": "魔法搜索状态",
"INDEXED_ITEMS": "索引项目",
"CACHE_DIRECTORY": "缓存文件夹"
"CACHE_DIRECTORY": "缓存文件夹",
"FREEHAND": "手画",
"APPLY_CROP": "应用裁剪",
"PHOTO_EDIT_REQUIRED_TO_SAVE": "保存之前必须至少执行一项转换或颜色调整。"
}

View file

@ -1,160 +0,0 @@
import Notification from 'components/Notification';
import { t } from 'i18next';
import isElectron from 'is-electron';
import { AppContext } from 'pages/_app';
import { GalleryContext } from 'pages/gallery';
import { useContext } from 'react';
import ElectronAPIs from '@ente/shared/electron';
export interface CollectionDownloadProgressAttributes {
success: number;
failed: number;
total: number;
collectionName: string;
collectionID: number;
isHidden: boolean;
downloadDirPath: string;
canceller: AbortController;
}
interface CollectionDownloadProgressProps {
attributesList: CollectionDownloadProgressAttributes[];
setAttributesList: (value: CollectionDownloadProgressAttributes[]) => void;
}
export const isCollectionDownloadCompleted = (
attributes: CollectionDownloadProgressAttributes
) => {
return (
attributes &&
attributes.success + attributes.failed === attributes.total
);
};
export const isCollectionDownloadCompletedWithErrors = (
attributes: CollectionDownloadProgressAttributes
) => {
return (
attributes &&
attributes.failed > 0 &&
isCollectionDownloadCompleted(attributes)
);
};
export const isCollectionDownloadCancelled = (
attributes: CollectionDownloadProgressAttributes
) => {
return attributes && attributes.canceller?.signal?.aborted;
};
export const CollectionDownloadProgress: React.FC<CollectionDownloadProgressProps> =
({ attributesList, setAttributesList }) => {
const appContext = useContext(AppContext);
const galleryContext = useContext(GalleryContext);
if (!attributesList) {
return <></>;
}
const onClose = (collectionID: number) => {
setAttributesList(
attributesList.filter(
(attr) => attr.collectionID !== collectionID
)
);
};
const confirmCancelUpload = (
attributes: CollectionDownloadProgressAttributes
) => {
appContext.setDialogMessage({
title: t('STOP_DOWNLOADS_HEADER'),
content: t('STOP_ALL_DOWNLOADS_MESSAGE'),
proceed: {
text: t('YES_STOP_DOWNLOADS'),
variant: 'critical',
action: () => {
attributes?.canceller.abort();
onClose(attributes.collectionID);
},
},
close: {
text: t('NO'),
variant: 'secondary',
action: () => {},
},
});
};
const handleClose =
(attributes: CollectionDownloadProgressAttributes) => () => {
if (isCollectionDownloadCompleted(attributes)) {
onClose(attributes.collectionID);
} else {
confirmCancelUpload(attributes);
}
};
const handleOnClick = (collectionID: number) => () => {
const attributes = attributesList.find(
(attr) => attr.collectionID === collectionID
);
if (isElectron()) {
ElectronAPIs.openDirectory(attributes.downloadDirPath);
} else {
if (attributes.isHidden) {
galleryContext.openHiddenSection(() => {
galleryContext.setActiveCollectionID(
attributes.collectionID
);
});
} else {
galleryContext.setActiveCollectionID(
attributes.collectionID
);
}
}
};
return (
<>
{attributesList.map((attributes, index) => (
<Notification
key={attributes.collectionID}
horizontal="left"
sx={{ '&&': { bottom: `${index * 80 + 20}px` } }}
open
onClose={handleClose(attributes)}
keepOpenOnClick
attributes={{
variant: isCollectionDownloadCompletedWithErrors(
attributes
)
? 'critical'
: 'secondary',
title: isCollectionDownloadCompletedWithErrors(
attributes
)
? t('DOWNLOAD_FAILED')
: isCollectionDownloadCompleted(attributes)
? t(`DOWNLOAD_COMPLETE`)
: t('DOWNLOADING_COLLECTION', {
name: attributes.collectionName,
}),
caption: isCollectionDownloadCompleted(attributes)
? attributes.collectionName
: t('DOWNLOAD_PROGRESS', {
progress: {
current:
attributes.success +
attributes.failed,
total: attributes.total,
},
}),
onClick: handleOnClick(attributes.collectionID),
}}
/>
))}
</>
);
};

View file

@ -11,16 +11,14 @@ import Favorite from '@mui/icons-material/FavoriteRounded';
import ArchiveOutlined from '@mui/icons-material/ArchiveOutlined';
import PeopleIcon from '@mui/icons-material/People';
import LinkIcon from '@mui/icons-material/Link';
import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
import { SetFilesDownloadProgressAttributesCreator } from 'types/gallery';
interface Iprops {
activeCollection: Collection;
collectionSummary: CollectionSummary;
setCollectionNamerAttributes: SetCollectionNamerAttributes;
showCollectionShareModal: () => void;
setCollectionDownloadProgressAttributesCreator: (
collectionID: number
) => SetCollectionDownloadProgressAttributes;
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator;
isActiveCollectionDownloadInProgress: () => boolean;
setActiveCollectionID: (collectionID: number) => void;
setShowAlbumCastDialog: Dispatch<SetStateAction<boolean>>;

View file

@ -39,13 +39,11 @@ import { Trans } from 'react-i18next';
import { t } from 'i18next';
import { Box } from '@mui/material';
import CollectionSortOrderMenu from './CollectionSortOrderMenu';
import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
import { SetFilesDownloadProgressAttributesCreator } from 'types/gallery';
interface CollectionOptionsProps {
setCollectionNamerAttributes: SetCollectionNamerAttributes;
setCollectionDownloadProgressAttributesCreator: (
collectionID: number
) => SetCollectionDownloadProgressAttributes;
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator;
isActiveCollectionDownloadInProgress: () => boolean;
activeCollection: Collection;
collectionSummaryType: CollectionSummaryType;
@ -84,7 +82,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
setActiveCollectionID,
setCollectionNamerAttributes,
showCollectionShareModal,
setCollectionDownloadProgressAttributesCreator,
setFilesDownloadProgressAttributesCreator,
isActiveCollectionDownloadInProgress,
setShowAlbumCastDialog,
} = props;
@ -235,21 +233,25 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
return;
}
if (collectionSummaryType === CollectionSummaryType.hiddenItems) {
const setCollectionDownloadProgressAttributes =
setCollectionDownloadProgressAttributesCreator(
HIDDEN_ITEMS_SECTION
const setFilesDownloadProgressAttributes =
setFilesDownloadProgressAttributesCreator(
activeCollection.name,
HIDDEN_ITEMS_SECTION,
true
);
downloadDefaultHiddenCollectionHelper(
setCollectionDownloadProgressAttributes
setFilesDownloadProgressAttributes
);
} else {
const setCollectionDownloadProgressAttributes =
setCollectionDownloadProgressAttributesCreator(
activeCollection.id
const setFilesDownloadProgressAttributes =
setFilesDownloadProgressAttributesCreator(
activeCollection.name,
activeCollection.id,
isHiddenCollection(activeCollection)
);
downloadCollectionHelper(
activeCollection.id,
setCollectionDownloadProgressAttributes
setFilesDownloadProgressAttributes
);
}
};

View file

@ -140,8 +140,8 @@ function CollectionSelector({
? t('UNHIDE_TO_COLLECTION')
: t('SELECT_COLLECTION')}
</DialogTitleWithCloseButton>
<DialogContent>
<FlexWrapper flexWrap="wrap" gap={0.5}>
<DialogContent sx={{ '&&&': { padding: 0 } }}>
<FlexWrapper flexWrap="wrap" gap={'4px'} padding={'16px'}>
<AddCollectionButton
showNextModal={attributes.showNextModal}
/>

View file

@ -16,12 +16,11 @@ import { useLocalState } from '@ente/shared/hooks/useLocalState';
import { sortCollectionSummaries } from 'services/collectionService';
import { LS_KEYS } from '@ente/shared/storage/localStorage';
import {
CollectionDownloadProgress,
CollectionDownloadProgressAttributes,
isCollectionDownloadCancelled,
isCollectionDownloadCompleted,
} from './CollectionDownloadProgress';
import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
FilesDownloadProgressAttributes,
isFilesDownloadCancelled,
isFilesDownloadCompleted,
} from '../FilesDownloadProgress';
import { SetFilesDownloadProgressAttributesCreator } from 'types/gallery';
import AlbumCastDialog from './CollectionOptions/AlbumCastDialog';
interface Iprops {
@ -34,6 +33,8 @@ interface Iprops {
hiddenCollectionSummaries: CollectionSummaries;
setCollectionNamerAttributes: SetCollectionNamerAttributes;
setPhotoListHeader: (value: TimeStampListItem) => void;
filesDownloadProgressAttributesList: FilesDownloadProgressAttributes[];
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator;
}
export default function Collections(props: Iprops) {
@ -47,17 +48,14 @@ export default function Collections(props: Iprops) {
hiddenCollectionSummaries,
setCollectionNamerAttributes,
setPhotoListHeader,
filesDownloadProgressAttributesList,
setFilesDownloadProgressAttributesCreator,
} = props;
const [allCollectionView, setAllCollectionView] = useState(false);
const [collectionShareModalView, setCollectionShareModalView] =
useState(false);
const [
collectionDownloadProgressAttributesList,
setCollectionDownloadProgressAttributesList,
] = useState<CollectionDownloadProgressAttributes[]>([]);
const [showAlbumCastDialog, setShowAlbumCastDialog] = useState(false);
const [collectionListSortBy, setCollectionListSortBy] =
@ -89,38 +87,16 @@ export default function Collections(props: Iprops) {
[collectionListSortBy, toShowCollectionSummaries]
);
const setCollectionDownloadProgressAttributesCreator =
(collectionID: number): SetCollectionDownloadProgressAttributes =>
(value) => {
setCollectionDownloadProgressAttributesList((prev) => {
const attributes = prev?.find(
(attr) => attr.collectionID === collectionID
);
const updatedAttributes =
typeof value === 'function' ? value(attributes) : value;
const updatedAttributesList = attributes
? prev.map((attr) =>
attr.collectionID === collectionID
? updatedAttributes
: attr
)
: [...prev, updatedAttributes];
return updatedAttributesList;
});
};
const isActiveCollectionDownloadInProgress = useCallback(() => {
const attributes = collectionDownloadProgressAttributesList.find(
const attributes = filesDownloadProgressAttributesList.find(
(attr) => attr.collectionID === activeCollectionID
);
return (
attributes &&
!isCollectionDownloadCancelled(attributes) &&
!isCollectionDownloadCompleted(attributes)
!isFilesDownloadCancelled(attributes) &&
!isFilesDownloadCompleted(attributes)
);
}, [activeCollectionID, collectionDownloadProgressAttributesList]);
}, [activeCollectionID, filesDownloadProgressAttributesList]);
useEffect(() => {
if (isInSearchMode) {
@ -137,8 +113,8 @@ export default function Collections(props: Iprops) {
showCollectionShareModal={() =>
setCollectionShareModalView(true)
}
setCollectionDownloadProgressAttributesCreator={
setCollectionDownloadProgressAttributesCreator
setFilesDownloadProgressAttributesCreator={
setFilesDownloadProgressAttributesCreator
}
isActiveCollectionDownloadInProgress={
isActiveCollectionDownloadInProgress
@ -199,10 +175,6 @@ export default function Collections(props: Iprops) {
onClose={closeCollectionShare}
collection={activeCollection}
/>
<CollectionDownloadProgress
attributesList={collectionDownloadProgressAttributesList}
setAttributesList={setCollectionDownloadProgressAttributesList}
/>
<AlbumCastDialog
currentCollection={props.activeCollection}
show={showAlbumCastDialog}

View file

@ -0,0 +1,159 @@
import Notification from 'components/Notification';
import { t } from 'i18next';
import isElectron from 'is-electron';
import { AppContext } from 'pages/_app';
import { GalleryContext } from 'pages/gallery';
import { useContext } from 'react';
import ElectronAPIs from '@ente/shared/electron';
export interface FilesDownloadProgressAttributes {
id: number;
success: number;
failed: number;
total: number;
folderName: string;
collectionID: number;
isHidden: boolean;
downloadDirPath: string;
canceller: AbortController;
}
interface FilesDownloadProgressProps {
attributesList: FilesDownloadProgressAttributes[];
setAttributesList: (value: FilesDownloadProgressAttributes[]) => void;
}
export const isFilesDownloadStarted = (
attributes: FilesDownloadProgressAttributes
) => {
return attributes && attributes.total > 0;
};
export const isFilesDownloadCompleted = (
attributes: FilesDownloadProgressAttributes
) => {
return (
attributes &&
attributes.success + attributes.failed === attributes.total
);
};
export const isFilesDownloadCompletedWithErrors = (
attributes: FilesDownloadProgressAttributes
) => {
return (
attributes &&
attributes.failed > 0 &&
isFilesDownloadCompleted(attributes)
);
};
export const isFilesDownloadCancelled = (
attributes: FilesDownloadProgressAttributes
) => {
return attributes && attributes.canceller?.signal?.aborted;
};
export const FilesDownloadProgress: React.FC<FilesDownloadProgressProps> = ({
attributesList,
setAttributesList,
}) => {
const appContext = useContext(AppContext);
const galleryContext = useContext(GalleryContext);
if (!attributesList) {
return <></>;
}
const onClose = (id: number) => {
setAttributesList(attributesList.filter((attr) => attr.id !== id));
};
const confirmCancelUpload = (
attributes: FilesDownloadProgressAttributes
) => {
appContext.setDialogMessage({
title: t('STOP_DOWNLOADS_HEADER'),
content: t('STOP_ALL_DOWNLOADS_MESSAGE'),
proceed: {
text: t('YES_STOP_DOWNLOADS'),
variant: 'critical',
action: () => {
attributes?.canceller.abort();
onClose(attributes.id);
},
},
close: {
text: t('NO'),
variant: 'secondary',
action: () => {},
},
});
};
const handleClose = (attributes: FilesDownloadProgressAttributes) => () => {
if (isFilesDownloadCompleted(attributes)) {
onClose(attributes.id);
} else {
confirmCancelUpload(attributes);
}
};
const handleOnClick = (id: number) => () => {
const attributes = attributesList.find((attr) => attr.id === id);
if (isElectron()) {
ElectronAPIs.openDirectory(attributes.downloadDirPath);
} else {
if (attributes.isHidden) {
galleryContext.openHiddenSection(() => {
galleryContext.setActiveCollectionID(
attributes.collectionID
);
});
} else {
galleryContext.setActiveCollectionID(attributes.collectionID);
}
}
};
return (
<>
{attributesList.map((attributes, index) => (
<Notification
key={attributes.id}
horizontal="left"
sx={{
'&&': { bottom: `${index * 80 + 20}px` },
zIndex: 1600,
}}
open={isFilesDownloadStarted(attributes)}
onClose={handleClose(attributes)}
keepOpenOnClick
attributes={{
variant: isFilesDownloadCompletedWithErrors(attributes)
? 'critical'
: 'secondary',
title: isFilesDownloadCompletedWithErrors(attributes)
? t('DOWNLOAD_FAILED')
: isFilesDownloadCompleted(attributes)
? t(`DOWNLOAD_COMPLETE`)
: t('DOWNLOADING_COLLECTION', {
name: attributes.folderName,
}),
caption: isFilesDownloadCompleted(attributes)
? attributes.folderName
: t('DOWNLOAD_PROGRESS', {
progress: {
current:
attributes.success +
attributes.failed,
total: attributes.total,
},
}),
onClick: handleOnClick(attributes.id),
}}
/>
))}
</>
);
};

View file

@ -11,8 +11,10 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import PhotoViewer from 'components/PhotoViewer';
import { TRASH_SECTION } from 'constants/collection';
import { updateFileMsrcProps, updateFileSrcProps } from 'utils/photoFrame';
import { SelectedState } from 'types/gallery';
import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery';
import {
SelectedState,
SetFilesDownloadProgressAttributesCreator,
} from 'types/gallery';
import { useRouter } from 'next/router';
import { logError } from '@ente/shared/sentry';
import { addLogLine } from '@ente/shared/logging';
@ -53,8 +55,8 @@ interface Props {
selected: SelectedState | ((selected: SelectedState) => SelectedState)
) => void;
selected: SelectedState;
deletedFileIds?: Set<number>;
setDeletedFileIds?: (value: Set<number>) => void;
tempDeletedFileIds?: Set<number>;
setTempDeletedFileIds?: (value: Set<number>) => void;
activeCollectionID: number;
enableDownload?: boolean;
fileToCollectionsMap: Map<number, number[]>;
@ -62,6 +64,7 @@ interface Props {
showAppDownloadBanner?: boolean;
setIsPhotoSwipeOpen?: (value: boolean) => void;
isInHiddenSection?: boolean;
setFilesDownloadProgressAttributesCreator?: SetFilesDownloadProgressAttributesCreator;
}
const PhotoFrame = ({
@ -72,8 +75,8 @@ const PhotoFrame = ({
favItemIds,
setSelected,
selected,
deletedFileIds,
setDeletedFileIds,
tempDeletedFileIds,
setTempDeletedFileIds,
activeCollectionID,
enableDownload,
fileToCollectionsMap,
@ -81,6 +84,7 @@ const PhotoFrame = ({
showAppDownloadBanner,
setIsPhotoSwipeOpen,
isInHiddenSection,
setFilesDownloadProgressAttributesCreator,
}: Props) => {
const [open, setOpen] = useState(false);
const [currentIndex, setCurrentIndex] = useState<number>(0);
@ -89,9 +93,6 @@ const PhotoFrame = ({
[k: number]: boolean;
}>({});
const galleryContext = useContext(GalleryContext);
const publicCollectionGalleryContext = useContext(
PublicCollectionGalleryContext
);
const [rangeStart, setRangeStart] = useState(null);
const [currentHover, setCurrentHover] = useState(null);
const [isShiftKeyPressed, setIsShiftKeyPressed] = useState(false);
@ -315,9 +316,7 @@ const PhotoFrame = ({
file={item}
updateURL={updateURL(index)}
onClick={onThumbnailClick(index)}
selectable={
!publicCollectionGalleryContext?.accessedThroughSharedURL
}
selectable={enableDownload}
onSelect={handleSelect(
item.id,
item.ownerID === galleryContext.user?.id,
@ -600,13 +599,16 @@ const PhotoFrame = ({
gettingData={getSlideData}
getConvertedItem={getConvertedItem}
favItemIds={favItemIds}
deletedFileIds={deletedFileIds}
setDeletedFileIds={setDeletedFileIds}
tempDeletedFileIds={tempDeletedFileIds}
setTempDeletedFileIds={setTempDeletedFileIds}
isTrashCollection={activeCollectionID === TRASH_SECTION}
isInHiddenSection={isInHiddenSection}
enableDownload={enableDownload}
fileToCollectionsMap={fileToCollectionsMap}
collectionNameMap={collectionNameMap}
setFilesDownloadProgressAttributesCreator={
setFilesDownloadProgressAttributesCreator
}
/>
</Container>
);

View file

@ -0,0 +1,133 @@
import { EnteMenuItem } from 'components/Menu/EnteMenuItem';
import { MenuItemGroup } from 'components/Menu/MenuItemGroup';
import MenuSectionTitle from 'components/Menu/MenuSectionTitle';
import { useContext } from 'react';
import { ImageEditorOverlayContext } from './';
import { CropBoxProps } from './';
import type { MutableRefObject } from 'react';
import { t } from 'i18next';
import CropIcon from '@mui/icons-material/Crop';
interface IProps {
previewScale: number;
cropBoxProps: CropBoxProps;
cropBoxRef: MutableRefObject<HTMLDivElement>;
resetCropBox: () => void;
}
export const cropRegionOfCanvas = (
canvas: HTMLCanvasElement,
topLeftX: number,
topLeftY: number,
bottomRightX: number,
bottomRightY: number,
scale: number = 1
) => {
const context = canvas.getContext('2d');
if (!context || !canvas) return;
context.imageSmoothingEnabled = false;
const width = (bottomRightX - topLeftX) * scale;
const height = (bottomRightY - topLeftY) * scale;
const img = new Image();
img.src = canvas.toDataURL();
img.onload = () => {
context.clearRect(0, 0, canvas.width, canvas.height);
canvas.width = width;
canvas.height = height;
context.drawImage(
img,
topLeftX,
topLeftY,
width,
height,
0,
0,
width,
height
);
};
};
export const getCropRegionArgs = (
cropBoxEle: HTMLDivElement,
canvasEle: HTMLCanvasElement
) => {
// get the bounding rectangle of the crop box
const cropBoxRect = cropBoxEle.getBoundingClientRect();
// Get the bounding rectangle of the canvas
const canvasRect = canvasEle.getBoundingClientRect();
// calculate the scale of the canvas display relative to its actual dimensions
const displayScale = canvasEle.width / canvasRect.width;
// calculate the coordinates of the crop box relative to the canvas and adjust for any scrolling by adding scroll offsets
const x1 =
(cropBoxRect.left - canvasRect.left + window.scrollX) * displayScale;
const y1 =
(cropBoxRect.top - canvasRect.top + window.scrollY) * displayScale;
const x2 = x1 + cropBoxRect.width * displayScale;
const y2 = y1 + cropBoxRect.height * displayScale;
return {
x1,
x2,
y1,
y2,
};
};
const CropMenu = (props: IProps) => {
const {
canvasRef,
originalSizeCanvasRef,
canvasLoading,
setCanvasLoading,
setTransformationPerformed,
setCurrentTab,
} = useContext(ImageEditorOverlayContext);
return (
<>
<MenuSectionTitle title={t('FREEHAND')} />
<MenuItemGroup
style={{
marginBottom: '0.5rem',
}}>
<EnteMenuItem
disabled={canvasLoading}
startIcon={<CropIcon />}
onClick={() => {
if (!props.cropBoxRef.current || !canvasRef.current)
return;
const { x1, x2, y1, y2 } = getCropRegionArgs(
props.cropBoxRef.current,
canvasRef.current
);
setCanvasLoading(true);
setTransformationPerformed(true);
cropRegionOfCanvas(canvasRef.current, x1, y1, x2, y2);
cropRegionOfCanvas(
originalSizeCanvasRef.current,
x1 / props.previewScale,
y1 / props.previewScale,
x2 / props.previewScale,
y2 / props.previewScale
);
props.resetCropBox();
setCanvasLoading(false);
setCurrentTab('transform');
}}
label={t('APPLY_CROP')}
/>
</MenuItemGroup>
</>
);
};
export default CropMenu;

View file

@ -0,0 +1,118 @@
import { CropBoxProps } from './';
import type { Ref, Dispatch, SetStateAction, CSSProperties } from 'react';
import { forwardRef } from 'react';
const handleStyle: CSSProperties = {
position: 'absolute',
height: '10px',
width: '10px',
backgroundColor: 'white',
border: '1px solid black',
};
const seHandleStyle: CSSProperties = {
...handleStyle,
right: '-5px',
bottom: '-5px',
cursor: 'se-resize',
};
interface IProps {
cropBox: CropBoxProps;
setIsDragging: Dispatch<SetStateAction<boolean>>;
}
const FreehandCropRegion = forwardRef(
(props: IProps, ref: Ref<HTMLDivElement>) => {
return (
<>
{/* Top overlay */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: props.cropBox.y + 'px', // height up to the top of the crop box
backgroundColor: 'rgba(0,0,0,0.5)',
pointerEvents: 'none',
}}></div>
{/* Bottom overlay */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: `calc(100% - ${
props.cropBox.y + props.cropBox.height
}px)`, // height from the bottom of the crop box to the bottom of the canvas
backgroundColor: 'rgba(0,0,0,0.5)',
pointerEvents: 'none',
}}></div>
{/* Left overlay */}
<div
style={{
position: 'absolute',
top: props.cropBox.y + 'px',
left: 0,
width: props.cropBox.x + 'px', // width up to the left side of the crop box
height: props.cropBox.height + 'px', // same height as the crop box
backgroundColor: 'rgba(0,0,0,0.5)',
pointerEvents: 'none',
}}></div>
{/* Right overlay */}
<div
style={{
position: 'absolute',
top: props.cropBox.y + 'px',
right: 0,
width: `calc(100% - ${
props.cropBox.x + props.cropBox.width
}px)`, // width from the right side of the crop box to the right side of the canvas
height: props.cropBox.height + 'px', // same height as the crop box
backgroundColor: 'rgba(0,0,0,0.5)',
pointerEvents: 'none',
}}></div>
<div
style={{
display: 'grid',
position: 'absolute',
left: props.cropBox.x + 'px',
top: props.cropBox.y + 'px',
width: props.cropBox.width + 'px',
height: props.cropBox.height + 'px',
border: '1px solid white',
gridTemplateColumns: '1fr 1fr 1fr',
gridTemplateRows: '1fr 1fr 1fr',
gap: '0px',
zIndex: 30, // make sure the crop box is above the overlays
}}
ref={ref}>
{Array.from({ length: 9 }).map((_, index) => (
<div
key={index}
style={{
border: '1px solid white',
boxSizing: 'border-box',
pointerEvents: 'none',
}}></div>
))}
<div
style={seHandleStyle}
onMouseDown={(e) => {
e.preventDefault();
props.setIsDragging(true);
}}></div>
</div>
</>
);
}
);
export default FreehandCropRegion;

View file

@ -89,6 +89,7 @@ const TransformMenu = () => {
);
};
};
const flipCanvas = (
canvas: HTMLCanvasElement,
direction: 'vertical' | 'horizontal'

View file

@ -29,6 +29,7 @@ import mime from 'mime-types';
import CloseIcon from '@mui/icons-material/Close';
import { HorizontalFlex } from '@ente/shared/components/Container';
import TransformMenu from './TransformMenu';
import CropMenu from './CropMenu';
import ColoursMenu from './ColoursMenu';
import { FileWithCollection } from 'types/upload';
import uploadManager from 'services/upload/uploadManager';
@ -44,6 +45,12 @@ import { getEditorCloseConfirmationMessage } from 'utils/ui';
import { logError } from '@ente/shared/sentry';
import { getFileType } from 'services/typeDetectionService';
import { downloadUsingAnchor } from '@ente/shared/utils';
import { CORNER_THRESHOLD, FILTER_DEFAULT_VALUES } from 'constants/photoEditor';
import FreehandCropRegion from './FreehandCropRegion';
import EnteButton from '@ente/shared/components/EnteButton';
import { CenteredFlex } from '@ente/shared/components/Container';
import CropIcon from '@mui/icons-material/Crop';
import { cropRegionOfCanvas, getCropRegionArgs } from './CropMenu';
interface IProps {
file: EnteFile;
@ -59,16 +66,11 @@ export const ImageEditorOverlayContext = createContext(
setTransformationPerformed: Dispatch<SetStateAction<boolean>>;
setCanvasLoading: Dispatch<SetStateAction<boolean>>;
canvasLoading: boolean;
setCurrentTab: Dispatch<SetStateAction<OperationTab>>;
}
);
const filterDefaultValues = {
brightness: 100,
contrast: 100,
blur: 0,
saturation: 100,
invert: false,
};
type OperationTab = 'crop' | 'transform' | 'colours';
const getEditedFileName = (fileName: string) => {
const fileNameParts = fileName.split('.');
@ -77,6 +79,13 @@ const getEditedFileName = (fileName: string) => {
return editedFileName;
};
export interface CropBoxProps {
x: number;
y: number;
width: number;
height: number;
}
const ImageEditorOverlay = (props: IProps) => {
const appContext = useContext(AppContext);
@ -88,19 +97,17 @@ const ImageEditorOverlay = (props: IProps) => {
const [currentRotationAngle, setCurrentRotationAngle] = useState(0);
const [currentTab, setCurrentTab] = useState<'transform' | 'colours'>(
'transform'
);
const [currentTab, setCurrentTab] = useState<OperationTab>('transform');
const [brightness, setBrightness] = useState(
filterDefaultValues.brightness
FILTER_DEFAULT_VALUES.brightness
);
const [contrast, setContrast] = useState(filterDefaultValues.contrast);
const [blur, setBlur] = useState(filterDefaultValues.blur);
const [contrast, setContrast] = useState(FILTER_DEFAULT_VALUES.contrast);
const [blur, setBlur] = useState(FILTER_DEFAULT_VALUES.blur);
const [saturation, setSaturation] = useState(
filterDefaultValues.saturation
FILTER_DEFAULT_VALUES.saturation
);
const [invert, setInvert] = useState(filterDefaultValues.invert);
const [invert, setInvert] = useState(FILTER_DEFAULT_VALUES.invert);
const [transformationPerformed, setTransformationPerformed] =
useState(false);
@ -110,6 +117,149 @@ const ImageEditorOverlay = (props: IProps) => {
const [showControlsDrawer, setShowControlsDrawer] = useState(true);
const [previewCanvasScale, setPreviewCanvasScale] = useState(0);
const [cropBox, setCropBox] = useState<CropBoxProps>({
x: 0,
y: 0,
width: 100,
height: 100,
});
const [startX, setStartX] = useState(0);
const [startY, setStartY] = useState(0);
const [beforeGrowthHeight, setBeforeGrowthHeight] = useState(0);
const [beforeGrowthWidth, setBeforeGrowthWidth] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [isGrowing, setIsGrowing] = useState(false);
const cropBoxRef = useRef<HTMLDivElement>(null);
const getCanvasBoundsOffsets = () => {
const canvasBounds = {
height: canvasRef.current.height,
width: canvasRef.current.width,
};
const parentBounds = parentRef.current.getBoundingClientRect();
// calculate the offset created by centering the canvas in its parent
const offsetX = (parentBounds.width - canvasBounds.width) / 2;
const offsetY = (parentBounds.height - canvasBounds.height) / 2;
return {
offsetY,
offsetX,
canvasBounds,
parentBounds,
};
};
const handleDragStart = (e) => {
if (currentTab !== 'crop') return;
const rect = cropBoxRef.current.getBoundingClientRect();
const offsetX = e.pageX - rect.left - rect.width / 2;
const offsetY = e.pageY - rect.top - rect.height / 2;
// check if the cursor is near the corners of the box
const isNearLeftOrRightEdge =
e.pageX < rect.left + CORNER_THRESHOLD ||
e.pageX > rect.right - CORNER_THRESHOLD;
const isNearTopOrBottomEdge =
e.pageY < rect.top + CORNER_THRESHOLD ||
e.pageY > rect.bottom - CORNER_THRESHOLD;
if (isNearLeftOrRightEdge && isNearTopOrBottomEdge) {
// cursor is near a corner, do not initiate dragging
setIsGrowing(true);
setStartX(e.pageX);
setStartY(e.pageY);
setBeforeGrowthWidth(cropBox.width);
setBeforeGrowthHeight(cropBox.height);
return;
}
setIsDragging(true);
setStartX(e.pageX - offsetX);
setStartY(e.pageY - offsetY);
};
const handleDrag = (e) => {
if (!isDragging && !isGrowing) return;
// d- variables are the delta change between start and now
const dx = e.pageX - startX;
const dy = e.pageY - startY;
const { offsetX, offsetY, canvasBounds } = getCanvasBoundsOffsets();
if (isGrowing) {
setCropBox((prev) => {
const newWidth = Math.min(
beforeGrowthWidth + dx,
canvasBounds.width - prev.x + offsetX
);
const newHeight = Math.min(
beforeGrowthHeight + dy,
canvasBounds.height - prev.y + offsetY
);
return {
...prev,
width: newWidth,
height: newHeight,
};
});
} else {
setCropBox((prev) => {
let newX = prev.x + dx;
let newY = prev.y + dy;
// constrain the new position to the canvas boundaries, accounting for the offset
newX = Math.max(
offsetX,
Math.min(newX, offsetX + canvasBounds.width - prev.width)
);
newY = Math.max(
offsetY,
Math.min(newY, offsetY + canvasBounds.height - prev.height)
);
return {
...prev,
x: newX,
y: newY,
};
});
setStartX(e.pageX);
setStartY(e.pageY);
}
};
const handleDragEnd = () => {
setStartX(0);
setStartY(0);
setIsGrowing(false);
setIsDragging(false);
};
const resetCropBox = () => {
setCropBox((prev) => {
const { offsetX, offsetY, canvasBounds } = getCanvasBoundsOffsets();
return {
...prev,
x: offsetX,
y: offsetY,
height: canvasBounds.height,
width: canvasBounds.width,
};
});
};
useEffect(() => {
if (!canvasRef.current) {
return;
@ -117,17 +267,23 @@ const ImageEditorOverlay = (props: IProps) => {
try {
applyFilters([canvasRef.current, originalSizeCanvasRef.current]);
setColoursAdjusted(
brightness !== filterDefaultValues.brightness ||
contrast !== filterDefaultValues.contrast ||
blur !== filterDefaultValues.blur ||
saturation !== filterDefaultValues.saturation ||
invert !== filterDefaultValues.invert
brightness !== FILTER_DEFAULT_VALUES.brightness ||
contrast !== FILTER_DEFAULT_VALUES.contrast ||
blur !== FILTER_DEFAULT_VALUES.blur ||
saturation !== FILTER_DEFAULT_VALUES.saturation ||
invert !== FILTER_DEFAULT_VALUES.invert
);
} catch (e) {
logError(e, 'Error applying filters');
}
}, [brightness, contrast, blur, saturation, invert, canvasRef, fileURL]);
useEffect(() => {
if (currentTab !== 'crop') return;
resetCropBox();
setShowControlsDrawer(false);
}, [currentTab]);
const applyFilters = async (canvases: HTMLCanvasElement[]) => {
try {
for (const canvas of canvases) {
@ -203,6 +359,7 @@ const ImageEditorOverlay = (props: IProps) => {
}
setCanvasLoading(true);
resetFilters();
setCurrentRotationAngle(0);
@ -226,6 +383,7 @@ const ImageEditorOverlay = (props: IProps) => {
parentRef.current.clientWidth / img.width,
parentRef.current.clientHeight / img.height
);
setPreviewCanvasScale(scale);
const width = img.width * scale;
const height = img.height * scale;
@ -246,6 +404,13 @@ const ImageEditorOverlay = (props: IProps) => {
setColoursAdjusted(false);
setCanvasLoading(false);
resetCropBox();
setStartX(0);
setStartY(0);
setIsDragging(false);
setIsGrowing(false);
resolve(true);
} catch (e) {
reject(e);
@ -387,35 +552,97 @@ const ImageEditorOverlay = (props: IProps) => {
boxSizing={'border-box'}
display="flex"
alignItems="center"
justifyContent="center">
justifyContent="center"
position="relative"
onMouseUp={handleDragEnd}
onMouseMove={isDragging ? handleDrag : null}
onMouseDown={handleDragStart}>
<Box
height="90%"
width="100%"
ref={parentRef}
display="flex"
alignItems="center"
justifyContent="center">
{(fileURL === null || canvasLoading) && (
<CircularProgress />
)}
style={{
position: 'relative',
width: '100%',
height: '100%',
}}>
<Box
height="90%"
width="100%"
ref={parentRef}
display="flex"
alignItems="center"
justifyContent="center"
position="relative">
{(fileURL === null || canvasLoading) && (
<CircularProgress />
)}
<canvas
ref={canvasRef}
style={{
objectFit: 'contain',
display:
fileURL === null || canvasLoading
? 'none'
: 'block',
position: 'absolute',
}}
/>
<canvas
ref={originalSizeCanvasRef}
style={{
display: 'none',
}}
/>
<canvas
ref={canvasRef}
style={{
objectFit: 'contain',
display:
fileURL === null || canvasLoading
? 'none'
: 'block',
position: 'absolute',
}}
/>
<canvas
ref={originalSizeCanvasRef}
style={{
display: 'none',
}}
/>
{currentTab === 'crop' && (
<FreehandCropRegion
cropBox={cropBox}
ref={cropBoxRef}
setIsDragging={setIsDragging}
/>
)}
</Box>
{currentTab === 'crop' && (
<CenteredFlex marginTop="1rem">
<EnteButton
color="accent"
startIcon={<CropIcon />}
onClick={() => {
if (
!cropBoxRef.current ||
!canvasRef.current
)
return;
const { x1, x2, y1, y2 } =
getCropRegionArgs(
cropBoxRef.current,
canvasRef.current
);
setCanvasLoading(true);
setTransformationPerformed(true);
cropRegionOfCanvas(
canvasRef.current,
x1,
y1,
x2,
y2
);
cropRegionOfCanvas(
originalSizeCanvasRef.current,
x1 / previewCanvasScale,
y1 / previewCanvasScale,
x2 / previewCanvasScale,
y2 / previewCanvasScale
);
resetCropBox();
setCanvasLoading(false);
setCurrentTab('transform');
}}>
{t('APPLY_CROP')}
</EnteButton>
</CenteredFlex>
)}
</Box>
</Box>
</Box>
@ -441,6 +668,7 @@ const ImageEditorOverlay = (props: IProps) => {
onChange={(_, value) => {
setCurrentTab(value);
}}>
<Tab label={t('CROP')} value="crop" />
<Tab label={t('TRANSFORM')} value="transform" />
<Tab
label={t('COLORS')}
@ -463,18 +691,25 @@ const ImageEditorOverlay = (props: IProps) => {
label={t('RESTORE_ORIGINAL')}
/>
</MenuItemGroup>
{currentTab === 'transform' && (
<ImageEditorOverlayContext.Provider
value={{
originalSizeCanvasRef,
canvasRef,
setCanvasLoading,
canvasLoading,
setTransformationPerformed,
}}>
<TransformMenu />
</ImageEditorOverlayContext.Provider>
)}
<ImageEditorOverlayContext.Provider
value={{
originalSizeCanvasRef,
canvasRef,
setCanvasLoading,
canvasLoading,
setTransformationPerformed,
setCurrentTab,
}}>
{currentTab === 'crop' && (
<CropMenu
previewScale={previewCanvasScale}
cropBoxProps={cropBox}
cropBoxRef={cropBoxRef}
resetCropBox={resetCropBox}
/>
)}
{currentTab === 'transform' && <TransformMenu />}
</ImageEditorOverlayContext.Provider>
{currentTab === 'colours' && (
<ColoursMenu
brightness={brightness}
@ -495,14 +730,25 @@ const ImageEditorOverlay = (props: IProps) => {
startIcon={<DownloadIcon />}
onClick={downloadEditedPhoto}
label={t('DOWNLOAD_EDITED')}
disabled={
!transformationPerformed && !coloursAdjusted
}
/>
<MenuItemDivider />
<EnteMenuItem
startIcon={<CloudUploadIcon />}
onClick={saveCopyToEnte}
label={t('SAVE_A_COPY_TO_ENTE')}
disabled={
!transformationPerformed && !coloursAdjusted
}
/>
</MenuItemGroup>
{!transformationPerformed && !coloursAdjusted && (
<MenuSectionTitle
title={t('PHOTO_EDIT_REQUIRED_TO_SAVE')}
/>
)}
</EnteDrawer>
</Backdrop>
</>

View file

@ -8,12 +8,12 @@ import {
} from 'services/collectionService';
import { EnteFile } from 'types/file';
import {
downloadFile,
copyFileToClipboard,
getFileExtension,
getFileFromURL,
isSupportedRawFormat,
isRawFile,
downloadSingleFile,
} from 'utils/file';
import { logError } from '@ente/shared/sentry';
@ -58,6 +58,7 @@ import isElectron from 'is-electron';
import ReplayIcon from '@mui/icons-material/Replay';
import ImageEditorOverlay from './ImageEditorOverlay';
import EditIcon from '@mui/icons-material/Edit';
import { SetFilesDownloadProgressAttributesCreator } from 'types/gallery';
interface PhotoswipeFullscreenAPI {
enter: () => void;
@ -85,13 +86,14 @@ interface Iprops {
id?: string;
className?: string;
favItemIds: Set<number>;
deletedFileIds: Set<number>;
setDeletedFileIds?: (value: Set<number>) => void;
tempDeletedFileIds: Set<number>;
setTempDeletedFileIds?: (value: Set<number>) => void;
isTrashCollection: boolean;
isInHiddenSection: boolean;
enableDownload: boolean;
fileToCollectionsMap: Map<number, number[]>;
collectionNameMap: Map<number, string>;
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator;
}
function PhotoViewer(props: Iprops) {
@ -192,6 +194,12 @@ function PhotoViewer(props: Iprops) {
case 'L':
onFavClick(photoSwipe?.currItem as EnteFile);
break;
case 'ArrowLeft':
handleArrowClick(event, 'left');
break;
case 'ArrowRight':
handleArrowClick(event, 'right');
break;
default:
break;
}
@ -259,7 +267,7 @@ function PhotoViewer(props: Iprops) {
`download-btn-${item.id}`
) as HTMLButtonElement;
const downloadFile = () => {
downloadFileHelper(photoSwipe.currItem);
downloadFileHelper(photoSwipe.currItem as unknown as EnteFile);
};
if (downloadLivePhotoBtn) {
@ -352,6 +360,7 @@ function PhotoViewer(props: Iprops) {
maxSpreadZoom: 5,
index: currentIndex,
showHideOpacity: true,
arrowKeys: false,
getDoubleTapZoom(isMouseClick, item) {
if (isMouseClick) {
return 2.5;
@ -484,13 +493,13 @@ function PhotoViewer(props: Iprops) {
};
const trashFile = async (file: EnteFile) => {
const { deletedFileIds, setDeletedFileIds } = props;
const { tempDeletedFileIds, setTempDeletedFileIds } = props;
try {
appContext.startLoading();
await trashFiles([file]);
appContext.finishLoading();
deletedFileIds.add(file.id);
setDeletedFileIds(new Set(deletedFileIds));
tempDeletedFileIds.add(file.id);
setTempDeletedFileIds(new Set(tempDeletedFileIds));
updateItems(props.items.filter((item) => item.id !== file.id));
needUpdate.current = true;
} catch (e) {
@ -505,6 +514,24 @@ function PhotoViewer(props: Iprops) {
appContext.setDialogMessage(getTrashFileMessage(() => trashFile(file)));
};
const handleArrowClick = (
e: KeyboardEvent,
direction: 'left' | 'right'
) => {
// ignore arrow clicks if the user is typing in a text field
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return;
}
if (direction === 'left') {
photoSwipe.prev();
} else {
photoSwipe.next();
}
};
const updateItems = (items: EnteFile[]) => {
try {
if (photoSwipe) {
@ -599,15 +626,21 @@ function PhotoViewer(props: Iprops) {
setShowImageEditorOverlay(false);
};
const downloadFileHelper = async (file) => {
if (file && props.enableDownload) {
appContext.startLoading();
const downloadFileHelper = async (file: EnteFile) => {
if (
file &&
props.enableDownload &&
props.setFilesDownloadProgressAttributesCreator
) {
try {
await downloadFile(file);
const setSingleFileDownloadProgress =
props.setFilesDownloadProgressAttributesCreator(
file.metadata.title
);
await downloadSingleFile(file, setSingleFileDownloadProgress);
} catch (e) {
// do nothing
}
appContext.finishLoading();
}
};
@ -702,7 +735,9 @@ function PhotoViewer(props: Iprops) {
onClose={() =>
setConversionFailedNotificationOpen(false)
}
onClick={() => downloadFileHelper(photoSwipe.currItem)}
onClick={() =>
downloadFileHelper(photoSwipe.currItem as EnteFile)
}
/>
<Box
@ -746,7 +781,9 @@ function PhotoViewer(props: Iprops) {
className="pswp__button pswp__button--custom"
title={t('DOWNLOAD_OPTION')}
onClick={() =>
downloadFileHelper(photoSwipe.currItem)
downloadFileHelper(
photoSwipe.currItem as EnteFile
)
}>
<DownloadIcon />
</button>

View file

@ -1,13 +1,7 @@
import { IconButton } from '@mui/material';
import pDebounce from 'p-debounce';
import { AppContext } from 'pages/_app';
import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import {
getAutoCompleteSuggestions,
getDefaultOptions,
@ -34,6 +28,9 @@ import { t } from 'i18next';
import memoize from 'memoize-one';
import { LocationTagData } from 'types/entity';
import { FILE_TYPE } from 'constants/file';
import { InputActionMeta } from 'react-select/src/types';
import { components } from 'react-select';
import { City } from 'services/locationSearchService';
interface Iprops {
isOpen: boolean;
@ -43,20 +40,33 @@ interface Iprops {
collections: Collection[];
}
const createComponents = memoize((Option, ValueContainer, Menu) => ({
const createComponents = memoize((Option, ValueContainer, Menu, Input) => ({
Option,
ValueContainer,
Menu,
Input,
}));
const VisibleInput = (props) => (
<components.Input {...props} isHidden={false} />
);
export default function SearchInput(props: Iprops) {
const selectRef = useRef(null);
const [value, setValue] = useState<SearchOption>(null);
const appContext = useContext(AppContext);
const handleChange = (value: SearchOption) => {
setValue(value);
setQuery(value.label);
blur();
};
const handleInputChange = (value: string, actionMeta: InputActionMeta) => {
if (actionMeta.action === 'input-change') {
setQuery(value);
}
};
const [defaultOptions, setDefaultOptions] = useState([]);
const [query, setQuery] = useState('');
useEffect(() => {
search(value);
@ -64,10 +74,12 @@ export default function SearchInput(props: Iprops) {
useEffect(() => {
refreshDefaultOptions();
const t = setInterval(() => refreshDefaultOptions(), 2000);
return () => clearInterval(t);
}, []);
async function refreshDefaultOptions() {
const defaultOptions = await getDefaultOptions(props.files);
const defaultOptions = await getDefaultOptions();
setDefaultOptions(defaultOptions);
}
@ -80,14 +92,22 @@ export default function SearchInput(props: Iprops) {
}, 10);
props.setIsOpen(false);
setValue(null);
setQuery('');
}
};
const getOptions = pDebounce(
getAutoCompleteSuggestions(props.files, props.collections),
250
const getOptions = useCallback(
pDebounce(
getAutoCompleteSuggestions(props.files, props.collections),
250
),
[props.files, props.collections]
);
const blur = () => {
selectRef.current?.blur();
};
const search = (selectedOption: SearchOption) => {
if (!selectedOption) {
return;
@ -106,9 +126,16 @@ export default function SearchInput(props: Iprops) {
};
props.setIsOpen(true);
break;
case SuggestionType.CITY:
search = {
city: selectedOption.value as City,
};
props.setIsOpen(true);
break;
case SuggestionType.COLLECTION:
search = { collection: selectedOption.value as number };
setValue(null);
setQuery('');
break;
case SuggestionType.FILE_NAME:
search = { files: selectedOption.value as number[] };
@ -159,7 +186,8 @@ export default function SearchInput(props: Iprops) {
const components = createComponents(
OptionWithInfo,
ValueContainerWithIcon,
MemoizedMenuWithPeople
MemoizedMenuWithPeople,
VisibleInput
);
return (
@ -173,6 +201,8 @@ export default function SearchInput(props: Iprops) {
onChange={handleChange}
onFocus={handleOnFocus}
isClearable
inputValue={query}
onInputChange={handleInputChange}
escapeClearsValue
styles={SelectStyles}
defaultOptions={

View file

@ -17,6 +17,7 @@ const getIconByType = (type: SuggestionType) => {
case SuggestionType.DATE:
return <CalendarIcon />;
case SuggestionType.LOCATION:
case SuggestionType.CITY:
return <LocationIcon />;
case SuggestionType.COLLECTION:
return <FolderIcon />;

View file

@ -52,15 +52,13 @@ export default function AdvancedSettings({ open, onClose, onRootClose }) {
});
useEffect(() => {
ClipService.setOnUpdateHandler(setIndexingStatus);
const main = async () => {
setIndexingStatus(await ClipService.getIndexingStatus());
ClipService.setOnUpdateHandler(setIndexingStatus);
};
main();
}, []);
useEffect(() => {
if (open) {
ClipService.updateIndexStatus();
}
}, [open]);
return (
<EnteDrawer
transitionDuration={0}
@ -112,7 +110,9 @@ export default function AdvancedSettings({ open, onClose, onRootClose }) {
{isElectron() && (
<Box>
<MenuSectionTitle title={t('STATUS')} />
<MenuSectionTitle
title={t('MAGIC_SEARCH_STATUS')}
/>
<Stack py={'12px'} px={'12px'} spacing={'24px'}>
<VerticallyCenteredFlex
justifyContent="space-between"

View file

@ -12,6 +12,7 @@ import UploadProgressContext from 'contexts/uploadProgress';
import { t } from 'i18next';
import { UPLOAD_STAGES } from 'constants/upload';
import { CaptionedText } from 'components/CaptionedText';
export const InProgressSection = () => {
const { inProgressUploads, hasLivePhotos, uploadFileNames, uploadStage } =
@ -44,9 +45,14 @@ export const InProgressSection = () => {
return (
<UploadProgressSection>
<UploadProgressSectionTitle expandIcon={<ExpandMoreIcon />}>
{uploadStage === UPLOAD_STAGES.EXTRACTING_METADATA
? t('INPROGRESS_METADATA_EXTRACTION')
: t('INPROGRESS_UPLOADS')}
<CaptionedText
mainText={
uploadStage === UPLOAD_STAGES.EXTRACTING_METADATA
? t('INPROGRESS_METADATA_EXTRACTION')
: t('INPROGRESS_UPLOADS')
}
subText={String(inProgressUploads?.length ?? 0)}
/>
</UploadProgressSectionTitle>
<UploadProgressSectionContent>
{hasLivePhotos && (

View file

@ -1,6 +1,5 @@
import React, { useContext } from 'react';
import { useContext } from 'react';
import ItemList from 'components/ItemList';
import { Typography } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { ResultItemContainer } from './styledComponents';
import { UPLOAD_RESULT } from 'constants/upload';
@ -11,6 +10,7 @@ import {
UploadProgressSectionTitle,
} from './section';
import UploadProgressContext from 'contexts/uploadProgress';
import { CaptionedText } from 'components/CaptionedText';
export interface ResultSectionProps {
uploadResult: UPLOAD_RESULT;
@ -46,7 +46,10 @@ export const ResultSection = (props: ResultSectionProps) => {
return (
<UploadProgressSection>
<UploadProgressSectionTitle expandIcon={<ExpandMoreIcon />}>
<Typography> {props.sectionTitle}</Typography>
<CaptionedText
mainText={props.sectionTitle}
subText={String(fileList?.length ?? 0)}
/>
</UploadProgressSectionTitle>
<UploadProgressSectionContent>
{props.sectionInfo && (

View file

@ -391,7 +391,7 @@ export default function Uploader(props: Props) {
) => {
try {
addLogLine(
`upload file to an existing collection - "${collection.name}"`
`upload file to an existing collection name:${collection.name}, collectionID:${collection.id}`
);
await preCollectionCreationAction();
const filesWithCollectionToUpload: FileWithCollection[] =

View file

@ -1,6 +1,6 @@
import React, { useContext, useEffect, useRef, useState } from 'react';
import { EnteFile } from 'types/file';
import { styled } from '@mui/material';
import { Tooltip, styled } from '@mui/material';
import PlayCircleOutlineOutlinedIcon from '@mui/icons-material/PlayCircleOutlineOutlined';
import DownloadManager from 'services/download';
import useLongPress from '@ente/shared/hooks/useLongPress';
@ -218,6 +218,12 @@ export default function PreviewCard(props: IProps) {
const galleryContext = useContext(GalleryContext);
const deduplicateContext = useContext(DeduplicateContext);
const longPressCallback = () => {
onSelect(!selected);
};
const longPress = useLongPress(longPressCallback, 500);
const {
file,
onClick,
@ -289,22 +295,19 @@ export default function PreviewCard(props: IProps) {
}
};
const longPressCallback = () => {
onSelect(!selected);
};
const handleHover = () => {
if (isRangeSelectActive) {
onHover();
}
};
return (
const renderFn = () => (
<Cont
key={`thumb-${file.id}}`}
onClick={handleClick}
onMouseEnter={handleHover}
disabled={!file?.msrc && !imgSrc}
{...(selectable ? useLongPress(longPressCallback, 500) : {})}>
{...(selectable ? longPress : {})}>
{selectable && (
<Check
type="checkbox"
@ -360,4 +363,22 @@ export default function PreviewCard(props: IProps) {
)}
</Cont>
);
if (deduplicateContext.isOnDeduplicatePage) {
return (
<Tooltip
placement="bottom-start"
enterDelay={300}
enterNextDelay={100}
title={`${
file.metadata.title
} - ${deduplicateContext.collectionNameMap.get(
file.collectionID
)}`}>
{renderFn()}
</Tooltip>
);
} else {
return renderFn();
}
}

View file

@ -0,0 +1,45 @@
import { useContext } from 'react';
import { FluidContainer } from '@ente/shared/components/Container';
import { SelectionBar } from '@ente/shared/components/Navbar/SelectionBar';
import { AppContext } from 'pages/_app';
import { Box, IconButton, Stack, Tooltip } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import DownloadIcon from '@mui/icons-material/Download';
import { t } from 'i18next';
import { formatNumber } from 'utils/number/format';
interface Props {
count: number;
clearSelection: () => void;
downloadFilesHelper: () => void;
}
const SelectedFileOptions = ({
downloadFilesHelper,
count,
clearSelection,
}: Props) => {
const { isMobile } = useContext(AppContext);
return (
<SelectionBar isMobile={isMobile}>
<FluidContainer>
<IconButton onClick={clearSelection}>
<CloseIcon />
</IconButton>
<Box ml={1.5}>
{formatNumber(count)} {t('SELECTED')}{' '}
</Box>
</FluidContainer>
<Stack spacing={2} direction="row" mr={2}>
<Tooltip title={t('DOWNLOAD')}>
<IconButton onClick={downloadFilesHelper}>
<DownloadIcon />
</IconButton>
</Tooltip>
</Stack>
</SelectionBar>
);
};
export default SelectedFileOptions;

View file

@ -0,0 +1,10 @@
export const FILTER_DEFAULT_VALUES = {
brightness: 100,
contrast: 100,
blur: 0,
saturation: 100,
invert: false,
};
// CORNER_THRESHOLD defines the threshold near the corners of the crop box in which dragging is assumed as not the intention
export const CORNER_THRESHOLD = 20;

View file

@ -62,6 +62,7 @@ import exportService from 'services/export';
import { REDIRECTS } from 'constants/redirects';
import {
getLocalMapEnabled,
getToken,
setLocalMapEnabled,
} from '@ente/shared/storage/localStorage/helpers';
import { isExportInProgress } from 'utils/export';
@ -76,6 +77,7 @@ import { useLocalState } from '@ente/shared/hooks/useLocalState';
import { PHOTOS_PAGES as PAGES } from '@ente/shared/constants/pages';
import { getTheme } from '@ente/shared/themes';
import { AppUpdateInfo } from '@ente/shared/electron/types';
import DownloadManager from 'services/download';
const redirectMap = new Map([
[REDIRECTS.ROADMAP, getRoadmapRedirectURL],
@ -232,6 +234,14 @@ export default function App(props: EnteAppProps) {
const initExport = async () => {
try {
addLogLine('init export');
const token = getToken();
if (!token) {
addLogLine(
'User not logged in, not starting export continuous sync job'
);
return;
}
await DownloadManager.init(APPS.PHOTOS, { token });
const exportSettings = exportService.getExportSettings();
if (!exportService.exportFolderExists(exportSettings?.folder)) {
return;

View file

@ -97,6 +97,8 @@ import { EnteFile } from 'types/file';
import {
GalleryContextType,
SelectedState,
SetFilesDownloadProgressAttributes,
SetFilesDownloadProgressAttributesCreator,
UploadTypeSelectorIntent,
} from 'types/gallery';
import Collections from 'components/Collections';
@ -120,7 +122,6 @@ import GalleryEmptyState from 'components/GalleryEmptyState';
import AuthenticateUserModal from 'components/AuthenticateUserModal';
import useMemoSingleThreaded from '@ente/shared/hooks/useMemoSingleThreaded';
import { isArchivedFile } from 'utils/magicMetadata';
import { isSameDayAnyYear, isInsideLocationTag } from 'utils/search';
import { getSessionExpiredMessage } from 'utils/ui';
import { syncEntities } from 'services/entityService';
import { constructUserIDToEmailMap } from 'services/collectionService';
@ -131,6 +132,13 @@ import { ClipService } from 'services/clipService';
import isElectron from 'is-electron';
import downloadManager from 'services/download';
import { APPS } from '@ente/shared/apps/constants';
import {
FilesDownloadProgress,
FilesDownloadProgressAttributes,
} from 'components/FilesDownloadProgress';
import locationSearchService from 'services/locationSearchService';
import ComlinkSearchWorker from 'utils/comlink/ComlinkSearchWorker';
import useEffectSingleThreaded from '@ente/shared/hooks/useEffectSingleThreaded';
export const DeadCenter = styled('div')`
flex: 1;
@ -225,10 +233,11 @@ export default function Gallery() {
const syncInProgress = useRef(true);
const syncInterval = useRef<NodeJS.Timeout>();
const resync = useRef<{ force: boolean; silent: boolean }>();
const [deletedFileIds, setDeletedFileIds] = useState<Set<number>>(
// tempDeletedFileIds and tempHiddenFileIds are used to keep track of files that are deleted/hidden in the current session but not yet synced with the server.
const [tempDeletedFileIds, setTempDeletedFileIds] = useState<Set<number>>(
new Set<number>()
);
const [hiddenFileIds, setHiddenFileIds] = useState<Set<number>>(
const [tempHiddenFileIds, setTempHiddenFileIds] = useState<Set<number>>(
new Set<number>()
);
const { startLoading, finishLoading, setDialogMessage, ...appContext } =
@ -242,6 +251,9 @@ export default function Gallery() {
const [emailList, setEmailList] = useState<string[]>(null);
const [activeCollectionID, setActiveCollectionID] =
useState<number>(undefined);
const [hiddenFileIds, setHiddenFileIds] = useState<Set<number>>(
new Set<number>()
);
const [fixCreationTimeView, setFixCreationTimeView] = useState(false);
const [fixCreationTimeAttributes, setFixCreationTimeAttributes] =
useState<FixCreationTimeAttributes>(null);
@ -280,6 +292,11 @@ export default function Gallery() {
const [isInHiddenSection, setIsInHiddenSection] = useState(false);
const [
filesDownloadProgressAttributesList,
setFilesDownloadProgressAttributesList,
] = useState<FilesDownloadProgressAttributes[]>([]);
const openHiddenSection: GalleryContextType['openHiddenSection'] = (
callback
) => {
@ -341,6 +358,7 @@ export default function Gallery() {
setIsFirstLoad(false);
setJustSignedUp(false);
setIsFirstFetch(false);
locationSearchService.loadCities();
syncInterval.current = setInterval(() => {
syncWithRemote(false, true);
}, SYNC_INTERVAL_IN_MICROSECONDS);
@ -361,6 +379,14 @@ export default function Gallery() {
};
}, []);
useEffectSingleThreaded(
async ([files]: [files: EnteFile[]]) => {
const searchWorker = await ComlinkSearchWorker.getInstance();
await searchWorker.setFiles(files);
},
[files]
);
useEffect(() => {
if (!user || !files || !collections || !hiddenFiles || !trashedFiles) {
return;
@ -466,7 +492,9 @@ export default function Gallery() {
);
}, [collections, activeCollectionID]);
const filteredData = useMemoSingleThreaded((): EnteFile[] => {
const filteredData = useMemoSingleThreaded(async (): Promise<
EnteFile[]
> => {
if (
!files ||
!user ||
@ -480,117 +508,74 @@ export default function Gallery() {
if (activeCollectionID === TRASH_SECTION && !isInSearchMode) {
return getUniqueFiles([
...trashedFiles,
...files.filter((file) => deletedFileIds?.has(file.id)),
...files.filter((file) => tempDeletedFileIds?.has(file.id)),
]);
}
const filteredFiles = getUniqueFiles(
(isInHiddenSection ? hiddenFiles : files).filter((item) => {
if (deletedFileIds?.has(item.id)) {
return false;
}
const searchWorker = await ComlinkSearchWorker.getInstance();
if (!isInHiddenSection && hiddenFileIds?.has(item.id)) {
return false;
}
let filteredFiles: EnteFile[] = [];
if (isInSearchMode) {
filteredFiles = getUniqueFiles(await searchWorker.search(search));
} else {
filteredFiles = getUniqueFiles(
(isInHiddenSection ? hiddenFiles : files).filter((item) => {
if (tempDeletedFileIds?.has(item.id)) {
return false;
}
// SEARCH MODE
if (isInSearchMode) {
if (
search?.date &&
!isSameDayAnyYear(search.date)(
new Date(item.metadata.creationTime / 1000)
)
) {
if (!isInHiddenSection && tempHiddenFileIds?.has(item.id)) {
return false;
}
if (
search?.location &&
!isInsideLocationTag(
{
latitude: item.metadata.latitude,
longitude: item.metadata.longitude,
},
search.location
)
) {
return false;
}
if (
search?.person &&
search.person.files.indexOf(item.id) === -1
) {
return false;
}
if (
search?.thing &&
search.thing.files.indexOf(item.id) === -1
) {
return false;
}
if (
search?.text &&
search.text.files.indexOf(item.id) === -1
) {
return false;
}
if (search?.files && search.files.indexOf(item.id) === -1) {
return false;
}
if (
typeof search?.fileType !== 'undefined' &&
search.fileType !== item.metadata.fileType
) {
return false;
}
if (search?.clip && search.clip.has(item.id) === false) {
return false;
}
return true;
}
// archived collections files can only be seen in their respective collection
if (archivedCollections.has(item.collectionID)) {
// archived collections files can only be seen in their respective collection
if (archivedCollections.has(item.collectionID)) {
if (activeCollectionID === item.collectionID) {
return true;
} else {
return false;
}
}
// HIDDEN ITEMS SECTION - show all individual hidden files
if (
activeCollectionID === HIDDEN_ITEMS_SECTION &&
defaultHiddenCollectionIDs.has(item.collectionID)
) {
return true;
}
// Archived files can only be seen in archive section or their respective collection
if (isArchivedFile(item)) {
if (
activeCollectionID === ARCHIVE_SECTION ||
activeCollectionID === item.collectionID
) {
return true;
} else {
return false;
}
}
// ALL SECTION - show all files
if (activeCollectionID === ALL_SECTION) {
// show all files except the ones in hidden collections
if (hiddenFileIds.has(item.id)) {
return false;
} else {
return true;
}
}
// COLLECTION SECTION - show files in the active collection
if (activeCollectionID === item.collectionID) {
return true;
} else {
return false;
}
}
// HIDDEN ITEMS SECTION - show all individual hidden files
if (
activeCollectionID === HIDDEN_ITEMS_SECTION &&
defaultHiddenCollectionIDs.has(item.collectionID)
) {
return true;
}
// Archived files can only be seen in archive section or their respective collection
if (isArchivedFile(item)) {
if (
activeCollectionID === ARCHIVE_SECTION ||
activeCollectionID === item.collectionID
) {
return true;
} else {
return false;
}
}
// ALL SECTION - show all files
if (activeCollectionID === ALL_SECTION) {
return true;
}
// COLLECTION SECTION - show files in the active collection
if (activeCollectionID === item.collectionID) {
return true;
} else {
return false;
}
})
);
})
);
}
if (search?.clip) {
return filteredFiles.sort((a, b) => {
return search.clip.get(b.id) - search.clip.get(a.id);
@ -606,7 +591,8 @@ export default function Gallery() {
files,
trashedFiles,
hiddenFiles,
deletedFileIds,
tempDeletedFileIds,
tempHiddenFileIds,
hiddenFileIds,
search,
activeCollectionID,
@ -737,8 +723,8 @@ export default function Gallery() {
logError(e, 'syncWithRemote failed');
}
} finally {
setDeletedFileIds(new Set());
setHiddenFileIds(new Set());
setTempDeletedFileIds(new Set());
setTempHiddenFileIds(new Set());
!silent && finishLoading();
}
syncInProgress.current = false;
@ -783,6 +769,8 @@ export default function Gallery() {
const defaultHiddenCollectionIDs =
getDefaultHiddenCollectionIDs(hiddenCollections);
setDefaultHiddenCollectionIDs(defaultHiddenCollectionIDs);
const hiddenFileIds = new Set<number>(hiddenFiles.map((f) => f.id));
setHiddenFileIds(hiddenFileIds);
const collectionSummaries = getCollectionSummaries(
user,
collections,
@ -816,6 +804,39 @@ export default function Gallery() {
return <div />;
}
const setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator =
(folderName, collectionID, isHidden) => {
const id = filesDownloadProgressAttributesList?.length ?? 0;
const updater: SetFilesDownloadProgressAttributes = (value) => {
setFilesDownloadProgressAttributesList((prev) => {
const attributes = prev?.find((attr) => attr.id === id);
const updatedAttributes =
typeof value === 'function'
? value(attributes)
: { ...attributes, ...value };
const updatedAttributesList = attributes
? prev.map((attr) =>
attr.id === id ? updatedAttributes : attr
)
: [...prev, updatedAttributes];
return updatedAttributesList;
});
};
updater({
id,
folderName,
collectionID,
isHidden,
canceller: null,
total: 0,
success: 0,
failed: 0,
downloadDirPath: null,
});
return updater;
};
const collectionOpsHelper =
(ops: COLLECTION_OPS_TYPE) => async (collection: Collection) => {
startLoading();
@ -836,13 +857,20 @@ export default function Gallery() {
selected.collectionID
);
}
if (selected?.ownCount === filteredData?.length) {
if (
ops === COLLECTION_OPS_TYPE.REMOVE ||
ops === COLLECTION_OPS_TYPE.RESTORE ||
ops === COLLECTION_OPS_TYPE.MOVE
) {
// redirect to all section when no items are left in the current collection.
setActiveCollectionID(ALL_SECTION);
} else if (ops === COLLECTION_OPS_TYPE.UNHIDE) {
exitHiddenSection();
}
}
clearSelection();
await syncWithRemote(false, true);
if (isInHiddenSection && ops === COLLECTION_OPS_TYPE.UNHIDE) {
exitHiddenSection();
}
setActiveCollectionID(collection.id);
} catch (e) {
logError(e, 'collection ops failed', { ops });
setDialogMessage({
@ -872,11 +900,20 @@ export default function Gallery() {
await handleFileOps(
ops,
toProcessFiles,
setDeletedFileIds,
setHiddenFileIds,
setFixCreationTimeAttributes
setTempDeletedFileIds,
setTempHiddenFileIds,
setFixCreationTimeAttributes,
setFilesDownloadProgressAttributesCreator
);
}
if (
selected?.ownCount === filteredData?.length &&
ops !== FILE_OPS_TYPE.ARCHIVE &&
ops !== FILE_OPS_TYPE.DOWNLOAD &&
ops !== FILE_OPS_TYPE.FIX_TIME
) {
setActiveCollectionID(ALL_SECTION);
}
clearSelection();
await syncWithRemote(false, true);
} catch (e) {
@ -1013,6 +1050,10 @@ export default function Gallery() {
attributes={collectionSelectorAttributes}
collections={collections}
/>
<FilesDownloadProgress
attributesList={filesDownloadProgressAttributesList}
setAttributesList={setFilesDownloadProgressAttributesList}
/>
<FixCreationTime
isOpen={fixCreationTimeView}
hide={() => setFixCreationTimeView(false)}
@ -1042,6 +1083,12 @@ export default function Gallery() {
hiddenCollectionSummaries={hiddenCollectionSummaries}
setCollectionNamerAttributes={setCollectionNamerAttributes}
setPhotoListHeader={setPhotoListHeader}
setFilesDownloadProgressAttributesCreator={
setFilesDownloadProgressAttributesCreator
}
filesDownloadProgressAttributesList={
filesDownloadProgressAttributesList
}
/>
<Uploader
@ -1098,8 +1145,8 @@ export default function Gallery() {
favItemIds={favItemIds}
setSelected={setSelected}
selected={selected}
deletedFileIds={deletedFileIds}
setDeletedFileIds={setDeletedFileIds}
tempDeletedFileIds={tempDeletedFileIds}
setTempDeletedFileIds={setTempDeletedFileIds}
setIsPhotoSwipeOpen={setIsPhotoSwipeOpen}
activeCollectionID={activeCollectionID}
enableDownload={true}
@ -1109,6 +1156,9 @@ export default function Gallery() {
files.length < 30 && !isInSearchMode
}
isInHiddenSection={isInHiddenSection}
setFilesDownloadProgressAttributesCreator={
setFilesDownloadProgressAttributesCreator
}
/>
)}
{selected.count > 0 &&

View file

@ -16,7 +16,12 @@ import {
} from 'services/publicCollectionService';
import { Collection } from 'types/collection';
import { EnteFile } from 'types/file';
import { downloadFile, mergeMetadata, sortFiles } from 'utils/file';
import {
downloadSelectedFiles,
getSelectedFiles,
mergeMetadata,
sortFiles,
} from 'utils/file';
import { AppContext } from 'pages/_app';
import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery';
import { CustomError, parseSharingErrorCodes } from '@ente/shared/error';
@ -52,7 +57,12 @@ import UploadButton from 'components/Upload/UploadButton';
import bs58 from 'bs58';
import AddPhotoAlternateOutlined from '@mui/icons-material/AddPhotoAlternateOutlined';
import ComlinkCryptoWorker from '@ente/shared/crypto';
import { UploadTypeSelectorIntent } from 'types/gallery';
import {
SelectedState,
SetFilesDownloadProgressAttributes,
SetFilesDownloadProgressAttributesCreator,
UploadTypeSelectorIntent,
} from 'types/gallery';
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
import MoreHoriz from '@mui/icons-material/MoreHoriz';
import OverflowMenu from '@ente/shared/components/OverflowMenu/menu';
@ -60,6 +70,12 @@ import { OverflowMenuOption } from '@ente/shared/components/OverflowMenu/option'
import { ENTE_WEBSITE_LINK } from '@ente/shared/constants/urls';
import { APPS } from '@ente/shared/apps/constants';
import downloadManager from 'services/download';
import {
FilesDownloadProgress,
FilesDownloadProgressAttributes,
} from 'components/FilesDownloadProgress';
import { downloadCollectionFiles, isHiddenCollection } from 'utils/collection';
import SelectedFileOptions from 'components/pages/sharedAlbum/SelectedFileOptions';
export default function PublicCollectionGallery() {
const token = useRef<string>(null);
@ -86,6 +102,11 @@ export default function PublicCollectionGallery() {
const [uploadTypeSelectorView, setUploadTypeSelectorView] = useState(false);
const [blockingLoad, setBlockingLoad] = useState(false);
const [shouldDisableDropzone, setShouldDisableDropzone] = useState(false);
const [selected, setSelected] = useState<SelectedState>({
ownCount: 0,
count: 0,
collectionID: 0,
});
const {
getRootProps: getDragAndDropRootProps,
@ -111,6 +132,44 @@ export default function PublicCollectionGallery() {
directory: true,
});
const [
filesDownloadProgressAttributesList,
setFilesDownloadProgressAttributesList,
] = useState<FilesDownloadProgressAttributes[]>([]);
const setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator =
(folderName, collectionID, isHidden) => {
const id = filesDownloadProgressAttributesList?.length ?? 0;
const updater: SetFilesDownloadProgressAttributes = (value) => {
setFilesDownloadProgressAttributesList((prev) => {
const attributes = prev?.find((attr) => attr.id === id);
const updatedAttributes =
typeof value === 'function'
? value(attributes)
: { ...attributes, ...value };
const updatedAttributesList = attributes
? prev.map((attr) =>
attr.id === id ? updatedAttributes : attr
)
: [...prev, updatedAttributes];
return updatedAttributesList;
});
};
updater({
id,
folderName,
collectionID,
isHidden,
canceller: null,
total: 0,
success: 0,
failed: 0,
downloadDirPath: null,
});
return updater;
};
const openUploader = () => {
setUploadTypeSelectorView(true);
};
@ -217,18 +276,24 @@ export default function PublicCollectionGallery() {
);
const downloadAllFiles = async () => {
if (!downloadEnabled) {
return;
}
appContext.startLoading();
for (const file of publicFiles) {
try {
await downloadFile(file);
} catch (e) {
// do nothing
try {
if (!downloadEnabled) {
return;
}
const setFilesDownloadProgressAttributes =
setFilesDownloadProgressAttributesCreator(
publicCollection.name,
publicCollection.id,
isHiddenCollection(publicCollection)
);
await downloadCollectionFiles(
publicCollection.name,
publicFiles,
setFilesDownloadProgressAttributes
);
} catch (e) {
logError(e, 'failed to downloads shared album all files');
}
appContext.finishLoading();
};
useEffect(() => {
@ -441,6 +506,30 @@ export default function PublicCollectionGallery() {
}
}
const clearSelection = () => {
if (!selected?.count) {
return;
}
setSelected({ ownCount: 0, count: 0, collectionID: 0 });
};
const downloadFilesHelper = async () => {
try {
const selectedFiles = getSelectedFiles(selected, publicFiles);
const setFilesDownloadProgressAttributes =
setFilesDownloadProgressAttributesCreator(
`${selectedFiles.length} ${t('FILES')}`
);
await downloadSelectedFiles(
selectedFiles,
setFilesDownloadProgressAttributes
);
clearSelection();
} catch (e) {
logError(e, 'failed to download selected files');
}
};
return (
<PublicCollectionGalleryContext.Provider
value={{
@ -468,12 +557,15 @@ export default function PublicCollectionGallery() {
page={PAGES.SHARED_ALBUMS}
files={publicFiles}
syncWithRemote={syncWithRemote}
setSelected={() => null}
selected={{ count: 0, collectionID: null, ownCount: 0 }}
setSelected={setSelected}
selected={selected}
activeCollectionID={ALL_SECTION}
enableDownload={downloadEnabled}
fileToCollectionsMap={null}
collectionNameMap={null}
setFilesDownloadProgressAttributesCreator={
setFilesDownloadProgressAttributesCreator
}
/>
{blockingLoad && (
<LoadingOverlay>
@ -498,6 +590,17 @@ export default function PublicCollectionGallery() {
UploadTypeSelectorIntent.collectPhotos
}
/>
<FilesDownloadProgress
attributesList={filesDownloadProgressAttributesList}
setAttributesList={setFilesDownloadProgressAttributesList}
/>
{selected.count > 0 && (
<SelectedFileOptions
downloadFilesHelper={downloadFilesHelper}
clearSelection={clearSelection}
count={selected.count}
/>
)}
</FullScreenDropZone>
</PublicCollectionGalleryContext.Provider>
);

View file

@ -1,8 +1,4 @@
import {
putEmbedding,
getLatestEmbeddings,
getLocalEmbeddings,
} from './embeddingService';
import { putEmbedding, getLocalEmbeddings } from './embeddingService';
import { getAllLocalFiles, getLocalFiles } from './fileService';
import downloadManager from './download';
import { logError } from '@ente/shared/sentry';
@ -45,12 +41,22 @@ class ClipServiceImpl {
this.liveEmbeddingExtractionQueue = new PQueue({
concurrency: 1,
});
eventBus.on(Events.LOGOUT, this.logoutHandler, this);
}
isPlatformSupported = () => {
return isElectron() && !this.unsupportedPlatform;
};
private logoutHandler = async () => {
if (this.embeddingExtractionInProgress) {
this.embeddingExtractionInProgress.abort();
}
if (this.onFileUploadedHandler) {
await this.removeOnFileUploadListener();
}
};
setupOnFileUploadListener = async () => {
try {
if (this.unsupportedPlatform) {
@ -90,14 +96,18 @@ class ClipServiceImpl {
}
};
updateIndexStatus = async () => {
getIndexingStatus = async () => {
try {
addLogLine('loading local clip index status');
this.clipExtractionStatus = await getClipExtractionStatus();
this.onUpdateHandler(this.clipExtractionStatus);
addLogLine('loaded local clip index status');
if (
!this.clipExtractionStatus ||
(this.clipExtractionStatus.pending === 0 &&
this.clipExtractionStatus.indexed === 0)
) {
this.clipExtractionStatus = await getClipExtractionStatus();
}
return this.clipExtractionStatus;
} catch (e) {
logError(e, 'failed to load local clip index status');
logError(e, 'failed to get clip indexing status');
}
};
@ -106,7 +116,9 @@ class ClipServiceImpl {
handler(this.clipExtractionStatus);
};
scheduleImageEmbeddingExtraction = async () => {
scheduleImageEmbeddingExtraction = async (
model: Model = Model.ONNX_CLIP
) => {
try {
if (this.embeddingExtractionInProgress) {
addLogLine(
@ -122,7 +134,7 @@ class ClipServiceImpl {
const canceller = new AbortController();
this.embeddingExtractionInProgress = canceller;
try {
await this.runClipEmbeddingExtraction(canceller);
await this.runClipEmbeddingExtraction(canceller, model);
} finally {
this.embeddingExtractionInProgress = null;
if (!canceller.signal.aborted && this.reRunNeeded) {
@ -141,9 +153,12 @@ class ClipServiceImpl {
}
};
getTextEmbedding = async (text: string): Promise<Float32Array> => {
getTextEmbedding = async (
text: string,
model: Model = Model.ONNX_CLIP
): Promise<Float32Array> => {
try {
return ElectronAPIs.computeTextEmbedding(text);
return ElectronAPIs.computeTextEmbedding(model, text);
} catch (e) {
if (e?.message?.includes(CustomError.UNSUPPORTED_PLATFORM)) {
this.unsupportedPlatform = true;
@ -153,7 +168,10 @@ class ClipServiceImpl {
}
};
private runClipEmbeddingExtraction = async (canceller: AbortController) => {
private runClipEmbeddingExtraction = async (
canceller: AbortController,
model: Model
) => {
try {
if (this.unsupportedPlatform) {
addLogLine(
@ -166,7 +184,7 @@ class ClipServiceImpl {
return;
}
const localFiles = getPersonalFiles(await getAllLocalFiles(), user);
const existingEmbeddings = await getLatestClipImageEmbeddings();
const existingEmbeddings = await getLocalEmbeddings(model);
const pendingFiles = await getNonClipEmbeddingExtractedFiles(
localFiles,
existingEmbeddings
@ -191,11 +209,15 @@ class ClipServiceImpl {
throw Error(CustomError.REQUEST_CANCELLED);
}
const embeddingData =
await this.extractFileClipImageEmbedding(file);
await this.extractFileClipImageEmbedding(model, file);
addLogLine(
`successfully extracted clip embedding for file: ${file.metadata.title} fileID: ${file.id} embedding length: ${embeddingData?.length}`
);
await this.encryptAndUploadEmbedding(file, embeddingData);
await this.encryptAndUploadEmbedding(
model,
file,
embeddingData
);
this.onSuccessStatusUpdater();
addLogLine(
`successfully put clip embedding to server for file: ${file.metadata.title} fileID: ${file.id}`
@ -228,10 +250,13 @@ class ClipServiceImpl {
}
};
private async runLocalFileClipExtraction(arg: {
enteFile: EnteFile;
localFile: globalThis.File;
}) {
private async runLocalFileClipExtraction(
arg: {
enteFile: EnteFile;
localFile: globalThis.File;
},
model: Model = Model.ONNX_CLIP
) {
const { enteFile, localFile } = arg;
addLogLine(
`clip embedding extraction onFileUploadedHandler file: ${enteFile.metadata.title} fileID: ${enteFile.id}`,
@ -243,12 +268,27 @@ class ClipServiceImpl {
);
return;
}
const extension = enteFile.metadata.title.split('.').pop();
if (!extension || !['jpg', 'jpeg'].includes(extension)) {
addLogLine(
`skipping non jpg file for clip embedding extraction file: ${enteFile.metadata.title} fileID: ${enteFile.id}`
);
return;
}
addLogLine(
`queuing up for local clip embedding extraction for file: ${enteFile.metadata.title} fileID: ${enteFile.id}`
);
try {
await this.liveEmbeddingExtractionQueue.add(async () => {
const embedding = await this.extractLocalFileClipImageEmbedding(
model,
localFile
);
await this.encryptAndUploadEmbedding(enteFile, embedding);
await this.encryptAndUploadEmbedding(
model,
enteFile,
embedding
);
});
addLogLine(
`successfully extracted clip embedding for file: ${enteFile.metadata.title} fileID: ${enteFile.id}`
@ -258,15 +298,19 @@ class ClipServiceImpl {
}
}
private extractLocalFileClipImageEmbedding = async (localFile: File) => {
private extractLocalFileClipImageEmbedding = async (
model: Model,
localFile: File
) => {
const file = await localFile
.arrayBuffer()
.then((buffer) => new Uint8Array(buffer));
const embedding = await ElectronAPIs.computeImageEmbedding(file);
const embedding = await ElectronAPIs.computeImageEmbedding(model, file);
return embedding;
};
private encryptAndUploadEmbedding = async (
model: Model,
file: EnteFile,
embeddingData: Float32Array
) => {
@ -285,7 +329,7 @@ class ClipServiceImpl {
fileID: file.id,
encryptedEmbedding: encryptedEmbeddingData.encryptedData,
decryptionHeader: encryptedEmbeddingData.decryptionHeader,
model: Model.GGML_CLIP,
model,
});
};
@ -296,9 +340,15 @@ class ClipServiceImpl {
}
};
private extractFileClipImageEmbedding = async (file: EnteFile) => {
private extractFileClipImageEmbedding = async (
model: Model,
file: EnteFile
) => {
const thumb = await downloadManager.getThumbnail(file);
const embedding = await ElectronAPIs.computeImageEmbedding(thumb);
const embedding = await ElectronAPIs.computeImageEmbedding(
model,
thumb
);
return embedding;
};
@ -333,13 +383,6 @@ const getNonClipEmbeddingExtractedFiles = async (
});
};
export const getLocalClipImageEmbeddings = async () => {
const allEmbeddings = await getLocalEmbeddings();
return allEmbeddings.filter(
(embedding) => embedding.model === Model.GGML_CLIP
);
};
export const computeClipMatchScore = async (
imageEmbedding: Float32Array,
textEmbedding: Float32Array
@ -367,19 +410,14 @@ export const computeClipMatchScore = async (
return score;
};
const getLatestClipImageEmbeddings = async () => {
const allEmbeddings = await getLatestEmbeddings();
return allEmbeddings.filter(
(embedding) => embedding.model === Model.GGML_CLIP
);
};
const getClipExtractionStatus = async (): Promise<ClipExtractionStatus> => {
const getClipExtractionStatus = async (
model: Model = Model.ONNX_CLIP
): Promise<ClipExtractionStatus> => {
const user = getData(LS_KEYS.USER);
if (!user) {
return;
}
const allEmbeddings = await getLocalClipImageEmbeddings();
const allEmbeddings = await getLocalEmbeddings(model);
const localFiles = getPersonalFiles(await getLocalFiles(), user);
const pendingFiles = await getNonClipEmbeddingExtractedFiles(
localFiles,

View file

@ -26,7 +26,11 @@ export async function getDuplicates(
collectionNameMap: Map<number, string>
) {
try {
const dupes = await fetchDuplicateFileIDs();
const ascDupes = await fetchDuplicateFileIDs();
const descSortedDupes = ascDupes.sort((firstDupe, secondDupe) => {
return secondDupe.size - firstDupe.size;
});
const fileMap = new Map<number, EnteFile>();
for (const file of files) {
@ -35,7 +39,7 @@ export async function getDuplicates(
let result: Duplicate[] = [];
for (const dupe of dupes) {
for (const dupe of descSortedDupes) {
let duplicateFiles: EnteFile[] = [];
for (const fileID of dupe.fileIDs) {
if (fileMap.has(fileID)) {

View file

@ -19,6 +19,7 @@ import { PhotosDownloadClient } from './clients/photos';
import { PublicAlbumsDownloadClient } from './clients/publicAlbums';
import isElectron from 'is-electron';
import { isInternalUser } from 'utils/user';
import { Events, eventBus } from '@ente/shared/events';
export type LivePhotoSourceURL = {
image: () => Promise<string>;
@ -89,12 +90,30 @@ class DownloadManagerImpl {
this.diskFileCache = isElectron() && (await openDiskFileCache());
this.cryptoWorker = await ComlinkCryptoWorker.getInstance();
this.ready = true;
eventBus.on(Events.LOGOUT, this.logoutHandler.bind(this), this);
} catch (e) {
logError(e, 'DownloadManager init failed');
throw e;
}
}
private async logoutHandler() {
try {
addLogLine('downloadManger logoutHandler started');
this.ready = false;
this.cryptoWorker = null;
this.downloadClient = null;
this.fileObjectURLPromises.clear();
this.fileConversionPromises.clear();
this.thumbnailObjectURLPromises.clear();
this.fileDownloadProgress.clear();
this.progressUpdater = () => {};
addLogLine('downloadManager logoutHandler completed');
} catch (e) {
logError(e, 'downloadManager logoutHandler failed');
}
}
updateToken(token: string, passwordToken?: string) {
this.downloadClient.updateTokens(token, passwordToken);
}

View file

@ -2,6 +2,7 @@ import {
Embedding,
EncryptedEmbedding,
GetEmbeddingDiffResponse,
Model,
PutEmbeddingRequest,
} from 'types/embedding';
import ComlinkCryptoWorker from '@ente/shared/crypto';
@ -16,105 +17,135 @@ import { getLatestVersionEmbeddings } from 'utils/embedding';
import { getLocalTrashedFiles } from './trashService';
import { getLocalCollections } from './collectionService';
import { CustomError } from '@ente/shared/error';
import { EnteFile } from 'types/file';
const ENDPOINT = getEndpoint();
const DIFF_LIMIT = 500;
const EMBEDDINGS_TABLE = 'embeddings';
const EMBEDDINGS_TABLE_V1 = 'embeddings';
const EMBEDDINGS_TABLE = 'embeddings_v2';
const EMBEDDING_SYNC_TIME_TABLE = 'embedding_sync_time';
export const getLocalEmbeddings = async () => {
const embeddings: Array<Embedding> =
(await localForage.getItem<Embedding[]>(EMBEDDINGS_TABLE)) || [];
export const getAllLocalEmbeddings = async () => {
const embeddings: Array<Embedding> = await localForage.getItem<Embedding[]>(
EMBEDDINGS_TABLE
);
if (!embeddings) {
await localForage.removeItem(EMBEDDINGS_TABLE_V1);
await localForage.removeItem(EMBEDDING_SYNC_TIME_TABLE);
await localForage.setItem(EMBEDDINGS_TABLE, []);
return [];
}
return embeddings;
};
const getEmbeddingSyncTime = async () => {
return (await localForage.getItem<number>(EMBEDDING_SYNC_TIME_TABLE)) ?? 0;
export const getLocalEmbeddings = async (model: Model) => {
const embeddings = await getAllLocalEmbeddings();
return embeddings.filter((embedding) => embedding.model === model);
};
export const getLatestEmbeddings = async () => {
await syncEmbeddings();
const embeddings = await getLocalEmbeddings();
return embeddings;
const getModelEmbeddingSyncTime = async (model: Model) => {
return (
(await localForage.getItem<number>(
`${model}-${EMBEDDING_SYNC_TIME_TABLE}`
)) ?? 0
);
};
export const syncEmbeddings = async () => {
const setModelEmbeddingSyncTime = async (model: Model, time: number) => {
await localForage.setItem(`${model}-${EMBEDDING_SYNC_TIME_TABLE}`, time);
};
export const syncEmbeddings = async (models: Model[] = [Model.ONNX_CLIP]) => {
try {
let embeddings = await getLocalEmbeddings();
let allEmbeddings = await getAllLocalEmbeddings();
const localFiles = await getAllLocalFiles();
const hiddenAlbums = await getLocalCollections('hidden');
const localTrashFiles = await getLocalTrashedFiles();
const fileIdToKeyMap = new Map<number, string>();
[...localFiles, ...localTrashFiles].forEach((file) => {
const allLocalFiles = [...localFiles, ...localTrashFiles];
allLocalFiles.forEach((file) => {
fileIdToKeyMap.set(file.id, file.key);
});
addLogLine(`Syncing embeddings localCount: ${embeddings.length}`);
let sinceTime = await getEmbeddingSyncTime();
addLogLine(`Syncing embeddings sinceTime: ${sinceTime}`);
let response: GetEmbeddingDiffResponse;
do {
response = await getEmbeddingsDiff(sinceTime);
if (!response.diff?.length) {
return;
}
const newEmbeddings = await Promise.all(
response.diff.map(async (embedding) => {
try {
const {
encryptedEmbedding,
decryptionHeader,
...rest
} = embedding;
const worker = await ComlinkCryptoWorker.getInstance();
const fileKey = fileIdToKeyMap.get(embedding.fileID);
if (!fileKey) {
throw Error(CustomError.FILE_NOT_FOUND);
}
const decryptedData = await worker.decryptEmbedding(
encryptedEmbedding,
decryptionHeader,
fileIdToKeyMap.get(embedding.fileID)
);
return {
...rest,
embedding: decryptedData,
} as Embedding;
} catch (e) {
let info: Record<string, unknown>;
if (e.message === CustomError.FILE_NOT_FOUND) {
const hasHiddenAlbums = hiddenAlbums?.length > 0;
info = {
hasHiddenAlbums,
};
}
logError(e, 'decryptEmbedding failed for file', info);
}
})
);
embeddings = getLatestVersionEmbeddings([
...embeddings,
...newEmbeddings,
]);
if (response.diff.length) {
sinceTime = response.diff.slice(-1)[0].updatedAt;
}
await localForage.setItem(EMBEDDINGS_TABLE, embeddings);
await localForage.setItem(EMBEDDING_SYNC_TIME_TABLE, sinceTime);
await cleanupDeletedEmbeddings(allLocalFiles, allEmbeddings);
addLogLine(`Syncing embeddings localCount: ${allEmbeddings.length}`);
for (const model of models) {
let modelLastSinceTime = await getModelEmbeddingSyncTime(model);
addLogLine(
`Syncing embeddings syncedEmbeddingsCount: ${newEmbeddings.length}`
`Syncing ${model} model's embeddings sinceTime: ${modelLastSinceTime}`
);
} while (response.diff.length === DIFF_LIMIT);
void cleanupDeletedEmbeddings();
let response: GetEmbeddingDiffResponse;
do {
response = await getEmbeddingsDiff(modelLastSinceTime, model);
if (!response.diff?.length) {
return;
}
const newEmbeddings = await Promise.all(
response.diff.map(async (embedding) => {
try {
const {
encryptedEmbedding,
decryptionHeader,
...rest
} = embedding;
const worker =
await ComlinkCryptoWorker.getInstance();
const fileKey = fileIdToKeyMap.get(
embedding.fileID
);
if (!fileKey) {
throw Error(CustomError.FILE_NOT_FOUND);
}
const decryptedData = await worker.decryptEmbedding(
encryptedEmbedding,
decryptionHeader,
fileIdToKeyMap.get(embedding.fileID)
);
return {
...rest,
embedding: decryptedData,
} as Embedding;
} catch (e) {
let info: Record<string, unknown>;
if (e.message === CustomError.FILE_NOT_FOUND) {
const hasHiddenAlbums =
hiddenAlbums?.length > 0;
info = {
hasHiddenAlbums,
};
}
logError(
e,
'decryptEmbedding failed for file',
info
);
}
})
);
allEmbeddings = getLatestVersionEmbeddings([
...allEmbeddings,
...newEmbeddings,
]);
if (response.diff.length) {
modelLastSinceTime = response.diff.slice(-1)[0].updatedAt;
}
await localForage.setItem(EMBEDDINGS_TABLE, allEmbeddings);
await setModelEmbeddingSyncTime(model, modelLastSinceTime);
addLogLine(
`Syncing embeddings syncedEmbeddingsCount: ${allEmbeddings.length}`
);
} while (response.diff.length === DIFF_LIMIT);
}
} catch (e) {
logError(e, 'Sync embeddings failed');
}
};
export const getEmbeddingsDiff = async (
sinceTime: number
sinceTime: number,
model: Model
): Promise<GetEmbeddingDiffResponse> => {
try {
const token = getToken();
@ -126,6 +157,7 @@ export const getEmbeddingsDiff = async (
{
sinceTime,
limit: DIFF_LIMIT,
model,
},
{
'X-Auth-Token': token,
@ -161,21 +193,21 @@ export const putEmbedding = async (
}
};
export const cleanupDeletedEmbeddings = async () => {
const files = await getAllLocalFiles();
const trashedFiles = await getLocalTrashedFiles();
export const cleanupDeletedEmbeddings = async (
allLocalFiles: EnteFile[],
allLocalEmbeddings: Embedding[]
) => {
const activeFileIds = new Set<number>();
[...files, ...trashedFiles].forEach((file) => {
allLocalFiles.forEach((file) => {
activeFileIds.add(file.id);
});
const embeddings = await getLocalEmbeddings();
const remainingEmbeddings = embeddings.filter((embedding) =>
const remainingEmbeddings = allLocalEmbeddings.filter((embedding) =>
activeFileIds.has(embedding.fileID)
);
if (embeddings.length !== remainingEmbeddings.length) {
if (allLocalEmbeddings.length !== remainingEmbeddings.length) {
addLogLine(
`cleanupDeletedEmbeddings embeddingsCount: ${embeddings.length} remainingEmbeddingsCount: ${remainingEmbeddings.length}`
`cleanupDeletedEmbeddings embeddingsCount: ${allLocalEmbeddings.length} remainingEmbeddingsCount: ${remainingEmbeddings.length}`
);
await localForage.setItem(EMBEDDINGS_TABLE, remainingEmbeddings);
}

View file

@ -0,0 +1,97 @@
import { CITIES_URL } from '@ente/shared/constants/urls';
import { logError } from '@ente/shared/sentry';
import { LocationTagData } from 'types/entity';
import { Location } from 'types/upload';
export interface City {
city: string;
country: string;
lat: number;
lng: number;
}
const DEFAULT_CITY_RADIUS = 10;
const KMS_PER_DEGREE = 111.16;
class LocationSearchService {
private cities: Array<City> = [];
private citiesPromise: Promise<void>;
async loadCities() {
try {
if (this.citiesPromise) {
return;
}
this.citiesPromise = fetch(CITIES_URL).then((response) => {
return response.json().then((data) => {
this.cities = data['data'];
});
});
await this.citiesPromise;
} catch (e) {
logError(e, 'LocationSearchService loadCities failed');
this.citiesPromise = null;
}
}
async searchCities(searchTerm: string) {
try {
if (!this.citiesPromise) {
this.loadCities();
}
await this.citiesPromise;
return this.cities.filter((city) => {
return city.city
.toLowerCase()
.startsWith(searchTerm.toLowerCase());
});
} catch (e) {
logError(e, 'LocationSearchService searchCities failed');
throw e;
}
}
}
export default new LocationSearchService();
export function isInsideLocationTag(
location: Location,
locationTag: LocationTagData
) {
return isLocationCloseToPoint(
location,
locationTag.centerPoint,
locationTag.radius
);
}
export function isInsideCity(location: Location, city: City) {
return isLocationCloseToPoint(
{ latitude: city.lat, longitude: city.lng },
location,
DEFAULT_CITY_RADIUS
);
}
function isLocationCloseToPoint(
centerPoint: Location,
location: Location,
radius: number
) {
const a = (radius * _scaleFactor(centerPoint.latitude)) / KMS_PER_DEGREE;
const b = radius / KMS_PER_DEGREE;
const x = centerPoint.latitude - location.latitude;
const y = centerPoint.longitude - location.longitude;
if ((x * x) / (a * a) + (y * y) / (b * b) <= 1) {
return true;
}
return false;
}
///The area bounded by the location tag becomes more elliptical with increase
///in the magnitude of the latitude on the caritesian plane. When latitude is
///0 degrees, the ellipse is a circle with a = b = r. When latitude incrases,
///the major axis (a) has to be scaled by the secant of the latitude.
function _scaleFactor(lat: number) {
return 1 / Math.cos(lat * (Math.PI / 180));
}

View file

@ -16,33 +16,29 @@ import {
ClipSearchScores,
} from 'types/search';
import ObjectService from './machineLearning/objectService';
import {
getFormattedDate,
isInsideLocationTag,
isSameDayAnyYear,
} from 'utils/search';
import { getFormattedDate } from 'utils/search';
import { Person, Thing } from 'types/machineLearning';
import { getUniqueFiles } from 'utils/file';
import { getLatestEntities } from './entityService';
import { LocationTag, LocationTagData, EntityType } from 'types/entity';
import { addLogLine } from '@ente/shared/logging';
import { FILE_TYPE } from 'constants/file';
import {
ClipService,
computeClipMatchScore,
getLocalClipImageEmbeddings,
} from './clipService';
import { ClipService, computeClipMatchScore } from './clipService';
import { CustomError } from '@ente/shared/error';
import { Model } from 'types/embedding';
import { getLocalEmbeddings } from './embeddingService';
import locationSearchService, { City } from './locationSearchService';
import ComlinkSearchWorker from 'utils/comlink/ComlinkSearchWorker';
const DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']);
const CLIP_SCORE_THRESHOLD = 0.23;
export const getDefaultOptions = async (files: EnteFile[]) => {
export const getDefaultOptions = async () => {
return [
await getIndexStatusSuggestion(),
...convertSuggestionsToOptions(await getAllPeopleSuggestion(), files),
];
...(await convertSuggestionsToOptions(await getAllPeopleSuggestion())),
].filter((t) => !!t);
};
export const getAutoCompleteSuggestions =
@ -62,47 +58,42 @@ export const getAutoCompleteSuggestions =
...getCollectionSuggestion(searchPhrase, collections),
getFileNameSuggestion(searchPhrase, files),
getFileCaptionSuggestion(searchPhrase, files),
...(await getLocationTagSuggestions(searchPhrase)),
...(await getLocationSuggestions(searchPhrase)),
...(await getThingSuggestion(searchPhrase)),
].filter((suggestion) => !!suggestion);
return convertSuggestionsToOptions(suggestions, files);
return convertSuggestionsToOptions(suggestions);
} catch (e) {
logError(e, 'getAutoCompleteSuggestions failed');
return [];
}
};
function convertSuggestionsToOptions(
suggestions: Suggestion[],
files: EnteFile[]
) {
const previewImageAppendedOptions: SearchOption[] = suggestions
.map((suggestion) => ({
suggestion,
searchQuery: convertSuggestionToSearchQuery(suggestion),
}))
.map(({ suggestion, searchQuery }) => {
const resultFiles = getUniqueFiles(
files.filter((file) => isSearchedFile(file, searchQuery))
);
if (searchQuery?.clip) {
resultFiles.sort((a, b) => {
const aScore = searchQuery.clip.get(a.id);
const bScore = searchQuery.clip.get(b.id);
return bScore - aScore;
});
}
return {
async function convertSuggestionsToOptions(
suggestions: Suggestion[]
): Promise<SearchOption[]> {
const searchWorker = await ComlinkSearchWorker.getInstance();
const previewImageAppendedOptions: SearchOption[] = [];
for (const suggestion of suggestions) {
const searchQuery = convertSuggestionToSearchQuery(suggestion);
const resultFiles = getUniqueFiles(
await searchWorker.search(searchQuery)
);
if (searchQuery?.clip) {
resultFiles.sort((a, b) => {
const aScore = searchQuery.clip.get(a.id);
const bScore = searchQuery.clip.get(b.id);
return bScore - aScore;
});
}
if (resultFiles.length) {
previewImageAppendedOptions.push({
...suggestion,
fileCount: resultFiles.length,
previewFiles: resultFiles.slice(0, 3),
};
})
.filter((option) => option.fileCount);
});
}
}
return previewImageAppendedOptions;
}
function getFileTypeSuggestion(searchPhrase: string): Suggestion[] {
@ -190,28 +181,32 @@ export async function getAllPeopleSuggestion(): Promise<Array<Suggestion>> {
}
export async function getIndexStatusSuggestion(): Promise<Suggestion> {
const config = await getMLSyncConfig();
const indexStatus = await mlIDbStorage.getIndexStatus(config.mlVersion);
try {
const config = await getMLSyncConfig();
const indexStatus = await mlIDbStorage.getIndexStatus(config.mlVersion);
let label;
if (!indexStatus.localFilesSynced) {
label = t('INDEXING_SCHEDULED');
} else if (indexStatus.outOfSyncFilesExists) {
label = t('ANALYZING_PHOTOS', {
indexStatus,
});
} else if (!indexStatus.peopleIndexSynced) {
label = t('INDEXING_PEOPLE', { indexStatus });
} else {
label = t('INDEXING_DONE', { indexStatus });
let label;
if (!indexStatus.localFilesSynced) {
label = t('INDEXING_SCHEDULED');
} else if (indexStatus.outOfSyncFilesExists) {
label = t('ANALYZING_PHOTOS', {
indexStatus,
});
} else if (!indexStatus.peopleIndexSynced) {
label = t('INDEXING_PEOPLE', { indexStatus });
} else {
label = t('INDEXING_DONE', { indexStatus });
}
return {
label,
type: SuggestionType.INDEX_STATUS,
value: indexStatus,
hide: true,
};
} catch (e) {
logError(e, 'getIndexStatusSuggestion failed');
}
return {
label,
type: SuggestionType.INDEX_STATUS,
value: indexStatus,
hide: true,
};
}
function getDateSuggestion(searchPhrase: string): Suggestion[] {
@ -264,10 +259,9 @@ function getFileCaptionSuggestion(
};
}
async function getLocationTagSuggestions(searchPhrase: string) {
const searchResults = await searchLocationTag(searchPhrase);
return searchResults.map(
async function getLocationSuggestions(searchPhrase: string) {
const locationTagResults = await searchLocationTag(searchPhrase);
const locationTagSuggestions = locationTagResults.map(
(locationTag) =>
({
type: SuggestionType.LOCATION,
@ -275,6 +269,28 @@ async function getLocationTagSuggestions(searchPhrase: string) {
label: locationTag.data.name,
} as Suggestion)
);
const locationTagNames = new Set(
locationTagSuggestions.map((result) => result.label)
);
const citySearchResults = await locationSearchService.searchCities(
searchPhrase
);
const nonConflictingCityResult = citySearchResults.filter(
(city) => !locationTagNames.has(city.city)
);
const citySearchSuggestions = nonConflictingCityResult.map(
(city) =>
({
type: SuggestionType.CITY,
value: city,
label: city.city,
} as Suggestion)
);
return [...locationTagSuggestions, ...citySearchSuggestions];
}
async function getThingSuggestion(searchPhrase: string): Promise<Suggestion[]> {
@ -383,7 +399,7 @@ async function searchThing(searchPhrase: string) {
}
async function searchClip(searchPhrase: string): Promise<ClipSearchScores> {
const imageEmbeddings = await getLocalClipImageEmbeddings();
const imageEmbeddings = await getLocalEmbeddings(Model.ONNX_CLIP);
const textEmbedding = await ClipService.getTextEmbedding(searchPhrase);
const clipSearchResult = new Map<number, number>(
(
@ -404,48 +420,6 @@ async function searchClip(searchPhrase: string): Promise<ClipSearchScores> {
return clipSearchResult;
}
function isSearchedFile(file: EnteFile, search: Search) {
if (search?.collection) {
return search.collection === file.collectionID;
}
if (search?.date) {
return isSameDayAnyYear(search.date)(
new Date(file.metadata.creationTime / 1000)
);
}
if (search?.location) {
return isInsideLocationTag(
{
latitude: file.metadata.latitude,
longitude: file.metadata.longitude,
},
search.location
);
}
if (search?.files) {
return search.files.indexOf(file.id) !== -1;
}
if (search?.person) {
return search.person.files.indexOf(file.id) !== -1;
}
if (search?.thing) {
return search.thing.files.indexOf(file.id) !== -1;
}
if (search?.text) {
return search.text.files.indexOf(file.id) !== -1;
}
if (typeof search?.fileType !== 'undefined') {
return search.fileType === file.metadata.fileType;
}
if (typeof search?.clip !== 'undefined') {
return search.clip.has(file.id);
}
return false;
}
function convertSuggestionToSearchQuery(option: Suggestion): Search {
switch (option.type) {
case SuggestionType.DATE:
@ -458,6 +432,9 @@ function convertSuggestionToSearchQuery(option: Suggestion): Search {
location: option.value as LocationTagData,
};
case SuggestionType.CITY:
return { city: option.value as City };
case SuggestionType.COLLECTION:
return { collection: option.value as number };

View file

@ -361,7 +361,9 @@ class UploadManager {
try {
eventBus.emit(Events.FILE_UPLOADED, {
enteFile: decryptedFile,
localFile: fileWithCollection.file,
localFile:
fileWithCollection.file ??
fileWithCollection.livePhotoAssets.image,
});
} catch (e) {
logError(e, 'Error in fileUploaded handlers');

View file

@ -49,11 +49,10 @@ export const SelectStyles = {
...style,
display: 'none',
}),
singleValue: (style, state) => ({
singleValue: (style) => ({
...style,
backgroundColor: 'transparent',
color: '#d1d1d1',
display: state.selectProps.menuIsOpen ? 'none' : 'block',
marginLeft: '36px',
}),
placeholder: (style) => ({

View file

@ -1,5 +1,6 @@
export enum Model {
GGML_CLIP = 'ggml-clip',
ONNX_CLIP = 'onnx-clip',
}
export interface EncryptedEmbedding {

View file

@ -1,4 +1,4 @@
import { CollectionDownloadProgressAttributes } from 'components/Collections/CollectionDownloadProgress';
import { FilesDownloadProgressAttributes } from 'components/FilesDownloadProgress';
import { CollectionSelectorAttributes } from 'components/Collections/CollectionSelector';
import { TimeStampListItem } from 'components/PhotoList';
import { Collection } from 'types/collection';
@ -17,9 +17,19 @@ export type SetLoading = React.Dispatch<React.SetStateAction<boolean>>;
export type SetCollectionSelectorAttributes = React.Dispatch<
React.SetStateAction<CollectionSelectorAttributes>
>;
export type SetCollectionDownloadProgressAttributes = React.Dispatch<
React.SetStateAction<CollectionDownloadProgressAttributes>
>;
export type SetFilesDownloadProgressAttributes = (
value:
| Partial<FilesDownloadProgressAttributes>
| ((
prev: FilesDownloadProgressAttributes
) => FilesDownloadProgressAttributes)
) => void;
export type SetFilesDownloadProgressAttributesCreator = (
folderName: string,
collectionID?: number,
isHidden?: boolean
) => SetFilesDownloadProgressAttributes;
export type MergedSourceURL = {
original: string;

View file

@ -3,6 +3,7 @@ import { IndexStatus } from 'types/machineLearning/ui';
import { EnteFile } from 'types/file';
import { LocationTagData } from 'types/entity';
import { FILE_TYPE } from 'constants/file';
import { City } from 'services/locationSearchService';
export enum SuggestionType {
DATE = 'DATE',
@ -16,6 +17,7 @@ export enum SuggestionType {
FILE_CAPTION = 'FILE_CAPTION',
FILE_TYPE = 'FILE_TYPE',
CLIP = 'CLIP',
CITY = 'CITY',
}
export interface DateValue {
@ -35,6 +37,7 @@ export interface Suggestion {
| Thing
| WordGroup
| LocationTagData
| City
| FILE_TYPE
| ClipSearchScores;
hide?: boolean;
@ -43,6 +46,7 @@ export interface Suggestion {
export type Search = {
date?: DateValue;
location?: LocationTagData;
city?: City;
collection?: number;
files?: number[];
person?: Person;

View file

@ -12,7 +12,7 @@ import {
updatePublicCollectionMagicMetadata,
updateSharedCollectionMagicMetadata,
} from 'services/collectionService';
import { downloadFiles, downloadFilesDesktop } from 'utils/file';
import { downloadFilesWithProgress } from 'utils/file';
import { getAllLocalFiles, getLocalFiles } from 'services/fileService';
import { EnteFile } from 'types/file';
import { CustomError } from '@ente/shared/error';
@ -34,7 +34,6 @@ import {
SYSTEM_COLLECTION_TYPES,
MOVE_TO_NOT_ALLOWED_COLLECTION,
ADD_TO_NOT_ALLOWED_COLLECTION,
HIDDEN_ITEMS_SECTION,
DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME,
} from 'constants/collection';
import { getUnixTimeInMicroSecondsWithDelta } from '@ente/shared/time';
@ -44,14 +43,14 @@ import { getAlbumsURL } from '@ente/shared/network/api';
import bs58 from 'bs58';
import { t } from 'i18next';
import isElectron from 'is-electron';
import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
import { SetFilesDownloadProgressAttributes } from 'types/gallery';
import ElectronAPIs from '@ente/shared/electron';
import {
getCollectionExportPath,
getUniqueCollectionExportName,
} from 'utils/export';
import exportService from 'services/export';
import { CollectionDownloadProgressAttributes } from 'components/Collections/CollectionDownloadProgress';
import { addLogLine } from '@ente/shared/logging';
export enum COLLECTION_OPS_TYPE {
ADD,
@ -100,7 +99,7 @@ export function getSelectedCollection(
export async function downloadCollectionHelper(
collectionID: number,
setCollectionDownloadProgressAttributes: SetCollectionDownloadProgressAttributes
setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
) {
try {
const allFiles = await getAllLocalFiles();
@ -116,10 +115,8 @@ export async function downloadCollectionHelper(
}
await downloadCollectionFiles(
collection.name,
collection.id,
isHiddenCollection(collection),
collectionFiles,
setCollectionDownloadProgressAttributes
setFilesDownloadProgressAttributes
);
} catch (e) {
logError(e, 'download collection failed ');
@ -127,7 +124,7 @@ export async function downloadCollectionHelper(
}
export async function downloadDefaultHiddenCollectionHelper(
setCollectionDownloadProgressAttributes: SetCollectionDownloadProgressAttributes
setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
) {
try {
const hiddenCollections = await getLocalCollections('hidden');
@ -139,78 +136,38 @@ export async function downloadDefaultHiddenCollectionHelper(
);
await downloadCollectionFiles(
DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME,
HIDDEN_ITEMS_SECTION,
true,
defaultHiddenCollectionFiles,
setCollectionDownloadProgressAttributes
setFilesDownloadProgressAttributes
);
} catch (e) {
logError(e, 'download hidden files failed ');
}
}
async function downloadCollectionFiles(
export async function downloadCollectionFiles(
collectionName: string,
collectionID: number,
isHidden: boolean,
collectionFiles: EnteFile[],
setCollectionDownloadProgressAttributes: SetCollectionDownloadProgressAttributes
setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
) {
if (!collectionFiles.length) {
return;
}
const canceller = new AbortController();
const increaseSuccess = () => {
if (canceller.signal.aborted) return;
setCollectionDownloadProgressAttributes((prev) => ({
...prev,
success: prev.success + 1,
}));
};
const increaseFailed = () => {
if (canceller.signal.aborted) return;
setCollectionDownloadProgressAttributes((prev) => ({
...prev,
failed: prev.failed + 1,
}));
};
const isCancelled = () => canceller.signal.aborted;
const initialProgressAttributes: CollectionDownloadProgressAttributes = {
collectionName,
collectionID,
isHidden,
canceller,
total: collectionFiles.length,
success: 0,
failed: 0,
downloadDirPath: null,
};
let downloadDirPath: string;
if (isElectron()) {
const selectedDir = await ElectronAPIs.selectDirectory();
if (!selectedDir) {
return;
}
const downloadDirPath = await createCollectionDownloadFolder(
downloadDirPath = await createCollectionDownloadFolder(
selectedDir,
collectionName
);
setCollectionDownloadProgressAttributes({
...initialProgressAttributes,
downloadDirPath,
});
await downloadFilesDesktop(
collectionFiles,
{ increaseSuccess, increaseFailed, isCancelled },
downloadDirPath
);
} else {
setCollectionDownloadProgressAttributes(initialProgressAttributes);
await downloadFiles(collectionFiles, {
increaseSuccess,
increaseFailed,
isCancelled,
});
}
await downloadFilesWithProgress(
collectionFiles,
downloadDirPath,
setFilesDownloadProgressAttributes
);
}
async function createCollectionDownloadFolder(
@ -521,7 +478,8 @@ export function isValidReplacementAlbum(
return (
collection.name === wantedCollectionName &&
(collection.type === CollectionType.album ||
collection.type === CollectionType.folder) &&
collection.type === CollectionType.folder ||
collection.type === CollectionType.uncategorized) &&
!isHiddenCollection(collection) &&
!isQuickLinkCollection(collection) &&
!isIncomingShare(collection, user)
@ -610,8 +568,13 @@ export const getOrCreateAlbum = async (
}
for (const collection of existingCollections) {
if (isValidReplacementAlbum(collection, user, albumName)) {
addLogLine(
`Found existing album ${albumName} with id ${collection.id}`
);
return collection;
}
}
return createAlbum(albumName);
const album = await createAlbum(albumName);
addLogLine(`Created new album ${albumName} with id ${album.id}`);
return album;
};

View file

@ -0,0 +1,30 @@
import { Remote } from 'comlink';
import { runningInBrowser } from 'utils/common';
import { ComlinkWorker } from '@ente/shared/worker/comlinkWorker';
import { DedicatedSearchWorker } from 'worker/search.worker';
class ComlinkSearchWorker {
private comlinkWorkerInstance: Remote<DedicatedSearchWorker>;
async getInstance() {
if (!this.comlinkWorkerInstance) {
this.comlinkWorkerInstance = await getDedicatedSearchWorker()
.remote;
}
return this.comlinkWorkerInstance;
}
}
export const getDedicatedSearchWorker = () => {
if (runningInBrowser()) {
const cryptoComlinkWorker = new ComlinkWorker<
typeof DedicatedSearchWorker
>(
'ente-search-worker',
new Worker(new URL('worker/search.worker.ts', import.meta.url))
);
return cryptoComlinkWorker;
}
};
export default new ComlinkSearchWorker();

View file

@ -1,4 +1,8 @@
import { SelectedState } from 'types/gallery';
import {
SelectedState,
SetFilesDownloadProgressAttributes,
SetFilesDownloadProgressAttributesCreator,
} from 'types/gallery';
import {
EnteFile,
EncryptedEnteFile,
@ -52,6 +56,7 @@ import { getFileExportPath, getUniqueFileExportName } from 'utils/export';
import imageProcessor from 'services/imageProcessor';
import ElectronAPIs from '@ente/shared/electron';
import { downloadUsingAnchor } from '@ente/shared/utils';
import { t } from 'i18next';
const WAIT_TIME_IMAGE_CONVERSION = 30 * 1000;
@ -625,9 +630,96 @@ export function getUniqueFiles(files: EnteFile[]) {
return uniqueFiles;
}
export async function downloadFilesWithProgress(
files: EnteFile[],
downloadDirPath: string,
setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
) {
if (!files.length) {
return;
}
const canceller = new AbortController();
const increaseSuccess = () => {
if (canceller.signal.aborted) return;
setFilesDownloadProgressAttributes((prev) => ({
...prev,
success: prev.success + 1,
}));
};
const increaseFailed = () => {
if (canceller.signal.aborted) return;
setFilesDownloadProgressAttributes((prev) => ({
...prev,
failed: prev.failed + 1,
}));
};
const isCancelled = () => canceller.signal.aborted;
setFilesDownloadProgressAttributes({
downloadDirPath,
success: 0,
failed: 0,
total: files.length,
canceller,
});
if (isElectron()) {
await downloadFilesDesktop(
files,
{ increaseSuccess, increaseFailed, isCancelled },
downloadDirPath
);
} else {
await downloadFiles(files, {
increaseSuccess,
increaseFailed,
isCancelled,
});
}
}
export async function downloadSelectedFiles(
files: EnteFile[],
setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
) {
if (!files.length) {
return;
}
let downloadDirPath: string;
if (isElectron()) {
downloadDirPath = await ElectronAPIs.selectDirectory();
if (!downloadDirPath) {
return;
}
}
await downloadFilesWithProgress(
files,
downloadDirPath,
setFilesDownloadProgressAttributes
);
}
export async function downloadSingleFile(
file: EnteFile,
setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
) {
let downloadDirPath: string;
if (isElectron()) {
downloadDirPath = await ElectronAPIs.selectDirectory();
if (!downloadDirPath) {
return;
}
}
await downloadFilesWithProgress(
[file],
downloadDirPath,
setFilesDownloadProgressAttributes
);
}
export async function downloadFiles(
files: EnteFile[],
progressBarUpdater?: {
progressBarUpdater: {
increaseSuccess: () => void;
increaseFailed: () => void;
isCancelled: () => boolean;
@ -857,11 +949,11 @@ export const shouldShowAvatar = (file: EnteFile, user: User) => {
export const handleFileOps = async (
ops: FILE_OPS_TYPE,
files: EnteFile[],
setDeletedFileIds: (
deletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
setTempDeletedFileIds: (
tempDeletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void,
setHiddenFileIds: (
hiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
setTempHiddenFileIds: (
tempHiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void,
setFixCreationTimeAttributes: (
fixCreationTimeAttributes:
@ -869,21 +961,30 @@ export const handleFileOps = async (
files: EnteFile[];
}
| ((prev: { files: EnteFile[] }) => { files: EnteFile[] })
) => void
) => void,
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator
) => {
switch (ops) {
case FILE_OPS_TYPE.TRASH:
await deleteFileHelper(files, false, setDeletedFileIds);
await deleteFileHelper(files, false, setTempDeletedFileIds);
break;
case FILE_OPS_TYPE.DELETE_PERMANENTLY:
await deleteFileHelper(files, true, setDeletedFileIds);
await deleteFileHelper(files, true, setTempDeletedFileIds);
break;
case FILE_OPS_TYPE.HIDE:
await hideFilesHelper(files, setHiddenFileIds);
await hideFilesHelper(files, setTempHiddenFileIds);
break;
case FILE_OPS_TYPE.DOWNLOAD:
await downloadFiles(files);
case FILE_OPS_TYPE.DOWNLOAD: {
const setSelectedFileDownloadProgressAttributes =
setFilesDownloadProgressAttributesCreator(
`${files.length} ${t('FILES')}`
);
await downloadSelectedFiles(
files,
setSelectedFileDownloadProgressAttributes
);
break;
}
case FILE_OPS_TYPE.FIX_TIME:
fixTimeHelper(files, setFixCreationTimeAttributes);
break;
@ -899,12 +1000,12 @@ export const handleFileOps = async (
const deleteFileHelper = async (
selectedFiles: EnteFile[],
permanent: boolean,
setDeletedFileIds: (
deletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
setTempDeletedFileIds: (
tempDeletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void
) => {
try {
setDeletedFileIds((deletedFileIds) => {
setTempDeletedFileIds((deletedFileIds) => {
selectedFiles.forEach((file) => deletedFileIds.add(file.id));
return new Set(deletedFileIds);
});
@ -914,25 +1015,25 @@ const deleteFileHelper = async (
await trashFiles(selectedFiles);
}
} catch (e) {
setDeletedFileIds(new Set());
setTempDeletedFileIds(new Set());
throw e;
}
};
const hideFilesHelper = async (
selectedFiles: EnteFile[],
setHiddenFileIds: (
hiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
setTempHiddenFileIds: (
tempHiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void
) => {
try {
setHiddenFileIds((hiddenFileIds) => {
setTempHiddenFileIds((hiddenFileIds) => {
selectedFiles.forEach((file) => hiddenFileIds.add(file.id));
return new Set(hiddenFileIds);
});
await moveToHiddenCollection(selectedFiles);
} catch (e) {
setHiddenFileIds(new Set());
setTempHiddenFileIds(new Set());
throw e;
}
};

View file

@ -1,6 +1,4 @@
import { LocationTagData } from 'types/entity';
import { DateValue } from 'types/search';
import { Location } from 'types/upload';
export const isSameDayAnyYear =
(baseDate: DateValue) => (compareDate: Date) => {
@ -28,18 +26,3 @@ export function getFormattedDate(date: DateValue) {
new Date(date.year ?? 1, date.month ?? 1, date.date ?? 1)
);
}
export function isInsideLocationTag(
location: Location,
locationTag: LocationTagData
) {
const { centerPoint, aSquare, bSquare } = locationTag;
const { latitude, longitude } = location;
const x = Math.abs(centerPoint.latitude - latitude);
const y = Math.abs(centerPoint.longitude - longitude);
if ((x * x) / aSquare + (y * y) / bSquare <= 1) {
return true;
} else {
return false;
}
}

View file

@ -0,0 +1,75 @@
import * as Comlink from 'comlink';
import {
isInsideLocationTag,
isInsideCity,
} from 'services/locationSearchService';
import { EnteFile } from 'types/file';
import { isSameDayAnyYear } from 'utils/search';
import { Search } from 'types/search';
export class DedicatedSearchWorker {
private files: EnteFile[] = [];
setFiles(files: EnteFile[]) {
this.files = files;
}
search(search: Search) {
return this.files.filter((file) => {
return isSearchedFile(file, search);
});
}
}
Comlink.expose(DedicatedSearchWorker, self);
function isSearchedFile(file: EnteFile, search: Search) {
if (search?.collection) {
return search.collection === file.collectionID;
}
if (search?.date) {
return isSameDayAnyYear(search.date)(
new Date(file.metadata.creationTime / 1000)
);
}
if (search?.location) {
return isInsideLocationTag(
{
latitude: file.metadata.latitude,
longitude: file.metadata.longitude,
},
search.location
);
}
if (search?.city) {
return isInsideCity(
{
latitude: file.metadata.latitude,
longitude: file.metadata.longitude,
},
search.city
);
}
if (search?.files) {
return search.files.indexOf(file.id) !== -1;
}
if (search?.person) {
return search.person.files.indexOf(file.id) !== -1;
}
if (search?.thing) {
return search.thing.files.indexOf(file.id) !== -1;
}
if (search?.text) {
return search.text.files.indexOf(file.id) !== -1;
}
if (typeof search?.fileType !== 'undefined') {
return search.fileType === file.metadata.fileType;
}
if (typeof search?.clip !== 'undefined') {
return search.clip.has(file.id);
}
return false;
}

View file

@ -17,3 +17,5 @@ export const WEB_ROADMAP_URL = 'https://github.com/ente-io/photos-web/issues';
export const DESKTOP_ROADMAP_URL =
'https://github.com/ente-io/photos-desktop/issues';
export const CITIES_URL = 'https://static.ente.io/world_cities.json';

View file

@ -7,6 +7,11 @@ export interface AppUpdateInfo {
version: string;
}
export enum Model {
GGML_CLIP = 'ggml-clip',
ONNX_CLIP = 'onnx-clip',
}
export interface ElectronAPIsType {
exists: (path: string) => boolean;
checkExistsAndCreateDir: (dirPath: string) => Promise<void>;
@ -97,8 +102,11 @@ export interface ElectronAPIsType {
deleteFile: (path: string) => void;
rename: (oldPath: string, newPath: string) => Promise<void>;
updateOptOutOfCrashReports: (optOut: boolean) => Promise<void>;
computeImageEmbedding: (imageData: Uint8Array) => Promise<Float32Array>;
computeTextEmbedding: (text: string) => Promise<Float32Array>;
computeImageEmbedding: (
model: Model,
imageData: Uint8Array
) => Promise<Float32Array>;
computeTextEmbedding: (model: Model, text: string) => Promise<Float32Array>;
getPlatform: () => Promise<'mac' | 'windows' | 'linux'>;
setCustomCacheDirectory: (directory: string) => Promise<void>;
getCacheDirectory: () => Promise<string>;

View file

@ -0,0 +1,33 @@
import { useEffect, useRef } from 'react';
import { isPromise } from '../utils';
// useEffectSingleThreaded is a useEffect that will only run one at a time, and will
// caches the latest deps of requests that come in while it is running, and will
// run that after the current run is complete.
export default function useEffectSingleThreaded(
fn: (deps) => void | Promise<void>,
deps: any[]
): void {
const updateInProgress = useRef(false);
const nextRequestDepsRef = useRef<any[]>(null);
useEffect(() => {
const main = async (deps) => {
if (updateInProgress.current) {
nextRequestDepsRef.current = deps;
return;
}
updateInProgress.current = true;
const result = fn(deps);
if (isPromise(result)) {
await result;
}
updateInProgress.current = false;
if (nextRequestDepsRef.current) {
const deps = nextRequestDepsRef.current;
nextRequestDepsRef.current = null;
setTimeout(() => main(deps), 0);
}
};
main(deps);
}, deps);
}

View file

@ -9,7 +9,7 @@ export function useLocalState<T>(
useEffect(() => {
const { value } = getData(key) ?? {};
if (value) {
if (typeof value !== 'undefined') {
setValue(value);
}
}, []);

View file

@ -22,3 +22,7 @@ export function downloadUsingAnchor(link: string, name: string) {
URL.revokeObjectURL(link);
a.remove();
}
export function isPromise<T>(obj: T | Promise<T>): obj is Promise<T> {
return obj && typeof (obj as any).then === 'function';
}

View file

@ -2275,9 +2275,9 @@ fn-name@~3.0.0:
integrity sha512-eNMNr5exLoavuAMhIUVsOKF79SWd/zG104ef6sxBTSw+cZc6BXdQXDvYcGvp0VbxVVSp1XDUNoz7mg1xMtSznA==
follow-redirects@^1.15.0:
version "1.15.2"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
version "1.15.4"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf"
integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==
for-each@^0.3.3:
version "0.3.3"