mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00: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:
parent
221e197633
commit
9ac95d6845
11 changed files with 157 additions and 2 deletions
BIN
mobile/openapi/lib/model/metadata_search_dto.dart
generated
BIN
mobile/openapi/lib/model/metadata_search_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/random_search_dto.dart
generated
BIN
mobile/openapi/lib/model/random_search_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/smart_search_dto.dart
generated
BIN
mobile/openapi/lib/model/smart_search_dto.dart
generated
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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!))
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue