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,
"type": "string"
},
"tagIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"takenAfter": {
"format": "date-time",
"type": "string"
@ -10649,6 +10656,13 @@
"nullable": true,
"type": "string"
},
"tagIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"takenAfter": {
"format": "date-time",
"type": "string"
@ -11564,6 +11578,13 @@
"nullable": true,
"type": "string"
},
"tagIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"takenAfter": {
"format": "date-time",
"type": "string"

View file

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

View file

@ -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 {

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'>) {
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)
.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!))

View file

@ -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;

View file

@ -8,6 +8,7 @@
query: string;
queryType: 'smart' | 'metadata';
personIds: SvelteSet<string>;
tagIds: SvelteSet<string>;
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 @@
<!-- TEXT -->
<SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} />
<!-- TAGS -->
<SearchTagsSection bind:selectedTags={filter.tagIds} />
<!-- 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 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}