Browse Source

Add support for searching by popular cities (#1578)

Vishnu Mohandas 1 year ago
parent
commit
fd1b3eeff1

+ 4 - 2
README.md

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

+ 1 - 0
apps/auth/public/locales/en/translation.json

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

+ 1 - 0
apps/photos/public/locales/en/translation.json

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

+ 14 - 4
apps/photos/src/components/Search/SearchBar/searchInput/index.tsx

@@ -30,6 +30,7 @@ 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 { InputActionMeta } from 'react-select/src/types';
 import { components } from 'react-select';
 import { components } from 'react-select';
+import { City } from 'services/locationSearchService';
 
 
 interface Iprops {
 interface Iprops {
     isOpen: boolean;
     isOpen: boolean;
@@ -78,7 +79,7 @@ export default function SearchInput(props: Iprops) {
     }, []);
     }, []);
 
 
     async function refreshDefaultOptions() {
     async function refreshDefaultOptions() {
-        const defaultOptions = await getDefaultOptions(props.files);
+        const defaultOptions = await getDefaultOptions();
         setDefaultOptions(defaultOptions);
         setDefaultOptions(defaultOptions);
     }
     }
 
 
@@ -95,9 +96,12 @@ export default function SearchInput(props: Iprops) {
         }
         }
     };
     };
 
 
-    const getOptions = pDebounce(
-        getAutoCompleteSuggestions(props.files, props.collections),
-        250
+    const getOptions = useCallback(
+        pDebounce(
+            getAutoCompleteSuggestions(props.files, props.collections),
+            250
+        ),
+        [props.files, props.collections]
     );
     );
 
 
     const blur = () => {
     const blur = () => {
@@ -122,6 +126,12 @@ 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);

+ 1 - 0
apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx

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

+ 62 - 97
apps/photos/src/pages/gallery/index.tsx

@@ -120,7 +120,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 +130,9 @@ 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 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;
@@ -345,6 +347,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);
@@ -365,6 +368,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;
@@ -470,7 +481,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 ||
@@ -488,118 +501,70 @@ export default function Gallery() {
             ]);
             ]);
         }
         }
 
 
-        const filteredFiles = getUniqueFiles(
-            (isInHiddenSection ? hiddenFiles : files).filter((item) => {
-                if (tempDeletedFileIds?.has(item.id)) {
-                    return false;
-                }
+        const searchWorker = await ComlinkSearchWorker.getInstance();
 
 
-                if (!isInHiddenSection && tempHiddenFileIds?.has(item.id)) {
-                    return false;
-                }
-
-                // SEARCH MODE
-                if (isInSearchMode) {
-                    if (
-                        search?.date &&
-                        !isSameDayAnyYear(search.date)(
-                            new Date(item.metadata.creationTime / 1000)
-                        )
-                    ) {
-                        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
-                    ) {
+        let filteredFiles: EnteFile[] = [];
+        if (isInSearchMode) {
+            filteredFiles = getUniqueFiles(await searchWorker.search(search));
+        } else {
+            filteredFiles = getUniqueFiles(
+                (isInHiddenSection ? hiddenFiles : files).filter((item) => {
+                    if (tempDeletedFileIds?.has(item.id)) {
                         return false;
                         return false;
                     }
                     }
-                    if (
-                        search?.text &&
-                        search.text.files.indexOf(item.id) === -1
-                    ) {
+
+                    if (!isInHiddenSection && tempHiddenFileIds?.has(item.id)) {
                         return false;
                         return false;
                     }
                     }
-                    if (search?.files && search.files.indexOf(item.id) === -1) {
-                        return false;
+
+                    // archived collections files can only be seen in their respective collection
+                    if (archivedCollections.has(item.collectionID)) {
+                        if (activeCollectionID === item.collectionID) {
+                            return true;
+                        } else {
+                            return false;
+                        }
                     }
                     }
+
+                    // HIDDEN ITEMS SECTION - show all individual hidden files
                     if (
                     if (
-                        typeof search?.fileType !== 'undefined' &&
-                        search.fileType !== item.metadata.fileType
+                        activeCollectionID === HIDDEN_ITEMS_SECTION &&
+                        defaultHiddenCollectionIDs.has(item.collectionID)
                     ) {
                     ) {
-                        return false;
-                    }
-                    if (search?.clip && search.clip.has(item.id) === false) {
-                        return false;
+                        return true;
                     }
                     }
-                    return true;
-                }
 
 
-                // archived collections files can only be seen in their respective collection
-                if (archivedCollections.has(item.collectionID)) {
-                    if (activeCollectionID === item.collectionID) {
-                        return true;
-                    } else {
-                        return false;
+                    // 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;
+                        }
                     }
                     }
-                }
 
 
-                // HIDDEN ITEMS SECTION - show all individual hidden files
-                if (
-                    activeCollectionID === HIDDEN_ITEMS_SECTION &&
-                    defaultHiddenCollectionIDs.has(item.collectionID)
-                ) {
-                    return true;
-                }
+                    // 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;
+                        }
+                    }
 
 
-                // Archived files can only be seen in archive section or their respective collection
-                if (isArchivedFile(item)) {
-                    if (
-                        activeCollectionID === ARCHIVE_SECTION ||
-                        activeCollectionID === item.collectionID
-                    ) {
+                    // COLLECTION SECTION - show files in the active collection
+                    if (activeCollectionID === item.collectionID) {
                         return true;
                         return true;
                     } else {
                     } else {
                         return false;
                         return false;
                     }
                     }
-                }
-
-                // ALL SECTION - show all files
-                if (activeCollectionID === ALL_SECTION) {
-                    // show all files except the ones in hidden collections
-                    if (hiddenFileIds.has(item.id)) {
-                        return false;
-                    } else {
-                        return true;
-                    }
-                }
-
-                // COLLECTION SECTION - show files in the active collection
-                if (activeCollectionID === item.collectionID) {
-                    return true;
-                } else {
-                    return false;
-                }
-            })
-        );
+                })
+            );
+        }
         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);

+ 97 - 0
apps/photos/src/services/locationSearchService.ts

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

+ 57 - 82
apps/photos/src/services/searchService.ts

@@ -16,11 +16,7 @@ import {
     ClipSearchScores,
     ClipSearchScores,
 } from 'types/search';
 } from 'types/search';
 import ObjectService from './machineLearning/objectService';
 import ObjectService from './machineLearning/objectService';
-import {
-    getFormattedDate,
-    isInsideLocationTag,
-    isSameDayAnyYear,
-} from 'utils/search';
+import { getFormattedDate } 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';
@@ -31,15 +27,17 @@ import { ClipService, computeClipMatchScore } from './clipService';
 import { CustomError } from '@ente/shared/error';
 import { CustomError } from '@ente/shared/error';
 import { Model } from 'types/embedding';
 import { Model } from 'types/embedding';
 import { getLocalEmbeddings } from './embeddingService';
 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);
     ].filter((t) => !!t);
 };
 };
 
 
@@ -60,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(
-    suggestions: Suggestion[],
-    files: EnteFile[]
-) {
-    const previewImageAppendedOptions: SearchOption[] = suggestions
-        .map((suggestion) => ({
-            suggestion,
-            searchQuery: convertSuggestionToSearchQuery(suggestion),
-        }))
-        .map(({ suggestion, searchQuery }) => {
-            const resultFiles = getUniqueFiles(
-                files.filter((file) => isSearchedFile(file, searchQuery))
-            );
-
-            if (searchQuery?.clip) {
-                resultFiles.sort((a, b) => {
-                    const aScore = searchQuery.clip.get(a.id);
-                    const bScore = searchQuery.clip.get(b.id);
-                    return bScore - aScore;
-                });
-            }
-
-            return {
+async function convertSuggestionsToOptions(
+    suggestions: Suggestion[]
+): Promise<SearchOption[]> {
+    const searchWorker = await ComlinkSearchWorker.getInstance();
+    const previewImageAppendedOptions: SearchOption[] = [];
+    for (const suggestion of suggestions) {
+        const searchQuery = convertSuggestionToSearchQuery(suggestion);
+        const resultFiles = getUniqueFiles(
+            await searchWorker.search(searchQuery)
+        );
+        if (searchQuery?.clip) {
+            resultFiles.sort((a, b) => {
+                const aScore = searchQuery.clip.get(a.id);
+                const bScore = searchQuery.clip.get(b.id);
+                return bScore - aScore;
+            });
+        }
+        if (resultFiles.length) {
+            previewImageAppendedOptions.push({
                 ...suggestion,
                 ...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[] {
@@ -266,10 +259,9 @@ function getFileCaptionSuggestion(
     };
     };
 }
 }
 
 
-async function getLocationTagSuggestions(searchPhrase: string) {
-    const searchResults = await searchLocationTag(searchPhrase);
-
-    return searchResults.map(
+async function getLocationSuggestions(searchPhrase: string) {
+    const locationTagResults = await searchLocationTag(searchPhrase);
+    const locationTagSuggestions = locationTagResults.map(
         (locationTag) =>
         (locationTag) =>
             ({
             ({
                 type: SuggestionType.LOCATION,
                 type: SuggestionType.LOCATION,
@@ -277,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[]> {
@@ -406,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:
@@ -460,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 };
 
 

+ 4 - 0
apps/photos/src/types/search/index.ts

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

+ 30 - 0
apps/photos/src/utils/comlink/ComlinkSearchWorker.ts

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

+ 0 - 17
apps/photos/src/utils/search/index.ts

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

+ 75 - 0
apps/photos/src/worker/search.worker.ts

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

+ 2 - 0
packages/shared/constants/urls.ts

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

+ 33 - 0
packages/shared/hooks/useEffectSingleThreaded.tsx

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

+ 4 - 0
packages/shared/utils/index.ts

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