Add support for searching by popular cities (#1578)
This commit is contained in:
commit
fd1b3eeff1
15 changed files with 392 additions and 209 deletions
|
@ -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)
|
||||
|
|
|
@ -206,6 +206,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "Album",
|
||||
"LOCATION": "Location",
|
||||
"CITY": "Location",
|
||||
"DATE": "Date",
|
||||
"FILE_NAME": "File name",
|
||||
"THING": "Content",
|
||||
|
|
|
@ -210,6 +210,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "Album",
|
||||
"LOCATION": "Location",
|
||||
"CITY": "Location",
|
||||
"DATE": "Date",
|
||||
"FILE_NAME": "File name",
|
||||
"THING": "Content",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 />;
|
||||
|
|
|
@ -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);
|
||||
|
|
97
apps/photos/src/services/locationSearchService.ts
Normal file
97
apps/photos/src/services/locationSearchService.ts
Normal 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));
|
||||
}
|
|
@ -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 };
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
30
apps/photos/src/utils/comlink/ComlinkSearchWorker.ts
Normal file
30
apps/photos/src/utils/comlink/ComlinkSearchWorker.ts
Normal 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();
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
75
apps/photos/src/worker/search.worker.ts
Normal file
75
apps/photos/src/worker/search.worker.ts
Normal 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;
|
||||
}
|
|
@ -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';
|
||||
|
|
33
packages/shared/hooks/useEffectSingleThreaded.tsx
Normal file
33
packages/shared/hooks/useEffectSingleThreaded.tsx
Normal 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);
|
||||
}
|
|
@ -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';
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue