From 9ac95d6845825d0d188eb9fb7b2971df3695ee92 Mon Sep 17 00:00:00 2001 From: David Wolff Date: Fri, 31 Jan 2025 22:37:22 +0100 Subject: [PATCH] feat: add searching by tags (#15395) * feat: add searching by tags * fix: fix merge --------- Co-authored-by: Alex --- .../lib/model/metadata_search_dto.dart | Bin 28221 -> 28551 bytes .../openapi/lib/model/random_search_dto.dart | Bin 21494 -> 21824 bytes .../openapi/lib/model/smart_search_dto.dart | Bin 20899 -> 21229 bytes open-api/immich-openapi-specs.json | 21 +++++ open-api/typescript-sdk/src/fetch-client.ts | 3 + server/src/dtos/search.dto.ts | 3 + server/src/entities/asset.entity.ts | 16 ++++ server/src/interfaces/search.interface.ts | 10 ++- .../search-bar/search-filter-modal.svelte | 8 ++ .../search-bar/search-tags-section.svelte | 80 ++++++++++++++++++ .../[[assetId=id]]/+page.svelte | 18 ++++ 11 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/components/shared-components/search-bar/search-tags-section.svelte diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 654883b38afc34e51d88a8ceeb06e43b7c2addff..5f9e3f8e15e00dbf2e25a96f9b5b3e28918851b5 100644 GIT binary patch delta 180 zcmdmchq3)WRK_RO+KQFpS9U`b6 r3$(8!BePf!!nfWm9{QhO0Hn_fN#*3H$r{W+6_bTiqBe`9YH|VqI$l7P delta 57 zcmV-90LK4^-vPbc0kF~ov+e@~AG4PUT>+EbB@eT9B#9KWF*&^lvj#yu1+!B|R}Ygw PO9`{sRq+qAU}h%?qqY_d diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 3fcab05bbb275632cbc55c2a6689d043946cbeae..c63d7e82f611df5be5ef919d26d7a03e313305dc 100644 GIT binary patch delta 179 zcmeyiobkXa#s$p$>7FUY3bqQ#`FX`93emBfKQrkG3;Sdim)HcC6lLb6+bNVJg4Aqo zl*(n?+$nxUaPoZ(X?}>H4pdTev#aJgRu-_j|9XxrJRp{>3RI_!@hz^&wN9Mk5WadW w(7uw4%wjzV-+Hs4og}XSNQ;$1R&jn_bdfqxX0nZ!1~Z7x@`~E5;eCz+0FOF8{{R30 delta 60 zcmV-C0K@;lssZ+=0iXl390MT}vsV#a0h6B^3$wi!#1WI(DGRf{DZ2%;kuNO-v(7Wt S3X`up3A55Z3k|b$Ow0()Di^r` diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 4e1408cafa737167d4ffabb5f6288d30b1fb9917..c81e1519b4eda18c9e60ed98c1d523a1f1707a42 100644 GIT binary patch delta 190 zcmZ3ynDOmW#to{>{OO)4#R|3x$@zK3B?{58n(qb: SelectQueryBuilder, personIds: ); } +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 withOwner(eb: ExpressionBuilder) { return jsonObjectFrom(eb.selectFrom('users').selectAll().whereRef('users.id', '=', 'assets.ownerId')).as('owner'); } @@ -326,6 +341,7 @@ export function searchAssetBuilder(kysely: Kysely, 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!)) diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index bb76ff7b1..e6f9acbd2 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 c367d001f..7653ad341 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 000000000..6071da146 --- /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 97d0cacdc..c416226c4 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,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}