diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 654883b38afc3..5f9e3f8e15e00 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -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 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 toJson() { final json = {}; @@ -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(json, r'previewPath'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], takenAfter: mapDateTime(json, r'takenAfter', r''), takenBefore: mapDateTime(json, r'takenBefore', r''), thumbnailPath: mapValueOfType(json, r'thumbnailPath'), diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 3fcab05bbb275..c63d7e82f611d 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -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 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 toJson() { final json = {}; @@ -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(json, r'state'), + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], takenAfter: mapDateTime(json, r'takenAfter', r''), takenBefore: mapDateTime(json, r'takenBefore', r''), trashedAfter: mapDateTime(json, r'trashedAfter', r''), diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 4e1408cafa737..c81e1519b4eda 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -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 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 toJson() { final json = {}; @@ -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(json, r'query')!, size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], takenAfter: mapDateTime(json, r'takenAfter', r''), takenBefore: mapDateTime(json, r'takenBefore', r''), trashedAfter: mapDateTime(json, r'trashedAfter', r''), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 3067b25449482..2f0f623fc8660 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10032,6 +10032,13 @@ "nullable": true, "type": "string" }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "takenAfter": { "format": "date-time", "type": "string" @@ -10645,6 +10652,13 @@ "nullable": true, "type": "string" }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "takenAfter": { "format": "date-time", "type": "string" @@ -11560,6 +11574,13 @@ "nullable": true, "type": "string" }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "takenAfter": { "format": "date-time", "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 088e30f9d8b4f..74c8f9221e80c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -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; diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index f3f45af44dc60..9dabfff25fc10 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -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 { diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index e9dbe67a2fffc..3080eb4778180 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -250,6 +250,21 @@ export function hasPeopleCte(db: Kysely, personIds: string[]) { ); } +export function hasTags(qb: SelectQueryBuilder, 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 hasPeople(db: Kysely, personIds?: string[]) { return personIds && personIds.length > 0 ? hasPeopleCte(db, personIds).selectFrom('assets').innerJoin('has_people', 'has_people.assetId', 'assets.id') @@ -328,6 +343,7 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore); return hasPeople(kysely.withPlugin(joinDeduplicationPlugin), options.personIds) .selectAll('assets') + .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!)) .$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!)) .$if(!!options.updatedBefore, (qb) => qb.where('assets.updatedAt', '<=', options.updatedBefore!)) diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index bb76ff7b1fd04..e6f9acbd212d0 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -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; diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte index c367d001f2eea..7653ad341311b 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte @@ -8,6 +8,7 @@ query: string; queryType: 'smart' | 'metadata'; personIds: SvelteSet; + tagIds: SvelteSet; 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 @@ + + + diff --git a/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte b/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte new file mode 100644 index 0000000000000..6071da1460266 --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte @@ -0,0 +1,80 @@ + + +{#if $preferences?.tags?.enabled} +
+
+
+ ({ id: tag.id, label: tag.value, value: tag.id }))} + bind:selectedOption + placeholder={$t('search_tags')} + /> +
+
+ +
+ {#each selectedTags as tagId (tagId)} + {@const tag = tagMap[tagId]} + {#if tag} +
+ +

+ {tag.value} +

+
+ + +
+ {/if} + {/each} +
+
+{/if} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 97d0cacdce572..0d243af695b50 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -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,22 @@ return personNames.join(', '); } + async function getTagNames(tagIds: string[]) { + const tagNames = await Promise.all( + tagIds.map(async (tagId) => { + const tag = await getTagById({ id: tagId }); + + if (tag.name == '') { + return $t('no_name'); + } + + return tag.name; + }), + ); + + return tagNames.join(', '); + } + const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets); const onAddToAlbum = (assetIds: string[]) => { @@ -299,6 +317,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}