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/> <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) [<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": { "SEARCH_TYPE": {
"COLLECTION": "Album", "COLLECTION": "Album",
"LOCATION": "Location", "LOCATION": "Location",
"CITY": "Location",
"DATE": "Date", "DATE": "Date",
"FILE_NAME": "File name", "FILE_NAME": "File name",
"THING": "Content", "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": { "SEARCH_TYPE": {
"COLLECTION": "Album", "COLLECTION": "Album",
"LOCATION": "Standort", "LOCATION": "Standort",
"CITY": "",
"DATE": "Datum", "DATE": "Datum",
"FILE_NAME": "Dateiname", "FILE_NAME": "Dateiname",
"THING": "Inhalt", "THING": "Inhalt",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "", "PHOTO_EDITOR": "",
"FASTER_UPLOAD": "", "FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "", "FASTER_UPLOAD_DESCRIPTION": "",
"STATUS": "", "MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "", "INDEXED_ITEMS": "",
"CACHE_DIRECTORY": "" "CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
} }

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": { "SEARCH_TYPE": {
"COLLECTION": "Album", "COLLECTION": "Album",
"LOCATION": "Location", "LOCATION": "Location",
"CITY": "Location",
"DATE": "Date", "DATE": "Date",
"FILE_NAME": "File name", "FILE_NAME": "File name",
"THING": "Content", "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": "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>", "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", "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>", "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", "ML_MORE_DETAILS": "More details",
"ENABLE_FACE_SEARCH": "Enable face search", "ENABLE_FACE_SEARCH": "Enable face recognition",
"ENABLE_FACE_SEARCH_TITLE": "Enable face search?", "ENABLE_FACE_SEARCH_TITLE": "Enable face recognition?",
"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>", "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": "Disable beta", "DISABLE_BETA": "Pause recognition",
"DISABLE_FACE_SEARCH": "Disable face search", "DISABLE_FACE_SEARCH": "Disable face recognition",
"DISABLE_FACE_SEARCH_TITLE": "Disable face search?", "DISABLE_FACE_SEARCH_TITLE": "Disable face recognition?",
"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>", "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", "ADVANCED": "Advanced",
"FACE_SEARCH_CONFIRMATION": "I understand, and wish to allow ente to process face geometry", "FACE_SEARCH_CONFIRMATION": "I understand, and wish to allow ente to process face geometry",
"LABS": "Labs", "LABS": "Labs",
@ -622,8 +623,9 @@
"PHOTO_EDITOR": "Photo Editor", "PHOTO_EDITOR": "Photo Editor",
"FASTER_UPLOAD": "Faster uploads", "FASTER_UPLOAD": "Faster uploads",
"FASTER_UPLOAD_DESCRIPTION": "Route uploads through nearby servers", "FASTER_UPLOAD_DESCRIPTION": "Route uploads through nearby servers",
"STATUS": "Status", "MAGIC_SEARCH_STATUS": "Magic Search Status",
"INDEXED_ITEMS": "Indexed items", "INDEXED_ITEMS": "Indexed items",
<<<<<<< HEAD
"CAST_ALBUM_TO_TV": "Play album on TV", "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.", "ENTER_CAST_PIN_CODE": "Enter the code you see on the TV below to pair this device.",
"PAIR_DEVICE_TO_TV": "Pair devices", "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.", "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.", "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.", "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": { "SEARCH_TYPE": {
"COLLECTION": "Álbum", "COLLECTION": "Álbum",
"LOCATION": "Localización", "LOCATION": "Localización",
"CITY": "",
"DATE": "Fecha", "DATE": "Fecha",
"FILE_NAME": "Nombre del archivo", "FILE_NAME": "Nombre del archivo",
"THING": "Contenido", "THING": "Contenido",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "", "PHOTO_EDITOR": "",
"FASTER_UPLOAD": "", "FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "", "FASTER_UPLOAD_DESCRIPTION": "",
"STATUS": "", "MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "", "INDEXED_ITEMS": "",
"CACHE_DIRECTORY": "" "CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
} }

View file

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

View file

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

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": { "SEARCH_TYPE": {
"COLLECTION": "l'album", "COLLECTION": "l'album",
"LOCATION": "Emplacement", "LOCATION": "Emplacement",
"CITY": "",
"DATE": "Date", "DATE": "Date",
"FILE_NAME": "Nom de fichier", "FILE_NAME": "Nom de fichier",
"THING": "Chose", "THING": "Chose",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "Éditeur de photos", "PHOTO_EDITOR": "Éditeur de photos",
"FASTER_UPLOAD": "Chargements plus rapides", "FASTER_UPLOAD": "Chargements plus rapides",
"FASTER_UPLOAD_DESCRIPTION": "Router les chargements vers les serveurs à proximité", "FASTER_UPLOAD_DESCRIPTION": "Router les chargements vers les serveurs à proximité",
"STATUS": "État", "MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "Éléments indexés", "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": { "SEARCH_TYPE": {
"COLLECTION": "Album", "COLLECTION": "Album",
"LOCATION": "Posizione", "LOCATION": "Posizione",
"CITY": "",
"DATE": "Data", "DATE": "Data",
"FILE_NAME": "Nome file", "FILE_NAME": "Nome file",
"THING": "", "THING": "",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "", "PHOTO_EDITOR": "",
"FASTER_UPLOAD": "", "FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "", "FASTER_UPLOAD_DESCRIPTION": "",
"STATUS": "", "MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "", "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", "ZOOM_IN_OUT": "In/uitzoomen",
"PREVIOUS": "Vorige (←)", "PREVIOUS": "Vorige (←)",
"NEXT": "Volgende (→)", "NEXT": "Volgende (→)",
"TITLE_PHOTOS": "", "TITLE_PHOTOS": "Ente Foto's",
"TITLE_ALBUMS": "", "TITLE_ALBUMS": "Ente Foto's",
"TITLE_AUTH": "", "TITLE_AUTH": "Ente Auth",
"UPLOAD_FIRST_PHOTO": "Je eerste foto uploaden", "UPLOAD_FIRST_PHOTO": "Je eerste foto uploaden",
"IMPORT_YOUR_FOLDERS": "Importeer uw mappen", "IMPORT_YOUR_FOLDERS": "Importeer uw mappen",
"UPLOAD_DROPZONE_MESSAGE": "Sleep om een back-up van je bestanden te maken", "UPLOAD_DROPZONE_MESSAGE": "Sleep om een back-up van je bestanden te maken",
@ -210,6 +210,7 @@
"SEARCH_TYPE": { "SEARCH_TYPE": {
"COLLECTION": "Album", "COLLECTION": "Album",
"LOCATION": "Locatie", "LOCATION": "Locatie",
"CITY": "",
"DATE": "Datum", "DATE": "Datum",
"FILE_NAME": "Bestandsnaam", "FILE_NAME": "Bestandsnaam",
"THING": "Inhoud", "THING": "Inhoud",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "Fotobewerker", "PHOTO_EDITOR": "Fotobewerker",
"FASTER_UPLOAD": "Snellere uploads", "FASTER_UPLOAD": "Snellere uploads",
"FASTER_UPLOAD_DESCRIPTION": "Uploaden door nabije servers", "FASTER_UPLOAD_DESCRIPTION": "Uploaden door nabije servers",
"STATUS": "Status", "MAGIC_SEARCH_STATUS": "Magische Zoekfunctie Status",
"INDEXED_ITEMS": "Geïndexeerde bestanden", "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": { "SEARCH_TYPE": {
"COLLECTION": "", "COLLECTION": "",
"LOCATION": "", "LOCATION": "",
"CITY": "",
"DATE": "", "DATE": "",
"FILE_NAME": "", "FILE_NAME": "",
"THING": "", "THING": "",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "", "PHOTO_EDITOR": "",
"FASTER_UPLOAD": "", "FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "", "FASTER_UPLOAD_DESCRIPTION": "",
"STATUS": "", "MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "", "INDEXED_ITEMS": "",
"CACHE_DIRECTORY": "" "CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
} }

View file

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

View file

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

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": { "SEARCH_TYPE": {
"COLLECTION": "相册", "COLLECTION": "相册",
"LOCATION": "地理位置", "LOCATION": "地理位置",
"CITY": "位置",
"DATE": "日期", "DATE": "日期",
"FILE_NAME": "文件名", "FILE_NAME": "文件名",
"THING": "内容", "THING": "内容",
@ -622,7 +623,10 @@
"PHOTO_EDITOR": "照片编辑器", "PHOTO_EDITOR": "照片编辑器",
"FASTER_UPLOAD": "更快上传", "FASTER_UPLOAD": "更快上传",
"FASTER_UPLOAD_DESCRIPTION": "通过附近的服务器路由上传", "FASTER_UPLOAD_DESCRIPTION": "通过附近的服务器路由上传",
"STATUS": "状态", "MAGIC_SEARCH_STATUS": "魔法搜索状态",
"INDEXED_ITEMS": "索引项目", "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 ArchiveOutlined from '@mui/icons-material/ArchiveOutlined';
import PeopleIcon from '@mui/icons-material/People'; import PeopleIcon from '@mui/icons-material/People';
import LinkIcon from '@mui/icons-material/Link'; import LinkIcon from '@mui/icons-material/Link';
import { SetCollectionDownloadProgressAttributes } from 'types/gallery'; import { SetFilesDownloadProgressAttributesCreator } from 'types/gallery';
interface Iprops { interface Iprops {
activeCollection: Collection; activeCollection: Collection;
collectionSummary: CollectionSummary; collectionSummary: CollectionSummary;
setCollectionNamerAttributes: SetCollectionNamerAttributes; setCollectionNamerAttributes: SetCollectionNamerAttributes;
showCollectionShareModal: () => void; showCollectionShareModal: () => void;
setCollectionDownloadProgressAttributesCreator: ( setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator;
collectionID: number
) => SetCollectionDownloadProgressAttributes;
isActiveCollectionDownloadInProgress: () => boolean; isActiveCollectionDownloadInProgress: () => boolean;
setActiveCollectionID: (collectionID: number) => void; setActiveCollectionID: (collectionID: number) => void;
setShowAlbumCastDialog: Dispatch<SetStateAction<boolean>>; setShowAlbumCastDialog: Dispatch<SetStateAction<boolean>>;

View file

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

View file

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

View file

@ -16,12 +16,11 @@ import { useLocalState } from '@ente/shared/hooks/useLocalState';
import { sortCollectionSummaries } from 'services/collectionService'; import { sortCollectionSummaries } from 'services/collectionService';
import { LS_KEYS } from '@ente/shared/storage/localStorage'; import { LS_KEYS } from '@ente/shared/storage/localStorage';
import { import {
CollectionDownloadProgress, FilesDownloadProgressAttributes,
CollectionDownloadProgressAttributes, isFilesDownloadCancelled,
isCollectionDownloadCancelled, isFilesDownloadCompleted,
isCollectionDownloadCompleted, } from '../FilesDownloadProgress';
} from './CollectionDownloadProgress'; import { SetFilesDownloadProgressAttributesCreator } from 'types/gallery';
import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
import AlbumCastDialog from './CollectionOptions/AlbumCastDialog'; import AlbumCastDialog from './CollectionOptions/AlbumCastDialog';
interface Iprops { interface Iprops {
@ -34,6 +33,8 @@ interface Iprops {
hiddenCollectionSummaries: CollectionSummaries; hiddenCollectionSummaries: CollectionSummaries;
setCollectionNamerAttributes: SetCollectionNamerAttributes; setCollectionNamerAttributes: SetCollectionNamerAttributes;
setPhotoListHeader: (value: TimeStampListItem) => void; setPhotoListHeader: (value: TimeStampListItem) => void;
filesDownloadProgressAttributesList: FilesDownloadProgressAttributes[];
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator;
} }
export default function Collections(props: Iprops) { export default function Collections(props: Iprops) {
@ -47,17 +48,14 @@ export default function Collections(props: Iprops) {
hiddenCollectionSummaries, hiddenCollectionSummaries,
setCollectionNamerAttributes, setCollectionNamerAttributes,
setPhotoListHeader, setPhotoListHeader,
filesDownloadProgressAttributesList,
setFilesDownloadProgressAttributesCreator,
} = props; } = props;
const [allCollectionView, setAllCollectionView] = useState(false); const [allCollectionView, setAllCollectionView] = useState(false);
const [collectionShareModalView, setCollectionShareModalView] = const [collectionShareModalView, setCollectionShareModalView] =
useState(false); useState(false);
const [
collectionDownloadProgressAttributesList,
setCollectionDownloadProgressAttributesList,
] = useState<CollectionDownloadProgressAttributes[]>([]);
const [showAlbumCastDialog, setShowAlbumCastDialog] = useState(false); const [showAlbumCastDialog, setShowAlbumCastDialog] = useState(false);
const [collectionListSortBy, setCollectionListSortBy] = const [collectionListSortBy, setCollectionListSortBy] =
@ -89,38 +87,16 @@ export default function Collections(props: Iprops) {
[collectionListSortBy, toShowCollectionSummaries] [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 isActiveCollectionDownloadInProgress = useCallback(() => {
const attributes = collectionDownloadProgressAttributesList.find( const attributes = filesDownloadProgressAttributesList.find(
(attr) => attr.collectionID === activeCollectionID (attr) => attr.collectionID === activeCollectionID
); );
return ( return (
attributes && attributes &&
!isCollectionDownloadCancelled(attributes) && !isFilesDownloadCancelled(attributes) &&
!isCollectionDownloadCompleted(attributes) !isFilesDownloadCompleted(attributes)
); );
}, [activeCollectionID, collectionDownloadProgressAttributesList]); }, [activeCollectionID, filesDownloadProgressAttributesList]);
useEffect(() => { useEffect(() => {
if (isInSearchMode) { if (isInSearchMode) {
@ -137,8 +113,8 @@ export default function Collections(props: Iprops) {
showCollectionShareModal={() => showCollectionShareModal={() =>
setCollectionShareModalView(true) setCollectionShareModalView(true)
} }
setCollectionDownloadProgressAttributesCreator={ setFilesDownloadProgressAttributesCreator={
setCollectionDownloadProgressAttributesCreator setFilesDownloadProgressAttributesCreator
} }
isActiveCollectionDownloadInProgress={ isActiveCollectionDownloadInProgress={
isActiveCollectionDownloadInProgress isActiveCollectionDownloadInProgress
@ -199,10 +175,6 @@ export default function Collections(props: Iprops) {
onClose={closeCollectionShare} onClose={closeCollectionShare}
collection={activeCollection} collection={activeCollection}
/> />
<CollectionDownloadProgress
attributesList={collectionDownloadProgressAttributesList}
setAttributesList={setCollectionDownloadProgressAttributesList}
/>
<AlbumCastDialog <AlbumCastDialog
currentCollection={props.activeCollection} currentCollection={props.activeCollection}
show={showAlbumCastDialog} 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 PhotoViewer from 'components/PhotoViewer';
import { TRASH_SECTION } from 'constants/collection'; import { TRASH_SECTION } from 'constants/collection';
import { updateFileMsrcProps, updateFileSrcProps } from 'utils/photoFrame'; import { updateFileMsrcProps, updateFileSrcProps } from 'utils/photoFrame';
import { SelectedState } from 'types/gallery'; import {
import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery'; SelectedState,
SetFilesDownloadProgressAttributesCreator,
} from 'types/gallery';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { logError } from '@ente/shared/sentry'; import { logError } from '@ente/shared/sentry';
import { addLogLine } from '@ente/shared/logging'; import { addLogLine } from '@ente/shared/logging';
@ -53,8 +55,8 @@ interface Props {
selected: SelectedState | ((selected: SelectedState) => SelectedState) selected: SelectedState | ((selected: SelectedState) => SelectedState)
) => void; ) => void;
selected: SelectedState; selected: SelectedState;
deletedFileIds?: Set<number>; tempDeletedFileIds?: Set<number>;
setDeletedFileIds?: (value: Set<number>) => void; setTempDeletedFileIds?: (value: Set<number>) => void;
activeCollectionID: number; activeCollectionID: number;
enableDownload?: boolean; enableDownload?: boolean;
fileToCollectionsMap: Map<number, number[]>; fileToCollectionsMap: Map<number, number[]>;
@ -62,6 +64,7 @@ interface Props {
showAppDownloadBanner?: boolean; showAppDownloadBanner?: boolean;
setIsPhotoSwipeOpen?: (value: boolean) => void; setIsPhotoSwipeOpen?: (value: boolean) => void;
isInHiddenSection?: boolean; isInHiddenSection?: boolean;
setFilesDownloadProgressAttributesCreator?: SetFilesDownloadProgressAttributesCreator;
} }
const PhotoFrame = ({ const PhotoFrame = ({
@ -72,8 +75,8 @@ const PhotoFrame = ({
favItemIds, favItemIds,
setSelected, setSelected,
selected, selected,
deletedFileIds, tempDeletedFileIds,
setDeletedFileIds, setTempDeletedFileIds,
activeCollectionID, activeCollectionID,
enableDownload, enableDownload,
fileToCollectionsMap, fileToCollectionsMap,
@ -81,6 +84,7 @@ const PhotoFrame = ({
showAppDownloadBanner, showAppDownloadBanner,
setIsPhotoSwipeOpen, setIsPhotoSwipeOpen,
isInHiddenSection, isInHiddenSection,
setFilesDownloadProgressAttributesCreator,
}: Props) => { }: Props) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [currentIndex, setCurrentIndex] = useState<number>(0); const [currentIndex, setCurrentIndex] = useState<number>(0);
@ -89,9 +93,6 @@ const PhotoFrame = ({
[k: number]: boolean; [k: number]: boolean;
}>({}); }>({});
const galleryContext = useContext(GalleryContext); const galleryContext = useContext(GalleryContext);
const publicCollectionGalleryContext = useContext(
PublicCollectionGalleryContext
);
const [rangeStart, setRangeStart] = useState(null); const [rangeStart, setRangeStart] = useState(null);
const [currentHover, setCurrentHover] = useState(null); const [currentHover, setCurrentHover] = useState(null);
const [isShiftKeyPressed, setIsShiftKeyPressed] = useState(false); const [isShiftKeyPressed, setIsShiftKeyPressed] = useState(false);
@ -315,9 +316,7 @@ const PhotoFrame = ({
file={item} file={item}
updateURL={updateURL(index)} updateURL={updateURL(index)}
onClick={onThumbnailClick(index)} onClick={onThumbnailClick(index)}
selectable={ selectable={enableDownload}
!publicCollectionGalleryContext?.accessedThroughSharedURL
}
onSelect={handleSelect( onSelect={handleSelect(
item.id, item.id,
item.ownerID === galleryContext.user?.id, item.ownerID === galleryContext.user?.id,
@ -600,13 +599,16 @@ const PhotoFrame = ({
gettingData={getSlideData} gettingData={getSlideData}
getConvertedItem={getConvertedItem} getConvertedItem={getConvertedItem}
favItemIds={favItemIds} favItemIds={favItemIds}
deletedFileIds={deletedFileIds} tempDeletedFileIds={tempDeletedFileIds}
setDeletedFileIds={setDeletedFileIds} setTempDeletedFileIds={setTempDeletedFileIds}
isTrashCollection={activeCollectionID === TRASH_SECTION} isTrashCollection={activeCollectionID === TRASH_SECTION}
isInHiddenSection={isInHiddenSection} isInHiddenSection={isInHiddenSection}
enableDownload={enableDownload} enableDownload={enableDownload}
fileToCollectionsMap={fileToCollectionsMap} fileToCollectionsMap={fileToCollectionsMap}
collectionNameMap={collectionNameMap} collectionNameMap={collectionNameMap}
setFilesDownloadProgressAttributesCreator={
setFilesDownloadProgressAttributesCreator
}
/> />
</Container> </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 = ( const flipCanvas = (
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
direction: 'vertical' | 'horizontal' direction: 'vertical' | 'horizontal'

View file

@ -29,6 +29,7 @@ import mime from 'mime-types';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import { HorizontalFlex } from '@ente/shared/components/Container'; import { HorizontalFlex } from '@ente/shared/components/Container';
import TransformMenu from './TransformMenu'; import TransformMenu from './TransformMenu';
import CropMenu from './CropMenu';
import ColoursMenu from './ColoursMenu'; import ColoursMenu from './ColoursMenu';
import { FileWithCollection } from 'types/upload'; import { FileWithCollection } from 'types/upload';
import uploadManager from 'services/upload/uploadManager'; import uploadManager from 'services/upload/uploadManager';
@ -44,6 +45,12 @@ import { getEditorCloseConfirmationMessage } from 'utils/ui';
import { logError } from '@ente/shared/sentry'; import { logError } from '@ente/shared/sentry';
import { getFileType } from 'services/typeDetectionService'; import { getFileType } from 'services/typeDetectionService';
import { downloadUsingAnchor } from '@ente/shared/utils'; 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 { interface IProps {
file: EnteFile; file: EnteFile;
@ -59,16 +66,11 @@ export const ImageEditorOverlayContext = createContext(
setTransformationPerformed: Dispatch<SetStateAction<boolean>>; setTransformationPerformed: Dispatch<SetStateAction<boolean>>;
setCanvasLoading: Dispatch<SetStateAction<boolean>>; setCanvasLoading: Dispatch<SetStateAction<boolean>>;
canvasLoading: boolean; canvasLoading: boolean;
setCurrentTab: Dispatch<SetStateAction<OperationTab>>;
} }
); );
const filterDefaultValues = { type OperationTab = 'crop' | 'transform' | 'colours';
brightness: 100,
contrast: 100,
blur: 0,
saturation: 100,
invert: false,
};
const getEditedFileName = (fileName: string) => { const getEditedFileName = (fileName: string) => {
const fileNameParts = fileName.split('.'); const fileNameParts = fileName.split('.');
@ -77,6 +79,13 @@ const getEditedFileName = (fileName: string) => {
return editedFileName; return editedFileName;
}; };
export interface CropBoxProps {
x: number;
y: number;
width: number;
height: number;
}
const ImageEditorOverlay = (props: IProps) => { const ImageEditorOverlay = (props: IProps) => {
const appContext = useContext(AppContext); const appContext = useContext(AppContext);
@ -88,19 +97,17 @@ const ImageEditorOverlay = (props: IProps) => {
const [currentRotationAngle, setCurrentRotationAngle] = useState(0); const [currentRotationAngle, setCurrentRotationAngle] = useState(0);
const [currentTab, setCurrentTab] = useState<'transform' | 'colours'>( const [currentTab, setCurrentTab] = useState<OperationTab>('transform');
'transform'
);
const [brightness, setBrightness] = useState( const [brightness, setBrightness] = useState(
filterDefaultValues.brightness FILTER_DEFAULT_VALUES.brightness
); );
const [contrast, setContrast] = useState(filterDefaultValues.contrast); const [contrast, setContrast] = useState(FILTER_DEFAULT_VALUES.contrast);
const [blur, setBlur] = useState(filterDefaultValues.blur); const [blur, setBlur] = useState(FILTER_DEFAULT_VALUES.blur);
const [saturation, setSaturation] = useState( 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] = const [transformationPerformed, setTransformationPerformed] =
useState(false); useState(false);
@ -110,6 +117,149 @@ const ImageEditorOverlay = (props: IProps) => {
const [showControlsDrawer, setShowControlsDrawer] = useState(true); 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(() => { useEffect(() => {
if (!canvasRef.current) { if (!canvasRef.current) {
return; return;
@ -117,17 +267,23 @@ const ImageEditorOverlay = (props: IProps) => {
try { try {
applyFilters([canvasRef.current, originalSizeCanvasRef.current]); applyFilters([canvasRef.current, originalSizeCanvasRef.current]);
setColoursAdjusted( setColoursAdjusted(
brightness !== filterDefaultValues.brightness || brightness !== FILTER_DEFAULT_VALUES.brightness ||
contrast !== filterDefaultValues.contrast || contrast !== FILTER_DEFAULT_VALUES.contrast ||
blur !== filterDefaultValues.blur || blur !== FILTER_DEFAULT_VALUES.blur ||
saturation !== filterDefaultValues.saturation || saturation !== FILTER_DEFAULT_VALUES.saturation ||
invert !== filterDefaultValues.invert invert !== FILTER_DEFAULT_VALUES.invert
); );
} catch (e) { } catch (e) {
logError(e, 'Error applying filters'); logError(e, 'Error applying filters');
} }
}, [brightness, contrast, blur, saturation, invert, canvasRef, fileURL]); }, [brightness, contrast, blur, saturation, invert, canvasRef, fileURL]);
useEffect(() => {
if (currentTab !== 'crop') return;
resetCropBox();
setShowControlsDrawer(false);
}, [currentTab]);
const applyFilters = async (canvases: HTMLCanvasElement[]) => { const applyFilters = async (canvases: HTMLCanvasElement[]) => {
try { try {
for (const canvas of canvases) { for (const canvas of canvases) {
@ -203,6 +359,7 @@ const ImageEditorOverlay = (props: IProps) => {
} }
setCanvasLoading(true); setCanvasLoading(true);
resetFilters(); resetFilters();
setCurrentRotationAngle(0); setCurrentRotationAngle(0);
@ -226,6 +383,7 @@ const ImageEditorOverlay = (props: IProps) => {
parentRef.current.clientWidth / img.width, parentRef.current.clientWidth / img.width,
parentRef.current.clientHeight / img.height parentRef.current.clientHeight / img.height
); );
setPreviewCanvasScale(scale);
const width = img.width * scale; const width = img.width * scale;
const height = img.height * scale; const height = img.height * scale;
@ -246,6 +404,13 @@ const ImageEditorOverlay = (props: IProps) => {
setColoursAdjusted(false); setColoursAdjusted(false);
setCanvasLoading(false); setCanvasLoading(false);
resetCropBox();
setStartX(0);
setStartY(0);
setIsDragging(false);
setIsGrowing(false);
resolve(true); resolve(true);
} catch (e) { } catch (e) {
reject(e); reject(e);
@ -387,35 +552,97 @@ const ImageEditorOverlay = (props: IProps) => {
boxSizing={'border-box'} boxSizing={'border-box'}
display="flex" display="flex"
alignItems="center" alignItems="center"
justifyContent="center"> justifyContent="center"
position="relative"
onMouseUp={handleDragEnd}
onMouseMove={isDragging ? handleDrag : null}
onMouseDown={handleDragStart}>
<Box <Box
height="90%" style={{
width="100%" position: 'relative',
ref={parentRef} width: '100%',
display="flex" height: '100%',
alignItems="center" }}>
justifyContent="center"> <Box
{(fileURL === null || canvasLoading) && ( height="90%"
<CircularProgress /> width="100%"
)} ref={parentRef}
display="flex"
alignItems="center"
justifyContent="center"
position="relative">
{(fileURL === null || canvasLoading) && (
<CircularProgress />
)}
<canvas <canvas
ref={canvasRef} ref={canvasRef}
style={{ style={{
objectFit: 'contain', objectFit: 'contain',
display: display:
fileURL === null || canvasLoading fileURL === null || canvasLoading
? 'none' ? 'none'
: 'block', : 'block',
position: 'absolute', position: 'absolute',
}} }}
/> />
<canvas <canvas
ref={originalSizeCanvasRef} ref={originalSizeCanvasRef}
style={{ style={{
display: 'none', 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> </Box>
</Box> </Box>
@ -441,6 +668,7 @@ const ImageEditorOverlay = (props: IProps) => {
onChange={(_, value) => { onChange={(_, value) => {
setCurrentTab(value); setCurrentTab(value);
}}> }}>
<Tab label={t('CROP')} value="crop" />
<Tab label={t('TRANSFORM')} value="transform" /> <Tab label={t('TRANSFORM')} value="transform" />
<Tab <Tab
label={t('COLORS')} label={t('COLORS')}
@ -463,18 +691,25 @@ const ImageEditorOverlay = (props: IProps) => {
label={t('RESTORE_ORIGINAL')} label={t('RESTORE_ORIGINAL')}
/> />
</MenuItemGroup> </MenuItemGroup>
{currentTab === 'transform' && ( <ImageEditorOverlayContext.Provider
<ImageEditorOverlayContext.Provider value={{
value={{ originalSizeCanvasRef,
originalSizeCanvasRef, canvasRef,
canvasRef, setCanvasLoading,
setCanvasLoading, canvasLoading,
canvasLoading, setTransformationPerformed,
setTransformationPerformed, setCurrentTab,
}}> }}>
<TransformMenu /> {currentTab === 'crop' && (
</ImageEditorOverlayContext.Provider> <CropMenu
)} previewScale={previewCanvasScale}
cropBoxProps={cropBox}
cropBoxRef={cropBoxRef}
resetCropBox={resetCropBox}
/>
)}
{currentTab === 'transform' && <TransformMenu />}
</ImageEditorOverlayContext.Provider>
{currentTab === 'colours' && ( {currentTab === 'colours' && (
<ColoursMenu <ColoursMenu
brightness={brightness} brightness={brightness}
@ -495,14 +730,25 @@ const ImageEditorOverlay = (props: IProps) => {
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
onClick={downloadEditedPhoto} onClick={downloadEditedPhoto}
label={t('DOWNLOAD_EDITED')} label={t('DOWNLOAD_EDITED')}
disabled={
!transformationPerformed && !coloursAdjusted
}
/> />
<MenuItemDivider /> <MenuItemDivider />
<EnteMenuItem <EnteMenuItem
startIcon={<CloudUploadIcon />} startIcon={<CloudUploadIcon />}
onClick={saveCopyToEnte} onClick={saveCopyToEnte}
label={t('SAVE_A_COPY_TO_ENTE')} label={t('SAVE_A_COPY_TO_ENTE')}
disabled={
!transformationPerformed && !coloursAdjusted
}
/> />
</MenuItemGroup> </MenuItemGroup>
{!transformationPerformed && !coloursAdjusted && (
<MenuSectionTitle
title={t('PHOTO_EDIT_REQUIRED_TO_SAVE')}
/>
)}
</EnteDrawer> </EnteDrawer>
</Backdrop> </Backdrop>
</> </>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import React, { useContext, useEffect, useRef, useState } from 'react'; import React, { useContext, useEffect, useRef, useState } from 'react';
import { EnteFile } from 'types/file'; import { EnteFile } from 'types/file';
import { styled } from '@mui/material'; import { Tooltip, styled } from '@mui/material';
import PlayCircleOutlineOutlinedIcon from '@mui/icons-material/PlayCircleOutlineOutlined'; import PlayCircleOutlineOutlinedIcon from '@mui/icons-material/PlayCircleOutlineOutlined';
import DownloadManager from 'services/download'; import DownloadManager from 'services/download';
import useLongPress from '@ente/shared/hooks/useLongPress'; import useLongPress from '@ente/shared/hooks/useLongPress';
@ -218,6 +218,12 @@ export default function PreviewCard(props: IProps) {
const galleryContext = useContext(GalleryContext); const galleryContext = useContext(GalleryContext);
const deduplicateContext = useContext(DeduplicateContext); const deduplicateContext = useContext(DeduplicateContext);
const longPressCallback = () => {
onSelect(!selected);
};
const longPress = useLongPress(longPressCallback, 500);
const { const {
file, file,
onClick, onClick,
@ -289,22 +295,19 @@ export default function PreviewCard(props: IProps) {
} }
}; };
const longPressCallback = () => {
onSelect(!selected);
};
const handleHover = () => { const handleHover = () => {
if (isRangeSelectActive) { if (isRangeSelectActive) {
onHover(); onHover();
} }
}; };
return ( const renderFn = () => (
<Cont <Cont
key={`thumb-${file.id}}`} key={`thumb-${file.id}}`}
onClick={handleClick} onClick={handleClick}
onMouseEnter={handleHover} onMouseEnter={handleHover}
disabled={!file?.msrc && !imgSrc} disabled={!file?.msrc && !imgSrc}
{...(selectable ? useLongPress(longPressCallback, 500) : {})}> {...(selectable ? longPress : {})}>
{selectable && ( {selectable && (
<Check <Check
type="checkbox" type="checkbox"
@ -360,4 +363,22 @@ export default function PreviewCard(props: IProps) {
)} )}
</Cont> </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 { REDIRECTS } from 'constants/redirects';
import { import {
getLocalMapEnabled, getLocalMapEnabled,
getToken,
setLocalMapEnabled, setLocalMapEnabled,
} from '@ente/shared/storage/localStorage/helpers'; } from '@ente/shared/storage/localStorage/helpers';
import { isExportInProgress } from 'utils/export'; 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 { PHOTOS_PAGES as PAGES } from '@ente/shared/constants/pages';
import { getTheme } from '@ente/shared/themes'; import { getTheme } from '@ente/shared/themes';
import { AppUpdateInfo } from '@ente/shared/electron/types'; import { AppUpdateInfo } from '@ente/shared/electron/types';
import DownloadManager from 'services/download';
const redirectMap = new Map([ const redirectMap = new Map([
[REDIRECTS.ROADMAP, getRoadmapRedirectURL], [REDIRECTS.ROADMAP, getRoadmapRedirectURL],
@ -232,6 +234,14 @@ export default function App(props: EnteAppProps) {
const initExport = async () => { const initExport = async () => {
try { try {
addLogLine('init export'); 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(); const exportSettings = exportService.getExportSettings();
if (!exportService.exportFolderExists(exportSettings?.folder)) { if (!exportService.exportFolderExists(exportSettings?.folder)) {
return; return;

View file

@ -97,6 +97,8 @@ import { EnteFile } from 'types/file';
import { import {
GalleryContextType, GalleryContextType,
SelectedState, SelectedState,
SetFilesDownloadProgressAttributes,
SetFilesDownloadProgressAttributesCreator,
UploadTypeSelectorIntent, UploadTypeSelectorIntent,
} from 'types/gallery'; } from 'types/gallery';
import Collections from 'components/Collections'; import Collections from 'components/Collections';
@ -120,7 +122,6 @@ import GalleryEmptyState from 'components/GalleryEmptyState';
import AuthenticateUserModal from 'components/AuthenticateUserModal'; import AuthenticateUserModal from 'components/AuthenticateUserModal';
import useMemoSingleThreaded from '@ente/shared/hooks/useMemoSingleThreaded'; import useMemoSingleThreaded from '@ente/shared/hooks/useMemoSingleThreaded';
import { isArchivedFile } from 'utils/magicMetadata'; import { isArchivedFile } from 'utils/magicMetadata';
import { isSameDayAnyYear, isInsideLocationTag } from 'utils/search';
import { getSessionExpiredMessage } from 'utils/ui'; import { getSessionExpiredMessage } from 'utils/ui';
import { syncEntities } from 'services/entityService'; import { syncEntities } from 'services/entityService';
import { constructUserIDToEmailMap } from 'services/collectionService'; import { constructUserIDToEmailMap } from 'services/collectionService';
@ -131,6 +132,13 @@ import { ClipService } from 'services/clipService';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import downloadManager from 'services/download'; import downloadManager from 'services/download';
import { APPS } from '@ente/shared/apps/constants'; 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')` export const DeadCenter = styled('div')`
flex: 1; flex: 1;
@ -225,10 +233,11 @@ export default function Gallery() {
const syncInProgress = useRef(true); const syncInProgress = useRef(true);
const syncInterval = useRef<NodeJS.Timeout>(); const syncInterval = useRef<NodeJS.Timeout>();
const resync = useRef<{ force: boolean; silent: boolean }>(); 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>() new Set<number>()
); );
const [hiddenFileIds, setHiddenFileIds] = useState<Set<number>>( const [tempHiddenFileIds, setTempHiddenFileIds] = useState<Set<number>>(
new Set<number>() new Set<number>()
); );
const { startLoading, finishLoading, setDialogMessage, ...appContext } = const { startLoading, finishLoading, setDialogMessage, ...appContext } =
@ -242,6 +251,9 @@ export default function Gallery() {
const [emailList, setEmailList] = useState<string[]>(null); const [emailList, setEmailList] = useState<string[]>(null);
const [activeCollectionID, setActiveCollectionID] = const [activeCollectionID, setActiveCollectionID] =
useState<number>(undefined); useState<number>(undefined);
const [hiddenFileIds, setHiddenFileIds] = useState<Set<number>>(
new Set<number>()
);
const [fixCreationTimeView, setFixCreationTimeView] = useState(false); const [fixCreationTimeView, setFixCreationTimeView] = useState(false);
const [fixCreationTimeAttributes, setFixCreationTimeAttributes] = const [fixCreationTimeAttributes, setFixCreationTimeAttributes] =
useState<FixCreationTimeAttributes>(null); useState<FixCreationTimeAttributes>(null);
@ -280,6 +292,11 @@ export default function Gallery() {
const [isInHiddenSection, setIsInHiddenSection] = useState(false); const [isInHiddenSection, setIsInHiddenSection] = useState(false);
const [
filesDownloadProgressAttributesList,
setFilesDownloadProgressAttributesList,
] = useState<FilesDownloadProgressAttributes[]>([]);
const openHiddenSection: GalleryContextType['openHiddenSection'] = ( const openHiddenSection: GalleryContextType['openHiddenSection'] = (
callback callback
) => { ) => {
@ -341,6 +358,7 @@ export default function Gallery() {
setIsFirstLoad(false); setIsFirstLoad(false);
setJustSignedUp(false); setJustSignedUp(false);
setIsFirstFetch(false); setIsFirstFetch(false);
locationSearchService.loadCities();
syncInterval.current = setInterval(() => { syncInterval.current = setInterval(() => {
syncWithRemote(false, true); syncWithRemote(false, true);
}, SYNC_INTERVAL_IN_MICROSECONDS); }, 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(() => { useEffect(() => {
if (!user || !files || !collections || !hiddenFiles || !trashedFiles) { if (!user || !files || !collections || !hiddenFiles || !trashedFiles) {
return; return;
@ -466,7 +492,9 @@ export default function Gallery() {
); );
}, [collections, activeCollectionID]); }, [collections, activeCollectionID]);
const filteredData = useMemoSingleThreaded((): EnteFile[] => { const filteredData = useMemoSingleThreaded(async (): Promise<
EnteFile[]
> => {
if ( if (
!files || !files ||
!user || !user ||
@ -480,117 +508,74 @@ export default function Gallery() {
if (activeCollectionID === TRASH_SECTION && !isInSearchMode) { if (activeCollectionID === TRASH_SECTION && !isInSearchMode) {
return getUniqueFiles([ return getUniqueFiles([
...trashedFiles, ...trashedFiles,
...files.filter((file) => deletedFileIds?.has(file.id)), ...files.filter((file) => tempDeletedFileIds?.has(file.id)),
]); ]);
} }
const filteredFiles = getUniqueFiles( const searchWorker = await ComlinkSearchWorker.getInstance();
(isInHiddenSection ? hiddenFiles : files).filter((item) => {
if (deletedFileIds?.has(item.id)) {
return false;
}
if (!isInHiddenSection && hiddenFileIds?.has(item.id)) { let filteredFiles: EnteFile[] = [];
return false; 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 (!isInHiddenSection && tempHiddenFileIds?.has(item.id)) {
if (isInSearchMode) {
if (
search?.date &&
!isSameDayAnyYear(search.date)(
new Date(item.metadata.creationTime / 1000)
)
) {
return false; 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 // archived collections files can only be seen in their respective collection
if (archivedCollections.has(item.collectionID)) { 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) { if (activeCollectionID === item.collectionID) {
return true; return true;
} else { } else {
return false; 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) { if (search?.clip) {
return filteredFiles.sort((a, b) => { return filteredFiles.sort((a, b) => {
return search.clip.get(b.id) - search.clip.get(a.id); return search.clip.get(b.id) - search.clip.get(a.id);
@ -606,7 +591,8 @@ export default function Gallery() {
files, files,
trashedFiles, trashedFiles,
hiddenFiles, hiddenFiles,
deletedFileIds, tempDeletedFileIds,
tempHiddenFileIds,
hiddenFileIds, hiddenFileIds,
search, search,
activeCollectionID, activeCollectionID,
@ -737,8 +723,8 @@ export default function Gallery() {
logError(e, 'syncWithRemote failed'); logError(e, 'syncWithRemote failed');
} }
} finally { } finally {
setDeletedFileIds(new Set()); setTempDeletedFileIds(new Set());
setHiddenFileIds(new Set()); setTempHiddenFileIds(new Set());
!silent && finishLoading(); !silent && finishLoading();
} }
syncInProgress.current = false; syncInProgress.current = false;
@ -783,6 +769,8 @@ export default function Gallery() {
const defaultHiddenCollectionIDs = const defaultHiddenCollectionIDs =
getDefaultHiddenCollectionIDs(hiddenCollections); getDefaultHiddenCollectionIDs(hiddenCollections);
setDefaultHiddenCollectionIDs(defaultHiddenCollectionIDs); setDefaultHiddenCollectionIDs(defaultHiddenCollectionIDs);
const hiddenFileIds = new Set<number>(hiddenFiles.map((f) => f.id));
setHiddenFileIds(hiddenFileIds);
const collectionSummaries = getCollectionSummaries( const collectionSummaries = getCollectionSummaries(
user, user,
collections, collections,
@ -816,6 +804,39 @@ export default function Gallery() {
return <div />; 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 = const collectionOpsHelper =
(ops: COLLECTION_OPS_TYPE) => async (collection: Collection) => { (ops: COLLECTION_OPS_TYPE) => async (collection: Collection) => {
startLoading(); startLoading();
@ -836,13 +857,20 @@ export default function Gallery() {
selected.collectionID 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(); clearSelection();
await syncWithRemote(false, true); await syncWithRemote(false, true);
if (isInHiddenSection && ops === COLLECTION_OPS_TYPE.UNHIDE) {
exitHiddenSection();
}
setActiveCollectionID(collection.id);
} catch (e) { } catch (e) {
logError(e, 'collection ops failed', { ops }); logError(e, 'collection ops failed', { ops });
setDialogMessage({ setDialogMessage({
@ -872,11 +900,20 @@ export default function Gallery() {
await handleFileOps( await handleFileOps(
ops, ops,
toProcessFiles, toProcessFiles,
setDeletedFileIds, setTempDeletedFileIds,
setHiddenFileIds, setTempHiddenFileIds,
setFixCreationTimeAttributes 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(); clearSelection();
await syncWithRemote(false, true); await syncWithRemote(false, true);
} catch (e) { } catch (e) {
@ -1013,6 +1050,10 @@ export default function Gallery() {
attributes={collectionSelectorAttributes} attributes={collectionSelectorAttributes}
collections={collections} collections={collections}
/> />
<FilesDownloadProgress
attributesList={filesDownloadProgressAttributesList}
setAttributesList={setFilesDownloadProgressAttributesList}
/>
<FixCreationTime <FixCreationTime
isOpen={fixCreationTimeView} isOpen={fixCreationTimeView}
hide={() => setFixCreationTimeView(false)} hide={() => setFixCreationTimeView(false)}
@ -1042,6 +1083,12 @@ export default function Gallery() {
hiddenCollectionSummaries={hiddenCollectionSummaries} hiddenCollectionSummaries={hiddenCollectionSummaries}
setCollectionNamerAttributes={setCollectionNamerAttributes} setCollectionNamerAttributes={setCollectionNamerAttributes}
setPhotoListHeader={setPhotoListHeader} setPhotoListHeader={setPhotoListHeader}
setFilesDownloadProgressAttributesCreator={
setFilesDownloadProgressAttributesCreator
}
filesDownloadProgressAttributesList={
filesDownloadProgressAttributesList
}
/> />
<Uploader <Uploader
@ -1098,8 +1145,8 @@ export default function Gallery() {
favItemIds={favItemIds} favItemIds={favItemIds}
setSelected={setSelected} setSelected={setSelected}
selected={selected} selected={selected}
deletedFileIds={deletedFileIds} tempDeletedFileIds={tempDeletedFileIds}
setDeletedFileIds={setDeletedFileIds} setTempDeletedFileIds={setTempDeletedFileIds}
setIsPhotoSwipeOpen={setIsPhotoSwipeOpen} setIsPhotoSwipeOpen={setIsPhotoSwipeOpen}
activeCollectionID={activeCollectionID} activeCollectionID={activeCollectionID}
enableDownload={true} enableDownload={true}
@ -1109,6 +1156,9 @@ export default function Gallery() {
files.length < 30 && !isInSearchMode files.length < 30 && !isInSearchMode
} }
isInHiddenSection={isInHiddenSection} isInHiddenSection={isInHiddenSection}
setFilesDownloadProgressAttributesCreator={
setFilesDownloadProgressAttributesCreator
}
/> />
)} )}
{selected.count > 0 && {selected.count > 0 &&

View file

@ -16,7 +16,12 @@ import {
} from 'services/publicCollectionService'; } from 'services/publicCollectionService';
import { Collection } from 'types/collection'; import { Collection } from 'types/collection';
import { EnteFile } from 'types/file'; 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 { AppContext } from 'pages/_app';
import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery'; import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery';
import { CustomError, parseSharingErrorCodes } from '@ente/shared/error'; import { CustomError, parseSharingErrorCodes } from '@ente/shared/error';
@ -52,7 +57,12 @@ import UploadButton from 'components/Upload/UploadButton';
import bs58 from 'bs58'; import bs58 from 'bs58';
import AddPhotoAlternateOutlined from '@mui/icons-material/AddPhotoAlternateOutlined'; import AddPhotoAlternateOutlined from '@mui/icons-material/AddPhotoAlternateOutlined';
import ComlinkCryptoWorker from '@ente/shared/crypto'; 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 FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
import MoreHoriz from '@mui/icons-material/MoreHoriz'; import MoreHoriz from '@mui/icons-material/MoreHoriz';
import OverflowMenu from '@ente/shared/components/OverflowMenu/menu'; 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 { ENTE_WEBSITE_LINK } from '@ente/shared/constants/urls';
import { APPS } from '@ente/shared/apps/constants'; import { APPS } from '@ente/shared/apps/constants';
import downloadManager from 'services/download'; 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() { export default function PublicCollectionGallery() {
const token = useRef<string>(null); const token = useRef<string>(null);
@ -86,6 +102,11 @@ export default function PublicCollectionGallery() {
const [uploadTypeSelectorView, setUploadTypeSelectorView] = useState(false); const [uploadTypeSelectorView, setUploadTypeSelectorView] = useState(false);
const [blockingLoad, setBlockingLoad] = useState(false); const [blockingLoad, setBlockingLoad] = useState(false);
const [shouldDisableDropzone, setShouldDisableDropzone] = useState(false); const [shouldDisableDropzone, setShouldDisableDropzone] = useState(false);
const [selected, setSelected] = useState<SelectedState>({
ownCount: 0,
count: 0,
collectionID: 0,
});
const { const {
getRootProps: getDragAndDropRootProps, getRootProps: getDragAndDropRootProps,
@ -111,6 +132,44 @@ export default function PublicCollectionGallery() {
directory: true, 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 = () => { const openUploader = () => {
setUploadTypeSelectorView(true); setUploadTypeSelectorView(true);
}; };
@ -217,18 +276,24 @@ export default function PublicCollectionGallery() {
); );
const downloadAllFiles = async () => { const downloadAllFiles = async () => {
if (!downloadEnabled) { try {
return; if (!downloadEnabled) {
} return;
appContext.startLoading();
for (const file of publicFiles) {
try {
await downloadFile(file);
} catch (e) {
// do nothing
} }
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(() => { 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 ( return (
<PublicCollectionGalleryContext.Provider <PublicCollectionGalleryContext.Provider
value={{ value={{
@ -468,12 +557,15 @@ export default function PublicCollectionGallery() {
page={PAGES.SHARED_ALBUMS} page={PAGES.SHARED_ALBUMS}
files={publicFiles} files={publicFiles}
syncWithRemote={syncWithRemote} syncWithRemote={syncWithRemote}
setSelected={() => null} setSelected={setSelected}
selected={{ count: 0, collectionID: null, ownCount: 0 }} selected={selected}
activeCollectionID={ALL_SECTION} activeCollectionID={ALL_SECTION}
enableDownload={downloadEnabled} enableDownload={downloadEnabled}
fileToCollectionsMap={null} fileToCollectionsMap={null}
collectionNameMap={null} collectionNameMap={null}
setFilesDownloadProgressAttributesCreator={
setFilesDownloadProgressAttributesCreator
}
/> />
{blockingLoad && ( {blockingLoad && (
<LoadingOverlay> <LoadingOverlay>
@ -498,6 +590,17 @@ export default function PublicCollectionGallery() {
UploadTypeSelectorIntent.collectPhotos UploadTypeSelectorIntent.collectPhotos
} }
/> />
<FilesDownloadProgress
attributesList={filesDownloadProgressAttributesList}
setAttributesList={setFilesDownloadProgressAttributesList}
/>
{selected.count > 0 && (
<SelectedFileOptions
downloadFilesHelper={downloadFilesHelper}
clearSelection={clearSelection}
count={selected.count}
/>
)}
</FullScreenDropZone> </FullScreenDropZone>
</PublicCollectionGalleryContext.Provider> </PublicCollectionGalleryContext.Provider>
); );

View file

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

View file

@ -26,7 +26,11 @@ export async function getDuplicates(
collectionNameMap: Map<number, string> collectionNameMap: Map<number, string>
) { ) {
try { 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>(); const fileMap = new Map<number, EnteFile>();
for (const file of files) { for (const file of files) {
@ -35,7 +39,7 @@ export async function getDuplicates(
let result: Duplicate[] = []; let result: Duplicate[] = [];
for (const dupe of dupes) { for (const dupe of descSortedDupes) {
let duplicateFiles: EnteFile[] = []; let duplicateFiles: EnteFile[] = [];
for (const fileID of dupe.fileIDs) { for (const fileID of dupe.fileIDs) {
if (fileMap.has(fileID)) { if (fileMap.has(fileID)) {

View file

@ -19,6 +19,7 @@ import { PhotosDownloadClient } from './clients/photos';
import { PublicAlbumsDownloadClient } from './clients/publicAlbums'; import { PublicAlbumsDownloadClient } from './clients/publicAlbums';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { isInternalUser } from 'utils/user'; import { isInternalUser } from 'utils/user';
import { Events, eventBus } from '@ente/shared/events';
export type LivePhotoSourceURL = { export type LivePhotoSourceURL = {
image: () => Promise<string>; image: () => Promise<string>;
@ -89,12 +90,30 @@ class DownloadManagerImpl {
this.diskFileCache = isElectron() && (await openDiskFileCache()); this.diskFileCache = isElectron() && (await openDiskFileCache());
this.cryptoWorker = await ComlinkCryptoWorker.getInstance(); this.cryptoWorker = await ComlinkCryptoWorker.getInstance();
this.ready = true; this.ready = true;
eventBus.on(Events.LOGOUT, this.logoutHandler.bind(this), this);
} catch (e) { } catch (e) {
logError(e, 'DownloadManager init failed'); logError(e, 'DownloadManager init failed');
throw e; 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) { updateToken(token: string, passwordToken?: string) {
this.downloadClient.updateTokens(token, passwordToken); this.downloadClient.updateTokens(token, passwordToken);
} }

View file

@ -2,6 +2,7 @@ import {
Embedding, Embedding,
EncryptedEmbedding, EncryptedEmbedding,
GetEmbeddingDiffResponse, GetEmbeddingDiffResponse,
Model,
PutEmbeddingRequest, PutEmbeddingRequest,
} from 'types/embedding'; } from 'types/embedding';
import ComlinkCryptoWorker from '@ente/shared/crypto'; import ComlinkCryptoWorker from '@ente/shared/crypto';
@ -16,105 +17,135 @@ import { getLatestVersionEmbeddings } from 'utils/embedding';
import { getLocalTrashedFiles } from './trashService'; import { getLocalTrashedFiles } from './trashService';
import { getLocalCollections } from './collectionService'; import { getLocalCollections } from './collectionService';
import { CustomError } from '@ente/shared/error'; import { CustomError } from '@ente/shared/error';
import { EnteFile } from 'types/file';
const ENDPOINT = getEndpoint(); const ENDPOINT = getEndpoint();
const DIFF_LIMIT = 500; 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'; const EMBEDDING_SYNC_TIME_TABLE = 'embedding_sync_time';
export const getLocalEmbeddings = async () => { export const getAllLocalEmbeddings = async () => {
const embeddings: Array<Embedding> = const embeddings: Array<Embedding> = await localForage.getItem<Embedding[]>(
(await localForage.getItem<Embedding[]>(EMBEDDINGS_TABLE)) || []; 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; return embeddings;
}; };
const getEmbeddingSyncTime = async () => { export const getLocalEmbeddings = async (model: Model) => {
return (await localForage.getItem<number>(EMBEDDING_SYNC_TIME_TABLE)) ?? 0; const embeddings = await getAllLocalEmbeddings();
return embeddings.filter((embedding) => embedding.model === model);
}; };
export const getLatestEmbeddings = async () => { const getModelEmbeddingSyncTime = async (model: Model) => {
await syncEmbeddings(); return (
const embeddings = await getLocalEmbeddings(); (await localForage.getItem<number>(
return embeddings; `${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 { try {
let embeddings = await getLocalEmbeddings(); let allEmbeddings = await getAllLocalEmbeddings();
const localFiles = await getAllLocalFiles(); const localFiles = await getAllLocalFiles();
const hiddenAlbums = await getLocalCollections('hidden'); const hiddenAlbums = await getLocalCollections('hidden');
const localTrashFiles = await getLocalTrashedFiles(); const localTrashFiles = await getLocalTrashedFiles();
const fileIdToKeyMap = new Map<number, string>(); const fileIdToKeyMap = new Map<number, string>();
[...localFiles, ...localTrashFiles].forEach((file) => { const allLocalFiles = [...localFiles, ...localTrashFiles];
allLocalFiles.forEach((file) => {
fileIdToKeyMap.set(file.id, file.key); fileIdToKeyMap.set(file.id, file.key);
}); });
addLogLine(`Syncing embeddings localCount: ${embeddings.length}`); await cleanupDeletedEmbeddings(allLocalFiles, allEmbeddings);
let sinceTime = await getEmbeddingSyncTime(); addLogLine(`Syncing embeddings localCount: ${allEmbeddings.length}`);
addLogLine(`Syncing embeddings sinceTime: ${sinceTime}`); for (const model of models) {
let response: GetEmbeddingDiffResponse; let modelLastSinceTime = await getModelEmbeddingSyncTime(model);
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);
addLogLine( addLogLine(
`Syncing embeddings syncedEmbeddingsCount: ${newEmbeddings.length}` `Syncing ${model} model's embeddings sinceTime: ${modelLastSinceTime}`
); );
} while (response.diff.length === DIFF_LIMIT); let response: GetEmbeddingDiffResponse;
void cleanupDeletedEmbeddings(); 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) { } catch (e) {
logError(e, 'Sync embeddings failed'); logError(e, 'Sync embeddings failed');
} }
}; };
export const getEmbeddingsDiff = async ( export const getEmbeddingsDiff = async (
sinceTime: number sinceTime: number,
model: Model
): Promise<GetEmbeddingDiffResponse> => { ): Promise<GetEmbeddingDiffResponse> => {
try { try {
const token = getToken(); const token = getToken();
@ -126,6 +157,7 @@ export const getEmbeddingsDiff = async (
{ {
sinceTime, sinceTime,
limit: DIFF_LIMIT, limit: DIFF_LIMIT,
model,
}, },
{ {
'X-Auth-Token': token, 'X-Auth-Token': token,
@ -161,21 +193,21 @@ export const putEmbedding = async (
} }
}; };
export const cleanupDeletedEmbeddings = async () => { export const cleanupDeletedEmbeddings = async (
const files = await getAllLocalFiles(); allLocalFiles: EnteFile[],
const trashedFiles = await getLocalTrashedFiles(); allLocalEmbeddings: Embedding[]
) => {
const activeFileIds = new Set<number>(); const activeFileIds = new Set<number>();
[...files, ...trashedFiles].forEach((file) => { allLocalFiles.forEach((file) => {
activeFileIds.add(file.id); activeFileIds.add(file.id);
}); });
const embeddings = await getLocalEmbeddings();
const remainingEmbeddings = embeddings.filter((embedding) => const remainingEmbeddings = allLocalEmbeddings.filter((embedding) =>
activeFileIds.has(embedding.fileID) activeFileIds.has(embedding.fileID)
); );
if (embeddings.length !== remainingEmbeddings.length) { if (allLocalEmbeddings.length !== remainingEmbeddings.length) {
addLogLine( addLogLine(
`cleanupDeletedEmbeddings embeddingsCount: ${embeddings.length} remainingEmbeddingsCount: ${remainingEmbeddings.length}` `cleanupDeletedEmbeddings embeddingsCount: ${allLocalEmbeddings.length} remainingEmbeddingsCount: ${remainingEmbeddings.length}`
); );
await localForage.setItem(EMBEDDINGS_TABLE, remainingEmbeddings); 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, ClipSearchScores,
} from 'types/search'; } from 'types/search';
import ObjectService from './machineLearning/objectService'; import ObjectService from './machineLearning/objectService';
import { import { getFormattedDate } from 'utils/search';
getFormattedDate,
isInsideLocationTag,
isSameDayAnyYear,
} from 'utils/search';
import { Person, Thing } from 'types/machineLearning'; import { Person, Thing } from 'types/machineLearning';
import { getUniqueFiles } from 'utils/file'; import { getUniqueFiles } from 'utils/file';
import { getLatestEntities } from './entityService'; import { getLatestEntities } from './entityService';
import { LocationTag, LocationTagData, EntityType } from 'types/entity'; import { LocationTag, LocationTagData, EntityType } from 'types/entity';
import { addLogLine } from '@ente/shared/logging'; import { addLogLine } from '@ente/shared/logging';
import { FILE_TYPE } from 'constants/file'; import { FILE_TYPE } from 'constants/file';
import { import { ClipService, computeClipMatchScore } from './clipService';
ClipService,
computeClipMatchScore,
getLocalClipImageEmbeddings,
} from './clipService';
import { CustomError } from '@ente/shared/error'; 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 DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']);
const CLIP_SCORE_THRESHOLD = 0.23; const CLIP_SCORE_THRESHOLD = 0.23;
export const getDefaultOptions = async (files: EnteFile[]) => { export const getDefaultOptions = async () => {
return [ return [
await getIndexStatusSuggestion(), await getIndexStatusSuggestion(),
...convertSuggestionsToOptions(await getAllPeopleSuggestion(), files), ...(await convertSuggestionsToOptions(await getAllPeopleSuggestion())),
]; ].filter((t) => !!t);
}; };
export const getAutoCompleteSuggestions = export const getAutoCompleteSuggestions =
@ -62,47 +58,42 @@ export const getAutoCompleteSuggestions =
...getCollectionSuggestion(searchPhrase, collections), ...getCollectionSuggestion(searchPhrase, collections),
getFileNameSuggestion(searchPhrase, files), getFileNameSuggestion(searchPhrase, files),
getFileCaptionSuggestion(searchPhrase, files), getFileCaptionSuggestion(searchPhrase, files),
...(await getLocationTagSuggestions(searchPhrase)), ...(await getLocationSuggestions(searchPhrase)),
...(await getThingSuggestion(searchPhrase)), ...(await getThingSuggestion(searchPhrase)),
].filter((suggestion) => !!suggestion); ].filter((suggestion) => !!suggestion);
return convertSuggestionsToOptions(suggestions, files); return convertSuggestionsToOptions(suggestions);
} catch (e) { } catch (e) {
logError(e, 'getAutoCompleteSuggestions failed'); logError(e, 'getAutoCompleteSuggestions failed');
return []; return [];
} }
}; };
function convertSuggestionsToOptions( async function convertSuggestionsToOptions(
suggestions: Suggestion[], suggestions: Suggestion[]
files: EnteFile[] ): Promise<SearchOption[]> {
) { const searchWorker = await ComlinkSearchWorker.getInstance();
const previewImageAppendedOptions: SearchOption[] = suggestions const previewImageAppendedOptions: SearchOption[] = [];
.map((suggestion) => ({ for (const suggestion of suggestions) {
suggestion, const searchQuery = convertSuggestionToSearchQuery(suggestion);
searchQuery: convertSuggestionToSearchQuery(suggestion), const resultFiles = getUniqueFiles(
})) await searchWorker.search(searchQuery)
.map(({ suggestion, searchQuery }) => { );
const resultFiles = getUniqueFiles( if (searchQuery?.clip) {
files.filter((file) => isSearchedFile(file, searchQuery)) resultFiles.sort((a, b) => {
); const aScore = searchQuery.clip.get(a.id);
const bScore = searchQuery.clip.get(b.id);
if (searchQuery?.clip) { return bScore - aScore;
resultFiles.sort((a, b) => { });
const aScore = searchQuery.clip.get(a.id); }
const bScore = searchQuery.clip.get(b.id); if (resultFiles.length) {
return bScore - aScore; previewImageAppendedOptions.push({
});
}
return {
...suggestion, ...suggestion,
fileCount: resultFiles.length, fileCount: resultFiles.length,
previewFiles: resultFiles.slice(0, 3), previewFiles: resultFiles.slice(0, 3),
}; });
}) }
.filter((option) => option.fileCount); }
return previewImageAppendedOptions; return previewImageAppendedOptions;
} }
function getFileTypeSuggestion(searchPhrase: string): Suggestion[] { function getFileTypeSuggestion(searchPhrase: string): Suggestion[] {
@ -190,28 +181,32 @@ export async function getAllPeopleSuggestion(): Promise<Array<Suggestion>> {
} }
export async function getIndexStatusSuggestion(): Promise<Suggestion> { export async function getIndexStatusSuggestion(): Promise<Suggestion> {
const config = await getMLSyncConfig(); try {
const indexStatus = await mlIDbStorage.getIndexStatus(config.mlVersion); const config = await getMLSyncConfig();
const indexStatus = await mlIDbStorage.getIndexStatus(config.mlVersion);
let label; let label;
if (!indexStatus.localFilesSynced) { if (!indexStatus.localFilesSynced) {
label = t('INDEXING_SCHEDULED'); label = t('INDEXING_SCHEDULED');
} else if (indexStatus.outOfSyncFilesExists) { } else if (indexStatus.outOfSyncFilesExists) {
label = t('ANALYZING_PHOTOS', { label = t('ANALYZING_PHOTOS', {
indexStatus, indexStatus,
}); });
} else if (!indexStatus.peopleIndexSynced) { } else if (!indexStatus.peopleIndexSynced) {
label = t('INDEXING_PEOPLE', { indexStatus }); label = t('INDEXING_PEOPLE', { indexStatus });
} else { } else {
label = t('INDEXING_DONE', { indexStatus }); 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[] { function getDateSuggestion(searchPhrase: string): Suggestion[] {
@ -264,10 +259,9 @@ function getFileCaptionSuggestion(
}; };
} }
async function getLocationTagSuggestions(searchPhrase: string) { async function getLocationSuggestions(searchPhrase: string) {
const searchResults = await searchLocationTag(searchPhrase); const locationTagResults = await searchLocationTag(searchPhrase);
const locationTagSuggestions = locationTagResults.map(
return searchResults.map(
(locationTag) => (locationTag) =>
({ ({
type: SuggestionType.LOCATION, type: SuggestionType.LOCATION,
@ -275,6 +269,28 @@ async function getLocationTagSuggestions(searchPhrase: string) {
label: locationTag.data.name, label: locationTag.data.name,
} as Suggestion) } 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[]> { async function getThingSuggestion(searchPhrase: string): Promise<Suggestion[]> {
@ -383,7 +399,7 @@ async function searchThing(searchPhrase: string) {
} }
async function searchClip(searchPhrase: string): Promise<ClipSearchScores> { 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 textEmbedding = await ClipService.getTextEmbedding(searchPhrase);
const clipSearchResult = new Map<number, number>( const clipSearchResult = new Map<number, number>(
( (
@ -404,48 +420,6 @@ async function searchClip(searchPhrase: string): Promise<ClipSearchScores> {
return clipSearchResult; 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 { function convertSuggestionToSearchQuery(option: Suggestion): Search {
switch (option.type) { switch (option.type) {
case SuggestionType.DATE: case SuggestionType.DATE:
@ -458,6 +432,9 @@ function convertSuggestionToSearchQuery(option: Suggestion): Search {
location: option.value as LocationTagData, location: option.value as LocationTagData,
}; };
case SuggestionType.CITY:
return { city: option.value as City };
case SuggestionType.COLLECTION: case SuggestionType.COLLECTION:
return { collection: option.value as number }; return { collection: option.value as number };

View file

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

View file

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

View file

@ -1,5 +1,6 @@
export enum Model { export enum Model {
GGML_CLIP = 'ggml-clip', GGML_CLIP = 'ggml-clip',
ONNX_CLIP = 'onnx-clip',
} }
export interface EncryptedEmbedding { 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 { CollectionSelectorAttributes } from 'components/Collections/CollectionSelector';
import { TimeStampListItem } from 'components/PhotoList'; import { TimeStampListItem } from 'components/PhotoList';
import { Collection } from 'types/collection'; import { Collection } from 'types/collection';
@ -17,9 +17,19 @@ export type SetLoading = React.Dispatch<React.SetStateAction<boolean>>;
export type SetCollectionSelectorAttributes = React.Dispatch< export type SetCollectionSelectorAttributes = React.Dispatch<
React.SetStateAction<CollectionSelectorAttributes> React.SetStateAction<CollectionSelectorAttributes>
>; >;
export type SetCollectionDownloadProgressAttributes = React.Dispatch< export type SetFilesDownloadProgressAttributes = (
React.SetStateAction<CollectionDownloadProgressAttributes> value:
>; | Partial<FilesDownloadProgressAttributes>
| ((
prev: FilesDownloadProgressAttributes
) => FilesDownloadProgressAttributes)
) => void;
export type SetFilesDownloadProgressAttributesCreator = (
folderName: string,
collectionID?: number,
isHidden?: boolean
) => SetFilesDownloadProgressAttributes;
export type MergedSourceURL = { export type MergedSourceURL = {
original: string; original: string;

View file

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

View file

@ -12,7 +12,7 @@ import {
updatePublicCollectionMagicMetadata, updatePublicCollectionMagicMetadata,
updateSharedCollectionMagicMetadata, updateSharedCollectionMagicMetadata,
} from 'services/collectionService'; } from 'services/collectionService';
import { downloadFiles, downloadFilesDesktop } from 'utils/file'; import { downloadFilesWithProgress } from 'utils/file';
import { getAllLocalFiles, getLocalFiles } from 'services/fileService'; import { getAllLocalFiles, getLocalFiles } from 'services/fileService';
import { EnteFile } from 'types/file'; import { EnteFile } from 'types/file';
import { CustomError } from '@ente/shared/error'; import { CustomError } from '@ente/shared/error';
@ -34,7 +34,6 @@ import {
SYSTEM_COLLECTION_TYPES, SYSTEM_COLLECTION_TYPES,
MOVE_TO_NOT_ALLOWED_COLLECTION, MOVE_TO_NOT_ALLOWED_COLLECTION,
ADD_TO_NOT_ALLOWED_COLLECTION, ADD_TO_NOT_ALLOWED_COLLECTION,
HIDDEN_ITEMS_SECTION,
DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME, DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME,
} from 'constants/collection'; } from 'constants/collection';
import { getUnixTimeInMicroSecondsWithDelta } from '@ente/shared/time'; import { getUnixTimeInMicroSecondsWithDelta } from '@ente/shared/time';
@ -44,14 +43,14 @@ import { getAlbumsURL } from '@ente/shared/network/api';
import bs58 from 'bs58'; import bs58 from 'bs58';
import { t } from 'i18next'; import { t } from 'i18next';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { SetCollectionDownloadProgressAttributes } from 'types/gallery'; import { SetFilesDownloadProgressAttributes } from 'types/gallery';
import ElectronAPIs from '@ente/shared/electron'; import ElectronAPIs from '@ente/shared/electron';
import { import {
getCollectionExportPath, getCollectionExportPath,
getUniqueCollectionExportName, getUniqueCollectionExportName,
} from 'utils/export'; } from 'utils/export';
import exportService from 'services/export'; import exportService from 'services/export';
import { CollectionDownloadProgressAttributes } from 'components/Collections/CollectionDownloadProgress'; import { addLogLine } from '@ente/shared/logging';
export enum COLLECTION_OPS_TYPE { export enum COLLECTION_OPS_TYPE {
ADD, ADD,
@ -100,7 +99,7 @@ export function getSelectedCollection(
export async function downloadCollectionHelper( export async function downloadCollectionHelper(
collectionID: number, collectionID: number,
setCollectionDownloadProgressAttributes: SetCollectionDownloadProgressAttributes setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
) { ) {
try { try {
const allFiles = await getAllLocalFiles(); const allFiles = await getAllLocalFiles();
@ -116,10 +115,8 @@ export async function downloadCollectionHelper(
} }
await downloadCollectionFiles( await downloadCollectionFiles(
collection.name, collection.name,
collection.id,
isHiddenCollection(collection),
collectionFiles, collectionFiles,
setCollectionDownloadProgressAttributes setFilesDownloadProgressAttributes
); );
} catch (e) { } catch (e) {
logError(e, 'download collection failed '); logError(e, 'download collection failed ');
@ -127,7 +124,7 @@ export async function downloadCollectionHelper(
} }
export async function downloadDefaultHiddenCollectionHelper( export async function downloadDefaultHiddenCollectionHelper(
setCollectionDownloadProgressAttributes: SetCollectionDownloadProgressAttributes setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
) { ) {
try { try {
const hiddenCollections = await getLocalCollections('hidden'); const hiddenCollections = await getLocalCollections('hidden');
@ -139,78 +136,38 @@ export async function downloadDefaultHiddenCollectionHelper(
); );
await downloadCollectionFiles( await downloadCollectionFiles(
DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME, DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME,
HIDDEN_ITEMS_SECTION,
true,
defaultHiddenCollectionFiles, defaultHiddenCollectionFiles,
setCollectionDownloadProgressAttributes setFilesDownloadProgressAttributes
); );
} catch (e) { } catch (e) {
logError(e, 'download hidden files failed '); logError(e, 'download hidden files failed ');
} }
} }
async function downloadCollectionFiles( export async function downloadCollectionFiles(
collectionName: string, collectionName: string,
collectionID: number,
isHidden: boolean,
collectionFiles: EnteFile[], collectionFiles: EnteFile[],
setCollectionDownloadProgressAttributes: SetCollectionDownloadProgressAttributes setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
) { ) {
if (!collectionFiles.length) { if (!collectionFiles.length) {
return; return;
} }
const canceller = new AbortController(); let downloadDirPath: string;
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,
};
if (isElectron()) { if (isElectron()) {
const selectedDir = await ElectronAPIs.selectDirectory(); const selectedDir = await ElectronAPIs.selectDirectory();
if (!selectedDir) { if (!selectedDir) {
return; return;
} }
const downloadDirPath = await createCollectionDownloadFolder( downloadDirPath = await createCollectionDownloadFolder(
selectedDir, selectedDir,
collectionName 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( async function createCollectionDownloadFolder(
@ -521,7 +478,8 @@ export function isValidReplacementAlbum(
return ( return (
collection.name === wantedCollectionName && collection.name === wantedCollectionName &&
(collection.type === CollectionType.album || (collection.type === CollectionType.album ||
collection.type === CollectionType.folder) && collection.type === CollectionType.folder ||
collection.type === CollectionType.uncategorized) &&
!isHiddenCollection(collection) && !isHiddenCollection(collection) &&
!isQuickLinkCollection(collection) && !isQuickLinkCollection(collection) &&
!isIncomingShare(collection, user) !isIncomingShare(collection, user)
@ -610,8 +568,13 @@ export const getOrCreateAlbum = async (
} }
for (const collection of existingCollections) { for (const collection of existingCollections) {
if (isValidReplacementAlbum(collection, user, albumName)) { if (isValidReplacementAlbum(collection, user, albumName)) {
addLogLine(
`Found existing album ${albumName} with id ${collection.id}`
);
return collection; 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 { import {
EnteFile, EnteFile,
EncryptedEnteFile, EncryptedEnteFile,
@ -52,6 +56,7 @@ import { getFileExportPath, getUniqueFileExportName } from 'utils/export';
import imageProcessor from 'services/imageProcessor'; import imageProcessor from 'services/imageProcessor';
import ElectronAPIs from '@ente/shared/electron'; import ElectronAPIs from '@ente/shared/electron';
import { downloadUsingAnchor } from '@ente/shared/utils'; import { downloadUsingAnchor } from '@ente/shared/utils';
import { t } from 'i18next';
const WAIT_TIME_IMAGE_CONVERSION = 30 * 1000; const WAIT_TIME_IMAGE_CONVERSION = 30 * 1000;
@ -625,9 +630,96 @@ export function getUniqueFiles(files: EnteFile[]) {
return uniqueFiles; 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( export async function downloadFiles(
files: EnteFile[], files: EnteFile[],
progressBarUpdater?: { progressBarUpdater: {
increaseSuccess: () => void; increaseSuccess: () => void;
increaseFailed: () => void; increaseFailed: () => void;
isCancelled: () => boolean; isCancelled: () => boolean;
@ -857,11 +949,11 @@ export const shouldShowAvatar = (file: EnteFile, user: User) => {
export const handleFileOps = async ( export const handleFileOps = async (
ops: FILE_OPS_TYPE, ops: FILE_OPS_TYPE,
files: EnteFile[], files: EnteFile[],
setDeletedFileIds: ( setTempDeletedFileIds: (
deletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>) tempDeletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void, ) => void,
setHiddenFileIds: ( setTempHiddenFileIds: (
hiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>) tempHiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void, ) => void,
setFixCreationTimeAttributes: ( setFixCreationTimeAttributes: (
fixCreationTimeAttributes: fixCreationTimeAttributes:
@ -869,21 +961,30 @@ export const handleFileOps = async (
files: EnteFile[]; files: EnteFile[];
} }
| ((prev: { files: EnteFile[] }) => { files: EnteFile[] }) | ((prev: { files: EnteFile[] }) => { files: EnteFile[] })
) => void ) => void,
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator
) => { ) => {
switch (ops) { switch (ops) {
case FILE_OPS_TYPE.TRASH: case FILE_OPS_TYPE.TRASH:
await deleteFileHelper(files, false, setDeletedFileIds); await deleteFileHelper(files, false, setTempDeletedFileIds);
break; break;
case FILE_OPS_TYPE.DELETE_PERMANENTLY: case FILE_OPS_TYPE.DELETE_PERMANENTLY:
await deleteFileHelper(files, true, setDeletedFileIds); await deleteFileHelper(files, true, setTempDeletedFileIds);
break; break;
case FILE_OPS_TYPE.HIDE: case FILE_OPS_TYPE.HIDE:
await hideFilesHelper(files, setHiddenFileIds); await hideFilesHelper(files, setTempHiddenFileIds);
break; break;
case FILE_OPS_TYPE.DOWNLOAD: case FILE_OPS_TYPE.DOWNLOAD: {
await downloadFiles(files); const setSelectedFileDownloadProgressAttributes =
setFilesDownloadProgressAttributesCreator(
`${files.length} ${t('FILES')}`
);
await downloadSelectedFiles(
files,
setSelectedFileDownloadProgressAttributes
);
break; break;
}
case FILE_OPS_TYPE.FIX_TIME: case FILE_OPS_TYPE.FIX_TIME:
fixTimeHelper(files, setFixCreationTimeAttributes); fixTimeHelper(files, setFixCreationTimeAttributes);
break; break;
@ -899,12 +1000,12 @@ export const handleFileOps = async (
const deleteFileHelper = async ( const deleteFileHelper = async (
selectedFiles: EnteFile[], selectedFiles: EnteFile[],
permanent: boolean, permanent: boolean,
setDeletedFileIds: ( setTempDeletedFileIds: (
deletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>) tempDeletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void ) => void
) => { ) => {
try { try {
setDeletedFileIds((deletedFileIds) => { setTempDeletedFileIds((deletedFileIds) => {
selectedFiles.forEach((file) => deletedFileIds.add(file.id)); selectedFiles.forEach((file) => deletedFileIds.add(file.id));
return new Set(deletedFileIds); return new Set(deletedFileIds);
}); });
@ -914,25 +1015,25 @@ const deleteFileHelper = async (
await trashFiles(selectedFiles); await trashFiles(selectedFiles);
} }
} catch (e) { } catch (e) {
setDeletedFileIds(new Set()); setTempDeletedFileIds(new Set());
throw e; throw e;
} }
}; };
const hideFilesHelper = async ( const hideFilesHelper = async (
selectedFiles: EnteFile[], selectedFiles: EnteFile[],
setHiddenFileIds: ( setTempHiddenFileIds: (
hiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>) tempHiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void ) => void
) => { ) => {
try { try {
setHiddenFileIds((hiddenFileIds) => { setTempHiddenFileIds((hiddenFileIds) => {
selectedFiles.forEach((file) => hiddenFileIds.add(file.id)); selectedFiles.forEach((file) => hiddenFileIds.add(file.id));
return new Set(hiddenFileIds); return new Set(hiddenFileIds);
}); });
await moveToHiddenCollection(selectedFiles); await moveToHiddenCollection(selectedFiles);
} catch (e) { } catch (e) {
setHiddenFileIds(new Set()); setTempHiddenFileIds(new Set());
throw e; throw e;
} }
}; };

View file

@ -1,6 +1,4 @@
import { LocationTagData } from 'types/entity';
import { DateValue } from 'types/search'; import { DateValue } from 'types/search';
import { Location } from 'types/upload';
export const isSameDayAnyYear = export const isSameDayAnyYear =
(baseDate: DateValue) => (compareDate: Date) => { (baseDate: DateValue) => (compareDate: Date) => {
@ -28,18 +26,3 @@ export function getFormattedDate(date: DateValue) {
new Date(date.year ?? 1, date.month ?? 1, date.date ?? 1) 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 = export const DESKTOP_ROADMAP_URL =
'https://github.com/ente-io/photos-desktop/issues'; '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; version: string;
} }
export enum Model {
GGML_CLIP = 'ggml-clip',
ONNX_CLIP = 'onnx-clip',
}
export interface ElectronAPIsType { export interface ElectronAPIsType {
exists: (path: string) => boolean; exists: (path: string) => boolean;
checkExistsAndCreateDir: (dirPath: string) => Promise<void>; checkExistsAndCreateDir: (dirPath: string) => Promise<void>;
@ -97,8 +102,11 @@ export interface ElectronAPIsType {
deleteFile: (path: string) => void; deleteFile: (path: string) => void;
rename: (oldPath: string, newPath: string) => Promise<void>; rename: (oldPath: string, newPath: string) => Promise<void>;
updateOptOutOfCrashReports: (optOut: boolean) => Promise<void>; updateOptOutOfCrashReports: (optOut: boolean) => Promise<void>;
computeImageEmbedding: (imageData: Uint8Array) => Promise<Float32Array>; computeImageEmbedding: (
computeTextEmbedding: (text: string) => Promise<Float32Array>; model: Model,
imageData: Uint8Array
) => Promise<Float32Array>;
computeTextEmbedding: (model: Model, text: string) => Promise<Float32Array>;
getPlatform: () => Promise<'mac' | 'windows' | 'linux'>; getPlatform: () => Promise<'mac' | 'windows' | 'linux'>;
setCustomCacheDirectory: (directory: string) => Promise<void>; setCustomCacheDirectory: (directory: string) => Promise<void>;
getCacheDirectory: () => Promise<string>; 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(() => { useEffect(() => {
const { value } = getData(key) ?? {}; const { value } = getData(key) ?? {};
if (value) { if (typeof value !== 'undefined') {
setValue(value); setValue(value);
} }
}, []); }, []);

View file

@ -22,3 +22,7 @@ export function downloadUsingAnchor(link: string, name: string) {
URL.revokeObjectURL(link); URL.revokeObjectURL(link);
a.remove(); 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== integrity sha512-eNMNr5exLoavuAMhIUVsOKF79SWd/zG104ef6sxBTSw+cZc6BXdQXDvYcGvp0VbxVVSp1XDUNoz7mg1xMtSznA==
follow-redirects@^1.15.0: follow-redirects@^1.15.0:
version "1.15.2" version "1.15.4"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf"
integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==
for-each@^0.3.3: for-each@^0.3.3:
version "0.3.3" version "0.3.3"