diff --git a/i18n/en.json b/i18n/en.json index 32be9d6c78f8ec..f0761e209a7837 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -807,7 +807,7 @@ "create_shared_album_page_share_select_photos": "Select Photos", "create_shared_link": "Create shared link", "create_tag": "Create tag", - "create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.", + "create_tag_description": "Create a new tag. For nested tags, the full path where your new tag will be placed is printed below.", "create_user": "Create user", "create_workflow": "Create workflow", "created": "Created", @@ -1845,6 +1845,7 @@ "search": "Search", "search_albums": "Search albums", "search_by_context": "Search by context", + "search_by_context_example": "Describe the photo you want to find, e.g. \"Children jumping on a trampoline\"", "search_by_description": "Search by description", "search_by_description_example": "Hiking day in Sapa", "search_by_filename": "Search by file name or extension", @@ -2128,6 +2129,8 @@ "tag_created": "Created tag: {tag}", "tag_face": "Tag face", "tag_feature_description": "Browsing photos and videos grouped by logical tag topics", + "tag_full_path": "Full path: {tag}", + "tag_not_found_question": "Cannot find a tag? Create a new tag.", "tag_people": "Tag People", "tag_plus_more_tags": "{tag} + {count, plural, one {# tag} other {# tags}}", "tag_updated": "Updated tag: {tag}", diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index bb25fc473943d4..b64cb20e74ad65 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -86,7 +86,7 @@ class BaseActionButton extends StatelessWidget { const SizedBox(height: 8), Text( label, - style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400), + style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w500), maxLines: 3, textAlign: TextAlign.center, softWrap: true, diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 15b90e58ea2abc..d6bf888e09be9b 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -65,7 +65,7 @@ class ViewerBottomBar extends ConsumerWidget { ? const SizedBox.shrink() : Theme( data: context.themeData.copyWith( - iconTheme: const IconThemeData(size: 22, color: Colors.white), + iconTheme: const IconThemeData(size: ImmichIconSize.md, color: Colors.white), textTheme: context.themeData.textTheme.copyWith( labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white), ), diff --git a/mobile/packages/ui/lib/src/components/column_button.dart b/mobile/packages/ui/lib/src/components/column_button.dart index 03b6933389b912..62b4c450094f20 100644 --- a/mobile/packages/ui/lib/src/components/column_button.dart +++ b/mobile/packages/ui/lib/src/components/column_button.dart @@ -78,7 +78,7 @@ class _ImmichColumnButtonState extends State { maxLines: 2, textAlign: .center, overflow: .ellipsis, - style: const .new(fontSize: ImmichTextSize.label, fontWeight: .w500), + style: const .new(fontSize: ImmichTextSize.body, fontWeight: .w500), ), ], ), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0b832e3c08e5b5..66a430ae13d4d0 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -28192,6 +28192,11 @@ "nullable": true, "pattern": "^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$", "type": "string" + }, + "name": { + "description": "Tag name", + "pattern": "^[^/]*$", + "type": "string" } }, "type": "object" diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 2834c63669c7a6..98444f4c45a028 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -2876,6 +2876,8 @@ export type TagBulkAssetsResponseDto = { export type TagUpdateDto = { /** Tag color (hex) */ color?: string | null; + /** Tag name */ + name?: string; }; export type TimeBucketAssetResponseDto = { /** Array of city names extracted from EXIF GPS data */ diff --git a/server/src/controllers/tag.controller.spec.ts b/server/src/controllers/tag.controller.spec.ts index a89ce14ddac192..432ef78f123b3b 100644 --- a/server/src/controllers/tag.controller.spec.ts +++ b/server/src/controllers/tag.controller.spec.ts @@ -2,6 +2,7 @@ import { TagController } from 'src/controllers/tag.controller'; import { TagService } from 'src/services/tag.service'; import request from 'supertest'; import { errorDto } from 'test/medium/responses'; +import { factory } from 'test/small.factory'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; describe(TagController.name, () => { @@ -33,6 +34,46 @@ describe(TagController.name, () => { }); }); + describe('PUT /tags/:id', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/tags/123`) + .send({ name: 'tag', color: '#000000' }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + }); + it('should require a valid color', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/tags/${factory.uuid()}`) + .send({ name: 'tag', color: 'invalid-color' }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([ + { + path: ['color'], + message: + 'Invalid string: must match pattern /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/', + }, + ]), + ); + }); + it('should throw an error if a slash is in tag name', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/tags/${factory.uuid()}`) + .send({ name: 'tagA/tagB', color: '#000000' }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: ['name'], message: 'Tag name cannot contain slash characters ("/")' }]), + ); + }); + it('should accept a null color', async () => { + const { status } = await request(ctx.getHttpServer()) + .put(`/tags/${factory.uuid()}`) + .send({ name: 'tagA', color: null }); + expect(status).toBe(200); + }); + }); + describe('DELETE /tags/:id', () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/tags/123`); diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index cfbae551a814b0..1c376b9c381904 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -13,8 +13,13 @@ const TagCreateSchema = z }) .meta({ id: 'TagCreateDto' }); -const TagUpdateSchema = z +export const TagUpdateSchema = z .object({ + name: z + .string() + .regex(/^[^/]*$/, `Tag name cannot contain slash characters ("/")`) + .optional() + .describe('Tag name'), color: hexColor.nullable().optional().describe('Tag color (hex)'), }) .meta({ id: 'TagUpdateDto' }); diff --git a/server/src/queries/tag.repository.sql b/server/src/queries/tag.repository.sql index c3b46dd9f3f2b4..317298bea6c5cb 100644 --- a/server/src/queries/tag.repository.sql +++ b/server/src/queries/tag.repository.sql @@ -69,13 +69,22 @@ returning * -- TagRepository.update +begin +select + "value" +from + "tag" +where + "id" = $1 update "tag" set - "color" = $1 + "value" = $1, + "color" = $2 where - "id" = $2 + "id" = $3 returning * +rollback -- TagRepository.delete delete from "tag" diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index d4572886af6281..c101f6b6852218 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -78,9 +78,68 @@ export class TagRepository { return this.db.insertInto('tag').values(tag).returningAll().executeTakeFirstOrThrow(); } - @GenerateSql({ params: [DummyValue.UUID, { color: DummyValue.STRING }] }) - update(id: string, dto: Updateable) { - return this.db.updateTable('tag').set(dto).where('id', '=', id).returningAll().executeTakeFirstOrThrow(); + @GenerateSql({ params: [DummyValue.UUID, { value: DummyValue.STRING, color: DummyValue.STRING }] }) + async update(id: string, dto: Updateable) { + return this.db.transaction().execute(async (tx) => { + // Get previous tag value for reference if the current update contains a new value + const previousTag = + dto.value === undefined + ? undefined + : await tx.selectFrom('tag').select('value').where('id', '=', id).executeTakeFirst(); + + // Perform main tag update + const updated = await tx + .updateTable('tag') + .set(dto) + .where('id', '=', id) + .returningAll() + .executeTakeFirstOrThrow(); + + // Check if value has changed, trigger value updates on all children if so + if (previousTag && dto.value !== previousTag.value) { + await tx + // Use a recursive cte to get all levels of nested child tags that need to be updated + .withRecursive('descendants(id, value)', (qb) => { + const directChildren = qb + .selectFrom('tag as child') + .select((eb) => [ + 'child.id as id', + eb + .fn('concat', [ + eb.cast(eb.val(updated.value), 'text'), + eb.cast(eb.val('/'), 'text'), + eb.fn('regexp_replace', ['child.value', eb.val('^.*/'), eb.val('')]), + ]) + .as('value'), + ]) + .where('child.parentId', '=', id); + + const nestedChildren = qb + .selectFrom('tag as child') + .innerJoin('descendants as parent', 'parent.id', 'child.parentId') + .select((eb) => [ + 'child.id as id', + eb + .fn('concat', [ + 'parent.value', + eb.cast(eb.val('/'), 'text'), + eb.fn('regexp_replace', ['child.value', eb.val('^.*/'), eb.val('')]), + ]) + .as('value'), + ]); + + return directChildren.unionAll(nestedChildren); + }) + .updateTable('tag') + .from('descendants') + .set((eb) => ({ + value: eb.ref('descendants.value'), + })) + .whereRef('tag.id', '=', 'descendants.id') + .execute(); + } + return updated; + }); } @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 0c748fded8dfe2..ddc0f388f38916 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -104,7 +104,15 @@ describe(TagService.name, () => { describe('update', () => { it('should throw an error for no update permission', async () => { mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); - await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf( + await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag', color: '#000000' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(mocks.tag.update).not.toHaveBeenCalled(); + }); + + it('should throw an error if updated tag name has a slash', async () => { + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); + await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag/test2', color: '#000000' })).rejects.toBeInstanceOf( BadRequestException, ); expect(mocks.tag.update).not.toHaveBeenCalled(); @@ -113,8 +121,11 @@ describe(TagService.name, () => { it('should update a tag', async () => { mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); mocks.tag.update.mockResolvedValue(tagStub.colorCreate); - await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1); - expect(mocks.tag.update).toHaveBeenCalledWith('tag-1', { color: '#000000' }); + mocks.tag.get.mockResolvedValue(tagStub.tag); + await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag', color: '#000000' })).resolves.toEqual( + tagResponseStub.color1, + ); + expect(mocks.tag.update).toHaveBeenCalledWith('tag-1', { value: 'tag', color: '#000000' }); }); }); diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 08a9b00104f3f6..f1fadd5c76e7b7 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -59,8 +59,19 @@ export class TagService extends BaseService { async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise { await this.requireAccess({ auth, permission: Permission.TagUpdate, ids: [id] }); - const { color } = dto; - const tag = await this.tagRepository.update(id, { color }); + const { name, color } = dto; + const existing = await this.findOrFail(id); + + let value; + if (name) { + const parts = existing.value.split('/'); + parts[parts.length - 1] = name; + value = parts.join('/'); + } else { + value = existing.value; + } + + const tag = await this.tagRepository.update(id, { value, color }); return mapTag(tag); } diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index f6a2497e7ee201..886f090b59cc74 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -316,6 +316,12 @@ export class MediumTestContext = }; } + async newTag(dto: Insertable) { + const tag = mediumFactory.tagInsert(dto); + const result = await this.get(TagRepository).create(tag); + return { tag, result }; + } + async newTagAsset(tagBulkAssets: { tagIds: string[]; assetIds: string[] }) { const tagsAssets: Insertable[] = []; for (const tagId of tagBulkAssets.tagIds) { diff --git a/server/test/medium/specs/repositories/tag.repository.spec.ts b/server/test/medium/specs/repositories/tag.repository.spec.ts new file mode 100644 index 00000000000000..24a89961eedc8c --- /dev/null +++ b/server/test/medium/specs/repositories/tag.repository.spec.ts @@ -0,0 +1,74 @@ +import { Kysely } from 'kysely'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { TagRepository } from 'src/repositories/tag.repository'; +import { DB } from 'src/schema'; +import { BaseService } from 'src/services/base.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + const { ctx } = newMediumService(BaseService, { + database: db || defaultDatabase, + real: [], + mock: [LoggingRepository], + }); + return { ctx, sut: ctx.get(TagRepository) }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(TagRepository.name, () => { + afterEach(async () => { + const { ctx } = setup(); + await ctx.database.deleteFrom('tag_closure').execute(); + await ctx.database.deleteFrom('tag_asset').execute(); + await ctx.database.deleteFrom('tag').execute(); + }); + + describe('update', () => { + it('should update a tag color', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + + const { tag } = await ctx.newTag({ + userId: user.id, + value: 'tagA', + color: '#000000', + }); + + await sut.update(tag.id, { color: '#FFFFFF' }); + + await expect( + ctx.database + .selectFrom('tag') + .select(['userId', 'value', 'color', 'parentId']) + .where('id', '=', tag.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ userId: user.id, value: 'tagA', color: '#FFFFFF', parentId: null }); + }); + it('should update a top-level tag value', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + + const { tag } = await ctx.newTag({ + userId: user.id, + value: 'tagA', + color: '#000000', + }); + + await sut.update(tag.id, { value: 'updatedTagA' }); + + await expect( + ctx.database + .selectFrom('tag') + .select(['userId', 'value', 'color', 'parentId']) + .where('id', '=', tag.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ userId: user.id, value: 'updatedTagA', color: '#000000', parentId: null }); + }); + }); +}); diff --git a/web/src/lib/components/shared-components/search-bar/SearchBar.svelte b/web/src/lib/components/shared-components/search-bar/SearchBar.svelte index fc6e6f6e2d8124..a126a7526dfa7c 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchBar.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchBar.svelte @@ -11,6 +11,7 @@ import { t } from 'svelte-i18n'; import SearchFilters from './SearchFilters.svelte'; import { searchManager } from '$lib/managers/search-manager.svelte'; + import { getSearchTypePlaceholder } from './search-bar-utils'; type Props = { grayTheme: boolean; @@ -19,6 +20,9 @@ let { grayTheme }: Props = $props(); let showClearIcon = $derived(searchManager.filter.query.length > 0); + let placeholder = $derived( + searchStore.isSearchEnabled ? getSearchTypePlaceholder(searchManager.filter.queryType) : $t('search_your_photos'), + ); let input = $state(); let searchFilters = $state>(); @@ -145,7 +149,7 @@ {grayTheme || showSuggestions ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'} {showSuggestions ? 'rounded-t-3xl shadow-[0_8px_20px_rgba(0,0,0,0.12)]' : 'rounded-3xl bg-gray-200'} {searchStore.isSearchEnabled ? 'border-light-200 bg-white dark:border-dark-600' : 'border-transparent'}" - placeholder={$t('search_your_photos')} + {placeholder} required pattern="^(?!m:$).*$" bind:value={searchManager.filter.query} diff --git a/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte b/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte index a3dc10a6d3cad9..d475eeb21c3baa 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte @@ -62,6 +62,7 @@ let searchHistory = $state(); let activeFilter = $state('type'); + let showAdvanced = $state(false); let peoplePromise = $state>(); let people = $state(); let tagsPromise = $state>(); @@ -187,7 +188,7 @@
{#if isOpen}
- {#if activeFilter === 'advanced'} -
-
- - {#if authManager.authenticated && authManager.preferences.ratings.enabled} - - {/if} - -
- {:else if activeFilter} + {#if activeFilter}
{#if activeFilter === 'type'} @@ -238,15 +230,31 @@ {/if}
{/if} +
+
+
+
+ + {#if authManager.authenticated && authManager.preferences.ratings.enabled} + + {/if} + +
+
+
(showAdvanced = !showAdvanced)}>{$t('advanced_filters')}