Refactor search code
In prep for moving location tags handling to @/new
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { Collection } from "types/collection";
|
||||
import { SearchBarMobile } from "./searchBarMobile";
|
||||
|
||||
import { UpdateSearch } from "@/new/photos/services/search/types";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import { UpdateSearch } from "types/search";
|
||||
import SearchInput from "./searchInput";
|
||||
import { SearchBarWrapper } from "./styledComponents";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { PeopleList } from "@/new/photos/components/PeopleList";
|
||||
import { isMLEnabled } from "@/new/photos/services/ml";
|
||||
import { Suggestion, SuggestionType } from "@/new/photos/services/search/types";
|
||||
import { Row } from "@ente/shared/components/Container";
|
||||
import { Box, styled } from "@mui/material";
|
||||
import { t } from "i18next";
|
||||
import { components } from "react-select";
|
||||
import { Suggestion, SuggestionType } from "types/search";
|
||||
|
||||
const { Menu } = components;
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { FileType } from "@/media/file-type";
|
||||
import { isMLEnabled } from "@/new/photos/services/ml";
|
||||
import type {
|
||||
City,
|
||||
LocationTagData,
|
||||
SearchDateComponents,
|
||||
SearchPerson,
|
||||
} from "@/new/photos/services/search/types";
|
||||
import {
|
||||
ClipSearchScores,
|
||||
SearchOption,
|
||||
SearchQuery,
|
||||
SuggestionType,
|
||||
UpdateSearch,
|
||||
} from "@/new/photos/services/search/types";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { IconButton } from "@mui/material";
|
||||
@@ -15,20 +24,11 @@ import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { components } from "react-select";
|
||||
import AsyncSelect from "react-select/async";
|
||||
import { InputActionMeta } from "react-select/src/types";
|
||||
import { City } from "services/locationSearchService";
|
||||
import {
|
||||
getAutoCompleteSuggestions,
|
||||
getDefaultOptions,
|
||||
} from "services/searchService";
|
||||
import { Collection } from "types/collection";
|
||||
import { LocationTagData } from "types/entity";
|
||||
import {
|
||||
ClipSearchScores,
|
||||
Search,
|
||||
SearchOption,
|
||||
SuggestionType,
|
||||
UpdateSearch,
|
||||
} from "types/search";
|
||||
import { SelectStyles } from "../../../../styles/search";
|
||||
import { SearchInputWrapper } from "../styledComponents";
|
||||
import MenuWithPeople from "./MenuWithPeople";
|
||||
@@ -116,7 +116,7 @@ export default function SearchInput(props: Iprops) {
|
||||
if (!selectedOption) {
|
||||
return;
|
||||
}
|
||||
let search: Search;
|
||||
let search: SearchQuery;
|
||||
switch (selectedOption.type) {
|
||||
case SuggestionType.DATE:
|
||||
search = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SearchOption } from "@/new/photos/services/search/types";
|
||||
import {
|
||||
FreeFlowText,
|
||||
SpaceBetweenFlex,
|
||||
@@ -6,7 +7,6 @@ import { Box, Divider, Stack, Typography } from "@mui/material";
|
||||
import CollectionCard from "components/Collections/CollectionCard";
|
||||
import { ResultPreviewTile } from "components/Collections/styledComponents";
|
||||
import { t } from "i18next";
|
||||
import { SearchOption } from "types/search";
|
||||
|
||||
import { components } from "react-select";
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
SearchOption,
|
||||
SuggestionType,
|
||||
} from "@/new/photos/services/search/types";
|
||||
import { FlexWrapper } from "@ente/shared/components/Container";
|
||||
import CalendarIcon from "@mui/icons-material/CalendarMonth";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
@@ -7,7 +11,6 @@ import SearchIcon from "@mui/icons-material/SearchOutlined";
|
||||
import { Box } from "@mui/material";
|
||||
import { components } from "react-select";
|
||||
import { SelectComponents } from "react-select/src/components";
|
||||
import { SearchOption, SuggestionType } from "types/search";
|
||||
|
||||
const { ValueContainer } = components;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { SearchResultSummary } from "@/new/photos/services/search/types";
|
||||
import { Typography } from "@mui/material";
|
||||
import { CollectionInfo } from "components/Collections/CollectionInfo";
|
||||
import { CollectionInfoBarWrapper } from "components/Collections/styledComponents";
|
||||
import { t } from "i18next";
|
||||
import { SearchResultSummary } from "types/search";
|
||||
|
||||
interface Iprops {
|
||||
searchResultSummary: SearchResultSummary;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NavbarBase } from "@/base/components/Navbar";
|
||||
import { UpdateSearch } from "@/new/photos/services/search/types";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import { FlexWrapper, HorizontalFlex } from "@ente/shared/components/Container";
|
||||
import ArrowBack from "@mui/icons-material/ArrowBack";
|
||||
@@ -8,7 +9,6 @@ import SearchBar from "components/Search/SearchBar";
|
||||
import UploadButton from "components/Upload/UploadButton";
|
||||
import { t } from "i18next";
|
||||
import { Collection } from "types/collection";
|
||||
import { UpdateSearch } from "types/search";
|
||||
|
||||
interface Iprops {
|
||||
openSidebar: () => void;
|
||||
|
||||
@@ -4,13 +4,13 @@ import { CustomHead } from "@/base/components/Head";
|
||||
import { AppNavbar } from "@/base/components/Navbar";
|
||||
import { setupI18n } from "@/base/i18n";
|
||||
import log from "@/base/log";
|
||||
import { runMigrations } from "@/new/photos/services/migrations";
|
||||
import {
|
||||
logStartupBanner,
|
||||
logUnhandledErrorsAndRejections,
|
||||
} from "@/base/log-web";
|
||||
import { AppUpdate } from "@/base/types/ipc";
|
||||
import DownloadManager from "@/new/photos/services/download";
|
||||
import { runMigrations } from "@/new/photos/services/migrations";
|
||||
import { initML, isMLSupported } from "@/new/photos/services/ml";
|
||||
import { ensure } from "@/utils/ensure";
|
||||
import { Overlay } from "@ente/shared/components/Container";
|
||||
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
getLocalTrashedFiles,
|
||||
} from "@/new/photos/services/files";
|
||||
import { wipHasSwitchedOnceCmpAndSet } from "@/new/photos/services/ml";
|
||||
import { search, setSearchableFiles } from "@/new/photos/services/search";
|
||||
import {
|
||||
SearchQuery,
|
||||
SearchResultSummary,
|
||||
UpdateSearch,
|
||||
} from "@/new/photos/services/search/types";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import { mergeMetadata } from "@/new/photos/utils/file";
|
||||
import { CenteredFlex } from "@ente/shared/components/Container";
|
||||
@@ -31,7 +37,6 @@ import {
|
||||
getKey,
|
||||
} from "@ente/shared/storage/sessionStorage";
|
||||
import type { User } from "@ente/shared/user/types";
|
||||
import { isPromise } from "@ente/shared/utils";
|
||||
import { Typography, styled } from "@mui/material";
|
||||
import AuthenticateUserModal from "components/AuthenticateUserModal";
|
||||
import Collections from "components/Collections";
|
||||
@@ -106,7 +111,6 @@ import {
|
||||
SetFilesDownloadProgressAttributes,
|
||||
SetFilesDownloadProgressAttributesCreator,
|
||||
} from "types/gallery";
|
||||
import { Search, SearchResultSummary, UpdateSearch } from "types/search";
|
||||
import { FamilyData } from "types/user";
|
||||
import { checkSubscriptionPurchase } from "utils/billing";
|
||||
import {
|
||||
@@ -119,7 +123,6 @@ import {
|
||||
hasNonSystemCollections,
|
||||
splitNormalAndHiddenCollections,
|
||||
} from "utils/collection";
|
||||
import ComlinkSearchWorker from "utils/comlink/ComlinkSearchWorker";
|
||||
import {
|
||||
FILE_OPS_TYPE,
|
||||
constructFileToCollectionMap,
|
||||
@@ -193,7 +196,7 @@ export default function Gallery() {
|
||||
const [collectionNamerAttributes, setCollectionNamerAttributes] =
|
||||
useState<CollectionNamerAttributes>(null);
|
||||
const [collectionNamerView, setCollectionNamerView] = useState(false);
|
||||
const [search, setSearch] = useState<Search>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<SearchQuery>(null);
|
||||
const [shouldDisableDropzone, setShouldDisableDropzone] = useState(false);
|
||||
const [isPhotoSwipeOpen, setIsPhotoSwipeOpen] = useState(false);
|
||||
// TODO(MR): This is never true currently, this is the WIP ability to show
|
||||
@@ -400,13 +403,7 @@ export default function Gallery() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffectSingleThreaded(
|
||||
async ([files]: [files: EnteFile[]]) => {
|
||||
const searchWorker = await ComlinkSearchWorker.getInstance();
|
||||
await searchWorker.setFiles(files);
|
||||
},
|
||||
[files],
|
||||
);
|
||||
useEffect(() => setSearchableFiles(files), [files]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !files || !collections || !hiddenFiles || !trashedFiles) {
|
||||
@@ -523,11 +520,9 @@ export default function Gallery() {
|
||||
]);
|
||||
}
|
||||
|
||||
const searchWorker = await ComlinkSearchWorker.getInstance();
|
||||
|
||||
let filteredFiles: EnteFile[] = [];
|
||||
if (isInSearchMode) {
|
||||
filteredFiles = getUniqueFiles(await searchWorker.search(search));
|
||||
filteredFiles = getUniqueFiles(await search(searchQuery));
|
||||
} else {
|
||||
filteredFiles = getUniqueFiles(
|
||||
(isInHiddenSection ? hiddenFiles : files).filter((item) => {
|
||||
@@ -587,9 +582,9 @@ export default function Gallery() {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (search?.clip) {
|
||||
if (searchQuery?.clip) {
|
||||
return filteredFiles.sort((a, b) => {
|
||||
return search.clip.get(b.id) - search.clip.get(a.id);
|
||||
return searchQuery.clip.get(b.id) - searchQuery.clip.get(a.id);
|
||||
});
|
||||
}
|
||||
const sortAsc = activeCollection?.pubMagicMetadata?.data?.asc ?? false;
|
||||
@@ -605,7 +600,7 @@ export default function Gallery() {
|
||||
tempDeletedFileIds,
|
||||
tempHiddenFileIds,
|
||||
hiddenFileIds,
|
||||
search,
|
||||
searchQuery,
|
||||
activeCollectionID,
|
||||
archivedCollections,
|
||||
]);
|
||||
@@ -975,7 +970,7 @@ export default function Gallery() {
|
||||
if (newSearch?.collection) {
|
||||
setActiveCollectionID(newSearch?.collection);
|
||||
} else {
|
||||
setSearch(newSearch);
|
||||
setSearchQuery(newSearch);
|
||||
}
|
||||
setIsClipSearchResult(!!newSearch?.clip);
|
||||
if (!newSearch?.collection) {
|
||||
@@ -1243,37 +1238,6 @@ export default function Gallery() {
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload all three variants of a responsive image.
|
||||
*/
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import log from "@/base/log";
|
||||
import type { Location, LocationTagData } from "types/entity";
|
||||
|
||||
export interface City {
|
||||
city: string;
|
||||
country: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CITY_RADIUS = 10;
|
||||
const KMS_PER_DEGREE = 111.16;
|
||||
import type { City } from "@/new/photos/services/search/types";
|
||||
|
||||
class LocationSearchService {
|
||||
private cities: Array<City> = [];
|
||||
@@ -53,45 +43,3 @@ class LocationSearchService {
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -8,26 +8,27 @@ import {
|
||||
mlStatusSnapshot,
|
||||
wipSearchPersons,
|
||||
} from "@/new/photos/services/ml";
|
||||
import { parseDateComponents } from "@/new/photos/services/search";
|
||||
import { parseDateComponents, search } from "@/new/photos/services/search";
|
||||
import type {
|
||||
SearchDateComponents,
|
||||
SearchPerson,
|
||||
} from "@/new/photos/services/search/types";
|
||||
import {
|
||||
City,
|
||||
ClipSearchScores,
|
||||
LocationTagData,
|
||||
SearchOption,
|
||||
SearchQuery,
|
||||
Suggestion,
|
||||
SuggestionType,
|
||||
} from "@/new/photos/services/search/types";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import { t } from "i18next";
|
||||
import { Collection } from "types/collection";
|
||||
import { EntityType, LocationTag, LocationTagData } from "types/entity";
|
||||
import {
|
||||
ClipSearchScores,
|
||||
Search,
|
||||
SearchOption,
|
||||
Suggestion,
|
||||
SuggestionType,
|
||||
} from "types/search";
|
||||
import ComlinkSearchWorker from "utils/comlink/ComlinkSearchWorker";
|
||||
import { EntityType, LocationTag } from "types/entity";
|
||||
import { getUniqueFiles } from "utils/file";
|
||||
import { getLatestEntities } from "./entityService";
|
||||
import locationSearchService, { City } from "./locationSearchService";
|
||||
import locationSearchService from "./locationSearchService";
|
||||
|
||||
export const getDefaultOptions = async () => {
|
||||
return [
|
||||
@@ -64,13 +65,10 @@ export const getAutoCompleteSuggestions =
|
||||
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),
|
||||
);
|
||||
const resultFiles = getUniqueFiles(await search(searchQuery));
|
||||
if (searchQuery?.clip) {
|
||||
resultFiles.sort((a, b) => {
|
||||
const aScore = searchQuery.clip.get(a.id);
|
||||
@@ -304,7 +302,7 @@ const searchClip = async (
|
||||
return matches;
|
||||
};
|
||||
|
||||
function convertSuggestionToSearchQuery(option: Suggestion): Search {
|
||||
function convertSuggestionToSearchQuery(option: Suggestion): SearchQuery {
|
||||
switch (option.type) {
|
||||
case SuggestionType.DATE:
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { LocationTagData } from "@/new/photos/services/search/types";
|
||||
|
||||
export enum EntityType {
|
||||
LOCATION_TAG = "location",
|
||||
}
|
||||
@@ -25,19 +27,6 @@ export interface EncryptedEntity {
|
||||
userID: number;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
}
|
||||
|
||||
export interface LocationTagData {
|
||||
name: string;
|
||||
radius: number;
|
||||
aSquare: number;
|
||||
bSquare: number;
|
||||
centerPoint: Location;
|
||||
}
|
||||
|
||||
export type LocationTag = Entity<LocationTagData>;
|
||||
|
||||
export interface Entity<T>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { FileType } from "@/media/file-type";
|
||||
import type { MLStatus } from "@/new/photos/services/ml";
|
||||
import type {
|
||||
SearchDateComponents,
|
||||
SearchPerson,
|
||||
} from "@/new/photos/services/search/types";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import { City } from "services/locationSearchService";
|
||||
import { LocationTagData } from "types/entity";
|
||||
|
||||
export enum SuggestionType {
|
||||
DATE = "DATE",
|
||||
LOCATION = "LOCATION",
|
||||
COLLECTION = "COLLECTION",
|
||||
FILE_NAME = "FILE_NAME",
|
||||
PERSON = "PERSON",
|
||||
INDEX_STATUS = "INDEX_STATUS",
|
||||
FILE_CAPTION = "FILE_CAPTION",
|
||||
FILE_TYPE = "FILE_TYPE",
|
||||
CLIP = "CLIP",
|
||||
CITY = "CITY",
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
type: SuggestionType;
|
||||
label: string;
|
||||
value:
|
||||
| SearchDateComponents
|
||||
| number[]
|
||||
| SearchPerson
|
||||
| MLStatus
|
||||
| LocationTagData
|
||||
| City
|
||||
| FileType
|
||||
| ClipSearchScores;
|
||||
hide?: boolean;
|
||||
}
|
||||
|
||||
export type Search = {
|
||||
date?: SearchDateComponents;
|
||||
location?: LocationTagData;
|
||||
city?: City;
|
||||
collection?: number;
|
||||
files?: number[];
|
||||
person?: SearchPerson;
|
||||
fileType?: FileType;
|
||||
clip?: ClipSearchScores;
|
||||
};
|
||||
|
||||
export type SearchResultSummary = {
|
||||
optionName: string;
|
||||
fileCount: number;
|
||||
};
|
||||
|
||||
export interface SearchOption extends Suggestion {
|
||||
fileCount: number;
|
||||
previewFiles: EnteFile[];
|
||||
}
|
||||
|
||||
export type UpdateSearch = (
|
||||
search: Search,
|
||||
summary: SearchResultSummary,
|
||||
) => void;
|
||||
|
||||
export type ClipSearchScores = Map<number, number>;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { haveWindow } from "@/base/env";
|
||||
import { ComlinkWorker } from "@/base/worker/comlink-worker";
|
||||
import type { Remote } from "comlink";
|
||||
import { type DedicatedSearchWorker } from "worker/search.worker";
|
||||
|
||||
class ComlinkSearchWorker {
|
||||
private comlinkWorkerInstance: Remote<DedicatedSearchWorker>;
|
||||
private comlinkWorker: ComlinkWorker<typeof DedicatedSearchWorker>;
|
||||
|
||||
async getInstance() {
|
||||
if (!this.comlinkWorkerInstance) {
|
||||
if (!this.comlinkWorker)
|
||||
this.comlinkWorker = getDedicatedSearchWorker();
|
||||
this.comlinkWorkerInstance = await this.comlinkWorker.remote;
|
||||
}
|
||||
return this.comlinkWorkerInstance;
|
||||
}
|
||||
}
|
||||
|
||||
export const getDedicatedSearchWorker = () => {
|
||||
if (haveWindow()) {
|
||||
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,88 +0,0 @@
|
||||
import { getUICreationDate } from "@/media/file-metadata";
|
||||
import type { SearchDateComponents } from "@/new/photos/services/search/types";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import { getPublicMagicMetadataSync } from "@ente/shared/file-metadata";
|
||||
import * as Comlink from "comlink";
|
||||
import {
|
||||
isInsideCity,
|
||||
isInsideLocationTag,
|
||||
} from "services/locationSearchService";
|
||||
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 isDateComponentsMatch(
|
||||
search.date,
|
||||
getUICreationDate(file, getPublicMagicMetadataSync(file)),
|
||||
);
|
||||
}
|
||||
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 (typeof search?.fileType !== "undefined") {
|
||||
return search.fileType === file.metadata.fileType;
|
||||
}
|
||||
if (typeof search?.clip !== "undefined") {
|
||||
return search.clip.has(file.id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const isDateComponentsMatch = (
|
||||
{ year, month, day, weekday, hour }: SearchDateComponents,
|
||||
date: Date,
|
||||
) => {
|
||||
// Components are guaranteed to have at least one attribute present, so
|
||||
// start by assuming true.
|
||||
let match = true;
|
||||
|
||||
if (year) match = date.getFullYear() == year;
|
||||
// JS getMonth is 0-indexed.
|
||||
if (match && month) match = date.getMonth() + 1 == month;
|
||||
if (match && day) match = date.getDate() == day;
|
||||
if (match && weekday) match = date.getDay() == weekday;
|
||||
if (match && hour) match = date.getHours() == hour;
|
||||
|
||||
return match;
|
||||
};
|
||||
@@ -2,7 +2,44 @@ import { nullToUndefined } from "@/utils/transform";
|
||||
import type { Component } from "chrono-node";
|
||||
import * as chrono from "chrono-node";
|
||||
import i18n, { t } from "i18next";
|
||||
import type { SearchDateComponents } from "./types";
|
||||
import type { SearchDateComponents, SearchQuery } from "./types";
|
||||
|
||||
import { ComlinkWorker } from "@/base/worker/comlink-worker";
|
||||
import type { EnteFile } from "../../types/file";
|
||||
import type { SearchWorker } from "./worker";
|
||||
|
||||
/**
|
||||
* Cached instance of the {@link ComlinkWorker} that wraps our web worker.
|
||||
*/
|
||||
let _comlinkWorker: ComlinkWorker<typeof SearchWorker> | undefined;
|
||||
|
||||
/**
|
||||
* Lazily created, cached, instance of {@link SearchWorker}.
|
||||
*/
|
||||
const worker = () => (_comlinkWorker ??= createComlinkWorker()).remote;
|
||||
|
||||
/**
|
||||
* Create a new instance of a comlink worker that wraps a {@link SearchWorker}
|
||||
* web worker.
|
||||
*/
|
||||
const createComlinkWorker = () =>
|
||||
new ComlinkWorker<typeof SearchWorker>(
|
||||
"search",
|
||||
new Worker(new URL("worker.ts", import.meta.url)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Set the files over which we will search.
|
||||
*/
|
||||
export const setSearchableFiles = (enteFiles: EnteFile[]) =>
|
||||
void worker().then((w) => w.setEnteFiles(enteFiles));
|
||||
|
||||
/**
|
||||
* Search for and return the list of {@link EnteFile}s that match the given
|
||||
* {@link search} query.
|
||||
*/
|
||||
export const search = async (search: SearchQuery) =>
|
||||
worker().then((w) => w.search(search));
|
||||
|
||||
interface DateSearchResult {
|
||||
components: SearchDateComponents;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* @file types shared between the main thread interface to search (`index.ts`)
|
||||
* and the search worker (`worker.ts`)
|
||||
* and the search worker that does the actual searching (`worker.ts`).
|
||||
*/
|
||||
|
||||
import type { EnteFile } from "../../types/file";
|
||||
import { FileType } from "@/media/file-type";
|
||||
import type { MLStatus } from "@/new/photos/services/ml";
|
||||
import type { EnteFile } from "@/new/photos/types/file";
|
||||
|
||||
/**
|
||||
* A parsed version of a potential natural language date time string.
|
||||
@@ -48,3 +50,81 @@ export interface SearchPerson {
|
||||
displayFaceID: string;
|
||||
displayFaceFile: EnteFile;
|
||||
}
|
||||
|
||||
// TODO-cgroup: Audit below
|
||||
|
||||
export interface Location {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
}
|
||||
|
||||
export interface LocationTagData {
|
||||
name: string;
|
||||
radius: number;
|
||||
aSquare: number;
|
||||
bSquare: number;
|
||||
centerPoint: Location;
|
||||
}
|
||||
|
||||
export interface City {
|
||||
city: string;
|
||||
country: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export enum SuggestionType {
|
||||
DATE = "DATE",
|
||||
LOCATION = "LOCATION",
|
||||
COLLECTION = "COLLECTION",
|
||||
FILE_NAME = "FILE_NAME",
|
||||
PERSON = "PERSON",
|
||||
INDEX_STATUS = "INDEX_STATUS",
|
||||
FILE_CAPTION = "FILE_CAPTION",
|
||||
FILE_TYPE = "FILE_TYPE",
|
||||
CLIP = "CLIP",
|
||||
CITY = "CITY",
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
type: SuggestionType;
|
||||
label: string;
|
||||
value:
|
||||
| SearchDateComponents
|
||||
| number[]
|
||||
| SearchPerson
|
||||
| MLStatus
|
||||
| LocationTagData
|
||||
| City
|
||||
| FileType
|
||||
| ClipSearchScores;
|
||||
hide?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchQuery {
|
||||
date?: SearchDateComponents;
|
||||
location?: LocationTagData;
|
||||
city?: City;
|
||||
collection?: number;
|
||||
files?: number[];
|
||||
person?: SearchPerson;
|
||||
fileType?: FileType;
|
||||
clip?: ClipSearchScores;
|
||||
}
|
||||
|
||||
export interface SearchResultSummary {
|
||||
optionName: string;
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
export interface SearchOption extends Suggestion {
|
||||
fileCount: number;
|
||||
previewFiles: EnteFile[];
|
||||
}
|
||||
|
||||
export type UpdateSearch = (
|
||||
search: SearchQuery,
|
||||
summary: SearchResultSummary,
|
||||
) => void;
|
||||
|
||||
export type ClipSearchScores = Map<number, number>;
|
||||
|
||||
146
web/packages/new/photos/services/search/worker.ts
Normal file
146
web/packages/new/photos/services/search/worker.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// TODO-cgroups
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/prefer-includes */
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
import { getUICreationDate } from "@/media/file-metadata";
|
||||
import type {
|
||||
City,
|
||||
Location,
|
||||
LocationTagData,
|
||||
} from "@/new/photos/services/search/types";
|
||||
import type { EnteFile } from "@/new/photos/types/file";
|
||||
import { getPublicMagicMetadataSync } from "@ente/shared/file-metadata";
|
||||
import { expose } from "comlink";
|
||||
import type { SearchDateComponents, SearchQuery } from "./types";
|
||||
|
||||
/**
|
||||
* A web worker that runs the search asynchronously so that the main thread
|
||||
* remains responsive.
|
||||
*/
|
||||
export class SearchWorker {
|
||||
private enteFiles: EnteFile[] = [];
|
||||
|
||||
/**
|
||||
* Set the files that we should search across.
|
||||
*/
|
||||
setEnteFiles(enteFiles: EnteFile[]) {
|
||||
this.enteFiles = enteFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return {@link EnteFile}s that satisfy the given {@link searchQuery}
|
||||
* query.
|
||||
*/
|
||||
search(searchQuery: SearchQuery) {
|
||||
return this.enteFiles.filter((f) => isMatch(f, searchQuery));
|
||||
}
|
||||
}
|
||||
|
||||
expose(SearchWorker);
|
||||
|
||||
function isMatch(file: EnteFile, query: SearchQuery) {
|
||||
if (query?.collection) {
|
||||
return query.collection === file.collectionID;
|
||||
}
|
||||
|
||||
if (query?.date) {
|
||||
return isDateComponentsMatch(
|
||||
query.date,
|
||||
getUICreationDate(file, getPublicMagicMetadataSync(file)),
|
||||
);
|
||||
}
|
||||
if (query?.location) {
|
||||
return isInsideLocationTag(
|
||||
{
|
||||
latitude: file.metadata.latitude ?? null,
|
||||
longitude: file.metadata.longitude ?? null,
|
||||
},
|
||||
query.location,
|
||||
);
|
||||
}
|
||||
if (query?.city) {
|
||||
return isInsideCity(
|
||||
{
|
||||
latitude: file.metadata.latitude ?? null,
|
||||
longitude: file.metadata.longitude ?? null,
|
||||
},
|
||||
query.city,
|
||||
);
|
||||
}
|
||||
if (query?.files) {
|
||||
return query.files.indexOf(file.id) !== -1;
|
||||
}
|
||||
if (query?.person) {
|
||||
return query.person.files.indexOf(file.id) !== -1;
|
||||
}
|
||||
if (typeof query?.fileType !== "undefined") {
|
||||
return query.fileType === file.metadata.fileType;
|
||||
}
|
||||
if (typeof query?.clip !== "undefined") {
|
||||
return query.clip.has(file.id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const isDateComponentsMatch = (
|
||||
{ year, month, day, weekday, hour }: SearchDateComponents,
|
||||
date: Date,
|
||||
) => {
|
||||
// Components are guaranteed to have at least one attribute present, so
|
||||
// start by assuming true.
|
||||
let match = true;
|
||||
|
||||
if (year) match = date.getFullYear() == year;
|
||||
// JS getMonth is 0-indexed.
|
||||
if (match && month) match = date.getMonth() + 1 == month;
|
||||
if (match && day) match = date.getDate() == day;
|
||||
if (match && weekday) match = date.getDay() == weekday;
|
||||
if (match && hour) match = date.getHours() == hour;
|
||||
|
||||
return match;
|
||||
};
|
||||
|
||||
export function isInsideLocationTag(
|
||||
location: Location,
|
||||
locationTag: LocationTagData,
|
||||
) {
|
||||
return isLocationCloseToPoint(
|
||||
location,
|
||||
locationTag.centerPoint,
|
||||
locationTag.radius,
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_CITY_RADIUS = 10;
|
||||
const KMS_PER_DEGREE = 111.16;
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
decryptPublicMagicMetadata,
|
||||
type PublicMagicMetadata,
|
||||
} from "@/media/file-metadata";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import type { EnteFile } from "@/new/photos/types/file";
|
||||
import { fileLogID } from "@/new/photos/utils/file";
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user