From f778adea92386dc498d7b0e7e22482382e27fe3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9opold=20Koprivnik?= Date: Thu, 10 Jul 2025 16:28:20 +0200 Subject: [PATCH] feat: adds option to search only for untagged assets (#19730) Co-authored-by: SkwalExe --- i18n/en.json | 1 + .../lib/model/metadata_search_dto.dart | 8 +++++-- .../openapi/lib/model/random_search_dto.dart | 8 +++++-- .../openapi/lib/model/smart_search_dto.dart | 8 +++++-- .../lib/model/statistics_search_dto.dart | 8 +++++-- open-api/immich-openapi-specs.json | 4 ++++ open-api/typescript-sdk/src/fetch-client.ts | 8 +++---- server/src/dtos/search.dto.ts | 4 ++-- server/src/repositories/search.repository.ts | 2 +- server/src/utils/database.ts | 3 +++ .../shared-components/combobox.svelte | 3 +++ .../search-bar/search-tags-section.svelte | 23 ++++++++++++++++--- web/src/lib/modals/SearchFilterModal.svelte | 11 ++++++--- .../[[assetId=id]]/+page.svelte | 7 ++++-- 14 files changed, 75 insertions(+), 23 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index e5c995f957..352c8a24b6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1886,6 +1886,7 @@ "unselect_all_in": "Unselect all in {group}", "unstack": "Un-stack", "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", + "untagged": "Untagged", "up_next": "Up next", "updated_at": "Updated", "updated_password": "Updated password", diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 520777a45d..b7e637d4b4 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -241,7 +241,7 @@ class MetadataSearchDto { String? state; - List tagIds; + List? tagIds; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -425,7 +425,7 @@ class MetadataSearchDto { (rating == null ? 0 : rating!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + - (tagIds.hashCode) + + (tagIds == null ? 0 : tagIds!.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + (thumbnailPath == null ? 0 : thumbnailPath!.hashCode) + @@ -578,7 +578,11 @@ class MetadataSearchDto { } else { // json[r'state'] = null; } + if (this.tagIds != null) { json[r'tagIds'] = this.tagIds; + } else { + // json[r'tagIds'] = null; + } if (this.takenAfter != null) { json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); } else { diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index c5914f9fa3..98cc715af4 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -155,7 +155,7 @@ class RandomSearchDto { String? state; - List tagIds; + List? tagIds; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -310,7 +310,7 @@ class RandomSearchDto { (rating == null ? 0 : rating!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + - (tagIds.hashCode) + + (tagIds == null ? 0 : tagIds!.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + @@ -416,7 +416,11 @@ class RandomSearchDto { } else { // json[r'state'] = null; } + if (this.tagIds != null) { json[r'tagIds'] = this.tagIds; + } else { + // json[r'tagIds'] = null; + } if (this.takenAfter != null) { json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); } else { diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index c221340553..0d16b56d74 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -175,7 +175,7 @@ class SmartSearchDto { String? state; - List tagIds; + List? tagIds; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -318,7 +318,7 @@ class SmartSearchDto { (rating == null ? 0 : rating!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + - (tagIds.hashCode) + + (tagIds == null ? 0 : tagIds!.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + @@ -433,7 +433,11 @@ class SmartSearchDto { } else { // json[r'state'] = null; } + if (this.tagIds != null) { json[r'tagIds'] = this.tagIds; + } else { + // json[r'tagIds'] = null; + } if (this.takenAfter != null) { json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); } else { diff --git a/mobile/openapi/lib/model/statistics_search_dto.dart b/mobile/openapi/lib/model/statistics_search_dto.dart index 55de23ba32..73d80c9e36 100644 --- a/mobile/openapi/lib/model/statistics_search_dto.dart +++ b/mobile/openapi/lib/model/statistics_search_dto.dart @@ -149,7 +149,7 @@ class StatisticsSearchDto { String? state; - List tagIds; + List? tagIds; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -268,7 +268,7 @@ class StatisticsSearchDto { (personIds.hashCode) + (rating == null ? 0 : rating!.hashCode) + (state == null ? 0 : state!.hashCode) + - (tagIds.hashCode) + + (tagIds == null ? 0 : tagIds!.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + @@ -370,7 +370,11 @@ class StatisticsSearchDto { } else { // json[r'state'] = null; } + if (this.tagIds != null) { json[r'tagIds'] = this.tagIds; + } else { + // json[r'tagIds'] = null; + } if (this.takenAfter != null) { json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); } else { diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7a44a5cf6f..1624d0ed7e 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11203,6 +11203,7 @@ "format": "uuid", "type": "string" }, + "nullable": true, "type": "array" }, "takenAfter": { @@ -12092,6 +12093,7 @@ "format": "uuid", "type": "string" }, + "nullable": true, "type": "array" }, "takenAfter": { @@ -13157,6 +13159,7 @@ "format": "uuid", "type": "string" }, + "nullable": true, "type": "array" }, "takenAfter": { @@ -13348,6 +13351,7 @@ "format": "uuid", "type": "string" }, + "nullable": true, "type": "array" }, "takenAfter": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9eb9990d2c..55991b8599 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -889,7 +889,7 @@ export type MetadataSearchDto = { rating?: number; size?: number; state?: string | null; - tagIds?: string[]; + tagIds?: string[] | null; takenAfter?: string; takenBefore?: string; thumbnailPath?: string; @@ -956,7 +956,7 @@ export type RandomSearchDto = { rating?: number; size?: number; state?: string | null; - tagIds?: string[]; + tagIds?: string[] | null; takenAfter?: string; takenBefore?: string; trashedAfter?: string; @@ -993,7 +993,7 @@ export type SmartSearchDto = { rating?: number; size?: number; state?: string | null; - tagIds?: string[]; + tagIds?: string[] | null; takenAfter?: string; takenBefore?: string; trashedAfter?: string; @@ -1025,7 +1025,7 @@ export type StatisticsSearchDto = { personIds?: string[]; rating?: number; state?: string | null; - tagIds?: string[]; + tagIds?: string[] | null; takenAfter?: string; takenBefore?: string; trashedAfter?: string; diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index d0427ef322..0024a1b34e 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -92,8 +92,8 @@ class BaseSearchDto { @ValidateUUID({ each: true, optional: true }) personIds?: string[]; - @ValidateUUID({ each: true, optional: true }) - tagIds?: string[]; + @ValidateUUID({ each: true, optional: true, nullable: true }) + tagIds?: string[] | null; @ValidateUUID({ each: true, optional: true }) albumIds?: string[]; diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 9026b795ca..b354e33e57 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -89,7 +89,7 @@ export interface SearchPeopleOptions { } export interface SearchTagOptions { - tagIds?: string[]; + tagIds?: string[] | null; } export interface SearchAlbumOptions { diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 191e02eb63..3d6f7b12a5 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -307,6 +307,9 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .where('assets.visibility', '=', visibility) .$if(!!options.albumIds && options.albumIds.length > 0, (qb) => inAlbums(qb, options.albumIds!)) .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) + .$if(options.tagIds === null, (qb) => + qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('tag_asset').whereRef('assetsId', '=', 'assets.id')))), + ) .$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/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 2b3dd23cd4..ce6f8482f2 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -33,6 +33,7 @@ interface Props { label: string; + disabled?: boolean; hideLabel?: boolean; options?: ComboBoxOption[]; selectedOption?: ComboBoxOption | undefined; @@ -52,6 +53,7 @@ let { label, hideLabel = false, + disabled = false, options = [], selectedOption = $bindable(), placeholder = '', @@ -275,6 +277,7 @@ ; + selectedTags: SvelteSet | null; } let { selectedTags = $bindable() }: Props = $props(); @@ -23,7 +24,7 @@ }); const handleSelect = (option?: ComboBoxOption) => { - if (!option || !option.id) { + if (!option || !option.id || selectedTags === null) { return; } @@ -32,6 +33,10 @@ }; const handleRemove = (tag: string) => { + if (selectedTags === null) { + return; + } + selectedTags.delete(tag); }; @@ -41,6 +46,7 @@
+
+ { + selectedTags = checked ? null : new SvelteSet(); + }} + /> +
- {#each selectedTags as tagId (tagId)} + {#each selectedTags ?? [] as tagId (tagId)} {@const tag = tagMap[tagId]} {#if tag}
diff --git a/web/src/lib/modals/SearchFilterModal.svelte b/web/src/lib/modals/SearchFilterModal.svelte index 9389a890e7..0647d0bb71 100644 --- a/web/src/lib/modals/SearchFilterModal.svelte +++ b/web/src/lib/modals/SearchFilterModal.svelte @@ -8,7 +8,7 @@ query: string; queryType: 'smart' | 'metadata' | 'description'; personIds: SvelteSet; - tagIds: SvelteSet; + tagIds: SvelteSet | null; location: SearchLocationFilter; camera: SearchCameraFilter; date: SearchDateFilter; @@ -68,7 +68,12 @@ query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '', queryType: defaultQueryType(), personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), - tagIds: new SvelteSet('tagIds' in searchQuery ? searchQuery.tagIds : []), + tagIds: + 'tagIds' in searchQuery + ? searchQuery.tagIds === null + ? null + : new SvelteSet(searchQuery.tagIds) + : new SvelteSet(), location: { country: withNullAsUndefined(searchQuery.country), state: withNullAsUndefined(searchQuery.state), @@ -140,7 +145,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, + tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined, type, rating: filter.rating, }; 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 092eb3b0d4..acb7176a7c 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 @@ -233,7 +233,10 @@ return personNames.join(', '); } - async function getTagNames(tagIds: string[]) { + async function getTagNames(tagIds: string[] | null) { + if (tagIds === null) { + return $t('untagged'); + } const tagNames = await Promise.all( tagIds.map(async (tagId) => { const tag = await getTagById({ id: tagId }); @@ -343,7 +346,7 @@ {#await getPersonName(value) then personName} {personName} {/await} - {:else if searchKey === 'tagIds' && Array.isArray(value)} + {:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)} {#await getTagNames(value) then tagNames} {tagNames} {/await}