diff --git a/i18n/en.json b/i18n/en.json index e5c995f95..352c8a24b 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 520777a45..b7e637d4b 100644 Binary files a/mobile/openapi/lib/model/metadata_search_dto.dart and b/mobile/openapi/lib/model/metadata_search_dto.dart differ diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index c5914f9fa..98cc715af 100644 Binary files a/mobile/openapi/lib/model/random_search_dto.dart and b/mobile/openapi/lib/model/random_search_dto.dart differ diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index c22134055..0d16b56d7 100644 Binary files a/mobile/openapi/lib/model/smart_search_dto.dart and b/mobile/openapi/lib/model/smart_search_dto.dart differ diff --git a/mobile/openapi/lib/model/statistics_search_dto.dart b/mobile/openapi/lib/model/statistics_search_dto.dart index 55de23ba3..73d80c9e3 100644 Binary files a/mobile/openapi/lib/model/statistics_search_dto.dart and b/mobile/openapi/lib/model/statistics_search_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7a44a5cf6..1624d0ed7 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 9eb9990d2..55991b859 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 d0427ef32..0024a1b34 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 9026b795c..b354e33e5 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 191e02eb6..3d6f7b12a 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 2b3dd23cd..ce6f8482f 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 9389a890e..0647d0bb7 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 092eb3b0d..acb7176a7 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}