mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:37:11 -04:00 
			
		
		
		
	feat: add searching by tags (#15395)
* feat: add searching by tags * fix: fix merge --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
		
							parent
							
								
									221e197633
								
							
						
					
					
						commit
						9ac95d6845
					
				
							
								
								
									
										11
									
								
								mobile/openapi/lib/model/metadata_search_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										11
									
								
								mobile/openapi/lib/model/metadata_search_dto.dart
									
									
									
										generated
									
									
									
								
							| @ -41,6 +41,7 @@ class MetadataSearchDto { | ||||
|     this.previewPath, | ||||
|     this.size, | ||||
|     this.state, | ||||
|     this.tagIds = const [], | ||||
|     this.takenAfter, | ||||
|     this.takenBefore, | ||||
|     this.thumbnailPath, | ||||
| @ -235,6 +236,8 @@ class MetadataSearchDto { | ||||
| 
 | ||||
|   String? state; | ||||
| 
 | ||||
|   List<String> tagIds; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
| @ -363,6 +366,7 @@ class MetadataSearchDto { | ||||
|     other.previewPath == previewPath && | ||||
|     other.size == size && | ||||
|     other.state == state && | ||||
|     _deepEquality.equals(other.tagIds, tagIds) && | ||||
|     other.takenAfter == takenAfter && | ||||
|     other.takenBefore == takenBefore && | ||||
|     other.thumbnailPath == thumbnailPath && | ||||
| @ -408,6 +412,7 @@ class MetadataSearchDto { | ||||
|     (previewPath == null ? 0 : previewPath!.hashCode) + | ||||
|     (size == null ? 0 : size!.hashCode) + | ||||
|     (state == null ? 0 : state!.hashCode) + | ||||
|     (tagIds.hashCode) + | ||||
|     (takenAfter == null ? 0 : takenAfter!.hashCode) + | ||||
|     (takenBefore == null ? 0 : takenBefore!.hashCode) + | ||||
|     (thumbnailPath == null ? 0 : thumbnailPath!.hashCode) + | ||||
| @ -423,7 +428,7 @@ class MetadataSearchDto { | ||||
|     (withStacked == null ? 0 : withStacked!.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; | ||||
|   String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @ -559,6 +564,7 @@ class MetadataSearchDto { | ||||
|     } else { | ||||
|     //  json[r'state'] = null; | ||||
|     } | ||||
|       json[r'tagIds'] = this.tagIds; | ||||
|     if (this.takenAfter != null) { | ||||
|       json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); | ||||
|     } else { | ||||
| @ -662,6 +668,9 @@ class MetadataSearchDto { | ||||
|         previewPath: mapValueOfType<String>(json, r'previewPath'), | ||||
|         size: num.parse('${json[r'size']}'), | ||||
|         state: mapValueOfType<String>(json, r'state'), | ||||
|         tagIds: json[r'tagIds'] is Iterable | ||||
|             ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) | ||||
|             : const [], | ||||
|         takenAfter: mapDateTime(json, r'takenAfter', r''), | ||||
|         takenBefore: mapDateTime(json, r'takenBefore', r''), | ||||
|         thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath'), | ||||
|  | ||||
							
								
								
									
										11
									
								
								mobile/openapi/lib/model/random_search_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										11
									
								
								mobile/openapi/lib/model/random_search_dto.dart
									
									
									
										generated
									
									
									
								
							| @ -32,6 +32,7 @@ class RandomSearchDto { | ||||
|     this.personIds = const [], | ||||
|     this.size, | ||||
|     this.state, | ||||
|     this.tagIds = const [], | ||||
|     this.takenAfter, | ||||
|     this.takenBefore, | ||||
|     this.trashedAfter, | ||||
| @ -158,6 +159,8 @@ class RandomSearchDto { | ||||
| 
 | ||||
|   String? state; | ||||
| 
 | ||||
|   List<String> tagIds; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
| @ -269,6 +272,7 @@ class RandomSearchDto { | ||||
|     _deepEquality.equals(other.personIds, personIds) && | ||||
|     other.size == size && | ||||
|     other.state == state && | ||||
|     _deepEquality.equals(other.tagIds, tagIds) && | ||||
|     other.takenAfter == takenAfter && | ||||
|     other.takenBefore == takenBefore && | ||||
|     other.trashedAfter == trashedAfter && | ||||
| @ -304,6 +308,7 @@ class RandomSearchDto { | ||||
|     (personIds.hashCode) + | ||||
|     (size == null ? 0 : size!.hashCode) + | ||||
|     (state == null ? 0 : state!.hashCode) + | ||||
|     (tagIds.hashCode) + | ||||
|     (takenAfter == null ? 0 : takenAfter!.hashCode) + | ||||
|     (takenBefore == null ? 0 : takenBefore!.hashCode) + | ||||
|     (trashedAfter == null ? 0 : trashedAfter!.hashCode) + | ||||
| @ -318,7 +323,7 @@ class RandomSearchDto { | ||||
|     (withStacked == null ? 0 : withStacked!.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; | ||||
|   String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @ -413,6 +418,7 @@ class RandomSearchDto { | ||||
|     } else { | ||||
|     //  json[r'state'] = null; | ||||
|     } | ||||
|       json[r'tagIds'] = this.tagIds; | ||||
|     if (this.takenAfter != null) { | ||||
|       json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); | ||||
|     } else { | ||||
| @ -502,6 +508,9 @@ class RandomSearchDto { | ||||
|             : const [], | ||||
|         size: num.parse('${json[r'size']}'), | ||||
|         state: mapValueOfType<String>(json, r'state'), | ||||
|         tagIds: json[r'tagIds'] is Iterable | ||||
|             ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) | ||||
|             : const [], | ||||
|         takenAfter: mapDateTime(json, r'takenAfter', r''), | ||||
|         takenBefore: mapDateTime(json, r'takenBefore', r''), | ||||
|         trashedAfter: mapDateTime(json, r'trashedAfter', r''), | ||||
|  | ||||
							
								
								
									
										11
									
								
								mobile/openapi/lib/model/smart_search_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										11
									
								
								mobile/openapi/lib/model/smart_search_dto.dart
									
									
									
										generated
									
									
									
								
							| @ -34,6 +34,7 @@ class SmartSearchDto { | ||||
|     required this.query, | ||||
|     this.size, | ||||
|     this.state, | ||||
|     this.tagIds = const [], | ||||
|     this.takenAfter, | ||||
|     this.takenBefore, | ||||
|     this.trashedAfter, | ||||
| @ -169,6 +170,8 @@ class SmartSearchDto { | ||||
| 
 | ||||
|   String? state; | ||||
| 
 | ||||
|   List<String> tagIds; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
| @ -266,6 +269,7 @@ class SmartSearchDto { | ||||
|     other.query == query && | ||||
|     other.size == size && | ||||
|     other.state == state && | ||||
|     _deepEquality.equals(other.tagIds, tagIds) && | ||||
|     other.takenAfter == takenAfter && | ||||
|     other.takenBefore == takenBefore && | ||||
|     other.trashedAfter == trashedAfter && | ||||
| @ -301,6 +305,7 @@ class SmartSearchDto { | ||||
|     (query.hashCode) + | ||||
|     (size == null ? 0 : size!.hashCode) + | ||||
|     (state == null ? 0 : state!.hashCode) + | ||||
|     (tagIds.hashCode) + | ||||
|     (takenAfter == null ? 0 : takenAfter!.hashCode) + | ||||
|     (takenBefore == null ? 0 : takenBefore!.hashCode) + | ||||
|     (trashedAfter == null ? 0 : trashedAfter!.hashCode) + | ||||
| @ -313,7 +318,7 @@ class SmartSearchDto { | ||||
|     (withExif == null ? 0 : withExif!.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; | ||||
|   String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @ -414,6 +419,7 @@ class SmartSearchDto { | ||||
|     } else { | ||||
|     //  json[r'state'] = null; | ||||
|     } | ||||
|       json[r'tagIds'] = this.tagIds; | ||||
|     if (this.takenAfter != null) { | ||||
|       json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); | ||||
|     } else { | ||||
| @ -495,6 +501,9 @@ class SmartSearchDto { | ||||
|         query: mapValueOfType<String>(json, r'query')!, | ||||
|         size: num.parse('${json[r'size']}'), | ||||
|         state: mapValueOfType<String>(json, r'state'), | ||||
|         tagIds: json[r'tagIds'] is Iterable | ||||
|             ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) | ||||
|             : const [], | ||||
|         takenAfter: mapDateTime(json, r'takenAfter', r''), | ||||
|         takenBefore: mapDateTime(json, r'takenBefore', r''), | ||||
|         trashedAfter: mapDateTime(json, r'trashedAfter', r''), | ||||
|  | ||||
| @ -10036,6 +10036,13 @@ | ||||
|             "nullable": true, | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "tagIds": { | ||||
|             "items": { | ||||
|               "format": "uuid", | ||||
|               "type": "string" | ||||
|             }, | ||||
|             "type": "array" | ||||
|           }, | ||||
|           "takenAfter": { | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
| @ -10649,6 +10656,13 @@ | ||||
|             "nullable": true, | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "tagIds": { | ||||
|             "items": { | ||||
|               "format": "uuid", | ||||
|               "type": "string" | ||||
|             }, | ||||
|             "type": "array" | ||||
|           }, | ||||
|           "takenAfter": { | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
| @ -11564,6 +11578,13 @@ | ||||
|             "nullable": true, | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "tagIds": { | ||||
|             "items": { | ||||
|               "format": "uuid", | ||||
|               "type": "string" | ||||
|             }, | ||||
|             "type": "array" | ||||
|           }, | ||||
|           "takenAfter": { | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|  | ||||
| @ -792,6 +792,7 @@ export type MetadataSearchDto = { | ||||
|     previewPath?: string; | ||||
|     size?: number; | ||||
|     state?: string | null; | ||||
|     tagIds?: string[]; | ||||
|     takenAfter?: string; | ||||
|     takenBefore?: string; | ||||
|     thumbnailPath?: string; | ||||
| @ -858,6 +859,7 @@ export type RandomSearchDto = { | ||||
|     personIds?: string[]; | ||||
|     size?: number; | ||||
|     state?: string | null; | ||||
|     tagIds?: string[]; | ||||
|     takenAfter?: string; | ||||
|     takenBefore?: string; | ||||
|     trashedAfter?: string; | ||||
| @ -893,6 +895,7 @@ export type SmartSearchDto = { | ||||
|     query: string; | ||||
|     size?: number; | ||||
|     state?: string | null; | ||||
|     tagIds?: string[]; | ||||
|     takenAfter?: string; | ||||
|     takenBefore?: string; | ||||
|     trashedAfter?: string; | ||||
|  | ||||
| @ -111,6 +111,9 @@ class BaseSearchDto { | ||||
| 
 | ||||
|   @ValidateUUID({ each: true, optional: true }) | ||||
|   personIds?: string[]; | ||||
| 
 | ||||
|   @ValidateUUID({ each: true, optional: true }) | ||||
|   tagIds?: string[]; | ||||
| } | ||||
| 
 | ||||
| export class RandomSearchDto extends BaseSearchDto { | ||||
|  | ||||
| @ -252,6 +252,21 @@ export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'assets', O>, personIds: | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export function hasTags<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagIds: string[]) { | ||||
|   return qb.innerJoin( | ||||
|     (eb) => | ||||
|       eb | ||||
|         .selectFrom('tag_asset') | ||||
|         .select('assetsId') | ||||
|         .innerJoin('tags_closure', 'tag_asset.tagsId', 'tags_closure.id_descendant') | ||||
|         .where('tags_closure.id_ancestor', '=', anyUuid(tagIds)) | ||||
|         .groupBy('assetsId') | ||||
|         .having((eb) => eb.fn.count('tags_closure.id_ancestor').distinct(), '>=', tagIds.length) | ||||
|         .as('has_tags'), | ||||
|     (join) => join.onRef('has_tags.assetsId', '=', 'assets.id'), | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export function withOwner(eb: ExpressionBuilder<DB, 'assets'>) { | ||||
|   return jsonObjectFrom(eb.selectFrom('users').selectAll().whereRef('users.id', '=', 'assets.ownerId')).as('owner'); | ||||
| } | ||||
| @ -326,6 +341,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild | ||||
|     .withPlugin(joinDeduplicationPlugin) | ||||
|     .selectFrom('assets') | ||||
|     .selectAll('assets') | ||||
|     .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) | ||||
|     .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!)) | ||||
|     .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!)) | ||||
|     .$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!)) | ||||
|  | ||||
| @ -112,6 +112,10 @@ export interface SearchPeopleOptions { | ||||
|   personIds?: string[]; | ||||
| } | ||||
| 
 | ||||
| export interface SearchTagOptions { | ||||
|   tagIds?: string[]; | ||||
| } | ||||
| 
 | ||||
| export interface SearchOrderOptions { | ||||
|   orderDirection?: 'asc' | 'desc'; | ||||
| } | ||||
| @ -128,7 +132,8 @@ type BaseAssetSearchOptions = SearchDateOptions & | ||||
|   SearchPathOptions & | ||||
|   SearchStatusOptions & | ||||
|   SearchUserIdOptions & | ||||
|   SearchPeopleOptions; | ||||
|   SearchPeopleOptions & | ||||
|   SearchTagOptions; | ||||
| 
 | ||||
| export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; | ||||
| 
 | ||||
| @ -142,7 +147,8 @@ export type SmartSearchOptions = SearchDateOptions & | ||||
|   SearchOneToOneRelationOptions & | ||||
|   SearchStatusOptions & | ||||
|   SearchUserIdOptions & | ||||
|   SearchPeopleOptions; | ||||
|   SearchPeopleOptions & | ||||
|   SearchTagOptions; | ||||
| 
 | ||||
| export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { | ||||
|   hasPerson?: boolean; | ||||
|  | ||||
| @ -8,6 +8,7 @@ | ||||
|     query: string; | ||||
|     queryType: 'smart' | 'metadata'; | ||||
|     personIds: SvelteSet<string>; | ||||
|     tagIds: SvelteSet<string>; | ||||
|     location: SearchLocationFilter; | ||||
|     camera: SearchCameraFilter; | ||||
|     date: SearchDateFilter; | ||||
| @ -20,6 +21,7 @@ | ||||
|   import { Button } from '@immich/ui'; | ||||
|   import { AssetTypeEnum, type SmartSearchDto, type MetadataSearchDto } from '@immich/sdk'; | ||||
|   import SearchPeopleSection from './search-people-section.svelte'; | ||||
|   import SearchTagsSection from './search-tags-section.svelte'; | ||||
|   import SearchLocationSection from './search-location-section.svelte'; | ||||
|   import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte'; | ||||
|   import SearchDateSection from './search-date-section.svelte'; | ||||
| @ -54,6 +56,7 @@ | ||||
|     query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '', | ||||
|     queryType: 'query' in searchQuery ? 'smart' : 'metadata', | ||||
|     personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), | ||||
|     tagIds: new SvelteSet('tagIds' in searchQuery ? searchQuery.tagIds : []), | ||||
|     location: { | ||||
|       country: withNullAsUndefined(searchQuery.country), | ||||
|       state: withNullAsUndefined(searchQuery.state), | ||||
| @ -85,6 +88,7 @@ | ||||
|       query: '', | ||||
|       queryType: 'smart', | ||||
|       personIds: new SvelteSet(), | ||||
|       tagIds: new SvelteSet(), | ||||
|       location: {}, | ||||
|       camera: {}, | ||||
|       date: {}, | ||||
| @ -117,6 +121,7 @@ | ||||
|       isFavorite: filter.display.isFavorite || undefined, | ||||
|       isNotInAlbum: filter.display.isNotInAlbum || undefined, | ||||
|       personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined, | ||||
|       tagIds: filter.tagIds.size > 0 ? [...filter.tagIds] : undefined, | ||||
|       type, | ||||
|     }; | ||||
| 
 | ||||
| @ -143,6 +148,9 @@ | ||||
|       <!-- TEXT --> | ||||
|       <SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} /> | ||||
| 
 | ||||
|       <!-- TAGS --> | ||||
|       <SearchTagsSection bind:selectedTags={filter.tagIds} /> | ||||
| 
 | ||||
|       <!-- LOCATION --> | ||||
|       <SearchLocationSection bind:filters={filter.location} /> | ||||
| 
 | ||||
|  | ||||
| @ -0,0 +1,80 @@ | ||||
| <script lang="ts"> | ||||
|   import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; | ||||
|   import { getAllTags, type TagResponseDto } from '@immich/sdk'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { onMount } from 'svelte'; | ||||
|   import { SvelteSet } from 'svelte/reactivity'; | ||||
|   import Icon from '$lib/components/elements/icon.svelte'; | ||||
|   import { mdiClose } from '@mdi/js'; | ||||
|   import { preferences } from '$lib/stores/user.store'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     selectedTags: SvelteSet<string>; | ||||
|   } | ||||
| 
 | ||||
|   let { selectedTags = $bindable() }: Props = $props(); | ||||
| 
 | ||||
|   let allTags: TagResponseDto[] = $state([]); | ||||
|   let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag]))); | ||||
|   let selectedOption = $state(undefined); | ||||
| 
 | ||||
|   onMount(async () => { | ||||
|     allTags = await getAllTags(); | ||||
|   }); | ||||
| 
 | ||||
|   const handleSelect = (option?: ComboBoxOption) => { | ||||
|     if (!option || !option.id) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     selectedTags.add(option.value); | ||||
|     selectedOption = undefined; | ||||
|   }; | ||||
| 
 | ||||
|   const handleRemove = (tag: string) => { | ||||
|     selectedTags.delete(tag); | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| {#if $preferences?.tags?.enabled} | ||||
|   <div id="location-selection"> | ||||
|     <form autocomplete="off" id="create-tag-form"> | ||||
|       <div class="my-4 flex flex-col gap-2"> | ||||
|         <Combobox | ||||
|           onSelect={handleSelect} | ||||
|           label={$t('tags').toUpperCase()} | ||||
|           defaultFirstOption | ||||
|           options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))} | ||||
|           bind:selectedOption | ||||
|           placeholder={$t('search_tags')} | ||||
|         /> | ||||
|       </div> | ||||
|     </form> | ||||
| 
 | ||||
|     <section class="flex flex-wrap pt-2 gap-1"> | ||||
|       {#each selectedTags as tagId (tagId)} | ||||
|         {@const tag = tagMap[tagId]} | ||||
|         {#if tag} | ||||
|           <div class="flex group transition-all"> | ||||
|             <span | ||||
|               class="inline-block h-min whitespace-nowrap pl-3 pr-1 group-hover:pl-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary rounded-tl-full rounded-bl-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all" | ||||
|             > | ||||
|               <p class="text-sm"> | ||||
|                 {tag.value} | ||||
|               </p> | ||||
|             </span> | ||||
| 
 | ||||
|             <button | ||||
|               type="button" | ||||
|               class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all" | ||||
|               title="Remove tag" | ||||
|               onclick={() => handleRemove(tagId)} | ||||
|             > | ||||
|               <Icon path={mdiClose} /> | ||||
|             </button> | ||||
|           </div> | ||||
|         {/if} | ||||
|       {/each} | ||||
|     </section> | ||||
|   </div> | ||||
| {/if} | ||||
| @ -29,6 +29,7 @@ | ||||
|     type SmartSearchDto, | ||||
|     type MetadataSearchDto, | ||||
|     type AlbumResponseDto, | ||||
|     getTagById, | ||||
|   } from '@immich/sdk'; | ||||
|   import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; | ||||
|   import type { Viewport } from '$lib/stores/assets.store'; | ||||
| @ -194,6 +195,7 @@ | ||||
|       model: $t('camera_model'), | ||||
|       lensModel: $t('lens_model'), | ||||
|       personIds: $t('people'), | ||||
|       tagIds: $t('tags'), | ||||
|       originalFileName: $t('file_name'), | ||||
|     }; | ||||
|     return keyMap[key] || key; | ||||
| @ -215,6 +217,18 @@ | ||||
|     return personNames.join(', '); | ||||
|   } | ||||
| 
 | ||||
|   async function getTagNames(tagIds: string[]) { | ||||
|     const tagNames = await Promise.all( | ||||
|       tagIds.map(async (tagId) => { | ||||
|         const tag = await getTagById({ id: tagId }); | ||||
| 
 | ||||
|         return tag.value; | ||||
|       }), | ||||
|     ); | ||||
| 
 | ||||
|     return tagNames.join(', '); | ||||
|   } | ||||
| 
 | ||||
|   const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets); | ||||
| 
 | ||||
|   const onAddToAlbum = (assetIds: string[]) => { | ||||
| @ -299,6 +313,10 @@ | ||||
|               {#await getPersonName(value) then personName} | ||||
|                 {personName} | ||||
|               {/await} | ||||
|             {:else if key === 'tagIds' && Array.isArray(value)} | ||||
|               {#await getTagNames(value) then tagNames} | ||||
|                 {tagNames} | ||||
|               {/await} | ||||
|             {:else if value === null || value === ''} | ||||
|               {$t('unknown')} | ||||
|             {:else} | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user