mirror of
https://github.com/immich-app/immich.git
synced 2024-11-28 09:33:27 +02:00
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:
parent
3afb5b497f
commit
d3a5490e71
@ -1,4 +1,4 @@
|
|||||||
import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, getMapMarkers, updateAsset } from '@immich/sdk';
|
import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, updateAsset } from '@immich/sdk';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@ -32,9 +32,6 @@ describe('/search', () => {
|
|||||||
let assetOneJpg5: AssetMediaResponseDto;
|
let assetOneJpg5: AssetMediaResponseDto;
|
||||||
let assetSprings: AssetMediaResponseDto;
|
let assetSprings: AssetMediaResponseDto;
|
||||||
let assetLast: AssetMediaResponseDto;
|
let assetLast: AssetMediaResponseDto;
|
||||||
let cities: string[];
|
|
||||||
let states: string[];
|
|
||||||
let countries: string[];
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
@ -85,7 +82,7 @@ describe('/search', () => {
|
|||||||
// note: the coordinates here are not the actual coordinates of the images and are random for most of them
|
// note: the coordinates here are not the actual coordinates of the images and are random for most of them
|
||||||
const coordinates = [
|
const coordinates = [
|
||||||
{ latitude: 48.853_41, longitude: 2.3488 }, // paris
|
{ latitude: 48.853_41, longitude: 2.3488 }, // paris
|
||||||
{ latitude: 63.0695, longitude: -151.0074 }, // denali
|
{ latitude: 35.6895, longitude: 139.691_71 }, // tokyo
|
||||||
{ latitude: 52.524_37, longitude: 13.410_53 }, // berlin
|
{ latitude: 52.524_37, longitude: 13.410_53 }, // berlin
|
||||||
{ latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore
|
{ latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore
|
||||||
{ latitude: 41.013_84, longitude: 28.949_66 }, // istanbul
|
{ latitude: 41.013_84, longitude: 28.949_66 }, // istanbul
|
||||||
@ -101,16 +98,15 @@ describe('/search', () => {
|
|||||||
{ latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh
|
{ latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh
|
||||||
{ latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge
|
{ latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge
|
||||||
{ latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg
|
{ latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg
|
||||||
{ latitude: 35.6895, longitude: 139.691_71 }, // tokyo
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const updates = assets.map((asset, i) =>
|
const updates = coordinates.map((dto, i) =>
|
||||||
updateAsset({ id: asset.id, updateAssetDto: coordinates[i] }, { headers: asBearerAuth(admin.accessToken) }),
|
updateAsset({ id: assets[i].id, updateAssetDto: dto }, { headers: asBearerAuth(admin.accessToken) }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(updates);
|
await Promise.all(updates);
|
||||||
for (const asset of assets) {
|
for (const [i] of coordinates.entries()) {
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: assets[i].id });
|
||||||
}
|
}
|
||||||
|
|
||||||
[
|
[
|
||||||
@ -137,12 +133,6 @@ describe('/search', () => {
|
|||||||
assetLast = assets.at(-1) as AssetMediaResponseDto;
|
assetLast = assets.at(-1) as AssetMediaResponseDto;
|
||||||
|
|
||||||
await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) });
|
await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
|
|
||||||
const mapMarkers = await getMapMarkers({}, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
const nonTrashed = mapMarkers.filter((mark) => mark.id !== assetSilver.id);
|
|
||||||
cities = [...new Set(nonTrashed.map((mark) => mark.city).filter((entry): entry is string => !!entry))].sort();
|
|
||||||
states = [...new Set(nonTrashed.map((mark) => mark.state).filter((entry): entry is string => !!entry))].sort();
|
|
||||||
countries = [...new Set(nonTrashed.map((mark) => mark.country).filter((entry): entry is string => !!entry))].sort();
|
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -321,23 +311,120 @@ describe('/search', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
should: 'should search by city',
|
should: 'should search by city',
|
||||||
deferred: () => ({ dto: { city: 'Accra' }, assets: [assetHeic] }),
|
deferred: () => ({
|
||||||
|
dto: {
|
||||||
|
city: 'Accra',
|
||||||
|
includeNull: true,
|
||||||
|
},
|
||||||
|
assets: [assetHeic],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: "should search city ('')",
|
||||||
|
deferred: () => ({
|
||||||
|
dto: {
|
||||||
|
city: '',
|
||||||
|
isVisible: true,
|
||||||
|
includeNull: true,
|
||||||
|
},
|
||||||
|
assets: [assetLast],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search city (null)',
|
||||||
|
deferred: () => ({
|
||||||
|
dto: {
|
||||||
|
city: null,
|
||||||
|
isVisible: true,
|
||||||
|
includeNull: true,
|
||||||
|
},
|
||||||
|
assets: [assetLast],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
should: 'should search by state',
|
should: 'should search by state',
|
||||||
deferred: () => ({ dto: { state: 'New York' }, assets: [assetDensity] }),
|
deferred: () => ({
|
||||||
|
dto: {
|
||||||
|
state: 'New York',
|
||||||
|
includeNull: true,
|
||||||
|
},
|
||||||
|
assets: [assetDensity],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: "should search state ('')",
|
||||||
|
deferred: () => ({
|
||||||
|
dto: {
|
||||||
|
state: '',
|
||||||
|
isVisible: true,
|
||||||
|
withExif: true,
|
||||||
|
includeNull: true,
|
||||||
|
},
|
||||||
|
assets: [assetLast, assetNotocactus],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search state (null)',
|
||||||
|
deferred: () => ({
|
||||||
|
dto: {
|
||||||
|
state: null,
|
||||||
|
isVisible: true,
|
||||||
|
includeNull: true,
|
||||||
|
},
|
||||||
|
assets: [assetLast, assetNotocactus],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
should: 'should search by country',
|
should: 'should search by country',
|
||||||
deferred: () => ({ dto: { country: 'France' }, assets: [assetFalcon] }),
|
deferred: () => ({
|
||||||
|
dto: {
|
||||||
|
country: 'France',
|
||||||
|
includeNull: true,
|
||||||
|
},
|
||||||
|
assets: [assetFalcon],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: "should search country ('')",
|
||||||
|
deferred: () => ({
|
||||||
|
dto: {
|
||||||
|
country: '',
|
||||||
|
isVisible: true,
|
||||||
|
includeNull: true,
|
||||||
|
},
|
||||||
|
assets: [assetLast],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search country (null)',
|
||||||
|
deferred: () => ({
|
||||||
|
dto: {
|
||||||
|
country: null,
|
||||||
|
isVisible: true,
|
||||||
|
includeNull: true,
|
||||||
|
},
|
||||||
|
assets: [assetLast],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
should: 'should search by make',
|
should: 'should search by make',
|
||||||
deferred: () => ({ dto: { make: 'Canon' }, assets: [assetFalcon, assetDenali] }),
|
deferred: () => ({
|
||||||
|
dto: {
|
||||||
|
make: 'Canon',
|
||||||
|
includeNull: true,
|
||||||
|
},
|
||||||
|
assets: [assetFalcon, assetDenali],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
should: 'should search by model',
|
should: 'should search by model',
|
||||||
deferred: () => ({ dto: { model: 'Canon EOS 7D' }, assets: [assetDenali] }),
|
deferred: () => ({
|
||||||
|
dto: {
|
||||||
|
model: 'Canon EOS 7D',
|
||||||
|
includeNull: true,
|
||||||
|
},
|
||||||
|
assets: [assetDenali],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
should: 'should allow searching the upload library (libraryId: null)',
|
should: 'should allow searching the upload library (libraryId: null)',
|
||||||
@ -450,32 +537,79 @@ describe('/search', () => {
|
|||||||
|
|
||||||
it('should get suggestions for country', async () => {
|
it('should get suggestions for country', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/search/suggestions?type=country')
|
.get('/search/suggestions?type=country&includeNull=true')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
expect(body).toEqual(countries);
|
expect(body).toEqual([
|
||||||
|
'Cuba',
|
||||||
|
'France',
|
||||||
|
'Georgia',
|
||||||
|
'Germany',
|
||||||
|
'Ghana',
|
||||||
|
'Japan',
|
||||||
|
'Morocco',
|
||||||
|
"People's Republic of China",
|
||||||
|
'Russian Federation',
|
||||||
|
'Singapore',
|
||||||
|
'Spain',
|
||||||
|
'Switzerland',
|
||||||
|
'United States of America',
|
||||||
|
null,
|
||||||
|
]);
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get suggestions for state', async () => {
|
it('should get suggestions for state', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/search/suggestions?type=state')
|
.get('/search/suggestions?type=state&includeNull=true')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
expect(body).toHaveLength(states.length);
|
expect(body).toEqual([
|
||||||
expect(body).toEqual(expect.arrayContaining(states));
|
'Andalusia',
|
||||||
|
'Berlin',
|
||||||
|
'Glarus',
|
||||||
|
'Greater Accra',
|
||||||
|
'Havana',
|
||||||
|
'Île-de-France',
|
||||||
|
'Marrakesh-Safi',
|
||||||
|
'Mississippi',
|
||||||
|
'New York',
|
||||||
|
'Shanghai',
|
||||||
|
'St.-Petersburg',
|
||||||
|
'Tbilisi',
|
||||||
|
'Tokyo',
|
||||||
|
'Virginia',
|
||||||
|
null,
|
||||||
|
]);
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get suggestions for city', async () => {
|
it('should get suggestions for city', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/search/suggestions?type=city')
|
.get('/search/suggestions?type=city&includeNull=true')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
expect(body).toEqual(cities);
|
expect(body).toEqual([
|
||||||
|
'Accra',
|
||||||
|
'Berlin',
|
||||||
|
'Glarus',
|
||||||
|
'Havana',
|
||||||
|
'Marrakesh',
|
||||||
|
'Montalbán de Córdoba',
|
||||||
|
'New York City',
|
||||||
|
'Novena',
|
||||||
|
'Paris',
|
||||||
|
'Philadelphia',
|
||||||
|
'Saint Petersburg',
|
||||||
|
'Shanghai',
|
||||||
|
'Stanley',
|
||||||
|
'Tbilisi',
|
||||||
|
'Tokyo',
|
||||||
|
null,
|
||||||
|
]);
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get suggestions for camera make', async () => {
|
it('should get suggestions for camera make', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/search/suggestions?type=camera-make')
|
.get('/search/suggestions?type=camera-make&includeNull=true')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
expect(body).toEqual([
|
expect(body).toEqual([
|
||||||
'Apple',
|
'Apple',
|
||||||
@ -485,13 +619,14 @@ describe('/search', () => {
|
|||||||
'PENTAX Corporation',
|
'PENTAX Corporation',
|
||||||
'samsung',
|
'samsung',
|
||||||
'SONY',
|
'SONY',
|
||||||
|
null,
|
||||||
]);
|
]);
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get suggestions for camera model', async () => {
|
it('should get suggestions for camera model', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/search/suggestions?type=camera-model')
|
.get('/search/suggestions?type=camera-model&includeNull=true')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
expect(body).toEqual([
|
expect(body).toEqual([
|
||||||
'Canon EOS 7D',
|
'Canon EOS 7D',
|
||||||
@ -506,6 +641,7 @@ describe('/search', () => {
|
|||||||
'SM-F711N',
|
'SM-F711N',
|
||||||
'SM-S906U',
|
'SM-S906U',
|
||||||
'SM-T970',
|
'SM-T970',
|
||||||
|
null,
|
||||||
]);
|
]);
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
});
|
});
|
||||||
|
BIN
mobile/openapi/lib/api/search_api.dart
generated
BIN
mobile/openapi/lib/api/search_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/metadata_search_dto.dart
generated
BIN
mobile/openapi/lib/model/metadata_search_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/smart_search_dto.dart
generated
BIN
mobile/openapi/lib/model/smart_search_dto.dart
generated
Binary file not shown.
@ -4727,6 +4727,15 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "includeNull",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "This property was added in v111.0.0",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "make",
|
"name": "make",
|
||||||
"required": false,
|
"required": false,
|
||||||
@ -9378,9 +9387,11 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"city": {
|
"city": {
|
||||||
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"country": {
|
"country": {
|
||||||
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"createdAfter": {
|
"createdAfter": {
|
||||||
@ -9426,6 +9437,7 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"lensModel": {
|
"lensModel": {
|
||||||
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"libraryId": {
|
"libraryId": {
|
||||||
@ -9437,6 +9449,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"order": {
|
"order": {
|
||||||
@ -9468,6 +9481,7 @@
|
|||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"takenAfter": {
|
"takenAfter": {
|
||||||
@ -10611,9 +10625,11 @@
|
|||||||
"SmartSearchDto": {
|
"SmartSearchDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"city": {
|
"city": {
|
||||||
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"country": {
|
"country": {
|
||||||
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"createdAfter": {
|
"createdAfter": {
|
||||||
@ -10649,6 +10665,7 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"lensModel": {
|
"lensModel": {
|
||||||
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"libraryId": {
|
"libraryId": {
|
||||||
@ -10660,6 +10677,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
@ -10682,6 +10700,7 @@
|
|||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"takenAfter": {
|
"takenAfter": {
|
||||||
|
@ -708,8 +708,8 @@ export type SearchExploreResponseDto = {
|
|||||||
};
|
};
|
||||||
export type MetadataSearchDto = {
|
export type MetadataSearchDto = {
|
||||||
checksum?: string;
|
checksum?: string;
|
||||||
city?: string;
|
city?: string | null;
|
||||||
country?: string;
|
country?: string | null;
|
||||||
createdAfter?: string;
|
createdAfter?: string;
|
||||||
createdBefore?: string;
|
createdBefore?: string;
|
||||||
deviceAssetId?: string;
|
deviceAssetId?: string;
|
||||||
@ -723,10 +723,10 @@ export type MetadataSearchDto = {
|
|||||||
isNotInAlbum?: boolean;
|
isNotInAlbum?: boolean;
|
||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
lensModel?: string;
|
lensModel?: string | null;
|
||||||
libraryId?: string | null;
|
libraryId?: string | null;
|
||||||
make?: string;
|
make?: string;
|
||||||
model?: string;
|
model?: string | null;
|
||||||
order?: AssetOrder;
|
order?: AssetOrder;
|
||||||
originalFileName?: string;
|
originalFileName?: string;
|
||||||
originalPath?: string;
|
originalPath?: string;
|
||||||
@ -734,7 +734,7 @@ export type MetadataSearchDto = {
|
|||||||
personIds?: string[];
|
personIds?: string[];
|
||||||
previewPath?: string;
|
previewPath?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
state?: string;
|
state?: string | null;
|
||||||
takenAfter?: string;
|
takenAfter?: string;
|
||||||
takenBefore?: string;
|
takenBefore?: string;
|
||||||
thumbnailPath?: string;
|
thumbnailPath?: string;
|
||||||
@ -782,8 +782,8 @@ export type PlacesResponseDto = {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
export type SmartSearchDto = {
|
export type SmartSearchDto = {
|
||||||
city?: string;
|
city?: string | null;
|
||||||
country?: string;
|
country?: string | null;
|
||||||
createdAfter?: string;
|
createdAfter?: string;
|
||||||
createdBefore?: string;
|
createdBefore?: string;
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
@ -794,15 +794,15 @@ export type SmartSearchDto = {
|
|||||||
isNotInAlbum?: boolean;
|
isNotInAlbum?: boolean;
|
||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
lensModel?: string;
|
lensModel?: string | null;
|
||||||
libraryId?: string | null;
|
libraryId?: string | null;
|
||||||
make?: string;
|
make?: string;
|
||||||
model?: string;
|
model?: string | null;
|
||||||
page?: number;
|
page?: number;
|
||||||
personIds?: string[];
|
personIds?: string[];
|
||||||
query: string;
|
query: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
state?: string;
|
state?: string | null;
|
||||||
takenAfter?: string;
|
takenAfter?: string;
|
||||||
takenBefore?: string;
|
takenBefore?: string;
|
||||||
trashedAfter?: string;
|
trashedAfter?: string;
|
||||||
@ -2418,8 +2418,9 @@ export function searchSmart({ smartSearchDto }: {
|
|||||||
body: smartSearchDto
|
body: smartSearchDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
export function getSearchSuggestions({ country, make, model, state, $type }: {
|
export function getSearchSuggestions({ country, includeNull, make, model, state, $type }: {
|
||||||
country?: string;
|
country?: string;
|
||||||
|
includeNull?: boolean;
|
||||||
make?: string;
|
make?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
@ -2430,6 +2431,7 @@ export function getSearchSuggestions({ country, make, model, state, $type }: {
|
|||||||
data: string[];
|
data: string[];
|
||||||
}>(`/search/suggestions${QS.query(QS.explode({
|
}>(`/search/suggestions${QS.query(QS.explode({
|
||||||
country,
|
country,
|
||||||
|
includeNull,
|
||||||
make,
|
make,
|
||||||
model,
|
model,
|
||||||
state,
|
state,
|
||||||
|
@ -62,6 +62,7 @@ export class SearchController {
|
|||||||
@Get('suggestions')
|
@Get('suggestions')
|
||||||
@Authenticated()
|
@Authenticated()
|
||||||
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
|
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
|
||||||
return this.service.getSearchSuggestions(auth, dto);
|
// TODO fix open api generation to indicate that results can be nullable
|
||||||
|
return this.service.getSearchSuggestions(auth, dto) as Promise<string[]>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
|
import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
|
||||||
|
import { PropertyLifecycle } from 'src/decorators';
|
||||||
import { AlbumResponseDto } from 'src/dtos/album.dto';
|
import { AlbumResponseDto } from 'src/dtos/album.dto';
|
||||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||||
import { AssetOrder } from 'src/entities/album.entity';
|
import { AssetOrder } from 'src/entities/album.entity';
|
||||||
@ -75,34 +76,29 @@ class BaseSearchDto {
|
|||||||
takenAfter?: Date;
|
takenAfter?: Date;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@Optional({ nullable: true, emptyToNull: true })
|
||||||
@Optional()
|
city?: string | null;
|
||||||
city?: string;
|
|
||||||
|
@IsString()
|
||||||
|
@Optional({ nullable: true, emptyToNull: true })
|
||||||
|
state?: string | null;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Optional()
|
@Optional({ nullable: true, emptyToNull: true })
|
||||||
state?: string;
|
country?: string | null;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@Optional({ nullable: true, emptyToNull: true })
|
||||||
@Optional()
|
|
||||||
country?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@Optional()
|
|
||||||
make?: string;
|
make?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@Optional({ nullable: true, emptyToNull: true })
|
||||||
@Optional()
|
model?: string | null;
|
||||||
model?: string;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@Optional({ nullable: true, emptyToNull: true })
|
||||||
@Optional()
|
lensModel?: string | null;
|
||||||
lensModel?: string;
|
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@ -242,6 +238,10 @@ export class SearchSuggestionRequestDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@Optional()
|
@Optional()
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
@PropertyLifecycle({ addedAt: 'v111.0.0' })
|
||||||
|
includeNull?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SearchFacetCountResponseDto {
|
class SearchFacetCountResponseDto {
|
||||||
|
@ -26,9 +26,9 @@ export interface IMetadataRepository {
|
|||||||
readTags(path: string): Promise<ImmichTags | null>;
|
readTags(path: string): Promise<ImmichTags | null>;
|
||||||
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
|
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
|
||||||
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
|
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
|
||||||
getCountries(userId: string): Promise<string[]>;
|
getCountries(userId: string): Promise<Array<string | null>>;
|
||||||
getStates(userId: string, country?: string): Promise<string[]>;
|
getStates(userId: string, country?: string): Promise<Array<string | null>>;
|
||||||
getCities(userId: string, country?: string, state?: string): Promise<string[]>;
|
getCities(userId: string, country?: string, state?: string): Promise<Array<string | null>>;
|
||||||
getCameraMakes(userId: string, model?: string): Promise<string[]>;
|
getCameraMakes(userId: string, model?: string): Promise<Array<string | null>>;
|
||||||
getCameraModels(userId: string, make?: string): Promise<string[]>;
|
getCameraModels(userId: string, make?: string): Promise<Array<string | null>>;
|
||||||
}
|
}
|
||||||
|
@ -95,12 +95,12 @@ export interface SearchPathOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchExifOptions {
|
export interface SearchExifOptions {
|
||||||
city?: string;
|
city?: string | null;
|
||||||
country?: string;
|
country?: string | null;
|
||||||
lensModel?: string;
|
lensModel?: string | null;
|
||||||
make?: string;
|
make?: string | null;
|
||||||
model?: string;
|
model?: string | null;
|
||||||
state?: string;
|
state?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchEmbeddingOptions {
|
export interface SearchEmbeddingOptions {
|
||||||
|
@ -2,65 +2,55 @@
|
|||||||
|
|
||||||
-- MetadataRepository.getCountries
|
-- MetadataRepository.getCountries
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
ON ("exif"."country") "exif"."country" AS "exif_country",
|
ON ("exif"."country") "exif"."country" AS "country"
|
||||||
"exif"."assetId" AS "exif_assetId"
|
|
||||||
FROM
|
FROM
|
||||||
"exif" "exif"
|
"exif" "exif"
|
||||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||||
AND ("asset"."deletedAt" IS NULL)
|
AND ("asset"."deletedAt" IS NULL)
|
||||||
WHERE
|
WHERE
|
||||||
"asset"."ownerId" = $1
|
"asset"."ownerId" = $1
|
||||||
AND "exif"."country" IS NOT NULL
|
|
||||||
|
|
||||||
-- MetadataRepository.getStates
|
-- MetadataRepository.getStates
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
ON ("exif"."state") "exif"."state" AS "exif_state",
|
ON ("exif"."state") "exif"."state" AS "state"
|
||||||
"exif"."assetId" AS "exif_assetId"
|
|
||||||
FROM
|
FROM
|
||||||
"exif" "exif"
|
"exif" "exif"
|
||||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||||
AND ("asset"."deletedAt" IS NULL)
|
AND ("asset"."deletedAt" IS NULL)
|
||||||
WHERE
|
WHERE
|
||||||
"asset"."ownerId" = $1
|
"asset"."ownerId" = $1
|
||||||
AND "exif"."state" IS NOT NULL
|
|
||||||
AND "exif"."country" = $2
|
AND "exif"."country" = $2
|
||||||
|
|
||||||
-- MetadataRepository.getCities
|
-- MetadataRepository.getCities
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
ON ("exif"."city") "exif"."city" AS "exif_city",
|
ON ("exif"."city") "exif"."city" AS "city"
|
||||||
"exif"."assetId" AS "exif_assetId"
|
|
||||||
FROM
|
FROM
|
||||||
"exif" "exif"
|
"exif" "exif"
|
||||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||||
AND ("asset"."deletedAt" IS NULL)
|
AND ("asset"."deletedAt" IS NULL)
|
||||||
WHERE
|
WHERE
|
||||||
"asset"."ownerId" = $1
|
"asset"."ownerId" = $1
|
||||||
AND "exif"."city" IS NOT NULL
|
|
||||||
AND "exif"."country" = $2
|
AND "exif"."country" = $2
|
||||||
AND "exif"."state" = $3
|
AND "exif"."state" = $3
|
||||||
|
|
||||||
-- MetadataRepository.getCameraMakes
|
-- MetadataRepository.getCameraMakes
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
ON ("exif"."make") "exif"."make" AS "exif_make",
|
ON ("exif"."make") "exif"."make" AS "make"
|
||||||
"exif"."assetId" AS "exif_assetId"
|
|
||||||
FROM
|
FROM
|
||||||
"exif" "exif"
|
"exif" "exif"
|
||||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||||
AND ("asset"."deletedAt" IS NULL)
|
AND ("asset"."deletedAt" IS NULL)
|
||||||
WHERE
|
WHERE
|
||||||
"asset"."ownerId" = $1
|
"asset"."ownerId" = $1
|
||||||
AND "exif"."make" IS NOT NULL
|
|
||||||
AND "exif"."model" = $2
|
AND "exif"."model" = $2
|
||||||
|
|
||||||
-- MetadataRepository.getCameraModels
|
-- MetadataRepository.getCameraModels
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
ON ("exif"."model") "exif"."model" AS "exif_model",
|
ON ("exif"."model") "exif"."model" AS "model"
|
||||||
"exif"."assetId" AS "exif_assetId"
|
|
||||||
FROM
|
FROM
|
||||||
"exif" "exif"
|
"exif" "exif"
|
||||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||||
AND ("asset"."deletedAt" IS NULL)
|
AND ("asset"."deletedAt" IS NULL)
|
||||||
WHERE
|
WHERE
|
||||||
"asset"."ownerId" = $1
|
"asset"."ownerId" = $1
|
||||||
AND "exif"."model" IS NOT NULL
|
|
||||||
AND "exif"."make" = $2
|
AND "exif"."make" = $2
|
||||||
|
@ -57,49 +57,42 @@ export class MetadataRepository implements IMetadataRepository {
|
|||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
async getCountries(userId: string): Promise<string[]> {
|
async getCountries(userId: string): Promise<string[]> {
|
||||||
const entity = await this.exifRepository
|
const results = await this.exifRepository
|
||||||
.createQueryBuilder('exif')
|
.createQueryBuilder('exif')
|
||||||
.leftJoin('exif.asset', 'asset')
|
.leftJoin('exif.asset', 'asset')
|
||||||
.where('asset.ownerId = :userId', { userId })
|
.where('asset.ownerId = :userId', { userId })
|
||||||
.andWhere('exif.country IS NOT NULL')
|
.select('exif.country', 'country')
|
||||||
.select('exif.country')
|
|
||||||
.distinctOn(['exif.country'])
|
.distinctOn(['exif.country'])
|
||||||
.getMany();
|
.getRawMany<{ country: string }>();
|
||||||
|
|
||||||
return entity.map((e) => e.country ?? '').filter((c) => c !== '');
|
return results.map(({ country }) => country).filter((item) => item !== '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||||
async getStates(userId: string, country: string | undefined): Promise<string[]> {
|
async getStates(userId: string, country: string | undefined): Promise<string[]> {
|
||||||
let result: ExifEntity[] = [];
|
|
||||||
|
|
||||||
const query = this.exifRepository
|
const query = this.exifRepository
|
||||||
.createQueryBuilder('exif')
|
.createQueryBuilder('exif')
|
||||||
.leftJoin('exif.asset', 'asset')
|
.leftJoin('exif.asset', 'asset')
|
||||||
.where('asset.ownerId = :userId', { userId })
|
.where('asset.ownerId = :userId', { userId })
|
||||||
.andWhere('exif.state IS NOT NULL')
|
.select('exif.state', 'state')
|
||||||
.select('exif.state')
|
|
||||||
.distinctOn(['exif.state']);
|
.distinctOn(['exif.state']);
|
||||||
|
|
||||||
if (country) {
|
if (country) {
|
||||||
query.andWhere('exif.country = :country', { country });
|
query.andWhere('exif.country = :country', { country });
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await query.getMany();
|
const result = await query.getRawMany<{ state: string }>();
|
||||||
|
|
||||||
return result.map((entity) => entity.state ?? '').filter((s) => s !== '');
|
return result.map(({ state }) => state).filter((item) => item !== '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] })
|
||||||
async getCities(userId: string, country: string | undefined, state: string | undefined): Promise<string[]> {
|
async getCities(userId: string, country: string | undefined, state: string | undefined): Promise<string[]> {
|
||||||
let result: ExifEntity[] = [];
|
|
||||||
|
|
||||||
const query = this.exifRepository
|
const query = this.exifRepository
|
||||||
.createQueryBuilder('exif')
|
.createQueryBuilder('exif')
|
||||||
.leftJoin('exif.asset', 'asset')
|
.leftJoin('exif.asset', 'asset')
|
||||||
.where('asset.ownerId = :userId', { userId })
|
.where('asset.ownerId = :userId', { userId })
|
||||||
.andWhere('exif.city IS NOT NULL')
|
.select('exif.city', 'city')
|
||||||
.select('exif.city')
|
|
||||||
.distinctOn(['exif.city']);
|
.distinctOn(['exif.city']);
|
||||||
|
|
||||||
if (country) {
|
if (country) {
|
||||||
@ -110,50 +103,42 @@ export class MetadataRepository implements IMetadataRepository {
|
|||||||
query.andWhere('exif.state = :state', { state });
|
query.andWhere('exif.state = :state', { state });
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await query.getMany();
|
const results = await query.getRawMany<{ city: string }>();
|
||||||
|
|
||||||
return result.map((entity) => entity.city ?? '').filter((c) => c !== '');
|
return results.map(({ city }) => city).filter((item) => item !== '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||||
async getCameraMakes(userId: string, model: string | undefined): Promise<string[]> {
|
async getCameraMakes(userId: string, model: string | undefined): Promise<string[]> {
|
||||||
let result: ExifEntity[] = [];
|
|
||||||
|
|
||||||
const query = this.exifRepository
|
const query = this.exifRepository
|
||||||
.createQueryBuilder('exif')
|
.createQueryBuilder('exif')
|
||||||
.leftJoin('exif.asset', 'asset')
|
.leftJoin('exif.asset', 'asset')
|
||||||
.where('asset.ownerId = :userId', { userId })
|
.where('asset.ownerId = :userId', { userId })
|
||||||
.andWhere('exif.make IS NOT NULL')
|
.select('exif.make', 'make')
|
||||||
.select('exif.make')
|
|
||||||
.distinctOn(['exif.make']);
|
.distinctOn(['exif.make']);
|
||||||
|
|
||||||
if (model) {
|
if (model) {
|
||||||
query.andWhere('exif.model = :model', { model });
|
query.andWhere('exif.model = :model', { model });
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await query.getMany();
|
const results = await query.getRawMany<{ make: string }>();
|
||||||
|
return results.map(({ make }) => make).filter((item) => item !== '');
|
||||||
return result.map((entity) => entity.make ?? '').filter((m) => m !== '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||||
async getCameraModels(userId: string, make: string | undefined): Promise<string[]> {
|
async getCameraModels(userId: string, make: string | undefined): Promise<string[]> {
|
||||||
let result: ExifEntity[] = [];
|
|
||||||
|
|
||||||
const query = this.exifRepository
|
const query = this.exifRepository
|
||||||
.createQueryBuilder('exif')
|
.createQueryBuilder('exif')
|
||||||
.leftJoin('exif.asset', 'asset')
|
.leftJoin('exif.asset', 'asset')
|
||||||
.where('asset.ownerId = :userId', { userId })
|
.where('asset.ownerId = :userId', { userId })
|
||||||
.andWhere('exif.model IS NOT NULL')
|
.select('exif.model', 'model')
|
||||||
.select('exif.model')
|
|
||||||
.distinctOn(['exif.model']);
|
.distinctOn(['exif.model']);
|
||||||
|
|
||||||
if (make) {
|
if (make) {
|
||||||
query.andWhere('exif.make = :make', { make });
|
query.andWhere('exif.make = :make', { make });
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await query.getMany();
|
const results = await query.getRawMany<{ model: string }>();
|
||||||
|
return results.map(({ model }) => model).filter((item) => item !== '');
|
||||||
return result.map((entity) => entity.model ?? '').filter((m) => m !== '');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
|
import { SearchSuggestionType } from 'src/dtos/search.dto';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
|
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
|
||||||
@ -95,4 +96,22 @@ describe(SearchService.name, () => {
|
|||||||
expect(result).toEqual(expectedResponse);
|
expect(result).toEqual(expectedResponse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getSearchSuggestions', () => {
|
||||||
|
it('should return search suggestions (including null)', async () => {
|
||||||
|
metadataMock.getCountries.mockResolvedValue(['USA', null]);
|
||||||
|
await expect(
|
||||||
|
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }),
|
||||||
|
).resolves.toEqual(['USA', null]);
|
||||||
|
expect(metadataMock.getCountries).toHaveBeenCalledWith(authStub.user1.user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return search suggestions (without null)', async () => {
|
||||||
|
metadataMock.getCountries.mockResolvedValue(['USA', null]);
|
||||||
|
await expect(
|
||||||
|
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }),
|
||||||
|
).resolves.toEqual(['USA']);
|
||||||
|
expect(metadataMock.getCountries).toHaveBeenCalledWith(authStub.user1.user.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -120,22 +120,30 @@ export class SearchService {
|
|||||||
return assets.map((asset) => mapAsset(asset));
|
return assets.map((asset) => mapAsset(asset));
|
||||||
}
|
}
|
||||||
|
|
||||||
getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> {
|
async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto) {
|
||||||
|
const results = await this.getSuggestions(auth.user.id, dto);
|
||||||
|
return results.filter((result) => (dto.includeNull ? true : result !== null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSuggestions(userId: string, dto: SearchSuggestionRequestDto) {
|
||||||
switch (dto.type) {
|
switch (dto.type) {
|
||||||
case SearchSuggestionType.COUNTRY: {
|
case SearchSuggestionType.COUNTRY: {
|
||||||
return this.metadataRepository.getCountries(auth.user.id);
|
return this.metadataRepository.getCountries(userId);
|
||||||
}
|
}
|
||||||
case SearchSuggestionType.STATE: {
|
case SearchSuggestionType.STATE: {
|
||||||
return this.metadataRepository.getStates(auth.user.id, dto.country);
|
return this.metadataRepository.getStates(userId, dto.country);
|
||||||
}
|
}
|
||||||
case SearchSuggestionType.CITY: {
|
case SearchSuggestionType.CITY: {
|
||||||
return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
|
return this.metadataRepository.getCities(userId, dto.country, dto.state);
|
||||||
}
|
}
|
||||||
case SearchSuggestionType.CAMERA_MAKE: {
|
case SearchSuggestionType.CAMERA_MAKE: {
|
||||||
return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
|
return this.metadataRepository.getCameraMakes(userId, dto.model);
|
||||||
}
|
}
|
||||||
case SearchSuggestionType.CAMERA_MODEL: {
|
case SearchSuggestionType.CAMERA_MODEL: {
|
||||||
return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
|
return this.metadataRepository.getCameraModels(userId, dto.make);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,7 +48,13 @@ export function searchAssetBuilder(
|
|||||||
? builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo')
|
? builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo')
|
||||||
: builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo');
|
: builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo');
|
||||||
|
|
||||||
builder.andWhere({ exifInfo });
|
for (const [key, value] of Object.entries(exifInfo)) {
|
||||||
|
if (value === null) {
|
||||||
|
builder.andWhere(`exifInfo.${key} IS NULL`);
|
||||||
|
} else {
|
||||||
|
builder.andWhere(`exifInfo.${key} = :${key}`, { [key]: value });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId']);
|
const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId']);
|
||||||
|
@ -66,6 +66,8 @@ export class UUIDParamDto {
|
|||||||
|
|
||||||
export interface OptionalOptions extends ValidationOptions {
|
export interface OptionalOptions extends ValidationOptions {
|
||||||
nullable?: boolean;
|
nullable?: boolean;
|
||||||
|
/** convert empty strings to null */
|
||||||
|
emptyToNull?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -76,12 +78,20 @@ export interface OptionalOptions extends ValidationOptions {
|
|||||||
* @see IsOptional exported from `class-validator.
|
* @see IsOptional exported from `class-validator.
|
||||||
*/
|
*/
|
||||||
// https://stackoverflow.com/a/71353929
|
// https://stackoverflow.com/a/71353929
|
||||||
export function Optional({ nullable, ...validationOptions }: OptionalOptions = {}) {
|
export function Optional({ nullable, emptyToNull, ...validationOptions }: OptionalOptions = {}) {
|
||||||
|
const decorators: PropertyDecorator[] = [];
|
||||||
|
|
||||||
if (nullable === true) {
|
if (nullable === true) {
|
||||||
return IsOptional(validationOptions);
|
decorators.push(IsOptional(validationOptions));
|
||||||
|
} else {
|
||||||
|
decorators.push(ValidateIf((object: any, v: any) => v !== undefined, validationOptions));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ValidateIf((object: any, v: any) => v !== undefined, validationOptions);
|
if (emptyToNull) {
|
||||||
|
decorators.push(Transform(({ value }) => (value === '' ? null : value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyDecorators(...decorators);
|
||||||
}
|
}
|
||||||
|
|
||||||
type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean };
|
type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean };
|
||||||
|
@ -4,9 +4,16 @@
|
|||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function toComboBoxOptions(items: string[]) {
|
export const asComboboxOptions = (values: string[]) =>
|
||||||
return items.map<ComboBoxOption>((item) => ({ label: item, value: item }));
|
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>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@ -21,6 +28,7 @@
|
|||||||
import { generateId } from '$lib/utils/generate-id';
|
import { generateId } from '$lib/utils/generate-id';
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
export let label: string;
|
export let label: string;
|
||||||
export let hideLabel = false;
|
export let hideLabel = false;
|
||||||
|
@ -6,9 +6,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
|
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
|
||||||
import Combobox, { toComboBoxOptions } from '../combobox.svelte';
|
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
|
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let filters: SearchCameraFilter;
|
export let filters: SearchCameraFilter;
|
||||||
@ -22,17 +22,30 @@
|
|||||||
$: handlePromiseError(updateModels(makeFilter));
|
$: handlePromiseError(updateModels(makeFilter));
|
||||||
|
|
||||||
async function updateMakes(model?: string) {
|
async function updateMakes(model?: string) {
|
||||||
makes = await getSearchSuggestions({
|
const results: Array<string | null> = await getSearchSuggestions({
|
||||||
$type: SearchSuggestionType.CameraMake,
|
$type: SearchSuggestionType.CameraMake,
|
||||||
model,
|
model,
|
||||||
|
includeNull: true,
|
||||||
});
|
});
|
||||||
|
if (filters.make && !makes.includes(filters.make)) {
|
||||||
|
filters.make = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
makes = results.map((result) => result ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateModels(make?: string) {
|
async function updateModels(make?: string) {
|
||||||
models = await getSearchSuggestions({
|
const results: Array<string | null> = await getSearchSuggestions({
|
||||||
$type: SearchSuggestionType.CameraModel,
|
$type: SearchSuggestionType.CameraModel,
|
||||||
make,
|
make,
|
||||||
|
includeNull: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const models = results.map((result) => result ?? '');
|
||||||
|
|
||||||
|
if (filters.model && !models.includes(filters.model)) {
|
||||||
|
filters.model = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -44,9 +57,9 @@
|
|||||||
<Combobox
|
<Combobox
|
||||||
label={$t('make')}
|
label={$t('make')}
|
||||||
on:select={({ detail }) => (filters.make = detail?.value)}
|
on:select={({ detail }) => (filters.make = detail?.value)}
|
||||||
options={toComboBoxOptions(makes)}
|
options={asComboboxOptions(makes)}
|
||||||
placeholder={$t('search_camera_make')}
|
placeholder={$t('search_camera_make')}
|
||||||
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
|
selectedOption={asSelectedOption(makeFilter)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -54,9 +67,9 @@
|
|||||||
<Combobox
|
<Combobox
|
||||||
label={$t('model')}
|
label={$t('model')}
|
||||||
on:select={({ detail }) => (filters.model = detail?.value)}
|
on:select={({ detail }) => (filters.model = detail?.value)}
|
||||||
options={toComboBoxOptions(models)}
|
options={asComboboxOptions(models)}
|
||||||
placeholder={$t('search_camera_model')}
|
placeholder={$t('search_camera_model')}
|
||||||
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
|
selectedOption={asSelectedOption(modelFilter)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -42,18 +42,23 @@
|
|||||||
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
|
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
|
||||||
const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>();
|
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 = {
|
let filter: SearchFilter = {
|
||||||
context: 'query' in searchQuery ? searchQuery.query : '',
|
context: 'query' in searchQuery ? searchQuery.query : '',
|
||||||
filename: 'originalFileName' in searchQuery ? searchQuery.originalFileName : undefined,
|
filename: 'originalFileName' in searchQuery ? searchQuery.originalFileName : undefined,
|
||||||
personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []),
|
personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []),
|
||||||
location: {
|
location: {
|
||||||
country: searchQuery.country,
|
country: withNullAsUndefined(searchQuery.country),
|
||||||
state: searchQuery.state,
|
state: withNullAsUndefined(searchQuery.state),
|
||||||
city: searchQuery.city,
|
city: withNullAsUndefined(searchQuery.city),
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
make: searchQuery.make,
|
make: withNullAsUndefined(searchQuery.make),
|
||||||
model: searchQuery.model,
|
model: withNullAsUndefined(searchQuery.model),
|
||||||
},
|
},
|
||||||
date: {
|
date: {
|
||||||
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
|
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
|
||||||
|
@ -7,9 +7,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
|
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
|
||||||
import Combobox, { toComboBoxOptions } from '../combobox.svelte';
|
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
|
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let filters: SearchLocationFilter;
|
export let filters: SearchLocationFilter;
|
||||||
@ -25,33 +25,41 @@
|
|||||||
$: handlePromiseError(updateCities(countryFilter, stateFilter));
|
$: handlePromiseError(updateCities(countryFilter, stateFilter));
|
||||||
|
|
||||||
async function updateCountries() {
|
async function updateCountries() {
|
||||||
countries = await getSearchSuggestions({
|
const results: Array<string | null> = await getSearchSuggestions({
|
||||||
$type: SearchSuggestionType.Country,
|
$type: SearchSuggestionType.Country,
|
||||||
|
includeNull: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
countries = results.map((result) => result ?? '');
|
||||||
|
|
||||||
if (filters.country && !countries.includes(filters.country)) {
|
if (filters.country && !countries.includes(filters.country)) {
|
||||||
filters.country = undefined;
|
filters.country = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateStates(country?: string) {
|
async function updateStates(country?: string) {
|
||||||
states = await getSearchSuggestions({
|
const results: Array<string | null> = await getSearchSuggestions({
|
||||||
$type: SearchSuggestionType.State,
|
$type: SearchSuggestionType.State,
|
||||||
country,
|
country,
|
||||||
|
includeNull: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
states = results.map((result) => result ?? '');
|
||||||
|
|
||||||
if (filters.state && !states.includes(filters.state)) {
|
if (filters.state && !states.includes(filters.state)) {
|
||||||
filters.state = undefined;
|
filters.state = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCities(country?: string, state?: string) {
|
async function updateCities(country?: string, state?: string) {
|
||||||
cities = await getSearchSuggestions({
|
const results: Array<string | null> = await getSearchSuggestions({
|
||||||
$type: SearchSuggestionType.City,
|
$type: SearchSuggestionType.City,
|
||||||
country,
|
country,
|
||||||
state,
|
state,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cities = results.map((result) => result ?? '');
|
||||||
|
|
||||||
if (filters.city && !cities.includes(filters.city)) {
|
if (filters.city && !cities.includes(filters.city)) {
|
||||||
filters.city = undefined;
|
filters.city = undefined;
|
||||||
}
|
}
|
||||||
@ -66,9 +74,9 @@
|
|||||||
<Combobox
|
<Combobox
|
||||||
label={$t('country')}
|
label={$t('country')}
|
||||||
on:select={({ detail }) => (filters.country = detail?.value)}
|
on:select={({ detail }) => (filters.country = detail?.value)}
|
||||||
options={toComboBoxOptions(countries)}
|
options={asComboboxOptions(countries)}
|
||||||
placeholder={$t('search_country')}
|
placeholder={$t('search_country')}
|
||||||
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
|
selectedOption={asSelectedOption(filters.country)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -76,9 +84,9 @@
|
|||||||
<Combobox
|
<Combobox
|
||||||
label={$t('state')}
|
label={$t('state')}
|
||||||
on:select={({ detail }) => (filters.state = detail?.value)}
|
on:select={({ detail }) => (filters.state = detail?.value)}
|
||||||
options={toComboBoxOptions(states)}
|
options={asComboboxOptions(states)}
|
||||||
placeholder={$t('search_state')}
|
placeholder={$t('search_state')}
|
||||||
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
|
selectedOption={asSelectedOption(filters.state)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -86,9 +94,9 @@
|
|||||||
<Combobox
|
<Combobox
|
||||||
label={$t('city')}
|
label={$t('city')}
|
||||||
on:select={({ detail }) => (filters.city = detail?.value)}
|
on:select={({ detail }) => (filters.city = detail?.value)}
|
||||||
options={toComboBoxOptions(cities)}
|
options={asComboboxOptions(cities)}
|
||||||
placeholder={$t('search_city')}
|
placeholder={$t('search_city')}
|
||||||
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
|
selectedOption={asSelectedOption(filters.city)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -259,6 +259,8 @@
|
|||||||
{#await getPersonName(value) then personName}
|
{#await getPersonName(value) then personName}
|
||||||
{personName}
|
{personName}
|
||||||
{/await}
|
{/await}
|
||||||
|
{:else if value === null || value === ''}
|
||||||
|
{$t('unknown')}
|
||||||
{:else}
|
{:else}
|
||||||
{value}
|
{value}
|
||||||
{/if}
|
{/if}
|
||||||
|
Loading…
Reference in New Issue
Block a user