diff --git a/README.md b/README.md
index 33bfd5ea5..5b8acb0b1 100644
--- a/README.md
+++ b/README.md
@@ -82,7 +82,9 @@ An important part of our journey is to build better software by consistently lis
----
+## 🙇 Attributions
-Cross-browser testing provided by
+- Cross-browser testing provided by
[](https://www.browserstack.com/open-source)
+
+- Location search powered by [Simple Maps](https://simplemaps.com/data/world-cities)
diff --git a/apps/auth/public/locales/en/translation.json b/apps/auth/public/locales/en/translation.json
index 0b315b9d6..67684d46e 100644
--- a/apps/auth/public/locales/en/translation.json
+++ b/apps/auth/public/locales/en/translation.json
@@ -206,6 +206,7 @@
"SEARCH_TYPE": {
"COLLECTION": "Album",
"LOCATION": "Location",
+ "CITY": "Location",
"DATE": "Date",
"FILE_NAME": "File name",
"THING": "Content",
diff --git a/apps/photos/public/locales/en/translation.json b/apps/photos/public/locales/en/translation.json
index 884a3d91a..48718e8e2 100644
--- a/apps/photos/public/locales/en/translation.json
+++ b/apps/photos/public/locales/en/translation.json
@@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "Album",
"LOCATION": "Location",
+ "CITY": "Location",
"DATE": "Date",
"FILE_NAME": "File name",
"THING": "Content",
diff --git a/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx b/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx
index 7db8d94ad..c8e9bb289 100644
--- a/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx
+++ b/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 { InputActionMeta } from 'react-select/src/types';
import { components } from 'react-select';
+import { City } from 'services/locationSearchService';
interface Iprops {
isOpen: boolean;
@@ -78,7 +79,7 @@ export default function SearchInput(props: Iprops) {
}, []);
async function refreshDefaultOptions() {
- const defaultOptions = await getDefaultOptions(props.files);
+ const defaultOptions = await getDefaultOptions();
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 = () => {
@@ -122,6 +126,12 @@ export default function SearchInput(props: Iprops) {
};
props.setIsOpen(true);
break;
+ case SuggestionType.CITY:
+ search = {
+ city: selectedOption.value as City,
+ };
+ props.setIsOpen(true);
+ break;
case SuggestionType.COLLECTION:
search = { collection: selectedOption.value as number };
setValue(null);
diff --git a/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx b/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx
index 8bf92a310..9ebe3cd58 100644
--- a/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx
+++ b/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx
@@ -17,6 +17,7 @@ const getIconByType = (type: SuggestionType) => {
case SuggestionType.DATE:
return ;
case SuggestionType.LOCATION:
+ case SuggestionType.CITY:
return ;
case SuggestionType.COLLECTION:
return ;
diff --git a/apps/photos/src/pages/gallery/index.tsx b/apps/photos/src/pages/gallery/index.tsx
index dda10a5fc..22bf87778 100644
--- a/apps/photos/src/pages/gallery/index.tsx
+++ b/apps/photos/src/pages/gallery/index.tsx
@@ -120,7 +120,6 @@ import GalleryEmptyState from 'components/GalleryEmptyState';
import AuthenticateUserModal from 'components/AuthenticateUserModal';
import useMemoSingleThreaded from '@ente/shared/hooks/useMemoSingleThreaded';
import { isArchivedFile } from 'utils/magicMetadata';
-import { isSameDayAnyYear, isInsideLocationTag } from 'utils/search';
import { getSessionExpiredMessage } from 'utils/ui';
import { syncEntities } from 'services/entityService';
import { constructUserIDToEmailMap } from 'services/collectionService';
@@ -131,6 +130,9 @@ import { ClipService } from 'services/clipService';
import isElectron from 'is-electron';
import downloadManager from 'services/download';
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')`
flex: 1;
@@ -345,6 +347,7 @@ export default function Gallery() {
setIsFirstLoad(false);
setJustSignedUp(false);
setIsFirstFetch(false);
+ locationSearchService.loadCities();
syncInterval.current = setInterval(() => {
syncWithRemote(false, true);
}, 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(() => {
if (!user || !files || !collections || !hiddenFiles || !trashedFiles) {
return;
@@ -470,7 +481,9 @@ export default function Gallery() {
);
}, [collections, activeCollectionID]);
- const filteredData = useMemoSingleThreaded((): EnteFile[] => {
+ const filteredData = useMemoSingleThreaded(async (): Promise<
+ EnteFile[]
+ > => {
if (
!files ||
!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;
- }
+ let filteredFiles: EnteFile[] = [];
+ if (isInSearchMode) {
+ filteredFiles = getUniqueFiles(await searchWorker.search(search));
+ } else {
+ filteredFiles = getUniqueFiles(
+ (isInHiddenSection ? hiddenFiles : files).filter((item) => {
+ if (tempDeletedFileIds?.has(item.id)) {
+ return false;
+ }
- // SEARCH MODE
- if (isInSearchMode) {
- if (
- search?.date &&
- !isSameDayAnyYear(search.date)(
- new Date(item.metadata.creationTime / 1000)
- )
- ) {
+ if (!isInHiddenSection && tempHiddenFileIds?.has(item.id)) {
return false;
}
- if (
- search?.location &&
- !isInsideLocationTag(
- {
- latitude: item.metadata.latitude,
- longitude: item.metadata.longitude,
- },
- search.location
- )
- ) {
- return false;
- }
- if (
- search?.person &&
- search.person.files.indexOf(item.id) === -1
- ) {
- return false;
- }
- if (
- search?.thing &&
- search.thing.files.indexOf(item.id) === -1
- ) {
- return false;
- }
- if (
- search?.text &&
- search.text.files.indexOf(item.id) === -1
- ) {
- return false;
- }
- if (search?.files && search.files.indexOf(item.id) === -1) {
- return false;
- }
- if (
- typeof search?.fileType !== 'undefined' &&
- search.fileType !== item.metadata.fileType
- ) {
- return false;
- }
- if (search?.clip && search.clip.has(item.id) === false) {
- return false;
- }
- return true;
- }
- // archived collections files can only be seen in their respective collection
- if (archivedCollections.has(item.collectionID)) {
+ // archived collections files can only be seen in their respective collection
+ if (archivedCollections.has(item.collectionID)) {
+ if (activeCollectionID === item.collectionID) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ // HIDDEN ITEMS SECTION - show all individual hidden files
+ if (
+ activeCollectionID === HIDDEN_ITEMS_SECTION &&
+ defaultHiddenCollectionIDs.has(item.collectionID)
+ ) {
+ return true;
+ }
+
+ // Archived files can only be seen in archive section or their respective collection
+ if (isArchivedFile(item)) {
+ if (
+ activeCollectionID === ARCHIVE_SECTION ||
+ activeCollectionID === item.collectionID
+ ) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ // ALL SECTION - show all files
+ if (activeCollectionID === ALL_SECTION) {
+ // show all files except the ones in hidden collections
+ if (hiddenFileIds.has(item.id)) {
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ // COLLECTION SECTION - show files in the active collection
if (activeCollectionID === item.collectionID) {
return true;
} else {
return false;
}
- }
-
- // HIDDEN ITEMS SECTION - show all individual hidden files
- if (
- activeCollectionID === HIDDEN_ITEMS_SECTION &&
- defaultHiddenCollectionIDs.has(item.collectionID)
- ) {
- return true;
- }
-
- // Archived files can only be seen in archive section or their respective collection
- if (isArchivedFile(item)) {
- if (
- activeCollectionID === ARCHIVE_SECTION ||
- activeCollectionID === item.collectionID
- ) {
- return true;
- } else {
- return false;
- }
- }
-
- // ALL SECTION - show all files
- if (activeCollectionID === ALL_SECTION) {
- // 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) {
return filteredFiles.sort((a, b) => {
return search.clip.get(b.id) - search.clip.get(a.id);
diff --git a/apps/photos/src/services/locationSearchService.ts b/apps/photos/src/services/locationSearchService.ts
new file mode 100644
index 000000000..eebe44fac
--- /dev/null
+++ b/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 = [];
+ private citiesPromise: Promise;
+
+ 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));
+}
diff --git a/apps/photos/src/services/searchService.ts b/apps/photos/src/services/searchService.ts
index 90665dd0b..d257fde4a 100644
--- a/apps/photos/src/services/searchService.ts
+++ b/apps/photos/src/services/searchService.ts
@@ -16,11 +16,7 @@ import {
ClipSearchScores,
} from 'types/search';
import ObjectService from './machineLearning/objectService';
-import {
- getFormattedDate,
- isInsideLocationTag,
- isSameDayAnyYear,
-} from 'utils/search';
+import { getFormattedDate } from 'utils/search';
import { Person, Thing } from 'types/machineLearning';
import { getUniqueFiles } from 'utils/file';
import { getLatestEntities } from './entityService';
@@ -31,15 +27,17 @@ import { ClipService, computeClipMatchScore } from './clipService';
import { CustomError } from '@ente/shared/error';
import { Model } from 'types/embedding';
import { getLocalEmbeddings } from './embeddingService';
+import locationSearchService, { City } from './locationSearchService';
+import ComlinkSearchWorker from 'utils/comlink/ComlinkSearchWorker';
const DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']);
const CLIP_SCORE_THRESHOLD = 0.23;
-export const getDefaultOptions = async (files: EnteFile[]) => {
+export const getDefaultOptions = async () => {
return [
await getIndexStatusSuggestion(),
- ...convertSuggestionsToOptions(await getAllPeopleSuggestion(), files),
+ ...(await convertSuggestionsToOptions(await getAllPeopleSuggestion())),
].filter((t) => !!t);
};
@@ -60,47 +58,42 @@ export const getAutoCompleteSuggestions =
...getCollectionSuggestion(searchPhrase, collections),
getFileNameSuggestion(searchPhrase, files),
getFileCaptionSuggestion(searchPhrase, files),
- ...(await getLocationTagSuggestions(searchPhrase)),
+ ...(await getLocationSuggestions(searchPhrase)),
...(await getThingSuggestion(searchPhrase)),
].filter((suggestion) => !!suggestion);
- return convertSuggestionsToOptions(suggestions, files);
+ return convertSuggestionsToOptions(suggestions);
} catch (e) {
logError(e, 'getAutoCompleteSuggestions failed');
return [];
}
};
-function convertSuggestionsToOptions(
- suggestions: Suggestion[],
- files: EnteFile[]
-) {
- const previewImageAppendedOptions: SearchOption[] = suggestions
- .map((suggestion) => ({
- suggestion,
- searchQuery: convertSuggestionToSearchQuery(suggestion),
- }))
- .map(({ suggestion, searchQuery }) => {
- const resultFiles = getUniqueFiles(
- files.filter((file) => isSearchedFile(file, searchQuery))
- );
-
- if (searchQuery?.clip) {
- resultFiles.sort((a, b) => {
- const aScore = searchQuery.clip.get(a.id);
- const bScore = searchQuery.clip.get(b.id);
- return bScore - aScore;
- });
- }
-
- return {
+async function convertSuggestionsToOptions(
+ suggestions: Suggestion[]
+): Promise {
+ const searchWorker = await ComlinkSearchWorker.getInstance();
+ const previewImageAppendedOptions: SearchOption[] = [];
+ for (const suggestion of suggestions) {
+ const searchQuery = convertSuggestionToSearchQuery(suggestion);
+ const resultFiles = getUniqueFiles(
+ await searchWorker.search(searchQuery)
+ );
+ if (searchQuery?.clip) {
+ resultFiles.sort((a, b) => {
+ const aScore = searchQuery.clip.get(a.id);
+ const bScore = searchQuery.clip.get(b.id);
+ return bScore - aScore;
+ });
+ }
+ if (resultFiles.length) {
+ previewImageAppendedOptions.push({
...suggestion,
fileCount: resultFiles.length,
previewFiles: resultFiles.slice(0, 3),
- };
- })
- .filter((option) => option.fileCount);
-
+ });
+ }
+ }
return previewImageAppendedOptions;
}
function getFileTypeSuggestion(searchPhrase: string): Suggestion[] {
@@ -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) =>
({
type: SuggestionType.LOCATION,
@@ -277,6 +269,28 @@ async function getLocationTagSuggestions(searchPhrase: string) {
label: locationTag.data.name,
} as Suggestion)
);
+ const locationTagNames = new Set(
+ locationTagSuggestions.map((result) => result.label)
+ );
+
+ const citySearchResults = await locationSearchService.searchCities(
+ searchPhrase
+ );
+
+ const nonConflictingCityResult = citySearchResults.filter(
+ (city) => !locationTagNames.has(city.city)
+ );
+
+ const citySearchSuggestions = nonConflictingCityResult.map(
+ (city) =>
+ ({
+ type: SuggestionType.CITY,
+ value: city,
+ label: city.city,
+ } as Suggestion)
+ );
+
+ return [...locationTagSuggestions, ...citySearchSuggestions];
}
async function getThingSuggestion(searchPhrase: string): Promise {
@@ -406,48 +420,6 @@ async function searchClip(searchPhrase: string): Promise {
return clipSearchResult;
}
-function isSearchedFile(file: EnteFile, search: Search) {
- if (search?.collection) {
- return search.collection === file.collectionID;
- }
-
- if (search?.date) {
- return isSameDayAnyYear(search.date)(
- new Date(file.metadata.creationTime / 1000)
- );
- }
- if (search?.location) {
- return isInsideLocationTag(
- {
- latitude: file.metadata.latitude,
- longitude: file.metadata.longitude,
- },
- search.location
- );
- }
- if (search?.files) {
- return search.files.indexOf(file.id) !== -1;
- }
- if (search?.person) {
- return search.person.files.indexOf(file.id) !== -1;
- }
-
- if (search?.thing) {
- return search.thing.files.indexOf(file.id) !== -1;
- }
-
- if (search?.text) {
- return search.text.files.indexOf(file.id) !== -1;
- }
- if (typeof search?.fileType !== 'undefined') {
- return search.fileType === file.metadata.fileType;
- }
- if (typeof search?.clip !== 'undefined') {
- return search.clip.has(file.id);
- }
- return false;
-}
-
function convertSuggestionToSearchQuery(option: Suggestion): Search {
switch (option.type) {
case SuggestionType.DATE:
@@ -460,6 +432,9 @@ function convertSuggestionToSearchQuery(option: Suggestion): Search {
location: option.value as LocationTagData,
};
+ case SuggestionType.CITY:
+ return { city: option.value as City };
+
case SuggestionType.COLLECTION:
return { collection: option.value as number };
diff --git a/apps/photos/src/types/search/index.ts b/apps/photos/src/types/search/index.ts
index 1e41d53f1..2e6c94f48 100644
--- a/apps/photos/src/types/search/index.ts
+++ b/apps/photos/src/types/search/index.ts
@@ -3,6 +3,7 @@ import { IndexStatus } from 'types/machineLearning/ui';
import { EnteFile } from 'types/file';
import { LocationTagData } from 'types/entity';
import { FILE_TYPE } from 'constants/file';
+import { City } from 'services/locationSearchService';
export enum SuggestionType {
DATE = 'DATE',
@@ -16,6 +17,7 @@ export enum SuggestionType {
FILE_CAPTION = 'FILE_CAPTION',
FILE_TYPE = 'FILE_TYPE',
CLIP = 'CLIP',
+ CITY = 'CITY',
}
export interface DateValue {
@@ -35,6 +37,7 @@ export interface Suggestion {
| Thing
| WordGroup
| LocationTagData
+ | City
| FILE_TYPE
| ClipSearchScores;
hide?: boolean;
@@ -43,6 +46,7 @@ export interface Suggestion {
export type Search = {
date?: DateValue;
location?: LocationTagData;
+ city?: City;
collection?: number;
files?: number[];
person?: Person;
diff --git a/apps/photos/src/utils/comlink/ComlinkSearchWorker.ts b/apps/photos/src/utils/comlink/ComlinkSearchWorker.ts
new file mode 100644
index 000000000..927c2d1bc
--- /dev/null
+++ b/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;
+
+ 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();
diff --git a/apps/photos/src/utils/search/index.ts b/apps/photos/src/utils/search/index.ts
index 891c99254..6392e4840 100644
--- a/apps/photos/src/utils/search/index.ts
+++ b/apps/photos/src/utils/search/index.ts
@@ -1,6 +1,4 @@
-import { LocationTagData } from 'types/entity';
import { DateValue } from 'types/search';
-import { Location } from 'types/upload';
export const isSameDayAnyYear =
(baseDate: DateValue) => (compareDate: Date) => {
@@ -28,18 +26,3 @@ export function getFormattedDate(date: DateValue) {
new Date(date.year ?? 1, date.month ?? 1, date.date ?? 1)
);
}
-
-export function isInsideLocationTag(
- location: Location,
- locationTag: LocationTagData
-) {
- const { centerPoint, aSquare, bSquare } = locationTag;
- const { latitude, longitude } = location;
- const x = Math.abs(centerPoint.latitude - latitude);
- const y = Math.abs(centerPoint.longitude - longitude);
- if ((x * x) / aSquare + (y * y) / bSquare <= 1) {
- return true;
- } else {
- return false;
- }
-}
diff --git a/apps/photos/src/worker/search.worker.ts b/apps/photos/src/worker/search.worker.ts
new file mode 100644
index 000000000..f64b5031a
--- /dev/null
+++ b/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;
+}
diff --git a/packages/shared/constants/urls.ts b/packages/shared/constants/urls.ts
index 291704e98..122785cf5 100644
--- a/packages/shared/constants/urls.ts
+++ b/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 =
'https://github.com/ente-io/photos-desktop/issues';
+
+export const CITIES_URL = 'https://static.ente.io/world_cities.json';
diff --git a/packages/shared/hooks/useEffectSingleThreaded.tsx b/packages/shared/hooks/useEffectSingleThreaded.tsx
new file mode 100644
index 000000000..3bdebed5a
--- /dev/null
+++ b/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,
+ deps: any[]
+): void {
+ const updateInProgress = useRef(false);
+ const nextRequestDepsRef = useRef(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);
+}
diff --git a/packages/shared/utils/index.ts b/packages/shared/utils/index.ts
index f83937702..d65ef4380 100644
--- a/packages/shared/utils/index.ts
+++ b/packages/shared/utils/index.ts
@@ -22,3 +22,7 @@ export function downloadUsingAnchor(link: string, name: string) {
URL.revokeObjectURL(link);
a.remove();
}
+
+export function isPromise(obj: T | Promise): obj is Promise {
+ return obj && typeof (obj as any).then === 'function';
+}