Add support for searching by popular cities (#1578)

This commit is contained in:
Vishnu Mohandas 2024-01-24 13:51:11 +05:30 committed by GitHub
commit fd1b3eeff1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 392 additions and 209 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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,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<SearchOption[]> {
const searchWorker = await ComlinkSearchWorker.getInstance();
const previewImageAppendedOptions: SearchOption[] = [];
for (const suggestion of suggestions) {
const searchQuery = convertSuggestionToSearchQuery(suggestion);
const resultFiles = getUniqueFiles(
await searchWorker.search(searchQuery)
);
if (searchQuery?.clip) {
resultFiles.sort((a, b) => {
const aScore = searchQuery.clip.get(a.id);
const bScore = searchQuery.clip.get(b.id);
return bScore - aScore;
});
}
if (resultFiles.length) {
previewImageAppendedOptions.push({
...suggestion,
fileCount: resultFiles.length,
previewFiles: resultFiles.slice(0, 3),
};
})
.filter((option) => option.fileCount);
});
}
}
return previewImageAppendedOptions;
}
function getFileTypeSuggestion(searchPhrase: string): Suggestion[] {
@ -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<Suggestion[]> {
@ -406,48 +420,6 @@ async function searchClip(searchPhrase: string): Promise<ClipSearchScores> {
return clipSearchResult;
}
function isSearchedFile(file: EnteFile, search: Search) {
if (search?.collection) {
return search.collection === file.collectionID;
}
if (search?.date) {
return isSameDayAnyYear(search.date)(
new Date(file.metadata.creationTime / 1000)
);
}
if (search?.location) {
return isInsideLocationTag(
{
latitude: file.metadata.latitude,
longitude: file.metadata.longitude,
},
search.location
);
}
if (search?.files) {
return search.files.indexOf(file.id) !== -1;
}
if (search?.person) {
return search.person.files.indexOf(file.id) !== -1;
}
if (search?.thing) {
return search.thing.files.indexOf(file.id) !== -1;
}
if (search?.text) {
return search.text.files.indexOf(file.id) !== -1;
}
if (typeof search?.fileType !== 'undefined') {
return search.fileType === file.metadata.fileType;
}
if (typeof search?.clip !== 'undefined') {
return search.clip.has(file.id);
}
return false;
}
function convertSuggestionToSearchQuery(option: Suggestion): Search {
switch (option.type) {
case SuggestionType.DATE:
@ -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 };

View file

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

View file

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

View file

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

View file

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

View file

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

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