0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-04-08 03:01:32 -05:00

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 187 additions and 5 deletions

View file

@ -41,6 +41,7 @@ class MetadataSearchDto {
this.previewPath,
this.size,
this.state,
this.tagIds = const [],
this.takenAfter,
this.takenBefore,
this.thumbnailPath,
@ -235,6 +236,8 @@ class MetadataSearchDto {
String? state;
List<String> 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
@ -363,6 +366,7 @@ class MetadataSearchDto {
other.previewPath == previewPath &&
other.size == size &&
other.state == state &&
_deepEquality.equals(other.tagIds, tagIds) &&
other.takenAfter == takenAfter &&
other.takenBefore == takenBefore &&
other.thumbnailPath == thumbnailPath &&
@ -408,6 +412,7 @@ class MetadataSearchDto {
(previewPath == null ? 0 : previewPath!.hashCode) +
(size == null ? 0 : size!.hashCode) +
(state == null ? 0 : state!.hashCode) +
(tagIds.hashCode) +
(takenAfter == null ? 0 : takenAfter!.hashCode) +
(takenBefore == null ? 0 : takenBefore!.hashCode) +
(thumbnailPath == null ? 0 : thumbnailPath!.hashCode) +
@ -423,7 +428,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, tagIds=$tagIds, 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]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -559,6 +564,7 @@ class MetadataSearchDto {
} else {
// json[r'state'] = null;
}
json[r'tagIds'] = this.tagIds;
if (this.takenAfter != null) {
json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String();
} else {
@ -662,6 +668,9 @@ class MetadataSearchDto {
previewPath: mapValueOfType<String>(json, r'previewPath'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
takenAfter: mapDateTime(json, r'takenAfter', r''),
takenBefore: mapDateTime(json, r'takenBefore', r''),
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath'),

View file

@ -32,6 +32,7 @@ class RandomSearchDto {
this.personIds = const [],
this.size,
this.state,
this.tagIds = const [],
this.takenAfter,
this.takenBefore,
this.trashedAfter,
@ -158,6 +159,8 @@ class RandomSearchDto {
String? state;
List<String> 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
@ -269,6 +272,7 @@ class RandomSearchDto {
_deepEquality.equals(other.personIds, personIds) &&
other.size == size &&
other.state == state &&
_deepEquality.equals(other.tagIds, tagIds) &&
other.takenAfter == takenAfter &&
other.takenBefore == takenBefore &&
other.trashedAfter == trashedAfter &&
@ -304,6 +308,7 @@ class RandomSearchDto {
(personIds.hashCode) +
(size == null ? 0 : size!.hashCode) +
(state == null ? 0 : state!.hashCode) +
(tagIds.hashCode) +
(takenAfter == null ? 0 : takenAfter!.hashCode) +
(takenBefore == null ? 0 : takenBefore!.hashCode) +
(trashedAfter == null ? 0 : trashedAfter!.hashCode) +
@ -318,7 +323,7 @@ class RandomSearchDto {
(withStacked == null ? 0 : withStacked!.hashCode);
@override
String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]';
String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -413,6 +418,7 @@ class RandomSearchDto {
} else {
// json[r'state'] = null;
}
json[r'tagIds'] = this.tagIds;
if (this.takenAfter != null) {
json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String();
} else {
@ -502,6 +508,9 @@ class RandomSearchDto {
: const [],
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
takenAfter: mapDateTime(json, r'takenAfter', r''),
takenBefore: mapDateTime(json, r'takenBefore', r''),
trashedAfter: mapDateTime(json, r'trashedAfter', r''),

View file

@ -34,6 +34,7 @@ class SmartSearchDto {
required this.query,
this.size,
this.state,
this.tagIds = const [],
this.takenAfter,
this.takenBefore,
this.trashedAfter,
@ -169,6 +170,8 @@ class SmartSearchDto {
String? state;
List<String> 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
@ -266,6 +269,7 @@ class SmartSearchDto {
other.query == query &&
other.size == size &&
other.state == state &&
_deepEquality.equals(other.tagIds, tagIds) &&
other.takenAfter == takenAfter &&
other.takenBefore == takenBefore &&
other.trashedAfter == trashedAfter &&
@ -301,6 +305,7 @@ class SmartSearchDto {
(query.hashCode) +
(size == null ? 0 : size!.hashCode) +
(state == null ? 0 : state!.hashCode) +
(tagIds.hashCode) +
(takenAfter == null ? 0 : takenAfter!.hashCode) +
(takenBefore == null ? 0 : takenBefore!.hashCode) +
(trashedAfter == null ? 0 : trashedAfter!.hashCode) +
@ -313,7 +318,7 @@ class SmartSearchDto {
(withExif == null ? 0 : withExif!.hashCode);
@override
String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]';
String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -414,6 +419,7 @@ class SmartSearchDto {
} else {
// json[r'state'] = null;
}
json[r'tagIds'] = this.tagIds;
if (this.takenAfter != null) {
json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String();
} else {
@ -495,6 +501,9 @@ class SmartSearchDto {
query: mapValueOfType<String>(json, r'query')!,
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
takenAfter: mapDateTime(json, r'takenAfter', r''),
takenBefore: mapDateTime(json, r'takenBefore', r''),
trashedAfter: mapDateTime(json, r'trashedAfter', r''),

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}