From d94746f72c95ca1102942e1a0eb8344e2e4b0df2 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 9 Jan 2025 19:03:57 -0800 Subject: [PATCH] adding tags to search modal --- i18n/en.json | 3 +- .../lib/model/metadata_search_dto.dart | 11 +- open-api/immich-openapi-specs.json | 21 ++++ open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/dtos/search.dto.ts | 3 + server/src/interfaces/search.interface.ts | 7 +- server/src/utils/database.ts | 11 ++ .../search-bar/search-filter-modal.svelte | 8 ++ .../search-bar/search-tag-section.svelte | 106 ++++++++++++++++++ .../[[assetId=id]]/+page.svelte | 22 ++++ 10 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/components/shared-components/search-bar/search-tag-section.svelte diff --git a/i18n/en.json b/i18n/en.json index 2abc586c2375a..6933c6e164ae8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -750,6 +750,7 @@ "filename": "Filename", "filetype": "Filetype", "filter_people": "Filter people", + "filter_tags": "Filter tags", "find_them_fast": "Find them fast by name with search", "fix_incorrect_match": "Fix incorrect match", "folders": "Folders", @@ -1344,4 +1345,4 @@ "yes": "Yes", "you_dont_have_any_shared_links": "You don't have any shared links", "zoom_image": "Zoom Image" -} \ No newline at end of file +} diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 0aef1f623efd0..0c0a491f77756 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -43,6 +43,7 @@ class MetadataSearchDto { this.state, this.takenAfter, this.takenBefore, + this.tagIds = const [], this.thumbnailPath, this.trashedAfter, this.trashedBefore, @@ -257,6 +258,8 @@ class MetadataSearchDto { /// DateTime? takenBefore; + 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 @@ -371,6 +374,7 @@ class MetadataSearchDto { other.state == state && other.takenAfter == takenAfter && other.takenBefore == takenBefore && + _deepEquality.equals(other.tagIds, tagIds) && other.thumbnailPath == thumbnailPath && other.trashedAfter == trashedAfter && other.trashedBefore == trashedBefore && @@ -416,6 +420,7 @@ class MetadataSearchDto { (state == null ? 0 : state!.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + + (tagIds.hashCode) + (thumbnailPath == null ? 0 : thumbnailPath!.hashCode) + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + (trashedBefore == null ? 0 : trashedBefore!.hashCode) + @@ -429,7 +434,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, takenAfter=$takenAfter, takenBefore=$takenBefore, tagIds=$tagIds, 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 = {}; @@ -579,6 +584,7 @@ class MetadataSearchDto { } else { // json[r'takenBefore'] = null; } + json[r'tagIds'] = this.tagIds; if (this.thumbnailPath != null) { json[r'thumbnailPath'] = this.thumbnailPath; } else { @@ -674,6 +680,9 @@ class MetadataSearchDto { state: mapValueOfType(json, r'state'), takenAfter: mapDateTime(json, r'takenAfter', r''), takenBefore: mapDateTime(json, r'takenBefore', r''), + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], thumbnailPath: mapValueOfType(json, r'thumbnailPath'), trashedAfter: mapDateTime(json, r'trashedAfter', r''), trashedBefore: mapDateTime(json, r'trashedBefore', r''), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2686d4f96d69f..49ed549d82d2c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10031,6 +10031,13 @@ "nullable": true, "type": "string" }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "takenAfter": { "format": "date-time", "type": "string" @@ -10644,6 +10651,13 @@ "nullable": true, "type": "string" }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "takenAfter": { "format": "date-time", "type": "string" @@ -11559,6 +11573,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 c31e71d05e961..3e275f304718e 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -794,6 +794,7 @@ export type MetadataSearchDto = { state?: string | null; takenAfter?: string; takenBefore?: string; + tagIds?: string[]; thumbnailPath?: string; trashedAfter?: string; trashedBefore?: string; diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 5c5dce1a1190a..fe3ba7137ce31 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/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index d59291c88339b..e7a09e45fa603 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -113,6 +113,10 @@ export interface SearchPeopleOptions { personIds?: string[]; } +export interface SearchTagsOptions { + tagIds?: string[]; +} + export interface SearchOrderOptions { orderDirection?: 'ASC' | 'DESC'; } @@ -129,7 +133,8 @@ type BaseAssetSearchOptions = SearchDateOptions & SearchPathOptions & SearchStatusOptions & SearchUserIdOptions & - SearchPeopleOptions; + SearchPeopleOptions & + SearchTagsOptions; export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index ad2198b38c571..f75ac42b1a023 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -92,6 +92,7 @@ export function searchAssetBuilder( withPeople, personIds, withStacked, + tagIds, trashedAfter, trashedBefore, } = options; @@ -135,6 +136,16 @@ export function searchAssetBuilder( builder.getQuery(); // typeorm mixes up parameters without this (੭ °ཀ°)੭ } + // Add tags to query if present + if (tagIds && tagIds.length > 0) { + builder.innerJoin( + 'asset.tags', + 'asset_tags', + 'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor IN (:...tagIds))', + { tagIds: options.tagIds }, + ); + } + if (withStacked) { builder.leftJoinAndSelect(`${builder.alias}.stack`, 'stack').leftJoinAndSelect('stack.assets', 'stackedAssets'); } 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 de34092658a14..9cba37ae40abe 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; @@ -26,6 +27,7 @@ import SearchMediaSection from './search-media-section.svelte'; import { parseUtcDate } from '$lib/utils/date-time'; import SearchDisplaySection from './search-display-section.svelte'; + import SearchTagSection from './search-tag-section.svelte'; import SearchTextSection from './search-text-section.svelte'; import { t } from 'svelte-i18n'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; @@ -78,6 +80,7 @@ : searchQuery.type === AssetTypeEnum.Video ? MediaType.Video : MediaType.All, + tagIds: new SvelteSet('tagIds' in searchQuery ? searchQuery.tagIds : []), }); const resetForm = () => { @@ -90,6 +93,7 @@ date: {}, display: {}, mediaType: MediaType.All, + tagIds: new SvelteSet() }; }; @@ -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-tag-section.svelte b/web/src/lib/components/shared-components/search-bar/search-tag-section.svelte new file mode 100644 index 0000000000000..782835618125d --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/search-tag-section.svelte @@ -0,0 +1,106 @@ + + +{#await tagPromise then tags} + {#if tags && tags.length > 0} + {@const tagList = showAllTags + ? filterTags(tags, name) + : filterTags(tags, name).slice(0, numberOfTags)} + +
+
+

{$t('tags').toUpperCase()}

+ +
+ + + {#each tagList as tag (tag.id)} + + {/each} + + + {#if showAllTags || tags.length > tagList.length} +
+ +
+ {/if} +
+ {/if} +{/await} 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 fe4a7a6612082..81f60d409ae20 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 @@ -26,6 +26,7 @@ searchAssets, searchSmart, getPerson, + getTagById, type SmartSearchDto, type MetadataSearchDto, type AlbumResponseDto, @@ -193,11 +194,28 @@ make: $t('camera_brand'), model: $t('camera_model'), personIds: $t('people'), + tagIds: $t('tags'), originalFileName: $t('file_name'), }; return keyMap[key] || key; } + async function getTagName(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(', '); + } + async function getPersonName(personIds: string[]) { const personNames = await Promise.all( personIds.map(async (personId) => { @@ -298,6 +316,10 @@ {#await getPersonName(value) then personName} {personName} {/await} + {:else if key === 'tagIds' && Array.isArray(value)} + {#await getTagName(value) then tagName} + {tagName} + {/await} {:else if value === null || value === ''} {$t('unknown')} {:else}