feat(server): search unknown place (#10866)

* Allow submission of null country

* Update searchAssetBuilder to handle nulls

andWhere({country:null}) produces `"exifInfo"."country" = NULL`. We want
`"exifInfo"."country" IS NULL`, so we have to treat NULL as a special
case

* Allow null country in frontend

* Make the query code a bit more straightforward

* Remove unused brackets import

* Remove log message

* Don't change whitespace for no reason

* Fix prettier style issue

* Update search.dto.ts validators per @jrasm91's recommendation

* Update api types

* Combine null country and state into one guard clause

* chore: clean up

* chore: add e2e for null/empty city, state, country search

* refactor: server returns suggestion for null values

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Justin Forseth 2024-08-01 21:27:40 -06:00 committed by GitHub
parent 3afb5b497f
commit d3a5490e71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 378 additions and 217 deletions

View file

@ -4,9 +4,16 @@
value: string;
};
export function toComboBoxOptions(items: string[]) {
return items.map<ComboBoxOption>((item) => ({ label: item, value: item }));
}
export const asComboboxOptions = (values: string[]) =>
values.map((value) => {
if (value === '') {
return { label: get(t)('unknown'), value: '' };
}
return { label: value, value };
});
export const asSelectedOption = (value?: string) => (value === undefined ? undefined : asComboboxOptions([value])[0]);
</script>
<script lang="ts">
@ -21,6 +28,7 @@
import { generateId } from '$lib/utils/generate-id';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
export let label: string;
export let hideLabel = false;

View file

@ -6,9 +6,9 @@
</script>
<script lang="ts">
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
import Combobox, { toComboBoxOptions } from '../combobox.svelte';
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils';
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
import { t } from 'svelte-i18n';
export let filters: SearchCameraFilter;
@ -22,17 +22,30 @@
$: handlePromiseError(updateModels(makeFilter));
async function updateMakes(model?: string) {
makes = await getSearchSuggestions({
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.CameraMake,
model,
includeNull: true,
});
if (filters.make && !makes.includes(filters.make)) {
filters.make = undefined;
}
makes = results.map((result) => result ?? '');
}
async function updateModels(make?: string) {
models = await getSearchSuggestions({
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.CameraModel,
make,
includeNull: true,
});
const models = results.map((result) => result ?? '');
if (filters.model && !models.includes(filters.model)) {
filters.model = undefined;
}
}
</script>
@ -44,9 +57,9 @@
<Combobox
label={$t('make')}
on:select={({ detail }) => (filters.make = detail?.value)}
options={toComboBoxOptions(makes)}
options={asComboboxOptions(makes)}
placeholder={$t('search_camera_make')}
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
selectedOption={asSelectedOption(makeFilter)}
/>
</div>
@ -54,9 +67,9 @@
<Combobox
label={$t('model')}
on:select={({ detail }) => (filters.model = detail?.value)}
options={toComboBoxOptions(models)}
options={asComboboxOptions(models)}
placeholder={$t('search_camera_model')}
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
selectedOption={asSelectedOption(modelFilter)}
/>
</div>
</div>

View file

@ -42,18 +42,23 @@
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>();
// combobox and all the search components have terrible support for value | null so we use empty string instead.
function withNullAsUndefined<T>(value: T | null) {
return value === null ? undefined : value;
}
let filter: SearchFilter = {
context: 'query' in searchQuery ? searchQuery.query : '',
filename: 'originalFileName' in searchQuery ? searchQuery.originalFileName : undefined,
personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []),
location: {
country: searchQuery.country,
state: searchQuery.state,
city: searchQuery.city,
country: withNullAsUndefined(searchQuery.country),
state: withNullAsUndefined(searchQuery.state),
city: withNullAsUndefined(searchQuery.city),
},
camera: {
make: searchQuery.make,
model: searchQuery.model,
make: withNullAsUndefined(searchQuery.make),
model: withNullAsUndefined(searchQuery.model),
},
date: {
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,

View file

@ -7,9 +7,9 @@
</script>
<script lang="ts">
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import Combobox, { toComboBoxOptions } from '../combobox.svelte';
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import { t } from 'svelte-i18n';
export let filters: SearchLocationFilter;
@ -25,33 +25,41 @@
$: handlePromiseError(updateCities(countryFilter, stateFilter));
async function updateCountries() {
countries = await getSearchSuggestions({
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.Country,
includeNull: true,
});
countries = results.map((result) => result ?? '');
if (filters.country && !countries.includes(filters.country)) {
filters.country = undefined;
}
}
async function updateStates(country?: string) {
states = await getSearchSuggestions({
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.State,
country,
includeNull: true,
});
states = results.map((result) => result ?? '');
if (filters.state && !states.includes(filters.state)) {
filters.state = undefined;
}
}
async function updateCities(country?: string, state?: string) {
cities = await getSearchSuggestions({
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.City,
country,
state,
});
cities = results.map((result) => result ?? '');
if (filters.city && !cities.includes(filters.city)) {
filters.city = undefined;
}
@ -66,9 +74,9 @@
<Combobox
label={$t('country')}
on:select={({ detail }) => (filters.country = detail?.value)}
options={toComboBoxOptions(countries)}
options={asComboboxOptions(countries)}
placeholder={$t('search_country')}
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
selectedOption={asSelectedOption(filters.country)}
/>
</div>
@ -76,9 +84,9 @@
<Combobox
label={$t('state')}
on:select={({ detail }) => (filters.state = detail?.value)}
options={toComboBoxOptions(states)}
options={asComboboxOptions(states)}
placeholder={$t('search_state')}
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
selectedOption={asSelectedOption(filters.state)}
/>
</div>
@ -86,9 +94,9 @@
<Combobox
label={$t('city')}
on:select={({ detail }) => (filters.city = detail?.value)}
options={toComboBoxOptions(cities)}
options={asComboboxOptions(cities)}
placeholder={$t('search_city')}
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
selectedOption={asSelectedOption(filters.city)}
/>
</div>
</div>