[web] More search related refactoring (#3162)
Preparing for showing cgroups in the search dropdown.
This commit is contained in:
@@ -1,37 +0,0 @@
|
||||
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 SearchInput from "./searchInput";
|
||||
import { SearchBarWrapper } from "./styledComponents";
|
||||
|
||||
interface Props {
|
||||
updateSearch: UpdateSearch;
|
||||
collections: Collection[];
|
||||
files: EnteFile[];
|
||||
isInSearchMode: boolean;
|
||||
setIsInSearchMode: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export default function SearchBar({
|
||||
setIsInSearchMode,
|
||||
isInSearchMode,
|
||||
...props
|
||||
}: Props) {
|
||||
const showSearchInput = () => setIsInSearchMode(true);
|
||||
|
||||
return (
|
||||
<SearchBarWrapper>
|
||||
<SearchInput
|
||||
{...props}
|
||||
isOpen={isInSearchMode}
|
||||
setIsOpen={setIsInSearchMode}
|
||||
/>
|
||||
<SearchBarMobile
|
||||
show={!isInSearchMode}
|
||||
showSearchInput={showSearchInput}
|
||||
/>
|
||||
</SearchBarWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { FluidContainer } from "@ente/shared/components/Container";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import { IconButton } from "@mui/material";
|
||||
import { SearchMobileBox } from "./styledComponents";
|
||||
|
||||
export function SearchBarMobile({ show, showSearchInput }) {
|
||||
if (!show) {
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<SearchMobileBox>
|
||||
<FluidContainer justifyContent="flex-end" ml={1.5}>
|
||||
<IconButton onClick={showSearchInput}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</FluidContainer>
|
||||
</SearchMobileBox>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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";
|
||||
|
||||
const { Menu } = components;
|
||||
|
||||
const Legend = styled("span")`
|
||||
font-size: 20px;
|
||||
color: #ddd;
|
||||
display: inline;
|
||||
padding: 0px 12px;
|
||||
`;
|
||||
|
||||
const Caption = styled("span")`
|
||||
font-size: 12px;
|
||||
display: inline;
|
||||
padding: 0px 12px;
|
||||
`;
|
||||
|
||||
const MenuWithPeople = (props) => {
|
||||
// log.info("props.selectProps.options: ", selectRef);
|
||||
const peopleSuggestions = props.selectProps.options.filter(
|
||||
(o) => o.type === SuggestionType.PERSON,
|
||||
);
|
||||
const people = peopleSuggestions.map((o) => o.value);
|
||||
|
||||
const indexStatusSuggestion = props.selectProps.options.filter(
|
||||
(o) => o.type === SuggestionType.INDEX_STATUS,
|
||||
)[0] as Suggestion;
|
||||
|
||||
const indexStatus = indexStatusSuggestion?.value;
|
||||
return (
|
||||
<Menu {...props}>
|
||||
<Box my={1}>
|
||||
{isMLEnabled() &&
|
||||
indexStatus &&
|
||||
(people && people.length > 0 ? (
|
||||
<Box>
|
||||
<Legend>{t("people")}</Legend>
|
||||
</Box>
|
||||
) : (
|
||||
<Box height={6} />
|
||||
))}
|
||||
|
||||
{isMLEnabled() && indexStatus && (
|
||||
<Box>
|
||||
<Caption>{indexStatusSuggestion.label}</Caption>
|
||||
</Box>
|
||||
)}
|
||||
{people && people.length > 0 && (
|
||||
<Row>
|
||||
<PeopleList
|
||||
people={people}
|
||||
maxRows={2}
|
||||
onSelect={(_, index) => {
|
||||
props.selectRef.current.blur();
|
||||
props.setValue(peopleSuggestions[index]);
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
</Box>
|
||||
{props.children}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuWithPeople;
|
||||
@@ -1,217 +0,0 @@
|
||||
import { FileType } from "@/media/file-type";
|
||||
import { isMLEnabled } from "@/new/photos/services/ml";
|
||||
import type {
|
||||
City,
|
||||
SearchDateComponents,
|
||||
SearchPerson,
|
||||
} from "@/new/photos/services/search/types";
|
||||
import {
|
||||
ClipSearchScores,
|
||||
SearchOption,
|
||||
SearchQuery,
|
||||
SuggestionType,
|
||||
UpdateSearch,
|
||||
} from "@/new/photos/services/search/types";
|
||||
import type { LocationTag } from "@/new/photos/services/user-entity";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { IconButton } from "@mui/material";
|
||||
import { t } from "i18next";
|
||||
import memoize from "memoize-one";
|
||||
import pDebounce from "p-debounce";
|
||||
import { AppContext } from "pages/_app";
|
||||
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 {
|
||||
getAutoCompleteSuggestions,
|
||||
getDefaultOptions,
|
||||
} from "services/searchService";
|
||||
import { Collection } from "types/collection";
|
||||
import { SelectStyles } from "../../../../styles/search";
|
||||
import { SearchInputWrapper } from "../styledComponents";
|
||||
import MenuWithPeople from "./MenuWithPeople";
|
||||
import { OptionWithInfo } from "./optionWithInfo";
|
||||
import { ValueContainerWithIcon } from "./valueContainerWithIcon";
|
||||
|
||||
interface Iprops {
|
||||
isOpen: boolean;
|
||||
updateSearch: UpdateSearch;
|
||||
setIsOpen: (value: boolean) => void;
|
||||
files: EnteFile[];
|
||||
collections: Collection[];
|
||||
}
|
||||
|
||||
const createComponents = memoize((Option, ValueContainer, Menu, Input) => ({
|
||||
Option,
|
||||
ValueContainer,
|
||||
Menu,
|
||||
Input,
|
||||
}));
|
||||
|
||||
const VisibleInput = (props) => (
|
||||
<components.Input {...props} isHidden={false} />
|
||||
);
|
||||
|
||||
export default function SearchInput(props: Iprops) {
|
||||
const selectRef = useRef(null);
|
||||
const [value, setValue] = useState<SearchOption>(null);
|
||||
const appContext = useContext(AppContext);
|
||||
const handleChange = (value: SearchOption) => {
|
||||
setValue(value);
|
||||
setQuery(value?.label);
|
||||
|
||||
blur();
|
||||
};
|
||||
const handleInputChange = (value: string, actionMeta: InputActionMeta) => {
|
||||
if (actionMeta.action === "input-change") {
|
||||
setQuery(value);
|
||||
}
|
||||
};
|
||||
const [defaultOptions, setDefaultOptions] = useState([]);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
search(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshDefaultOptions();
|
||||
const t = setInterval(() => refreshDefaultOptions(), 2000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
async function refreshDefaultOptions() {
|
||||
const defaultOptions = await getDefaultOptions();
|
||||
setDefaultOptions(defaultOptions);
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
if (props.isOpen) {
|
||||
appContext.startLoading();
|
||||
props.updateSearch(null, null);
|
||||
setTimeout(() => {
|
||||
appContext.finishLoading();
|
||||
}, 10);
|
||||
props.setIsOpen(false);
|
||||
setValue(null);
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
const getOptions = useCallback(
|
||||
pDebounce(
|
||||
getAutoCompleteSuggestions(props.files, props.collections),
|
||||
250,
|
||||
),
|
||||
[props.files, props.collections],
|
||||
);
|
||||
|
||||
const blur = () => {
|
||||
selectRef.current?.blur();
|
||||
};
|
||||
|
||||
const search = (selectedOption: SearchOption) => {
|
||||
if (!selectedOption) {
|
||||
return;
|
||||
}
|
||||
let search: SearchQuery;
|
||||
switch (selectedOption.type) {
|
||||
case SuggestionType.DATE:
|
||||
search = {
|
||||
date: selectedOption.value as SearchDateComponents,
|
||||
};
|
||||
props.setIsOpen(true);
|
||||
break;
|
||||
case SuggestionType.LOCATION:
|
||||
search = {
|
||||
location: selectedOption.value as LocationTag,
|
||||
};
|
||||
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);
|
||||
setQuery("");
|
||||
break;
|
||||
case SuggestionType.FILE_NAME:
|
||||
search = { files: selectedOption.value as number[] };
|
||||
break;
|
||||
case SuggestionType.FILE_CAPTION:
|
||||
search = { files: selectedOption.value as number[] };
|
||||
break;
|
||||
case SuggestionType.PERSON:
|
||||
search = { person: selectedOption.value as SearchPerson };
|
||||
break;
|
||||
case SuggestionType.FILE_TYPE:
|
||||
search = { fileType: selectedOption.value as FileType };
|
||||
break;
|
||||
case SuggestionType.CLIP:
|
||||
search = { clip: selectedOption.value as ClipSearchScores };
|
||||
}
|
||||
props.updateSearch(search, {
|
||||
optionName: selectedOption.label,
|
||||
fileCount: selectedOption.fileCount,
|
||||
});
|
||||
};
|
||||
|
||||
// TODO: HACK as AsyncSelect does not support default options reloading on focus/click
|
||||
// unwanted side effect: placeholder is not shown on focus/click
|
||||
// https://github.com/JedWatson/react-select/issues/1879
|
||||
// for correct fix AsyncSelect can be extended to support default options reloading on focus/click
|
||||
const handleOnFocus = () => {
|
||||
refreshDefaultOptions();
|
||||
};
|
||||
|
||||
const MemoizedMenuWithPeople = useCallback(
|
||||
(props) => (
|
||||
<MenuWithPeople
|
||||
{...props}
|
||||
setValue={setValue}
|
||||
selectRef={selectRef}
|
||||
/>
|
||||
),
|
||||
[setValue, selectRef],
|
||||
);
|
||||
|
||||
const components = createComponents(
|
||||
OptionWithInfo,
|
||||
ValueContainerWithIcon,
|
||||
MemoizedMenuWithPeople,
|
||||
VisibleInput,
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchInputWrapper isOpen={props.isOpen}>
|
||||
<AsyncSelect
|
||||
ref={selectRef}
|
||||
value={value}
|
||||
components={components}
|
||||
placeholder={<span>{t("search_hint")}</span>}
|
||||
loadOptions={getOptions}
|
||||
onChange={handleChange}
|
||||
onFocus={handleOnFocus}
|
||||
isClearable
|
||||
inputValue={query}
|
||||
onInputChange={handleInputChange}
|
||||
escapeClearsValue
|
||||
styles={SelectStyles}
|
||||
defaultOptions={isMLEnabled() ? defaultOptions : null}
|
||||
noOptionsMessage={() => null}
|
||||
/>
|
||||
|
||||
{props.isOpen && (
|
||||
<IconButton onClick={() => resetSearch()} sx={{ ml: 1 }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</SearchInputWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { SearchOption } from "@/new/photos/services/search/types";
|
||||
import { labelForSuggestionType } from "@/new/photos/services/search/ui";
|
||||
import {
|
||||
FreeFlowText,
|
||||
SpaceBetweenFlex,
|
||||
} from "@ente/shared/components/Container";
|
||||
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 { components } from "react-select";
|
||||
|
||||
const { Option } = components;
|
||||
|
||||
export const OptionWithInfo = (props) => (
|
||||
<Option {...props}>
|
||||
<LabelWithInfo data={props.data} />
|
||||
</Option>
|
||||
);
|
||||
|
||||
const LabelWithInfo = ({ data }: { data: SearchOption }) => {
|
||||
return (
|
||||
!data.hide && (
|
||||
<>
|
||||
<Box className="main" px={2} py={1}>
|
||||
<Typography variant="mini" mb={1}>
|
||||
{labelForSuggestionType(data.type)}
|
||||
</Typography>
|
||||
<SpaceBetweenFlex>
|
||||
<Box mr={1}>
|
||||
<FreeFlowText>
|
||||
<Typography fontWeight={"bold"}>
|
||||
{data.label}
|
||||
</Typography>
|
||||
</FreeFlowText>
|
||||
<Typography color="text.muted">
|
||||
{t("photos_count", { count: data.fileCount })}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction={"row"} spacing={1}>
|
||||
{data.previewFiles.map((file) => (
|
||||
<CollectionCard
|
||||
key={file.id}
|
||||
coverFile={file}
|
||||
onClick={() => null}
|
||||
collectionTile={ResultPreviewTile}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SpaceBetweenFlex>
|
||||
</Box>
|
||||
<Divider sx={{ mx: 2, my: 1 }} />
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
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";
|
||||
import ImageIcon from "@mui/icons-material/Image";
|
||||
import LocationIcon from "@mui/icons-material/LocationOn";
|
||||
import SearchIcon from "@mui/icons-material/SearchOutlined";
|
||||
import { Box } from "@mui/material";
|
||||
import { components } from "react-select";
|
||||
import { SelectComponents } from "react-select/src/components";
|
||||
|
||||
const { ValueContainer } = components;
|
||||
|
||||
const getIconByType = (type: SuggestionType) => {
|
||||
switch (type) {
|
||||
case SuggestionType.DATE:
|
||||
return <CalendarIcon />;
|
||||
case SuggestionType.LOCATION:
|
||||
case SuggestionType.CITY:
|
||||
return <LocationIcon />;
|
||||
case SuggestionType.COLLECTION:
|
||||
return <FolderIcon />;
|
||||
case SuggestionType.FILE_NAME:
|
||||
return <ImageIcon />;
|
||||
default:
|
||||
return <SearchIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
export const ValueContainerWithIcon: SelectComponents<
|
||||
SearchOption,
|
||||
false
|
||||
>["ValueContainer"] = (props) => (
|
||||
<ValueContainer {...props}>
|
||||
<FlexWrapper>
|
||||
<Box
|
||||
style={{ display: "inline-flex" }}
|
||||
mr={1.5}
|
||||
color={(theme) => theme.colors.stroke.muted}
|
||||
>
|
||||
{getIconByType(props.getValue()[0]?.type)}
|
||||
</Box>
|
||||
{props.children}
|
||||
</FlexWrapper>
|
||||
</ValueContainer>
|
||||
);
|
||||
@@ -1,39 +0,0 @@
|
||||
import {
|
||||
CenteredFlex,
|
||||
FlexWrapper,
|
||||
FluidContainer,
|
||||
} from "@ente/shared/components/Container";
|
||||
import { css, styled } from "@mui/material";
|
||||
import { IMAGE_CONTAINER_MAX_WIDTH, MIN_COLUMNS } from "constants/gallery";
|
||||
|
||||
export const SearchBarWrapper = styled(FlexWrapper)`
|
||||
padding: 0 24px;
|
||||
@media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * MIN_COLUMNS}px) {
|
||||
padding: 0 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const SearchMobileBox = styled(FluidContainer)`
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@media (min-width: 625px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const SearchInputWrapper = styled(CenteredFlex, {
|
||||
shouldForwardProp: (propName) => propName != "isOpen",
|
||||
})<{ isOpen: boolean }>`
|
||||
background: ${({ theme }) => theme.colors.background.base};
|
||||
max-width: 484px;
|
||||
margin: auto;
|
||||
${(props) =>
|
||||
!props.isOpen &&
|
||||
css`
|
||||
@media (max-width: 624px) {
|
||||
display: none;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
@@ -1,25 +0,0 @@
|
||||
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";
|
||||
|
||||
interface Iprops {
|
||||
searchResultSummary: SearchResultSummary;
|
||||
}
|
||||
export default function SearchResultInfo({ searchResultSummary }: Iprops) {
|
||||
if (!searchResultSummary) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const { optionName, fileCount } = searchResultSummary;
|
||||
|
||||
return (
|
||||
<CollectionInfoBarWrapper>
|
||||
<Typography color="text.muted" variant="large">
|
||||
{t("search_results")}
|
||||
</Typography>
|
||||
<CollectionInfo name={optionName} fileCount={fileCount} />
|
||||
</CollectionInfoBarWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { styled } from "@mui/material";
|
||||
const SearchStatsContainer = styled("div")(
|
||||
({ theme }) => `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #979797;
|
||||
margin: ${theme.spacing(1, 0)};
|
||||
`,
|
||||
);
|
||||
|
||||
export default SearchStatsContainer;
|
||||
459
web/apps/photos/src/components/SearchBar.tsx
Normal file
459
web/apps/photos/src/components/SearchBar.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import { FileType } from "@/media/file-type";
|
||||
import { PeopleList } from "@/new/photos/components/PeopleList";
|
||||
import { isMLEnabled } from "@/new/photos/services/ml";
|
||||
import type {
|
||||
City,
|
||||
SearchDateComponents,
|
||||
SearchPerson,
|
||||
} from "@/new/photos/services/search/types";
|
||||
import {
|
||||
ClipSearchScores,
|
||||
SearchOption,
|
||||
SearchQuery,
|
||||
Suggestion,
|
||||
SuggestionType,
|
||||
UpdateSearch,
|
||||
} from "@/new/photos/services/search/types";
|
||||
import { labelForSuggestionType } from "@/new/photos/services/search/ui";
|
||||
import type { LocationTag } from "@/new/photos/services/user-entity";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import {
|
||||
CenteredFlex,
|
||||
FlexWrapper,
|
||||
FluidContainer,
|
||||
FreeFlowText,
|
||||
Row,
|
||||
SpaceBetweenFlex,
|
||||
} from "@ente/shared/components/Container";
|
||||
import CalendarIcon from "@mui/icons-material/CalendarMonth";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import ImageIcon from "@mui/icons-material/Image";
|
||||
import LocationIcon from "@mui/icons-material/LocationOn";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import {
|
||||
Box,
|
||||
css,
|
||||
Divider,
|
||||
IconButton,
|
||||
Stack,
|
||||
styled,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import CollectionCard from "components/Collections/CollectionCard";
|
||||
import { ResultPreviewTile } from "components/Collections/styledComponents";
|
||||
import { IMAGE_CONTAINER_MAX_WIDTH, MIN_COLUMNS } from "constants/gallery";
|
||||
import { t } from "i18next";
|
||||
import memoize from "memoize-one";
|
||||
import pDebounce from "p-debounce";
|
||||
import { AppContext } from "pages/_app";
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { components } from "react-select";
|
||||
import AsyncSelect from "react-select/async";
|
||||
import { SelectComponents } from "react-select/src/components";
|
||||
import { InputActionMeta } from "react-select/src/types";
|
||||
import {
|
||||
getAutoCompleteSuggestions,
|
||||
getDefaultOptions,
|
||||
} from "services/searchService";
|
||||
import { Collection } from "types/collection";
|
||||
import { SelectStyles } from "../styles/search";
|
||||
|
||||
const { Option, ValueContainer, Menu } = components;
|
||||
|
||||
interface SearchBarProps {
|
||||
updateSearch: UpdateSearch;
|
||||
collections: Collection[];
|
||||
files: EnteFile[];
|
||||
isInSearchMode: boolean;
|
||||
setIsInSearchMode: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export default function SearchBar({
|
||||
setIsInSearchMode,
|
||||
isInSearchMode,
|
||||
...props
|
||||
}: SearchBarProps) {
|
||||
const showSearchInput = () => setIsInSearchMode(true);
|
||||
|
||||
return (
|
||||
<SearchBarWrapper>
|
||||
<SearchInput
|
||||
{...props}
|
||||
isOpen={isInSearchMode}
|
||||
setIsOpen={setIsInSearchMode}
|
||||
/>
|
||||
<SearchBarMobile
|
||||
show={!isInSearchMode}
|
||||
showSearchInput={showSearchInput}
|
||||
/>
|
||||
</SearchBarWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const SearchBarWrapper = styled(FlexWrapper)`
|
||||
padding: 0 24px;
|
||||
@media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * MIN_COLUMNS}px) {
|
||||
padding: 0 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface SearchInputProps {
|
||||
isOpen: boolean;
|
||||
updateSearch: UpdateSearch;
|
||||
setIsOpen: (value: boolean) => void;
|
||||
files: EnteFile[];
|
||||
collections: Collection[];
|
||||
}
|
||||
|
||||
const createComponents = memoize((Option, ValueContainer, Menu, Input) => ({
|
||||
Option,
|
||||
ValueContainer,
|
||||
Menu,
|
||||
Input,
|
||||
}));
|
||||
|
||||
export const SearchInput: React.FC<SearchInputProps> = (props) => {
|
||||
const selectRef = useRef(null);
|
||||
const [value, setValue] = useState<SearchOption>(null);
|
||||
const appContext = useContext(AppContext);
|
||||
const handleChange = (value: SearchOption) => {
|
||||
setValue(value);
|
||||
setQuery(value?.label);
|
||||
|
||||
blur();
|
||||
};
|
||||
const handleInputChange = (value: string, actionMeta: InputActionMeta) => {
|
||||
if (actionMeta.action === "input-change") {
|
||||
setQuery(value);
|
||||
}
|
||||
};
|
||||
const [defaultOptions, setDefaultOptions] = useState([]);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
search(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshDefaultOptions();
|
||||
const t = setInterval(() => refreshDefaultOptions(), 2000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
async function refreshDefaultOptions() {
|
||||
const defaultOptions = await getDefaultOptions();
|
||||
setDefaultOptions(defaultOptions);
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
if (props.isOpen) {
|
||||
appContext.startLoading();
|
||||
props.updateSearch(null, null);
|
||||
setTimeout(() => {
|
||||
appContext.finishLoading();
|
||||
}, 10);
|
||||
props.setIsOpen(false);
|
||||
setValue(null);
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
const getOptions = useCallback(
|
||||
pDebounce(
|
||||
getAutoCompleteSuggestions(props.files, props.collections),
|
||||
250,
|
||||
),
|
||||
[props.files, props.collections],
|
||||
);
|
||||
|
||||
const blur = () => {
|
||||
selectRef.current?.blur();
|
||||
};
|
||||
|
||||
const search = (selectedOption: SearchOption) => {
|
||||
if (!selectedOption) {
|
||||
return;
|
||||
}
|
||||
let search: SearchQuery;
|
||||
switch (selectedOption.type) {
|
||||
case SuggestionType.DATE:
|
||||
search = {
|
||||
date: selectedOption.value as SearchDateComponents,
|
||||
};
|
||||
props.setIsOpen(true);
|
||||
break;
|
||||
case SuggestionType.LOCATION:
|
||||
search = {
|
||||
location: selectedOption.value as LocationTag,
|
||||
};
|
||||
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);
|
||||
setQuery("");
|
||||
break;
|
||||
case SuggestionType.FILE_NAME:
|
||||
search = { files: selectedOption.value as number[] };
|
||||
break;
|
||||
case SuggestionType.FILE_CAPTION:
|
||||
search = { files: selectedOption.value as number[] };
|
||||
break;
|
||||
case SuggestionType.PERSON:
|
||||
search = { person: selectedOption.value as SearchPerson };
|
||||
break;
|
||||
case SuggestionType.FILE_TYPE:
|
||||
search = { fileType: selectedOption.value as FileType };
|
||||
break;
|
||||
case SuggestionType.CLIP:
|
||||
search = { clip: selectedOption.value as ClipSearchScores };
|
||||
}
|
||||
props.updateSearch(search, {
|
||||
optionName: selectedOption.label,
|
||||
fileCount: selectedOption.fileCount,
|
||||
});
|
||||
};
|
||||
|
||||
// TODO: HACK as AsyncSelect does not support default options reloading on focus/click
|
||||
// unwanted side effect: placeholder is not shown on focus/click
|
||||
// https://github.com/JedWatson/react-select/issues/1879
|
||||
// for correct fix AsyncSelect can be extended to support default options reloading on focus/click
|
||||
const handleOnFocus = () => {
|
||||
refreshDefaultOptions();
|
||||
};
|
||||
|
||||
const MemoizedMenuWithPeople = useCallback(
|
||||
(props) => (
|
||||
<MenuWithPeople
|
||||
{...props}
|
||||
setValue={setValue}
|
||||
selectRef={selectRef}
|
||||
/>
|
||||
),
|
||||
[setValue, selectRef],
|
||||
);
|
||||
|
||||
const components = createComponents(
|
||||
OptionWithInfo,
|
||||
ValueContainerWithIcon,
|
||||
MemoizedMenuWithPeople,
|
||||
VisibleInput,
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchInputWrapper isOpen={props.isOpen}>
|
||||
<AsyncSelect
|
||||
ref={selectRef}
|
||||
value={value}
|
||||
components={components}
|
||||
placeholder={<span>{t("search_hint")}</span>}
|
||||
loadOptions={getOptions}
|
||||
onChange={handleChange}
|
||||
onFocus={handleOnFocus}
|
||||
isClearable
|
||||
inputValue={query}
|
||||
onInputChange={handleInputChange}
|
||||
escapeClearsValue
|
||||
styles={SelectStyles}
|
||||
defaultOptions={isMLEnabled() ? defaultOptions : null}
|
||||
noOptionsMessage={() => null}
|
||||
/>
|
||||
|
||||
{props.isOpen && (
|
||||
<IconButton onClick={() => resetSearch()} sx={{ ml: 1 }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</SearchInputWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchInputWrapper = styled(CenteredFlex, {
|
||||
shouldForwardProp: (propName) => propName != "isOpen",
|
||||
})<{ isOpen: boolean }>`
|
||||
background: ${({ theme }) => theme.colors.background.base};
|
||||
max-width: 484px;
|
||||
margin: auto;
|
||||
${(props) =>
|
||||
!props.isOpen &&
|
||||
css`
|
||||
@media (max-width: 624px) {
|
||||
display: none;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const OptionWithInfo = (props) => (
|
||||
<Option {...props}>
|
||||
<LabelWithInfo data={props.data} />
|
||||
</Option>
|
||||
);
|
||||
|
||||
const LabelWithInfo = ({ data }: { data: SearchOption }) => {
|
||||
return (
|
||||
!data.hide && (
|
||||
<>
|
||||
<Box className="main" px={2} py={1}>
|
||||
<Typography variant="mini" mb={1}>
|
||||
{labelForSuggestionType(data.type)}
|
||||
</Typography>
|
||||
<SpaceBetweenFlex>
|
||||
<Box mr={1}>
|
||||
<FreeFlowText>
|
||||
<Typography fontWeight={"bold"}>
|
||||
{data.label}
|
||||
</Typography>
|
||||
</FreeFlowText>
|
||||
<Typography color="text.muted">
|
||||
{t("photos_count", { count: data.fileCount })}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction={"row"} spacing={1}>
|
||||
{data.previewFiles.map((file) => (
|
||||
<CollectionCard
|
||||
key={file.id}
|
||||
coverFile={file}
|
||||
onClick={() => null}
|
||||
collectionTile={ResultPreviewTile}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SpaceBetweenFlex>
|
||||
</Box>
|
||||
<Divider sx={{ mx: 2, my: 1 }} />
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const ValueContainerWithIcon: SelectComponents<
|
||||
SearchOption,
|
||||
false
|
||||
>["ValueContainer"] = (props) => (
|
||||
<ValueContainer {...props}>
|
||||
<FlexWrapper>
|
||||
<Box
|
||||
style={{ display: "inline-flex" }}
|
||||
mr={1.5}
|
||||
color={(theme) => theme.colors.stroke.muted}
|
||||
>
|
||||
{getIconByType(props.getValue()[0]?.type)}
|
||||
</Box>
|
||||
{props.children}
|
||||
</FlexWrapper>
|
||||
</ValueContainer>
|
||||
);
|
||||
|
||||
const getIconByType = (type: SuggestionType) => {
|
||||
switch (type) {
|
||||
case SuggestionType.DATE:
|
||||
return <CalendarIcon />;
|
||||
case SuggestionType.LOCATION:
|
||||
case SuggestionType.CITY:
|
||||
return <LocationIcon />;
|
||||
case SuggestionType.COLLECTION:
|
||||
return <FolderIcon />;
|
||||
case SuggestionType.FILE_NAME:
|
||||
return <ImageIcon />;
|
||||
default:
|
||||
return <SearchIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
export const MenuWithPeople = (props) => {
|
||||
// log.info("props.selectProps.options: ", selectRef);
|
||||
const peopleSuggestions = props.selectProps.options.filter(
|
||||
(o) => o.type === SuggestionType.PERSON,
|
||||
);
|
||||
const people = peopleSuggestions.map((o) => o.value);
|
||||
|
||||
const indexStatusSuggestion = props.selectProps.options.filter(
|
||||
(o) => o.type === SuggestionType.INDEX_STATUS,
|
||||
)[0] as Suggestion;
|
||||
|
||||
const indexStatus = indexStatusSuggestion?.value;
|
||||
return (
|
||||
<Menu {...props}>
|
||||
<Box my={1}>
|
||||
{isMLEnabled() &&
|
||||
indexStatus &&
|
||||
(people && people.length > 0 ? (
|
||||
<Box>
|
||||
<Legend>{t("people")}</Legend>
|
||||
</Box>
|
||||
) : (
|
||||
<Box height={6} />
|
||||
))}
|
||||
|
||||
{isMLEnabled() && indexStatus && (
|
||||
<Box>
|
||||
<Caption>{indexStatusSuggestion.label}</Caption>
|
||||
</Box>
|
||||
)}
|
||||
{people && people.length > 0 && (
|
||||
<Row>
|
||||
<PeopleList
|
||||
people={people}
|
||||
maxRows={2}
|
||||
onSelect={(_, index) => {
|
||||
props.selectRef.current.blur();
|
||||
props.setValue(peopleSuggestions[index]);
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
</Box>
|
||||
{props.children}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const Legend = styled("span")`
|
||||
font-size: 20px;
|
||||
color: #ddd;
|
||||
display: inline;
|
||||
padding: 0px 12px;
|
||||
`;
|
||||
|
||||
const Caption = styled("span")`
|
||||
font-size: 12px;
|
||||
display: inline;
|
||||
padding: 0px 12px;
|
||||
`;
|
||||
|
||||
const VisibleInput = (props) => (
|
||||
<components.Input {...props} isHidden={false} />
|
||||
);
|
||||
|
||||
function SearchBarMobile({ show, showSearchInput }) {
|
||||
if (!show) {
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<SearchMobileBox>
|
||||
<FluidContainer justifyContent="flex-end" ml={1.5}>
|
||||
<IconButton onClick={showSearchInput}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</FluidContainer>
|
||||
</SearchMobileBox>
|
||||
);
|
||||
}
|
||||
|
||||
const SearchMobileBox = styled(FluidContainer)`
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@media (min-width: 625px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
@@ -5,7 +5,7 @@ import { FlexWrapper, HorizontalFlex } from "@ente/shared/components/Container";
|
||||
import ArrowBack from "@mui/icons-material/ArrowBack";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import { IconButton, Typography } from "@mui/material";
|
||||
import SearchBar from "components/Search/SearchBar";
|
||||
import SearchBar from "components/SearchBar";
|
||||
import UploadButton from "components/Upload/UploadButton";
|
||||
import { t } from "i18next";
|
||||
import { Collection } from "types/collection";
|
||||
|
||||
@@ -40,12 +40,14 @@ import type { User } from "@ente/shared/user/types";
|
||||
import { Typography, styled } from "@mui/material";
|
||||
import AuthenticateUserModal from "components/AuthenticateUserModal";
|
||||
import Collections from "components/Collections";
|
||||
import { CollectionInfo } from "components/Collections/CollectionInfo";
|
||||
import CollectionNamer, {
|
||||
CollectionNamerAttributes,
|
||||
} from "components/Collections/CollectionNamer";
|
||||
import CollectionSelector, {
|
||||
CollectionSelectorAttributes,
|
||||
} from "components/Collections/CollectionSelector";
|
||||
import { CollectionInfoBarWrapper } from "components/Collections/styledComponents";
|
||||
import ExportModal from "components/ExportModal";
|
||||
import {
|
||||
FilesDownloadProgress,
|
||||
@@ -59,7 +61,6 @@ import GalleryEmptyState from "components/GalleryEmptyState";
|
||||
import { LoadingOverlay } from "components/LoadingOverlay";
|
||||
import PhotoFrame from "components/PhotoFrame";
|
||||
import { ITEM_TYPE, TimeStampListItem } from "components/PhotoList";
|
||||
import SearchResultInfo from "components/Search/SearchResultInfo";
|
||||
import Sidebar from "components/Sidebar";
|
||||
import type { UploadTypeSelectorIntent } from "components/Upload/UploadTypeSelector";
|
||||
import Uploader from "components/Upload/Uploader";
|
||||
@@ -480,7 +481,7 @@ export default function Gallery() {
|
||||
setPhotoListHeader({
|
||||
height: 104,
|
||||
item: (
|
||||
<SearchResultInfo
|
||||
<SearchResultSummaryHeader
|
||||
searchResultSummary={searchResultSummary}
|
||||
/>
|
||||
),
|
||||
@@ -1252,3 +1253,26 @@ const mergeMaps = <K, V>(map1: Map<K, V>, map2: Map<K, V>) => {
|
||||
});
|
||||
return mergedMap;
|
||||
};
|
||||
|
||||
interface SearchResultSummaryHeaderProps {
|
||||
searchResultSummary: SearchResultSummary;
|
||||
}
|
||||
|
||||
const SearchResultSummaryHeader: React.FC<SearchResultSummaryHeaderProps> = ({
|
||||
searchResultSummary,
|
||||
}) => {
|
||||
if (!searchResultSummary) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const { optionName, fileCount } = searchResultSummary;
|
||||
|
||||
return (
|
||||
<CollectionInfoBarWrapper>
|
||||
<Typography color="text.muted" variant="large">
|
||||
{t("search_results")}
|
||||
</Typography>
|
||||
<CollectionInfo name={optionName} fileCount={fileCount} />
|
||||
</CollectionInfoBarWrapper>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import log from "@/base/log";
|
||||
import DownloadManager from "@/new/photos/services/download";
|
||||
import { clearFeatureFlagSessionState } from "@/new/photos/services/feature-flags";
|
||||
import { logoutML, terminateMLWorker } from "@/new/photos/services/ml";
|
||||
import { logoutSearch } from "@/new/photos/services/search";
|
||||
import exportService from "./export";
|
||||
|
||||
/**
|
||||
@@ -18,13 +19,13 @@ export const photosLogout = async () => {
|
||||
|
||||
// - Workers
|
||||
|
||||
// Terminate any workers before clearing persistent state.
|
||||
// See: [Note: Caching IDB instances in separate execution contexts].
|
||||
// Terminate any workers that might access the DB before clearing persistent
|
||||
// state. See: [Note: Caching IDB instances in separate execution contexts].
|
||||
|
||||
try {
|
||||
await terminateMLWorker();
|
||||
} catch (e) {
|
||||
ignoreError("face", e);
|
||||
ignoreError("ml/worker", e);
|
||||
}
|
||||
|
||||
// - Remote logout and clear state
|
||||
@@ -47,6 +48,12 @@ export const photosLogout = async () => {
|
||||
ignoreError("download", e);
|
||||
}
|
||||
|
||||
try {
|
||||
logoutSearch();
|
||||
} catch (e) {
|
||||
ignoreError("search", e);
|
||||
}
|
||||
|
||||
// - Desktop
|
||||
|
||||
const electron = globalThis.electron;
|
||||
|
||||
@@ -47,8 +47,8 @@ export const getAutoCompleteSuggestions =
|
||||
// - getClipSuggestion(searchPhrase)
|
||||
// - getDateSuggestion(searchPhrase),
|
||||
// - getLocationSuggestion(searchPhrase),
|
||||
// - getFileTypeSuggestion(searchPhrase),
|
||||
...(await createSearchQuery(searchPhrase)),
|
||||
...getFileTypeSuggestion(searchPhrase2),
|
||||
...getCollectionSuggestion(searchPhrase2, collections),
|
||||
getFileNameSuggestion(searchPhrase2, files),
|
||||
getFileCaptionSuggestion(searchPhrase2, files),
|
||||
@@ -85,27 +85,6 @@ async function convertSuggestionsToOptions(
|
||||
}
|
||||
return previewImageAppendedOptions;
|
||||
}
|
||||
function getFileTypeSuggestion(searchPhrase: string): Suggestion[] {
|
||||
return [
|
||||
{
|
||||
label: t("IMAGE"),
|
||||
value: FileType.image,
|
||||
type: SuggestionType.FILE_TYPE,
|
||||
},
|
||||
{
|
||||
label: t("VIDEO"),
|
||||
value: FileType.video,
|
||||
type: SuggestionType.FILE_TYPE,
|
||||
},
|
||||
{
|
||||
label: t("LIVE_PHOTO"),
|
||||
value: FileType.livePhoto,
|
||||
type: SuggestionType.FILE_TYPE,
|
||||
},
|
||||
].filter((suggestion) =>
|
||||
suggestion.label.toLowerCase().includes(searchPhrase),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllPeopleSuggestion(): Promise<Array<Suggestion>> {
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { masterKeyFromSession } from "@/base/session-store";
|
||||
import { pullCGroups } from "../user-entity";
|
||||
|
||||
/**
|
||||
* A cgroup ("cluster group") is a group of clusters (possibly containing a
|
||||
* single cluster) that the user has interacted with.
|
||||
@@ -18,11 +21,14 @@
|
||||
* cluster, or they may hide an named {@link CGroup}. In both cases, we promote
|
||||
* the cluster to a CGroup if needed so that their request to hide gets synced.
|
||||
*
|
||||
* cgroups are synced with remote.
|
||||
*
|
||||
* While in our local representation we separately maintain clusters and link to
|
||||
* them from within CGroups by their clusterID, in the remote representation
|
||||
* clusters themselves don't get synced. Instead, the "cgroup" entities synced
|
||||
* with remote contain the clusters within themselves. So a group that gets
|
||||
* synced with remote looks something like:
|
||||
* clusters themselves don't get synced. Instead, the cgroup entities synced
|
||||
* with remote contain the clusters within themselves.
|
||||
*
|
||||
* That is, a cgroup that gets synced with remote looks something like:
|
||||
*
|
||||
* { id, name, clusters: [{ clusterID, faceIDs }] }
|
||||
*
|
||||
@@ -63,7 +69,7 @@ export interface CGroup {
|
||||
isHidden: boolean;
|
||||
/**
|
||||
* The ID of the face that should be used as the cover photo for this
|
||||
* cluster group (if the user has set one).
|
||||
* cluster group. Optional.
|
||||
*
|
||||
* This is similar to the [@link displayFaceID}, the difference being:
|
||||
*
|
||||
@@ -73,6 +79,13 @@ export interface CGroup {
|
||||
* into effect if the user has not explicitly selected a face.
|
||||
*/
|
||||
avatarFaceID: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link CGroup} annotated with various in-memory state to make it easier for
|
||||
* the upper layers of our code to directly use it.
|
||||
*/
|
||||
export type AnnotatedCGroup = CGroup & {
|
||||
/**
|
||||
* Locally determined ID of the "best" face that should be used as the
|
||||
* display face, to represent this cluster group in the UI.
|
||||
@@ -81,7 +94,7 @@ export interface CGroup {
|
||||
* {@link avatarFaceID}.
|
||||
*/
|
||||
displayFaceID: string | undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Syncronize the user's cluster groups with remote, running local clustering if
|
||||
@@ -109,7 +122,7 @@ export interface CGroup {
|
||||
* - They can hide a cluster. This creates an unnamed cgroup so that the
|
||||
* user's other clients know not to show it.
|
||||
*/
|
||||
export const syncCGroups = () => {
|
||||
export const syncCGroups = async () => {
|
||||
// 1. Fetch existing cgroups for the user from remote.
|
||||
// 2. Save them to DB.
|
||||
// 3. Prune stale faceIDs from the clusters in the DB.
|
||||
@@ -118,7 +131,10 @@ export const syncCGroups = () => {
|
||||
//
|
||||
// The user can see both the cgroups and clusters in the UI, but only the
|
||||
// cgroups are synced.
|
||||
// const syncCGroupsWithRemote()
|
||||
|
||||
const masterKey = await masterKeyFromSession();
|
||||
await pullCGroups(masterKey);
|
||||
|
||||
/*
|
||||
* After clustering, we also do some routine cleanup. Faces belonging to files
|
||||
* that have been deleted (including those in Trash) should be pruned off.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { newNonSecureID } from "@/base/id-worker";
|
||||
import log from "@/base/log";
|
||||
import { ensure } from "@/utils/ensure";
|
||||
import type { EnteFile } from "../../types/file";
|
||||
import type { CGroup } from "./cgroups";
|
||||
import type { AnnotatedCGroup } from "./cgroups";
|
||||
import { faceDirection, type Face, type FaceIndex } from "./face";
|
||||
import { dotProduct } from "./math";
|
||||
|
||||
@@ -199,7 +199,7 @@ export const clusterFaces = (
|
||||
// locally, so cgroups will be empty. Create a temporary (unsaved, unsynced)
|
||||
// cgroup, one per cluster.
|
||||
|
||||
const cgroups: CGroup[] = [];
|
||||
const cgroups: AnnotatedCGroup[] = [];
|
||||
for (const cluster of sortedClusters) {
|
||||
const faces = cluster.faceIDs.map((id) =>
|
||||
ensure(faceForFaceID.get(id)),
|
||||
|
||||
@@ -32,17 +32,17 @@ import type { LocalFaceIndex } from "./face";
|
||||
* In tandem, these serve as the underlying storage for the indexes maintained
|
||||
* in the ML database.
|
||||
*
|
||||
* The cluster related object stores are the following:
|
||||
* The face clustering related object stores are the following:
|
||||
*
|
||||
* - "face-cluster": Contains {@link FaceCluster} objects, one for each
|
||||
* cluster of faces that either the clustering algorithm produced locally or
|
||||
* were synced from remote. It is indexed by the (cluster) ID.
|
||||
* were synced from remote. It is indexed by the cluster ID.
|
||||
*
|
||||
* - "cluster-group": Contains {@link CGroup} objects, one for each group of
|
||||
* clusters that were synced from remote. The client can also locally
|
||||
* generate cluster groups on certain user interactions, but these too will
|
||||
* eventually get synced with remote. This object store is indexed by the
|
||||
* (cgroup) ID.
|
||||
* cgroup ID.
|
||||
*/
|
||||
interface MLDBSchema extends DBSchema {
|
||||
"file-status": {
|
||||
@@ -441,24 +441,3 @@ export const applyCGroupDiff = async (diff: (string | CGroup)[]) => {
|
||||
);
|
||||
return tx.done;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add or overwrite the entry for the given {@link cgroup}, as identified by
|
||||
* their {@link id}.
|
||||
*/
|
||||
// TODO-Cluster: Remove me
|
||||
export const saveClusterGroup = async (cgroup: CGroup) => {
|
||||
const db = await mlDB();
|
||||
const tx = db.transaction("cluster-group", "readwrite");
|
||||
await Promise.all([tx.store.put(cgroup), tx.done]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete the entry (if any) for the cluster group with the given {@link id}.
|
||||
*/
|
||||
// TODO-Cluster: Remove me
|
||||
export const deleteClusterGroup = async (id: string) => {
|
||||
const db = await mlDB();
|
||||
const tx = db.transaction("cluster-group", "readwrite");
|
||||
await Promise.all([tx.store.delete(id), tx.done]);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { isDesktop } from "@/base/app";
|
||||
import { masterKeyFromSession } from "@/base/session-store";
|
||||
import { ComlinkWorker } from "@/base/worker/comlink-worker";
|
||||
import { FileType } from "@/media/file-type";
|
||||
import i18n, { t } from "i18next";
|
||||
import type { EnteFile } from "../../types/file";
|
||||
import { clipMatches, isMLEnabled } from "../ml";
|
||||
import {
|
||||
SuggestionType,
|
||||
type DateSearchResult,
|
||||
type LabelledFileType,
|
||||
type LocalizedSearchData,
|
||||
type SearchQuery,
|
||||
} from "./types";
|
||||
import type { SearchWorker } from "./worker";
|
||||
@@ -31,6 +34,17 @@ const createComlinkWorker = () =>
|
||||
new Worker(new URL("worker.ts", import.meta.url)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Perform any logout specific cleanup for the search subsystem.
|
||||
*/
|
||||
export const logoutSearch = () => {
|
||||
if (_comlinkWorker) {
|
||||
_comlinkWorker.terminate();
|
||||
_comlinkWorker = undefined;
|
||||
}
|
||||
_localizedSearchData = undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch any data that would be needed if the user were to search.
|
||||
*/
|
||||
@@ -59,7 +73,7 @@ export const createSearchQuery = async (searchString: string) => {
|
||||
// the search worker, then combine the two.
|
||||
const results = await Promise.all([
|
||||
clipSuggestions(s, searchString).then((s) => s ?? []),
|
||||
worker().then((w) => w.createSearchQuery(s, i18n.language, holidays())),
|
||||
worker().then((w) => w.createSearchQuery(s, localizedSearchData())),
|
||||
]);
|
||||
return results.flat();
|
||||
};
|
||||
@@ -85,12 +99,40 @@ export const search = async (search: SearchQuery) =>
|
||||
worker().then((w) => w.search(search));
|
||||
|
||||
/**
|
||||
* A list of holidays - their yearly dates and localized names.
|
||||
* Cached value of {@link localizedSearchData}.
|
||||
*/
|
||||
let _localizedSearchData: LocalizedSearchData | undefined;
|
||||
|
||||
/*
|
||||
* For searching, the web worker needs a bunch of otherwise static data that has
|
||||
* names and labels formed by localized strings.
|
||||
*
|
||||
* We need to keep this on the main thread since it uses the t() function for
|
||||
* localization (although I haven't tried that in a web worker, it might work
|
||||
* there too). Also, it cannot be a const since it needs to be evaluated lazily
|
||||
* for the t() to work.
|
||||
* Since it would be tricky to get the t() function to work in a web worker, we
|
||||
* instead pass this from the main thread (lazily initialized and cached).
|
||||
*
|
||||
* Note that these need to be evaluated at runtime, and cannot be static
|
||||
* constants since t() depends on the user's locale.
|
||||
*
|
||||
* We currently clear the cached data on logout, but this is not necessary. The
|
||||
* only point we necessarily need to clear this data is if the user changes their
|
||||
* preferred locale, but currently we reload the page in such cases so any in
|
||||
* memory state would be reset that way.
|
||||
*/
|
||||
const localizedSearchData = () =>
|
||||
(_localizedSearchData ??= {
|
||||
locale: i18n.language,
|
||||
holidays: holidays().map((h) => ({
|
||||
...h,
|
||||
lowercasedName: h.label.toLowerCase(),
|
||||
})),
|
||||
labelledFileTypes: labelledFileTypes().map((t) => ({
|
||||
...t,
|
||||
lowercasedName: t.label.toLowerCase(),
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* A list of holidays - their yearly dates and localized names.
|
||||
*/
|
||||
const holidays = (): DateSearchResult[] => [
|
||||
{ components: { month: 12, day: 25 }, label: t("CHRISTMAS") },
|
||||
@@ -98,3 +140,12 @@ const holidays = (): DateSearchResult[] => [
|
||||
{ components: { month: 1, day: 1 }, label: t("NEW_YEAR") },
|
||||
{ components: { month: 12, day: 31 }, label: t("NEW_YEAR_EVE") },
|
||||
];
|
||||
|
||||
/**
|
||||
* A list of file types with their localized names.
|
||||
*/
|
||||
const labelledFileTypes = (): LabelledFileType[] => [
|
||||
{ fileType: FileType.image, label: t("IMAGE") },
|
||||
{ fileType: FileType.video, label: t("VIDEO") },
|
||||
{ fileType: FileType.livePhoto, label: t("LIVE_PHOTO") },
|
||||
];
|
||||
|
||||
@@ -14,6 +14,35 @@ export interface DateSearchResult {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface LabelledFileType {
|
||||
fileType: FileType;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An annotated version of {@link T} that includes its searchable "lowercased"
|
||||
* label or name.
|
||||
*
|
||||
* Precomputing these lowercased values saves us from doing the lowercasing
|
||||
* during the search itself.
|
||||
*/
|
||||
export type Searchable<T> = T & {
|
||||
/**
|
||||
* The name or label of T, lowercased.
|
||||
*/
|
||||
lowercasedName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Various bits of static but locale specific data that the search worker needs
|
||||
* during searching.
|
||||
*/
|
||||
export interface LocalizedSearchData {
|
||||
locale: string;
|
||||
holidays: Searchable<DateSearchResult>[];
|
||||
labelledFileTypes: Searchable<LabelledFileType>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed version of a potential natural language date time string.
|
||||
*
|
||||
|
||||
@@ -9,42 +9,30 @@ import * as chrono from "chrono-node";
|
||||
import { expose } from "comlink";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
pullLocationTags,
|
||||
savedLocationTags,
|
||||
syncLocationTags,
|
||||
type LocationTag,
|
||||
} from "../user-entity";
|
||||
import type {
|
||||
City,
|
||||
DateSearchResult,
|
||||
LabelledFileType,
|
||||
LocalizedSearchData,
|
||||
Searchable,
|
||||
SearchDateComponents,
|
||||
SearchQuery,
|
||||
Suggestion,
|
||||
} from "./types";
|
||||
import { SuggestionType } from "./types";
|
||||
|
||||
type SearchableCity = City & {
|
||||
/**
|
||||
* Name of the city, lowercased. Precomputed to save an op during search.
|
||||
*/
|
||||
lowercasedName: string;
|
||||
};
|
||||
|
||||
type SearchableLocationTag = LocationTag & {
|
||||
/**
|
||||
* Name of the location tag, lowercased. Precomputed to save an op during
|
||||
* search.
|
||||
*/
|
||||
lowercasedName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A web worker that runs the search asynchronously so that the main thread
|
||||
* remains responsive.
|
||||
*/
|
||||
export class SearchWorker {
|
||||
private enteFiles: EnteFile[] = [];
|
||||
private locationTags: SearchableLocationTag[] = [];
|
||||
private cities: SearchableCity[] = [];
|
||||
private locationTags: Searchable<LocationTag>[] = [];
|
||||
private cities: Searchable<City>[] = [];
|
||||
|
||||
/**
|
||||
* Fetch any state we might need when the actual search happens.
|
||||
@@ -54,7 +42,7 @@ export class SearchWorker {
|
||||
*/
|
||||
async sync(masterKey: Uint8Array) {
|
||||
return Promise.all([
|
||||
syncLocationTags(masterKey)
|
||||
pullLocationTags(masterKey)
|
||||
.then(() => savedLocationTags())
|
||||
.then((ts) => {
|
||||
this.locationTags = ts.map((t) => ({
|
||||
@@ -81,11 +69,10 @@ export class SearchWorker {
|
||||
/**
|
||||
* Convert a search string into a reusable query.
|
||||
*/
|
||||
createSearchQuery(s: string, locale: string, holidays: DateSearchResult[]) {
|
||||
createSearchQuery(s: string, localizedSearchData: LocalizedSearchData) {
|
||||
return createSearchQuery(
|
||||
s,
|
||||
locale,
|
||||
holidays,
|
||||
localizedSearchData,
|
||||
this.locationTags,
|
||||
this.cities,
|
||||
);
|
||||
@@ -103,20 +90,20 @@ expose(SearchWorker);
|
||||
|
||||
const createSearchQuery = (
|
||||
s: string,
|
||||
locale: string,
|
||||
holidays: DateSearchResult[],
|
||||
locationTags: SearchableLocationTag[],
|
||||
cities: SearchableCity[],
|
||||
{ locale, holidays, labelledFileTypes }: LocalizedSearchData,
|
||||
locationTags: Searchable<LocationTag>[],
|
||||
cities: Searchable<City>[],
|
||||
): Suggestion[] =>
|
||||
[
|
||||
dateSuggestions(s, locale, holidays),
|
||||
locationSuggestions(s, locationTags, cities),
|
||||
fileTypeSuggestions(s, labelledFileTypes),
|
||||
].flat();
|
||||
|
||||
const dateSuggestions = (
|
||||
s: string,
|
||||
locale: string,
|
||||
holidays: DateSearchResult[],
|
||||
holidays: Searchable<DateSearchResult>[],
|
||||
) =>
|
||||
parseDateComponents(s, locale, holidays).map(({ components, label }) => ({
|
||||
type: SuggestionType.DATE,
|
||||
@@ -141,12 +128,12 @@ const dateSuggestions = (
|
||||
const parseDateComponents = (
|
||||
s: string,
|
||||
locale: string,
|
||||
holidays: DateSearchResult[],
|
||||
holidays: Searchable<DateSearchResult>[],
|
||||
): DateSearchResult[] =>
|
||||
[
|
||||
parseChrono(s, locale),
|
||||
parseYearComponents(s),
|
||||
parseHolidayComponents(s, holidays),
|
||||
holidays.filter(searchableIncludes(s)),
|
||||
].flat();
|
||||
|
||||
const parseChrono = (s: string, locale: string): DateSearchResult[] =>
|
||||
@@ -195,8 +182,13 @@ const parseYearComponents = (s: string): DateSearchResult[] => {
|
||||
return [];
|
||||
};
|
||||
|
||||
const parseHolidayComponents = (s: string, holidays: DateSearchResult[]) =>
|
||||
holidays.filter(({ label }) => label.toLowerCase().includes(s));
|
||||
/**
|
||||
* A helper function to directly pass to filters on Searchable<T>[].
|
||||
*/
|
||||
const searchableIncludes =
|
||||
(s: string) =>
|
||||
({ lowercasedName }: { lowercasedName: string }) =>
|
||||
lowercasedName.includes(s);
|
||||
|
||||
/**
|
||||
* Zod schema describing world_cities.json.
|
||||
@@ -223,12 +215,10 @@ const fetchCities = async () => {
|
||||
|
||||
const locationSuggestions = (
|
||||
s: string,
|
||||
locationTags: SearchableLocationTag[],
|
||||
cities: SearchableCity[],
|
||||
locationTags: Searchable<LocationTag>[],
|
||||
cities: Searchable<City>[],
|
||||
) => {
|
||||
const matchingLocationTags = locationTags.filter((t) =>
|
||||
t.lowercasedName.includes(s),
|
||||
);
|
||||
const matchingLocationTags = locationTags.filter(searchableIncludes(s));
|
||||
|
||||
const matchingLocationTagLNames = new Set(
|
||||
matchingLocationTags.map((t) => t.lowercasedName),
|
||||
@@ -254,6 +244,18 @@ const locationSuggestions = (
|
||||
].flat();
|
||||
};
|
||||
|
||||
const fileTypeSuggestions = (
|
||||
s: string,
|
||||
labelledFileTypes: Searchable<LabelledFileType>[],
|
||||
) =>
|
||||
labelledFileTypes
|
||||
.filter(searchableIncludes(s))
|
||||
.map(({ fileType, label }) => ({
|
||||
label,
|
||||
value: fileType,
|
||||
type: SuggestionType.FILE_TYPE,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Return true if file satisfies the given {@link query}.
|
||||
*/
|
||||
|
||||
@@ -35,16 +35,16 @@ export type EntityType =
|
||||
| "cgroup";
|
||||
|
||||
/**
|
||||
* Sync our local location tags with those on remote.
|
||||
* Update our local location tags with changes from remote.
|
||||
*
|
||||
* This function fetches all the location tag user entities from remote and
|
||||
* updates our local database. It uses local state to remember the last time it
|
||||
* synced, so each subsequent sync is a lightweight diff.
|
||||
* updates our local database. It uses local state to remember the latest entry
|
||||
* the last time it did a pull, so each subsequent pull is a lightweight diff.
|
||||
*
|
||||
* @param masterKey The user's master key. This is used to encrypt and decrypt
|
||||
* the location tags specific entity key.
|
||||
*/
|
||||
export const syncLocationTags = async (masterKey: Uint8Array) => {
|
||||
export const pullLocationTags = async (masterKey: Uint8Array) => {
|
||||
const decoder = new TextDecoder();
|
||||
const parse = (id: string, data: Uint8Array): LocationTag => ({
|
||||
id,
|
||||
@@ -63,7 +63,7 @@ export const syncLocationTags = async (masterKey: Uint8Array) => {
|
||||
return saveLocationTags([...existingTagsByID.values()]);
|
||||
};
|
||||
|
||||
return syncUserEntity("location", masterKey, processBatch);
|
||||
return pullUserEntities("location", masterKey, processBatch);
|
||||
};
|
||||
|
||||
/** Zod schema for the tag that we get from or put to remote. */
|
||||
@@ -89,7 +89,7 @@ const saveLocationTags = (tags: LocationTag[]) =>
|
||||
/**
|
||||
* Return all the location tags that are present locally.
|
||||
*
|
||||
* Use {@link syncLocationTags} to sync this list with remote.
|
||||
* Use {@link pullLocationTags} to synchronize this list with remote.
|
||||
*/
|
||||
export const savedLocationTags = async () =>
|
||||
LocalLocationTag.array().parse(
|
||||
@@ -97,7 +97,7 @@ export const savedLocationTags = async () =>
|
||||
);
|
||||
|
||||
/**
|
||||
* Sync the {@link CGroup} entities that we have locally with remote.
|
||||
* Update our local cgroups with changes from remote.
|
||||
*
|
||||
* This fetches all the user entities corresponding to the "cgroup" entity type
|
||||
* from remote that have been created, updated or deleted since the last time we
|
||||
@@ -108,16 +108,15 @@ export const savedLocationTags = async () =>
|
||||
* @param masterKey The user's master key. This is used to encrypt and decrypt
|
||||
* the cgroup specific entity key.
|
||||
*/
|
||||
export const syncCGroups = (masterKey: Uint8Array) => {
|
||||
export const pullCGroups = (masterKey: Uint8Array) => {
|
||||
const parse = async (id: string, data: Uint8Array): Promise<CGroup> => {
|
||||
const rp = RemoteCGroup.parse(JSON.parse(await gunzip(data)));
|
||||
const r = RemoteCGroup.parse(JSON.parse(await gunzip(data)));
|
||||
return {
|
||||
id,
|
||||
name: rp.name,
|
||||
clusterIDs: rp.assigned.map(({ id }) => id),
|
||||
isHidden: rp.isHidden,
|
||||
avatarFaceID: rp.avatarFaceID,
|
||||
displayFaceID: undefined,
|
||||
name: r.name,
|
||||
clusterIDs: r.assigned.map(({ id }) => id),
|
||||
isHidden: r.isHidden,
|
||||
avatarFaceID: r.avatarFaceID,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -130,7 +129,7 @@ export const syncCGroups = (masterKey: Uint8Array) => {
|
||||
),
|
||||
);
|
||||
|
||||
return syncUserEntity("cgroup", masterKey, processBatch);
|
||||
return pullUserEntities("cgroup", masterKey, processBatch);
|
||||
};
|
||||
|
||||
const RemoteCGroup = z.object({
|
||||
@@ -141,6 +140,8 @@ const RemoteCGroup = z.object({
|
||||
faces: z.string().array(),
|
||||
}),
|
||||
),
|
||||
// The remote cgroup also has a "rejected" property, but that is not
|
||||
// currently used by any of the clients.
|
||||
isHidden: z.boolean(),
|
||||
avatarFaceID: z.string().nullish().transform(nullToUndefined),
|
||||
});
|
||||
@@ -209,7 +210,7 @@ interface UserEntityChange {
|
||||
* The user's {@link masterKey} is used to decrypt (or encrypt, when generating
|
||||
* a new one) the entity key.
|
||||
*/
|
||||
const syncUserEntity = async (
|
||||
const pullUserEntities = async (
|
||||
type: EntityType,
|
||||
masterKey: Uint8Array,
|
||||
processBatch: (entities: UserEntityChange[]) => Promise<void>,
|
||||
|
||||
Reference in New Issue
Block a user