feat: add searching by tags (#15395)

* feat: add searching by tags

* fix: fix merge

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
David Wolff 2025-01-31 22:37:22 +01:00 committed by GitHub
parent 221e197633
commit 9ac95d6845
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 157 additions and 2 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -10036,6 +10036,13 @@
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"tagIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"takenAfter": { "takenAfter": {
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
@ -10649,6 +10656,13 @@
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"tagIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"takenAfter": { "takenAfter": {
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
@ -11564,6 +11578,13 @@
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"tagIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"takenAfter": { "takenAfter": {
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"

View file

@ -792,6 +792,7 @@ export type MetadataSearchDto = {
previewPath?: string; previewPath?: string;
size?: number; size?: number;
state?: string | null; state?: string | null;
tagIds?: string[];
takenAfter?: string; takenAfter?: string;
takenBefore?: string; takenBefore?: string;
thumbnailPath?: string; thumbnailPath?: string;
@ -858,6 +859,7 @@ export type RandomSearchDto = {
personIds?: string[]; personIds?: string[];
size?: number; size?: number;
state?: string | null; state?: string | null;
tagIds?: string[];
takenAfter?: string; takenAfter?: string;
takenBefore?: string; takenBefore?: string;
trashedAfter?: string; trashedAfter?: string;
@ -893,6 +895,7 @@ export type SmartSearchDto = {
query: string; query: string;
size?: number; size?: number;
state?: string | null; state?: string | null;
tagIds?: string[];
takenAfter?: string; takenAfter?: string;
takenBefore?: string; takenBefore?: string;
trashedAfter?: string; trashedAfter?: string;

View file

@ -111,6 +111,9 @@ class BaseSearchDto {
@ValidateUUID({ each: true, optional: true }) @ValidateUUID({ each: true, optional: true })
personIds?: string[]; personIds?: string[];
@ValidateUUID({ each: true, optional: true })
tagIds?: string[];
} }
export class RandomSearchDto extends BaseSearchDto { export class RandomSearchDto extends BaseSearchDto {

View file

@ -252,6 +252,21 @@ export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'assets', O>, personIds:
); );
} }
export function hasTags<O>(qb: SelectQueryBuilder<DB, 'assets', O>, 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<DB, 'assets'>) { export function withOwner(eb: ExpressionBuilder<DB, 'assets'>) {
return jsonObjectFrom(eb.selectFrom('users').selectAll().whereRef('users.id', '=', 'assets.ownerId')).as('owner'); return jsonObjectFrom(eb.selectFrom('users').selectAll().whereRef('users.id', '=', 'assets.ownerId')).as('owner');
} }
@ -326,6 +341,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.withPlugin(joinDeduplicationPlugin) .withPlugin(joinDeduplicationPlugin)
.selectFrom('assets') .selectFrom('assets')
.selectAll('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.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!))
.$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!)) .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!))
.$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!)) .$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!))

View file

@ -112,6 +112,10 @@ export interface SearchPeopleOptions {
personIds?: string[]; personIds?: string[];
} }
export interface SearchTagOptions {
tagIds?: string[];
}
export interface SearchOrderOptions { export interface SearchOrderOptions {
orderDirection?: 'asc' | 'desc'; orderDirection?: 'asc' | 'desc';
} }
@ -128,7 +132,8 @@ type BaseAssetSearchOptions = SearchDateOptions &
SearchPathOptions & SearchPathOptions &
SearchStatusOptions & SearchStatusOptions &
SearchUserIdOptions & SearchUserIdOptions &
SearchPeopleOptions; SearchPeopleOptions &
SearchTagOptions;
export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions;
@ -142,7 +147,8 @@ export type SmartSearchOptions = SearchDateOptions &
SearchOneToOneRelationOptions & SearchOneToOneRelationOptions &
SearchStatusOptions & SearchStatusOptions &
SearchUserIdOptions & SearchUserIdOptions &
SearchPeopleOptions; SearchPeopleOptions &
SearchTagOptions;
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
hasPerson?: boolean; hasPerson?: boolean;

View file

@ -8,6 +8,7 @@
query: string; query: string;
queryType: 'smart' | 'metadata'; queryType: 'smart' | 'metadata';
personIds: SvelteSet<string>; personIds: SvelteSet<string>;
tagIds: SvelteSet<string>;
location: SearchLocationFilter; location: SearchLocationFilter;
camera: SearchCameraFilter; camera: SearchCameraFilter;
date: SearchDateFilter; date: SearchDateFilter;
@ -20,6 +21,7 @@
import { Button } from '@immich/ui'; import { Button } from '@immich/ui';
import { AssetTypeEnum, type SmartSearchDto, type MetadataSearchDto } from '@immich/sdk'; import { AssetTypeEnum, type SmartSearchDto, type MetadataSearchDto } from '@immich/sdk';
import SearchPeopleSection from './search-people-section.svelte'; import SearchPeopleSection from './search-people-section.svelte';
import SearchTagsSection from './search-tags-section.svelte';
import SearchLocationSection from './search-location-section.svelte'; import SearchLocationSection from './search-location-section.svelte';
import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte'; import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte';
import SearchDateSection from './search-date-section.svelte'; import SearchDateSection from './search-date-section.svelte';
@ -54,6 +56,7 @@
query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '', query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '',
queryType: 'query' in searchQuery ? 'smart' : 'metadata', queryType: 'query' in searchQuery ? 'smart' : 'metadata',
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
tagIds: new SvelteSet('tagIds' in searchQuery ? searchQuery.tagIds : []),
location: { location: {
country: withNullAsUndefined(searchQuery.country), country: withNullAsUndefined(searchQuery.country),
state: withNullAsUndefined(searchQuery.state), state: withNullAsUndefined(searchQuery.state),
@ -85,6 +88,7 @@
query: '', query: '',
queryType: 'smart', queryType: 'smart',
personIds: new SvelteSet(), personIds: new SvelteSet(),
tagIds: new SvelteSet(),
location: {}, location: {},
camera: {}, camera: {},
date: {}, date: {},
@ -117,6 +121,7 @@
isFavorite: filter.display.isFavorite || undefined, isFavorite: filter.display.isFavorite || undefined,
isNotInAlbum: filter.display.isNotInAlbum || undefined, isNotInAlbum: filter.display.isNotInAlbum || undefined,
personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined, personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined,
tagIds: filter.tagIds.size > 0 ? [...filter.tagIds] : undefined,
type, type,
}; };
@ -143,6 +148,9 @@
<!-- TEXT --> <!-- TEXT -->
<SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} /> <SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} />
<!-- TAGS -->
<SearchTagsSection bind:selectedTags={filter.tagIds} />
<!-- LOCATION --> <!-- LOCATION -->
<SearchLocationSection bind:filters={filter.location} /> <SearchLocationSection bind:filters={filter.location} />

View file

@ -0,0 +1,80 @@
<script lang="ts">
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import { getAllTags, type TagResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiClose } from '@mdi/js';
import { preferences } from '$lib/stores/user.store';
interface Props {
selectedTags: SvelteSet<string>;
}
let { selectedTags = $bindable() }: Props = $props();
let allTags: TagResponseDto[] = $state([]);
let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag])));
let selectedOption = $state(undefined);
onMount(async () => {
allTags = await getAllTags();
});
const handleSelect = (option?: ComboBoxOption) => {
if (!option || !option.id) {
return;
}
selectedTags.add(option.value);
selectedOption = undefined;
};
const handleRemove = (tag: string) => {
selectedTags.delete(tag);
};
</script>
{#if $preferences?.tags?.enabled}
<div id="location-selection">
<form autocomplete="off" id="create-tag-form">
<div class="my-4 flex flex-col gap-2">
<Combobox
onSelect={handleSelect}
label={$t('tags').toUpperCase()}
defaultFirstOption
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
bind:selectedOption
placeholder={$t('search_tags')}
/>
</div>
</form>
<section class="flex flex-wrap pt-2 gap-1">
{#each selectedTags as tagId (tagId)}
{@const tag = tagMap[tagId]}
{#if tag}
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap pl-3 pr-1 group-hover:pl-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary rounded-tl-full rounded-bl-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{tag.value}
</p>
</span>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title="Remove tag"
onclick={() => handleRemove(tagId)}
>
<Icon path={mdiClose} />
</button>
</div>
{/if}
{/each}
</section>
</div>
{/if}

View file

@ -29,6 +29,7 @@
type SmartSearchDto, type SmartSearchDto,
type MetadataSearchDto, type MetadataSearchDto,
type AlbumResponseDto, type AlbumResponseDto,
getTagById,
} from '@immich/sdk'; } from '@immich/sdk';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import type { Viewport } from '$lib/stores/assets.store'; import type { Viewport } from '$lib/stores/assets.store';
@ -194,6 +195,7 @@
model: $t('camera_model'), model: $t('camera_model'),
lensModel: $t('lens_model'), lensModel: $t('lens_model'),
personIds: $t('people'), personIds: $t('people'),
tagIds: $t('tags'),
originalFileName: $t('file_name'), originalFileName: $t('file_name'),
}; };
return keyMap[key] || key; return keyMap[key] || key;
@ -215,6 +217,18 @@
return personNames.join(', '); 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 triggerAssetUpdate = () => (searchResultAssets = searchResultAssets);
const onAddToAlbum = (assetIds: string[]) => { const onAddToAlbum = (assetIds: string[]) => {
@ -299,6 +313,10 @@
{#await getPersonName(value) then personName} {#await getPersonName(value) then personName}
{personName} {personName}
{/await} {/await}
{:else if key === 'tagIds' && Array.isArray(value)}
{#await getTagNames(value) then tagNames}
{tagNames}
{/await}
{:else if value === null || value === ''} {:else if value === null || value === ''}
{$t('unknown')} {$t('unknown')}
{:else} {:else}