1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-26 10:50:29 +02:00

feat: enhance search (#7127)

* feat: hybrid search

* fixing normal search

* building out the query

* okla

* filters

* date

* order by date

* Remove hybrid search endpoint

* remove search hybrid endpoint

* faces query

* search for person

* search and pagination

* with exif

* with exif

* justify gallery viewer

* memory view

* Fixed userId is null

* openapi and styling

* searchdto

* lint and format

* remove term

* generate sql

* fix test

* chips

* not showing true

* pr feedback

* pr feedback

* nit name

* linting

* pr feedback

* styling

* linting
This commit is contained in:
Alex 2024-02-17 11:00:55 -06:00 committed by GitHub
parent 60ba37b3a7
commit 69983ff83a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1298 additions and 1752 deletions

View File

@ -90,6 +90,7 @@ doc/MapMarkerResponseDto.md
doc/MapTheme.md doc/MapTheme.md
doc/MemoryLaneResponseDto.md doc/MemoryLaneResponseDto.md
doc/MergePersonDto.md doc/MergePersonDto.md
doc/MetadataSearchDto.md
doc/ModelType.md doc/ModelType.md
doc/OAuthApi.md doc/OAuthApi.md
doc/OAuthAuthorizeResponseDto.md doc/OAuthAuthorizeResponseDto.md
@ -137,6 +138,7 @@ doc/SharedLinkResponseDto.md
doc/SharedLinkType.md doc/SharedLinkType.md
doc/SignUpDto.md doc/SignUpDto.md
doc/SmartInfoResponseDto.md doc/SmartInfoResponseDto.md
doc/SmartSearchDto.md
doc/SystemConfigApi.md doc/SystemConfigApi.md
doc/SystemConfigDto.md doc/SystemConfigDto.md
doc/SystemConfigFFmpegDto.md doc/SystemConfigFFmpegDto.md
@ -288,6 +290,7 @@ lib/model/map_marker_response_dto.dart
lib/model/map_theme.dart lib/model/map_theme.dart
lib/model/memory_lane_response_dto.dart lib/model/memory_lane_response_dto.dart
lib/model/merge_person_dto.dart lib/model/merge_person_dto.dart
lib/model/metadata_search_dto.dart
lib/model/model_type.dart lib/model/model_type.dart
lib/model/o_auth_authorize_response_dto.dart lib/model/o_auth_authorize_response_dto.dart
lib/model/o_auth_callback_dto.dart lib/model/o_auth_callback_dto.dart
@ -329,6 +332,7 @@ lib/model/shared_link_response_dto.dart
lib/model/shared_link_type.dart lib/model/shared_link_type.dart
lib/model/sign_up_dto.dart lib/model/sign_up_dto.dart
lib/model/smart_info_response_dto.dart lib/model/smart_info_response_dto.dart
lib/model/smart_search_dto.dart
lib/model/system_config_dto.dart lib/model/system_config_dto.dart
lib/model/system_config_f_fmpeg_dto.dart lib/model/system_config_f_fmpeg_dto.dart
lib/model/system_config_job_dto.dart lib/model/system_config_job_dto.dart
@ -457,6 +461,7 @@ test/map_marker_response_dto_test.dart
test/map_theme_test.dart test/map_theme_test.dart
test/memory_lane_response_dto_test.dart test/memory_lane_response_dto_test.dart
test/merge_person_dto_test.dart test/merge_person_dto_test.dart
test/metadata_search_dto_test.dart
test/model_type_test.dart test/model_type_test.dart
test/o_auth_api_test.dart test/o_auth_api_test.dart
test/o_auth_authorize_response_dto_test.dart test/o_auth_authorize_response_dto_test.dart
@ -504,6 +509,7 @@ test/shared_link_response_dto_test.dart
test/shared_link_type_test.dart test/shared_link_type_test.dart
test/sign_up_dto_test.dart test/sign_up_dto_test.dart
test/smart_info_response_dto_test.dart test/smart_info_response_dto_test.dart
test/smart_search_dto_test.dart
test/system_config_api_test.dart test/system_config_api_test.dart
test/system_config_dto_test.dart test/system_config_dto_test.dart
test/system_config_f_fmpeg_dto_test.dart test/system_config_f_fmpeg_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/MetadataSearchDto.md generated Normal file

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/SmartSearchDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -2256,6 +2256,14 @@
"type": "boolean" "type": "boolean"
} }
}, },
{
"name": "isNotInAlbum",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{ {
"name": "isOffline", "name": "isOffline",
"required": false, "required": false,
@ -2345,6 +2353,17 @@
"type": "number" "type": "number"
} }
}, },
{
"name": "personIds",
"required": false,
"in": "query",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{ {
"name": "resizePath", "name": "resizePath",
"required": false, "required": false,
@ -4526,350 +4545,21 @@
} }
}, },
"/search/metadata": { "/search/metadata": {
"get": { "post": {
"operationId": "searchMetadata", "operationId": "searchMetadata",
"parameters": [ "parameters": [],
{ "requestBody": {
"name": "checksum", "content": {
"required": false, "application/json": {
"in": "query",
"schema": { "schema": {
"type": "string" "$ref": "#/components/schemas/MetadataSearchDto"
}
} }
}, },
{ "required": true
"name": "city",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}, },
{
"name": "country",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "createdAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "createdBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "deviceAssetId",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "deviceId",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "encodedVideoPath",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "id",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isEncoded",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isExternal",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isMotion",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isOffline",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isReadOnly",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isVisible",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "lensModel",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "libraryId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "make",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "model",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "order",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetOrder"
}
},
{
"name": "originalFileName",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "originalPath",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "page",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "resizePath",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "size",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "state",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "takenAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "takenBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "trashedAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "trashedBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "type",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetTypeEnum"
}
},
{
"name": "updatedAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "updatedBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "webpPath",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "withArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withDeleted",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withExif",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withPeople",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withStacked",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": { "responses": {
"200": { "201": {
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@ -4949,269 +4639,21 @@
} }
}, },
"/search/smart": { "/search/smart": {
"get": { "post": {
"operationId": "searchSmart", "operationId": "searchSmart",
"parameters": [ "parameters": [],
{ "requestBody": {
"name": "city", "content": {
"required": false, "application/json": {
"in": "query",
"schema": { "schema": {
"type": "string" "$ref": "#/components/schemas/SmartSearchDto"
}
} }
}, },
{ "required": true
"name": "country",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}, },
{
"name": "createdAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "createdBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "deviceId",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isEncoded",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isExternal",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isMotion",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isOffline",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isReadOnly",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isVisible",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "lensModel",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "libraryId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "make",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "model",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "page",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "query",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "size",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "state",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "takenAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "takenBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "trashedAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "trashedBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "type",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetTypeEnum"
}
},
{
"name": "updatedAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "updatedBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "withArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withDeleted",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withExif",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": { "responses": {
"200": { "201": {
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@ -8805,6 +8247,153 @@
], ],
"type": "object" "type": "object"
}, },
"MetadataSearchDto": {
"properties": {
"checksum": {
"type": "string"
},
"city": {
"type": "string"
},
"country": {
"type": "string"
},
"createdAfter": {
"format": "date-time",
"type": "string"
},
"createdBefore": {
"format": "date-time",
"type": "string"
},
"deviceAssetId": {
"type": "string"
},
"deviceId": {
"type": "string"
},
"encodedVideoPath": {
"type": "string"
},
"id": {
"format": "uuid",
"type": "string"
},
"isArchived": {
"type": "boolean"
},
"isEncoded": {
"type": "boolean"
},
"isExternal": {
"type": "boolean"
},
"isFavorite": {
"type": "boolean"
},
"isMotion": {
"type": "boolean"
},
"isNotInAlbum": {
"type": "boolean"
},
"isOffline": {
"type": "boolean"
},
"isReadOnly": {
"type": "boolean"
},
"isVisible": {
"type": "boolean"
},
"lensModel": {
"type": "string"
},
"libraryId": {
"format": "uuid",
"type": "string"
},
"make": {
"type": "string"
},
"model": {
"type": "string"
},
"order": {
"$ref": "#/components/schemas/AssetOrder"
},
"originalFileName": {
"type": "string"
},
"originalPath": {
"type": "string"
},
"page": {
"type": "number"
},
"personIds": {
"items": {
"type": "string"
},
"type": "array"
},
"resizePath": {
"type": "string"
},
"size": {
"type": "number"
},
"state": {
"type": "string"
},
"takenAfter": {
"format": "date-time",
"type": "string"
},
"takenBefore": {
"format": "date-time",
"type": "string"
},
"trashedAfter": {
"format": "date-time",
"type": "string"
},
"trashedBefore": {
"format": "date-time",
"type": "string"
},
"type": {
"$ref": "#/components/schemas/AssetTypeEnum"
},
"updatedAfter": {
"format": "date-time",
"type": "string"
},
"updatedBefore": {
"format": "date-time",
"type": "string"
},
"webpPath": {
"type": "string"
},
"withArchived": {
"type": "boolean"
},
"withDeleted": {
"type": "boolean"
},
"withExif": {
"type": "boolean"
},
"withPeople": {
"type": "boolean"
},
"withStacked": {
"type": "boolean"
}
},
"type": "object"
},
"ModelType": { "ModelType": {
"enum": [ "enum": [
"facial-recognition", "facial-recognition",
@ -9760,6 +9349,116 @@
}, },
"type": "object" "type": "object"
}, },
"SmartSearchDto": {
"properties": {
"city": {
"type": "string"
},
"country": {
"type": "string"
},
"createdAfter": {
"format": "date-time",
"type": "string"
},
"createdBefore": {
"format": "date-time",
"type": "string"
},
"deviceId": {
"type": "string"
},
"isArchived": {
"type": "boolean"
},
"isEncoded": {
"type": "boolean"
},
"isExternal": {
"type": "boolean"
},
"isFavorite": {
"type": "boolean"
},
"isMotion": {
"type": "boolean"
},
"isOffline": {
"type": "boolean"
},
"isReadOnly": {
"type": "boolean"
},
"isVisible": {
"type": "boolean"
},
"lensModel": {
"type": "string"
},
"libraryId": {
"format": "uuid",
"type": "string"
},
"make": {
"type": "string"
},
"model": {
"type": "string"
},
"page": {
"type": "number"
},
"query": {
"type": "string"
},
"size": {
"type": "number"
},
"state": {
"type": "string"
},
"takenAfter": {
"format": "date-time",
"type": "string"
},
"takenBefore": {
"format": "date-time",
"type": "string"
},
"trashedAfter": {
"format": "date-time",
"type": "string"
},
"trashedBefore": {
"format": "date-time",
"type": "string"
},
"type": {
"$ref": "#/components/schemas/AssetTypeEnum"
},
"updatedAfter": {
"format": "date-time",
"type": "string"
},
"updatedBefore": {
"format": "date-time",
"type": "string"
},
"withArchived": {
"type": "boolean"
},
"withDeleted": {
"type": "boolean"
},
"withExif": {
"type": "boolean"
}
},
"required": [
"query"
],
"type": "object"
},
"SystemConfigDto": { "SystemConfigDto": {
"properties": { "properties": {
"ffmpeg": { "ffmpeg": {

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -66,13 +66,14 @@ export interface SearchAssetIDOptions {
id?: string; id?: string;
} }
export interface SearchUserIDOptions { export interface SearchUserIdOptions {
deviceId?: string; deviceId?: string;
libraryId?: string; libraryId?: string;
ownerId?: string; ownerId?: string;
userIds?: string[];
} }
export type SearchIDOptions = SearchAssetIDOptions & SearchUserIDOptions; export type SearchIdOptions = SearchAssetIDOptions & SearchUserIdOptions;
export interface SearchStatusOptions { export interface SearchStatusOptions {
isArchived?: boolean; isArchived?: boolean;
@ -83,6 +84,7 @@ export interface SearchStatusOptions {
isOffline?: boolean; isOffline?: boolean;
isReadOnly?: boolean; isReadOnly?: boolean;
isVisible?: boolean; isVisible?: boolean;
isNotInAlbum?: boolean;
type?: AssetType; type?: AssetType;
withArchived?: boolean; withArchived?: boolean;
withDeleted?: boolean; withDeleted?: boolean;
@ -132,6 +134,10 @@ export interface SearchEmbeddingOptions {
userIds: string[]; userIds: string[];
} }
export interface SearchPeopleOptions {
personIds?: string[];
}
export interface SearchOrderOptions { export interface SearchOrderOptions {
orderDirection?: 'ASC' | 'DESC'; orderDirection?: 'ASC' | 'DESC';
} }
@ -142,12 +148,14 @@ export interface SearchPaginationOptions {
} }
export type AssetSearchOptions = SearchDateOptions & export type AssetSearchOptions = SearchDateOptions &
SearchIDOptions & SearchIdOptions &
SearchExifOptions & SearchExifOptions &
SearchOrderOptions & SearchOrderOptions &
SearchPathOptions & SearchPathOptions &
SearchRelationOptions & SearchRelationOptions &
SearchStatusOptions; SearchStatusOptions &
SearchUserIdOptions &
SearchPeopleOptions;
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>; export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
@ -156,7 +164,8 @@ export type SmartSearchOptions = SearchDateOptions &
SearchExifOptions & SearchExifOptions &
SearchOneToOneRelationOptions & SearchOneToOneRelationOptions &
SearchStatusOptions & SearchStatusOptions &
SearchUserIDOptions; SearchUserIdOptions &
SearchPeopleOptions;
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
hasPerson?: boolean; hasPerson?: boolean;

View File

@ -169,6 +169,12 @@ export class MetadataSearchDto extends BaseSearchDto {
@Optional() @Optional()
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
order?: AssetOrder; order?: AssetOrder;
@QueryBoolean({ optional: true })
isNotInAlbum?: boolean;
@Optional()
personIds?: string[];
} }
export class SmartSearchDto extends BaseSearchDto { export class SmartSearchDto extends BaseSearchDto {

View File

@ -60,6 +60,7 @@ export class SearchService {
async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> { async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
let checksum: Buffer | undefined; let checksum: Buffer | undefined;
const userIds = await this.getUserIdsToSearch(auth);
if (dto.checksum) { if (dto.checksum) {
const encoding = dto.checksum.length === 28 ? 'base64' : 'hex'; const encoding = dto.checksum.length === 28 ? 'base64' : 'hex';
@ -74,7 +75,7 @@ export class SearchService {
{ {
...dto, ...dto,
checksum, checksum,
ownerId: auth.user.id, userIds,
orderDirection: dto.order ? enumToOrder[dto.order] : 'DESC', orderDirection: dto.order ? enumToOrder[dto.order] : 'DESC',
}, },
); );

View File

@ -10,7 +10,7 @@ import {
SmartSearchDto, SmartSearchDto,
} from '@app/domain'; } from '@app/domain';
import { SearchSuggestionRequestDto } from '@app/domain/search/dto/search-suggestion.dto'; import { SearchSuggestionRequestDto } from '@app/domain/search/dto/search-suggestion.dto';
import { Controller, Get, Query } from '@nestjs/common'; import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Auth, Authenticated } from '../app.guard'; import { Auth, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils'; import { UseValidation } from '../app.utils';
@ -22,13 +22,13 @@ import { UseValidation } from '../app.utils';
export class SearchController { export class SearchController {
constructor(private service: SearchService) {} constructor(private service: SearchService) {}
@Get('metadata') @Post('metadata')
searchMetadata(@Auth() auth: AuthDto, @Query() dto: MetadataSearchDto): Promise<SearchResponseDto> { searchMetadata(@Auth() auth: AuthDto, @Body() dto: MetadataSearchDto): Promise<SearchResponseDto> {
return this.service.searchMetadata(auth, dto); return this.service.searchMetadata(auth, dto);
} }
@Get('smart') @Post('smart')
searchSmart(@Auth() auth: AuthDto, @Query() dto: SmartSearchDto): Promise<SearchResponseDto> { searchSmart(@Auth() auth: AuthDto, @Body() dto: SmartSearchDto): Promise<SearchResponseDto> {
return this.service.searchSmart(auth, dto); return this.service.searchSmart(auth, dto);
} }

View File

@ -139,14 +139,27 @@ export function searchAssetBuilder(
); );
const exifInfo = _.omitBy(_.pick(options, ['city', 'country', 'lensModel', 'make', 'model', 'state']), _.isUndefined); const exifInfo = _.omitBy(_.pick(options, ['city', 'country', 'lensModel', 'make', 'model', 'state']), _.isUndefined);
if (Object.keys(exifInfo).length > 0) { const hasExifQuery = Object.keys(exifInfo).length > 0;
builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo');
if (options.withExif && !hasExifQuery) {
builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo');
}
if (hasExifQuery) {
options.withExif
? builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo')
: builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo');
builder.andWhere({ exifInfo }); builder.andWhere({ exifInfo });
} }
const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId', 'ownerId']); const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId']);
builder.andWhere(_.omitBy(id, _.isUndefined)); builder.andWhere(_.omitBy(id, _.isUndefined));
if (options.userIds) {
builder.andWhere(`${builder.alias}.ownerId IN (:...userIds)`, { userIds: options.userIds });
}
const path = _.pick(options, ['encodedVideoPath', 'originalFileName', 'originalPath', 'resizePath', 'webpPath']); const path = _.pick(options, ['encodedVideoPath', 'originalFileName', 'originalPath', 'resizePath', 'webpPath']);
builder.andWhere(_.omitBy(path, _.isUndefined)); builder.andWhere(_.omitBy(path, _.isUndefined));
@ -164,8 +177,8 @@ export function searchAssetBuilder(
), ),
); );
if (options.withExif) { if (options.isNotInAlbum) {
builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo'); builder.leftJoin(`${builder.alias}.albums`, 'albums').andWhere('albums.id IS NULL');
} }
if (options.withFaces || options.withPeople) { if (options.withFaces || options.withPeople) {
@ -180,6 +193,18 @@ export function searchAssetBuilder(
builder.leftJoinAndSelect(`${builder.alias}.smartInfo`, 'smartInfo'); builder.leftJoinAndSelect(`${builder.alias}.smartInfo`, 'smartInfo');
} }
if (options.personIds && options.personIds.length > 0) {
builder
.leftJoin(`${builder.alias}.faces`, 'faces')
.andWhere('faces.personId IN (:...personIds)', { personIds: options.personIds })
.addGroupBy(`${builder.alias}.id`)
.having('COUNT(faces.id) = :personCount', { personCount: options.personIds.length });
if (options.withExif) {
builder.addGroupBy('exifInfo.assetId');
}
}
if (options.withStacked) { if (options.withStacked) {
builder builder
.leftJoinAndSelect(`${builder.alias}.stack`, 'stack') .leftJoinAndSelect(`${builder.alias}.stack`, 'stack')

View File

@ -58,6 +58,7 @@ export class SearchRepository implements ISearchRepository {
ownerId: DummyValue.UUID, ownerId: DummyValue.UUID,
withStacked: true, withStacked: true,
isFavorite: true, isFavorite: true,
ownerIds: [DummyValue.UUID],
}, },
], ],
}) })
@ -66,7 +67,6 @@ export class SearchRepository implements ISearchRepository {
builder = searchAssetBuilder(builder, options); builder = searchAssetBuilder(builder, options);
builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC');
return paginatedBuilder<AssetEntity>(builder, { return paginatedBuilder<AssetEntity>(builder, {
mode: PaginationMode.SKIP_TAKE, mode: PaginationMode.SKIP_TAKE,
skip: (pagination.page - 1) * pagination.size, skip: (pagination.page - 1) * pagination.size,

View File

@ -77,9 +77,9 @@ FROM
( (
"asset"."fileCreatedAt" >= $1 "asset"."fileCreatedAt" >= $1
AND "exifInfo"."lensModel" = $2 AND "exifInfo"."lensModel" = $2
AND "asset"."ownerId" = $3
AND 1 = 1 AND 1 = 1
AND "asset"."isFavorite" = $4 AND 1 = 1
AND "asset"."isFavorite" = $3
AND ( AND (
"stack"."primaryAssetId" = "asset"."id" "stack"."primaryAssetId" = "asset"."id"
OR "asset"."stackId" IS NULL OR "asset"."stackId" IS NULL

View File

@ -38,6 +38,7 @@ module.exports = {
'unicorn/prevent-abbreviations': 'off', 'unicorn/prevent-abbreviations': 'off',
'unicorn/no-nested-ternary': 'off', 'unicorn/no-nested-ternary': 'off',
'unicorn/consistent-function-scoping': 'off', 'unicorn/consistent-function-scoping': 'off',
'unicorn/prefer-top-level-await': 'off',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'warn', 'warn',
{ {

View File

@ -7,6 +7,7 @@
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import type { Viewport } from '$lib/stores/assets.store';
import { memoryStore } from '$lib/stores/memory.store'; import { memoryStore } from '$lib/stores/memory.store';
import { getAssetThumbnailUrl } from '$lib/utils'; import { getAssetThumbnailUrl } from '$lib/utils';
import { fromLocalDateTime } from '$lib/utils/timeline-util'; import { fromLocalDateTime } from '$lib/utils/timeline-util';
@ -34,6 +35,7 @@
$: canGoForward = !!(nextMemory || nextAsset); $: canGoForward = !!(nextMemory || nextAsset);
$: canGoBack = !!(previousMemory || previousAsset); $: canGoBack = !!(previousMemory || previousAsset);
const viewport: Viewport = { width: 0, height: 0 };
const toNextMemory = () => goto(`?${QueryParameter.MEMORY_INDEX}=${memoryIndex + 1}`); const toNextMemory = () => goto(`?${QueryParameter.MEMORY_INDEX}=${memoryIndex + 1}`);
const toPreviousMemory = () => goto(`?${QueryParameter.MEMORY_INDEX}=${memoryIndex - 1}`); const toPreviousMemory = () => goto(`?${QueryParameter.MEMORY_INDEX}=${memoryIndex - 1}`);
@ -251,7 +253,7 @@
<!-- GALERY VIEWER --> <!-- GALERY VIEWER -->
<section class="bg-immich-dark-gray pl-4"> <section class="bg-immich-dark-gray m-4">
<div <div
class="sticky mb-10 mt-4 flex place-content-center place-items-center transition-all" class="sticky mb-10 mt-4 flex place-content-center place-items-center transition-all"
class:opacity-0={galleryInView} class:opacity-0={galleryInView}
@ -268,8 +270,13 @@
on:hidden={() => (galleryInView = false)} on:hidden={() => (galleryInView = false)}
bottom={-200} bottom={-200}
> >
<div id="gallery-memory" bind:this={memoryGallery}> <div
<GalleryViewer assets={currentMemory.assets} /> id="gallery-memory"
bind:this={memoryGallery}
bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width}
>
<GalleryViewer assets={currentMemory.assets} {viewport} />
</div> </div>
</IntersectionObserver> </IntersectionObserver>
</section> </section>

View File

@ -5,7 +5,12 @@
import type { AssetStore, Viewport } from '$lib/stores/assets.store'; import type { AssetStore, Viewport } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils'; import { getAssetRatio } from '$lib/utils/asset-utils';
import { formatGroupTitle, fromLocalDateTime, splitBucketIntoDateGroups } from '$lib/utils/timeline-util'; import {
calculateWidth,
formatGroupTitle,
fromLocalDateTime,
splitBucketIntoDateGroups,
} from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import justifiedLayout from 'justified-layout'; import justifiedLayout from 'justified-layout';
@ -36,12 +41,6 @@
let actualBucketHeight: number; let actualBucketHeight: number;
let hoveredDateGroup = ''; let hoveredDateGroup = '';
interface LayoutBox {
top: number;
left: number;
width: number;
}
$: assetsGroupByDate = splitBucketIntoDateGroups(assets, $locale); $: assetsGroupByDate = splitBucketIntoDateGroups(assets, $locale);
$: geometry = (() => { $: geometry = (() => {
@ -80,17 +79,6 @@
}); });
} }
const calculateWidth = (boxes: LayoutBox[]): number => {
let width = 0;
for (const box of boxes) {
if (box.top < 100) {
width = box.left + box.width;
}
}
return width;
};
const assetClickHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => { const assetClickHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => {
if (isSelectionMode || $isMultiSelectState) { if (isSelectionMode || $isMultiSelectState) {
assetSelectHandler(asset, assetsInDateGroup, groupTitle); assetSelectHandler(asset, assetsInDateGroup, groupTitle);

View File

@ -16,10 +16,12 @@
import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte'; import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte'; import ImmichLogo from '../shared-components/immich-logo.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { NotificationType, notificationController } from '../shared-components/notification/notification';
import type { Viewport } from '$lib/stores/assets.store';
export let sharedLink: SharedLinkResponseDto; export let sharedLink: SharedLinkResponseDto;
export let isOwned: boolean; export let isOwned: boolean;
const viewport: Viewport = { width: 0, height: 0 };
let selectedAssets: Set<AssetResponseDto> = new Set(); let selectedAssets: Set<AssetResponseDto> = new Set();
$: assets = sharedLink.assets; $: assets = sharedLink.assets;
@ -97,7 +99,7 @@
</svelte:fragment> </svelte:fragment>
</ControlAppBar> </ControlAppBar>
{/if} {/if}
<section class="my-[160px] flex flex-col px-6 sm:px-12 md:px-24 lg:px-40"> <section class="my-[160px] mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
<GalleryViewer {assets} bind:selectedAssets /> <GalleryViewer {assets} bind:selectedAssets {viewport} />
</section> </section>
</section> </section>

View File

@ -51,7 +51,7 @@
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent"> <div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
<div <div
id="asset-selection-app-bar" id="asset-selection-app-bar"
class={`grid grid-cols-[10%_80%_10%] justify-between md:grid-cols-[20%_60%_20%] lg:grid-cols-3 ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${ class={`grid grid-cols-[10%_80%_10%] justify-between md:grid-cols-[15%_70%_15%] lg:grid-cols-[25%_50%_25%] ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${
forceDark && 'bg-immich-dark-gray text-white' forceDark && 'bg-immich-dark-gray text-white'
}`} }`}
> >

View File

@ -2,13 +2,14 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { BucketPosition } from '$lib/stores/assets.store'; import type { BucketPosition, Viewport } from '$lib/stores/assets.store';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getThumbnailSize } from '$lib/utils/thumbnail-util'; import { type AssetResponseDto } from '@immich/sdk';
import { ThumbnailFormat, type AssetResponseDto } from '@immich/sdk';
import { createEventDispatcher, onDestroy } from 'svelte'; import { createEventDispatcher, onDestroy } from 'svelte';
import { flip } from 'svelte/animate';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import justifiedLayout from 'justified-layout';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { calculateWidth } from '$lib/utils/timeline-util';
const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>(); const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>();
@ -16,14 +17,12 @@
export let selectedAssets: Set<AssetResponseDto> = new Set(); export let selectedAssets: Set<AssetResponseDto> = new Set();
export let disableAssetSelect = false; export let disableAssetSelect = false;
export let showArchiveIcon = false; export let showArchiveIcon = false;
export let viewport: Viewport;
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
let selectedAsset: AssetResponseDto; let selectedAsset: AssetResponseDto;
let currentViewAssetIndex = 0; let currentViewAssetIndex = 0;
let viewWidth: number;
$: thumbnailSize = getThumbnailSize(assets.length, viewWidth);
$: isMultiSelectionMode = selectedAssets.size > 0; $: isMultiSelectionMode = selectedAssets.size > 0;
const viewAssetHandler = (event: CustomEvent) => { const viewAssetHandler = (event: CustomEvent) => {
@ -86,23 +85,45 @@
onDestroy(() => { onDestroy(() => {
$showAssetViewer = false; $showAssetViewer = false;
}); });
$: geometry = (() => {
const justifiedLayoutResult = justifiedLayout(
assets.map((asset) => getAssetRatio(asset)),
{
boxSpacing: 2,
containerWidth: Math.floor(viewport.width),
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235,
},
);
return {
...justifiedLayoutResult,
containerWidth: calculateWidth(justifiedLayoutResult.boxes),
};
})();
</script> </script>
{#if assets.length > 0} {#if assets.length > 0}
<div class="flex w-full flex-wrap gap-1 pb-20" bind:clientWidth={viewWidth}> <div class="relative" style="height: {geometry.containerHeight}px;width: {geometry.containerWidth}px ">
{#each assets as asset, i (asset.id)} {#each assets as asset, i (i)}
<div animate:flip={{ duration: 500 }}> <div
class="absolute"
style="width: {geometry.boxes[i].width}px; height: {geometry.boxes[i].height}px; top: {geometry.boxes[i]
.top}px; left: {geometry.boxes[i].left}px"
>
<Thumbnail <Thumbnail
{asset} {asset}
{thumbnailSize}
readonly={disableAssetSelect} readonly={disableAssetSelect}
format={assets.length < 7 ? ThumbnailFormat.Jpeg : ThumbnailFormat.Webp}
on:click={(e) => (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))} on:click={(e) => (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))}
on:select={selectAssetHandler} on:select={selectAssetHandler}
on:intersected={(event) => on:intersected={(event) =>
i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined} i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined}
selected={selectedAssets.has(asset)} selected={selectedAssets.has(asset)}
{showArchiveIcon} {showArchiveIcon}
thumbnailWidth={geometry.boxes[i].width}
thumbnailHeight={geometry.boxes[i].height}
/> />
</div> </div>
{/each} {/each}

View File

@ -2,12 +2,18 @@
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store'; import {
isSearchEnabled,
preventRaceConditionSearchBar,
savedSearchTerms,
searchQuery,
} from '$lib/stores/search.store';
import { clickOutside } from '$lib/utils/click-outside'; import { clickOutside } from '$lib/utils/click-outside';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js'; import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import IconButton from '$lib/components/elements/buttons/icon-button.svelte'; import IconButton from '$lib/components/elements/buttons/icon-button.svelte';
import SearchHistoryBox from './search-history-box.svelte'; import SearchHistoryBox from './search-history-box.svelte';
import SearchFilterBox from './search-filter-box.svelte'; import SearchFilterBox from './search-filter-box.svelte';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
export let value = ''; export let value = '';
export let grayTheme: boolean; export let grayTheme: boolean;
@ -17,28 +23,17 @@
let showFilter = false; let showFilter = false;
$: showClearIcon = value.length > 0; $: showClearIcon = value.length > 0;
function onSearch() { const onSearch = (payload: SmartSearchDto | MetadataSearchDto) => {
let smartSearch = 'true';
let searchValue = value;
if (value.slice(0, 2) == 'm:') {
smartSearch = 'false';
searchValue = value.slice(2);
}
$savedSearchTerms = $savedSearchTerms.filter((item) => item !== value);
saveSearchTerm(value);
const parameters = new URLSearchParams({ const parameters = new URLSearchParams({
q: searchValue, query: JSON.stringify(payload),
smart: smartSearch,
take: '100',
}); });
showHistory = false; showHistory = false;
showFilter = false;
$isSearchEnabled = false; $isSearchEnabled = false;
$searchQuery = payload;
goto(`${AppRoute.SEARCH}?${parameters}`, { invalidateAll: true }); goto(`${AppRoute.SEARCH}?${parameters}`, { invalidateAll: true });
} };
const clearSearchTerm = (searchTerm: string) => { const clearSearchTerm = (searchTerm: string) => {
input.focus(); input.focus();
@ -70,6 +65,26 @@
showHistory = false; showHistory = false;
$isSearchEnabled = false; $isSearchEnabled = false;
showFilter = false;
};
const onHistoryTermClick = (searchTerm: string) => {
const searchPayload = { query: searchTerm };
onSearch(searchPayload);
};
const onFilterClick = () => {
showFilter = !showFilter;
value = '';
if (showFilter) {
showHistory = false;
}
};
const onSubmit = () => {
onSearch({ query: value });
saveSearchTerm(value);
}; };
</script> </script>
@ -80,7 +95,7 @@
class="relative select-text text-sm" class="relative select-text text-sm"
action={AppRoute.SEARCH} action={AppRoute.SEARCH}
on:reset={() => (value = '')} on:reset={() => (value = '')}
on:submit|preventDefault={() => onSearch()} on:submit|preventDefault={onSubmit}
> >
<label> <label>
<div class="absolute inset-y-0 left-0 flex items-center pl-6"> <div class="absolute inset-y-0 left-0 flex items-center pl-6">
@ -107,9 +122,9 @@
disabled={showFilter} disabled={showFilter}
/> />
<div class="absolute inset-y-0 right-5 flex items-center pl-6"> <div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all">
<div class="dark:text-immich-dark-fg/75"> <div class="dark:text-immich-dark-fg/75">
<IconButton on:click={() => (showFilter = !showFilter)} title="Show search options"> <IconButton on:click={onFilterClick} title="Show search options">
<Icon path={mdiTune} size="1.5em" /> <Icon path={mdiTune} size="1.5em" />
</IconButton> </IconButton>
</div> </div>
@ -131,15 +146,12 @@
<SearchHistoryBox <SearchHistoryBox
on:clearAllSearchTerms={clearAllSearchTerms} on:clearAllSearchTerms={clearAllSearchTerms}
on:clearSearchTerm={({ detail: searchTerm }) => clearSearchTerm(searchTerm)} on:clearSearchTerm={({ detail: searchTerm }) => clearSearchTerm(searchTerm)}
on:selectSearchTerm={({ detail: searchTerm }) => { on:selectSearchTerm={({ detail: searchTerm }) => onHistoryTermClick(searchTerm)}
value = searchTerm;
onSearch();
}}
/> />
{/if} {/if}
{#if showFilter} {#if showFilter}
<SearchFilterBox /> <SearchFilterBox on:search={({ detail }) => onSearch(detail)} />
{/if} {/if}
</form> </form>
</div> </div>

View File

@ -4,12 +4,20 @@
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils'; import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { SearchSuggestionType, type PersonResponseDto } from '@immich/sdk'; import {
AssetTypeEnum,
SearchSuggestionType,
type PersonResponseDto,
type SmartSearchDto,
type MetadataSearchDto,
} from '@immich/sdk';
import { getAllPeople, getSearchSuggestions } from '@immich/sdk'; import { getAllPeople, getSearchSuggestions } from '@immich/sdk';
import { mdiArrowRight, mdiClose } from '@mdi/js'; import { mdiArrowRight, mdiClose } from '@mdi/js';
import { onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import Combobox, { type ComboBoxOption } from '../combobox.svelte'; import Combobox, { type ComboBoxOption } from '../combobox.svelte';
import { DateTime } from 'luxon';
import { searchQuery } from '$lib/stores/search.store';
enum MediaType { enum MediaType {
All = 'all', All = 'all',
@ -22,8 +30,8 @@
country: ComboBoxOption[]; country: ComboBoxOption[];
state: ComboBoxOption[]; state: ComboBoxOption[];
city: ComboBoxOption[]; city: ComboBoxOption[];
cameraMake: ComboBoxOption[]; make: ComboBoxOption[];
cameraModel: ComboBoxOption[]; model: ComboBoxOption[];
}; };
type SearchParams = { type SearchParams = {
@ -49,14 +57,14 @@
model?: ComboBoxOption; model?: ComboBoxOption;
}; };
dateRange: { date: {
startDate?: Date; takenAfter?: string;
endDate?: Date; takenBefore?: string;
}; };
inArchive?: boolean; isArchive?: boolean;
inFavorite?: boolean; isFavorite?: boolean;
notInAlbum?: boolean; isNotInAlbum?: boolean;
mediaType: MediaType; mediaType: MediaType;
}; };
@ -66,8 +74,8 @@
country: [], country: [],
state: [], state: [],
city: [], city: [],
cameraMake: [], make: [],
cameraModel: [], model: [],
}; };
let filter: SearchFilter = { let filter: SearchFilter = {
@ -82,21 +90,23 @@
make: undefined, make: undefined,
model: undefined, model: undefined,
}, },
dateRange: { date: {
startDate: undefined, takenAfter: undefined,
endDate: undefined, takenBefore: undefined,
}, },
inArchive: undefined, isArchive: undefined,
inFavorite: undefined, isFavorite: undefined,
notInAlbum: undefined, isNotInAlbum: undefined,
mediaType: MediaType.All, mediaType: MediaType.All,
}; };
const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>();
let showAllPeople = false; let showAllPeople = false;
$: peopleList = showAllPeople ? suggestions.people : suggestions.people.slice(0, 11); $: peopleList = showAllPeople ? suggestions.people : suggestions.people.slice(0, 11);
onMount(() => { onMount(() => {
getPeople(); getPeople();
populateExistingFilters();
}); });
const showSelectedPeopleFirst = () => { const showSelectedPeopleFirst = () => {
@ -141,7 +151,7 @@
} }
if (type === SearchSuggestionType.CameraMake || type === SearchSuggestionType.CameraModel) { if (type === SearchSuggestionType.CameraMake || type === SearchSuggestionType.CameraModel) {
suggestions = { ...suggestions, cameraMake: [], cameraModel: [] }; suggestions = { ...suggestions, make: [], model: [] };
} }
try { try {
@ -178,14 +188,14 @@
case SearchSuggestionType.CameraMake: { case SearchSuggestionType.CameraMake: {
for (const make of data) { for (const make of data) {
suggestions.cameraMake = [...suggestions.cameraMake, { label: make, value: make }]; suggestions.make = [...suggestions.make, { label: make, value: make }];
} }
break; break;
} }
case SearchSuggestionType.CameraModel: { case SearchSuggestionType.CameraModel: {
for (const model of data) { for (const model of data) {
suggestions.cameraModel = [...suggestions.cameraModel, { label: model, value: model }]; suggestions.model = [...suggestions.model, { label: model, value: model }];
} }
break; break;
} }
@ -208,18 +218,99 @@
make: undefined, make: undefined,
model: undefined, model: undefined,
}, },
dateRange: { date: {
startDate: undefined, takenAfter: undefined,
endDate: undefined, takenBefore: undefined,
}, },
inArchive: undefined, isArchive: undefined,
inFavorite: undefined, isFavorite: undefined,
notInAlbum: undefined, isNotInAlbum: undefined,
mediaType: MediaType.All, mediaType: MediaType.All,
}; };
}; };
const search = () => {}; const search = async () => {
let type: AssetTypeEnum | undefined = undefined;
if (filter.mediaType === MediaType.Image) {
type = AssetTypeEnum.Image;
} else if (filter.mediaType === MediaType.Video) {
type = AssetTypeEnum.Video;
}
let payload: SmartSearchDto | MetadataSearchDto = {
country: filter.location.country?.value,
state: filter.location.state?.value,
city: filter.location.city?.value,
make: filter.camera.make?.value,
model: filter.camera.model?.value,
takenAfter: filter.date.takenAfter
? DateTime.fromFormat(filter.date.takenAfter, 'yyyy-MM-dd').toUTC().startOf('day').toString()
: undefined,
takenBefore: filter.date.takenBefore
? DateTime.fromFormat(filter.date.takenBefore, 'yyyy-MM-dd').toUTC().endOf('day').toString()
: undefined,
/* eslint-disable unicorn/prefer-logical-operator-over-ternary */
isArchived: filter.isArchive ? filter.isArchive : undefined,
isFavorite: filter.isFavorite ? filter.isFavorite : undefined,
isNotInAlbum: filter.isNotInAlbum ? filter.isNotInAlbum : undefined,
personIds: filter.people && filter.people.length > 0 ? filter.people.map((p) => p.id) : undefined,
type,
};
if (filter.context) {
if (payload.personIds && payload.personIds.length > 0) {
handleError(
new Error('Context search does not support people filter'),
'Context search does not support people filter',
);
return;
}
payload = {
...payload,
query: filter.context,
};
}
dispatch('search', payload);
};
function populateExistingFilters() {
if ($searchQuery) {
filter = {
context: 'query' in $searchQuery ? $searchQuery.query : '',
people:
'personIds' in $searchQuery ? ($searchQuery.personIds?.map((id) => ({ id })) as PersonResponseDto[]) : [],
location: {
country: $searchQuery.country ? { label: $searchQuery.country, value: $searchQuery.country } : undefined,
state: $searchQuery.state ? { label: $searchQuery.state, value: $searchQuery.state } : undefined,
city: $searchQuery.city ? { label: $searchQuery.city, value: $searchQuery.city } : undefined,
},
camera: {
make: $searchQuery.make ? { label: $searchQuery.make, value: $searchQuery.make } : undefined,
model: $searchQuery.model ? { label: $searchQuery.model, value: $searchQuery.model } : undefined,
},
date: {
takenAfter: $searchQuery.takenAfter
? DateTime.fromISO($searchQuery.takenAfter).toUTC().toFormat('yyyy-MM-dd')
: undefined,
takenBefore: $searchQuery.takenBefore
? DateTime.fromISO($searchQuery.takenBefore).toUTC().toFormat('yyyy-MM-dd')
: undefined,
},
isArchive: $searchQuery.isArchived,
isFavorite: $searchQuery.isFavorite,
isNotInAlbum: 'isNotInAlbum' in $searchQuery ? $searchQuery.isNotInAlbum : undefined,
mediaType:
$searchQuery.type === AssetTypeEnum.Image
? MediaType.Image
: $searchQuery.type === AssetTypeEnum.Video
? MediaType.Video
: MediaType.All,
};
}
}
</script> </script>
<div <div
@ -347,7 +438,7 @@
<div class="w-full"> <div class="w-full">
<p class="text-sm text-black dark:text-white">Make</p> <p class="text-sm text-black dark:text-white">Make</p>
<Combobox <Combobox
options={suggestions.cameraMake} options={suggestions.make}
bind:selectedOption={filter.camera.make} bind:selectedOption={filter.camera.make}
placeholder="Search camera make..." placeholder="Search camera make..."
on:click={() => on:click={() =>
@ -358,7 +449,7 @@
<div class="w-full"> <div class="w-full">
<p class="text-sm text-black dark:text-white">Model</p> <p class="text-sm text-black dark:text-white">Model</p>
<Combobox <Combobox
options={suggestions.cameraModel} options={suggestions.model}
bind:selectedOption={filter.camera.model} bind:selectedOption={filter.camera.model}
placeholder="Search camera model..." placeholder="Search camera model..."
on:click={() => on:click={() =>
@ -379,7 +470,7 @@
type="date" type="date"
id="start-date" id="start-date"
name="start-date" name="start-date"
bind:value={filter.dateRange.startDate} bind:value={filter.date.takenAfter}
/> />
</div> </div>
@ -391,7 +482,7 @@
id="end-date" id="end-date"
name="end-date" name="end-date"
placeholder="" placeholder=""
bind:value={filter.dateRange.endDate} bind:value={filter.date.takenBefore}
/> />
</div> </div>
</div> </div>
@ -450,17 +541,17 @@
<div class="flex gap-5 mt-3"> <div class="flex gap-5 mt-3">
<label class="flex items-center mb-2"> <label class="flex items-center mb-2">
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.notInAlbum} /> <input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.isNotInAlbum} />
<span class="ml-2 text-sm text-black dark:text-white pt-1">Not in any album</span> <span class="ml-2 text-sm text-black dark:text-white pt-1">Not in any album</span>
</label> </label>
<label class="flex items-center mb-2"> <label class="flex items-center mb-2">
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.inArchive} /> <input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.isArchive} />
<span class="ml-2 text-sm text-black dark:text-white pt-1">Archive</span> <span class="ml-2 text-sm text-black dark:text-white pt-1">Archive</span>
</label> </label>
<label class="flex items-center mb-2"> <label class="flex items-center mb-2">
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.inFavorite} /> <input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.isFavorite} />
<span class="ml-2 text-sm text-black dark:text-white pt-1">Favorite</span> <span class="ml-2 text-sm text-black dark:text-white pt-1">Favorite</span>
</label> </label>
</div> </div>

View File

@ -16,15 +16,6 @@
transition:fly={{ y: 25, duration: 250 }} transition:fly={{ y: 25, duration: 250 }}
class="absolute w-full rounded-b-3xl border border-gray-200 bg-white pb-5 shadow-2xl transition-all dark:border-gray-800 dark:bg-immich-dark-gray dark:text-gray-300" class="absolute w-full rounded-b-3xl border border-gray-200 bg-white pb-5 shadow-2xl transition-all dark:border-gray-800 dark:bg-immich-dark-gray dark:text-gray-300"
> >
<div class="flex px-5 pt-5 text-left text-sm">
<p>
Smart search is enabled by default, to search for metadata use the syntax <span
class="rounded-lg bg-gray-100 p-2 font-mono font-semibold leading-7 text-immich-primary dark:bg-gray-900 dark:text-immich-dark-primary"
>m:your-search-term</span
>
</p>
</div>
{#if $savedSearchTerms.length > 0} {#if $savedSearchTerms.length > 0}
<div class="flex items-center justify-between px-5 pt-5 text-xs"> <div class="flex items-center justify-between px-5 pt-5 text-xs">
<p>RECENT SEARCHES</p> <p>RECENT SEARCHES</p>

View File

@ -71,6 +71,7 @@ export enum QueryParameter {
SEARCHED_PEOPLE = 'searchedPeople', SEARCHED_PEOPLE = 'searchedPeople',
SEARCH_TERM = 'q', SEARCH_TERM = 'q',
SMART_SEARCH = 'smartSearch', SMART_SEARCH = 'smartSearch',
PAGE = 'page',
} }
export enum OpenSettingQueryParameterValue { export enum OpenSettingQueryParameterValue {

View File

@ -1,6 +1,8 @@
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { persisted } from 'svelte-local-storage-store'; import { persisted } from 'svelte-local-storage-store';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
export const savedSearchTerms = persisted<string[]>('search-terms', [], {}); export const savedSearchTerms = persisted<string[]>('search-terms', [], {});
export const isSearchEnabled = writable<boolean>(false); export const isSearchEnabled = writable<boolean>(false);
export const preventRaceConditionSearchBar = writable<boolean>(false); export const preventRaceConditionSearchBar = writable<boolean>(false);
export const searchQuery = writable<SmartSearchDto | MetadataSearchDto | undefined>(undefined);

View File

@ -50,3 +50,20 @@ export function splitBucketIntoDateGroups(
); );
return sortBy(grouped, (group) => assets.indexOf(group[0])); return sortBy(grouped, (group) => assets.indexOf(group[0]));
} }
export type LayoutBox = {
top: number;
left: number;
width: number;
};
export function calculateWidth(boxes: LayoutBox[]): number {
let width = 0;
for (const box of boxes) {
if (box.top < 100) {
width = box.left + box.width;
}
}
return width;
}

View File

@ -20,25 +20,30 @@
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { preventRaceConditionSearchBar } from '$lib/stores/search.store'; import { preventRaceConditionSearchBar, searchQuery } from '$lib/stores/search.store';
import { authenticate } from '$lib/utils/auth'; import { authenticate } from '$lib/utils/auth';
import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
import { search, type AssetResponseDto, type SearchResponseDto } from '@immich/sdk'; import { type AssetResponseDto, type SearchResponseDto, searchSmart, searchMetadata, getPerson } from '@immich/sdk';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { Viewport } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
export let data: PageData; export let data: PageData;
const MAX_ASSET_COUNT = 5000; const MAX_ASSET_COUNT = 5000;
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
const viewport: Viewport = { width: 0, height: 0 };
// The GalleryViewer pushes it's own history state, which causes weird // The GalleryViewer pushes it's own history state, which causes weird
// behavior for history.back(). To prevent that we store the previous page // behavior for history.back(). To prevent that we store the previous page
// manually and navigate back to that. // manually and navigate back to that.
let previousRoute = AppRoute.EXPLORE as string; let previousRoute = AppRoute.EXPLORE as string;
$: curPage = data.results?.assets.nextPage; /* eslint-disable @typescript-eslint/no-explicit-any */
let terms: any;
$: currentPage = data.results?.assets.nextPage;
$: albums = data.results?.albums.items; $: albums = data.results?.albums.items;
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event); const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
@ -87,16 +92,9 @@
if (from?.route.id === '/(user)/albums/[albumId]') { if (from?.route.id === '/(user)/albums/[albumId]') {
previousRoute = AppRoute.EXPLORE; previousRoute = AppRoute.EXPLORE;
} }
});
$: term = (() => { updateInformationChip();
let term = $page.url.searchParams.get(QueryParameter.SEARCH_TERM) || data.term || ''; });
const isMetadataSearch = $page.url.searchParams.get(QueryParameter.SMART_SEARCH) === 'false';
if (isMetadataSearch && term !== '') {
term = `m:${term}`;
}
return term;
})();
let selectedAssets: Set<AssetResponseDto> = new Set(); let selectedAssets: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = selectedAssets.size > 0; $: isMultiSelectionMode = selectedAssets.size > 0;
@ -111,32 +109,120 @@
selectedAssets = new Set(searchResultAssets); selectedAssets = new Set(searchResultAssets);
}; };
function updateInformationChip() {
let query = $page.url.searchParams.get(QueryParameter.SEARCH_TERM) || data.term || '';
terms = JSON.parse(query);
}
export const loadNextPage = async () => { export const loadNextPage = async () => {
if (curPage == null || !term || (searchResultAssets && searchResultAssets.length >= MAX_ASSET_COUNT)) { if (currentPage == null || !terms || (searchResultAssets && searchResultAssets.length >= MAX_ASSET_COUNT)) {
return; return;
} }
await authenticate(); await authenticate();
let results: SearchResponseDto | null = null; let results: SearchResponseDto | null = null;
$page.url.searchParams.set('page', curPage.toString()); $page.url.searchParams.set(QueryParameter.PAGE, currentPage.toString());
const res = await search({ ...$page.url.searchParams }); const payload = $searchQuery;
let responses: SearchResponseDto;
responses =
payload && 'query' in payload
? await searchSmart({
smartSearchDto: { ...payload, page: Number.parseInt(currentPage), withExif: true },
})
: await searchMetadata({
metadataSearchDto: { ...payload, page: Number.parseInt(currentPage), withExif: true },
});
if (searchResultAssets) { if (searchResultAssets) {
searchResultAssets.push(...res.assets.items); searchResultAssets.push(...responses.assets.items);
} else { } else {
searchResultAssets = res.assets.items; searchResultAssets = responses.assets.items;
} }
const assets = { const assets = {
...res.assets, ...responses.assets,
items: searchResultAssets, items: searchResultAssets,
}; };
results = { results = {
assets, assets,
albums: res.albums, albums: responses.albums,
}; };
data.results = results; data.results = results;
}; };
function getHumanReadableDate(date: string) {
const d = new Date(date);
return d.toLocaleDateString($locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function getHumanReadableSearchKey(key: string): string {
switch (key) {
case 'takenAfter': {
return 'Start date';
}
case 'takenBefore': {
return 'End date';
}
case 'isArchived': {
return 'In archive';
}
case 'isFavorite': {
return 'Favorite';
}
case 'isNotInAlbum': {
return 'Not in any album';
}
case 'type': {
return 'Media type';
}
case 'query': {
return 'Context';
}
case 'city': {
return 'City';
}
case 'country': {
return 'Country';
}
case 'state': {
return 'State';
}
case 'make': {
return 'Camera brand';
}
case 'model': {
return 'Camera model';
}
case 'personIds': {
return 'People';
}
default: {
return key;
}
}
}
async function getPersonName(personIds: string[]) {
const personNames = await Promise.all(
personIds.map(async (personId) => {
const person = await getPerson({ id: personId });
if (person.name == '') {
return 'No Name';
}
return person.name;
}),
);
return personNames.join(', ');
}
</script> </script>
<section> <section>
@ -161,15 +247,53 @@
</AssetSelectControlBar> </AssetSelectControlBar>
</div> </div>
{:else} {:else}
<div class="fixed z-[100] top-0 left-0 w-full">
<ControlAppBar on:close={() => goto(previousRoute)} backIcon={mdiArrowLeft}> <ControlAppBar on:close={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="w-full flex-1 pl-4"> <div class="w-full flex-1 pl-4">
<SearchBar grayTheme={false} value={term} /> <SearchBar grayTheme={false} />
</div> </div>
</ControlAppBar> </ControlAppBar>
</div>
{/if} {/if}
</section> </section>
<section class="relative mb-12 bg-immich-bg pt-32 dark:bg-immich-dark-bg"> {#if terms}
<section
id="search-chips"
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
>
{#each Object.keys(terms) as key, index (index)}
<div class="flex place-content-center place-items-center text-xs">
<div
class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
{terms[key] === true ? 'rounded-full' : 'rounded-tl-full rounded-bl-full'}"
>
{getHumanReadableSearchKey(key)}
</div>
{#if terms[key] !== true}
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full">
{#if key === 'takenAfter' || key === 'takenBefore'}
{getHumanReadableDate(terms[key])}
{:else if key === 'personIds'}
{#await getPersonName(terms[key]) then personName}
{personName}
{/await}
{:else}
{terms[key]}
{/if}
</div>
{/if}
</div>
{/each}
</section>
{/if}
<section
class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4"
bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width}
>
<section class="immich-scrollbar relative overflow-y-auto"> <section class="immich-scrollbar relative overflow-y-auto">
{#if albums && albums.length > 0} {#if albums && albums.length > 0}
<section> <section>
@ -193,14 +317,13 @@
{/if} {/if}
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg"> <section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
{#if searchResultAssets && searchResultAssets.length > 0} {#if searchResultAssets && searchResultAssets.length > 0}
<div class="pl-4">
<GalleryViewer <GalleryViewer
assets={searchResultAssets} assets={searchResultAssets}
bind:selectedAssets bind:selectedAssets
on:intersected={loadNextPage} on:intersected={loadNextPage}
showArchiveIcon={true} showArchiveIcon={true}
{viewport}
/> />
</div>
{:else} {:else}
<div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white"> <div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
<div class="flex flex-col content-center items-center text-center"> <div class="flex flex-col content-center items-center text-center">

View File

@ -1,6 +1,13 @@
import { QueryParameter } from '$lib/constants'; import { QueryParameter } from '$lib/constants';
import { searchQuery } from '$lib/stores/search.store';
import { authenticate } from '$lib/utils/auth'; import { authenticate } from '$lib/utils/auth';
import { search, type AssetResponseDto, type SearchResponseDto } from '@immich/sdk'; import {
searchMetadata,
searchSmart,
type MetadataSearchDto,
type SearchResponseDto,
type SmartSearchDto,
} from '@immich/sdk';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async (data) => { export const load = (async (data) => {
@ -10,22 +17,13 @@ export const load = (async (data) => {
url.searchParams.get(QueryParameter.SEARCH_TERM) || url.searchParams.get(QueryParameter.QUERY) || undefined; url.searchParams.get(QueryParameter.SEARCH_TERM) || url.searchParams.get(QueryParameter.QUERY) || undefined;
let results: SearchResponseDto | null = null; let results: SearchResponseDto | null = null;
if (term) { if (term) {
let params = {}; const payload = JSON.parse(term) as SmartSearchDto | MetadataSearchDto;
for (const [key, value] of data.url.searchParams) { searchQuery.set(payload);
params = { ...params, [key]: value };
} results =
const response = await search({ ...params }); payload && 'query' in payload
let items: AssetResponseDto[] = (data as unknown as { results: SearchResponseDto }).results?.assets.items; ? await searchSmart({ smartSearchDto: { ...payload, withExif: true } })
if (items) { : await searchMetadata({ metadataSearchDto: { ...payload, withExif: true } });
items.push(...response.assets.items);
} else {
items = response.assets.items;
}
const assets = { ...response.assets, items };
results = {
assets,
albums: response.albums,
};
} }
return { return {