feat(web): search by filename (#7624)

* Toggle to search by filename

* wild card search and pr feedback

* Pr feedback

* naming

* placeholder

* Create index

* pr feedback

* pr feedback

* Update web/src/lib/components/shared-components/search-bar/search-text-section.svelte

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* pr feedback

* pr feedback

* pr feedback

* pr feedback

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Alex 2024-03-05 17:08:35 -06:00 committed by GitHub
parent ae46188753
commit 2f53f6a62c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 86 additions and 15 deletions

View File

@ -35,6 +35,7 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_library_checksum';
@Index('IDX_day_of_month', { synchronize: false }) @Index('IDX_day_of_month', { synchronize: false })
@Index('IDX_month', { synchronize: false }) @Index('IDX_month', { synchronize: false })
@Index('IDX_originalPath_libraryId', ['originalPath', 'libraryId']) @Index('IDX_originalPath_libraryId', ['originalPath', 'libraryId'])
@Index('idx_originalpath_trigram', { synchronize: false })
// For all assets, each originalpath must be unique per user and library // For all assets, each originalpath must be unique per user and library
export class AssetEntity { export class AssetEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')

View File

@ -160,9 +160,15 @@ export function searchAssetBuilder(
builder.andWhere(`${builder.alias}.ownerId IN (:...userIds)`, { userIds: options.userIds }); builder.andWhere(`${builder.alias}.ownerId IN (:...userIds)`, { userIds: options.userIds });
} }
const path = _.pick(options, ['encodedVideoPath', 'originalFileName', 'originalPath', 'resizePath', 'webpPath']); const path = _.pick(options, ['encodedVideoPath', 'originalFileName', 'resizePath', 'webpPath']);
builder.andWhere(_.omitBy(path, _.isUndefined)); builder.andWhere(_.omitBy(path, _.isUndefined));
if (options.originalPath) {
builder.andWhere(`f_unaccent(${builder.alias}.originalPath) ILIKE f_unaccent(:originalPath)`, {
originalPath: `%${options.originalPath}%`,
});
}
const status = _.pick(options, ['isExternal', 'isFavorite', 'isOffline', 'isReadOnly', 'isVisible', 'type']); const status = _.pick(options, ['isExternal', 'isFavorite', 'isOffline', 'isReadOnly', 'isVisible', 'type']);
const { const {
isArchived, isArchived,

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAssetOriginalPathTrigramIndex1709608140355 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX idx_originalpath_trigram
ON assets
USING gin (f_unaccent("originalPath") gin_trgm_ops)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "idx_originalpath_trigram"`);
}
}

View File

@ -11,6 +11,7 @@
export type SearchFilter = { export type SearchFilter = {
context?: string; context?: string;
filename?: string;
personIds: Set<string>; personIds: Set<string>;
location: SearchLocationFilter; location: SearchLocationFilter;
camera: SearchCameraFilter; camera: SearchCameraFilter;
@ -32,6 +33,7 @@
import SearchMediaSection from './search-media-section.svelte'; import SearchMediaSection from './search-media-section.svelte';
import { parseUtcDate } from '$lib/utils/date-time'; import { parseUtcDate } from '$lib/utils/date-time';
import SearchDisplaySection from './search-display-section.svelte'; import SearchDisplaySection from './search-display-section.svelte';
import SearchTextSection from './search-text-section.svelte';
export let searchQuery: MetadataSearchDto | SmartSearchDto; export let searchQuery: MetadataSearchDto | SmartSearchDto;
@ -41,6 +43,7 @@
let filter: SearchFilter = { let filter: SearchFilter = {
context: 'query' in searchQuery ? searchQuery.query : '', context: 'query' in searchQuery ? searchQuery.query : '',
filename: 'originalPath' in searchQuery ? searchQuery.originalPath : undefined,
personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []), personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []),
location: { location: {
country: searchQuery.country, country: searchQuery.country,
@ -91,6 +94,7 @@
let payload: SmartSearchDto | MetadataSearchDto = { let payload: SmartSearchDto | MetadataSearchDto = {
query: filter.context || undefined, query: filter.context || undefined,
originalPath: filter.filename,
country: filter.location.country, country: filter.location.country,
state: filter.location.state, state: filter.location.state,
city: filter.location.city, city: filter.location.city,
@ -124,20 +128,8 @@
<!-- PEOPLE --> <!-- PEOPLE -->
<SearchPeopleSection width={filterBoxWidth} bind:selectedPeople={filter.personIds} /> <SearchPeopleSection width={filterBoxWidth} bind:selectedPeople={filter.personIds} />
<!-- CONTEXT --> <!-- TEXT -->
<div> <SearchTextSection bind:filename={filter.filename} bind:context={filter.context} />
<label class="immich-form-label" for="context">
<span>CONTEXT</span>
<input
class="immich-form-input hover:cursor-text w-full mt-1"
type="text"
id="context"
name="context"
placeholder="Sunrise on the beach"
bind:value={filter.context}
/>
</label>
</div>
<!-- LOCATION --> <!-- LOCATION -->
<SearchLocationSection bind:filters={filter.location} /> <SearchLocationSection bind:filters={filter.location} />

View File

@ -0,0 +1,57 @@
<script lang="ts">
export let filename: string | undefined;
export let context: string | undefined;
enum TextSearchOptions {
Context = 'context',
Filename = 'filename',
}
let selectedOption = filename ? TextSearchOptions.Filename : TextSearchOptions.Context;
$: {
if (selectedOption === TextSearchOptions.Context) {
filename = undefined;
} else {
context = undefined;
}
}
</script>
<div class="flex gap-5">
<label class="immich-form-label" for="context">
<input type="radio" name="context" id="context" bind:group={selectedOption} value={TextSearchOptions.Context} />
<span>CONTEXT</span>
</label>
<label class="immich-form-label" for="file-name">
<input
type="radio"
name="file-name"
id="file-name"
bind:group={selectedOption}
value={TextSearchOptions.Filename}
/>
<span>FILE NAME</span>
</label>
</div>
{#if selectedOption === TextSearchOptions.Context}
<input
class="immich-form-input hover:cursor-text w-full !mt-1"
type="text"
id="context"
name="context"
placeholder="Sunrise on the beach"
bind:value={context}
/>
{:else}
<input
class="immich-form-input hover:cursor-text w-full !mt-1"
type="text"
id="file-name"
name="file-name"
placeholder="File name or extension i.e. IMG_1234.JPG or PNG"
bind:value={filename}
/>
{/if}

View File

@ -173,6 +173,7 @@
make: 'Camera brand', make: 'Camera brand',
model: 'Camera model', model: 'Camera model',
personIds: 'People', personIds: 'People',
originalPath: 'File name',
}; };
return keyMap[key] || key; return keyMap[key] || key;
} }