From 2c05ceaf504915b51cf8770cc14692499e7f2c87 Mon Sep 17 00:00:00 2001 From: martyfuhry Date: Wed, 31 Jul 2024 14:09:30 -0400 Subject: [PATCH 001/723] fix(server): external domain url validation (#11493) * fix(web): Changes externalDomain to IsUrl() * refactor(web): asset viewer actions (#11449) * refactor(web): asset viewer actions * motion photo slot and more refactoring fix(web): Changes externalDomain to IsUrl() --------- Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> --- server/src/dtos/system-config.dto.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 98acb495ce..e2255223d0 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -376,7 +376,8 @@ class SystemConfigReverseGeocodingDto { } class SystemConfigServerDto { - @IsString() + @ValidateIf((_, value: string) => value !== '') + @IsUrl({ require_tld: false, require_protocol: true, protocols: ['http', 'https'] }) externalDomain!: string; @IsString() From 1f0f880ecb98eefeb68fed8a6990e2f470bfe79c Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 1 Aug 2024 13:36:31 +0200 Subject: [PATCH 002/723] fix(web): websocket over ipv6 (#11508) --- e2e/src/utils.ts | 8 ++++---- e2e/src/web/specs/websocket.e2e-spec.ts | 25 +++++++++++++++++++++++++ web/src/lib/stores/websocket.ts | 2 +- 3 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 e2e/src/web/specs/websocket.e2e-spec.ts diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 3acfc6f67c..9e397d03ed 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -424,12 +424,12 @@ export const utils = { createPartner: (accessToken: string, id: string) => createPartner({ id }, { headers: asBearerAuth(accessToken) }), - setAuthCookies: async (context: BrowserContext, accessToken: string) => + setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') => await context.addCookies([ { name: 'immich_access_token', value: accessToken, - domain: '127.0.0.1', + domain, path: '/', expires: 1_742_402_728, httpOnly: true, @@ -439,7 +439,7 @@ export const utils = { { name: 'immich_auth_type', value: 'password', - domain: '127.0.0.1', + domain, path: '/', expires: 1_742_402_728, httpOnly: true, @@ -449,7 +449,7 @@ export const utils = { { name: 'immich_is_authenticated', value: 'true', - domain: '127.0.0.1', + domain, path: '/', expires: 1_742_402_728, httpOnly: false, diff --git a/e2e/src/web/specs/websocket.e2e-spec.ts b/e2e/src/web/specs/websocket.e2e-spec.ts new file mode 100644 index 0000000000..47f69ec4ea --- /dev/null +++ b/e2e/src/web/specs/websocket.e2e-spec.ts @@ -0,0 +1,25 @@ +import { LoginResponseDto } from '@immich/sdk'; +import { expect, test } from '@playwright/test'; +import { utils } from 'src/utils'; + +test.describe('Websocket', () => { + let admin: LoginResponseDto; + + test.beforeAll(async () => { + utils.initSdk(); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + }); + + test('connects using ipv4', async ({ page, context }) => { + await utils.setAuthCookies(context, admin.accessToken); + await page.goto('http://127.0.0.1:2283/'); + await expect(page.locator('#sidebar')).toContainText('Server Online'); + }); + + test('connects using ipv6', async ({ page, context }) => { + await utils.setAuthCookies(context, admin.accessToken, '[::1]'); + await page.goto('http://[::1]:2283/'); + await expect(page.locator('#sidebar')).toContainText('Server Online'); + }); +}); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 9142b3174e..6422983d94 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -26,7 +26,7 @@ export interface Events { on_new_release: (newRelase: ReleaseEvent) => void; } -const websocket: Socket = io('', { +const websocket: Socket = io({ path: '/api/socket.io', transports: ['websocket'], reconnection: true, From 3afb5b497f10ad7981a45d3f6345c3dd572da240 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 1 Aug 2024 13:39:26 +0200 Subject: [PATCH 003/723] fix(web): correctly format future timeline dates (#11506) --- web/src/lib/utils/timeline-util.spec.ts | 22 +++++++++++++++++----- web/src/lib/utils/timeline-util.ts | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/web/src/lib/utils/timeline-util.spec.ts b/web/src/lib/utils/timeline-util.spec.ts index 863b6e613a..4643ae1218 100644 --- a/web/src/lib/utils/timeline-util.spec.ts +++ b/web/src/lib/utils/timeline-util.spec.ts @@ -1,3 +1,4 @@ +import { parseUtcDate } from '$lib/utils/date-time'; import { formatGroupTitle } from '$lib/utils/timeline-util'; import { DateTime } from 'luxon'; @@ -12,35 +13,46 @@ describe('formatGroupTitle', () => { }); it('formats today', () => { - const date = DateTime.fromISO('2024-07-27T01:00:00Z'); + const date = parseUtcDate('2024-07-27T01:00:00Z'); expect(formatGroupTitle(date.setLocale('en'))).toBe('today'); expect(formatGroupTitle(date.setLocale('es'))).toBe('hoy'); }); it('formats yesterday', () => { - const date = DateTime.fromISO('2024-07-26T23:59:59Z'); + const date = parseUtcDate('2024-07-26T23:59:59Z'); expect(formatGroupTitle(date.setLocale('en'))).toBe('yesterday'); expect(formatGroupTitle(date.setLocale('fr'))).toBe('hier'); }); it('formats last week', () => { - const date = DateTime.fromISO('2024-07-21T00:00:00Z'); + const date = parseUtcDate('2024-07-21T00:00:00Z'); expect(formatGroupTitle(date.setLocale('en'))).toBe('Sunday'); expect(formatGroupTitle(date.setLocale('ar-SA'))).toBe('الأحد'); }); it('formats date 7 days ago', () => { - const date = DateTime.fromISO('2024-07-20T00:00:00Z'); + const date = parseUtcDate('2024-07-20T00:00:00Z'); expect(formatGroupTitle(date.setLocale('en'))).toBe('Sat, Jul 20'); expect(formatGroupTitle(date.setLocale('de'))).toBe('Sa., 20. Juli'); }); it('formats date this year', () => { - const date = DateTime.fromISO('2020-01-01T00:00:00Z'); + const date = parseUtcDate('2020-01-01T00:00:00Z'); expect(formatGroupTitle(date.setLocale('en'))).toBe('Wed, Jan 1, 2020'); expect(formatGroupTitle(date.setLocale('ja'))).toBe('2020年1月1日(水)'); }); + it('formats future date', () => { + const tomorrow = parseUtcDate('2024-07-28T00:00:00Z'); + expect(formatGroupTitle(tomorrow.setLocale('en'))).toBe('Sun, Jul 28'); + + const nextMonth = parseUtcDate('2024-08-28T00:00:00Z'); + expect(formatGroupTitle(nextMonth.setLocale('en'))).toBe('Wed, Aug 28'); + + const nextYear = parseUtcDate('2025-01-10T12:00:00Z'); + expect(formatGroupTitle(nextYear.setLocale('en'))).toBe('Fri, Jan 10, 2025'); + }); + it('returns "Invalid DateTime" when date is invalid', () => { const date = DateTime.invalid('test'); expect(formatGroupTitle(date.setLocale('en'))).toBe('Invalid DateTime'); diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index fe5f799bc3..76a0d1b5cb 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -32,7 +32,7 @@ export function formatGroupTitle(_date: DateTime): string { } // Last week - if (date >= today.minus({ days: 6 })) { + if (date >= today.minus({ days: 6 }) && date < today) { return date.toLocaleString({ weekday: 'long' }); } From d3a5490e711cdb891241190a3af0586dd55a7743 Mon Sep 17 00:00:00 2001 From: Justin Forseth <51516525+jforseth210@users.noreply.github.com> Date: Thu, 1 Aug 2024 21:27:40 -0600 Subject: [PATCH 004/723] feat(server): search unknown place (#10866) * Allow submission of null country * Update searchAssetBuilder to handle nulls andWhere({country:null}) produces `"exifInfo"."country" = NULL`. We want `"exifInfo"."country" IS NULL`, so we have to treat NULL as a special case * Allow null country in frontend * Make the query code a bit more straightforward * Remove unused brackets import * Remove log message * Don't change whitespace for no reason * Fix prettier style issue * Update search.dto.ts validators per @jrasm91's recommendation * Update api types * Combine null country and state into one guard clause * chore: clean up * chore: add e2e for null/empty city, state, country search * refactor: server returns suggestion for null values * chore: clean up --------- Co-authored-by: Jason Rasmussen Co-authored-by: Alex Tran Co-authored-by: Jason Rasmussen --- e2e/src/api/specs/search.e2e-spec.ts | 196 +++++++++++++++--- mobile/openapi/lib/api/search_api.dart | 15 +- .../lib/model/metadata_search_dto.dart | 30 --- .../openapi/lib/model/smart_search_dto.dart | 30 --- open-api/immich-openapi-specs.json | 19 ++ open-api/typescript-sdk/src/fetch-client.ts | 24 ++- server/src/controllers/search.controller.ts | 3 +- server/src/dtos/search.dto.ts | 36 ++-- server/src/interfaces/metadata.interface.ts | 10 +- server/src/interfaces/search.interface.ts | 12 +- server/src/queries/metadata.repository.sql | 20 +- .../src/repositories/metadata.repository.ts | 47 ++--- server/src/services/search.service.spec.ts | 19 ++ server/src/services/search.service.ts | 20 +- server/src/utils/database.ts | 8 +- server/src/validation.ts | 16 +- .../shared-components/combobox.svelte | 14 +- .../search-bar/search-camera-section.svelte | 29 ++- .../search-bar/search-filter-box.svelte | 15 +- .../search-bar/search-location-section.svelte | 30 ++- .../[[assetId=id]]/+page.svelte | 2 + 21 files changed, 378 insertions(+), 217 deletions(-) diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index b1116d4d6e..beeaf1cc01 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -1,4 +1,4 @@ -import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, getMapMarkers, updateAsset } from '@immich/sdk'; +import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, updateAsset } from '@immich/sdk'; import { DateTime } from 'luxon'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; @@ -32,9 +32,6 @@ describe('/search', () => { let assetOneJpg5: AssetMediaResponseDto; let assetSprings: AssetMediaResponseDto; let assetLast: AssetMediaResponseDto; - let cities: string[]; - let states: string[]; - let countries: string[]; beforeAll(async () => { await utils.resetDatabase(); @@ -85,7 +82,7 @@ describe('/search', () => { // note: the coordinates here are not the actual coordinates of the images and are random for most of them const coordinates = [ { latitude: 48.853_41, longitude: 2.3488 }, // paris - { latitude: 63.0695, longitude: -151.0074 }, // denali + { latitude: 35.6895, longitude: 139.691_71 }, // tokyo { latitude: 52.524_37, longitude: 13.410_53 }, // berlin { latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore { latitude: 41.013_84, longitude: 28.949_66 }, // istanbul @@ -101,16 +98,15 @@ describe('/search', () => { { latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh { latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge { latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg - { latitude: 35.6895, longitude: 139.691_71 }, // tokyo ]; - const updates = assets.map((asset, i) => - updateAsset({ id: asset.id, updateAssetDto: coordinates[i] }, { headers: asBearerAuth(admin.accessToken) }), + const updates = coordinates.map((dto, i) => + updateAsset({ id: assets[i].id, updateAssetDto: dto }, { headers: asBearerAuth(admin.accessToken) }), ); await Promise.all(updates); - for (const asset of assets) { - await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id }); + for (const [i] of coordinates.entries()) { + await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: assets[i].id }); } [ @@ -137,12 +133,6 @@ describe('/search', () => { assetLast = assets.at(-1) as AssetMediaResponseDto; await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) }); - - const mapMarkers = await getMapMarkers({}, { headers: asBearerAuth(admin.accessToken) }); - const nonTrashed = mapMarkers.filter((mark) => mark.id !== assetSilver.id); - cities = [...new Set(nonTrashed.map((mark) => mark.city).filter((entry): entry is string => !!entry))].sort(); - states = [...new Set(nonTrashed.map((mark) => mark.state).filter((entry): entry is string => !!entry))].sort(); - countries = [...new Set(nonTrashed.map((mark) => mark.country).filter((entry): entry is string => !!entry))].sort(); }, 30_000); afterAll(async () => { @@ -321,23 +311,120 @@ describe('/search', () => { }, { should: 'should search by city', - deferred: () => ({ dto: { city: 'Accra' }, assets: [assetHeic] }), + deferred: () => ({ + dto: { + city: 'Accra', + includeNull: true, + }, + assets: [assetHeic], + }), + }, + { + should: "should search city ('')", + deferred: () => ({ + dto: { + city: '', + isVisible: true, + includeNull: true, + }, + assets: [assetLast], + }), + }, + { + should: 'should search city (null)', + deferred: () => ({ + dto: { + city: null, + isVisible: true, + includeNull: true, + }, + assets: [assetLast], + }), }, { should: 'should search by state', - deferred: () => ({ dto: { state: 'New York' }, assets: [assetDensity] }), + deferred: () => ({ + dto: { + state: 'New York', + includeNull: true, + }, + assets: [assetDensity], + }), + }, + { + should: "should search state ('')", + deferred: () => ({ + dto: { + state: '', + isVisible: true, + withExif: true, + includeNull: true, + }, + assets: [assetLast, assetNotocactus], + }), + }, + { + should: 'should search state (null)', + deferred: () => ({ + dto: { + state: null, + isVisible: true, + includeNull: true, + }, + assets: [assetLast, assetNotocactus], + }), }, { should: 'should search by country', - deferred: () => ({ dto: { country: 'France' }, assets: [assetFalcon] }), + deferred: () => ({ + dto: { + country: 'France', + includeNull: true, + }, + assets: [assetFalcon], + }), + }, + { + should: "should search country ('')", + deferred: () => ({ + dto: { + country: '', + isVisible: true, + includeNull: true, + }, + assets: [assetLast], + }), + }, + { + should: 'should search country (null)', + deferred: () => ({ + dto: { + country: null, + isVisible: true, + includeNull: true, + }, + assets: [assetLast], + }), }, { should: 'should search by make', - deferred: () => ({ dto: { make: 'Canon' }, assets: [assetFalcon, assetDenali] }), + deferred: () => ({ + dto: { + make: 'Canon', + includeNull: true, + }, + assets: [assetFalcon, assetDenali], + }), }, { should: 'should search by model', - deferred: () => ({ dto: { model: 'Canon EOS 7D' }, assets: [assetDenali] }), + deferred: () => ({ + dto: { + model: 'Canon EOS 7D', + includeNull: true, + }, + assets: [assetDenali], + }), }, { should: 'should allow searching the upload library (libraryId: null)', @@ -450,32 +537,79 @@ describe('/search', () => { it('should get suggestions for country', async () => { const { status, body } = await request(app) - .get('/search/suggestions?type=country') + .get('/search/suggestions?type=country&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); - expect(body).toEqual(countries); + expect(body).toEqual([ + 'Cuba', + 'France', + 'Georgia', + 'Germany', + 'Ghana', + 'Japan', + 'Morocco', + "People's Republic of China", + 'Russian Federation', + 'Singapore', + 'Spain', + 'Switzerland', + 'United States of America', + null, + ]); expect(status).toBe(200); }); it('should get suggestions for state', async () => { const { status, body } = await request(app) - .get('/search/suggestions?type=state') + .get('/search/suggestions?type=state&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); - expect(body).toHaveLength(states.length); - expect(body).toEqual(expect.arrayContaining(states)); + expect(body).toEqual([ + 'Andalusia', + 'Berlin', + 'Glarus', + 'Greater Accra', + 'Havana', + 'Île-de-France', + 'Marrakesh-Safi', + 'Mississippi', + 'New York', + 'Shanghai', + 'St.-Petersburg', + 'Tbilisi', + 'Tokyo', + 'Virginia', + null, + ]); expect(status).toBe(200); }); it('should get suggestions for city', async () => { const { status, body } = await request(app) - .get('/search/suggestions?type=city') + .get('/search/suggestions?type=city&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); - expect(body).toEqual(cities); + expect(body).toEqual([ + 'Accra', + 'Berlin', + 'Glarus', + 'Havana', + 'Marrakesh', + 'Montalbán de Córdoba', + 'New York City', + 'Novena', + 'Paris', + 'Philadelphia', + 'Saint Petersburg', + 'Shanghai', + 'Stanley', + 'Tbilisi', + 'Tokyo', + null, + ]); expect(status).toBe(200); }); it('should get suggestions for camera make', async () => { const { status, body } = await request(app) - .get('/search/suggestions?type=camera-make') + .get('/search/suggestions?type=camera-make&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toEqual([ 'Apple', @@ -485,13 +619,14 @@ describe('/search', () => { 'PENTAX Corporation', 'samsung', 'SONY', + null, ]); expect(status).toBe(200); }); it('should get suggestions for camera model', async () => { const { status, body } = await request(app) - .get('/search/suggestions?type=camera-model') + .get('/search/suggestions?type=camera-model&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toEqual([ 'Canon EOS 7D', @@ -506,6 +641,7 @@ describe('/search', () => { 'SM-F711N', 'SM-S906U', 'SM-T970', + null, ]); expect(status).toBe(200); }); diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 21af2d57cb..4b6cdfea78 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -111,12 +111,15 @@ class SearchApi { /// /// * [String] country: /// + /// * [bool] includeNull: + /// This property was added in v111.0.0 + /// /// * [String] make: /// /// * [String] model: /// /// * [String] state: - Future getSearchSuggestionsWithHttpInfo(SearchSuggestionType type, { String? country, String? make, String? model, String? state, }) async { + Future getSearchSuggestionsWithHttpInfo(SearchSuggestionType type, { String? country, bool? includeNull, String? make, String? model, String? state, }) async { // ignore: prefer_const_declarations final path = r'/search/suggestions'; @@ -130,6 +133,9 @@ class SearchApi { if (country != null) { queryParams.addAll(_queryParams('', 'country', country)); } + if (includeNull != null) { + queryParams.addAll(_queryParams('', 'includeNull', includeNull)); + } if (make != null) { queryParams.addAll(_queryParams('', 'make', make)); } @@ -161,13 +167,16 @@ class SearchApi { /// /// * [String] country: /// + /// * [bool] includeNull: + /// This property was added in v111.0.0 + /// /// * [String] make: /// /// * [String] model: /// /// * [String] state: - Future?> getSearchSuggestions(SearchSuggestionType type, { String? country, String? make, String? model, String? state, }) async { - final response = await getSearchSuggestionsWithHttpInfo(type, country: country, make: make, model: model, state: state, ); + Future?> getSearchSuggestions(SearchSuggestionType type, { String? country, bool? includeNull, String? make, String? model, String? state, }) async { + final response = await getSearchSuggestionsWithHttpInfo(type, country: country, includeNull: includeNull, make: make, model: model, state: state, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index d77f2e7736..fabf7a2610 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -64,20 +64,8 @@ class MetadataSearchDto { /// String? checksum; - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? city; - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? country; /// @@ -184,12 +172,6 @@ class MetadataSearchDto { /// bool? isVisible; - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? lensModel; String? libraryId; @@ -202,12 +184,6 @@ class MetadataSearchDto { /// String? make; - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? model; /// @@ -263,12 +239,6 @@ class MetadataSearchDto { /// num? size; - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? state; /// diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 25927f4244..2a42b75768 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -46,20 +46,8 @@ class SmartSearchDto { this.withExif, }); - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? city; - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? country; /// @@ -142,12 +130,6 @@ class SmartSearchDto { /// bool? isVisible; - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? lensModel; String? libraryId; @@ -160,12 +142,6 @@ class SmartSearchDto { /// String? make; - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? model; /// Minimum value: 1 @@ -191,12 +167,6 @@ class SmartSearchDto { /// num? size; - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? state; /// diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5a327f1d34..6506a6293f 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4727,6 +4727,15 @@ "type": "string" } }, + { + "name": "includeNull", + "required": false, + "in": "query", + "description": "This property was added in v111.0.0", + "schema": { + "type": "boolean" + } + }, { "name": "make", "required": false, @@ -9378,9 +9387,11 @@ "type": "string" }, "city": { + "nullable": true, "type": "string" }, "country": { + "nullable": true, "type": "string" }, "createdAfter": { @@ -9426,6 +9437,7 @@ "type": "boolean" }, "lensModel": { + "nullable": true, "type": "string" }, "libraryId": { @@ -9437,6 +9449,7 @@ "type": "string" }, "model": { + "nullable": true, "type": "string" }, "order": { @@ -9468,6 +9481,7 @@ "type": "number" }, "state": { + "nullable": true, "type": "string" }, "takenAfter": { @@ -10611,9 +10625,11 @@ "SmartSearchDto": { "properties": { "city": { + "nullable": true, "type": "string" }, "country": { + "nullable": true, "type": "string" }, "createdAfter": { @@ -10649,6 +10665,7 @@ "type": "boolean" }, "lensModel": { + "nullable": true, "type": "string" }, "libraryId": { @@ -10660,6 +10677,7 @@ "type": "string" }, "model": { + "nullable": true, "type": "string" }, "page": { @@ -10682,6 +10700,7 @@ "type": "number" }, "state": { + "nullable": true, "type": "string" }, "takenAfter": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 85575893f0..ec2a230f77 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -708,8 +708,8 @@ export type SearchExploreResponseDto = { }; export type MetadataSearchDto = { checksum?: string; - city?: string; - country?: string; + city?: string | null; + country?: string | null; createdAfter?: string; createdBefore?: string; deviceAssetId?: string; @@ -723,10 +723,10 @@ export type MetadataSearchDto = { isNotInAlbum?: boolean; isOffline?: boolean; isVisible?: boolean; - lensModel?: string; + lensModel?: string | null; libraryId?: string | null; make?: string; - model?: string; + model?: string | null; order?: AssetOrder; originalFileName?: string; originalPath?: string; @@ -734,7 +734,7 @@ export type MetadataSearchDto = { personIds?: string[]; previewPath?: string; size?: number; - state?: string; + state?: string | null; takenAfter?: string; takenBefore?: string; thumbnailPath?: string; @@ -782,8 +782,8 @@ export type PlacesResponseDto = { name: string; }; export type SmartSearchDto = { - city?: string; - country?: string; + city?: string | null; + country?: string | null; createdAfter?: string; createdBefore?: string; deviceId?: string; @@ -794,15 +794,15 @@ export type SmartSearchDto = { isNotInAlbum?: boolean; isOffline?: boolean; isVisible?: boolean; - lensModel?: string; + lensModel?: string | null; libraryId?: string | null; make?: string; - model?: string; + model?: string | null; page?: number; personIds?: string[]; query: string; size?: number; - state?: string; + state?: string | null; takenAfter?: string; takenBefore?: string; trashedAfter?: string; @@ -2418,8 +2418,9 @@ export function searchSmart({ smartSearchDto }: { body: smartSearchDto }))); } -export function getSearchSuggestions({ country, make, model, state, $type }: { +export function getSearchSuggestions({ country, includeNull, make, model, state, $type }: { country?: string; + includeNull?: boolean; make?: string; model?: string; state?: string; @@ -2430,6 +2431,7 @@ export function getSearchSuggestions({ country, make, model, state, $type }: { data: string[]; }>(`/search/suggestions${QS.query(QS.explode({ country, + includeNull, make, model, state, diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 688ff1c138..5b8c1eeece 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -62,6 +62,7 @@ export class SearchController { @Get('suggestions') @Authenticated() getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise { - return this.service.getSearchSuggestions(auth, dto); + // TODO fix open api generation to indicate that results can be nullable + return this.service.getSearchSuggestions(auth, dto) as Promise; } } diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 0874300d5f..b81321b873 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; +import { PropertyLifecycle } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetOrder } from 'src/entities/album.entity'; @@ -75,34 +76,29 @@ class BaseSearchDto { takenAfter?: Date; @IsString() - @IsNotEmpty() - @Optional() - city?: string; + @Optional({ nullable: true, emptyToNull: true }) + city?: string | null; + + @IsString() + @Optional({ nullable: true, emptyToNull: true }) + state?: string | null; @IsString() @IsNotEmpty() - @Optional() - state?: string; + @Optional({ nullable: true, emptyToNull: true }) + country?: string | null; @IsString() - @IsNotEmpty() - @Optional() - country?: string; - - @IsString() - @IsNotEmpty() - @Optional() + @Optional({ nullable: true, emptyToNull: true }) make?: string; @IsString() - @IsNotEmpty() - @Optional() - model?: string; + @Optional({ nullable: true, emptyToNull: true }) + model?: string | null; @IsString() - @IsNotEmpty() - @Optional() - lensModel?: string; + @Optional({ nullable: true, emptyToNull: true }) + lensModel?: string | null; @IsInt() @Min(1) @@ -242,6 +238,10 @@ export class SearchSuggestionRequestDto { @IsString() @Optional() model?: string; + + @ValidateBoolean({ optional: true }) + @PropertyLifecycle({ addedAt: 'v111.0.0' }) + includeNull?: boolean; } class SearchFacetCountResponseDto { diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index 1ccd704b59..daba4184e3 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -26,9 +26,9 @@ export interface IMetadataRepository { readTags(path: string): Promise; writeTags(path: string, tags: Partial): Promise; extractBinaryTag(tagName: string, path: string): Promise; - getCountries(userId: string): Promise; - getStates(userId: string, country?: string): Promise; - getCities(userId: string, country?: string, state?: string): Promise; - getCameraMakes(userId: string, model?: string): Promise; - getCameraModels(userId: string, make?: string): Promise; + getCountries(userId: string): Promise>; + getStates(userId: string, country?: string): Promise>; + getCities(userId: string, country?: string, state?: string): Promise>; + getCameraMakes(userId: string, model?: string): Promise>; + getCameraModels(userId: string, make?: string): Promise>; } diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index c84b56c62e..c17f833615 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -95,12 +95,12 @@ export interface SearchPathOptions { } export interface SearchExifOptions { - city?: string; - country?: string; - lensModel?: string; - make?: string; - model?: string; - state?: string; + city?: string | null; + country?: string | null; + lensModel?: string | null; + make?: string | null; + model?: string | null; + state?: string | null; } export interface SearchEmbeddingOptions { diff --git a/server/src/queries/metadata.repository.sql b/server/src/queries/metadata.repository.sql index bed7d59ab6..077b4644b8 100644 --- a/server/src/queries/metadata.repository.sql +++ b/server/src/queries/metadata.repository.sql @@ -2,65 +2,55 @@ -- MetadataRepository.getCountries SELECT DISTINCT - ON ("exif"."country") "exif"."country" AS "exif_country", - "exif"."assetId" AS "exif_assetId" + ON ("exif"."country") "exif"."country" AS "country" FROM "exif" "exif" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE "asset"."ownerId" = $1 - AND "exif"."country" IS NOT NULL -- MetadataRepository.getStates SELECT DISTINCT - ON ("exif"."state") "exif"."state" AS "exif_state", - "exif"."assetId" AS "exif_assetId" + ON ("exif"."state") "exif"."state" AS "state" FROM "exif" "exif" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE "asset"."ownerId" = $1 - AND "exif"."state" IS NOT NULL AND "exif"."country" = $2 -- MetadataRepository.getCities SELECT DISTINCT - ON ("exif"."city") "exif"."city" AS "exif_city", - "exif"."assetId" AS "exif_assetId" + ON ("exif"."city") "exif"."city" AS "city" FROM "exif" "exif" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE "asset"."ownerId" = $1 - AND "exif"."city" IS NOT NULL AND "exif"."country" = $2 AND "exif"."state" = $3 -- MetadataRepository.getCameraMakes SELECT DISTINCT - ON ("exif"."make") "exif"."make" AS "exif_make", - "exif"."assetId" AS "exif_assetId" + ON ("exif"."make") "exif"."make" AS "make" FROM "exif" "exif" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE "asset"."ownerId" = $1 - AND "exif"."make" IS NOT NULL AND "exif"."model" = $2 -- MetadataRepository.getCameraModels SELECT DISTINCT - ON ("exif"."model") "exif"."model" AS "exif_model", - "exif"."assetId" AS "exif_assetId" + ON ("exif"."model") "exif"."model" AS "model" FROM "exif" "exif" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE "asset"."ownerId" = $1 - AND "exif"."model" IS NOT NULL AND "exif"."make" = $2 diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 3ca088e2d7..832cffbee6 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -57,49 +57,42 @@ export class MetadataRepository implements IMetadataRepository { @GenerateSql({ params: [DummyValue.UUID] }) async getCountries(userId: string): Promise { - const entity = await this.exifRepository + const results = await this.exifRepository .createQueryBuilder('exif') .leftJoin('exif.asset', 'asset') .where('asset.ownerId = :userId', { userId }) - .andWhere('exif.country IS NOT NULL') - .select('exif.country') + .select('exif.country', 'country') .distinctOn(['exif.country']) - .getMany(); + .getRawMany<{ country: string }>(); - return entity.map((e) => e.country ?? '').filter((c) => c !== ''); + return results.map(({ country }) => country).filter((item) => item !== ''); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) async getStates(userId: string, country: string | undefined): Promise { - let result: ExifEntity[] = []; - const query = this.exifRepository .createQueryBuilder('exif') .leftJoin('exif.asset', 'asset') .where('asset.ownerId = :userId', { userId }) - .andWhere('exif.state IS NOT NULL') - .select('exif.state') + .select('exif.state', 'state') .distinctOn(['exif.state']); if (country) { query.andWhere('exif.country = :country', { country }); } - result = await query.getMany(); + const result = await query.getRawMany<{ state: string }>(); - return result.map((entity) => entity.state ?? '').filter((s) => s !== ''); + return result.map(({ state }) => state).filter((item) => item !== ''); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] }) async getCities(userId: string, country: string | undefined, state: string | undefined): Promise { - let result: ExifEntity[] = []; - const query = this.exifRepository .createQueryBuilder('exif') .leftJoin('exif.asset', 'asset') .where('asset.ownerId = :userId', { userId }) - .andWhere('exif.city IS NOT NULL') - .select('exif.city') + .select('exif.city', 'city') .distinctOn(['exif.city']); if (country) { @@ -110,50 +103,42 @@ export class MetadataRepository implements IMetadataRepository { query.andWhere('exif.state = :state', { state }); } - result = await query.getMany(); + const results = await query.getRawMany<{ city: string }>(); - return result.map((entity) => entity.city ?? '').filter((c) => c !== ''); + return results.map(({ city }) => city).filter((item) => item !== ''); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) async getCameraMakes(userId: string, model: string | undefined): Promise { - let result: ExifEntity[] = []; - const query = this.exifRepository .createQueryBuilder('exif') .leftJoin('exif.asset', 'asset') .where('asset.ownerId = :userId', { userId }) - .andWhere('exif.make IS NOT NULL') - .select('exif.make') + .select('exif.make', 'make') .distinctOn(['exif.make']); if (model) { query.andWhere('exif.model = :model', { model }); } - result = await query.getMany(); - - return result.map((entity) => entity.make ?? '').filter((m) => m !== ''); + const results = await query.getRawMany<{ make: string }>(); + return results.map(({ make }) => make).filter((item) => item !== ''); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) async getCameraModels(userId: string, make: string | undefined): Promise { - let result: ExifEntity[] = []; - const query = this.exifRepository .createQueryBuilder('exif') .leftJoin('exif.asset', 'asset') .where('asset.ownerId = :userId', { userId }) - .andWhere('exif.model IS NOT NULL') - .select('exif.model') + .select('exif.model', 'model') .distinctOn(['exif.model']); if (make) { query.andWhere('exif.make = :make', { make }); } - result = await query.getMany(); - - return result.map((entity) => entity.model ?? '').filter((m) => m !== ''); + const results = await query.getRawMany<{ model: string }>(); + return results.map(({ model }) => model).filter((item) => item !== ''); } } diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index afc98b69de..89609d5d89 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -1,4 +1,5 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; +import { SearchSuggestionType } from 'src/dtos/search.dto'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; @@ -95,4 +96,22 @@ describe(SearchService.name, () => { expect(result).toEqual(expectedResponse); }); }); + + describe('getSearchSuggestions', () => { + it('should return search suggestions (including null)', async () => { + metadataMock.getCountries.mockResolvedValue(['USA', null]); + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }), + ).resolves.toEqual(['USA', null]); + expect(metadataMock.getCountries).toHaveBeenCalledWith(authStub.user1.user.id); + }); + + it('should return search suggestions (without null)', async () => { + metadataMock.getCountries.mockResolvedValue(['USA', null]); + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }), + ).resolves.toEqual(['USA']); + expect(metadataMock.getCountries).toHaveBeenCalledWith(authStub.user1.user.id); + }); + }); }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 5010067a3f..1d746a03d8 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -120,22 +120,30 @@ export class SearchService { return assets.map((asset) => mapAsset(asset)); } - getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise { + async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto) { + const results = await this.getSuggestions(auth.user.id, dto); + return results.filter((result) => (dto.includeNull ? true : result !== null)); + } + + private getSuggestions(userId: string, dto: SearchSuggestionRequestDto) { switch (dto.type) { case SearchSuggestionType.COUNTRY: { - return this.metadataRepository.getCountries(auth.user.id); + return this.metadataRepository.getCountries(userId); } case SearchSuggestionType.STATE: { - return this.metadataRepository.getStates(auth.user.id, dto.country); + return this.metadataRepository.getStates(userId, dto.country); } case SearchSuggestionType.CITY: { - return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state); + return this.metadataRepository.getCities(userId, dto.country, dto.state); } case SearchSuggestionType.CAMERA_MAKE: { - return this.metadataRepository.getCameraMakes(auth.user.id, dto.model); + return this.metadataRepository.getCameraMakes(userId, dto.model); } case SearchSuggestionType.CAMERA_MODEL: { - return this.metadataRepository.getCameraModels(auth.user.id, dto.make); + return this.metadataRepository.getCameraModels(userId, dto.make); + } + default: { + return []; } } } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 944978bddd..ebf27270d5 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -48,7 +48,13 @@ export function searchAssetBuilder( ? builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo') : builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo'); - builder.andWhere({ exifInfo }); + for (const [key, value] of Object.entries(exifInfo)) { + if (value === null) { + builder.andWhere(`exifInfo.${key} IS NULL`); + } else { + builder.andWhere(`exifInfo.${key} = :${key}`, { [key]: value }); + } + } } const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId']); diff --git a/server/src/validation.ts b/server/src/validation.ts index 063f2150a5..10f2e7b77b 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -66,6 +66,8 @@ export class UUIDParamDto { export interface OptionalOptions extends ValidationOptions { nullable?: boolean; + /** convert empty strings to null */ + emptyToNull?: boolean; } /** @@ -76,12 +78,20 @@ export interface OptionalOptions extends ValidationOptions { * @see IsOptional exported from `class-validator. */ // https://stackoverflow.com/a/71353929 -export function Optional({ nullable, ...validationOptions }: OptionalOptions = {}) { +export function Optional({ nullable, emptyToNull, ...validationOptions }: OptionalOptions = {}) { + const decorators: PropertyDecorator[] = []; + if (nullable === true) { - return IsOptional(validationOptions); + decorators.push(IsOptional(validationOptions)); + } else { + decorators.push(ValidateIf((object: any, v: any) => v !== undefined, validationOptions)); } - return ValidateIf((object: any, v: any) => v !== undefined, validationOptions); + if (emptyToNull) { + decorators.push(Transform(({ value }) => (value === '' ? null : value))); + } + + return applyDecorators(...decorators); } type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean }; diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index f087a6e18f..7cdcef9e40 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -4,9 +4,16 @@ value: string; }; - export function toComboBoxOptions(items: string[]) { - return items.map((item) => ({ label: item, value: item })); - } + export const asComboboxOptions = (values: string[]) => + values.map((value) => { + if (value === '') { + return { label: get(t)('unknown'), value: '' }; + } + + return { label: value, value }; + }); + + export const asSelectedOption = (value?: string) => (value === undefined ? undefined : asComboboxOptions([value])[0]); @@ -44,9 +57,9 @@ (filters.make = detail?.value)} - options={toComboBoxOptions(makes)} + options={asComboboxOptions(makes)} placeholder={$t('search_camera_make')} - selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined} + selectedOption={asSelectedOption(makeFilter)} /> @@ -54,9 +67,9 @@ (filters.model = detail?.value)} - options={toComboBoxOptions(models)} + options={asComboboxOptions(models)} placeholder={$t('search_camera_model')} - selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined} + selectedOption={asSelectedOption(modelFilter)} /> diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte index 5fa92ac7b2..35e7ea7535 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte @@ -42,18 +42,23 @@ const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined; const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>(); + // combobox and all the search components have terrible support for value | null so we use empty string instead. + function withNullAsUndefined(value: T | null) { + return value === null ? undefined : value; + } + let filter: SearchFilter = { context: 'query' in searchQuery ? searchQuery.query : '', filename: 'originalFileName' in searchQuery ? searchQuery.originalFileName : undefined, personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []), location: { - country: searchQuery.country, - state: searchQuery.state, - city: searchQuery.city, + country: withNullAsUndefined(searchQuery.country), + state: withNullAsUndefined(searchQuery.state), + city: withNullAsUndefined(searchQuery.city), }, camera: { - make: searchQuery.make, - model: searchQuery.model, + make: withNullAsUndefined(searchQuery.make), + model: withNullAsUndefined(searchQuery.model), }, date: { takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined, diff --git a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte index 075a305cef..4ac59bb374 100644 --- a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte @@ -7,9 +7,9 @@ {#if $hasError || $isUploading} diff --git a/web/src/lib/components/shared-components/user-avatar.svelte b/web/src/lib/components/shared-components/user-avatar.svelte index 0cb8ee9f77..bb59af9aab 100644 --- a/web/src/lib/components/shared-components/user-avatar.svelte +++ b/web/src/lib/components/shared-components/user-avatar.svelte @@ -27,6 +27,8 @@ let img: HTMLImageElement; let showFallback = true; + // sveeeeeeelteeeeee fiveeeeee + // eslint-disable-next-line @typescript-eslint/no-unused-expressions $: img, user, void tryLoadImage(); const tryLoadImage = async () => { diff --git a/web/src/lib/components/shared-components/version-announcement-box.svelte b/web/src/lib/components/shared-components/version-announcement-box.svelte index 95f185a59c..fb5466e7ae 100644 --- a/web/src/lib/components/shared-components/version-announcement-box.svelte +++ b/web/src/lib/components/shared-components/version-announcement-box.svelte @@ -14,7 +14,9 @@ $: releaseVersion = $release && semverToName($release.releaseVersion); $: serverVersion = $release && semverToName($release.serverVersion); - $: $release?.isAvailable && handleRelease(); + $: if ($release?.isAvailable) { + handleRelease(); + } const onAcknowledge = () => { localStorage.setItem('appVersion', releaseVersion); diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 1d606ba8d7..3eb65ca1bd 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -36,7 +36,9 @@ assetViewingStore.showAssetViewer(false); }); - $: $featureFlags.map || handlePromiseError(goto(AppRoute.PHOTOS)); + $: if (!$featureFlags.map) { + handlePromiseError(goto(AppRoute.PHOTOS)); + } const omit = (obj: MapSettings, key: string) => { return Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key)); }; diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index 0708ec5de9..2907a542b3 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -28,7 +28,9 @@ export let data: PageData; - $featureFlags.trash || handlePromiseError(goto(AppRoute.PHOTOS)); + if (!$featureFlags.trash) { + handlePromiseError(goto(AppRoute.PHOTOS)); + } const assetStore = new AssetStore({ isTrashed: true }); const assetInteractionStore = createAssetInteractionStore(); From 82d934d09d87cb0e79fcc5b2e3a91434a7d6c642 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 5 Aug 2024 16:13:16 -0400 Subject: [PATCH 021/723] chore(deps): update dependency eslint to v9 (#11601) * chore(deps): update dependency eslint to v9 * chore: migrate to eslint flat config files --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel Dietzler --- cli/.eslintignore | 1 - cli/.eslintrc.cjs | 28 - cli/eslint.config.mjs | 60 +++ cli/package-lock.json | 424 ++++++--------- cli/package.json | 5 +- e2e/.eslintrc.cjs | 32 -- e2e/eslint.config.mjs | 64 +++ e2e/package-lock.json | 278 +++++----- e2e/package.json | 5 +- server/.eslintrc.js | 39 -- server/eslint.config.mjs | 80 +++ server/package-lock.json | 504 ++++++++---------- server/package.json | 5 +- web/.eslintignore | 14 - web/.eslintrc.cjs | 61 --- web/eslint.config.mjs | 105 ++++ web/package-lock.json | 355 ++++++------ web/package.json | 5 +- .../asset-viewer/detail-panel.svelte | 2 +- 19 files changed, 1078 insertions(+), 989 deletions(-) delete mode 100644 cli/.eslintignore delete mode 100644 cli/.eslintrc.cjs create mode 100644 cli/eslint.config.mjs delete mode 100644 e2e/.eslintrc.cjs create mode 100644 e2e/eslint.config.mjs delete mode 100644 server/.eslintrc.js create mode 100644 server/eslint.config.mjs delete mode 100644 web/.eslintignore delete mode 100644 web/.eslintrc.cjs create mode 100644 web/eslint.config.mjs diff --git a/cli/.eslintignore b/cli/.eslintignore deleted file mode 100644 index 9b1c8b133c..0000000000 --- a/cli/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -/dist diff --git a/cli/.eslintrc.cjs b/cli/.eslintrc.cjs deleted file mode 100644 index fe8044df81..0000000000 --- a/cli/.eslintrc.cjs +++ /dev/null @@ -1,28 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - project: 'tsconfig.json', - sourceType: 'module', - tsconfigRootDir: __dirname, - }, - plugins: ['@typescript-eslint/eslint-plugin'], - extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:unicorn/recommended'], - root: true, - env: { - node: true, - }, - ignorePatterns: ['.eslintrc.js'], - rules: { - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'error', - 'unicorn/prefer-module': 'off', - 'unicorn/prevent-abbreviations': 'off', - 'unicorn/no-process-exit': 'off', - 'unicorn/import-style': 'off', - curly: 2, - 'prettier/prettier': 0, - }, -}; diff --git a/cli/eslint.config.mjs b/cli/eslint.config.mjs new file mode 100644 index 0000000000..3f724506a3 --- /dev/null +++ b/cli/eslint.config.mjs @@ -0,0 +1,60 @@ +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import typescriptEslint from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + { + ignores: ['eslint.config.mjs', 'dist'], + }, + ...compat.extends( + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + 'plugin:unicorn/recommended', + ), + { + plugins: { + '@typescript-eslint': typescriptEslint, + }, + + languageOptions: { + globals: { + ...globals.node, + }, + + parser: tsParser, + ecmaVersion: 5, + sourceType: 'module', + + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + }, + }, + + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'error', + 'unicorn/prefer-module': 'off', + 'unicorn/prevent-abbreviations': 'off', + 'unicorn/no-process-exit': 'off', + 'unicorn/import-style': 'off', + curly: 2, + 'prettier/prettier': 0, + }, + }, +]; diff --git a/cli/package-lock.json b/cli/package-lock.json index d5ed4d8b31..b442ea77cb 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -17,6 +17,8 @@ "immich": "dist/index.js" }, "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@immich/sdk": "file:../open-api/typescript-sdk", "@types/byte-size": "^8.1.0", "@types/cli-progress": "^3.11.0", @@ -29,10 +31,11 @@ "byte-size": "^9.0.0", "cli-progress": "^3.12.0", "commander": "^12.0.0", - "eslint": "^8.56.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", + "globals": "^15.9.0", "mock-fs": "^5.2.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", @@ -714,24 +717,65 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "node_modules/@eslint/config-array": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.1.tgz", + "integrity": "sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -739,7 +783,7 @@ "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -755,6 +799,19 @@ "concat-map": "0.0.1" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -768,48 +825,23 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", + "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", "dev": true, + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, + "license": "Apache-2.0", "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@humanwhocodes/module-importer": { @@ -825,11 +857,19 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", - "dev": true + "node_modules/@humanwhocodes/retry": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", + "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@immich/sdk": { "resolved": "../open-api/typescript-sdk", @@ -1434,12 +1474,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, "node_modules/@vitest/coverage-v8": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", @@ -1548,9 +1582,9 @@ } }, "node_modules/acorn": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", - "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "license": "MIT", "bin": { @@ -1565,6 +1599,7 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -1957,18 +1992,6 @@ "node": ">=8" } }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2057,41 +2080,38 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", + "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.17.1", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.8.0", "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.0.2", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.1.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", @@ -2105,10 +2125,10 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" } }, "node_modules/eslint-config-prettier": { @@ -2188,30 +2208,18 @@ "eslint": ">=8.56.0" } }, - "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.8.0.tgz", - "integrity": "sha512-VZAJ4cewHTExBWDHR6yptdIBlx9YSSZuwojj9Nt5mBRXQzrKakDsVKQ1J63sklLvzAJm0X5+RpO4i3Y2hcOnFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", + "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2234,16 +2242,31 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2252,17 +2275,31 @@ } }, "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", + "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.0.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2401,15 +2438,16 @@ } }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, + "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/fill-range": { @@ -2440,24 +2478,25 @@ } }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, + "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/foreground-child": { "version": "3.2.1", @@ -2475,12 +2514,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2558,15 +2591,13 @@ } }, "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "version": "15.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.9.0.tgz", + "integrity": "sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==", "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2690,22 +2721,6 @@ "node": ">=8" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -2901,7 +2916,8 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -2926,6 +2942,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, + "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -3210,15 +3227,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, "node_modules/onetime": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", @@ -3335,15 +3343,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3709,63 +3708,6 @@ "node": ">=0.10.0" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/rollup": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz", @@ -4194,18 +4136,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typescript": { "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", @@ -4595,12 +4525,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, "node_modules/yaml": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", diff --git a/cli/package.json b/cli/package.json index eb68cde2af..2d4cb4ba81 100644 --- a/cli/package.json +++ b/cli/package.json @@ -13,6 +13,8 @@ "cli" ], "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@immich/sdk": "file:../open-api/typescript-sdk", "@types/byte-size": "^8.1.0", "@types/cli-progress": "^3.11.0", @@ -25,10 +27,11 @@ "byte-size": "^9.0.0", "cli-progress": "^3.12.0", "commander": "^12.0.0", - "eslint": "^8.56.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", + "globals": "^15.9.0", "mock-fs": "^5.2.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", diff --git a/e2e/.eslintrc.cjs b/e2e/.eslintrc.cjs deleted file mode 100644 index 3594073202..0000000000 --- a/e2e/.eslintrc.cjs +++ /dev/null @@ -1,32 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - project: 'tsconfig.json', - sourceType: 'module', - tsconfigRootDir: __dirname, - }, - plugins: ['@typescript-eslint/eslint-plugin'], - extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:unicorn/recommended'], - root: true, - env: { - node: true, - }, - ignorePatterns: ['.eslintrc.js'], - rules: { - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'error', - 'unicorn/prefer-module': 'off', - 'unicorn/import-style': 'off', - curly: 2, - 'prettier/prettier': 0, - 'unicorn/prevent-abbreviations': 'off', - 'unicorn/filename-case': 'off', - 'unicorn/no-null': 'off', - 'unicorn/prefer-top-level-await': 'off', - 'unicorn/prefer-event-target': 'off', - 'unicorn/no-thenable': 'off', - }, -}; diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs new file mode 100644 index 0000000000..9a1bb99598 --- /dev/null +++ b/e2e/eslint.config.mjs @@ -0,0 +1,64 @@ +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import typescriptEslint from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + { + ignores: ['eslint.config.mjs'], + }, + ...compat.extends( + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + 'plugin:unicorn/recommended', + ), + { + plugins: { + '@typescript-eslint': typescriptEslint, + }, + + languageOptions: { + globals: { + ...globals.node, + }, + + parser: tsParser, + ecmaVersion: 5, + sourceType: 'module', + + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + }, + }, + + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'error', + 'unicorn/prefer-module': 'off', + 'unicorn/import-style': 'off', + curly: 2, + 'prettier/prettier': 0, + 'unicorn/prevent-abbreviations': 'off', + 'unicorn/filename-case': 'off', + 'unicorn/no-null': 'off', + 'unicorn/prefer-top-level-await': 'off', + 'unicorn/prefer-event-target': 'off', + 'unicorn/no-thenable': 'off', + }, + }, +]; diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 0081bd4b05..ed135d580e 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -9,6 +9,8 @@ "version": "1.111.0", "license": "GNU Affero General Public License version 3", "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@immich/cli": "file:../cli", "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", @@ -21,11 +23,12 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", - "eslint": "^8.57.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", "exiftool-vendored": "^28.0.0", + "globals": "^15.9.0", "jose": "^5.6.3", "luxon": "^3.4.4", "oidc-provider": "^8.5.1", @@ -54,6 +57,8 @@ "immich": "dist/index.js" }, "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@immich/sdk": "file:../open-api/typescript-sdk", "@types/byte-size": "^8.1.0", "@types/cli-progress": "^3.11.0", @@ -66,10 +71,11 @@ "byte-size": "^9.0.0", "cli-progress": "^3.12.0", "commander": "^12.0.0", - "eslint": "^8.56.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", + "globals": "^15.9.0", "mock-fs": "^5.2.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", @@ -731,24 +737,41 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "node_modules/@eslint/config-array": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.1.tgz", + "integrity": "sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -756,33 +779,43 @@ "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "node_modules/@eslint/js": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", + "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, + "license": "MIT", "engines": { - "node": ">=10.10.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@humanwhocodes/module-importer": { @@ -798,11 +831,19 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", - "dev": true + "node_modules/@humanwhocodes/retry": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", + "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@immich/cli": { "resolved": "../cli", @@ -1847,12 +1888,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, "node_modules/@vitest/coverage-v8": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", @@ -1980,9 +2015,9 @@ } }, "node_modules/acorn": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", - "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "license": "MIT", "bin": { @@ -1997,6 +2032,7 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -2701,18 +2737,6 @@ "node": ">=8" } }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2864,41 +2888,38 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", + "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.17.1", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.8.0", "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.0.2", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.1.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", @@ -2912,10 +2933,10 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" } }, "node_modules/eslint-config-prettier": { @@ -2995,30 +3016,18 @@ "eslint": ">=8.56.0" } }, - "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.8.0.tgz", - "integrity": "sha512-VZAJ4cewHTExBWDHR6yptdIBlx9YSSZuwojj9Nt5mBRXQzrKakDsVKQ1J63sklLvzAJm0X5+RpO4i3Y2hcOnFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", + "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3036,18 +3045,45 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", + "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.0.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3249,15 +3285,16 @@ } }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, + "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/fill-range": { @@ -3290,24 +3327,25 @@ } }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, + "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/foreground-child": { "version": "3.2.1", @@ -3523,15 +3561,13 @@ } }, "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "version": "15.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.9.0.tgz", + "integrity": "sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==", "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6274,18 +6310,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/e2e/package.json b/e2e/package.json index 8d48db2d5c..fabcc5cd98 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -19,6 +19,8 @@ "author": "", "license": "GNU Affero General Public License version 3", "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@immich/cli": "file:../cli", "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", @@ -31,11 +33,12 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", - "eslint": "^8.57.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", "exiftool-vendored": "^28.0.0", + "globals": "^15.9.0", "jose": "^5.6.3", "luxon": "^3.4.4", "oidc-provider": "^8.5.1", diff --git a/server/.eslintrc.js b/server/.eslintrc.js deleted file mode 100644 index 243f1b11e0..0000000000 --- a/server/.eslintrc.js +++ /dev/null @@ -1,39 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - project: 'tsconfig.json', - sourceType: 'module', - tsconfigRootDir: __dirname, - }, - plugins: ['@typescript-eslint/eslint-plugin'], - extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:unicorn/recommended'], - root: true, - env: { - node: true, - }, - ignorePatterns: ['.eslintrc.js'], - rules: { - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'error', - 'unicorn/prevent-abbreviations': 'off', - 'unicorn/filename-case': 'off', - 'unicorn/no-null': 'off', - 'unicorn/prefer-top-level-await': 'off', - 'unicorn/prefer-event-target': 'off', - 'unicorn/no-thenable': 'off', - 'unicorn/import-style': 'off', - 'unicorn/prefer-structured-clone': 'off', - '@typescript-eslint/await-thenable': 'error', - '@typescript-eslint/no-floating-promises': 'error', - '@typescript-eslint/no-misused-promises': 'error', - // Note: you must disable the base rule as it can report incorrect errors - 'require-await': 'off', - '@typescript-eslint/require-await': 'error', - curly: 2, - 'prettier/prettier': 0, - 'no-restricted-imports': ['error', { patterns: [{ group: ['.*'], message: 'Relative imports are not allowed.' }] }], - }, -}; diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs new file mode 100644 index 0000000000..638b7b2959 --- /dev/null +++ b/server/eslint.config.mjs @@ -0,0 +1,80 @@ +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import typescriptEslint from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + { + ignores: ['eslint.config.mjs'], + }, + ...compat.extends( + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + 'plugin:unicorn/recommended', + ), + { + plugins: { + '@typescript-eslint': typescriptEslint, + }, + + languageOptions: { + globals: { + ...globals.node, + }, + + parser: tsParser, + ecmaVersion: 5, + sourceType: 'module', + + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + }, + }, + + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'error', + 'unicorn/prevent-abbreviations': 'off', + 'unicorn/filename-case': 'off', + 'unicorn/no-null': 'off', + 'unicorn/prefer-top-level-await': 'off', + 'unicorn/prefer-event-target': 'off', + 'unicorn/no-thenable': 'off', + 'unicorn/import-style': 'off', + 'unicorn/prefer-structured-clone': 'off', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-misused-promises': 'error', + 'require-await': 'off', + '@typescript-eslint/require-await': 'error', + curly: 2, + 'prettier/prettier': 0, + + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['.*'], + message: 'Relative imports are not allowed.', + }, + ], + }, + ], + }, + }, +]; diff --git a/server/package-lock.json b/server/package-lock.json index 189609f760..bcd7072eff 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -66,6 +66,8 @@ "ua-parser-js": "^1.0.35" }, "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@nestjs/cli": "^10.1.16", "@nestjs/schematics": "^10.0.2", "@nestjs/testing": "^10.2.2", @@ -90,10 +92,11 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", - "eslint": "^8.56.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", + "globals": "^15.9.0", "mock-fs": "^5.2.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^4.0.0", @@ -1135,22 +1138,36 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/@eslint/config-array": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.1.tgz", + "integrity": "sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==", + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -1158,7 +1175,7 @@ "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -1179,17 +1196,38 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", + "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@fastify/busboy": { @@ -1335,19 +1373,6 @@ "@hapi/hoek": "^9.0.0" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1360,10 +1385,17 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==" + "node_modules/@humanwhocodes/retry": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", + "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.4", @@ -6464,11 +6496,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" - }, "node_modules/@vitest/coverage-v8": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", @@ -8506,17 +8533,6 @@ "node": ">=6" } }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -8829,40 +8845,36 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", + "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.17.1", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.8.0", "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.0.2", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.1.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", @@ -8876,10 +8888,10 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" } }, "node_modules/eslint-config-prettier": { @@ -8987,28 +8999,16 @@ "eslint": ">=8.56.0" } }, - "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.8.0.tgz", - "integrity": "sha512-VZAJ4cewHTExBWDHR6yptdIBlx9YSSZuwojj9Nt5mBRXQzrKakDsVKQ1J63sklLvzAJm0X5+RpO4i3Y2hcOnFw==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", + "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -9040,6 +9040,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint/node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -9057,16 +9068,27 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", + "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.0.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -9419,14 +9441,14 @@ } }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/file-source": { @@ -9494,55 +9516,21 @@ } }, "node_modules/flat-cache": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.0.tgz", - "integrity": "sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dependencies": { - "flatted": "^3.2.7", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "flatted": "^3.2.9", + "keyv": "^4.5.4" }, "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/flat-cache/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/flat-cache/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=16" } }, "node_modules/flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==" + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==" }, "node_modules/fluent-ffmpeg": { "version": "2.1.3", @@ -10015,14 +10003,13 @@ } }, "node_modules/globals": { - "version": "13.22.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.22.0.tgz", - "integrity": "sha512-H1Ddc/PbZHTDVJSnj8kWptIRSD6AM3pK+mKytuIVF4uoBV7rshFlhhvA58ceJ5wp3Er58w6zj7bykMpYXt3ETw==", - "dependencies": { - "type-fest": "^0.20.2" - }, + "version": "15.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.9.0.tgz", + "integrity": "sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10073,7 +10060,8 @@ "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true }, "node_modules/handlebars": { "version": "4.7.8", @@ -10832,9 +10820,9 @@ } }, "node_modules/keyv": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", - "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dependencies": { "json-buffer": "3.0.1" } @@ -15694,17 +15682,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -17380,19 +17357,29 @@ } }, "@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==" + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==" + }, + "@eslint/config-array": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.1.tgz", + "integrity": "sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==", + "requires": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + } }, "@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", "requires": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -17411,6 +17398,11 @@ "uri-js": "^4.2.2" } }, + "globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==" + }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -17419,9 +17411,14 @@ } }, "@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==" + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", + "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==" + }, + "@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==" }, "@fastify/busboy": { "version": "2.1.1", @@ -17536,25 +17533,15 @@ "@hapi/hoek": "^9.0.0" } }, - "@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "requires": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - } - }, "@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==" }, - "@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==" + "@humanwhocodes/retry": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", + "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==" }, "@img/sharp-darwin-arm64": { "version": "0.33.4", @@ -20727,11 +20714,6 @@ "eslint-visitor-keys": "^3.4.3" } }, - "@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" - }, "@vitest/coverage-v8": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", @@ -22249,14 +22231,6 @@ } } }, - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "requires": { - "esutils": "^2.0.2" - } - }, "dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -22489,40 +22463,36 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, "eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", + "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", "requires": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.17.1", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.8.0", "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.0.2", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.1.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", @@ -22544,6 +22514,11 @@ "uri-js": "^4.2.2" } }, + "eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==" + }, "glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -22621,20 +22596,12 @@ "regjsparser": "^0.10.0", "semver": "^7.6.1", "strip-indent": "^3.0.0" - }, - "dependencies": { - "globals": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.8.0.tgz", - "integrity": "sha512-VZAJ4cewHTExBWDHR6yptdIBlx9YSSZuwojj9Nt5mBRXQzrKakDsVKQ1J63sklLvzAJm0X5+RpO4i3Y2hcOnFw==", - "dev": true - } } }, "eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", + "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==", "requires": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -22646,13 +22613,20 @@ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==" }, "espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", + "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", "requires": { - "acorn": "^8.9.0", + "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.0.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==" + } } }, "esprima": { @@ -22927,11 +22901,11 @@ } }, "file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "requires": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" } }, "file-source": { @@ -22989,42 +22963,18 @@ } }, "flat-cache": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.0.tgz", - "integrity": "sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "requires": { - "flatted": "^3.2.7", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "dependencies": { - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "requires": { - "glob": "^7.1.3" - } - } + "flatted": "^3.2.9", + "keyv": "^4.5.4" } }, "flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==" + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==" }, "fluent-ffmpeg": { "version": "2.1.3", @@ -23353,12 +23303,10 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" }, "globals": { - "version": "13.22.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.22.0.tgz", - "integrity": "sha512-H1Ddc/PbZHTDVJSnj8kWptIRSD6AM3pK+mKytuIVF4uoBV7rshFlhhvA58ceJ5wp3Er58w6zj7bykMpYXt3ETw==", - "requires": { - "type-fest": "^0.20.2" - } + "version": "15.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.9.0.tgz", + "integrity": "sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==", + "dev": true }, "globby": { "version": "11.1.0", @@ -23396,7 +23344,8 @@ "graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true }, "handlebars": { "version": "4.7.8", @@ -23936,9 +23885,9 @@ } }, "keyv": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", - "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "requires": { "json-buffer": "3.0.1" } @@ -27296,11 +27245,6 @@ "prelude-ls": "^1.2.1" } }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==" - }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/server/package.json b/server/package.json index 189bbf6294..5a8d24919e 100644 --- a/server/package.json +++ b/server/package.json @@ -92,6 +92,8 @@ "ua-parser-js": "^1.0.35" }, "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@nestjs/cli": "^10.1.16", "@nestjs/schematics": "^10.0.2", "@nestjs/testing": "^10.2.2", @@ -116,10 +118,11 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", - "eslint": "^8.56.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", + "globals": "^15.9.0", "mock-fs": "^5.2.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^4.0.0", diff --git a/web/.eslintignore b/web/.eslintignore deleted file mode 100644 index f944e33c4e..0000000000 --- a/web/.eslintignore +++ /dev/null @@ -1,14 +0,0 @@ -.DS_Store -node_modules -/build -/.svelte-kit -/package -.env -.env.* -!.env.example - -# Ignore files for PNPM, NPM and YARN -pnpm-lock.yaml -package-lock.json -yarn.lock -svelte.config.js diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs deleted file mode 100644 index de0a64bd37..0000000000 --- a/web/.eslintrc.cjs +++ /dev/null @@ -1,61 +0,0 @@ -/** @type {import('eslint').Linter.Config} */ -module.exports = { - root: true, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:svelte/recommended', - 'plugin:unicorn/recommended', - ], - parser: '@typescript-eslint/parser', - plugins: ['@typescript-eslint'], - parserOptions: { - sourceType: 'module', - ecmaVersion: 2022, - extraFileExtensions: ['.svelte'], - tsconfigRootDir: __dirname, - project: ['./tsconfig.json'], - }, - env: { - browser: true, - es2017: true, - node: true, - }, - overrides: [ - { - files: ['*.svelte'], - parser: 'svelte-eslint-parser', - parserOptions: { - parser: '@typescript-eslint/parser', - }, - }, - ], - globals: { - NodeJS: true, - }, - rules: { - '@typescript-eslint/no-unused-vars': [ - 'warn', - { - // Allow underscore (_) variables - argsIgnorePattern: '^_$', - varsIgnorePattern: '^_$', - }, - ], - curly: 2, - 'unicorn/no-useless-undefined': 'off', - 'unicorn/prefer-spread': 'off', - 'unicorn/no-null': 'off', - 'unicorn/prevent-abbreviations': 'off', - 'unicorn/no-nested-ternary': 'off', - 'unicorn/consistent-function-scoping': 'off', - 'unicorn/prefer-top-level-await': 'off', - 'unicorn/import-style': 'off', - 'svelte/button-has-type': 'error', - // TODO: set recommended-type-checked and remove these rules - '@typescript-eslint/await-thenable': 'error', - '@typescript-eslint/no-floating-promises': 'error', - '@typescript-eslint/no-misused-promises': 'error', - '@typescript-eslint/require-await': 'error', - }, -}; diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs new file mode 100644 index 0000000000..e7ce7e1388 --- /dev/null +++ b/web/eslint.config.mjs @@ -0,0 +1,105 @@ +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import typescriptEslint from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import parser from 'svelte-eslint-parser'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + { + ignores: [ + '**/.DS_Store', + '**/node_modules', + 'build', + '.svelte-kit', + 'package', + '**/.env', + '**/.env.*', + '!**/.env.example', + '**/pnpm-lock.yaml', + '**/package-lock.json', + '**/yarn.lock', + '**/svelte.config.js', + 'eslint.config.mjs', + 'postcss.config.cjs', + 'tailwind.config.cjs', + ], + }, + ...compat.extends( + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:svelte/recommended', + 'plugin:unicorn/recommended', + ), + { + plugins: { + '@typescript-eslint': typescriptEslint, + }, + + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + NodeJS: true, + }, + + parser: tsParser, + ecmaVersion: 2022, + sourceType: 'module', + + parserOptions: { + extraFileExtensions: ['.svelte'], + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + }, + }, + + rules: { + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_$', + varsIgnorePattern: '^_$', + }, + ], + + curly: 2, + 'unicorn/no-useless-undefined': 'off', + 'unicorn/prefer-spread': 'off', + 'unicorn/no-null': 'off', + 'unicorn/prevent-abbreviations': 'off', + 'unicorn/no-nested-ternary': 'off', + 'unicorn/consistent-function-scoping': 'off', + 'unicorn/prefer-top-level-await': 'off', + 'unicorn/import-style': 'off', + 'svelte/button-has-type': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/require-await': 'error', + }, + }, + { + files: ['**/*.svelte'], + + languageOptions: { + parser: parser, + ecmaVersion: 5, + sourceType: 'script', + + parserOptions: { + parser: '@typescript-eslint/parser', + }, + }, + }, +]; diff --git a/web/package-lock.json b/web/package-lock.json index 6fabc2b1a6..3a144312d0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -30,6 +30,8 @@ "thumbhash": "^0.1.1" }, "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@faker-js/faker": "^8.4.1", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", @@ -48,11 +50,12 @@ "@vitest/coverage-v8": "^2.0.5", "autoprefixer": "^10.4.17", "dotenv": "^16.4.5", - "eslint": "^8.57.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.35.1", "eslint-plugin-unicorn": "^55.0.0", "factory.ts": "^1.4.1", + "globals": "^15.9.0", "postcss": "^8.4.35", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", @@ -439,6 +442,18 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/types": { "version": "7.25.2", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz", @@ -876,24 +891,41 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "node_modules/@eslint/config-array": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.1.tgz", + "integrity": "sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -901,34 +933,74 @@ "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/espree": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", + "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.12.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", + "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", "dev": true, + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@faker-js/faker": { @@ -991,20 +1063,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1018,11 +1076,19 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", - "dev": true + "node_modules/@humanwhocodes/retry": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", + "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.3", @@ -2668,12 +2734,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, "node_modules/@vitest/coverage-v8": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", @@ -3680,18 +3740,6 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "dev": true }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -3913,41 +3961,38 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", + "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.17.1", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.8.0", "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.0.2", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.1.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", @@ -3961,10 +4006,10 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" } }, "node_modules/eslint-compat-utils": { @@ -4110,19 +4155,6 @@ "eslint": ">=8.56.0" } }, - "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.8.0.tgz", - "integrity": "sha512-VZAJ4cewHTExBWDHR6yptdIBlx9YSSZuwojj9Nt5mBRXQzrKakDsVKQ1J63sklLvzAJm0X5+RpO4i3Y2hcOnFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint-plugin-unicorn/node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -4182,6 +4214,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -4197,6 +4230,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4213,6 +4247,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -4224,13 +4259,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -4238,19 +4275,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "node_modules/eslint/node_modules/eslint-scope": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", + "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "type-fest": "^0.20.2" + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/espree": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", + "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.12.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/has-flag": { @@ -4258,6 +4328,7 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -4267,6 +4338,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -4498,15 +4570,16 @@ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, + "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/fill-range": { @@ -4538,24 +4611,25 @@ } }, "node_modules/flat-cache": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.0.tgz", - "integrity": "sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, + "license": "MIT", "dependencies": { - "flatted": "^3.2.7", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "flatted": "^3.2.9", + "keyv": "^4.5.4" }, "engines": { - "node": ">=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", - "dev": true + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true, + "license": "ISC" }, "node_modules/foreground-child": { "version": "3.2.1", @@ -4743,14 +4817,16 @@ } }, "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "version": "15.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.9.0.tgz", + "integrity": "sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==", "dev": true, - "optional": true, - "peer": true, + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/globalyzer": { @@ -5408,7 +5484,8 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -5468,10 +5545,11 @@ "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==" }, "node_modules/keyv": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", - "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, + "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -6870,21 +6948,6 @@ "node": ">=0.10.0" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rollup": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz", @@ -8554,18 +8617,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typescript": { "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", diff --git a/web/package.json b/web/package.json index d28ab12326..48f07127c9 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,8 @@ "prepare": "svelte-kit sync" }, "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@faker-js/faker": "^8.4.1", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", @@ -41,11 +43,12 @@ "@vitest/coverage-v8": "^2.0.5", "autoprefixer": "^10.4.17", "dotenv": "^16.4.5", - "eslint": "^8.57.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.35.1", "eslint-plugin-unicorn": "^55.0.0", "factory.ts": "^1.4.1", + "globals": "^15.9.0", "postcss": "^8.4.35", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 16f65241f2..3a56e19d78 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -391,7 +391,7 @@

{asset.exifInfo.make || ''} {asset.exifInfo.model || ''}

{#if asset.exifInfo?.fNumber} -

{`ƒ/${asset.exifInfo.fNumber.toLocaleString($locale)}` || ''}

+

{$locale ? `ƒ/${asset.exifInfo.fNumber.toLocaleString($locale)}` : ''}

{/if} {#if asset.exifInfo.exposureTime} From 9765ccb5a7ec798c2cd0df05cf70f8f34deb9529 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 5 Aug 2024 21:00:00 -0400 Subject: [PATCH 022/723] chore(deps): update machine-learning (#11605) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/Dockerfile | 4 +- machine-learning/export/Dockerfile | 2 +- machine-learning/poetry.lock | 72 +++++++++++++++--------------- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 9a411354c7..c47bba8985 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:f89d36dbb4728313572f88877b8be7d11fd03bea964cdf0a6b0f61edfcde3709 AS builder-cpu +FROM python:3.11-bookworm@sha256:d0131ce0ff4bdb5e9eae6bc86ebde891c207d5cac1f3f582b5de0f903cc68384 AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:7f49f147e57a65a5ca731203ed350ac5c88fa54aeb942924dd7057fe34a18e79 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:a90e299af8a9cd6b59c4aaed2b024c78561476978244a1ab89742a4a5ac8c974 AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index d3969bd98f..c467b1d5f6 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:eb744eed8e9308edaea942ddd92ad8da8a9b904ca0796fa240b72de51ce0d353 AS builder +FROM mambaorg/micromamba:bookworm-slim@sha256:954e438daab0ad0835430ea84acb27dd47d1ea35a7120c3c9dd9d1a5578f4b13 AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index cbc8985622..1f8a362095 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -1530,13 +1530,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "locust" -version = "2.29.1" +version = "2.31.1" description = "Developer-friendly load testing framework" optional = false python-versions = ">=3.9" files = [ - {file = "locust-2.29.1-py3-none-any.whl", hash = "sha256:8b15daab44cdf50eef1860a32bb30969423e3795247115e5a37446da3240c6d6"}, - {file = "locust-2.29.1.tar.gz", hash = "sha256:2e0628a59e2689a50cb4735a9a43709e30f2da7ed276c15d877c5325507f44b1"}, + {file = "locust-2.31.1-py3-none-any.whl", hash = "sha256:20756509939004e95c622ac3042886edab38b736f00534cc03ce2774064e7f71"}, + {file = "locust-2.31.1.tar.gz", hash = "sha256:d26b7333cdef80645f3978d8ff9aabab4d53e41ed82cc8490212aa68e8498fdd"}, ] [package.dependencies] @@ -1548,14 +1548,14 @@ gevent = ">=22.10.2" geventhttpclient = ">=2.3.1" msgpack = ">=1.0.0" psutil = ">=5.9.1" -pywin32 = {version = "*", markers = "platform_system == \"Windows\""} +pywin32 = {version = "*", markers = "sys_platform == \"win32\""} pyzmq = ">=25.0.0" requests = [ - {version = ">=2.32.2", markers = "python_version > \"3.11\""}, - {version = ">=2.26.0", markers = "python_version <= \"3.11\""}, + {version = ">=2.26.0", markers = "python_full_version <= \"3.11.0\""}, + {version = ">=2.32.2", markers = "python_full_version > \"3.11.0\""}, ] tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.11\""} +typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.11\""} Werkzeug = ">=2.0.0" [[package]] @@ -1794,38 +1794,38 @@ files = [ [[package]] name = "mypy" -version = "1.11.0" +version = "1.11.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3824187c99b893f90c845bab405a585d1ced4ff55421fdf5c84cb7710995229"}, - {file = "mypy-1.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96f8dbc2c85046c81bcddc246232d500ad729cb720da4e20fce3b542cab91287"}, - {file = "mypy-1.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a5d8d8dd8613a3e2be3eae829ee891b6b2de6302f24766ff06cb2875f5be9c6"}, - {file = "mypy-1.11.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:72596a79bbfb195fd41405cffa18210af3811beb91ff946dbcb7368240eed6be"}, - {file = "mypy-1.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:35ce88b8ed3a759634cb4eb646d002c4cef0a38f20565ee82b5023558eb90c00"}, - {file = "mypy-1.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:98790025861cb2c3db8c2f5ad10fc8c336ed2a55f4daf1b8b3f877826b6ff2eb"}, - {file = "mypy-1.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:25bcfa75b9b5a5f8d67147a54ea97ed63a653995a82798221cca2a315c0238c1"}, - {file = "mypy-1.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bea2a0e71c2a375c9fa0ede3d98324214d67b3cbbfcbd55ac8f750f85a414e3"}, - {file = "mypy-1.11.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2b3d36baac48e40e3064d2901f2fbd2a2d6880ec6ce6358825c85031d7c0d4d"}, - {file = "mypy-1.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8e2e43977f0e09f149ea69fd0556623919f816764e26d74da0c8a7b48f3e18a"}, - {file = "mypy-1.11.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:1d44c1e44a8be986b54b09f15f2c1a66368eb43861b4e82573026e04c48a9e20"}, - {file = "mypy-1.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cea3d0fb69637944dd321f41bc896e11d0fb0b0aa531d887a6da70f6e7473aba"}, - {file = "mypy-1.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a83ec98ae12d51c252be61521aa5731f5512231d0b738b4cb2498344f0b840cd"}, - {file = "mypy-1.11.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c7b73a856522417beb78e0fb6d33ef89474e7a622db2653bc1285af36e2e3e3d"}, - {file = "mypy-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:f2268d9fcd9686b61ab64f077be7ffbc6fbcdfb4103e5dd0cc5eaab53a8886c2"}, - {file = "mypy-1.11.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:940bfff7283c267ae6522ef926a7887305945f716a7704d3344d6d07f02df850"}, - {file = "mypy-1.11.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:14f9294528b5f5cf96c721f231c9f5b2733164e02c1c018ed1a0eff8a18005ac"}, - {file = "mypy-1.11.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7b54c27783991399046837df5c7c9d325d921394757d09dbcbf96aee4649fe9"}, - {file = "mypy-1.11.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:65f190a6349dec29c8d1a1cd4aa71284177aee5949e0502e6379b42873eddbe7"}, - {file = "mypy-1.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:dbe286303241fea8c2ea5466f6e0e6a046a135a7e7609167b07fd4e7baf151bf"}, - {file = "mypy-1.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:104e9c1620c2675420abd1f6c44bab7dd33cc85aea751c985006e83dcd001095"}, - {file = "mypy-1.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f006e955718ecd8d159cee9932b64fba8f86ee6f7728ca3ac66c3a54b0062abe"}, - {file = "mypy-1.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:becc9111ca572b04e7e77131bc708480cc88a911adf3d0239f974c034b78085c"}, - {file = "mypy-1.11.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6801319fe76c3f3a3833f2b5af7bd2c17bb93c00026a2a1b924e6762f5b19e13"}, - {file = "mypy-1.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:c1a184c64521dc549324ec6ef7cbaa6b351912be9cb5edb803c2808a0d7e85ac"}, - {file = "mypy-1.11.0-py3-none-any.whl", hash = "sha256:56913ec8c7638b0091ef4da6fcc9136896914a9d60d54670a75880c3e5b99ace"}, - {file = "mypy-1.11.0.tar.gz", hash = "sha256:93743608c7348772fdc717af4aeee1997293a1ad04bc0ea6efa15bf65385c538"}, + {file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c"}, + {file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411"}, + {file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03"}, + {file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4"}, + {file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58"}, + {file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5"}, + {file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca"}, + {file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de"}, + {file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"}, + {file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72"}, + {file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8"}, + {file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a"}, + {file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417"}, + {file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e"}, + {file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525"}, + {file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2"}, + {file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b"}, + {file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0"}, + {file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd"}, + {file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb"}, + {file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe"}, + {file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c"}, + {file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69"}, + {file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74"}, + {file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b"}, + {file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54"}, + {file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08"}, ] [package.dependencies] @@ -2074,10 +2074,10 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] [[package]] From d5b23373c73aca320fdb45e07643bf2e542fe054 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 5 Aug 2024 21:00:25 -0400 Subject: [PATCH 023/723] refactor(server): startup checks for vector extension (#11559) * update update logic refactor * update tests * get version range through repo method, make tests more static * move "should work" test --- server/src/interfaces/database.interface.ts | 10 +- .../src/repositories/database.repository.ts | 127 ++--- server/src/services/database.service.spec.ts | 432 +++++++++--------- server/src/services/database.service.ts | 184 ++++---- .../repositories/database.repository.mock.ts | 3 +- 5 files changed, 390 insertions(+), 366 deletions(-) diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index f78f6388fb..98bb0c0288 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -28,6 +28,11 @@ export const EXTENSION_NAMES: Record = { vectors: 'pgvecto.rs', } as const; +export interface ExtensionVersion { + availableVersion: string | null; + installedVersion: string | null; +} + export interface VectorUpdateResult { restartRequired: boolean; } @@ -35,9 +40,10 @@ export interface VectorUpdateResult { export const IDatabaseRepository = 'IDatabaseRepository'; export interface IDatabaseRepository { - getExtensionVersion(extensionName: string): Promise; - getAvailableExtensionVersion(extension: DatabaseExtension): Promise; + getExtensionVersion(extension: DatabaseExtension): Promise; + getExtensionVersionRange(extension: VectorExtension): string; getPostgresVersion(): Promise; + getPostgresVersionRange(): string; createExtension(extension: DatabaseExtension): Promise; updateExtension(extension: DatabaseExtension, version?: string): Promise; updateVectorExtension(extension: VectorExtension, version?: string): Promise; diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index fc9e76b0aa..9ee7f8e6fc 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -2,11 +2,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import AsyncLock from 'async-lock'; import semver from 'semver'; +import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; import { getVectorExtension } from 'src/database.config'; import { DatabaseExtension, DatabaseLock, EXTENSION_NAMES, + ExtensionVersion, IDatabaseRepository, VectorExtension, VectorIndex, @@ -29,20 +31,18 @@ export class DatabaseRepository implements IDatabaseRepository { this.logger.setContext(DatabaseRepository.name); } - async getExtensionVersion(extension: DatabaseExtension): Promise { - const res = await this.dataSource.query(`SELECT extversion FROM pg_extension WHERE extname = $1`, [extension]); - return res[0]?.['extversion']; - } - - async getAvailableExtensionVersion(extension: DatabaseExtension): Promise { - const res = await this.dataSource.query( - ` - SELECT version FROM pg_available_extension_versions - WHERE name = $1 AND installed = false - ORDER BY version DESC`, + async getExtensionVersion(extension: DatabaseExtension): Promise { + const [res]: ExtensionVersion[] = await this.dataSource.query( + `SELECT default_version as "availableVersion", installed_version as "installedVersion" + FROM pg_available_extensions + WHERE name = $1`, [extension], ); - return res[0]?.['version']; + return res ?? { availableVersion: null, installedVersion: null }; + } + + getExtensionVersionRange(extension: VectorExtension): string { + return extension === DatabaseExtension.VECTORS ? VECTORS_VERSION_RANGE : VECTOR_VERSION_RANGE; } async getPostgresVersion(): Promise { @@ -50,6 +50,10 @@ export class DatabaseRepository implements IDatabaseRepository { return version; } + getPostgresVersionRange(): string { + return POSTGRES_VERSION_RANGE; + } + async createExtension(extension: DatabaseExtension): Promise { await this.dataSource.query(`CREATE EXTENSION IF NOT EXISTS ${extension}`); } @@ -59,28 +63,34 @@ export class DatabaseRepository implements IDatabaseRepository { } async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise { - const currentVersion = await this.getExtensionVersion(extension); - if (!currentVersion) { + const { availableVersion, installedVersion } = await this.getExtensionVersion(extension); + if (!installedVersion) { throw new Error(`${EXTENSION_NAMES[extension]} extension is not installed`); } + if (!availableVersion) { + throw new Error(`No available version for ${EXTENSION_NAMES[extension]} extension`); + } + targetVersion ??= availableVersion; + const isVectors = extension === DatabaseExtension.VECTORS; let restartRequired = false; await this.dataSource.manager.transaction(async (manager) => { await this.setSearchPath(manager); - const isSchemaUpgrade = targetVersion && semver.satisfies(targetVersion, '0.1.1 || 0.1.11'); + if (isVectors && installedVersion === '0.1.1') { + await this.setExtVersion(manager, DatabaseExtension.VECTORS, '0.1.11'); + } + + const isSchemaUpgrade = semver.satisfies(installedVersion, '0.1.1 || 0.1.11'); if (isSchemaUpgrade && isVectors) { - await this.updateVectorsSchema(manager, currentVersion); + await this.updateVectorsSchema(manager); } - await manager.query(`ALTER EXTENSION ${extension} UPDATE${targetVersion ? ` TO '${targetVersion}'` : ''}`); + await manager.query(`ALTER EXTENSION ${extension} UPDATE TO '${targetVersion}'`); - if (!isSchemaUpgrade) { - return; - } - - if (isVectors) { + const diff = semver.diff(installedVersion, targetVersion); + if (isVectors && diff && ['minor', 'major'].includes(diff)) { await manager.query('SELECT pgvectors_upgrade()'); restartRequired = true; } else { @@ -96,24 +106,24 @@ export class DatabaseRepository implements IDatabaseRepository { try { await this.dataSource.query(`REINDEX INDEX ${index}`); } catch (error) { - if (getVectorExtension() === DatabaseExtension.VECTORS) { - this.logger.warn(`Could not reindex index ${index}. Attempting to auto-fix.`); - const table = index === VectorIndex.CLIP ? 'smart_search' : 'face_search'; - const dimSize = await this.getDimSize(table); - await this.dataSource.manager.transaction(async (manager) => { - await this.setSearchPath(manager); - await manager.query(`DROP INDEX IF EXISTS ${index}`); - await manager.query(`ALTER TABLE ${table} ALTER COLUMN embedding SET DATA TYPE real[]`); - await manager.query(`ALTER TABLE ${table} ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`); - await manager.query(`SET vectors.pgvector_compatibility=on`); - await manager.query(` - CREATE INDEX IF NOT EXISTS ${index} ON ${table} - USING hnsw (embedding vector_cosine_ops) - WITH (ef_construction = 300, m = 16)`); - }); - } else { + if (getVectorExtension() !== DatabaseExtension.VECTORS) { throw error; } + this.logger.warn(`Could not reindex index ${index}. Attempting to auto-fix.`); + + const table = await this.getIndexTable(index); + const dimSize = await this.getDimSize(table); + await this.dataSource.manager.transaction(async (manager) => { + await this.setSearchPath(manager); + await manager.query(`DROP INDEX IF EXISTS ${index}`); + await manager.query(`ALTER TABLE ${table} ALTER COLUMN embedding SET DATA TYPE real[]`); + await manager.query(`ALTER TABLE ${table} ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`); + await manager.query(`SET vectors.pgvector_compatibility=on`); + await manager.query(` + CREATE INDEX IF NOT EXISTS ${index} ON ${table} + USING hnsw (embedding vector_cosine_ops) + WITH (ef_construction = 300, m = 16)`); + }); } } @@ -123,13 +133,8 @@ export class DatabaseRepository implements IDatabaseRepository { } try { - const res = await this.dataSource.query( - ` - SELECT idx_status - FROM pg_vector_index_stat - WHERE indexname = $1`, - [name], - ); + const query = `SELECT idx_status FROM pg_vector_index_stat WHERE indexname = $1`; + const res = await this.dataSource.query(query, [name]); return res[0]?.['idx_status'] === 'UPGRADE'; } catch (error) { const message: string = (error as any).message; @@ -146,19 +151,27 @@ export class DatabaseRepository implements IDatabaseRepository { await manager.query(`SET search_path TO "$user", public, vectors`); } - private async updateVectorsSchema(manager: EntityManager, currentVersion: string): Promise { - await manager.query('CREATE SCHEMA IF NOT EXISTS vectors'); - await manager.query(`UPDATE pg_catalog.pg_extension SET extversion = $1 WHERE extname = $2`, [ - currentVersion, - DatabaseExtension.VECTORS, - ]); - await manager.query('UPDATE pg_catalog.pg_extension SET extrelocatable = true WHERE extname = $1', [ - DatabaseExtension.VECTORS, - ]); + private async setExtVersion(manager: EntityManager, extName: DatabaseExtension, version: string): Promise { + const query = `UPDATE pg_catalog.pg_extension SET extversion = $1 WHERE extname = $2`; + await manager.query(query, [version, extName]); + } + + private async getIndexTable(index: VectorIndex): Promise { + const tableQuery = `SELECT relname FROM pg_stat_all_indexes WHERE indexrelname = $1`; + const [res]: { relname: string | null }[] = await this.dataSource.manager.query(tableQuery, [index]); + const table = res?.relname; + if (!table) { + throw new Error(`Could not find table for index ${index}`); + } + return table; + } + + private async updateVectorsSchema(manager: EntityManager): Promise { + const extension = DatabaseExtension.VECTORS; + await manager.query(`CREATE SCHEMA IF NOT EXISTS ${extension}`); + await manager.query('UPDATE pg_catalog.pg_extension SET extrelocatable = true WHERE extname = $1', [extension]); await manager.query('ALTER EXTENSION vectors SET SCHEMA vectors'); - await manager.query('UPDATE pg_catalog.pg_extension SET extrelocatable = false WHERE extname = $1', [ - DatabaseExtension.VECTORS, - ]); + await manager.query('UPDATE pg_catalog.pg_extension SET extrelocatable = false WHERE extname = $1', [extension]); } private async getDimSize(table: string, column = 'embedding'): Promise { diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index df3a9798ef..a21b1d7d67 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -1,4 +1,4 @@ -import { DatabaseExtension, IDatabaseRepository } from 'src/interfaces/database.interface'; +import { DatabaseExtension, EXTENSION_NAMES, IDatabaseRepository } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { DatabaseService } from 'src/services/database.service'; import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; @@ -9,15 +9,33 @@ describe(DatabaseService.name, () => { let sut: DatabaseService; let databaseMock: Mocked; let loggerMock: Mocked; + let extensionRange: string; + let versionBelowRange: string; + let minVersionInRange: string; + let updateInRange: string; + let versionAboveRange: string; beforeEach(() => { - delete process.env.DB_SKIP_MIGRATIONS; - delete process.env.DB_VECTOR_EXTENSION; databaseMock = newDatabaseRepositoryMock(); loggerMock = newLoggerRepositoryMock(); sut = new DatabaseService(databaseMock, loggerMock); - databaseMock.getExtensionVersion.mockResolvedValue('0.2.0'); + extensionRange = '0.2.x'; + databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange); + + versionBelowRange = '0.1.0'; + minVersionInRange = '0.2.0'; + updateInRange = '0.2.1'; + versionAboveRange = '0.3.0'; + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: minVersionInRange, + availableVersion: minVersionInRange, + }); + }); + + afterEach(() => { + delete process.env.DB_SKIP_MIGRATIONS; + delete process.env.DB_VECTOR_EXTENSION; }); it('should work', () => { @@ -32,264 +50,238 @@ describe(DatabaseService.name, () => { expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); }); - it(`should start up successfully with pgvectors`, async () => { - databaseMock.getPostgresVersion.mockResolvedValue('14.0.0'); + describe.each([ + { extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] }, + { extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] }, + ])('should work with $extensionName', ({ extension, extensionName }) => { + beforeEach(() => { + process.env.DB_VECTOR_EXTENSION = extensionName; + }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + it(`should start up successfully with ${extension}`, async () => { + databaseMock.getPostgresVersion.mockResolvedValue('14.0.0'); + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: null, + availableVersion: minVersionInRange, + }); - expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); - expect(databaseMock.createExtension).toHaveBeenCalledWith(DatabaseExtension.VECTORS); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - it(`should start up successfully with pgvector`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getPostgresVersion.mockResolvedValue('14.0.0'); - databaseMock.getExtensionVersion.mockResolvedValue('0.5.0'); + expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); + expect(databaseMock.createExtension).toHaveBeenCalledWith(extension); + expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); + expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + it(`should throw an error if the ${extension} extension is not installed`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); + const message = `The ${extensionName} extension is not available in this Postgres instance. + If using a container image, ensure the image has the extension installed.`; + await expect(sut.onBootstrapEvent()).rejects.toThrow(message); - expect(databaseMock.createExtension).toHaveBeenCalledWith(DatabaseExtension.VECTOR); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + expect(databaseMock.createExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); - it(`should throw an error if the pgvecto.rs extension is not installed`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue(''); - await expect(sut.onBootstrapEvent()).rejects.toThrow(`Unexpected: The pgvecto.rs extension is not installed.`); + it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: versionBelowRange, + availableVersion: versionBelowRange, + }); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrapEvent()).rejects.toThrow( + `The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`, + ); - it(`should throw an error if the pgvector extension is not installed`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue(''); - await expect(sut.onBootstrapEvent()).rejects.toThrow(`Unexpected: The pgvector extension is not installed.`); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + it(`should throw an error if ${extension} extension version is a nightly`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); - it(`should throw an error if the pgvecto.rs extension version is below minimum supported version`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue('0.1.0'); + await expect(sut.onBootstrapEvent()).rejects.toThrow( + `The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`, + ); - await expect(sut.onBootstrapEvent()).rejects.toThrow( - 'The pgvecto.rs extension version is 0.1.0, but Immich only supports 0.2.x.', - ); + expect(databaseMock.createExtension).not.toHaveBeenCalled(); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + it(`should do in-range update for ${extension} extension`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: updateInRange, + installedVersion: minVersionInRange, + }); + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - it(`should throw an error if the pgvector extension version is below minimum supported version`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue('0.1.0'); + await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - await expect(sut.onBootstrapEvent()).rejects.toThrow( - 'The pgvector extension version is 0.1.0, but Immich only supports >=0.5 <1', - ); + expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); + expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + it(`should not upgrade ${extension} if same version`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: minVersionInRange, + installedVersion: minVersionInRange, + }); - it(`should throw an error if pgvecto.rs extension version is a nightly`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue('0.0.0'); + await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - await expect(sut.onBootstrapEvent()).rejects.toThrow( - 'The pgvecto.rs extension version is 0.0.0, which means it is a nightly release.', - ); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + it(`should throw error if ${extension} available version is below range`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: versionBelowRange, + installedVersion: null, + }); - it(`should throw an error if pgvector extension version is a nightly`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue('0.0.0'); + await expect(sut.onBootstrapEvent()).rejects.toThrow(); - await expect(sut.onBootstrapEvent()).rejects.toThrow( - 'The pgvector extension version is 0.0.0, which means it is a nightly release.', - ); + expect(databaseMock.createExtension).not.toHaveBeenCalled(); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + it(`should throw error if ${extension} available version is above range`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: versionAboveRange, + installedVersion: minVersionInRange, + }); - it(`should throw error if pgvecto.rs extension could not be created`, async () => { - databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); + await expect(sut.onBootstrapEvent()).rejects.toThrow(); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to create extension'); + expect(databaseMock.createExtension).not.toHaveBeenCalled(); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); - expect(loggerMock.fatal).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal.mock.calls[0][0]).toContain( - 'Alternatively, if your Postgres instance has pgvector, you may use this instead', - ); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + it('should throw error if available version is below installed version', async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: minVersionInRange, + installedVersion: updateInRange, + }); + + await expect(sut.onBootstrapEvent()).rejects.toThrow( + `The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`, + ); + + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); + + it(`should raise error if ${extension} extension upgrade failed`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: updateInRange, + installedVersion: minVersionInRange, + }); + databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); + + await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to update extension'); + + expect(loggerMock.warn.mock.calls[0][0]).toContain( + `The ${extensionName} extension can be updated to ${updateInRange}.`, + ); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); + + it(`should warn if ${extension} extension update requires restart`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: updateInRange, + installedVersion: minVersionInRange, + }); + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); + + await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + + expect(loggerMock.warn).toHaveBeenCalledTimes(1); + expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName); + expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); + + it(`should reindex ${extension} indices if needed`, async () => { + databaseMock.shouldReindex.mockResolvedValue(true); + + await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + + expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); + expect(databaseMock.reindex).toHaveBeenCalledTimes(2); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); + + it(`should not reindex ${extension} indices if not needed`, async () => { + databaseMock.shouldReindex.mockResolvedValue(false); + + await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + + expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); + expect(databaseMock.reindex).toHaveBeenCalledTimes(0); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); + + it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { + process.env.DB_SKIP_MIGRATIONS = 'true'; + + await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); }); it(`should throw error if pgvector extension could not be created`, async () => { process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue('0.0.0'); + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: null, + availableVersion: minVersionInRange, + }); + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to create extension'); expect(loggerMock.fatal).toHaveBeenCalledTimes(1); expect(loggerMock.fatal.mock.calls[0][0]).toContain( - 'Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead', + `Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`, ); expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); - for (const version of ['0.2.1', '0.2.0', '0.2.9']) { - it(`should update the pgvecto.rs extension to ${version}`, async () => { - databaseMock.getAvailableExtensionVersion.mockResolvedValue(version); - databaseMock.getExtensionVersion.mockResolvedValueOnce(void 0); - databaseMock.getExtensionVersion.mockResolvedValue(version); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith('vectors', version); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + it(`should throw error if pgvecto.rs extension could not be created`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: null, + availableVersion: minVersionInRange, }); - } + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - for (const version of ['0.5.1', '0.6.0', '0.7.10']) { - it(`should update the pgvectors extension to ${version}`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getAvailableExtensionVersion.mockResolvedValue(version); - databaseMock.getExtensionVersion.mockResolvedValueOnce(void 0); - databaseMock.getExtensionVersion.mockResolvedValue(version); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith('vector', version); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); - } - - for (const version of ['0.1.0', '0.3.0', '1.0.0']) { - it(`should not upgrade pgvecto.rs to ${version}`, async () => { - databaseMock.getAvailableExtensionVersion.mockResolvedValue(version); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); - } - - for (const version of ['0.4.0', '0.7.1', '0.7.2', '1.0.0']) { - it(`should not upgrade pgvector to ${version}`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue('0.7.2'); - databaseMock.getAvailableExtensionVersion.mockResolvedValue(version); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); - } - - it(`should warn if the pgvecto.rs extension upgrade failed`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue('0.5.0'); - databaseMock.getAvailableExtensionVersion.mockResolvedValue('0.5.2'); - databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(loggerMock.warn.mock.calls[0][0]).toContain('The pgvector extension can be updated to 0.5.2.'); - expect(loggerMock.error).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith('vector', '0.5.2'); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - }); - - it(`should warn if the pgvector extension upgrade failed`, async () => { - databaseMock.getAvailableExtensionVersion.mockResolvedValue('0.2.1'); - databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(loggerMock.warn.mock.calls[0][0]).toContain('The pgvecto.rs extension can be updated to 0.2.1.'); - expect(loggerMock.error).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith('vectors', '0.2.1'); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - }); - - it(`should warn if the pgvecto.rs extension update requires restart`, async () => { - databaseMock.getAvailableExtensionVersion.mockResolvedValue('0.2.1'); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(loggerMock.warn).toHaveBeenCalledTimes(1); - expect(loggerMock.warn.mock.calls[0][0]).toContain('pgvecto.rs'); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith('vectors', '0.2.1'); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); - - it(`should warn if the pgvector extension update requires restart`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue('0.5.0'); - databaseMock.getAvailableExtensionVersion.mockResolvedValue('0.5.1'); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(loggerMock.warn).toHaveBeenCalledTimes(1); - expect(loggerMock.warn.mock.calls[0][0]).toContain('pgvector'); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith('vector', '0.5.1'); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); - - it('should reindex if needed', async () => { - databaseMock.shouldReindex.mockResolvedValue(true); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(2); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); - - it('should not reindex if not needed', async () => { - databaseMock.shouldReindex.mockResolvedValue(false); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(0); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); - - it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { - process.env.DB_SKIP_MIGRATIONS = 'true'; - databaseMock.getExtensionVersion.mockResolvedValue('0.2.0'); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to create extension'); + expect(loggerMock.fatal).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal.mock.calls[0][0]).toContain( + `Alternatively, if your Postgres instance has pgvector, you may use this instead`, + ); + expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index e50a509dbf..a2f43c58ba 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import semver from 'semver'; -import { POSTGRES_VERSION_RANGE, VECTORS_VERSION_RANGE, VECTOR_VERSION_RANGE } from 'src/constants'; import { getVectorExtension } from 'src/database.config'; import { EventHandlerOptions } from 'src/decorators'; import { @@ -8,6 +7,7 @@ import { DatabaseLock, EXTENSION_NAMES, IDatabaseRepository, + VectorExtension, VectorIndex, } from 'src/interfaces/database.interface'; import { OnEvents } from 'src/interfaces/event.interface'; @@ -18,50 +18,46 @@ type UpdateFailedArgs = { name: string; extension: string; availableVersion: str type RestartRequiredArgs = { name: string; availableVersion: string }; type NightlyVersionArgs = { name: string; extension: string; version: string }; type OutOfRangeArgs = { name: string; extension: string; version: string; range: string }; - -const EXTENSION_RANGES = { - [DatabaseExtension.VECTOR]: VECTOR_VERSION_RANGE, - [DatabaseExtension.VECTORS]: VECTORS_VERSION_RANGE, -}; +type InvalidDowngradeArgs = { name: string; extension: string; installedVersion: string; availableVersion: string }; const messages = { - notInstalled: (name: string) => `Unexpected: The ${name} extension is not installed.`, + notInstalled: (name: string) => + `The ${name} extension is not available in this Postgres instance. + If using a container image, ensure the image has the extension installed.`, nightlyVersion: ({ name, extension, version }: NightlyVersionArgs) => ` - The ${name} extension version is ${version}, which means it is a nightly release. + The ${name} extension version is ${version}, which means it is a nightly release. - Please run 'DROP EXTENSION IF EXISTS ${extension}' and switch to a release version. - See https://immich.app/docs/guides/database-queries for how to query the database.`, - outOfRange: ({ name, extension, version, range }: OutOfRangeArgs) => ` - The ${name} extension version is ${version}, but Immich only supports ${range}. + Please run 'DROP EXTENSION IF EXISTS ${extension}' and switch to a release version. + See https://immich.app/docs/guides/database-queries for how to query the database.`, + outOfRange: ({ name, version, range }: OutOfRangeArgs) => + `The ${name} extension version is ${version}, but Immich only supports ${range}. + Please change ${name} to a compatible version in the Postgres instance.`, + createFailed: ({ name, extension, otherName }: CreateFailedArgs) => + `Failed to activate ${name} extension. + Please ensure the Postgres instance has ${name} installed. - If the Postgres instance already has a compatible version installed, Immich may not have the necessary permissions to activate it. - In this case, please run 'ALTER EXTENSION UPDATE ${extension}' manually as a superuser. - See https://immich.app/docs/guides/database-queries for how to query the database. + If the Postgres instance already has ${name} installed, Immich may not have the necessary permissions to activate it. + In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${extension}' manually as a superuser. + See https://immich.app/docs/guides/database-queries for how to query the database. - Otherwise, please update the version of ${name} in the Postgres instance to a compatible version.`, - createFailed: ({ name, extension, otherName }: CreateFailedArgs) => ` - Failed to activate ${name} extension. - Please ensure the Postgres instance has ${name} installed. + Alternatively, if your Postgres instance has ${otherName}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${otherName}'. + Note that switching between the two extensions after a successful startup is not supported. + The exception is if your version of Immich prior to upgrading was 1.90.2 or earlier. + In this case, you may set either extension now, but you will not be able to switch to the other extension following a successful startup.`, + updateFailed: ({ name, extension, availableVersion }: UpdateFailedArgs) => + `The ${name} extension can be updated to ${availableVersion}. + Immich attempted to update the extension, but failed to do so. + This may be because Immich does not have the necessary permissions to update the extension. - If the Postgres instance already has ${name} installed, Immich may not have the necessary permissions to activate it. - In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${extension}' manually as a superuser. - See https://immich.app/docs/guides/database-queries for how to query the database. - - Alternatively, if your Postgres instance has ${otherName}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${otherName}'. - Note that switching between the two extensions after a successful startup is not supported. - The exception is if your version of Immich prior to upgrading was 1.90.2 or earlier. - In this case, you may set either extension now, but you will not be able to switch to the other extension following a successful startup. - `, - updateFailed: ({ name, extension, availableVersion }: UpdateFailedArgs) => ` - The ${name} extension can be updated to ${availableVersion}. - Immich attempted to update the extension, but failed to do so. - This may be because Immich does not have the necessary permissions to update the extension. - - Please run 'ALTER EXTENSION ${extension} UPDATE' manually as a superuser. - See https://immich.app/docs/guides/database-queries for how to query the database.`, - restartRequired: ({ name, availableVersion }: RestartRequiredArgs) => ` - The ${name} extension has been updated to ${availableVersion}. - Please restart the Postgres instance to complete the update.`, + Please run 'ALTER EXTENSION ${extension} UPDATE' manually as a superuser. + See https://immich.app/docs/guides/database-queries for how to query the database.`, + restartRequired: ({ name, availableVersion }: RestartRequiredArgs) => + `The ${name} extension has been updated to ${availableVersion}. + Please restart the Postgres instance to complete the update.`, + invalidDowngrade: ({ name, installedVersion, availableVersion }: InvalidDowngradeArgs) => + `The database currently has ${name} ${installedVersion} activated, but the Postgres instance only has ${availableVersion} available. + This most likely means the extension was downgraded. + If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`, }; @Injectable() @@ -77,74 +73,90 @@ export class DatabaseService implements OnEvents { async onBootstrapEvent() { const version = await this.databaseRepository.getPostgresVersion(); const current = semver.coerce(version); - if (!current || !semver.satisfies(current, POSTGRES_VERSION_RANGE)) { + const postgresRange = this.databaseRepository.getPostgresVersionRange(); + if (!current || !semver.satisfies(current, postgresRange)) { throw new Error( - `Invalid PostgreSQL version. Found ${version}, but needed ${POSTGRES_VERSION_RANGE}. Please use a supported version.`, + `Invalid PostgreSQL version. Found ${version}, but needed ${postgresRange}. Please use a supported version.`, ); } await this.databaseRepository.withLock(DatabaseLock.Migrations, async () => { const extension = getVectorExtension(); - const otherExtension = - extension === DatabaseExtension.VECTORS ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS; - const otherName = EXTENSION_NAMES[otherExtension]; const name = EXTENSION_NAMES[extension]; - const extensionRange = EXTENSION_RANGES[extension]; + const extensionRange = this.databaseRepository.getExtensionVersionRange(extension); - try { - await this.databaseRepository.createExtension(extension); - } catch (error) { - this.logger.fatal(messages.createFailed({ name, extension, otherName })); - throw error; - } - - const initialVersion = await this.databaseRepository.getExtensionVersion(extension); - const availableVersion = await this.databaseRepository.getAvailableExtensionVersion(extension); - const isAvailable = availableVersion && semver.satisfies(availableVersion, extensionRange); - if (isAvailable && (!initialVersion || semver.gt(availableVersion, initialVersion))) { - try { - this.logger.log(`Updating ${name} extension to ${availableVersion}`); - const { restartRequired } = await this.databaseRepository.updateVectorExtension(extension, availableVersion); - if (restartRequired) { - this.logger.warn(messages.restartRequired({ name, availableVersion })); - } - } catch (error) { - this.logger.warn(messages.updateFailed({ name, extension, availableVersion })); - this.logger.error(error); - } - } - - const version = await this.databaseRepository.getExtensionVersion(extension); - if (!version) { + const { availableVersion, installedVersion } = await this.databaseRepository.getExtensionVersion(extension); + if (!availableVersion) { throw new Error(messages.notInstalled(name)); } - if (semver.eq(version, '0.0.0')) { - throw new Error(messages.nightlyVersion({ name, extension, version })); + if ([availableVersion, installedVersion].some((version) => version && semver.eq(version, '0.0.0'))) { + throw new Error(messages.nightlyVersion({ name, extension, version: '0.0.0' })); } - if (!semver.satisfies(version, extensionRange)) { - throw new Error(messages.outOfRange({ name, extension, version, range: extensionRange })); + if (!semver.satisfies(availableVersion, extensionRange)) { + throw new Error(messages.outOfRange({ name, extension, version: availableVersion, range: extensionRange })); } - try { - if (await this.databaseRepository.shouldReindex(VectorIndex.CLIP)) { - await this.databaseRepository.reindex(VectorIndex.CLIP); - } - - if (await this.databaseRepository.shouldReindex(VectorIndex.FACE)) { - await this.databaseRepository.reindex(VectorIndex.FACE); - } - } catch (error) { - this.logger.warn( - 'Could not run vector reindexing checks. If the extension was updated, please restart the Postgres instance.', - ); - throw error; + if (!installedVersion) { + await this.createExtension(extension); } + if (installedVersion && semver.gt(availableVersion, installedVersion)) { + await this.updateExtension(extension, availableVersion); + } else if (installedVersion && !semver.satisfies(installedVersion, extensionRange)) { + throw new Error(messages.outOfRange({ name, extension, version: installedVersion, range: extensionRange })); + } else if (installedVersion && semver.lt(availableVersion, installedVersion)) { + throw new Error(messages.invalidDowngrade({ name, extension, availableVersion, installedVersion })); + } + + await this.checkReindexing(); + if (process.env.DB_SKIP_MIGRATIONS !== 'true') { await this.databaseRepository.runMigrations(); } }); } + + private async createExtension(extension: DatabaseExtension) { + try { + await this.databaseRepository.createExtension(extension); + } catch (error) { + const otherExtension = + extension === DatabaseExtension.VECTORS ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS; + const name = EXTENSION_NAMES[extension]; + this.logger.fatal(messages.createFailed({ name, extension, otherName: EXTENSION_NAMES[otherExtension] })); + throw error; + } + } + + private async updateExtension(extension: VectorExtension, availableVersion: string) { + this.logger.log(`Updating ${EXTENSION_NAMES[extension]} extension to ${availableVersion}`); + try { + const { restartRequired } = await this.databaseRepository.updateVectorExtension(extension, availableVersion); + if (restartRequired) { + this.logger.warn(messages.restartRequired({ name: EXTENSION_NAMES[extension], availableVersion })); + } + } catch (error) { + this.logger.warn(messages.updateFailed({ name: EXTENSION_NAMES[extension], extension, availableVersion })); + throw error; + } + } + + private async checkReindexing() { + try { + if (await this.databaseRepository.shouldReindex(VectorIndex.CLIP)) { + await this.databaseRepository.reindex(VectorIndex.CLIP); + } + + if (await this.databaseRepository.shouldReindex(VectorIndex.FACE)) { + await this.databaseRepository.reindex(VectorIndex.FACE); + } + } catch (error) { + this.logger.warn( + 'Could not run vector reindexing checks. If the extension was updated, please restart the Postgres instance.', + ); + throw error; + } + } } diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index aef2e50ae8..e8b0817dfe 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -4,8 +4,9 @@ import { Mocked, vitest } from 'vitest'; export const newDatabaseRepositoryMock = (): Mocked => { return { getExtensionVersion: vitest.fn(), - getAvailableExtensionVersion: vitest.fn(), + getExtensionVersionRange: vitest.fn(), getPostgresVersion: vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'), + getPostgresVersionRange: vitest.fn().mockReturnValue('>=14.0.0'), createExtension: vitest.fn().mockResolvedValue(void 0), updateExtension: vitest.fn(), updateVectorExtension: vitest.fn(), From dd638ac20747581a854d03772ea92218880dee31 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Tue, 6 Aug 2024 15:34:17 +0200 Subject: [PATCH 024/723] fix(web): slideshow on iphone (#11599) * fix(web): slideshow on iphone * make requestFullscreen type optional --- web/src/app.d.ts | 5 +++++ web/src/lib/components/asset-viewer/asset-viewer.svelte | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/web/src/app.d.ts b/web/src/app.d.ts index d041386df2..4fcb901892 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -22,3 +22,8 @@ declare module '$env/static/public' { export const PUBLIC_IMMICH_PAY_HOST: string; export const PUBLIC_IMMICH_BUY_HOST: string; } + +interface Element { + // Make optional, because it's unavailable on iPhones. + requestFullscreen?(options?: FullscreenOptions): Promise; +} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index af4ba84a4a..a5485346ed 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -334,7 +334,7 @@ const handlePlaySlideshow = async () => { try { - await assetViewerHtmlElement.requestFullscreen(); + await assetViewerHtmlElement.requestFullscreen?.(); } catch (error) { handleError(error, $t('errors.unable_to_enter_fullscreen')); $slideshowState = SlideshowState.StopSlideshow; @@ -422,7 +422,7 @@
assetViewerHtmlElement.requestFullscreen()} + onSetToFullScreen={() => assetViewerHtmlElement.requestFullscreen?.()} onPrevious={() => navigateAsset('previous')} onNext={() => navigateAsset('next')} onClose={() => ($slideshowState = SlideshowState.StopSlideshow)} From 20262209ce0e5910fa361649063e4a19dd2eb8a5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 6 Aug 2024 10:09:38 -0400 Subject: [PATCH 025/723] fix(deps): update dependency setuptools to v70 [security] (#11609) --- machine-learning/poetry.lock | 13 ++++++------- machine-learning/pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 1f8a362095..abe4003442 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -2991,19 +2991,18 @@ test = ["asv", "gmpy2", "mpmath", "pooch", "pytest", "pytest-cov", "pytest-timeo [[package]] name = "setuptools" -version = "68.2.2" +version = "70.3.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"}, - {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"}, + {file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"}, + {file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "six" @@ -3601,4 +3600,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "df9afeda50e05cb62b322a047028a9b0851db197c4f379903c70adab3a98777a" +content-hash = "b2b053886ca1dd3a3305c63caf155b1976dfc4066f72f5d1ecfc42099db34aab" diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index ff6de49811..37001ba2eb 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -17,7 +17,7 @@ pydantic = "^1.10.8" aiocache = ">=0.12.1,<1.0" rich = ">=13.4.2" ftfy = ">=6.1.1" -setuptools = "^68.0.0" +setuptools = "^70.0.0" python-multipart = ">=0.0.6,<1.0" orjson = ">=3.9.5" gunicorn = ">=21.1.0" From 0eacdf93ebf32dfdeb672e7b67d2aa57e96baea4 Mon Sep 17 00:00:00 2001 From: Pruthvi Bugidi Date: Tue, 6 Aug 2024 19:50:27 +0530 Subject: [PATCH 026/723] feat(mobile): add support for material themes (#11560) * feat(mobile): add support for material themes Added support for custom theming and updated all elements accordingly. * fix(mobile): Restored immich brand colors to default theme * fix(mobile): make ListTile titles bold in settings main page * feat(mobile): update bottom nav and appbar colors * small tweaks --------- Co-authored-by: Alex --- mobile/assets/i18n/en-US.json | 7 +- mobile/lib/constants/immich_colors.dart | 109 +++- mobile/lib/entities/store.entity.dart | 5 + .../extensions/build_context_extensions.dart | 4 +- mobile/lib/extensions/theme_extensions.dart | 24 + mobile/lib/main.dart | 9 +- .../lib/pages/backup/album_preview.page.dart | 4 +- .../backup/backup_album_selection.page.dart | 6 +- .../pages/backup/backup_controller.page.dart | 9 +- .../lib/pages/common/album_options.page.dart | 11 +- .../lib/pages/common/album_viewer.page.dart | 6 +- mobile/lib/pages/common/app_log.page.dart | 20 +- .../lib/pages/common/app_log_detail.page.dart | 6 +- .../lib/pages/common/create_album.page.dart | 22 +- mobile/lib/pages/common/settings.page.dart | 23 +- mobile/lib/pages/library/library.page.dart | 32 +- mobile/lib/pages/login/login.page.dart | 5 +- mobile/lib/pages/search/search.page.dart | 19 +- .../lib/pages/search/search_input.page.dart | 3 +- .../shared_link/shared_link_edit.page.dart | 19 +- mobile/lib/pages/sharing/sharing.page.dart | 21 +- mobile/lib/services/app_settings.service.dart | 16 + mobile/lib/utils/immich_app_theme.dart | 489 +++++++++--------- ...n.dart => album_action_filled_button.dart} | 15 +- .../widgets/album/album_thumbnail_card.dart | 18 +- .../widgets/album/album_title_text_field.dart | 22 +- .../widgets/album/album_viewer_appbar.dart | 2 +- .../album/album_viewer_editable_title.dart | 18 +- .../asset_grid/control_bottom_app_bar.dart | 2 +- .../disable_multi_select_button.dart | 7 +- .../asset_grid/group_divider_title.dart | 5 +- .../asset_grid/immich_asset_grid_view.dart | 5 +- .../widgets/asset_grid/thumbnail_image.dart | 9 +- .../asset_grid/thumbnail_placeholder.dart | 18 +- .../asset_viewer/description_input.dart | 7 +- .../exif_sheet/exif_bottom_sheet.dart | 2 +- .../asset_viewer/top_control_app_bar.dart | 1 + .../widgets/backup/album_info_list_tile.dart | 10 +- .../lib/widgets/backup/backup_info_card.dart | 9 +- .../backup/current_backup_asset_info_box.dart | 27 +- .../common/app_bar_dialog/app_bar_dialog.dart | 14 +- .../app_bar_dialog/app_bar_profile_info.dart | 11 +- .../app_bar_dialog/app_bar_server_info.dart | 37 +- mobile/lib/widgets/common/confirm_dialog.dart | 2 +- mobile/lib/widgets/common/immich_app_bar.dart | 4 +- .../lib/widgets/common/immich_title_text.dart | 1 + mobile/lib/widgets/common/immich_toast.dart | 4 +- .../widgets/forms/change_password_form.dart | 5 +- .../lib/widgets/map/map_theme_override.dart | 5 +- .../lib/widgets/memories/memory_epilogue.dart | 29 +- .../memories/memory_progress_indicator.dart | 9 +- .../search_filter/search_filter_chip.dart | 9 +- .../search/thumbnail_with_info_container.dart | 12 +- .../custome_proxy_headers_settings.dart | 5 +- .../widgets/settings/language_settings.dart | 4 +- .../settings/local_storage_settings.dart | 7 +- .../preference_setting.dart | 5 +- .../primary_color_setting.dart | 221 ++++++++ .../preference_settings/theme_setting.dart | 37 +- .../settings/settings_button_list_tile.dart | 8 +- .../settings/settings_switch_list_tile.dart | 5 +- .../settings/ssl_client_cert_settings.dart | 5 +- .../widgets/shared_link/shared_link_item.dart | 12 +- mobile/pubspec.lock | 8 + mobile/pubspec.yaml | 2 + 65 files changed, 944 insertions(+), 563 deletions(-) create mode 100644 mobile/lib/extensions/theme_extensions.dart rename mobile/lib/widgets/album/{album_action_outlined_button.dart => album_action_filled_button.dart} (70%) create mode 100644 mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index ad3103b002..220a73b58d 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -531,6 +531,11 @@ "theme_setting_dark_mode_switch": "Dark mode", "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_colorful_interface_title": "Colorful interface", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatic (Follow system setting)", "theme_setting_theme_subtitle": "Choose the app's theme setting", "theme_setting_theme_title": "Theme", @@ -562,4 +567,4 @@ "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" -} \ No newline at end of file +} diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/constants/immich_colors.dart index 598f956619..38deac3f0e 100644 --- a/mobile/lib/constants/immich_colors.dart +++ b/mobile/lib/constants/immich_colors.dart @@ -1,5 +1,108 @@ import 'package:flutter/material.dart'; +import 'package:immich_mobile/utils/immich_app_theme.dart'; -const Color immichBackgroundColor = Color(0xFFf6f8fe); -const Color immichDarkBackgroundColor = Color.fromARGB(255, 0, 0, 0); -const Color immichDarkThemePrimaryColor = Color.fromARGB(255, 173, 203, 250); +enum ImmichColorPreset { + indigo, + deepPurple, + pink, + red, + orange, + yellow, + lime, + green, + cyan, + slateGray +} + +const ImmichColorPreset defaultColorPreset = ImmichColorPreset.indigo; +const String defaultColorPresetName = "indigo"; + +const Color immichBrandColorLight = Color(0xFF4150AF); +const Color immichBrandColorDark = Color(0xFFACCBFA); + +final Map _themePresetsMap = { + ImmichColorPreset.indigo: ImmichTheme( + light: ColorScheme.fromSeed( + seedColor: immichBrandColorLight, + ).copyWith(primary: immichBrandColorLight), + dark: ColorScheme.fromSeed( + seedColor: immichBrandColorDark, + brightness: Brightness.dark, + ).copyWith(primary: immichBrandColorDark), + ), + ImmichColorPreset.deepPurple: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFF6F43C0)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFFD3BBFF), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.pink: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFFED79B5)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFFED79B5), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.red: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFFC51C16)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFFD3302F), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.orange: ImmichTheme( + light: ColorScheme.fromSeed( + seedColor: const Color(0xffff5b01), + dynamicSchemeVariant: DynamicSchemeVariant.fidelity, + ), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFFCC6D08), + brightness: Brightness.dark, + dynamicSchemeVariant: DynamicSchemeVariant.fidelity, + ), + ), + ImmichColorPreset.yellow: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFFFFB400)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFFFFB400), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.lime: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFFCDDC39)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFFCDDC39), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.green: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFF18C249)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFF18C249), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.cyan: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFF00BCD4)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFF00BCD4), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.slateGray: ImmichTheme( + light: ColorScheme.fromSeed( + seedColor: const Color(0xFF696969), + dynamicSchemeVariant: DynamicSchemeVariant.neutral, + ), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xff696969), + brightness: Brightness.dark, + dynamicSchemeVariant: DynamicSchemeVariant.neutral, + ), + ), +}; + +extension ImmichColorModeExtension on ImmichColorPreset { + ImmichTheme getTheme() => _themePresetsMap[this]!; +} diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart index baa7ff51a3..a84f980001 100644 --- a/mobile/lib/entities/store.entity.dart +++ b/mobile/lib/entities/store.entity.dart @@ -229,6 +229,11 @@ enum StoreKey { mapwithPartners(125, type: bool), enableHapticFeedback(126, type: bool), customHeaders(127, type: String), + + // theme settings + primaryColor(128, type: String), + dynamicTheme(129, type: bool), + colorfulInterface(130, type: bool), ; const StoreKey( diff --git a/mobile/lib/extensions/build_context_extensions.dart b/mobile/lib/extensions/build_context_extensions.dart index 6a61b00530..141a1ede15 100644 --- a/mobile/lib/extensions/build_context_extensions.dart +++ b/mobile/lib/extensions/build_context_extensions.dart @@ -20,10 +20,10 @@ extension ContextHelper on BuildContext { bool get isDarkTheme => themeData.brightness == Brightness.dark; // Returns the current Primary color of the Theme - Color get primaryColor => themeData.primaryColor; + Color get primaryColor => themeData.colorScheme.primary; // Returns the Scaffold background color of the Theme - Color get scaffoldBackgroundColor => themeData.scaffoldBackgroundColor; + Color get scaffoldBackgroundColor => colorScheme.surface; // Returns the current TextTheme TextTheme get textTheme => themeData.textTheme; diff --git a/mobile/lib/extensions/theme_extensions.dart b/mobile/lib/extensions/theme_extensions.dart new file mode 100644 index 0000000000..3e17e2b991 --- /dev/null +++ b/mobile/lib/extensions/theme_extensions.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +extension ImmichColorSchemeExtensions on ColorScheme { + bool get _isDarkMode => brightness == Brightness.dark; + Color get onSurfaceSecondary => _isDarkMode + ? onSurface.darken(amount: .3) + : onSurface.lighten(amount: .3); +} + +extension ColorExtensions on Color { + Color lighten({double amount = 0.1}) { + return Color.alphaBlend( + Colors.white.withOpacity(amount), + this, + ); + } + + Color darken({double amount = 0.1}) { + return Color.alphaBlend( + Colors.black.withOpacity(amount), + this, + ); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 2340ed70d2..916c1ad3d3 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -65,6 +65,8 @@ Future initApp() async { } } + await fetchSystemPalette(); + // Initialize Immich Logger Service ImmichLogger(); @@ -187,6 +189,7 @@ class ImmichAppState extends ConsumerState @override Widget build(BuildContext context) { var router = ref.watch(appRouterProvider); + var immichTheme = ref.watch(immichThemeProvider); return MaterialApp( localizationsDelegates: context.localizationDelegates, @@ -196,9 +199,9 @@ class ImmichAppState extends ConsumerState home: MaterialApp.router( title: 'Immich', debugShowCheckedModeBanner: false, - themeMode: ref.watch(immichThemeProvider), - darkTheme: immichDarkTheme, - theme: immichLightTheme, + themeMode: ref.watch(immichThemeModeProvider), + darkTheme: getThemeData(colorScheme: immichTheme.dark), + theme: getThemeData(colorScheme: immichTheme.light), routeInformationParser: router.defaultRouteParser(), routerDelegate: router.delegate( navigatorObservers: () => [TabNavigationObserver(ref: ref)], diff --git a/mobile/lib/pages/backup/album_preview.page.dart b/mobile/lib/pages/backup/album_preview.page.dart index 218127ff43..5cb5d418a0 100644 --- a/mobile/lib/pages/backup/album_preview.page.dart +++ b/mobile/lib/pages/backup/album_preview.page.dart @@ -4,6 +4,8 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -46,7 +48,7 @@ class AlbumPreviewPage extends HookConsumerWidget { "ID ${album.id}", style: TextStyle( fontSize: 10, - color: Colors.grey[600], + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, ), ), diff --git a/mobile/lib/pages/backup/backup_album_selection.page.dart b/mobile/lib/pages/backup/backup_album_selection.page.dart index ecfebd3cb7..9f3e387755 100644 --- a/mobile/lib/pages/backup/backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/backup_album_selection.page.dart @@ -3,7 +3,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/widgets/backup/album_info_card.dart'; @@ -128,13 +127,12 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { album.name, style: TextStyle( fontSize: 12, - color: isDarkTheme ? Colors.black : immichBackgroundColor, + color: context.scaffoldBackgroundColor, fontWeight: FontWeight.bold, ), ), backgroundColor: Colors.red[300], - deleteIconColor: - isDarkTheme ? Colors.black : immichBackgroundColor, + deleteIconColor: context.scaffoldBackgroundColor, deleteIcon: const Icon( Icons.cancel_rounded, size: 15, diff --git a/mobile/lib/pages/backup/backup_controller.page.dart b/mobile/lib/pages/backup/backup_controller.page.dart index 89384cf97a..61a6bc1bb9 100644 --- a/mobile/lib/pages/backup/backup_controller.page.dart +++ b/mobile/lib/pages/backup/backup_controller.page.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; @@ -130,9 +131,7 @@ class BackupControllerPage extends HookConsumerWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), side: BorderSide( - color: context.isDarkTheme - ? const Color.fromARGB(255, 56, 56, 56) - : Colors.black12, + color: context.colorScheme.outlineVariant, width: 1, ), ), @@ -151,7 +150,9 @@ class BackupControllerPage extends HookConsumerWidget { children: [ Text( "backup_controller_page_to_backup", - style: context.textTheme.bodyMedium, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), ).tr(), buildSelectedAlbumName(), buildExcludedAlbumName(), diff --git a/mobile/lib/pages/common/album_options.page.dart b/mobile/lib/pages/common/album_options.page.dart index 1cc24af09c..3cc30af7a9 100644 --- a/mobile/lib/pages/common/album_options.page.dart +++ b/mobile/lib/pages/common/album_options.page.dart @@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/authentication.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; @@ -102,7 +103,7 @@ class AlbumOptionsPage extends HookConsumerWidget { } showModalBottomSheet( - backgroundColor: context.scaffoldBackgroundColor, + backgroundColor: context.colorScheme.surfaceContainer, isScrollControlled: false, context: context, builder: (context) { @@ -131,7 +132,7 @@ class AlbumOptionsPage extends HookConsumerWidget { ), subtitle: Text( album.owner.value?.email ?? "", - style: TextStyle(color: Colors.grey[600]), + style: TextStyle(color: context.colorScheme.onSurfaceSecondary), ), trailing: Text( "shared_album_section_people_owner_label", @@ -160,7 +161,9 @@ class AlbumOptionsPage extends HookConsumerWidget { ), subtitle: Text( user.email, - style: TextStyle(color: Colors.grey[600]), + style: TextStyle( + color: context.colorScheme.onSurfaceSecondary, + ), ), trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) @@ -214,7 +217,7 @@ class AlbumOptionsPage extends HookConsumerWidget { subtitle: Text( "shared_album_activity_setting_subtitle", style: context.textTheme.labelLarge?.copyWith( - color: context.textTheme.labelLarge?.color?.withAlpha(175), + color: context.colorScheme.onSurfaceSecondary, ), ).tr(), ), diff --git a/mobile/lib/pages/common/album_viewer.page.dart b/mobile/lib/pages/common/album_viewer.page.dart index e1e0419d52..33b314f3b1 100644 --- a/mobile/lib/pages/common/album_viewer.page.dart +++ b/mobile/lib/pages/common/album_viewer.page.dart @@ -14,7 +14,7 @@ import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/widgets/album/album_action_outlined_button.dart'; +import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/providers/authentication.provider.dart'; @@ -114,13 +114,13 @@ class AlbumViewerPage extends HookConsumerWidget { child: ListView( scrollDirection: Axis.horizontal, children: [ - AlbumActionOutlinedButton( + AlbumActionFilledButton( iconData: Icons.add_photo_alternate_outlined, onPressed: () => onAddPhotosPressed(album), labelText: "share_add_photos".tr(), ), if (userId == album.ownerId) - AlbumActionOutlinedButton( + AlbumActionFilledButton( iconData: Icons.person_add_alt_rounded, onPressed: () => onAddUsersPressed(album), labelText: "album_viewer_page_share_add_users".tr(), diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart index 8066835d84..fd718ee37d 100644 --- a/mobile/lib/pages/common/app_log.page.dart +++ b/mobile/lib/pages/common/app_log.page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; @@ -18,7 +19,6 @@ class AppLogPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final immichLogger = ImmichLogger(); final logMessages = useState(immichLogger.messages); - final isDarkTheme = context.isDarkTheme; Widget colorStatusIndicator(Color color) { return Column( @@ -55,13 +55,9 @@ class AppLogPage extends HookConsumerWidget { case LogLevel.INFO: return Colors.transparent; case LogLevel.SEVERE: - return isDarkTheme - ? Colors.redAccent.withOpacity(0.25) - : Colors.redAccent.withOpacity(0.075); + return Colors.redAccent.withOpacity(0.25); case LogLevel.WARNING: - return isDarkTheme - ? Colors.orangeAccent.withOpacity(0.25) - : Colors.orangeAccent.withOpacity(0.075); + return Colors.orangeAccent.withOpacity(0.25); default: return context.primaryColor.withOpacity(0.1); } @@ -120,10 +116,7 @@ class AppLogPage extends HookConsumerWidget { ), body: ListView.separated( separatorBuilder: (context, index) { - return Divider( - height: 0, - color: isDarkTheme ? Colors.white70 : Colors.grey[600], - ); + return const Divider(height: 0); }, itemCount: logMessages.value.length, itemBuilder: (context, index) { @@ -141,8 +134,9 @@ class AppLogPage extends HookConsumerWidget { minLeadingWidth: 10, title: Text( truncateLogMessage(logMessage.message, 4), - style: const TextStyle( + style: TextStyle( fontSize: 14.0, + color: context.colorScheme.onSurface, fontFamily: "Inconsolata", ), ), @@ -150,7 +144,7 @@ class AppLogPage extends HookConsumerWidget { "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}", style: TextStyle( fontSize: 12.0, - color: Colors.grey[600], + color: context.colorScheme.onSurfaceSecondary, ), ), leading: buildLeadingIcon(logMessage.level), diff --git a/mobile/lib/pages/common/app_log_detail.page.dart b/mobile/lib/pages/common/app_log_detail.page.dart index 61f510c0de..1b9af6cfcf 100644 --- a/mobile/lib/pages/common/app_log_detail.page.dart +++ b/mobile/lib/pages/common/app_log_detail.page.dart @@ -13,8 +13,6 @@ class AppLogDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - var isDarkTheme = context.isDarkTheme; - buildTextWithCopyButton(String header, String text) { return Padding( padding: const EdgeInsets.all(8.0), @@ -61,7 +59,7 @@ class AppLogDetailPage extends HookConsumerWidget { ), Container( decoration: BoxDecoration( - color: isDarkTheme ? Colors.grey[900] : Colors.grey[200], + color: context.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(15.0), ), child: Padding( @@ -100,7 +98,7 @@ class AppLogDetailPage extends HookConsumerWidget { ), Container( decoration: BoxDecoration( - color: isDarkTheme ? Colors.grey[900] : Colors.grey[200], + color: context.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(15.0), ), child: Padding( diff --git a/mobile/lib/pages/common/create_album.page.dart b/mobile/lib/pages/common/create_album.page.dart index 053057425e..1ed6885a07 100644 --- a/mobile/lib/pages/common/create_album.page.dart +++ b/mobile/lib/pages/common/create_album.page.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album_title.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/widgets/album/album_action_outlined_button.dart'; +import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; import 'package:immich_mobile/widgets/album/album_title_text_field.dart'; import 'package:immich_mobile/widgets/album/shared_album_thumbnail_image.dart'; @@ -109,20 +109,16 @@ class CreateAlbumPage extends HookConsumerWidget { if (selectedAssets.value.isEmpty) { return SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.only(top: 16, left: 18, right: 18), - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( + padding: const EdgeInsets.only(top: 16, left: 16, right: 16), + child: FilledButton.icon( + style: FilledButton.styleFrom( alignment: Alignment.centerLeft, padding: - const EdgeInsets.symmetric(vertical: 22, horizontal: 16), - side: BorderSide( - color: context.isDarkTheme - ? const Color.fromARGB(255, 63, 63, 63) - : const Color.fromARGB(255, 129, 129, 129), - ), + const EdgeInsets.symmetric(vertical: 16, horizontal: 16), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), + borderRadius: BorderRadius.circular(10), ), + backgroundColor: context.colorScheme.surfaceContainerHighest, ), onPressed: onSelectPhotosButtonPressed, icon: Icon( @@ -134,7 +130,7 @@ class CreateAlbumPage extends HookConsumerWidget { child: Text( 'create_shared_album_page_share_select_photos', style: context.textTheme.titleMedium?.copyWith( - color: context.primaryColor, + fontWeight: FontWeight.normal, ), ).tr(), ), @@ -154,7 +150,7 @@ class CreateAlbumPage extends HookConsumerWidget { child: ListView( scrollDirection: Axis.horizontal, children: [ - AlbumActionOutlinedButton( + AlbumActionFilledButton( iconData: Icons.add_photo_alternate_outlined, onPressed: onSelectPhotosButtonPressed, labelText: "share_add_photos".tr(), diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 486eeba4cd..117b0aedc0 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -49,10 +49,6 @@ class SettingsPage extends StatelessWidget { return Scaffold( appBar: AppBar( centerTitle: false, - bottom: const PreferredSize( - preferredSize: Size.fromHeight(1), - child: Divider(height: 1), - ), title: const Text('setting_pages_app_bar_settings').tr(), ), body: context.isMobile ? _MobileLayout() : _TabletLayout(), @@ -67,13 +63,18 @@ class _MobileLayout extends StatelessWidget { children: SettingSection.values .map( (s) => ListTile( - title: Text( - s.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ).tr(), + contentPadding: + const EdgeInsets.symmetric(vertical: 2.0, horizontal: 16.0), leading: Icon(s.icon), + title: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + s.title, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ).tr(), + ), onTap: () => context.pushRoute(SettingsSubRoute(section: s)), ), ) @@ -102,7 +103,7 @@ class _TabletLayout extends HookWidget { leading: Icon(s.icon), selected: s.index == selectedSection.value.index, selectedColor: context.primaryColor, - selectedTileColor: context.primaryColor.withAlpha(50), + selectedTileColor: context.themeData.highlightColor, onTap: () => selectedSection.value = s, ), ), diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index be98440349..5f03ed6871 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -20,7 +20,6 @@ class LibraryPage extends HookConsumerWidget { final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); final albums = ref.watch(albumProvider); - final isDarkTheme = context.isDarkTheme; final albumSortOption = ref.watch(albumSortByOptionsProvider); final albumSortIsReverse = ref.watch(albumSortOrderProvider); @@ -116,12 +115,7 @@ class LibraryPage extends HookConsumerWidget { width: cardSize, height: cardSize, decoration: BoxDecoration( - border: Border.all( - color: isDarkTheme - ? const Color.fromARGB(255, 53, 53, 53) - : const Color.fromARGB(255, 203, 203, 203), - ), - color: isDarkTheme ? Colors.grey[900] : Colors.grey[50], + color: context.colorScheme.surfaceContainer, borderRadius: const BorderRadius.all(Radius.circular(20)), ), child: Center( @@ -139,7 +133,9 @@ class LibraryPage extends HookConsumerWidget { ), child: Text( 'library_page_new_album', - style: context.textTheme.labelLarge, + style: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.onSurface, + ), ).tr(), ), ], @@ -156,26 +152,25 @@ class LibraryPage extends HookConsumerWidget { Function() onClick, ) { return Expanded( - child: OutlinedButton.icon( + child: FilledButton.icon( onPressed: onClick, label: Padding( padding: const EdgeInsets.only(left: 8.0), child: Text( label, style: TextStyle( - color: context.isDarkTheme - ? Colors.white - : Colors.black.withAlpha(200), + color: context.colorScheme.onSurface, ), ), ), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - backgroundColor: isDarkTheme ? Colors.grey[900] : Colors.grey[50], - side: BorderSide( - color: isDarkTheme ? Colors.grey[800]! : Colors.grey[300]!, - ), + style: FilledButton.styleFrom( + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + backgroundColor: context.colorScheme.surfaceContainer, alignment: Alignment.centerLeft, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(20)), + ), ), icon: Icon( icon, @@ -247,6 +242,7 @@ class LibraryPage extends HookConsumerWidget { Text( 'library_page_albums', style: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurface, fontWeight: FontWeight.w500, ), ).tr(), diff --git a/mobile/lib/pages/login/login.page.dart b/mobile/lib/pages/login/login.page.dart index 212145ed5a..b305b5fc53 100644 --- a/mobile/lib/pages/login/login.page.dart +++ b/mobile/lib/pages/login/login.page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/forms/login/login_form.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -39,8 +40,8 @@ class LoginPage extends HookConsumerWidget { children: [ Text( 'v${appVersion.value}', - style: const TextStyle( - color: Colors.grey, + style: TextStyle( + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, fontFamily: "Inconsolata", ), diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 2c578925c1..173115185b 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/models/search/search_curated_content.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; @@ -38,7 +39,7 @@ class SearchPage extends HookConsumerWidget { fontSize: 15.0, ); - Color categoryIconColor = context.isDarkTheme ? Colors.white : Colors.black; + Color categoryIconColor = context.colorScheme.onSurface; showNameEditModel( String personId, @@ -128,13 +129,9 @@ class SearchPage extends HookConsumerWidget { }, child: Card( elevation: 0, + color: context.colorScheme.surfaceContainerHigh, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - side: BorderSide( - color: context.isDarkTheme - ? Colors.grey[800]! - : const Color.fromARGB(255, 225, 225, 225), - ), + borderRadius: BorderRadius.circular(50), ), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Padding( @@ -144,13 +141,15 @@ class SearchPage extends HookConsumerWidget { ), child: Row( children: [ - Icon(Icons.search, color: context.primaryColor), + Icon( + Icons.search, + color: context.colorScheme.onSurfaceSecondary, + ), const SizedBox(width: 16.0), Text( "search_bar_hint", style: context.textTheme.bodyLarge?.copyWith( - color: - context.isDarkTheme ? Colors.white70 : Colors.black54, + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.w400, ), ).tr(), diff --git a/mobile/lib/pages/search/search_input.page.dart b/mobile/lib/pages/search/search_input.page.dart index 1f90f2929c..acabc75aa4 100644 --- a/mobile/lib/pages/search/search_input.page.dart +++ b/mobile/lib/pages/search/search_input.page.dart @@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; @@ -509,7 +510,7 @@ class SearchInputPage extends HookConsumerWidget { ? 'contextual_search'.tr() : 'filename_search'.tr(), hintStyle: context.textTheme.bodyLarge?.copyWith( - color: context.themeData.colorScheme.onSurface.withOpacity(0.75), + color: context.themeData.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.w500, ), enabledBorder: const UnderlineInputBorder( diff --git a/mobile/lib/pages/sharing/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/sharing/shared_link/shared_link_edit.page.dart index 6223e110e1..5ed85932f8 100644 --- a/mobile/lib/pages/sharing/shared_link/shared_link_edit.page.dart +++ b/mobile/lib/pages/sharing/shared_link/shared_link_edit.page.dart @@ -30,6 +30,7 @@ class SharedLinkEditPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { const padding = 20.0; final themeData = context.themeData; + final colorScheme = context.colorScheme; final descriptionController = useTextEditingController(text: existingLink?.description ?? ""); final descriptionFocusNode = useFocusNode(); @@ -58,7 +59,7 @@ class SharedLinkEditPage extends HookConsumerWidget { Text( existingLink!.title, style: TextStyle( - color: themeData.primaryColor, + color: colorScheme.primary, fontWeight: FontWeight.bold, ), ), @@ -81,7 +82,7 @@ class SharedLinkEditPage extends HookConsumerWidget { child: Text( existingLink!.description ?? "--", style: TextStyle( - color: themeData.primaryColor, + color: colorScheme.primary, fontWeight: FontWeight.bold, ), overflow: TextOverflow.ellipsis, @@ -109,7 +110,7 @@ class SharedLinkEditPage extends HookConsumerWidget { labelText: 'shared_link_edit_description'.tr(), labelStyle: TextStyle( fontWeight: FontWeight.bold, - color: themeData.primaryColor, + color: colorScheme.primary, ), floatingLabelBehavior: FloatingLabelBehavior.always, border: const OutlineInputBorder(), @@ -135,7 +136,7 @@ class SharedLinkEditPage extends HookConsumerWidget { labelText: 'shared_link_edit_password'.tr(), labelStyle: TextStyle( fontWeight: FontWeight.bold, - color: themeData.primaryColor, + color: colorScheme.primary, ), floatingLabelBehavior: FloatingLabelBehavior.always, border: const OutlineInputBorder(), @@ -157,7 +158,7 @@ class SharedLinkEditPage extends HookConsumerWidget { onChanged: newShareLink.value.isEmpty ? (value) => showMetadata.value = value : null, - activeColor: themeData.primaryColor, + activeColor: colorScheme.primary, dense: true, title: Text( "shared_link_edit_show_meta", @@ -173,7 +174,7 @@ class SharedLinkEditPage extends HookConsumerWidget { onChanged: newShareLink.value.isEmpty ? (value) => allowDownload.value = value : null, - activeColor: themeData.primaryColor, + activeColor: colorScheme.primary, dense: true, title: Text( "shared_link_edit_allow_download", @@ -189,7 +190,7 @@ class SharedLinkEditPage extends HookConsumerWidget { onChanged: newShareLink.value.isEmpty ? (value) => allowUpload.value = value : null, - activeColor: themeData.primaryColor, + activeColor: colorScheme.primary, dense: true, title: Text( "shared_link_edit_allow_upload", @@ -205,7 +206,7 @@ class SharedLinkEditPage extends HookConsumerWidget { onChanged: newShareLink.value.isEmpty ? (value) => editExpiry.value = value : null, - activeColor: themeData.primaryColor, + activeColor: colorScheme.primary, dense: true, title: Text( "shared_link_edit_change_expiry", @@ -221,7 +222,7 @@ class SharedLinkEditPage extends HookConsumerWidget { "shared_link_edit_expire_after", style: TextStyle( fontWeight: FontWeight.bold, - color: themeData.primaryColor, + color: colorScheme.primary, ), ).tr(), enableSearch: false, diff --git a/mobile/lib/pages/sharing/sharing.page.dart b/mobile/lib/pages/sharing/sharing.page.dart index 45148945ed..98d4cfafe9 100644 --- a/mobile/lib/pages/sharing/sharing.page.dart +++ b/mobile/lib/pages/sharing/sharing.page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; @@ -83,20 +84,24 @@ class SharingPage extends HookConsumerWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: context.textTheme.bodyMedium?.copyWith( - color: context.primaryColor, + color: context.colorScheme.onSurface, fontWeight: FontWeight.w500, ), ), subtitle: isOwner ? Text( 'album_thumbnail_owned'.tr(), - style: context.textTheme.bodyMedium, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), ) : album.ownerName != null ? Text( 'album_thumbnail_shared_by' .tr(args: [album.ownerName!]), - style: context.textTheme.bodyMedium, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), ) : null, onTap: () => context @@ -166,11 +171,13 @@ class SharingPage extends HookConsumerWidget { padding: const EdgeInsets.all(8.0), child: Card( elevation: 0, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(20)), + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(20)), side: BorderSide( - color: Colors.grey, - width: 0.5, + color: context.isDarkTheme + ? const Color(0xFF383838) + : Colors.black12, + width: 1, ), ), child: Padding( diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index fd6c2d89a7..bd25403215 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/entities/store.entity.dart'; enum AppSettingsEnum { @@ -8,6 +9,21 @@ enum AppSettingsEnum { "themeMode", "system", ), // "light","dark","system" + primaryColor( + StoreKey.primaryColor, + "primaryColor", + defaultColorPresetName, + ), + dynamicTheme( + StoreKey.dynamicTheme, + "dynamicTheme", + false, + ), + colorfulInterface( + StoreKey.colorfulInterface, + "colorfulInterface", + true, + ), tilesPerRow(StoreKey.tilesPerRow, "tilesPerRow", 4), dynamicLayout(StoreKey.dynamicLayout, "dynamicLayout", false), groupAssetsBy(StoreKey.groupAssetsBy, "groupBy", 0), diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index 32a26439d5..d61eba73b2 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -1,10 +1,22 @@ +import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -final immichThemeProvider = StateProvider((ref) { +class ImmichTheme { + ColorScheme light; + ColorScheme dark; + + ImmichTheme({required this.light, required this.dark}); +} + +ImmichTheme? _immichDynamicTheme; +bool get isDynamicThemeAvailable => _immichDynamicTheme != null; + +final immichThemeModeProvider = StateProvider((ref) { var themeMode = ref .watch(appSettingsServiceProvider) .getSetting(AppSettingsEnum.themeMode); @@ -20,266 +32,241 @@ final immichThemeProvider = StateProvider((ref) { } }); -final ThemeData base = ThemeData( - chipTheme: const ChipThemeData( - side: BorderSide.none, - ), - sliderTheme: const SliderThemeData( - thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), - trackHeight: 2.0, - ), -); +final immichThemePresetProvider = StateProvider((ref) { + var appSettingsProvider = ref.watch(appSettingsServiceProvider); + var primaryColorName = + appSettingsProvider.getSetting(AppSettingsEnum.primaryColor); -final ThemeData immichLightTheme = ThemeData( - useMaterial3: true, - brightness: Brightness.light, - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.indigo, - ), - primarySwatch: Colors.indigo, - primaryColor: Colors.indigo, - hintColor: Colors.indigo, - focusColor: Colors.indigo, - splashColor: Colors.indigo.withOpacity(0.15), - fontFamily: 'Overpass', - scaffoldBackgroundColor: immichBackgroundColor, - snackBarTheme: const SnackBarThemeData( - contentTextStyle: TextStyle( - fontFamily: 'Overpass', - color: Colors.indigo, - fontWeight: FontWeight.bold, - ), - backgroundColor: Colors.white, - ), - appBarTheme: const AppBarTheme( - titleTextStyle: TextStyle( - fontFamily: 'Overpass', - color: Colors.indigo, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - backgroundColor: immichBackgroundColor, - foregroundColor: Colors.indigo, - elevation: 0, - scrolledUnderElevation: 0, - centerTitle: true, - ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - type: BottomNavigationBarType.fixed, - backgroundColor: immichBackgroundColor, - selectedItemColor: Colors.indigo, - ), - cardTheme: const CardTheme( - surfaceTintColor: Colors.transparent, - ), - drawerTheme: const DrawerThemeData( - backgroundColor: immichBackgroundColor, - ), - textTheme: const TextTheme( - displayLarge: TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - color: Colors.indigo, - ), - displayMedium: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - displaySmall: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.indigo, - ), - titleSmall: TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.bold, - ), - titleMedium: TextStyle( - fontSize: 18.0, - fontWeight: FontWeight.bold, - ), - titleLarge: TextStyle( - fontSize: 26.0, - fontWeight: FontWeight.bold, - ), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - foregroundColor: Colors.white, - ), - ), - chipTheme: base.chipTheme, - sliderTheme: base.sliderTheme, - popupMenuTheme: const PopupMenuThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(10)), - ), - surfaceTintColor: Colors.transparent, - color: Colors.white, - ), - navigationBarTheme: NavigationBarThemeData( - indicatorColor: Colors.indigo.withOpacity(0.15), - iconTheme: WidgetStatePropertyAll( - IconThemeData(color: Colors.grey[700]), - ), - backgroundColor: immichBackgroundColor, - surfaceTintColor: Colors.transparent, - labelTextStyle: WidgetStatePropertyAll( - TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Colors.grey[800], - ), - ), - ), - dialogTheme: const DialogTheme( - surfaceTintColor: Colors.transparent, - ), - inputDecorationTheme: const InputDecorationTheme( - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Colors.indigo, - ), - ), - labelStyle: TextStyle( - color: Colors.indigo, - ), - hintStyle: TextStyle( - fontSize: 14.0, - fontWeight: FontWeight.normal, - ), - ), - textSelectionTheme: const TextSelectionThemeData( - cursorColor: Colors.indigo, - ), -); + debugPrint("Current theme preset $primaryColorName"); -final ThemeData immichDarkTheme = ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - primarySwatch: Colors.indigo, - primaryColor: immichDarkThemePrimaryColor, - colorScheme: ColorScheme.fromSeed( - seedColor: immichDarkThemePrimaryColor, - brightness: Brightness.dark, - ), - scaffoldBackgroundColor: immichDarkBackgroundColor, - hintColor: Colors.grey[600], - fontFamily: 'Overpass', - snackBarTheme: SnackBarThemeData( - contentTextStyle: const TextStyle( - fontFamily: 'Overpass', - color: immichDarkThemePrimaryColor, - fontWeight: FontWeight.bold, + try { + return ImmichColorPreset.values + .firstWhere((e) => e.name == primaryColorName); + } catch (e) { + debugPrint( + "Theme preset $primaryColorName not found. Applying default preset.", + ); + appSettingsProvider.setSetting( + AppSettingsEnum.primaryColor, + defaultColorPresetName, + ); + return defaultColorPreset; + } +}); + +final dynamicThemeSettingProvider = StateProvider((ref) { + return ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.dynamicTheme); +}); + +final colorfulInterfaceSettingProvider = StateProvider((ref) { + return ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.colorfulInterface); +}); + +// Provider for current selected theme +final immichThemeProvider = StateProvider((ref) { + var primaryColor = ref.read(immichThemePresetProvider); + var useSystemColor = ref.watch(dynamicThemeSettingProvider); + var useColorfulInterface = ref.watch(colorfulInterfaceSettingProvider); + + var currentTheme = (useSystemColor && _immichDynamicTheme != null) + ? _immichDynamicTheme! + : primaryColor.getTheme(); + + return useColorfulInterface + ? currentTheme + : _decolorizeSurfaces(theme: currentTheme); +}); + +// Method to fetch dynamic system colors +Future fetchSystemPalette() async { + try { + final corePalette = await DynamicColorPlugin.getCorePalette(); + if (corePalette != null) { + final primaryColor = corePalette.toColorScheme().primary; + debugPrint('dynamic_color: Core palette detected.'); + + // Some palettes do not generate surface container colors accurately, + // so we regenerate all colors using the primary color + _immichDynamicTheme = ImmichTheme( + light: ColorScheme.fromSeed( + seedColor: primaryColor, + brightness: Brightness.light, + ), + dark: ColorScheme.fromSeed( + seedColor: primaryColor, + brightness: Brightness.dark, + ), + ); + } + } catch (e) { + debugPrint('dynamic_color: Failed to obtain core palette.'); + } +} + +// This method replaces all surface shades in ImmichTheme to a static ones +// as we are creating the colorscheme through seedColor the default surfaces are +// tinted with primary color +ImmichTheme _decolorizeSurfaces({ + required ImmichTheme theme, +}) { + return ImmichTheme( + light: theme.light.copyWith( + surface: const Color(0xFFf9f9f9), + onSurface: const Color(0xFF1b1b1b), + surfaceContainerLowest: const Color(0xFFffffff), + surfaceContainerLow: const Color(0xFFf3f3f3), + surfaceContainer: const Color(0xFFeeeeee), + surfaceContainerHigh: const Color(0xFFe8e8e8), + surfaceContainerHighest: const Color(0xFFe2e2e2), + surfaceDim: const Color(0xFFdadada), + surfaceBright: const Color(0xFFf9f9f9), + onSurfaceVariant: const Color(0xFF4c4546), + inverseSurface: const Color(0xFF303030), + onInverseSurface: const Color(0xFFf1f1f1), ), - backgroundColor: Colors.grey[900], - ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: immichDarkThemePrimaryColor, + dark: theme.dark.copyWith( + surface: const Color(0xFF131313), + onSurface: const Color(0xFFE2E2E2), + surfaceContainerLowest: const Color(0xFF0E0E0E), + surfaceContainerLow: const Color(0xFF1B1B1B), + surfaceContainer: const Color(0xFF1F1F1F), + surfaceContainerHigh: const Color(0xFF242424), + surfaceContainerHighest: const Color(0xFF2E2E2E), + surfaceDim: const Color(0xFF131313), + surfaceBright: const Color(0xFF353535), + onSurfaceVariant: const Color(0xFFCfC4C5), + inverseSurface: const Color(0xFFE2E2E2), + onInverseSurface: const Color(0xFF303030), ), - ), - appBarTheme: const AppBarTheme( - titleTextStyle: TextStyle( - fontFamily: 'Overpass', - color: immichDarkThemePrimaryColor, - fontWeight: FontWeight.bold, - fontSize: 18, + ); +} + +ThemeData getThemeData({required ColorScheme colorScheme}) { + var isDark = colorScheme.brightness == Brightness.dark; + var primaryColor = colorScheme.primary; + + return ThemeData( + useMaterial3: true, + brightness: isDark ? Brightness.dark : Brightness.light, + colorScheme: colorScheme, + primaryColor: primaryColor, + hintColor: colorScheme.onSurfaceSecondary, + focusColor: primaryColor, + scaffoldBackgroundColor: colorScheme.surface, + splashColor: primaryColor.withOpacity(0.1), + highlightColor: primaryColor.withOpacity(0.1), + dialogBackgroundColor: colorScheme.surfaceContainer, + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: colorScheme.surfaceContainer, ), - backgroundColor: Color.fromARGB(255, 32, 33, 35), - foregroundColor: immichDarkThemePrimaryColor, - elevation: 0, - scrolledUnderElevation: 0, - centerTitle: true, - ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - type: BottomNavigationBarType.fixed, - backgroundColor: Color.fromARGB(255, 35, 36, 37), - selectedItemColor: immichDarkThemePrimaryColor, - ), - drawerTheme: DrawerThemeData( - backgroundColor: immichDarkBackgroundColor, - scrimColor: Colors.white.withOpacity(0.1), - ), - textTheme: const TextTheme( - displayLarge: TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - color: Color.fromARGB(255, 255, 255, 255), + fontFamily: 'Overpass', + snackBarTheme: SnackBarThemeData( + contentTextStyle: TextStyle( + fontFamily: 'Overpass', + color: primaryColor, + fontWeight: FontWeight.bold, + ), + backgroundColor: colorScheme.surfaceContainerHighest, ), - displayMedium: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Color.fromARGB(255, 255, 255, 255), + appBarTheme: AppBarTheme( + titleTextStyle: TextStyle( + color: primaryColor, + fontFamily: 'Overpass', + fontWeight: FontWeight.bold, + fontSize: 18, + ), + backgroundColor: + isDark ? colorScheme.surfaceContainer : colorScheme.surface, + foregroundColor: primaryColor, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, ), - displaySmall: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: immichDarkThemePrimaryColor, - ), - titleSmall: TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.bold, - ), - titleMedium: TextStyle( - fontSize: 18.0, - fontWeight: FontWeight.bold, - ), - titleLarge: TextStyle( - fontSize: 26.0, - fontWeight: FontWeight.bold, - ), - ), - cardColor: Colors.grey[900], - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - foregroundColor: Colors.black87, - backgroundColor: immichDarkThemePrimaryColor, - ), - ), - chipTheme: base.chipTheme, - sliderTheme: base.sliderTheme, - popupMenuTheme: const PopupMenuThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(10)), - ), - surfaceTintColor: Colors.transparent, - ), - navigationBarTheme: NavigationBarThemeData( - indicatorColor: immichDarkThemePrimaryColor.withOpacity(0.4), - iconTheme: WidgetStatePropertyAll( - IconThemeData(color: Colors.grey[500]), - ), - backgroundColor: Colors.grey[900], - surfaceTintColor: Colors.transparent, - labelTextStyle: WidgetStatePropertyAll( - TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Colors.grey[300], + textTheme: TextTheme( + displayLarge: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : primaryColor, + ), + displayMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : Colors.black87, + ), + displaySmall: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: primaryColor, + ), + titleSmall: const TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.bold, + ), + titleMedium: const TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.bold, + ), + titleLarge: const TextStyle( + fontSize: 26.0, + fontWeight: FontWeight.bold, ), ), - ), - dialogTheme: const DialogTheme( - surfaceTintColor: Colors.transparent, - ), - inputDecorationTheme: const InputDecorationTheme( - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: immichDarkThemePrimaryColor, + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: isDark ? Colors.black87 : Colors.white, ), ), - labelStyle: TextStyle( - color: immichDarkThemePrimaryColor, + chipTheme: const ChipThemeData( + side: BorderSide.none, ), - hintStyle: TextStyle( - fontSize: 14.0, - fontWeight: FontWeight.normal, + sliderTheme: const SliderThemeData( + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), + trackHeight: 2.0, ), - ), - textSelectionTheme: const TextSelectionThemeData( - cursorColor: immichDarkThemePrimaryColor, - ), -); + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + type: BottomNavigationBarType.fixed, + ), + popupMenuTheme: const PopupMenuThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: + isDark ? colorScheme.surfaceContainer : colorScheme.surface, + labelTextStyle: const WidgetStatePropertyAll( + TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: primaryColor, + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), + ), + labelStyle: TextStyle( + color: primaryColor, + ), + hintStyle: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.normal, + ), + ), + textSelectionTheme: TextSelectionThemeData( + cursorColor: primaryColor, + ), + ); +} diff --git a/mobile/lib/widgets/album/album_action_outlined_button.dart b/mobile/lib/widgets/album/album_action_filled_button.dart similarity index 70% rename from mobile/lib/widgets/album/album_action_outlined_button.dart rename to mobile/lib/widgets/album/album_action_filled_button.dart index 02676ae6e2..6a466aa4f1 100644 --- a/mobile/lib/widgets/album/album_action_outlined_button.dart +++ b/mobile/lib/widgets/album/album_action_filled_button.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -class AlbumActionOutlinedButton extends StatelessWidget { +class AlbumActionFilledButton extends StatelessWidget { final VoidCallback? onPressed; final String labelText; final IconData iconData; - const AlbumActionOutlinedButton({ + const AlbumActionFilledButton({ super.key, this.onPressed, required this.labelText, @@ -17,18 +17,13 @@ class AlbumActionOutlinedButton extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(right: 16.0), - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( + child: FilledButton.icon( + style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 10), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(25), ), - side: BorderSide( - width: 1, - color: context.isDarkTheme - ? const Color.fromARGB(255, 63, 63, 63) - : const Color.fromARGB(255, 206, 206, 206), - ), + backgroundColor: context.colorScheme.surfaceContainerHigh, ), icon: Icon( iconData, diff --git a/mobile/lib/widgets/album/album_thumbnail_card.dart b/mobile/lib/widgets/album/album_thumbnail_card.dart index 737e8b383f..42fa55cdd4 100644 --- a/mobile/lib/widgets/album/album_thumbnail_card.dart +++ b/mobile/lib/widgets/album/album_thumbnail_card.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; class AlbumThumbnailCard extends StatelessWidget { @@ -23,8 +24,6 @@ class AlbumThumbnailCard extends StatelessWidget { @override Widget build(BuildContext context) { - var isDarkTheme = context.isDarkTheme; - return LayoutBuilder( builder: (context, constraints) { var cardSize = constraints.maxWidth; @@ -34,12 +33,13 @@ class AlbumThumbnailCard extends StatelessWidget { height: cardSize, width: cardSize, decoration: BoxDecoration( - color: isDarkTheme ? Colors.grey[800] : Colors.grey[200], + color: context.colorScheme.surfaceContainerHigh, ), child: Center( child: Icon( Icons.no_photography, size: cardSize * .15, + color: context.colorScheme.primary, ), ), ); @@ -65,6 +65,9 @@ class AlbumThumbnailCard extends StatelessWidget { return RichText( overflow: TextOverflow.fade, text: TextSpan( + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), children: [ TextSpan( text: album.assetCount == 1 @@ -72,14 +75,9 @@ class AlbumThumbnailCard extends StatelessWidget { .tr(args: ['${album.assetCount}']) : 'album_thumbnail_card_items' .tr(args: ['${album.assetCount}']), - style: context.textTheme.bodyMedium, ), if (owner != null) const TextSpan(text: ' · '), - if (owner != null) - TextSpan( - text: owner, - style: context.textTheme.bodyMedium, - ), + if (owner != null) TextSpan(text: owner), ], ), ); @@ -112,7 +110,7 @@ class AlbumThumbnailCard extends StatelessWidget { album.name, overflow: TextOverflow.ellipsis, style: context.textTheme.bodyMedium?.copyWith( - color: context.primaryColor, + color: context.colorScheme.onSurface, fontWeight: FontWeight.w500, ), ), diff --git a/mobile/lib/widgets/album/album_title_text_field.dart b/mobile/lib/widgets/album/album_title_text_field.dart index 8715c0c038..d005a96417 100644 --- a/mobile/lib/widgets/album/album_title_text_field.dart +++ b/mobile/lib/widgets/album/album_title_text_field.dart @@ -20,8 +20,6 @@ class AlbumTitleTextField extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isDarkTheme = context.isDarkTheme; - return TextField( onChanged: (v) { if (v.isEmpty) { @@ -35,7 +33,7 @@ class AlbumTitleTextField extends ConsumerWidget { focusNode: albumTitleTextFieldFocusNode, style: TextStyle( fontSize: 28, - color: isDarkTheme ? Colors.grey[300] : Colors.grey[700], + color: context.colorScheme.onSurface, fontWeight: FontWeight.bold, ), controller: albumTitleController, @@ -61,24 +59,18 @@ class AlbumTitleTextField extends ConsumerWidget { splashRadius: 10, ) : null, - enabledBorder: OutlineInputBorder( - borderSide: const BorderSide(color: Colors.transparent), - borderRadius: BorderRadius.circular(10), + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), ), - focusedBorder: OutlineInputBorder( - borderSide: const BorderSide(color: Colors.transparent), - borderRadius: BorderRadius.circular(10), + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), ), hintText: 'share_add_title'.tr(), - hintStyle: TextStyle( + hintStyle: context.themeData.inputDecorationTheme.hintStyle?.copyWith( fontSize: 28, - color: isDarkTheme ? Colors.grey[300] : Colors.grey[700], - fontWeight: FontWeight.bold, ), focusColor: Colors.grey[300], - fillColor: isDarkTheme - ? const Color.fromARGB(255, 32, 33, 35) - : Colors.grey[200], + fillColor: context.scaffoldBackgroundColor, filled: isAlbumTitleTextFieldFocus.value, ), ); diff --git a/mobile/lib/widgets/album/album_viewer_appbar.dart b/mobile/lib/widgets/album/album_viewer_appbar.dart index 6fb58f8082..1067d7241e 100644 --- a/mobile/lib/widgets/album/album_viewer_appbar.dart +++ b/mobile/lib/widgets/album/album_viewer_appbar.dart @@ -95,7 +95,7 @@ class AlbumViewerAppbar extends HookConsumerWidget 'action_common_confirm', style: TextStyle( fontWeight: FontWeight.bold, - color: !context.isDarkTheme ? Colors.red : Colors.red[300], + color: context.colorScheme.error, ), ).tr(), ), diff --git a/mobile/lib/widgets/album/album_viewer_editable_title.dart b/mobile/lib/widgets/album/album_viewer_editable_title.dart index 788c61d8a4..59e09aa050 100644 --- a/mobile/lib/widgets/album/album_viewer_editable_title.dart +++ b/mobile/lib/widgets/album/album_viewer_editable_title.dart @@ -73,24 +73,18 @@ class AlbumViewerEditableTitle extends HookConsumerWidget { splashRadius: 10, ) : null, - enabledBorder: OutlineInputBorder( - borderSide: const BorderSide(color: Colors.transparent), - borderRadius: BorderRadius.circular(10), + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), ), - focusedBorder: OutlineInputBorder( - borderSide: const BorderSide(color: Colors.transparent), - borderRadius: BorderRadius.circular(10), + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), ), focusColor: Colors.grey[300], - fillColor: context.isDarkTheme - ? const Color.fromARGB(255, 32, 33, 35) - : Colors.grey[200], + fillColor: context.scaffoldBackgroundColor, filled: titleFocusNode.hasFocus, hintText: 'share_add_title'.tr(), - hintStyle: TextStyle( + hintStyle: context.themeData.inputDecorationTheme.hintStyle?.copyWith( fontSize: 28, - color: context.isDarkTheme ? Colors.grey[300] : Colors.grey[700], - fontWeight: FontWeight.bold, ), ), ), diff --git a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart index 060e0bc04e..e6d769a3d7 100644 --- a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart +++ b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart @@ -281,7 +281,7 @@ class ControlBottomAppBar extends HookConsumerWidget { ScrollController scrollController, ) { return Card( - color: context.isDarkTheme ? Colors.grey[900] : Colors.grey[100], + color: context.colorScheme.surfaceContainerLow, surfaceTintColor: Colors.transparent, elevation: 18.0, shape: const RoundedRectangleBorder( diff --git a/mobile/lib/widgets/asset_grid/disable_multi_select_button.dart b/mobile/lib/widgets/asset_grid/disable_multi_select_button.dart index 9d26745b16..50b38c2a4a 100644 --- a/mobile/lib/widgets/asset_grid/disable_multi_select_button.dart +++ b/mobile/lib/widgets/asset_grid/disable_multi_select_button.dart @@ -22,12 +22,15 @@ class DisableMultiSelectButton extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 4.0), child: ElevatedButton.icon( onPressed: () => onPressed(), - icon: const Icon(Icons.close_rounded), + icon: Icon( + Icons.close_rounded, + color: context.colorScheme.onPrimary, + ), label: Text( '$selectedItemCount', style: context.textTheme.titleMedium?.copyWith( height: 2.5, - color: context.isDarkTheme ? Colors.black : Colors.white, + color: context.colorScheme.onPrimary, ), ), ), diff --git a/mobile/lib/widgets/asset_grid/group_divider_title.dart b/mobile/lib/widgets/asset_grid/group_divider_title.dart index 4c1f468343..3a411c09db 100644 --- a/mobile/lib/widgets/asset_grid/group_divider_title.dart +++ b/mobile/lib/widgets/asset_grid/group_divider_title.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; @@ -74,9 +75,9 @@ class GroupDividerTitle extends HookConsumerWidget { Icons.check_circle_rounded, color: context.primaryColor, ) - : const Icon( + : Icon( Icons.check_circle_outline_rounded, - color: Colors.grey, + color: context.colorScheme.onSurfaceSecondary, ), ), ], diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 906d0e5969..ea65031a0c 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -11,6 +11,7 @@ import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart'; @@ -266,7 +267,9 @@ class ImmichAssetGridViewState extends ConsumerState { scrollStateListener: dragScrolling, itemPositionsListener: _itemPositionsListener, controller: _itemScrollController, - backgroundColor: context.themeData.hintColor, + backgroundColor: context.isDarkTheme + ? context.colorScheme.primary.darken(amount: .5) + : context.colorScheme.primary, labelTextBuilder: _labelBuilder, padding: appBarOffset() ? const EdgeInsets.only(top: 60) diff --git a/mobile/lib/widgets/asset_grid/thumbnail_image.dart b/mobile/lib/widgets/asset_grid/thumbnail_image.dart index d9c9aa0566..2480f44278 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_image.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_image.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; import 'package:immich_mobile/utils/storage_indicator.dart'; import 'package:isar/isar.dart'; @@ -42,8 +43,8 @@ class ThumbnailImage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final assetContainerColor = context.isDarkTheme - ? Colors.blueGrey - : context.themeData.primaryColorLight; + ? context.primaryColor.darken(amount: 0.6) + : context.primaryColor.lighten(amount: 0.8); // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id final isFromDto = asset.id == Isar.autoIncrement; @@ -192,8 +193,8 @@ class ThumbnailImage extends ConsumerWidget { bottom: 5, child: Icon( storageIcon(asset), - color: Colors.white, - size: 18, + color: Colors.white.withOpacity(.8), + size: 16, ), ), if (asset.isFavorite) diff --git a/mobile/lib/widgets/asset_grid/thumbnail_placeholder.dart b/mobile/lib/widgets/asset_grid/thumbnail_placeholder.dart index d762704835..5b12426a50 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_placeholder.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_placeholder.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; class ThumbnailPlaceholder extends StatelessWidget { final EdgeInsets margin; @@ -13,25 +14,20 @@ class ThumbnailPlaceholder extends StatelessWidget { this.height = 250, }); - static const _brightColors = [ - Color(0xFFF1F3F4), - Color(0xFFB4B6B8), - ]; - - static const _darkColors = [ - Color(0xFF3B3F42), - Color(0xFF2B2F32), - ]; - @override Widget build(BuildContext context) { + var gradientColors = [ + context.colorScheme.surfaceContainer, + context.colorScheme.surfaceContainer.darken(amount: .1), + ]; + return Container( width: width, height: height, margin: margin, decoration: BoxDecoration( gradient: LinearGradient( - colors: context.isDarkTheme ? _darkColors : _brightColors, + colors: gradientColors, begin: Alignment.topCenter, end: Alignment.bottomCenter, ), diff --git a/mobile/lib/widgets/asset_viewer/description_input.dart b/mobile/lib/widgets/asset_viewer/description_input.dart index 7422e43335..1a91d1614b 100644 --- a/mobile/lib/widgets/asset_viewer/description_input.dart +++ b/mobile/lib/widgets/asset_viewer/description_input.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/asset_description.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -23,7 +24,6 @@ class DescriptionInput extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final textColor = context.isDarkTheme ? Colors.white : Colors.black; final controller = useTextEditingController(); final focusNode = useFocusNode(); final isFocus = useState(false); @@ -71,7 +71,7 @@ class DescriptionInput extends HookConsumerWidget { }, icon: Icon( Icons.cancel_rounded, - color: Colors.grey[500], + color: context.colorScheme.onSurfaceSecondary, ), splashRadius: 10, ); @@ -100,9 +100,6 @@ class DescriptionInput extends HookConsumerWidget { decoration: InputDecoration( hintText: 'description_input_hint_text'.tr(), border: InputBorder.none, - hintStyle: context.textTheme.labelLarge?.copyWith( - color: textColor.withOpacity(0.5), - ), suffixIcon: suffixIcon, ), ); diff --git a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart b/mobile/lib/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart index a0505e3d48..ae32c133c3 100644 --- a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart +++ b/mobile/lib/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart @@ -22,7 +22,7 @@ class ExifBottomSheet extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final assetWithExif = ref.watch(assetDetailProvider(asset)); - var textColor = context.isDarkTheme ? Colors.white : Colors.black; + var textColor = context.colorScheme.onSurface; final ExifInfo? exifInfo = (assetWithExif.value ?? asset).exifInfo; // Format the date time with the timezone final (dt, timeZone) = diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index 70fd5e3b89..2157a1aebb 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -178,6 +178,7 @@ class TopControlAppBar extends HookConsumerWidget { actionsIconTheme: const IconThemeData( size: iconSize, ), + shape: const Border(), actions: [ if (asset.isRemote && isOwner) buildFavoriteButton(a), if (asset.livePhotoVideoId != null) buildLivePhotoButton(), diff --git a/mobile/lib/widgets/backup/album_info_list_tile.dart b/mobile/lib/widgets/backup/album_info_list_tile.dart index 2e10fe0b75..7cdc595c7f 100644 --- a/mobile/lib/widgets/backup/album_info_list_tile.dart +++ b/mobile/lib/widgets/backup/album_info_list_tile.dart @@ -47,22 +47,22 @@ class AlbumInfoListTile extends HookConsumerWidget { buildIcon() { if (isSelected) { - return const Icon( + return Icon( Icons.check_circle_rounded, - color: Colors.green, + color: context.colorScheme.primary, ); } if (isExcluded) { - return const Icon( + return Icon( Icons.remove_circle_rounded, - color: Colors.red, + color: context.colorScheme.error, ); } return Icon( Icons.circle, - color: context.isDarkTheme ? Colors.grey[400] : Colors.black45, + color: context.colorScheme.surfaceContainerHighest, ); } diff --git a/mobile/lib/widgets/backup/backup_info_card.dart b/mobile/lib/widgets/backup/backup_info_card.dart index e1b56a970a..58fc89cb65 100644 --- a/mobile/lib/widgets/backup/backup_info_card.dart +++ b/mobile/lib/widgets/backup/backup_info_card.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; class BackupInfoCard extends StatelessWidget { final String title; @@ -19,9 +20,7 @@ class BackupInfoCard extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), // if you need this side: BorderSide( - color: context.isDarkTheme - ? const Color.fromARGB(255, 56, 56, 56) - : Colors.black12, + color: context.colorScheme.outlineVariant, width: 1, ), ), @@ -38,7 +37,9 @@ class BackupInfoCard extends StatelessWidget { padding: const EdgeInsets.only(top: 4.0, right: 18.0), child: Text( subtitle, - style: context.textTheme.bodyMedium, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), ), ), trailing: Column( diff --git a/mobile/lib/widgets/backup/current_backup_asset_info_box.dart b/mobile/lib/widgets/backup/current_backup_asset_info_box.dart index 2520acedf1..8e58905aaa 100644 --- a/mobile/lib/widgets/backup/current_backup_asset_info_box.dart +++ b/mobile/lib/widgets/backup/current_backup_asset_info_box.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; @@ -82,22 +83,20 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { Widget buildAssetInfoTable() { return Table( border: TableBorder.all( - color: context.themeData.primaryColorLight, + color: context.colorScheme.outlineVariant, width: 1, ), children: [ TableRow( - decoration: const BoxDecoration( - // color: Colors.grey[100], - ), children: [ TableCell( verticalAlignment: TableCellVerticalAlignment.middle, child: Padding( padding: const EdgeInsets.all(6.0), - child: const Text( + child: Text( 'backup_controller_page_filename', style: TextStyle( + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, fontSize: 10.0, ), @@ -109,17 +108,15 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { ], ), TableRow( - decoration: const BoxDecoration( - // color: Colors.grey[200], - ), children: [ TableCell( verticalAlignment: TableCellVerticalAlignment.middle, child: Padding( padding: const EdgeInsets.all(6.0), - child: const Text( + child: Text( "backup_controller_page_created", style: TextStyle( + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, fontSize: 10.0, ), @@ -131,16 +128,14 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { ], ), TableRow( - decoration: const BoxDecoration( - // color: Colors.grey[100], - ), children: [ TableCell( child: Padding( padding: const EdgeInsets.all(6.0), - child: const Text( + child: Text( "backup_controller_page_id", style: TextStyle( + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, fontSize: 10.0, ), @@ -181,8 +176,7 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { child: LinearProgressIndicator( minHeight: 10.0, value: uploadProgress / 100.0, - backgroundColor: Colors.grey, - color: context.primaryColor, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), ), ), Text( @@ -214,8 +208,7 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { child: LinearProgressIndicator( minHeight: 10.0, value: uploadProgress / 100.0, - backgroundColor: Colors.grey, - color: context.primaryColor, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), ), ), Text( diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index fbcfd64713..5b6e60b1db 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -57,6 +57,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { ? 'assets/immich-text-dark.png' : 'assets/immich-text-light.png', height: 16, + color: context.primaryColor, ), ), ], @@ -88,7 +89,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { buildSettingButton() { return buildActionButton( - Icons.settings_rounded, + Icons.settings_outlined, "profile_drawer_settings", () => context.pushRoute(const SettingsRoute()), ); @@ -146,9 +147,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { child: Container( padding: const EdgeInsets.symmetric(vertical: 4), decoration: BoxDecoration( - color: context.isDarkTheme - ? context.scaffoldBackgroundColor - : const Color.fromARGB(255, 225, 229, 240), + color: context.colorScheme.surface, ), child: ListTile( minLeadingWidth: 50, @@ -171,10 +170,10 @@ class ImmichAppBarDialog extends HookConsumerWidget { Padding( padding: const EdgeInsets.only(top: 8.0), child: LinearProgressIndicator( - minHeight: 5.0, + minHeight: 10.0, value: percentage, - backgroundColor: Colors.grey, - color: theme.primaryColor, + borderRadius: + const BorderRadius.all(Radius.circular(10.0)), ), ), Padding( @@ -248,7 +247,6 @@ class ImmichAppBarDialog extends HookConsumerWidget { right: horizontalPadding, bottom: isHorizontal ? 20 : 100, ), - backgroundColor: theme.cardColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index 5e768f3241..a40dcf914e 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -79,9 +80,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { child: Container( width: double.infinity, decoration: BoxDecoration( - color: context.isDarkTheme - ? context.scaffoldBackgroundColor - : const Color.fromARGB(255, 225, 229, 240), + color: context.colorScheme.surface, borderRadius: const BorderRadius.only( topLeft: Radius.circular(10), topRight: Radius.circular(10), @@ -99,9 +98,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { bottom: -5, right: -8, child: Material( - color: context.isDarkTheme - ? Colors.blueGrey[800] - : Colors.white, + color: context.colorScheme.surfaceContainerHighest, elevation: 3, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(50.0), @@ -129,7 +126,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { subtitle: Text( authState.userEmail, style: context.textTheme.bodySmall?.copyWith( - color: context.textTheme.bodySmall?.color?.withAlpha(200), + color: context.colorScheme.onSurfaceSecondary, ), ), ), diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart index 0beb45c49f..8cab0bd72f 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -42,9 +43,7 @@ class AppBarServerInfo extends HookConsumerWidget { padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 10.0), child: Container( decoration: BoxDecoration( - color: context.isDarkTheme - ? context.scaffoldBackgroundColor - : const Color.fromARGB(255, 225, 229, 240), + color: context.colorScheme.surface, borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(10), bottomRight: Radius.circular(10), @@ -71,10 +70,7 @@ class AppBarServerInfo extends HookConsumerWidget { ), const Padding( padding: EdgeInsets.symmetric(horizontal: 10), - child: Divider( - color: Color.fromARGB(101, 201, 201, 201), - thickness: 1, - ), + child: Divider(thickness: 1), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -100,8 +96,7 @@ class AppBarServerInfo extends HookConsumerWidget { "${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}", style: TextStyle( fontSize: contentFontSize, - color: context.textTheme.labelSmall?.color - ?.withOpacity(0.5), + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, ), ), @@ -111,10 +106,7 @@ class AppBarServerInfo extends HookConsumerWidget { ), const Padding( padding: EdgeInsets.symmetric(horizontal: 10), - child: Divider( - color: Color.fromARGB(101, 201, 201, 201), - thickness: 1, - ), + child: Divider(thickness: 1), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -142,8 +134,7 @@ class AppBarServerInfo extends HookConsumerWidget { : "--", style: TextStyle( fontSize: contentFontSize, - color: context.textTheme.labelSmall?.color - ?.withOpacity(0.5), + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, ), ), @@ -153,10 +144,7 @@ class AppBarServerInfo extends HookConsumerWidget { ), const Padding( padding: EdgeInsets.symmetric(horizontal: 10), - child: Divider( - color: Color.fromARGB(101, 201, 201, 201), - thickness: 1, - ), + child: Divider(thickness: 1), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -197,8 +185,7 @@ class AppBarServerInfo extends HookConsumerWidget { getServerUrl() ?? '--', style: TextStyle( fontSize: contentFontSize, - color: context.textTheme.labelSmall?.color - ?.withOpacity(0.5), + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, overflow: TextOverflow.ellipsis, ), @@ -211,10 +198,7 @@ class AppBarServerInfo extends HookConsumerWidget { ), const Padding( padding: EdgeInsets.symmetric(horizontal: 10), - child: Divider( - color: Color.fromARGB(101, 201, 201, 201), - thickness: 1, - ), + child: Divider(thickness: 1), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -255,8 +239,7 @@ class AppBarServerInfo extends HookConsumerWidget { : "--", style: TextStyle( fontSize: contentFontSize, - color: context.textTheme.labelSmall?.color - ?.withOpacity(0.5), + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, ), ), diff --git a/mobile/lib/widgets/common/confirm_dialog.dart b/mobile/lib/widgets/common/confirm_dialog.dart index 5f24f75d51..5e043cf8de 100644 --- a/mobile/lib/widgets/common/confirm_dialog.dart +++ b/mobile/lib/widgets/common/confirm_dialog.dart @@ -47,7 +47,7 @@ class ConfirmDialog extends StatelessWidget { child: Text( ok, style: TextStyle( - color: Colors.red[400], + color: context.colorScheme.error, fontWeight: FontWeight.bold, ), ).tr(), diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index a3b3a19f34..30802a435a 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -111,7 +111,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { buildBackupIndicator() { final indicatorIcon = getBackupBadgeIcon(); - final badgeBackground = isDarkTheme ? Colors.blueGrey[800] : Colors.white; + final badgeBackground = context.colorScheme.surfaceContainer; return InkWell( onTap: () => context.pushRoute(const BackupControllerRoute()), @@ -123,7 +123,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { decoration: BoxDecoration( color: badgeBackground, border: Border.all( - color: isDarkTheme ? Colors.black : Colors.grey, + color: context.colorScheme.outline.withOpacity(.3), ), borderRadius: BorderRadius.circular(widgetSize / 2), ), diff --git a/mobile/lib/widgets/common/immich_title_text.dart b/mobile/lib/widgets/common/immich_title_text.dart index 2a4edb4230..711d0bf396 100644 --- a/mobile/lib/widgets/common/immich_title_text.dart +++ b/mobile/lib/widgets/common/immich_title_text.dart @@ -21,6 +21,7 @@ class ImmichTitleText extends StatelessWidget { ), width: fontSize * 4, filterQuality: FilterQuality.high, + color: context.primaryColor, ); } } diff --git a/mobile/lib/widgets/common/immich_toast.dart b/mobile/lib/widgets/common/immich_toast.dart index e15623c86c..d33f6c4caf 100644 --- a/mobile/lib/widgets/common/immich_toast.dart +++ b/mobile/lib/widgets/common/immich_toast.dart @@ -51,9 +51,9 @@ class ImmichToast { padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(5.0), - color: context.isDarkTheme ? Colors.grey[900] : Colors.grey[50], + color: context.colorScheme.surfaceContainer, border: Border.all( - color: Colors.black12, + color: context.colorScheme.outline.withOpacity(.5), width: 1, ), ), diff --git a/mobile/lib/widgets/forms/change_password_form.dart b/mobile/lib/widgets/forms/change_password_form.dart index 0d1ac539dc..98ce66d2d1 100644 --- a/mobile/lib/widgets/forms/change_password_form.dart +++ b/mobile/lib/widgets/forms/change_password_form.dart @@ -51,7 +51,7 @@ class ChangePasswordForm extends HookConsumerWidget { ), style: TextStyle( fontSize: 14, - color: Colors.grey[700], + color: context.colorScheme.onSurface, fontWeight: FontWeight.w600, ), ), @@ -191,9 +191,6 @@ class ChangePasswordButton extends ConsumerWidget { return ElevatedButton( style: ElevatedButton.styleFrom( visualDensity: VisualDensity.standard, - backgroundColor: context.primaryColor, - foregroundColor: Colors.grey[50], - elevation: 2, padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25), ), onPressed: onPressed, diff --git a/mobile/lib/widgets/map/map_theme_override.dart b/mobile/lib/widgets/map/map_theme_override.dart index f56942c69c..3b66a1cc35 100644 --- a/mobile/lib/widgets/map/map_theme_override.dart +++ b/mobile/lib/widgets/map/map_theme_override.dart @@ -70,6 +70,7 @@ class _MapThemeOverideState extends ConsumerState Widget build(BuildContext context) { _theme = widget.themeMode ?? ref.watch(mapStateNotifierProvider.select((v) => v.themeMode)); + var appTheme = ref.watch(immichThemeProvider); useValueChanged(_theme, (_, __) { if (_theme == ThemeMode.system) { @@ -83,7 +84,9 @@ class _MapThemeOverideState extends ConsumerState }); return Theme( - data: _isDarkTheme ? immichDarkTheme : immichLightTheme, + data: _isDarkTheme + ? getThemeData(colorScheme: appTheme.dark) + : getThemeData(colorScheme: appTheme.light), child: widget.mapBuilder.call( ref.watch( mapStateNotifierProvider.select( diff --git a/mobile/lib/widgets/memories/memory_epilogue.dart b/mobile/lib/widgets/memories/memory_epilogue.dart index b817d67f05..9796bee6b1 100644 --- a/mobile/lib/widgets/memories/memory_epilogue.dart +++ b/mobile/lib/widgets/memories/memory_epilogue.dart @@ -1,6 +1,5 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; class MemoryEpilogue extends StatefulWidget { @@ -49,24 +48,26 @@ class _MemoryEpilogueState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon( + Icon( Icons.check_circle_outline_sharp, - color: immichDarkThemePrimaryColor, + color: context.isDarkTheme + ? context.colorScheme.primary + : context.colorScheme.inversePrimary, size: 64.0, ), const SizedBox(height: 16.0), Text( "memories_all_caught_up", - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - ), + style: context.textTheme.headlineMedium?.copyWith( + color: Colors.white, + ), ).tr(), const SizedBox(height: 16.0), Text( "memories_check_back_tomorrow", - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.white, - ), + style: context.textTheme.bodyMedium?.copyWith( + color: Colors.white, + ), ).tr(), const SizedBox(height: 16.0), TextButton( @@ -74,7 +75,9 @@ class _MemoryEpilogueState extends State child: Text( "memories_start_over", style: context.textTheme.displayMedium?.copyWith( - color: immichDarkThemePrimaryColor, + color: context.isDarkTheme + ? context.colorScheme.primary + : context.colorScheme.inversePrimary, ), ).tr(), ), @@ -108,9 +111,9 @@ class _MemoryEpilogueState extends State ), Text( "memories_swipe_to_close", - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.white, - ), + style: context.textTheme.bodyMedium?.copyWith( + color: Colors.white, + ), ).tr(), ], ), diff --git a/mobile/lib/widgets/memories/memory_progress_indicator.dart b/mobile/lib/widgets/memories/memory_progress_indicator.dart index 0ee3893cb9..438816d99c 100644 --- a/mobile/lib/widgets/memories/memory_progress_indicator.dart +++ b/mobile/lib/widgets/memories/memory_progress_indicator.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; class MemoryProgressIndicator extends StatelessWidget { /// The number of ticks in the progress indicator @@ -25,8 +25,11 @@ class MemoryProgressIndicator extends StatelessWidget { children: [ LinearProgressIndicator( value: value, - backgroundColor: Colors.grey[600], - color: immichDarkThemePrimaryColor, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + backgroundColor: Colors.grey[800], + color: context.isDarkTheme + ? context.colorScheme.primary + : context.colorScheme.inversePrimary, ), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, diff --git a/mobile/lib/widgets/search/search_filter/search_filter_chip.dart b/mobile/lib/widgets/search/search_filter/search_filter_chip.dart index b2e0d086ac..7db2eea70b 100644 --- a/mobile/lib/widgets/search/search_filter/search_filter_chip.dart +++ b/mobile/lib/widgets/search/search_filter/search_filter_chip.dart @@ -22,9 +22,9 @@ class SearchFilterChip extends StatelessWidget { onTap: onTap, child: Card( elevation: 0, - color: context.primaryColor.withAlpha(25), + color: context.primaryColor.withOpacity(.5), shape: StadiumBorder( - side: BorderSide(color: context.primaryColor), + side: BorderSide(color: context.colorScheme.secondaryContainer), ), child: Padding( padding: @@ -47,8 +47,9 @@ class SearchFilterChip extends StatelessWidget { onTap: onTap, child: Card( elevation: 0, - shape: - StadiumBorder(side: BorderSide(color: Colors.grey.withAlpha(100))), + shape: StadiumBorder( + side: BorderSide(color: context.colorScheme.outline.withOpacity(.5)), + ), child: Padding( padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 14.0), child: Row( diff --git a/mobile/lib/widgets/search/thumbnail_with_info_container.dart b/mobile/lib/widgets/search/thumbnail_with_info_container.dart index 6df45ec464..d2084bdcc8 100644 --- a/mobile/lib/widgets/search/thumbnail_with_info_container.dart +++ b/mobile/lib/widgets/search/thumbnail_with_info_container.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; class ThumbnailWithInfoContainer extends StatelessWidget { const ThumbnailWithInfoContainer({ @@ -25,7 +26,14 @@ class ThumbnailWithInfoContainer extends StatelessWidget { Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(borderRadius), - color: context.isDarkTheme ? Colors.grey[900] : Colors.grey[100], + gradient: LinearGradient( + colors: [ + context.colorScheme.surfaceContainer, + context.colorScheme.surfaceContainer.darken(amount: .1), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), ), foregroundDecoration: BoxDecoration( borderRadius: BorderRadius.circular(borderRadius), @@ -34,7 +42,7 @@ class ThumbnailWithInfoContainer extends StatelessWidget { begin: FractionalOffset.topCenter, end: FractionalOffset.bottomCenter, colors: [ - Colors.grey.withOpacity(0.0), + Colors.transparent, label == '' ? Colors.black.withOpacity(0.1) : Colors.black.withOpacity(0.5), diff --git a/mobile/lib/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart b/mobile/lib/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart index 12efa52b2d..2e1f165602 100644 --- a/mobile/lib/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart +++ b/mobile/lib/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; class CustomeProxyHeaderSettings extends StatelessWidget { @@ -20,8 +21,8 @@ class CustomeProxyHeaderSettings extends StatelessWidget { ), subtitle: Text( "headers_settings_tile_subtitle".tr(), - style: const TextStyle( - fontSize: 14, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, ), ), onTap: () => context.pushRoute(const HeaderSettingsRoute()), diff --git a/mobile/lib/widgets/settings/language_settings.dart b/mobile/lib/widgets/settings/language_settings.dart index 378d32085e..990dcfdfe8 100644 --- a/mobile/lib/widgets/settings/language_settings.dart +++ b/mobile/lib/widgets/settings/language_settings.dart @@ -40,9 +40,7 @@ class LanguageSettings extends HookConsumerWidget { ), ), backgroundColor: WidgetStatePropertyAll( - context.isDarkTheme - ? Colors.grey[900]! - : context.scaffoldBackgroundColor, + context.colorScheme.surfaceContainer, ), ), menuHeight: context.height * 0.5, diff --git a/mobile/lib/widgets/settings/local_storage_settings.dart b/mobile/lib/widgets/settings/local_storage_settings.dart index 6e7723cbff..5b21d9bd4d 100644 --- a/mobile/lib/widgets/settings/local_storage_settings.dart +++ b/mobile/lib/widgets/settings/local_storage_settings.dart @@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart' show useEffect, useState; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/db.provider.dart'; class LocalStorageSettings extends HookConsumerWidget { @@ -35,10 +36,10 @@ class LocalStorageSettings extends HookConsumerWidget { fontWeight: FontWeight.w500, ), ).tr(args: ["${cacheItemCount.value}"]), - subtitle: const Text( + subtitle: Text( "cache_settings_duplicated_assets_subtitle", - style: TextStyle( - fontSize: 14, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, ), ).tr(), trailing: TextButton( diff --git a/mobile/lib/widgets/settings/preference_settings/preference_setting.dart b/mobile/lib/widgets/settings/preference_settings/preference_setting.dart index 62508df6e2..8a3684e093 100644 --- a/mobile/lib/widgets/settings/preference_settings/preference_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/preference_setting.dart @@ -15,6 +15,9 @@ class PreferenceSetting extends StatelessWidget { HapticSetting(), ]; - return const SettingsSubPageScaffold(settings: preferenceSettings); + return const SettingsSubPageScaffold( + settings: preferenceSettings, + showDivider: true, + ); } } diff --git a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart new file mode 100644 index 0000000000..1c7cd1f207 --- /dev/null +++ b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart @@ -0,0 +1,221 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/immich_app_theme.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; + +class PrimaryColorSetting extends HookConsumerWidget { + const PrimaryColorSetting({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeProvider = ref.read(immichThemeProvider); + + final primaryColorSetting = + useAppSettingsState(AppSettingsEnum.primaryColor); + final systemPrimaryColorSetting = + useAppSettingsState(AppSettingsEnum.dynamicTheme); + + final currentPreset = useValueNotifier(ref.read(immichThemePresetProvider)); + const tileSize = 55.0; + + useValueChanged( + primaryColorSetting.value, + (_, __) => currentPreset.value = ImmichColorPreset.values + .firstWhere((e) => e.name == primaryColorSetting.value), + ); + + void popBottomSheet() { + Future.delayed(const Duration(milliseconds: 200), () { + Navigator.pop(context); + }); + } + + onUseSystemColorChange(bool newValue) { + systemPrimaryColorSetting.value = newValue; + ref.watch(dynamicThemeSettingProvider.notifier).state = newValue; + ref.invalidate(immichThemeProvider); + popBottomSheet(); + } + + onPrimaryColorChange(ImmichColorPreset colorPreset) { + primaryColorSetting.value = colorPreset.name; + ref.watch(immichThemePresetProvider.notifier).state = colorPreset; + ref.invalidate(immichThemeProvider); + + //turn off system color setting + if (systemPrimaryColorSetting.value) { + onUseSystemColorChange(false); + } else { + popBottomSheet(); + } + } + + buildPrimaryColorTile({ + required Color topColor, + required Color bottomColor, + required double tileSize, + required bool showSelector, + }) { + return Container( + margin: const EdgeInsets.all(4.0), + child: Stack( + children: [ + Container( + height: tileSize, + width: tileSize, + decoration: BoxDecoration( + color: bottomColor, + borderRadius: const BorderRadius.all(Radius.circular(100)), + ), + ), + Container( + height: tileSize / 2, + width: tileSize, + decoration: BoxDecoration( + color: topColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(100), + topRight: Radius.circular(100), + ), + ), + ), + if (showSelector) + Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(100)), + color: Colors.grey[900]?.withOpacity(.4), + ), + child: const Padding( + padding: EdgeInsets.all(3), + child: Icon( + Icons.check_rounded, + color: Colors.white, + size: 25, + ), + ), + ), + ), + ], + ), + ); + } + + bottomSheetContent() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Align( + alignment: Alignment.center, + child: Text( + "theme_setting_primary_color_title".tr(), + style: context.textTheme.titleLarge, + ), + ), + if (isDynamicThemeAvailable) + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + margin: const EdgeInsets.only(top: 10), + child: SwitchListTile.adaptive( + contentPadding: + const EdgeInsets.symmetric(vertical: 6, horizontal: 20), + dense: true, + activeColor: context.primaryColor, + tileColor: context.colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + title: Text( + 'theme_setting_system_primary_color_title'.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + height: 1.5, + ), + ), + value: systemPrimaryColorSetting.value, + onChanged: onUseSystemColorChange, + ), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: ImmichColorPreset.values.map((themePreset) { + var theme = themePreset.getTheme(); + + return GestureDetector( + onTap: () => onPrimaryColorChange(themePreset), + child: buildPrimaryColorTile( + topColor: theme.light.primary, + bottomColor: theme.dark.primary, + tileSize: tileSize, + showSelector: currentPreset.value == themePreset && + !systemPrimaryColorSetting.value, + ), + ); + }).toList(), + ), + ), + ], + ); + } + + return ListTile( + onTap: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext ctx) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 0), + child: bottomSheetContent(), + ); + }, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + title: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "theme_setting_primary_color_title".tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + "theme_setting_primary_color_subtitle".tr(), + style: context.textTheme.bodyMedium + ?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 8.0), + child: buildPrimaryColorTile( + topColor: themeProvider.light.primary, + bottomColor: themeProvider.dark.primary, + tileSize: 42.0, + showSelector: false, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart index 5780054428..050593a229 100644 --- a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; @@ -16,11 +17,16 @@ class ThemeSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final currentThemeString = useAppSettingsState(AppSettingsEnum.themeMode); - final currentTheme = useValueNotifier(ref.read(immichThemeProvider)); + final currentTheme = useValueNotifier(ref.read(immichThemeModeProvider)); final isDarkTheme = useValueNotifier(currentTheme.value == ThemeMode.dark); final isSystemTheme = useValueNotifier(currentTheme.value == ThemeMode.system); + final applyThemeToBackgroundSetting = + useAppSettingsState(AppSettingsEnum.colorfulInterface); + final applyThemeToBackgroundProvider = + useValueNotifier(ref.read(colorfulInterfaceSettingProvider)); + useValueChanged( currentThemeString.value, (_, __) => currentTheme.value = switch (currentThemeString.value) { @@ -30,12 +36,18 @@ class ThemeSetting extends HookConsumerWidget { }, ); + useValueChanged( + applyThemeToBackgroundSetting.value, + (_, __) => applyThemeToBackgroundProvider.value = + applyThemeToBackgroundSetting.value, + ); + void onThemeChange(bool isDark) { if (isDark) { - ref.watch(immichThemeProvider.notifier).state = ThemeMode.dark; + ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.dark; currentThemeString.value = "dark"; } else { - ref.watch(immichThemeProvider.notifier).state = ThemeMode.light; + ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.light; currentThemeString.value = "light"; } } @@ -44,7 +56,7 @@ class ThemeSetting extends HookConsumerWidget { if (isSystem) { currentThemeString.value = "system"; isSystemTheme.value = true; - ref.watch(immichThemeProvider.notifier).state = ThemeMode.system; + ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.system; } else { final currentSystemBrightness = MediaQuery.platformBrightnessOf(context); @@ -52,14 +64,20 @@ class ThemeSetting extends HookConsumerWidget { isDarkTheme.value = currentSystemBrightness == Brightness.dark; if (currentSystemBrightness == Brightness.light) { currentThemeString.value = "light"; - ref.watch(immichThemeProvider.notifier).state = ThemeMode.light; + ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.light; } else if (currentSystemBrightness == Brightness.dark) { currentThemeString.value = "dark"; - ref.watch(immichThemeProvider.notifier).state = ThemeMode.dark; + ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.dark; } } } + void onSurfaceColorSettingChange(bool useColorfulInterface) { + applyThemeToBackgroundSetting.value = useColorfulInterface; + ref.watch(colorfulInterfaceSettingProvider.notifier).state = + useColorfulInterface; + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -75,6 +93,13 @@ class ThemeSetting extends HookConsumerWidget { title: 'theme_setting_dark_mode_switch'.tr(), onChanged: onThemeChange, ), + const PrimaryColorSetting(), + SettingsSwitchListTile( + valueNotifier: applyThemeToBackgroundProvider, + title: "theme_setting_colorful_interface_title".tr(), + subtitle: 'theme_setting_colorful_interface_subtitle'.tr(), + onChanged: onSurfaceColorSettingChange, + ), ], ); } diff --git a/mobile/lib/widgets/settings/settings_button_list_tile.dart b/mobile/lib/widgets/settings/settings_button_list_tile.dart index fca5b878de..196e3d170f 100644 --- a/mobile/lib/widgets/settings/settings_button_list_tile.dart +++ b/mobile/lib/widgets/settings/settings_button_list_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; class SettingsButtonListTile extends StatelessWidget { final IconData icon; @@ -39,7 +40,12 @@ class SettingsButtonListTile extends StatelessWidget { children: [ if (subtileText != null) const SizedBox(height: 4), if (subtileText != null) - Text(subtileText!, style: context.textTheme.bodyMedium), + Text( + subtileText!, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), if (subtitle != null) subtitle!, const SizedBox(height: 6), ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)), diff --git a/mobile/lib/widgets/settings/settings_switch_list_tile.dart b/mobile/lib/widgets/settings/settings_switch_list_tile.dart index c7328f0b96..78f1738266 100644 --- a/mobile/lib/widgets/settings/settings_switch_list_tile.dart +++ b/mobile/lib/widgets/settings/settings_switch_list_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; class SettingsSwitchListTile extends StatelessWidget { final ValueNotifier valueNotifier; @@ -54,7 +55,9 @@ class SettingsSwitchListTile extends StatelessWidget { ? Text( subtitle!, style: context.textTheme.bodyMedium?.copyWith( - color: enabled ? null : context.themeData.disabledColor, + color: enabled + ? context.colorScheme.onSurfaceSecondary + : context.themeData.disabledColor, ), ) : null, diff --git a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart index 0daddd6d88..21d9738b84 100644 --- a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart +++ b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; class SslClientCertSettings extends StatefulWidget { @@ -40,7 +41,9 @@ class _SslClientCertSettingsState extends State { children: [ Text( "client_cert_subtitle".tr(), - style: context.textTheme.bodyMedium, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), ), const SizedBox( height: 6, diff --git a/mobile/lib/widgets/shared_link/shared_link_item.dart b/mobile/lib/widgets/shared_link/shared_link_item.dart index 86c0890cd2..9e29f5f9a0 100644 --- a/mobile/lib/widgets/shared_link/shared_link_item.dart +++ b/mobile/lib/widgets/shared_link/shared_link_item.dart @@ -65,8 +65,8 @@ class SharedLinkItem extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final themeData = context.themeData; - final isDarkMode = themeData.brightness == Brightness.dark; + final colorScheme = context.colorScheme; + final isDarkMode = colorScheme.brightness == Brightness.dark; final thumbnailUrl = sharedLink.thumbAssetId != null ? getThumbnailUrlForRemoteId(sharedLink.thumbAssetId!) : null; @@ -159,7 +159,7 @@ class SharedLinkItem extends ConsumerWidget { return Padding( padding: const EdgeInsets.only(right: 10), child: Chip( - backgroundColor: themeData.primaryColor, + backgroundColor: colorScheme.primary, label: Text( labelText, style: TextStyle( @@ -240,7 +240,7 @@ class SharedLinkItem extends ConsumerWidget { child: Tooltip( verticalOffset: 0, decoration: BoxDecoration( - color: themeData.primaryColor.withOpacity(0.9), + color: colorScheme.primary.withOpacity(0.9), borderRadius: BorderRadius.circular(10), ), textStyle: TextStyle( @@ -253,7 +253,7 @@ class SharedLinkItem extends ConsumerWidget { child: Text( sharedLink.title, style: TextStyle( - color: themeData.primaryColor, + color: colorScheme.primary, fontWeight: FontWeight.bold, overflow: TextOverflow.ellipsis, ), @@ -268,7 +268,7 @@ class SharedLinkItem extends ConsumerWidget { child: Tooltip( verticalOffset: 0, decoration: BoxDecoration( - color: themeData.primaryColor.withOpacity(0.9), + color: colorScheme.primary.withOpacity(0.9), borderRadius: BorderRadius.circular(10), ), textStyle: TextStyle( diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index c7e397999c..8d5a912a51 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -377,6 +377,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + dynamic_color: + dependency: "direct main" + description: + name: dynamic_color + sha256: eae98052fa6e2826bdac3dd2e921c6ce2903be15c6b7f8b6d8a5d49b5086298d + url: "https://pub.dev" + source: hosted + version: "1.7.0" easy_image_viewer: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index c830707182..9b74bec14c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -61,9 +61,11 @@ dependencies: octo_image: ^2.0.0 thumbhash: 0.1.0+1 async: ^2.11.0 + dynamic_color: ^1.7.0 #package to apply system theme #image editing packages crop_image: ^1.0.13 + openapi: path: openapi From f040c9fb38bb0e2327313fbc8f763b7280c2f466 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 6 Aug 2024 11:22:13 -0500 Subject: [PATCH 027/723] chore(server): remove get person asset limit (#11597) * chore(server): remover get person asset limit * sql * remove getPersonAsset endpoint * remove getPersonAsset endpoint * use search endpoint to get people * fix: server test * mobile linter * fix: server test * remove debuglog * deprecated endpoint * change page size on mobile * revert max size * fix test --- .../lib/providers/search/people.provider.dart | 3 - mobile/lib/services/person.service.dart | 36 ++++++++++-- mobile/openapi/README.md | 1 + mobile/openapi/lib/api/deprecated_api.dart | 56 +++++++++++++++++++ mobile/openapi/lib/api/people_api.dart | 7 ++- open-api/immich-openapi-specs.json | 10 +++- open-api/typescript-sdk/src/fetch-client.ts | 3 + server/src/controllers/person.controller.ts | 2 + 8 files changed, 107 insertions(+), 11 deletions(-) diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart index 753b9f19bb..e2c243354b 100644 --- a/mobile/lib/providers/search/people.provider.dart +++ b/mobile/lib/providers/search/people.provider.dart @@ -22,9 +22,6 @@ Future> getAllPeople( Future personAssets(PersonAssetsRef ref, String personId) async { final PersonService personService = ref.read(personServiceProvider); final assets = await personService.getPersonAssets(personId); - if (assets == null) { - return RenderList.empty(); - } final settings = ref.read(appSettingsServiceProvider); final groupBy = diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart index f35ae1a225..ddb61f5e48 100644 --- a/mobile/lib/services/person.service.dart +++ b/mobile/lib/services/person.service.dart @@ -30,15 +30,41 @@ class PersonService { } } - Future?> getPersonAssets(String id) async { + Future> getPersonAssets(String id) async { + List result = []; + var hasNext = true; + var currentPage = 1; + try { - final assets = await _apiService.peopleApi.getPersonAssets(id); - if (assets == null) return null; - return await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); + while (hasNext) { + final response = await _apiService.searchApi.searchMetadata( + MetadataSearchDto( + personIds: [id], + page: currentPage, + size: 1000, + ), + ); + + if (response == null) { + break; + } + + if (response.assets.nextPage == null) { + hasNext = false; + } + + final assets = response.assets.items; + final mapAssets = + await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); + result.addAll(mapAssets); + + currentPage++; + } } catch (error, stack) { _log.severe("Error while fetching person assets", error, stack); } - return null; + + return result; } Future updateName(String id, String name) async { diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 52e2e3cb40..89a4fb8e3b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -117,6 +117,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *DeprecatedApi* | [**getAboutInfo**](doc//DeprecatedApi.md#getaboutinfo) | **GET** /server-info/about | +*DeprecatedApi* | [**getPersonAssets**](doc//DeprecatedApi.md#getpersonassets) | **GET** /people/{id}/assets | *DeprecatedApi* | [**getServerConfig**](doc//DeprecatedApi.md#getserverconfig) | **GET** /server-info/config | *DeprecatedApi* | [**getServerFeatures**](doc//DeprecatedApi.md#getserverfeatures) | **GET** /server-info/features | *DeprecatedApi* | [**getServerStatistics**](doc//DeprecatedApi.md#getserverstatistics) | **GET** /server-info/statistics | diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index 18518cca69..e1e09ae4b2 100644 --- a/mobile/openapi/lib/api/deprecated_api.dart +++ b/mobile/openapi/lib/api/deprecated_api.dart @@ -60,6 +60,62 @@ class DeprecatedApi { return null; } + /// This property was deprecated in v1.113.0 + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getPersonAssetsWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/people/{id}/assets' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This property was deprecated in v1.113.0 + /// + /// Parameters: + /// + /// * [String] id (required): + Future?> getPersonAssets(String id,) async { + final response = await getPersonAssetsWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// This property was deprecated in v1.107.0 /// /// Note: This method returns the HTTP [Response]. diff --git a/mobile/openapi/lib/api/people_api.dart b/mobile/openapi/lib/api/people_api.dart index 9fe62f0841..95c4a2fd45 100644 --- a/mobile/openapi/lib/api/people_api.dart +++ b/mobile/openapi/lib/api/people_api.dart @@ -180,7 +180,10 @@ class PeopleApi { return null; } - /// Performs an HTTP 'GET /people/{id}/assets' operation and returns the [Response]. + /// This property was deprecated in v1.113.0 + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [String] id (required): @@ -210,6 +213,8 @@ class PeopleApi { ); } + /// This property was deprecated in v1.113.0 + /// /// Parameters: /// /// * [String] id (required): diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 6506a6293f..c30c43fabf 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4115,6 +4115,8 @@ }, "/people/{id}/assets": { "get": { + "deprecated": true, + "description": "This property was deprecated in v1.113.0", "operationId": "getPersonAssets", "parameters": [ { @@ -4154,8 +4156,12 @@ } ], "tags": [ - "People" - ] + "People", + "Deprecated" + ], + "x-immich-lifecycle": { + "deprecatedAt": "v1.113.0" + } } }, "/people/{id}/merge": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index ec2a230f77..184052a4f6 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2267,6 +2267,9 @@ export function updatePerson({ id, personUpdateDto }: { body: personUpdateDto }))); } +/** + * This property was deprecated in v1.113.0 + */ export function getPersonAssets({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index 5f642dfa00..082d5ca46c 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, Inject, Next, Param, Post, Put, Query, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; +import { EndpointLifecycle } from 'src/decorators'; import { BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -81,6 +82,7 @@ export class PersonController { await sendFile(res, next, () => this.service.getThumbnail(auth, id), this.logger); } + @EndpointLifecycle({ deprecatedAt: 'v1.113.0' }) @Get(':id/assets') @Authenticated() getPersonAssets(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { From 325fb4b5d1854fec46a08296017584ec1beed250 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Tue, 6 Aug 2024 18:27:05 +0200 Subject: [PATCH 028/723] fix(server): video duration extraction (#11610) --- server/src/services/metadata.service.spec.ts | 69 +++++++++++++------- server/src/services/metadata.service.ts | 56 ++++++++-------- 2 files changed, 72 insertions(+), 53 deletions(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 956b45e214..e9d09e33aa 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -647,13 +647,19 @@ describe(MetadataService.name, () => { }); }); - it('should handle duration', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Duration: 6.21 }); + it('should extract duration', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mediaMock.probe.mockResolvedValue({ + ...probeStub.videoStreamH264, + format: { + ...probeStub.videoStreamH264.format, + duration: 6.21, + }, + }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); expect(assetMock.upsertExif).toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -663,10 +669,15 @@ describe(MetadataService.name, () => { ); }); - it('should handle duration in ISO time string', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Duration: '00:00:08.41' }); - + it('only extracts duration for videos', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]); + mediaMock.probe.mockResolvedValue({ + ...probeStub.videoStreamH264, + format: { + ...probeStub.videoStreamH264.format, + duration: 6.21, + }, + }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); @@ -674,39 +685,51 @@ describe(MetadataService.name, () => { expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, - duration: '00:00:08.410', + duration: null, }), ); }); - it('should handle duration as an object without Scale', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Duration: { Value: 6.2 } }); + it('omits duration of zero', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mediaMock.probe.mockResolvedValue({ + ...probeStub.videoStreamH264, + format: { + ...probeStub.videoStreamH264.format, + duration: 0, + }, + }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); expect(assetMock.upsertExif).toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, - duration: '00:00:06.200', + duration: null, }), ); }); - it('should handle duration with scale', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Duration: { Scale: 1.111_111_111_111_11e-5, Value: 558_720 } }); + it('handles duration of 1 week', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mediaMock.probe.mockResolvedValue({ + ...probeStub.videoStreamH264, + format: { + ...probeStub.videoStreamH264.format, + duration: 604_800, + }, + }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); expect(assetMock.upsertExif).toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.image.id, - duration: '00:00:06.207', + id: assetStub.video.id, + duration: '168:00:00.000', }), ); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index ee3b24fad5..aa29d47131 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -214,28 +214,7 @@ export class MetadataService implements OnEvents { const { exifData, tags } = await this.exifData(asset); if (asset.type === AssetType.VIDEO) { - const { videoStreams } = await this.mediaRepository.probe(asset.originalPath); - - if (videoStreams[0]) { - switch (videoStreams[0].rotation) { - case -90: { - exifData.orientation = Orientation.Rotate90CW; - break; - } - case 0: { - exifData.orientation = Orientation.Horizontal; - break; - } - case 90: { - exifData.orientation = Orientation.Rotate270CW; - break; - } - case 180: { - exifData.orientation = Orientation.Rotate180; - break; - } - } - } + await this.applyVideoMetadata(asset, exifData); } await this.applyMotionPhotos(asset, tags); @@ -252,7 +231,7 @@ export class MetadataService implements OnEvents { } await this.assetRepository.update({ id: asset.id, - duration: tags.Duration ? this.getDuration(tags.Duration) : null, + duration: asset.duration, localDateTime, fileCreatedAt: exifData.dateTimeOriginal ?? undefined, }); @@ -567,16 +546,33 @@ export class MetadataService implements OnEvents { return bitsPerSample; } - private getDuration(seconds?: ImmichTags['Duration']): string { - let _seconds = seconds as number; + private async applyVideoMetadata(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { + const { videoStreams, format } = await this.mediaRepository.probe(asset.originalPath); - if (typeof seconds === 'object') { - _seconds = seconds.Value * (seconds?.Scale || 1); - } else if (typeof seconds === 'string') { - _seconds = Duration.fromISOTime(seconds).as('seconds'); + if (videoStreams[0]) { + switch (videoStreams[0].rotation) { + case -90: { + exifData.orientation = Orientation.Rotate90CW; + break; + } + case 0: { + exifData.orientation = Orientation.Horizontal; + break; + } + case 90: { + exifData.orientation = Orientation.Rotate270CW; + break; + } + case 180: { + exifData.orientation = Orientation.Rotate180; + break; + } + } } - return Duration.fromObject({ seconds: _seconds }).toFormat('hh:mm:ss.SSS'); + if (format.duration) { + asset.duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS'); + } } private async processSidecar(id: string, isSync: boolean): Promise { From 9f4fad2a0f6577f4d6104c9d3237f5585b6e6d0d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 6 Aug 2024 12:57:03 -0400 Subject: [PATCH 029/723] chore(deps): update base-image to v20240806 (major) (#11616) chore(deps): update base-image to v20240806 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index c618de4277..8d419b83f1 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20240730@sha256:3e03f236d7669d0b27fbd49bc617df69fbb719cec2310a1c7ed8291236648c22 AS dev +FROM ghcr.io/immich-app/base-server-dev:20240806@sha256:357c0e3a6b3cece3af7e9c46f5a2d11b6f032ded6a5b1de7706acf785b85a873 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -41,7 +41,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20240730@sha256:40efde970c4dfb1ace5a10211b8ca1b5f04bff5da4b7537c9f76a0454a05f47d +FROM ghcr.io/immich-app/base-server-prod:20240806@sha256:c13555680d8b454a416fa0e8c0e9e33b348433793c29680231e83b08838f06ec WORKDIR /usr/src/app ENV NODE_ENV=production \ From 65f5118bdd4349f46f4b40b29a82c10f1016a374 Mon Sep 17 00:00:00 2001 From: i-am-a-teapot <75271959+i-am-a-teapot@users.noreply.github.com> Date: Tue, 6 Aug 2024 19:06:30 +0200 Subject: [PATCH 030/723] feat(web): Add stacking option to deduplication utilities (#11114) * feat(web): Add stacking option to deduplication utilities * Update web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte Co-authored-by: Alex * Fix prettier * Draft for server side modifications. Endpoint for stacks (PUT,DELETE) * Fix error * Disable stakc button if less or more than one asset selected * Remove unnecesarry log * Revert to first commit * Further Revert * Actually Revert to Origin * Only one stack button * Update +page.svelte * Fix optional arguments * Fix Prettier * Fix Linting * Add stack information to asset view * clean up --------- Co-authored-by: Alex --- .../duplicates/duplicate-asset.svelte | 25 +++++++--- .../duplicates-compare-control.svelte | 50 ++++++++++++++----- web/src/lib/i18n/en.json | 2 + web/src/lib/utils/asset-utils.ts | 22 ++++---- .../[[assetId=id]]/+page.svelte | 13 ++++- 5 files changed, 82 insertions(+), 30 deletions(-) diff --git a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte index 74d17c621d..5fc2177e88 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte @@ -4,7 +4,7 @@ import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils'; import { getAltText } from '$lib/utils/thumbnail-util'; import { getAllAlbums, type AssetResponseDto } from '@immich/sdk'; - import { mdiHeart, mdiMagnifyPlus } from '@mdi/js'; + import { mdiHeart, mdiMagnifyPlus, mdiImageMultipleOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; export let asset: AssetResponseDto; @@ -14,6 +14,7 @@ $: isFromExternalLibrary = !!asset.libraryId; $: assetData = JSON.stringify(asset, null, 2); + $: stackCount = asset.stackCount;
- - {#if isFromExternalLibrary} -
- {$t('external')} -
- {/if} + +
+ {#if isFromExternalLibrary} +
+ {$t('external')} +
+ {/if} + {#if stackCount != null && stackCount != 0} +
+
+
{stackCount}
+ +
+
+ {/if} +
- {#if trashCount === 0} - + {:else} + + {/if} + - {:else} - - {/if} +
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 1149bc99b8..172b1b5d05 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1116,6 +1116,8 @@ "sort_title": "Title", "source": "Source", "stack": "Stack", + "stack_duplicates": "Stack duplicates", + "stack_select_one_photo": "Select one main photo for the stack", "stack_selected_photos": "Stack selected photos", "stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}", "stacktrace": "Stacktrace", diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 476d910523..a23c369009 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -324,7 +324,7 @@ export const getSelectedAssets = (assets: Set, user: UserRespo return ids; }; -export const stackAssets = async (assets: AssetResponseDto[]) => { +export const stackAssets = async (assets: AssetResponseDto[], showNotification = true) => { if (assets.length < 2) { return false; } @@ -362,16 +362,18 @@ export const stackAssets = async (assets: AssetResponseDto[]) => { parent.stack = parent.stack.concat(children, grandChildren); parent.stackCount = parent.stack.length + 1; - notificationController.show({ - message: $t('stacked_assets_count', { values: { count: parent.stackCount } }), - type: NotificationType.Info, - button: { - text: $t('view_stack'), - onClick() { - return assetViewingStore.setAssetId(parent.id); + if (showNotification) { + notificationController.show({ + message: $t('stacked_assets_count', { values: { count: parent.stackCount } }), + type: NotificationType.Info, + button: { + text: $t('view_stack'), + onClick() { + return assetViewingStore.setAssetId(parent.id); + }, }, - }, - }); + }); + } return ids; }; diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3a9bfbea7f..34889261d5 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -6,6 +6,7 @@ notificationController, } from '$lib/components/shared-components/notification/notification'; import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte'; + import type { AssetResponseDto } from '@immich/sdk'; import { featureFlags } from '$lib/stores/server-config.store'; import { handleError } from '$lib/utils/handle-error'; import { deleteAssets, updateAssets } from '@immich/sdk'; @@ -13,10 +14,11 @@ import type { PageData } from './$types'; import { suggestDuplicateByFileSize } from '$lib/utils'; import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; + import { mdiCheckOutline, mdiTrashCanOutline } from '@mdi/js'; + import { stackAssets } from '$lib/utils/asset-utils'; import ShowShortcuts from '$lib/components/shared-components/show-shortcuts.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { mdiKeyboard } from '@mdi/js'; - import { mdiCheckOutline, mdiTrashCanOutline } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; import { locale } from '$lib/stores/preferences.store'; @@ -40,6 +42,7 @@ { key: ['s'], action: $t('view') }, { key: ['d'], action: $t('unselect_all_duplicates') }, { key: ['⇧', 'c'], action: $t('resolve_duplicates') }, + { key: ['⇧', 'c'], action: $t('stack_duplicates') }, ], }; @@ -88,6 +91,13 @@ ); }; + const handleStack = async (duplicateId: string, assets: AssetResponseDto[]) => { + await stackAssets(assets, false); + const duplicateAssetIds = assets.map((asset) => asset.id); + await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } }); + data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); + }; + const handleDeduplicateAll = async () => { const idsToKeep = data.duplicates .map((group) => suggestDuplicateByFileSize(group.assets)) @@ -174,6 +184,7 @@ assets={data.duplicates[0].assets} onResolve={(duplicateAssetIds, trashIds) => handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)} + onStack={(assets) => handleStack(data.duplicates[0].duplicateId, assets)} /> {/key} {:else} From f679021f0e719a423a18a43bc715e31596579ea1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 6 Aug 2024 17:24:55 +0000 Subject: [PATCH 031/723] fix(deps): update dependency share_plus to v10 (#11550) * fix(deps): update dependency share_plus to v10 * resolve dep conflict --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Alex --- mobile/ios/Podfile.lock | 6 +++--- mobile/pubspec.lock | 48 ++++++++++++++++++++--------------------- mobile/pubspec.yaml | 4 ++-- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 39938b020a..61915eb30b 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -211,7 +211,7 @@ SPEC CHECKSUMS: isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9 - package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 + package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 @@ -219,14 +219,14 @@ SPEC CHECKSUMS: ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d - share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 + share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812 video_player_avfoundation: 02011213dab73ae3687df27ce441fbbcc82b5579 - wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47 + wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1 PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 8d5a912a51..69a608b0cf 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -285,10 +285,10 @@ packages: dependency: transitive description: name: cross_file - sha256: fedaadfa3a6996f75211d835aaeb8fede285dae94262485698afd832371b9a5e + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" url: "https://pub.dev" source: hosted - version: "0.3.3+8" + version: "0.3.4+2" crypto: dependency: transitive description: @@ -357,10 +357,10 @@ packages: dependency: transitive description: name: dbus - sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263" + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.10" device_info_plus: dependency: "direct main" description: @@ -421,10 +421,10 @@ packages: dependency: transitive description: name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" file: dependency: transitive description: @@ -1042,18 +1042,18 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "88bc797f44a94814f2213db1c9bd5badebafdfb8290ca9f78d4b9ee2a3db4d79" + sha256: "4de6c36df77ffbcef0a5aefe04669d33f2d18397fea228277b852a2d4e58e860" url: "https://pub.dev" source: hosted - version: "5.0.1" + version: "8.0.1" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6" + sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" path: dependency: "direct main" description: @@ -1322,18 +1322,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "3ef39599b00059db0990ca2e30fca0a29d8b37aae924d60063f8e0184cf20900" + sha256: "59dfd53f497340a0c3a81909b220cfdb9b8973a91055c4e5ab9b9b9ad7c513c0" url: "https://pub.dev" source: hosted - version: "7.2.2" + version: "10.0.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: df08bc3a07d01f5ea47b45d03ffcba1fa9cd5370fb44b3f38c70e42cced0f956 + sha256: "6ababf341050edff57da8b6990f11f4e99eaba837865e2e6defe16d039619db5" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "5.0.0" shared_preferences: dependency: transitive description: @@ -1631,10 +1631,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: fff0932192afeedf63cdd50ecbb1bc825d31aed259f02bb8dba0f3b729a5e88b + sha256: a36e2d7981122fa185006b216eb6b5b97ede3f9a54b7a511bc966971ab98d049 url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.2" url_launcher_windows: dependency: transitive description: @@ -1735,18 +1735,18 @@ packages: dependency: "direct main" description: name: wakelock_plus - sha256: f268ca2116db22e57577fb99d52515a24bdc1d570f12ac18bb762361d43b043d + sha256: "4fa83a128b4127619e385f686b4f080a5d2de46cff8e8c94eccac5fcf76550e5" url: "https://pub.dev" source: hosted - version: "1.1.4" + version: "1.2.7" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "40fabed5da06caff0796dc638e1f07ee395fb18801fbff3255a2372db2d80385" + sha256: "422d1cdbb448079a8a62a5a770b69baa489f8f7ca21aef47800c726d404f9d16" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" watcher: dependency: transitive description: @@ -1759,10 +1759,10 @@ packages: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.5.1" web_socket_channel: dependency: transitive description: @@ -1783,10 +1783,10 @@ packages: dependency: transitive description: name: win32 - sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + sha256: "015002c060f1ae9f41a818f2d5640389cc05283e368be19dc8d77cecb43c40c9" url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.5.3" win32_registry: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 9b74bec14c..6752ad59b6 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -36,12 +36,12 @@ dependencies: geolocator: ^11.0.0 # used to move to current location in map view flutter_udid: ^3.0.0 flutter_svg: ^2.0.9 - package_info_plus: ^5.0.1 + package_info_plus: ^8.0.1 url_launcher: ^6.2.4 http: ^0.13.6 cancellation_token_http: ^2.0.0 easy_localization: ^3.0.3 - share_plus: ^7.2.2 + share_plus: ^10.0.0 flutter_displaymode: ^0.6.0 scrollable_positioned_list: ^0.3.8 path: ^1.8.3 From 8ca24f0ef295ffcb3ecbdfcbe43a3d7aaa4bf4c7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 6 Aug 2024 12:50:20 -0500 Subject: [PATCH 032/723] fix(deps): update dependency auto_route to v9 (#11566) * fix(deps): update dependency auto_route to v9 * fix dep conflict * linting --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Alex --- ...additional_shared_user_selection.page.dart | 2 +- .../common/album_asset_selection.page.dart | 2 +- .../album_shared_user_selection.page.dart | 2 +- .../search/map/map_location_picker.page.dart | 2 +- .../providers/search/people.provider.g.dart | 2 +- mobile/lib/routing/router.dart | 3 +- mobile/lib/routing/router.gr.dart | 799 ++++++++---------- mobile/pubspec.lock | 28 +- mobile/pubspec.yaml | 4 +- 9 files changed, 385 insertions(+), 459 deletions(-) diff --git a/mobile/lib/pages/common/album_additional_shared_user_selection.page.dart b/mobile/lib/pages/common/album_additional_shared_user_selection.page.dart index 5e253a7b58..02026b828d 100644 --- a/mobile/lib/pages/common/album_additional_shared_user_selection.page.dart +++ b/mobile/lib/pages/common/album_additional_shared_user_selection.page.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; -@RoutePage?>() +@RoutePage() class AlbumAdditionalSharedUserSelectionPage extends HookConsumerWidget { final Album album; diff --git a/mobile/lib/pages/common/album_asset_selection.page.dart b/mobile/lib/pages/common/album_asset_selection.page.dart index b1281a2486..18ceb3e144 100644 --- a/mobile/lib/pages/common/album_asset_selection.page.dart +++ b/mobile/lib/pages/common/album_asset_selection.page.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:isar/isar.dart'; -@RoutePage() +@RoutePage() class AlbumAssetSelectionPage extends HookConsumerWidget { const AlbumAssetSelectionPage({ super.key, diff --git a/mobile/lib/pages/common/album_shared_user_selection.page.dart b/mobile/lib/pages/common/album_shared_user_selection.page.dart index d8cf4ecd27..aefa8e2736 100644 --- a/mobile/lib/pages/common/album_shared_user_selection.page.dart +++ b/mobile/lib/pages/common/album_shared_user_selection.page.dart @@ -13,7 +13,7 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; -@RoutePage>() +@RoutePage() class AlbumSharedUserSelectionPage extends HookConsumerWidget { const AlbumSharedUserSelectionPage({super.key, required this.assets}); diff --git a/mobile/lib/pages/search/map/map_location_picker.page.dart b/mobile/lib/pages/search/map/map_location_picker.page.dart index db0c980c89..2fd1e1ee9e 100644 --- a/mobile/lib/pages/search/map/map_location_picker.page.dart +++ b/mobile/lib/pages/search/map/map_location_picker.page.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:immich_mobile/utils/map_utils.dart'; -@RoutePage() +@RoutePage() class MapLocationPickerPage extends HookConsumerWidget { final LatLng initialLatLng; diff --git a/mobile/lib/providers/search/people.provider.g.dart b/mobile/lib/providers/search/people.provider.g.dart index c68f7a75fc..db2edfb956 100644 --- a/mobile/lib/providers/search/people.provider.g.dart +++ b/mobile/lib/providers/search/people.provider.g.dart @@ -21,7 +21,7 @@ final getAllPeopleProvider = ); typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; -String _$personAssetsHash() => r'1d6eff5ca3aa630b58c4dad9516193b21896984d'; +String _$personAssetsHash() => r'3dfecb67a54d07e4208bcb9581b2625acd2e1832'; /// Copied from Dart SDK class _SystemHash { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 3b28c73b27..211c847726 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -5,7 +5,6 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; @@ -69,7 +68,7 @@ import 'package:photo_manager/photo_manager.dart' hide LatLng; part 'router.gr.dart'; @AutoRouterConfig(replaceInRouteName: 'Page,Route') -class AppRouter extends _$AppRouter { +class AppRouter extends RootStackRouter { late final AuthGuard _authGuard; late final DuplicateGuard _duplicateGuard; late final BackupPermissionGuard _backupPermissionGuard; diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 77d031b5ed..a4259676c7 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -9,379 +9,6 @@ part of 'router.dart'; -abstract class _$AppRouter extends RootStackRouter { - // ignore: unused_element - _$AppRouter({super.navigatorKey}); - - @override - final Map pagesMap = { - ActivitiesRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const ActivitiesPage(), - ); - }, - AlbumAdditionalSharedUserSelectionRoute.name: (routeData) { - final args = - routeData.argsAs(); - return AutoRoutePage?>( - routeData: routeData, - child: AlbumAdditionalSharedUserSelectionPage( - key: args.key, - album: args.album, - ), - ); - }, - AlbumAssetSelectionRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: AlbumAssetSelectionPage( - key: args.key, - existingAssets: args.existingAssets, - canDeselect: args.canDeselect, - query: args.query, - ), - ); - }, - AlbumOptionsRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: AlbumOptionsPage( - key: args.key, - album: args.album, - ), - ); - }, - AlbumPreviewRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: AlbumPreviewPage( - key: args.key, - album: args.album, - ), - ); - }, - AlbumSharedUserSelectionRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage>( - routeData: routeData, - child: AlbumSharedUserSelectionPage( - key: args.key, - assets: args.assets, - ), - ); - }, - AlbumViewerRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: AlbumViewerPage( - key: args.key, - albumId: args.albumId, - ), - ); - }, - AllMotionPhotosRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const AllMotionPhotosPage(), - ); - }, - AllPeopleRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const AllPeoplePage(), - ); - }, - AllPlacesRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const AllPlacesPage(), - ); - }, - AllVideosRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const AllVideosPage(), - ); - }, - AppLogDetailRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: AppLogDetailPage( - key: args.key, - logMessage: args.logMessage, - ), - ); - }, - AppLogRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const AppLogPage(), - ); - }, - ArchiveRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const ArchivePage(), - ); - }, - BackupAlbumSelectionRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const BackupAlbumSelectionPage(), - ); - }, - BackupControllerRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const BackupControllerPage(), - ); - }, - BackupOptionsRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const BackupOptionsPage(), - ); - }, - ChangePasswordRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const ChangePasswordPage(), - ); - }, - CreateAlbumRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: CreateAlbumPage( - key: args.key, - isSharedAlbum: args.isSharedAlbum, - initialAssets: args.initialAssets, - ), - ); - }, - CropImageRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: CropImagePage( - key: args.key, - image: args.image, - ), - ); - }, - EditImageRoute.name: (routeData) { - final args = routeData.argsAs( - orElse: () => const EditImageRouteArgs()); - return AutoRoutePage( - routeData: routeData, - child: EditImagePage( - key: args.key, - image: args.image, - asset: args.asset, - ), - ); - }, - FailedBackupStatusRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const FailedBackupStatusPage(), - ); - }, - FavoritesRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const FavoritesPage(), - ); - }, - GalleryViewerRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: GalleryViewerPage( - key: args.key, - renderList: args.renderList, - initialIndex: args.initialIndex, - heroOffset: args.heroOffset, - showStack: args.showStack, - ), - ); - }, - HeaderSettingsRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const HeaderSettingsPage(), - ); - }, - LibraryRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const LibraryPage(), - ); - }, - LoginRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const LoginPage(), - ); - }, - MapLocationPickerRoute.name: (routeData) { - final args = routeData.argsAs( - orElse: () => const MapLocationPickerRouteArgs()); - return AutoRoutePage( - routeData: routeData, - child: MapLocationPickerPage( - key: args.key, - initialLatLng: args.initialLatLng, - ), - ); - }, - MapRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const MapPage(), - ); - }, - MemoryRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: MemoryPage( - memories: args.memories, - memoryIndex: args.memoryIndex, - key: args.key, - ), - ); - }, - PartnerDetailRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: PartnerDetailPage( - key: args.key, - partner: args.partner, - ), - ); - }, - PartnerRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const PartnerPage(), - ); - }, - PermissionOnboardingRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const PermissionOnboardingPage(), - ); - }, - PersonResultRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: PersonResultPage( - key: args.key, - personId: args.personId, - personName: args.personName, - ), - ); - }, - PhotosRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const PhotosPage(), - ); - }, - RecentlyAddedRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const RecentlyAddedPage(), - ); - }, - SearchInputRoute.name: (routeData) { - final args = routeData.argsAs( - orElse: () => const SearchInputRouteArgs()); - return AutoRoutePage( - routeData: routeData, - child: SearchInputPage( - key: args.key, - prefilter: args.prefilter, - ), - ); - }, - SearchRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const SearchPage(), - ); - }, - SettingsRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const SettingsPage(), - ); - }, - SettingsSubRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: SettingsSubPage( - args.section, - key: args.key, - ), - ); - }, - SharedLinkEditRoute.name: (routeData) { - final args = routeData.argsAs( - orElse: () => const SharedLinkEditRouteArgs()); - return AutoRoutePage( - routeData: routeData, - child: SharedLinkEditPage( - key: args.key, - existingLink: args.existingLink, - assetsList: args.assetsList, - albumId: args.albumId, - ), - ); - }, - SharedLinkRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const SharedLinkPage(), - ); - }, - SharingRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const SharingPage(), - ); - }, - SplashScreenRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const SplashScreenPage(), - ); - }, - TabControllerRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const TabControllerPage(), - ); - }, - TrashRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const TrashPage(), - ); - }, - }; -} - /// generated route for /// [ActivitiesPage] class ActivitiesRoute extends PageRouteInfo { @@ -393,7 +20,12 @@ class ActivitiesRoute extends PageRouteInfo { static const String name = 'ActivitiesRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const ActivitiesPage(); + }, + ); } /// generated route for @@ -415,8 +47,16 @@ class AlbumAdditionalSharedUserSelectionRoute static const String name = 'AlbumAdditionalSharedUserSelectionRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AlbumAdditionalSharedUserSelectionPage( + key: args.key, + album: args.album, + ); + }, + ); } class AlbumAdditionalSharedUserSelectionRouteArgs { @@ -458,8 +98,18 @@ class AlbumAssetSelectionRoute static const String name = 'AlbumAssetSelectionRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AlbumAssetSelectionPage( + key: args.key, + existingAssets: args.existingAssets, + canDeselect: args.canDeselect, + query: args.query, + ); + }, + ); } class AlbumAssetSelectionRouteArgs { @@ -502,8 +152,16 @@ class AlbumOptionsRoute extends PageRouteInfo { static const String name = 'AlbumOptionsRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AlbumOptionsPage( + key: args.key, + album: args.album, + ); + }, + ); } class AlbumOptionsRouteArgs { @@ -540,8 +198,16 @@ class AlbumPreviewRoute extends PageRouteInfo { static const String name = 'AlbumPreviewRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AlbumPreviewPage( + key: args.key, + album: args.album, + ); + }, + ); } class AlbumPreviewRouteArgs { @@ -579,8 +245,16 @@ class AlbumSharedUserSelectionRoute static const String name = 'AlbumSharedUserSelectionRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AlbumSharedUserSelectionPage( + key: args.key, + assets: args.assets, + ); + }, + ); } class AlbumSharedUserSelectionRouteArgs { @@ -617,8 +291,16 @@ class AlbumViewerRoute extends PageRouteInfo { static const String name = 'AlbumViewerRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AlbumViewerPage( + key: args.key, + albumId: args.albumId, + ); + }, + ); } class AlbumViewerRouteArgs { @@ -648,7 +330,12 @@ class AllMotionPhotosRoute extends PageRouteInfo { static const String name = 'AllMotionPhotosRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const AllMotionPhotosPage(); + }, + ); } /// generated route for @@ -662,7 +349,12 @@ class AllPeopleRoute extends PageRouteInfo { static const String name = 'AllPeopleRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const AllPeoplePage(); + }, + ); } /// generated route for @@ -676,7 +368,12 @@ class AllPlacesRoute extends PageRouteInfo { static const String name = 'AllPlacesRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const AllPlacesPage(); + }, + ); } /// generated route for @@ -690,7 +387,12 @@ class AllVideosRoute extends PageRouteInfo { static const String name = 'AllVideosRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const AllVideosPage(); + }, + ); } /// generated route for @@ -711,8 +413,16 @@ class AppLogDetailRoute extends PageRouteInfo { static const String name = 'AppLogDetailRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AppLogDetailPage( + key: args.key, + logMessage: args.logMessage, + ); + }, + ); } class AppLogDetailRouteArgs { @@ -742,7 +452,12 @@ class AppLogRoute extends PageRouteInfo { static const String name = 'AppLogRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const AppLogPage(); + }, + ); } /// generated route for @@ -756,7 +471,12 @@ class ArchiveRoute extends PageRouteInfo { static const String name = 'ArchiveRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const ArchivePage(); + }, + ); } /// generated route for @@ -770,7 +490,12 @@ class BackupAlbumSelectionRoute extends PageRouteInfo { static const String name = 'BackupAlbumSelectionRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const BackupAlbumSelectionPage(); + }, + ); } /// generated route for @@ -784,7 +509,12 @@ class BackupControllerRoute extends PageRouteInfo { static const String name = 'BackupControllerRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const BackupControllerPage(); + }, + ); } /// generated route for @@ -798,7 +528,12 @@ class BackupOptionsRoute extends PageRouteInfo { static const String name = 'BackupOptionsRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const BackupOptionsPage(); + }, + ); } /// generated route for @@ -812,7 +547,12 @@ class ChangePasswordRoute extends PageRouteInfo { static const String name = 'ChangePasswordRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const ChangePasswordPage(); + }, + ); } /// generated route for @@ -835,8 +575,17 @@ class CreateAlbumRoute extends PageRouteInfo { static const String name = 'CreateAlbumRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return CreateAlbumPage( + key: args.key, + isSharedAlbum: args.isSharedAlbum, + initialAssets: args.initialAssets, + ); + }, + ); } class CreateAlbumRouteArgs { @@ -876,8 +625,16 @@ class CropImageRoute extends PageRouteInfo { static const String name = 'CropImageRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return CropImagePage( + key: args.key, + image: args.image, + ); + }, + ); } class CropImageRouteArgs { @@ -916,8 +673,18 @@ class EditImageRoute extends PageRouteInfo { static const String name = 'EditImageRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const EditImageRouteArgs()); + return EditImagePage( + key: args.key, + image: args.image, + asset: args.asset, + ); + }, + ); } class EditImageRouteArgs { @@ -950,7 +717,12 @@ class FailedBackupStatusRoute extends PageRouteInfo { static const String name = 'FailedBackupStatusRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const FailedBackupStatusPage(); + }, + ); } /// generated route for @@ -964,7 +736,12 @@ class FavoritesRoute extends PageRouteInfo { static const String name = 'FavoritesRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const FavoritesPage(); + }, + ); } /// generated route for @@ -991,8 +768,19 @@ class GalleryViewerRoute extends PageRouteInfo { static const String name = 'GalleryViewerRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return GalleryViewerPage( + key: args.key, + renderList: args.renderList, + initialIndex: args.initialIndex, + heroOffset: args.heroOffset, + showStack: args.showStack, + ); + }, + ); } class GalleryViewerRouteArgs { @@ -1031,7 +819,12 @@ class HeaderSettingsRoute extends PageRouteInfo { static const String name = 'HeaderSettingsRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const HeaderSettingsPage(); + }, + ); } /// generated route for @@ -1045,7 +838,12 @@ class LibraryRoute extends PageRouteInfo { static const String name = 'LibraryRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const LibraryPage(); + }, + ); } /// generated route for @@ -1059,7 +857,12 @@ class LoginRoute extends PageRouteInfo { static const String name = 'LoginRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const LoginPage(); + }, + ); } /// generated route for @@ -1080,8 +883,17 @@ class MapLocationPickerRoute extends PageRouteInfo { static const String name = 'MapLocationPickerRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const MapLocationPickerRouteArgs()); + return MapLocationPickerPage( + key: args.key, + initialLatLng: args.initialLatLng, + ); + }, + ); } class MapLocationPickerRouteArgs { @@ -1111,7 +923,12 @@ class MapRoute extends PageRouteInfo { static const String name = 'MapRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const MapPage(); + }, + ); } /// generated route for @@ -1134,7 +951,17 @@ class MemoryRoute extends PageRouteInfo { static const String name = 'MemoryRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return MemoryPage( + memories: args.memories, + memoryIndex: args.memoryIndex, + key: args.key, + ); + }, + ); } class MemoryRouteArgs { @@ -1174,8 +1001,16 @@ class PartnerDetailRoute extends PageRouteInfo { static const String name = 'PartnerDetailRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return PartnerDetailPage( + key: args.key, + partner: args.partner, + ); + }, + ); } class PartnerDetailRouteArgs { @@ -1205,7 +1040,12 @@ class PartnerRoute extends PageRouteInfo { static const String name = 'PartnerRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PartnerPage(); + }, + ); } /// generated route for @@ -1219,7 +1059,12 @@ class PermissionOnboardingRoute extends PageRouteInfo { static const String name = 'PermissionOnboardingRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PermissionOnboardingPage(); + }, + ); } /// generated route for @@ -1242,8 +1087,17 @@ class PersonResultRoute extends PageRouteInfo { static const String name = 'PersonResultRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return PersonResultPage( + key: args.key, + personId: args.personId, + personName: args.personName, + ); + }, + ); } class PersonResultRouteArgs { @@ -1276,7 +1130,12 @@ class PhotosRoute extends PageRouteInfo { static const String name = 'PhotosRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PhotosPage(); + }, + ); } /// generated route for @@ -1290,7 +1149,12 @@ class RecentlyAddedRoute extends PageRouteInfo { static const String name = 'RecentlyAddedRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const RecentlyAddedPage(); + }, + ); } /// generated route for @@ -1311,8 +1175,17 @@ class SearchInputRoute extends PageRouteInfo { static const String name = 'SearchInputRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const SearchInputRouteArgs()); + return SearchInputPage( + key: args.key, + prefilter: args.prefilter, + ); + }, + ); } class SearchInputRouteArgs { @@ -1342,7 +1215,12 @@ class SearchRoute extends PageRouteInfo { static const String name = 'SearchRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const SearchPage(); + }, + ); } /// generated route for @@ -1356,7 +1234,12 @@ class SettingsRoute extends PageRouteInfo { static const String name = 'SettingsRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const SettingsPage(); + }, + ); } /// generated route for @@ -1377,8 +1260,16 @@ class SettingsSubRoute extends PageRouteInfo { static const String name = 'SettingsSubRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return SettingsSubPage( + args.section, + key: args.key, + ); + }, + ); } class SettingsSubRouteArgs { @@ -1419,8 +1310,19 @@ class SharedLinkEditRoute extends PageRouteInfo { static const String name = 'SharedLinkEditRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const SharedLinkEditRouteArgs()); + return SharedLinkEditPage( + key: args.key, + existingLink: args.existingLink, + assetsList: args.assetsList, + albumId: args.albumId, + ); + }, + ); } class SharedLinkEditRouteArgs { @@ -1456,7 +1358,12 @@ class SharedLinkRoute extends PageRouteInfo { static const String name = 'SharedLinkRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const SharedLinkPage(); + }, + ); } /// generated route for @@ -1470,7 +1377,12 @@ class SharingRoute extends PageRouteInfo { static const String name = 'SharingRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const SharingPage(); + }, + ); } /// generated route for @@ -1484,7 +1396,12 @@ class SplashScreenRoute extends PageRouteInfo { static const String name = 'SplashScreenRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const SplashScreenPage(); + }, + ); } /// generated route for @@ -1498,7 +1415,12 @@ class TabControllerRoute extends PageRouteInfo { static const String name = 'TabControllerRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const TabControllerPage(); + }, + ); } /// generated route for @@ -1512,5 +1434,10 @@ class TrashRoute extends PageRouteInfo { static const String name = 'TrashRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const TrashPage(); + }, + ); } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 69a608b0cf..efe353e2ea 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.5.0" async: dependency: "direct main" description: @@ -61,18 +61,18 @@ packages: dependency: "direct main" description: name: auto_route - sha256: "6cad3f408863ffff2b5757967c802b18415dac4acb1b40c5cdd45d0a26e5080f" + sha256: bb673104dbdc22667d01ec668df3d2a358b6e3da481428eeb1151933cfc1a7d6 url: "https://pub.dev" source: hosted - version: "8.1.3" + version: "9.2.0" auto_route_generator: dependency: "direct dev" description: name: auto_route_generator - sha256: ba28133d3a3bf0a66772bcc98dade5843753cd9f1a8fb4802b842895515b67d3 + sha256: c9086eb07271e51b44071ad5cff34e889f3156710b964a308c2ab590769e79e6 url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "9.0.0" boolean_selector: dependency: transitive description: @@ -117,10 +117,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "581bacf68f89ec8792f5e5a0b2c4decd1c948e97ce659dc783688c8a88fbec21" + sha256: "644dc98a0f179b872f612d3eb627924b578897c629788e858157fa5e704ca0c7" url: "https://pub.dev" source: hosted - version: "2.4.8" + version: "2.4.11" build_runner_core: dependency: transitive description: @@ -237,10 +237,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "4ad01d6e56db961d29661561effde45e519939fdaeb46c351275b182eac70189" + sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 url: "https://pub.dev" source: hosted - version: "4.5.0" + version: "4.10.0" collection: dependency: "direct main" description: @@ -341,10 +341,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.6" dartx: dependency: transitive description: @@ -1431,10 +1431,10 @@ packages: dependency: transitive description: name: source_gen - sha256: fc0da689e5302edb6177fdd964efcb7f58912f43c28c2047a808f5bfff643d16 + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" source_span: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6752ad59b6..6c765b966d 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -23,7 +23,7 @@ dependencies: cached_network_image: ^3.3.1 flutter_cache_manager: ^3.3.1 intl: ^0.19.0 - auto_route: ^8.0.2 + auto_route: ^9.2.0 fluttertoast: ^8.2.4 video_player: ^2.8.2 chewie: ^1.7.4 @@ -94,7 +94,7 @@ dev_dependencies: sdk: flutter flutter_lints: ^4.0.0 build_runner: ^2.4.8 - auto_route_generator: ^8.0.0 + auto_route_generator: ^9.0.0 flutter_launcher_icons: ^0.13.1 flutter_native_splash: ^2.3.9 isar_generator: ^3.1.0+1 From 1dae622dbcc1b17e9755058d1f591601df78399e Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 6 Aug 2024 14:39:07 -0500 Subject: [PATCH 033/723] chore(mobile): minor styling fix (#11619) --- mobile/lib/pages/common/create_album.page.dart | 11 ++++++----- .../widgets/album/album_action_filled_button.dart | 2 +- .../lib/widgets/album/album_title_text_field.dart | 13 ++++++++----- .../common/app_bar_dialog/app_bar_dialog.dart | 1 - 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/mobile/lib/pages/common/create_album.page.dart b/mobile/lib/pages/common/create_album.page.dart index 1ed6885a07..51282d8dd6 100644 --- a/mobile/lib/pages/common/create_album.page.dart +++ b/mobile/lib/pages/common/create_album.page.dart @@ -114,11 +114,11 @@ class CreateAlbumPage extends HookConsumerWidget { style: FilledButton.styleFrom( alignment: Alignment.centerLeft, padding: - const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + const EdgeInsets.symmetric(vertical: 24, horizontal: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), - backgroundColor: context.colorScheme.surfaceContainerHighest, + backgroundColor: context.colorScheme.surfaceContainerHigh, ), onPressed: onSelectPhotosButtonPressed, icon: Icon( @@ -130,7 +130,8 @@ class CreateAlbumPage extends HookConsumerWidget { child: Text( 'create_shared_album_page_share_select_photos', style: context.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.normal, + fontWeight: FontWeight.w600, + color: context.primaryColor, ), ).tr(), ), @@ -146,7 +147,7 @@ class CreateAlbumPage extends HookConsumerWidget { return Padding( padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16), child: SizedBox( - height: 30, + height: 42, child: ListView( scrollDirection: Axis.horizontal, children: [ @@ -262,7 +263,7 @@ class CreateAlbumPage extends HookConsumerWidget { pinned: true, floating: false, bottom: PreferredSize( - preferredSize: const Size.fromHeight(66.0), + preferredSize: const Size.fromHeight(96.0), child: Column( children: [ buildTitleInputField(), diff --git a/mobile/lib/widgets/album/album_action_filled_button.dart b/mobile/lib/widgets/album/album_action_filled_button.dart index 6a466aa4f1..de73307443 100644 --- a/mobile/lib/widgets/album/album_action_filled_button.dart +++ b/mobile/lib/widgets/album/album_action_filled_button.dart @@ -19,7 +19,7 @@ class AlbumActionFilledButton extends StatelessWidget { padding: const EdgeInsets.only(right: 16.0), child: FilledButton.icon( style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 10), + padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(25), ), diff --git a/mobile/lib/widgets/album/album_title_text_field.dart b/mobile/lib/widgets/album/album_title_text_field.dart index d005a96417..8a5c28d6af 100644 --- a/mobile/lib/widgets/album/album_title_text_field.dart +++ b/mobile/lib/widgets/album/album_title_text_field.dart @@ -59,18 +59,21 @@ class AlbumTitleTextField extends ConsumerWidget { splashRadius: 10, ) : null, - enabledBorder: const OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent), + enabledBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.transparent), + borderRadius: BorderRadius.circular(10), ), - focusedBorder: const OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.transparent), + borderRadius: BorderRadius.circular(10), ), hintText: 'share_add_title'.tr(), hintStyle: context.themeData.inputDecorationTheme.hintStyle?.copyWith( fontSize: 28, + fontWeight: FontWeight.bold, ), focusColor: Colors.grey[300], - fillColor: context.scaffoldBackgroundColor, + fillColor: context.colorScheme.surfaceContainerHigh, filled: isAlbumTitleTextFieldFocus.value, ), ); diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index 5b6e60b1db..1c9713f4d7 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -57,7 +57,6 @@ class ImmichAppBarDialog extends HookConsumerWidget { ? 'assets/immich-text-dark.png' : 'assets/immich-text-light.png', height: 16, - color: context.primaryColor, ), ), ], From 745e1b003dd5343fb2e712726c4ccb89c4bb3da5 Mon Sep 17 00:00:00 2001 From: Saschl <19493808+Saschl@users.noreply.github.com> Date: Wed, 7 Aug 2024 00:13:11 +0200 Subject: [PATCH 034/723] feat(mobile): enable wakelock on backup page (#11621) --- mobile/lib/pages/backup/backup_controller.page.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mobile/lib/pages/backup/backup_controller.page.dart b/mobile/lib/pages/backup/backup_controller.page.dart index 61a6bc1bb9..86cd8b2baa 100644 --- a/mobile/lib/pages/backup/backup_controller.page.dart +++ b/mobile/lib/pages/backup/backup_controller.page.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; import 'package:immich_mobile/widgets/backup/current_backup_asset_info_box.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; @RoutePage() class BackupControllerPage extends HookConsumerWidget { @@ -49,7 +50,11 @@ class BackupControllerPage extends HookConsumerWidget { ref .watch(websocketProvider.notifier) .stopListenToEvent('on_upload_success'); - return null; + + WakelockPlus.enable(); + return () { + WakelockPlus.disable(); + }; }, [], ); From ea135cc3107b53d54808299da6db28970b71701d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 6 Aug 2024 22:59:26 -0400 Subject: [PATCH 035/723] chore(deps): update dependency @types/node to ^20.14.13 (#11604) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 10 +++++----- cli/package.json | 2 +- e2e/package-lock.json | 12 ++++++------ e2e/package.json | 2 +- open-api/typescript-sdk/package-lock.json | 8 ++++---- open-api/typescript-sdk/package.json | 2 +- server/package-lock.json | 14 +++++++------- server/package.json | 2 +- 8 files changed, 26 insertions(+), 26 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index b442ea77cb..4e8ff311df 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -24,7 +24,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.12", + "@types/node": "^20.14.13", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -59,7 +59,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.12", + "@types/node": "^20.14.13", "typescript": "^5.3.3" } }, @@ -1269,9 +1269,9 @@ } }, "node_modules/@types/node": { - "version": "20.14.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", - "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "version": "20.14.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", + "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 2d4cb4ba81..805efb0124 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.12", + "@types/node": "^20.14.13", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index ed135d580e..70ffcf8fc7 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.14.12", + "@types/node": "^20.14.13", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -64,7 +64,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.12", + "@types/node": "^20.14.13", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -99,7 +99,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.12", + "@types/node": "^20.14.13", "typescript": "^5.3.3" } }, @@ -1516,9 +1516,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.14.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", - "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "version": "20.14.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", + "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index fabcc5cd98..1b272c7229 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.14.12", + "@types/node": "^20.14.13", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 6c0a40930e..6dd2e5d3f4 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -12,7 +12,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.12", + "@types/node": "^20.14.13", "typescript": "^5.3.3" } }, @@ -22,9 +22,9 @@ "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" }, "node_modules/@types/node": { - "version": "20.14.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", - "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "version": "20.14.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", + "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index dd9aa16f02..9f80d5b5f9 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.12", + "@types/node": "^20.14.13", "typescript": "^5.3.3" }, "repository": { diff --git a/server/package-lock.json b/server/package-lock.json index bcd7072eff..885d1c9f24 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -83,7 +83,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.14.12", + "@types/node": "^20.14.13", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/semver": "^7.5.8", @@ -6029,9 +6029,9 @@ } }, "node_modules/@types/node": { - "version": "20.14.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", - "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "version": "20.14.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", + "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", "dependencies": { "undici-types": "~5.26.4" } @@ -20350,9 +20350,9 @@ } }, "@types/node": { - "version": "20.14.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", - "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "version": "20.14.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", + "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", "requires": { "undici-types": "~5.26.4" } diff --git a/server/package.json b/server/package.json index 5a8d24919e..de2ed9ca82 100644 --- a/server/package.json +++ b/server/package.json @@ -109,7 +109,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.14.12", + "@types/node": "^20.14.13", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/semver": "^7.5.8", From 23d4314eed2c26b88e5a86bc38029c866151715b Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 6 Aug 2024 23:04:55 -0400 Subject: [PATCH 036/723] chore(server): support pgvecto.rs 0.3.0 (#11624) relax pgvecto.rs constraint --- server/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/constants.ts b/server/src/constants.ts index 422fa21a1b..f3a6c486ad 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { SemVer } from 'semver'; export const POSTGRES_VERSION_RANGE = '>=14.0.0'; -export const VECTORS_VERSION_RANGE = '0.2.x'; +export const VECTORS_VERSION_RANGE = '>=0.2 <0.4'; export const VECTOR_VERSION_RANGE = '>=0.5 <1'; export const NEXT_RELEASE = 'NEXT_RELEASE'; From 10ed31d725ad5fc2e6ebcbc546af9cda8ee003b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Aug 2024 08:31:23 -0400 Subject: [PATCH 037/723] chore(deps): bump docker/build-push-action from 6.5.0 to 6.6.0 (#11629) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.5.0 to 6.6.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6.5.0...v6.6.0) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli.yml | 2 +- .github/workflows/docker.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 691bfdcce8..bd54ba4eff 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -88,7 +88,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@v6.5.0 + uses: docker/build-push-action@v6.6.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0cf2668ec5..cf85762533 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -115,7 +115,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.5.0 + uses: docker/build-push-action@v6.6.0 with: context: ${{ matrix.context }} file: ${{ matrix.file }} From 02fd6d22b38a343fd8de8c6283519e17025a7603 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 7 Aug 2024 08:36:30 -0400 Subject: [PATCH 038/723] chore: more cursed knowledge (#11630) --- docs/src/pages/cursed-knowledge.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/src/pages/cursed-knowledge.tsx b/docs/src/pages/cursed-knowledge.tsx index ade68161ba..9b49f8b8c4 100644 --- a/docs/src/pages/cursed-knowledge.tsx +++ b/docs/src/pages/cursed-knowledge.tsx @@ -1,4 +1,4 @@ -import { mdiCalendarToday, mdiLeadPencil, mdiLockOutline, mdiSpeedometerSlow, mdiWeb } from '@mdi/js'; +import { mdiCalendarToday, mdiLeadPencil, mdiLockOff, mdiLockOutline, mdiSpeedometerSlow, mdiWeb } from '@mdi/js'; import Layout from '@theme/Layout'; import React from 'react'; import { Item as TimelineItem, Timeline } from '../components/timeline'; @@ -8,6 +8,18 @@ const withLanguage = (date: Date) => (language: string) => date.toLocaleDateStri type Item = Omit & { date: Date }; const items: Item[] = [ + { + icon: mdiLockOff, + iconColor: 'red', + title: 'Fetch inside Cloudflare Workers is cursed', + description: + 'Fetch requests in Cloudflare Workers use http by default, even if you explicitly specify https, which can often cause redirect loops.', + link: { + url: 'https://community.cloudflare.com/t/does-cloudflare-worker-allow-secure-https-connection-to-fetch-even-on-flexible-ssl/68051/5', + text: 'Cloudflare', + }, + date: new Date(2024, 7, 7), + }, { icon: mdiLeadPencil, iconColor: 'gold', From 5b64456f4821b87d3a4b11076a2803953e918994 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 7 Aug 2024 09:54:57 -0400 Subject: [PATCH 039/723] chore: more cursed knowledge (#11631) * chore: more cursed knowledge * chore: more cursed knowledge * chore: rework footer --- docs/docusaurus.config.js | 34 +++++++++++++++++++++-------- docs/src/pages/cursed-knowledge.tsx | 34 ++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 6e3152cb0d..a94a54b60c 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -145,28 +145,36 @@ const config = { label: 'Installation', to: '/docs/install/requirements', }, + { + label: 'Contributing', + to: '/docs/overview/support-the-project', + }, + { + label: 'Privacy Policy', + to: '/privacy-policy', + }, ], }, { - title: 'Community', + title: 'Documentation', items: [ { - label: 'Discord', - href: 'https://discord.immich.app', + label: 'Roadmap', + to: '/roadmap', }, { - label: 'Reddit', - href: 'https://www.reddit.com/r/immich/', + label: 'API', + to: '/docs/api', + }, + { + label: 'Cursed Knowledge', + to: '/cursed-knowledge', }, ], }, { title: 'Links', items: [ - // { - // label: "Blog", - // to: "/blog", - // }, { label: 'GitHub', href: 'https://github.com/immich-app/immich', @@ -175,6 +183,14 @@ const config = { label: 'YouTube', href: 'https://www.youtube.com/@immich-app', }, + { + label: 'Discord', + href: 'https://discord.immich.app', + }, + { + label: 'Reddit', + href: 'https://www.reddit.com/r/immich/', + }, ], }, ], diff --git a/docs/src/pages/cursed-knowledge.tsx b/docs/src/pages/cursed-knowledge.tsx index 9b49f8b8c4..638868bec5 100644 --- a/docs/src/pages/cursed-knowledge.tsx +++ b/docs/src/pages/cursed-knowledge.tsx @@ -1,4 +1,13 @@ -import { mdiCalendarToday, mdiLeadPencil, mdiLockOff, mdiLockOutline, mdiSpeedometerSlow, mdiWeb } from '@mdi/js'; +import { + mdiCalendarToday, + mdiCrosshairsOff, + mdiLeadPencil, + mdiLockOff, + mdiLockOutline, + mdiSpeedometerSlow, + mdiWeb, + mdiWrap, +} from '@mdi/js'; import Layout from '@theme/Layout'; import React from 'react'; import { Item as TimelineItem, Timeline } from '../components/timeline'; @@ -8,6 +17,17 @@ const withLanguage = (date: Date) => (language: string) => date.toLocaleDateStri type Item = Omit & { date: Date }; const items: Item[] = [ + { + icon: mdiWrap, + iconColor: 'gray', + title: 'Carriage returns in bash scripts are cursed', + description: 'Git can be configured to automatically convert LF to CRLF on checkout and CRLF breaks bash scripts.', + link: { + url: 'https://github.com/immich-app/immich/pull/11613', + text: '#11613', + }, + date: new Date(2024, 7, 7), + }, { icon: mdiLockOff, iconColor: 'red', @@ -20,6 +40,18 @@ const items: Item[] = [ }, date: new Date(2024, 7, 7), }, + { + icon: mdiCrosshairsOff, + iconColor: 'gray', + title: 'GPS sharing on mobile is cursed', + description: + 'Some phones will silently strip GPS data from images when apps without location permission try to access them.', + link: { + url: 'https://github.com/immich-app/immich/discussions/11268', + text: '#11268', + }, + date: new Date(2024, 6, 21), + }, { icon: mdiLeadPencil, iconColor: 'gold', From 28ba22e8c12749d0e5efe0a4400c807d9abcb5d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Gro=C3=9F?= Date: Wed, 7 Aug 2024 17:23:36 +0200 Subject: [PATCH 040/723] fix(server): handle numeric 'Image Description' and 'Description' values (#11636) * Made 'Image Description' and 'Description' type safe during exif parsing * add test + update types --------- Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> --- server/src/interfaces/metadata.interface.ts | 6 +++++- server/src/services/metadata.service.spec.ts | 12 ++++++++++++ server/src/services/metadata.service.ts | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index daba4184e3..386f69a9e7 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -7,7 +7,7 @@ export interface ExifDuration { Scale?: number; } -export interface ImmichTags extends Omit { +export interface ImmichTags extends Omit { ContentIdentifier?: string; MotionPhoto?: number; MotionPhotoVersion?: number; @@ -19,6 +19,10 @@ export interface ImmichTags extends Omit { EmbeddedVideoType?: string; EmbeddedVideoFile?: BinaryField; MotionPhotoVideo?: BinaryField; + + // Type is wrong, can also be number. + Description?: string | number; + ImageDescription?: string | number; } export interface IMetadataRepository { diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index e9d09e33aa..d1806a1f4c 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -753,6 +753,18 @@ describe(MetadataService.name, () => { }), ); }); + + it('handles a numeric description', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Description: 1000 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + description: '1000', + }), + ); + }); }); describe('handleQueueSidecar', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index aa29d47131..126a49ee6c 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -482,7 +482,7 @@ export class MetadataService implements OnEvents { bitsPerSample: this.getBitsPerSample(tags), colorspace: tags.ColorSpace ?? null, dateTimeOriginal: this.getDateTimeOriginal(tags) ?? asset.fileCreatedAt, - description: (tags.ImageDescription || tags.Description || '').trim(), + description: String(tags.ImageDescription || tags.Description || '').trim(), exifImageHeight: validate(tags.ImageHeight), exifImageWidth: validate(tags.ImageWidth), exposureTime: tags.ExposureTime ?? null, From aeed24b5b408409de560dce4fac116a9a14928bb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 7 Aug 2024 15:45:30 +0000 Subject: [PATCH 041/723] fix(deps): update typescript-projects (#11606) * fix(deps): update typescript-projects * fix: type error --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Jason Rasmussen --- docs/package-lock.json | 22 +- server/package-lock.json | 454 +++++++++----------- server/package.json | 2 +- server/src/repositories/media.repository.ts | 7 +- web/package-lock.json | 44 +- 5 files changed, 245 insertions(+), 284 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index bb83c65b25..d7af7be4cf 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -13747,9 +13747,10 @@ } }, "node_modules/qs": { - "version": "6.12.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", - "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.0.6" }, @@ -16714,12 +16715,16 @@ } }, "node_modules/url": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/url/-/url-0.11.3.tgz", - "integrity": "sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw==", + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "license": "MIT", "dependencies": { "punycode": "^1.4.1", - "qs": "^6.11.2" + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/url-loader": { @@ -16783,7 +16788,8 @@ "node_modules/url/node_modules/punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "license": "MIT" }, "node_modules/util": { "version": "0.10.4", diff --git a/server/package-lock.json b/server/package-lock.json index 885d1c9f24..6d793bac9a 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -20,7 +20,7 @@ "@nestjs/swagger": "^7.1.8", "@nestjs/typeorm": "^10.0.0", "@nestjs/websockets": "^10.2.2", - "@opentelemetry/auto-instrumentations-node": "^0.48.0", + "@opentelemetry/auto-instrumentations-node": "^0.49.0", "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.52.0", "@opentelemetry/sdk-node": "^0.52.0", @@ -2082,11 +2082,11 @@ ] }, "node_modules/@nestjs/bull-shared": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.1.1.tgz", - "integrity": "sha512-su7eThDrSz1oQagEi8l+1CyZ7N6nMgmyAX0DuZoXqT1KEVEDqGX7x80RlPVF60m/8SYOskckGMjJROSfNQcErw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.0.tgz", + "integrity": "sha512-cSi6CyPECHDFumnHWWfwLCnbc6hm5jXt7FqzJ0Id6EhGqdz5ja0FmgRwXoS4xoMA2RRjlxn2vGXr4YOaHBAeig==", "dependencies": { - "tslib": "2.6.2" + "tslib": "2.6.3" }, "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", @@ -2094,12 +2094,12 @@ } }, "node_modules/@nestjs/bullmq": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.1.1.tgz", - "integrity": "sha512-afYx1wYCKtXEu1p0S1+qw2o7QaZWr/EQgF7Wkt3YL8RBIECy5S4C450gv/cRGd8EZjlt6bw8hGCLqR2Q5VjHpQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.0.tgz", + "integrity": "sha512-lHXWDocXh1Yl6unsUzGFEKmK02mu0DdI35cdBp3Fq/9D5V3oLuWjwAPFnTztedshIjlFmNW6x5mdaT5WZ0AV1Q==", "dependencies": { - "@nestjs/bull-shared": "^10.1.1", - "tslib": "2.6.2" + "@nestjs/bull-shared": "^10.2.0", + "tslib": "2.6.3" }, "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", @@ -2193,11 +2193,6 @@ } } }, - "node_modules/@nestjs/common/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" - }, "node_modules/@nestjs/config": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.2.3.tgz", @@ -2249,11 +2244,6 @@ } } }, - "node_modules/@nestjs/core/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" - }, "node_modules/@nestjs/event-emitter": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.0.4.tgz", @@ -2305,11 +2295,6 @@ "@nestjs/core": "^10.0.0" } }, - "node_modules/@nestjs/platform-express/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" - }, "node_modules/@nestjs/platform-socket.io": { "version": "10.3.10", "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.10.tgz", @@ -2328,11 +2313,6 @@ "rxjs": "^7.1.0" } }, - "node_modules/@nestjs/platform-socket.io/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" - }, "node_modules/@nestjs/schedule": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.0.tgz", @@ -2439,12 +2419,6 @@ } } }, - "node_modules/@nestjs/testing/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true - }, "node_modules/@nestjs/typeorm": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", @@ -2482,11 +2456,6 @@ } } }, - "node_modules/@nestjs/websockets/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" - }, "node_modules/@next/env": { "version": "14.1.4", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.4.tgz", @@ -2701,21 +2670,21 @@ } }, "node_modules/@opentelemetry/auto-instrumentations-node": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.48.0.tgz", - "integrity": "sha512-meON9LM9dyPun8ZlIs90BzqHAIWfWkC8g+OoPuIEeV5UOSyKqMsWtbMyiTbs/k/i7k1V4miJQMX/PcLbD7pWcQ==", + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.49.1.tgz", + "integrity": "sha512-oF8g0cOEL4u1xkoAgSFAhOwMVVwDyZod6g1hVL1TtmpHTGMeEP2FfM6pPHE1soAFyddxd4B3NahZX3xczEbLdA==", "dependencies": { "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/instrumentation-amqplib": "^0.39.0", + "@opentelemetry/instrumentation-amqplib": "^0.41.0", "@opentelemetry/instrumentation-aws-lambda": "^0.43.0", - "@opentelemetry/instrumentation-aws-sdk": "^0.43.0", + "@opentelemetry/instrumentation-aws-sdk": "^0.43.1", "@opentelemetry/instrumentation-bunyan": "^0.40.0", "@opentelemetry/instrumentation-cassandra-driver": "^0.40.0", "@opentelemetry/instrumentation-connect": "^0.38.0", "@opentelemetry/instrumentation-cucumber": "^0.8.0", "@opentelemetry/instrumentation-dataloader": "^0.11.0", "@opentelemetry/instrumentation-dns": "^0.38.0", - "@opentelemetry/instrumentation-express": "^0.41.0", + "@opentelemetry/instrumentation-express": "^0.41.1", "@opentelemetry/instrumentation-fastify": "^0.38.0", "@opentelemetry/instrumentation-fs": "^0.14.0", "@opentelemetry/instrumentation-generic-pool": "^0.38.0", @@ -2724,7 +2693,8 @@ "@opentelemetry/instrumentation-hapi": "^0.40.0", "@opentelemetry/instrumentation-http": "^0.52.0", "@opentelemetry/instrumentation-ioredis": "^0.42.0", - "@opentelemetry/instrumentation-knex": "^0.38.0", + "@opentelemetry/instrumentation-kafkajs": "^0.2.0", + "@opentelemetry/instrumentation-knex": "^0.39.0", "@opentelemetry/instrumentation-koa": "^0.42.0", "@opentelemetry/instrumentation-lru-memoizer": "^0.39.0", "@opentelemetry/instrumentation-memcached": "^0.38.0", @@ -2744,7 +2714,7 @@ "@opentelemetry/instrumentation-tedious": "^0.12.0", "@opentelemetry/instrumentation-undici": "^0.4.0", "@opentelemetry/instrumentation-winston": "^0.39.0", - "@opentelemetry/resource-detector-alibaba-cloud": "^0.28.10", + "@opentelemetry/resource-detector-alibaba-cloud": "^0.29.0", "@opentelemetry/resource-detector-aws": "^1.5.2", "@opentelemetry/resource-detector-azure": "^0.2.9", "@opentelemetry/resource-detector-container": "^0.3.11", @@ -3017,9 +2987,9 @@ } }, "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.39.0.tgz", - "integrity": "sha512-i9SccU5bbHivmmN8ba8HitLnM915BWdGwk5Jl6dwHTp0eV4KpoprZLE/jXUY1AAP/LXpTrM7NgVHmslFSVWRYA==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.41.0.tgz", + "integrity": "sha512-00Oi6N20BxJVcqETjgNzCmVKN+I5bJH/61IlHiIWd00snj1FdgiIKlpE4hYVacTB2sjIBB3nTbHskttdZEE2eg==", "dependencies": { "@opentelemetry/core": "^1.8.0", "@opentelemetry/instrumentation": "^0.52.0", @@ -3051,9 +3021,9 @@ } }, "node_modules/@opentelemetry/instrumentation-aws-sdk": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.43.0.tgz", - "integrity": "sha512-klfA48MT0uZY/mGw3cYdQeCXTyMhtY4FzHcZy9R7DdTcuCExgbxWrUlOSiqIJ5kBgsCZfBMEeA6UQKDBwa6X7Q==", + "version": "0.43.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.43.1.tgz", + "integrity": "sha512-qLT2cCniJ5W+6PFzKbksnoIQuq9pS83nmgaExfUwXVvlwi0ILc50dea0tWBHZMkdIDa/zZdcuFrJ7+fUcSnRow==", "dependencies": { "@opentelemetry/core": "^1.8.0", "@opentelemetry/instrumentation": "^0.52.0", @@ -3160,9 +3130,9 @@ } }, "node_modules/@opentelemetry/instrumentation-express": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.41.0.tgz", - "integrity": "sha512-/B7fbMdaf3SYe5f1P973tkqd6s7XZirjpfkoJ63E7nltU30qmlgm9tY5XwZOzAFI0rHS9tbrFI2HFPAvQUFe/A==", + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.41.1.tgz", + "integrity": "sha512-uRx0V3LPGzjn2bxAnV8eUsDT82vT7NTwI0ezEuPMBOTOsnPpGhWdhcdNdhH80sM4TrWrOfXm9HGEdfWE3TRIww==", "dependencies": { "@opentelemetry/core": "^1.8.0", "@opentelemetry/instrumentation": "^0.52.0", @@ -3298,10 +3268,25 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.2.0.tgz", + "integrity": "sha512-uKKmhEFd0zR280tJovuiBG7cfnNZT4kvVTvqtHPxQP7nOmRbJstCYHFH13YzjVcKjkmoArmxiSulmZmF7SLIlg==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.38.0.tgz", - "integrity": "sha512-EFef6Ss5ATsf5AxJOLE+pxkfupcWDaejkPH+2q7TNeG1UwsBFobfiWM+iHROZ1Cl/y3mTi60MW70FxsaX2/TjA==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.39.0.tgz", + "integrity": "sha512-lRwTqIKQecPWDkH1KEcAUcFhCaNssbKSpxf4sxRTAROCwrCEnYkjOuqJHV+q1/CApjMTaKu0Er4LBv/6bDpoxA==", "dependencies": { "@opentelemetry/instrumentation": "^0.52.0", "@opentelemetry/semantic-conventions": "^1.22.0" @@ -3856,9 +3841,9 @@ } }, "node_modules/@opentelemetry/resource-detector-alibaba-cloud": { - "version": "0.28.10", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.28.10.tgz", - "integrity": "sha512-TZv/1Y2QCL6sJ+X9SsPPBXe4786bc/Qsw0hQXFsNTbJzDTGGUmOAlSZ2qPiuqAd4ZheUYfD+QA20IvAjUz9Hhg==", + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.29.0.tgz", + "integrity": "sha512-cYL1DfBwszTQcpzjiezzFkZp1bzevXjaVJ+VClrufHzH17S0RADcaLRQcLq4GqbWCGfvkJKUqBNz6f1SgfePgw==", "dependencies": { "@opentelemetry/resources": "^1.0.0", "@opentelemetry/semantic-conventions": "^1.22.0" @@ -5498,9 +5483,9 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "node_modules/@swc/core": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.2.tgz", - "integrity": "sha512-mjIlT0e6ygKR8LZ1TjtNrDVMhnB8qpyYAdwexhuVHY255yDdDQCpuPGi20odwnE82QhFBSIWs4HcENDVO/yiMw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.5.tgz", + "integrity": "sha512-qKK0/Ta4qvxs/ok3XyYVPT7OBenwRn1sSINf1cKQTBHPqr7U/uB4k2GTl6JgEs8H4PiJrMTNWfMLTucIoVSfAg==", "devOptional": true, "hasInstallScript": true, "dependencies": { @@ -5515,16 +5500,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.7.2", - "@swc/core-darwin-x64": "1.7.2", - "@swc/core-linux-arm-gnueabihf": "1.7.2", - "@swc/core-linux-arm64-gnu": "1.7.2", - "@swc/core-linux-arm64-musl": "1.7.2", - "@swc/core-linux-x64-gnu": "1.7.2", - "@swc/core-linux-x64-musl": "1.7.2", - "@swc/core-win32-arm64-msvc": "1.7.2", - "@swc/core-win32-ia32-msvc": "1.7.2", - "@swc/core-win32-x64-msvc": "1.7.2" + "@swc/core-darwin-arm64": "1.7.5", + "@swc/core-darwin-x64": "1.7.5", + "@swc/core-linux-arm-gnueabihf": "1.7.5", + "@swc/core-linux-arm64-gnu": "1.7.5", + "@swc/core-linux-arm64-musl": "1.7.5", + "@swc/core-linux-x64-gnu": "1.7.5", + "@swc/core-linux-x64-musl": "1.7.5", + "@swc/core-win32-arm64-msvc": "1.7.5", + "@swc/core-win32-ia32-msvc": "1.7.5", + "@swc/core-win32-x64-msvc": "1.7.5" }, "peerDependencies": { "@swc/helpers": "*" @@ -5536,9 +5521,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.2.tgz", - "integrity": "sha512-Zb8KiGaESzOgh5HBnp6Vhs2fRpngHIT81JOfIo0oaGlzAckamnG7UAXC/yK6cQ8q2KXc78utJ/yq/NM2yVKLqw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.5.tgz", + "integrity": "sha512-Y+bvW9C4/u26DskMbtQKT4FU6QQenaDYkKDi028vDIKAa7v1NZqYG9wmhD/Ih7n5EUy2uJ5I5EWD7WaoLzT6PA==", "cpu": [ "arm64" ], @@ -5552,9 +5537,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.2.tgz", - "integrity": "sha512-qb0HY9GEexpPm46Hb3OY7E6xb4r+eniiThm+0Gcnhf19EZV2ZlsCC8Rdbhmav33x++ZqSDzZ44fxMY2vnN5VDg==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.5.tgz", + "integrity": "sha512-AuIbDlcaAhYS6mtF4UqvXgrLeAfXZbVf4pgtgShPbutF80VbCQiIB55zOFz5aZdCpsBVuCWcBq0zLneK+VQKkQ==", "cpu": [ "x64" ], @@ -5568,9 +5553,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.2.tgz", - "integrity": "sha512-x2+MOK3RzH3yEkaukKtpDW/udM1x9GoYtXaLNqlq6ovAzZPQ9FDFI0pm1asL4akHUw3s7YTh1aUY7QscstJAHQ==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.5.tgz", + "integrity": "sha512-99uBPHITRqgGwCXAjHY94VaV3Z40+D2NQNgR1t6xQpO8ZnevI6YSzX6GVZfBnV7+7oisiGkrVEwfIRRa+1s8FA==", "cpu": [ "arm" ], @@ -5584,9 +5569,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.2.tgz", - "integrity": "sha512-4J3HGEDus7a9xnrJUFGyJJgvj4w+BFGiZvs08xbw4Z1ZN4uHJQiJiDsQEAWWciKUxrOndP3SocUq/GhEGiDm0g==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.5.tgz", + "integrity": "sha512-xHL3Erlz+OGGCG4h6K2HWiR56H5UYMuBWWPbbUufi2bJpfhuKQy/X3vWffwL8ZVfJmCUwr4/G91GHcm32uYzRg==", "cpu": [ "arm64" ], @@ -5600,9 +5585,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.2.tgz", - "integrity": "sha512-4FhQmYbj8SCmir4pHRLSn8IIFmRKHTL3eZFtOpm26RLME7rXL7Yt33DpzIeTRoHFIesI5NEfaR38WU5mY7P1pA==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.5.tgz", + "integrity": "sha512-5ArGdqvFMszNHdi4a67vopeYq8d1K+FuTWDrblHrAvZFhAyv+GQz2PnKqYOgl0sWmQxsNPfNwBFtxACpUO3Jzg==", "cpu": [ "arm64" ], @@ -5616,9 +5601,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.2.tgz", - "integrity": "sha512-Loz10Hy6z5mBIAOe6OInOVsYu+PVxyknCB3thtr7QH+uqEz6dcXhU2ERrO2Lf4dsTsFs/Wb80rv8zTSwB8dpsw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.5.tgz", + "integrity": "sha512-mSVVV/PFzCGtI1nVQQyx34NwCMgSurF6ZX/me8pUAX054vsE/pSFL66xN+kQOe/1Z/LOd4UmXFkZ/EzOSnYcSg==", "cpu": [ "x64" ], @@ -5632,9 +5617,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.2.tgz", - "integrity": "sha512-8p8qNWaLcTa+qHX4NSv1KNm8BQ6zPoLXuOBo9DtOEqc+K60IISGKPCAS7TJlCcv0q20JnmxZ/cEWW5Qo4TR4XQ==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.5.tgz", + "integrity": "sha512-09hY3ZKMUORXVunESKS9yuP78+gQbr759GKHo8wyCdtAx8lCZdEjfI5NtC7/1VqwfeE32/U6u+5MBTVhZTt0AA==", "cpu": [ "x64" ], @@ -5648,9 +5633,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.2.tgz", - "integrity": "sha512-eNWAYOalBlFrhv/IVSQ1dxu7qIGuhxlUJZTYa8jsgLnKt93vAFd2cjLtKZ85k1OibBnq9PkKQyo4NKVr4hBavw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.5.tgz", + "integrity": "sha512-B/UDtPI3RlYRFW42xQxOpl6kI/9LtkD7No+XeRIKQTPe15EP2o+rUlv7CmKljVBXgJ8KmaQbZlaEh1YP+QZEEQ==", "cpu": [ "arm64" ], @@ -5664,9 +5649,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.2.tgz", - "integrity": "sha512-BbpaCPCnbQHCzpQ9yDH3qp1Y5Ijd0NSMNk4qqESN2WWx0ojV2uBTjPou5NC2MZxk8fM3iJpJ05enf+IeaXuh6A==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.5.tgz", + "integrity": "sha512-BgLesVGmIY6Nub/sURqtSRvWYcbCE/ACfuZB3bZHVKD6nsZJJuOpdB8oC41fZPyc8yZUzL3XTBIifkT2RP+w9w==", "cpu": [ "ia32" ], @@ -5680,9 +5665,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.2.tgz", - "integrity": "sha512-21mf4Jg9Arx0lUnmRQtYd8IQB4WkY4LHJrvcz3EmKbwCTCXI5rQ6Ifnjk7EmG3Tizv0giHqQBQLu5NXWBz45Mg==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.5.tgz", + "integrity": "sha512-CnF557tidLfQRPczcqDJ8x+LBQYsFa0Ra6w2+YU1iFUboaI2jJVuqt3vEChu80y6JiRIBAaaV2L/GawDJh1dIQ==", "cpu": [ "x64" ], @@ -5717,12 +5702,12 @@ } }, "node_modules/@testcontainers/postgresql": { - "version": "10.10.4", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.10.4.tgz", - "integrity": "sha512-yGRW3IYXAnv91ncOyhf6XVSMbKqfKQzFbFdaSu67agtXwIUYvGE+RFXa/SMZ6oNKHNWgMGKXB9Paj7+md79+VQ==", + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.11.0.tgz", + "integrity": "sha512-TJC6kyb2lmkSF2XWvsjDVn61YRin8e1mE2IiLRkeR3mKWHm/LDwyRX14RTnRuNK7auSCCr35Ft/fKv/R6O5Taw==", "dev": true, "dependencies": { - "testcontainers": "^10.10.4" + "testcontainers": "^10.11.0" } }, "node_modules/@tsconfig/node10": { @@ -5936,9 +5921,9 @@ } }, "node_modules/@types/fluent-ffmpeg": { - "version": "2.1.24", - "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.24.tgz", - "integrity": "sha512-g5oQO8Jgi2kFS3tTub7wLvfLztr1s8tdXmRd8PiL/hLMLzTIAyMR2sANkTggM/rdEDAg3d63nYRRVepwBiCw5A==", + "version": "2.1.25", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.25.tgz", + "integrity": "sha512-a9/Jtv/RVaCG4lUwWIcuClWE5eXJFoFS/oHOecOv/RS8n+lQdJzcJVmDlxA8Xbk4B82YpO88Dijcoljb6sYTcA==", "dev": true, "dependencies": { "@types/node": "*" @@ -11527,9 +11512,9 @@ } }, "node_modules/nestjs-cls": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-4.3.0.tgz", - "integrity": "sha512-MVTun6tqCZih8AJXRj8uBuuFyJhQrIA9m9fStiQjbBXUkE3BrlMRvmLzyw8UcneB3xtFFTfwkAh5PYKRulyaOg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-4.4.0.tgz", + "integrity": "sha512-qxsptbCo8Cp7xnAxtWv9+pSqOtB2NCr9ekQDH3FhxPAmgOys8F4WEGhuLLQ9iyW4dwqCao0xXatqQyA4anedmQ==", "engines": { "node": ">=16" }, @@ -15661,9 +15646,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/tweetnacl": { "version": "0.14.5", @@ -17866,20 +17851,20 @@ "optional": true }, "@nestjs/bull-shared": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.1.1.tgz", - "integrity": "sha512-su7eThDrSz1oQagEi8l+1CyZ7N6nMgmyAX0DuZoXqT1KEVEDqGX7x80RlPVF60m/8SYOskckGMjJROSfNQcErw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.0.tgz", + "integrity": "sha512-cSi6CyPECHDFumnHWWfwLCnbc6hm5jXt7FqzJ0Id6EhGqdz5ja0FmgRwXoS4xoMA2RRjlxn2vGXr4YOaHBAeig==", "requires": { - "tslib": "2.6.2" + "tslib": "2.6.3" } }, "@nestjs/bullmq": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.1.1.tgz", - "integrity": "sha512-afYx1wYCKtXEu1p0S1+qw2o7QaZWr/EQgF7Wkt3YL8RBIECy5S4C450gv/cRGd8EZjlt6bw8hGCLqR2Q5VjHpQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.0.tgz", + "integrity": "sha512-lHXWDocXh1Yl6unsUzGFEKmK02mu0DdI35cdBp3Fq/9D5V3oLuWjwAPFnTztedshIjlFmNW6x5mdaT5WZ0AV1Q==", "requires": { - "@nestjs/bull-shared": "^10.1.1", - "tslib": "2.6.2" + "@nestjs/bull-shared": "^10.2.0", + "tslib": "2.6.3" } }, "@nestjs/cli": { @@ -17925,13 +17910,6 @@ "iterare": "1.2.1", "tslib": "2.6.3", "uid": "2.0.2" - }, - "dependencies": { - "tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" - } } }, "@nestjs/config": { @@ -17955,13 +17933,6 @@ "path-to-regexp": "3.2.0", "tslib": "2.6.3", "uid": "2.0.2" - }, - "dependencies": { - "tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" - } } }, "@nestjs/event-emitter": { @@ -17988,13 +17959,6 @@ "express": "4.19.2", "multer": "1.4.4-lts.1", "tslib": "2.6.3" - }, - "dependencies": { - "tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" - } } }, "@nestjs/platform-socket.io": { @@ -18004,13 +17968,6 @@ "requires": { "socket.io": "4.7.5", "tslib": "2.6.3" - }, - "dependencies": { - "tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" - } } }, "@nestjs/schedule": { @@ -18070,14 +18027,6 @@ "dev": true, "requires": { "tslib": "2.6.3" - }, - "dependencies": { - "tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true - } } }, "@nestjs/typeorm": { @@ -18096,13 +18045,6 @@ "iterare": "1.2.1", "object-hash": "3.0.0", "tslib": "2.6.3" - }, - "dependencies": { - "tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" - } } }, "@next/env": { @@ -18216,21 +18158,21 @@ } }, "@opentelemetry/auto-instrumentations-node": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.48.0.tgz", - "integrity": "sha512-meON9LM9dyPun8ZlIs90BzqHAIWfWkC8g+OoPuIEeV5UOSyKqMsWtbMyiTbs/k/i7k1V4miJQMX/PcLbD7pWcQ==", + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.49.1.tgz", + "integrity": "sha512-oF8g0cOEL4u1xkoAgSFAhOwMVVwDyZod6g1hVL1TtmpHTGMeEP2FfM6pPHE1soAFyddxd4B3NahZX3xczEbLdA==", "requires": { "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/instrumentation-amqplib": "^0.39.0", + "@opentelemetry/instrumentation-amqplib": "^0.41.0", "@opentelemetry/instrumentation-aws-lambda": "^0.43.0", - "@opentelemetry/instrumentation-aws-sdk": "^0.43.0", + "@opentelemetry/instrumentation-aws-sdk": "^0.43.1", "@opentelemetry/instrumentation-bunyan": "^0.40.0", "@opentelemetry/instrumentation-cassandra-driver": "^0.40.0", "@opentelemetry/instrumentation-connect": "^0.38.0", "@opentelemetry/instrumentation-cucumber": "^0.8.0", "@opentelemetry/instrumentation-dataloader": "^0.11.0", "@opentelemetry/instrumentation-dns": "^0.38.0", - "@opentelemetry/instrumentation-express": "^0.41.0", + "@opentelemetry/instrumentation-express": "^0.41.1", "@opentelemetry/instrumentation-fastify": "^0.38.0", "@opentelemetry/instrumentation-fs": "^0.14.0", "@opentelemetry/instrumentation-generic-pool": "^0.38.0", @@ -18239,7 +18181,8 @@ "@opentelemetry/instrumentation-hapi": "^0.40.0", "@opentelemetry/instrumentation-http": "^0.52.0", "@opentelemetry/instrumentation-ioredis": "^0.42.0", - "@opentelemetry/instrumentation-knex": "^0.38.0", + "@opentelemetry/instrumentation-kafkajs": "^0.2.0", + "@opentelemetry/instrumentation-knex": "^0.39.0", "@opentelemetry/instrumentation-koa": "^0.42.0", "@opentelemetry/instrumentation-lru-memoizer": "^0.39.0", "@opentelemetry/instrumentation-memcached": "^0.38.0", @@ -18259,7 +18202,7 @@ "@opentelemetry/instrumentation-tedious": "^0.12.0", "@opentelemetry/instrumentation-undici": "^0.4.0", "@opentelemetry/instrumentation-winston": "^0.39.0", - "@opentelemetry/resource-detector-alibaba-cloud": "^0.28.10", + "@opentelemetry/resource-detector-alibaba-cloud": "^0.29.0", "@opentelemetry/resource-detector-aws": "^1.5.2", "@opentelemetry/resource-detector-azure": "^0.2.9", "@opentelemetry/resource-detector-container": "^0.3.11", @@ -18438,9 +18381,9 @@ } }, "@opentelemetry/instrumentation-amqplib": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.39.0.tgz", - "integrity": "sha512-i9SccU5bbHivmmN8ba8HitLnM915BWdGwk5Jl6dwHTp0eV4KpoprZLE/jXUY1AAP/LXpTrM7NgVHmslFSVWRYA==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.41.0.tgz", + "integrity": "sha512-00Oi6N20BxJVcqETjgNzCmVKN+I5bJH/61IlHiIWd00snj1FdgiIKlpE4hYVacTB2sjIBB3nTbHskttdZEE2eg==", "requires": { "@opentelemetry/core": "^1.8.0", "@opentelemetry/instrumentation": "^0.52.0", @@ -18460,9 +18403,9 @@ } }, "@opentelemetry/instrumentation-aws-sdk": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.43.0.tgz", - "integrity": "sha512-klfA48MT0uZY/mGw3cYdQeCXTyMhtY4FzHcZy9R7DdTcuCExgbxWrUlOSiqIJ5kBgsCZfBMEeA6UQKDBwa6X7Q==", + "version": "0.43.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.43.1.tgz", + "integrity": "sha512-qLT2cCniJ5W+6PFzKbksnoIQuq9pS83nmgaExfUwXVvlwi0ILc50dea0tWBHZMkdIDa/zZdcuFrJ7+fUcSnRow==", "requires": { "@opentelemetry/core": "^1.8.0", "@opentelemetry/instrumentation": "^0.52.0", @@ -18527,9 +18470,9 @@ } }, "@opentelemetry/instrumentation-express": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.41.0.tgz", - "integrity": "sha512-/B7fbMdaf3SYe5f1P973tkqd6s7XZirjpfkoJ63E7nltU30qmlgm9tY5XwZOzAFI0rHS9tbrFI2HFPAvQUFe/A==", + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.41.1.tgz", + "integrity": "sha512-uRx0V3LPGzjn2bxAnV8eUsDT82vT7NTwI0ezEuPMBOTOsnPpGhWdhcdNdhH80sM4TrWrOfXm9HGEdfWE3TRIww==", "requires": { "@opentelemetry/core": "^1.8.0", "@opentelemetry/instrumentation": "^0.52.0", @@ -18611,10 +18554,19 @@ "@opentelemetry/semantic-conventions": "^1.23.0" } }, + "@opentelemetry/instrumentation-kafkajs": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.2.0.tgz", + "integrity": "sha512-uKKmhEFd0zR280tJovuiBG7cfnNZT4kvVTvqtHPxQP7nOmRbJstCYHFH13YzjVcKjkmoArmxiSulmZmF7SLIlg==", + "requires": { + "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + } + }, "@opentelemetry/instrumentation-knex": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.38.0.tgz", - "integrity": "sha512-EFef6Ss5ATsf5AxJOLE+pxkfupcWDaejkPH+2q7TNeG1UwsBFobfiWM+iHROZ1Cl/y3mTi60MW70FxsaX2/TjA==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.39.0.tgz", + "integrity": "sha512-lRwTqIKQecPWDkH1KEcAUcFhCaNssbKSpxf4sxRTAROCwrCEnYkjOuqJHV+q1/CApjMTaKu0Er4LBv/6bDpoxA==", "requires": { "@opentelemetry/instrumentation": "^0.52.0", "@opentelemetry/semantic-conventions": "^1.22.0" @@ -18969,9 +18921,9 @@ "integrity": "sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==" }, "@opentelemetry/resource-detector-alibaba-cloud": { - "version": "0.28.10", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.28.10.tgz", - "integrity": "sha512-TZv/1Y2QCL6sJ+X9SsPPBXe4786bc/Qsw0hQXFsNTbJzDTGGUmOAlSZ2qPiuqAd4ZheUYfD+QA20IvAjUz9Hhg==", + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.29.0.tgz", + "integrity": "sha512-cYL1DfBwszTQcpzjiezzFkZp1bzevXjaVJ+VClrufHzH17S0RADcaLRQcLq4GqbWCGfvkJKUqBNz6f1SgfePgw==", "requires": { "@opentelemetry/resources": "^1.0.0", "@opentelemetry/semantic-conventions": "^1.22.0" @@ -19936,92 +19888,92 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "@swc/core": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.2.tgz", - "integrity": "sha512-mjIlT0e6ygKR8LZ1TjtNrDVMhnB8qpyYAdwexhuVHY255yDdDQCpuPGi20odwnE82QhFBSIWs4HcENDVO/yiMw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.5.tgz", + "integrity": "sha512-qKK0/Ta4qvxs/ok3XyYVPT7OBenwRn1sSINf1cKQTBHPqr7U/uB4k2GTl6JgEs8H4PiJrMTNWfMLTucIoVSfAg==", "devOptional": true, "requires": { - "@swc/core-darwin-arm64": "1.7.2", - "@swc/core-darwin-x64": "1.7.2", - "@swc/core-linux-arm-gnueabihf": "1.7.2", - "@swc/core-linux-arm64-gnu": "1.7.2", - "@swc/core-linux-arm64-musl": "1.7.2", - "@swc/core-linux-x64-gnu": "1.7.2", - "@swc/core-linux-x64-musl": "1.7.2", - "@swc/core-win32-arm64-msvc": "1.7.2", - "@swc/core-win32-ia32-msvc": "1.7.2", - "@swc/core-win32-x64-msvc": "1.7.2", + "@swc/core-darwin-arm64": "1.7.5", + "@swc/core-darwin-x64": "1.7.5", + "@swc/core-linux-arm-gnueabihf": "1.7.5", + "@swc/core-linux-arm64-gnu": "1.7.5", + "@swc/core-linux-arm64-musl": "1.7.5", + "@swc/core-linux-x64-gnu": "1.7.5", + "@swc/core-linux-x64-musl": "1.7.5", + "@swc/core-win32-arm64-msvc": "1.7.5", + "@swc/core-win32-ia32-msvc": "1.7.5", + "@swc/core-win32-x64-msvc": "1.7.5", "@swc/counter": "^0.1.3", "@swc/types": "^0.1.12" } }, "@swc/core-darwin-arm64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.2.tgz", - "integrity": "sha512-Zb8KiGaESzOgh5HBnp6Vhs2fRpngHIT81JOfIo0oaGlzAckamnG7UAXC/yK6cQ8q2KXc78utJ/yq/NM2yVKLqw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.5.tgz", + "integrity": "sha512-Y+bvW9C4/u26DskMbtQKT4FU6QQenaDYkKDi028vDIKAa7v1NZqYG9wmhD/Ih7n5EUy2uJ5I5EWD7WaoLzT6PA==", "dev": true, "optional": true }, "@swc/core-darwin-x64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.2.tgz", - "integrity": "sha512-qb0HY9GEexpPm46Hb3OY7E6xb4r+eniiThm+0Gcnhf19EZV2ZlsCC8Rdbhmav33x++ZqSDzZ44fxMY2vnN5VDg==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.5.tgz", + "integrity": "sha512-AuIbDlcaAhYS6mtF4UqvXgrLeAfXZbVf4pgtgShPbutF80VbCQiIB55zOFz5aZdCpsBVuCWcBq0zLneK+VQKkQ==", "dev": true, "optional": true }, "@swc/core-linux-arm-gnueabihf": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.2.tgz", - "integrity": "sha512-x2+MOK3RzH3yEkaukKtpDW/udM1x9GoYtXaLNqlq6ovAzZPQ9FDFI0pm1asL4akHUw3s7YTh1aUY7QscstJAHQ==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.5.tgz", + "integrity": "sha512-99uBPHITRqgGwCXAjHY94VaV3Z40+D2NQNgR1t6xQpO8ZnevI6YSzX6GVZfBnV7+7oisiGkrVEwfIRRa+1s8FA==", "dev": true, "optional": true }, "@swc/core-linux-arm64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.2.tgz", - "integrity": "sha512-4J3HGEDus7a9xnrJUFGyJJgvj4w+BFGiZvs08xbw4Z1ZN4uHJQiJiDsQEAWWciKUxrOndP3SocUq/GhEGiDm0g==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.5.tgz", + "integrity": "sha512-xHL3Erlz+OGGCG4h6K2HWiR56H5UYMuBWWPbbUufi2bJpfhuKQy/X3vWffwL8ZVfJmCUwr4/G91GHcm32uYzRg==", "dev": true, "optional": true }, "@swc/core-linux-arm64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.2.tgz", - "integrity": "sha512-4FhQmYbj8SCmir4pHRLSn8IIFmRKHTL3eZFtOpm26RLME7rXL7Yt33DpzIeTRoHFIesI5NEfaR38WU5mY7P1pA==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.5.tgz", + "integrity": "sha512-5ArGdqvFMszNHdi4a67vopeYq8d1K+FuTWDrblHrAvZFhAyv+GQz2PnKqYOgl0sWmQxsNPfNwBFtxACpUO3Jzg==", "dev": true, "optional": true }, "@swc/core-linux-x64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.2.tgz", - "integrity": "sha512-Loz10Hy6z5mBIAOe6OInOVsYu+PVxyknCB3thtr7QH+uqEz6dcXhU2ERrO2Lf4dsTsFs/Wb80rv8zTSwB8dpsw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.5.tgz", + "integrity": "sha512-mSVVV/PFzCGtI1nVQQyx34NwCMgSurF6ZX/me8pUAX054vsE/pSFL66xN+kQOe/1Z/LOd4UmXFkZ/EzOSnYcSg==", "dev": true, "optional": true }, "@swc/core-linux-x64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.2.tgz", - "integrity": "sha512-8p8qNWaLcTa+qHX4NSv1KNm8BQ6zPoLXuOBo9DtOEqc+K60IISGKPCAS7TJlCcv0q20JnmxZ/cEWW5Qo4TR4XQ==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.5.tgz", + "integrity": "sha512-09hY3ZKMUORXVunESKS9yuP78+gQbr759GKHo8wyCdtAx8lCZdEjfI5NtC7/1VqwfeE32/U6u+5MBTVhZTt0AA==", "dev": true, "optional": true }, "@swc/core-win32-arm64-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.2.tgz", - "integrity": "sha512-eNWAYOalBlFrhv/IVSQ1dxu7qIGuhxlUJZTYa8jsgLnKt93vAFd2cjLtKZ85k1OibBnq9PkKQyo4NKVr4hBavw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.5.tgz", + "integrity": "sha512-B/UDtPI3RlYRFW42xQxOpl6kI/9LtkD7No+XeRIKQTPe15EP2o+rUlv7CmKljVBXgJ8KmaQbZlaEh1YP+QZEEQ==", "dev": true, "optional": true }, "@swc/core-win32-ia32-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.2.tgz", - "integrity": "sha512-BbpaCPCnbQHCzpQ9yDH3qp1Y5Ijd0NSMNk4qqESN2WWx0ojV2uBTjPou5NC2MZxk8fM3iJpJ05enf+IeaXuh6A==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.5.tgz", + "integrity": "sha512-BgLesVGmIY6Nub/sURqtSRvWYcbCE/ACfuZB3bZHVKD6nsZJJuOpdB8oC41fZPyc8yZUzL3XTBIifkT2RP+w9w==", "dev": true, "optional": true }, "@swc/core-win32-x64-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.2.tgz", - "integrity": "sha512-21mf4Jg9Arx0lUnmRQtYd8IQB4WkY4LHJrvcz3EmKbwCTCXI5rQ6Ifnjk7EmG3Tizv0giHqQBQLu5NXWBz45Mg==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.5.tgz", + "integrity": "sha512-CnF557tidLfQRPczcqDJ8x+LBQYsFa0Ra6w2+YU1iFUboaI2jJVuqt3vEChu80y6JiRIBAaaV2L/GawDJh1dIQ==", "dev": true, "optional": true }, @@ -20047,12 +19999,12 @@ } }, "@testcontainers/postgresql": { - "version": "10.10.4", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.10.4.tgz", - "integrity": "sha512-yGRW3IYXAnv91ncOyhf6XVSMbKqfKQzFbFdaSu67agtXwIUYvGE+RFXa/SMZ6oNKHNWgMGKXB9Paj7+md79+VQ==", + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.11.0.tgz", + "integrity": "sha512-TJC6kyb2lmkSF2XWvsjDVn61YRin8e1mE2IiLRkeR3mKWHm/LDwyRX14RTnRuNK7auSCCr35Ft/fKv/R6O5Taw==", "dev": true, "requires": { - "testcontainers": "^10.10.4" + "testcontainers": "^10.11.0" } }, "@tsconfig/node10": { @@ -20257,9 +20209,9 @@ } }, "@types/fluent-ffmpeg": { - "version": "2.1.24", - "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.24.tgz", - "integrity": "sha512-g5oQO8Jgi2kFS3tTub7wLvfLztr1s8tdXmRd8PiL/hLMLzTIAyMR2sANkTggM/rdEDAg3d63nYRRVepwBiCw5A==", + "version": "2.1.25", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.25.tgz", + "integrity": "sha512-a9/Jtv/RVaCG4lUwWIcuClWE5eXJFoFS/oHOecOv/RS8n+lQdJzcJVmDlxA8Xbk4B82YpO88Dijcoljb6sYTcA==", "dev": true, "requires": { "@types/node": "*" @@ -24438,9 +24390,9 @@ } }, "nestjs-cls": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-4.3.0.tgz", - "integrity": "sha512-MVTun6tqCZih8AJXRj8uBuuFyJhQrIA9m9fStiQjbBXUkE3BrlMRvmLzyw8UcneB3xtFFTfwkAh5PYKRulyaOg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-4.4.0.tgz", + "integrity": "sha512-qxsptbCo8Cp7xnAxtWv9+pSqOtB2NCr9ekQDH3FhxPAmgOys8F4WEGhuLLQ9iyW4dwqCao0xXatqQyA4anedmQ==", "requires": {} }, "nestjs-otel": { @@ -27227,9 +27179,9 @@ } }, "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "tweetnacl": { "version": "0.14.5", diff --git a/server/package.json b/server/package.json index de2ed9ca82..fb6563cdd0 100644 --- a/server/package.json +++ b/server/package.json @@ -46,7 +46,7 @@ "@nestjs/swagger": "^7.1.8", "@nestjs/typeorm": "^10.0.0", "@nestjs/websockets": "^10.2.2", - "@opentelemetry/auto-instrumentations-node": "^0.48.0", + "@opentelemetry/auto-instrumentations-node": "^0.49.0", "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.52.0", "@opentelemetry/sdk-node": "^0.52.0", diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 4003193ad4..da1cb7f411 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -101,7 +101,10 @@ export class MediaRepository implements IMediaRepository { transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise { if (!options.twoPass) { return new Promise((resolve, reject) => { - this.configureFfmpegCall(input, output, options).on('error', reject).on('end', resolve).run(); + this.configureFfmpegCall(input, output, options) + .on('error', reject) + .on('end', () => resolve()) + .run(); }); } @@ -126,7 +129,7 @@ export class MediaRepository implements IMediaRepository { .on('error', reject) .on('end', () => handlePromiseError(fs.unlink(`${output}-0.log`), this.logger)) .on('end', () => handlePromiseError(fs.rm(`${output}-0.log.mbtree`, { force: true }), this.logger)) - .on('end', resolve) + .on('end', () => resolve()) .run(); }) .run(); diff --git a/web/package-lock.json b/web/package-lock.json index 3a144312d0..05cabc99ed 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -79,7 +79,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.12", + "@types/node": "^20.14.13", "typescript": "^5.3.3" } }, @@ -1811,30 +1811,30 @@ } }, "node_modules/@photo-sphere-viewer/core": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.8.3.tgz", - "integrity": "sha512-Aj2NJic2MM+Ei35+KPFOHTg4F7qjPZfjQgm0xrveso2huearW2cYJaFzEO7d9rwgO6vL6XINVNJHU7710ShepQ==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.9.0.tgz", + "integrity": "sha512-Th8S2SbKpKEE5l150Mh0Na+3RirceJL9ioRl+33kE59s0Dx675snGWI7gy/xFKEWsdYOhj9f6xNWZ8MSqs8RhQ==", "license": "MIT", "dependencies": { - "three": "^0.166.1" + "three": "^0.167.0" } }, "node_modules/@photo-sphere-viewer/equirectangular-video-adapter": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.8.3.tgz", - "integrity": "sha512-3QA3qFwrCtq3ngFAxiQeOZXO9UDoWK6ETYJsdbzl+cM91+3ApQBy2MNq+BasPECpppuYYeVyUscm/CIDj4horg==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.9.0.tgz", + "integrity": "sha512-mQPnuKQPQvtNKMtjY8M3b6ANupA7soSDDLL/R8igtlP9vGMPgbVzPmGbrkyq6Ed2bQr+u8j2LkT38ztZ70Ingg==", "license": "MIT", "peerDependencies": { - "@photo-sphere-viewer/core": "5.8.3" + "@photo-sphere-viewer/core": "5.9.0" } }, "node_modules/@photo-sphere-viewer/video-plugin": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.8.3.tgz", - "integrity": "sha512-vs+zh2UQvOP7xMLGBWw4iIgCmC2lXQEcKqan9BteA/vQalcWWtHa4L6qQCgAt+h+rP6s4TMpTS5ZOfVIfeL3gw==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.9.0.tgz", + "integrity": "sha512-u1li4KEO7iRMhlLWZsn55Jprb8LdSyFbisvHvk75wcSLGZIZj24vabogPrDtdiXuELaC1DTD6En9IpVD/H+mGQ==", "license": "MIT", "peerDependencies": { - "@photo-sphere-viewer/core": "5.8.3" + "@photo-sphere-viewer/core": "5.9.0" } }, "node_modules/@pkgjs/parseargs": { @@ -2082,9 +2082,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.5.18", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.18.tgz", - "integrity": "sha512-+g06hvpVAnH7b4CDjhnTDgFWBKBiQJpuSmQeGYOuzbO3SC3tdYjRNlDCrafvDtKbGiT2uxY5Dn9qdEUGVZdWOQ==", + "version": "2.5.19", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.19.tgz", + "integrity": "sha512-r/lah3nnYEZX1btlvpSy+Exkt1aWhmOP5pnCt+BBro+tZrh2Zci+26Xnm1fCBLLMeM5q7gHvWiS8c/UtrWjdvQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7669,9 +7669,9 @@ } }, "node_modules/svelte-check": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.4.tgz", - "integrity": "sha512-61aHMkdinWyH8BkkTX9jPLYxYzaAAz/FK/VQqdr2FiCQQ/q04WCwDlpGbHff1GdrMYTmW8chlTFvRWL9k0A8vg==", + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.5.tgz", + "integrity": "sha512-3OGGgr9+bJ/+1nbPgsvulkLC48xBsqsgtc8Wam281H4G9F5v3mYGa2bHRsPuwHC5brKl4AxJH95QF73kmfihGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8449,9 +8449,9 @@ } }, "node_modules/three": { - "version": "0.166.1", - "resolved": "https://registry.npmjs.org/three/-/three-0.166.1.tgz", - "integrity": "sha512-LtuafkKHHzm61AQA1be2MAYIw1IjmhOUxhBa0prrLpEMWbV7ijvxCRHjSgHPGp2493wLBzwKV46tA9nivLEgKg==", + "version": "0.167.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.167.1.tgz", + "integrity": "sha512-gYTLJA/UQip6J/tJvl91YYqlZF47+D/kxiWrbTon35ZHlXEN0VOo+Qke2walF1/x92v55H6enomymg4Dak52kw==", "license": "MIT" }, "node_modules/thumbhash": { From 905a062a6efc4e4cf8cfb99bba993aa451bb2e74 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Wed, 7 Aug 2024 13:38:27 -0400 Subject: [PATCH 042/723] docs: how to decrease Redis logs (#11638) --- docs/docs/FAQ.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index feb35a02db..117ca74c03 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -294,6 +294,12 @@ You need to enable WebSockets on your reverse proxy. Immich components are typically deployed using docker. To see logs for deployed docker containers, you can use the [Docker CLI](https://docs.docker.com/engine/reference/commandline/cli/), specifically the `docker logs` command. For examples, see [Docker Help](/docs/guides/docker-help.md). +### How can I reduce the log verbosity of Redis? + +To decrease Redis logs, you can add the following line to the `redis:` section of the `docker-compose.yml`: + +` command: redis-server --loglevel warning` + ### How can I run Immich as a non-root user? You can change the user in the container by setting the `user` argument in `docker-compose.yml` for each service. From c34fc4f2d1566b6cb22e93e6d6319646437a3d9b Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 7 Aug 2024 13:09:15 -0500 Subject: [PATCH 043/723] fix(mobile): iOS crashing when download iCloud content (#11639) --- mobile/pubspec.lock | 8 ++++---- mobile/pubspec.yaml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index efe353e2ea..bd756703b9 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1186,18 +1186,18 @@ packages: dependency: "direct main" description: name: photo_manager - sha256: "68d6099d07ce5033170f8368af8128a4555cf1d590a97242f83669552de989b1" + sha256: "1e8bbe46a6858870e34c976aafd85378bed221ce31c1201961eba9ad3d94df9f" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.2.3" photo_manager_image_provider: dependency: "direct main" description: name: photo_manager_image_provider - sha256: c187f60c3fdbe5630735d9a0bccbb071397ec03dcb1ba6085c29c8adece798a0 + sha256: "38ef1023dc11de3a8669f16e7c981673b3c5cfee715d17120f4b87daa2cdd0af" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" platform: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6c765b966d..f429b5374c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -15,8 +15,8 @@ dependencies: path_provider_ios: # TODO: upgrade to stable after 3.0.1 is released. 3.0.0 is broken # https://github.com/fluttercandies/flutter_photo_manager/pull/990#issuecomment-2058066427 - photo_manager: ^3.2.0 - photo_manager_image_provider: ^2.1.0 + photo_manager: ^3.2.3 + photo_manager_image_provider: ^2.1.1 flutter_hooks: ^0.20.4 hooks_riverpod: ^2.4.9 riverpod_annotation: ^2.3.3 From d93ccb1669556c63fea4fdee791797747da56bef Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 7 Aug 2024 13:47:40 -0500 Subject: [PATCH 044/723] chore(mobile): update maplibre_gl dep (#11640) --- .../android/app/src/main/AndroidManifest.xml | 9 +++-- mobile/android/build.gradle | 2 ++ mobile/android/settings.gradle | 4 +-- mobile/pubspec.lock | 33 +++++++++---------- mobile/pubspec.yaml | 8 +---- 5 files changed, 26 insertions(+), 30 deletions(-) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index dc0e10ee82..1bac79daf5 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,8 @@ + android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true" + android:largeHeap="true"> - + @@ -65,6 +67,7 @@ + @@ -76,4 +79,4 @@ - + \ No newline at end of file diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle index 9b757fbc36..9b5e515a68 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -8,9 +8,11 @@ allprojects { } rootProject.buildDir = '../build' + subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } + subprojects { project.evaluationDependsOn(':app') } diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle index e832517e64..e809a0abaa 100644 --- a/mobile/android/settings.gradle +++ b/mobile/android/settings.gradle @@ -19,8 +19,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "7.4.2" apply false - id "org.jetbrains.kotlin.android" version "1.9.24" apply false - id "org.jetbrains.kotlin.kapt" version "1.9.24" apply false + id "org.jetbrains.kotlin.android" version "1.9.0" apply false + id "org.jetbrains.kotlin.kapt" version "1.9.0" apply false } include ":app" diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index bd756703b9..d6c37632ad 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -935,30 +935,27 @@ packages: maplibre_gl: dependency: "direct main" description: - path: "." - ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 - resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 - url: "https://github.com/maplibre/flutter-maplibre-gl.git" - source: git - version: "0.18.0" + name: maplibre_gl + sha256: "9dd9eebee52f42a45aaa9cdb912afa47845c37007b26a799aa482ecd368804c8" + url: "https://pub.dev" + source: hosted + version: "0.19.0+2" maplibre_gl_platform_interface: dependency: transitive description: - path: maplibre_gl_platform_interface - ref: main - resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 - url: "https://github.com/maplibre/flutter-maplibre-gl.git" - source: git - version: "0.18.0" + name: maplibre_gl_platform_interface + sha256: a95fa38a3532253f32dfe181389adfe9f402773e58ac902d9c4efad3209e0903 + url: "https://pub.dev" + source: hosted + version: "0.19.0+2" maplibre_gl_web: dependency: transitive description: - path: maplibre_gl_web - ref: main - resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 - url: "https://github.com/maplibre/flutter-maplibre-gl.git" - source: git - version: "0.18.0" + name: maplibre_gl_web + sha256: "7f1540b384f16f3c9bc8b4ebdfca96fb07f6dab5d9ef4dd0e102985dba238691" + url: "https://pub.dev" + source: hosted + version: "0.19.0+2" matcher: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f429b5374c..6c853054ea 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -13,8 +13,6 @@ dependencies: sdk: flutter path_provider_ios: - # TODO: upgrade to stable after 3.0.1 is released. 3.0.0 is broken - # https://github.com/fluttercandies/flutter_photo_manager/pull/990#issuecomment-2058066427 photo_manager: ^3.2.3 photo_manager_image_provider: ^2.1.1 flutter_hooks: ^0.20.4 @@ -28,11 +26,7 @@ dependencies: video_player: ^2.8.2 chewie: ^1.7.4 socket_io_client: ^2.0.3+1 - # TODO: Update it to tag once next stable release - maplibre_gl: - git: - url: https://github.com/maplibre/flutter-maplibre-gl.git - ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 + maplibre_gl: 0.19.0+2 geolocator: ^11.0.0 # used to move to current location in map view flutter_udid: ^3.0.0 flutter_svg: ^2.0.9 From 720b9a286eff742b36894285389ef10d345774b3 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 7 Aug 2024 14:09:56 -0500 Subject: [PATCH 045/723] chore(mobile): update other dependencies (#11641) --- mobile/ios/Podfile.lock | 33 +- mobile/ios/Runner.xcodeproj/project.pbxproj | 18 + mobile/pubspec.lock | 413 ++++++++++---------- 3 files changed, 249 insertions(+), 215 deletions(-) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 61915eb30b..e3603eef42 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -51,9 +51,6 @@ PODS: - fluttertoast (0.0.2): - Flutter - Toast - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - geolocator_apple (1.2.0): - Flutter - image_picker_ios (0.0.1): @@ -73,7 +70,7 @@ PODS: - FlutterMacOS - path_provider_ios (0.0.1): - Flutter - - permission_handler_apple (9.1.1): + - permission_handler_apple (9.3.0): - Flutter - photo_manager (2.0.0): - Flutter @@ -90,7 +87,7 @@ PODS: - FlutterMacOS - sqflite (0.0.3): - Flutter - - FMDB (>= 2.7.5) + - FlutterMacOS - SwiftyGif (5.4.5) - Toast (4.0.0) - url_launcher_ios (0.0.1): @@ -123,7 +120,7 @@ DEPENDENCIES: - photo_manager (from `.symlinks/plugins/photo_manager/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite (from `.symlinks/plugins/sqflite/ios`) + - sqflite (from `.symlinks/plugins/sqflite/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) @@ -132,7 +129,6 @@ SPEC REPOS: trunk: - DKImagePickerController - DKPhotoGallery - - FMDB - MapLibre - ReachabilitySwift - SAMKeychain @@ -184,7 +180,7 @@ EXTERNAL SOURCES: shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite: - :path: ".symlinks/plugins/sqflite/ios" + :path: ".symlinks/plugins/sqflite/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" video_player_avfoundation: @@ -200,32 +196,31 @@ SPEC CHECKSUMS: file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 - flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef + flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04 flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d - fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - geolocator_apple: 9157311f654584b9bb72686c55fc02a97b73f461 - image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 + fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c + geolocator_apple: 6cbaf322953988e009e5ecb481f07efece75c450 + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9 package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 - permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 - sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 - url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812 - video_player_avfoundation: 02011213dab73ae3687df27ce441fbbcc82b5579 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1 PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 2ab8571fc6..6f15687916 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -155,6 +155,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, D218A34AEE62BC1EF119F5B0 /* [CP] Embed Pods Frameworks */, + C494C1A226E78FAB736DAB6C /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -267,6 +268,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; + C494C1A226E78FAB736DAB6C /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; D218A34AEE62BC1EF119F5B0 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index d6c37632ad..5c62b95227 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -5,18 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0f7b1783ddb1e4600580b8c00d0ddae5b06ae7f0382bd4fcce5db4df97b618e1" + sha256: "5aaf60d96c4cd00fe7f21594b5ad6a1b699c80a27420f8a837f4d68473ef09e3" url: "https://pub.dev" source: hosted - version: "66.0.0" + version: "68.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.1.0" analyzer: dependency: "direct overridden" description: name: analyzer - sha256: "5e8bdcda061d91da6b034d64d8e4026f355bcb8c3e7a0ac2da1523205a91a737" + sha256: "21f1d3720fd1c70316399d5e2bccaebb415c434592d778cce8acb967b8578808" url: "https://pub.dev" source: hosted - version: "6.4.0" + version: "6.5.0" analyzer_plugin: dependency: "direct overridden" description: @@ -37,10 +42,10 @@ packages: dependency: transitive description: name: archive - sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d url: "https://pub.dev" source: hosted - version: "3.4.10" + version: "3.6.1" args: dependency: transitive description: @@ -101,18 +106,18 @@ packages: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "6c4dd11d05d056e76320b828a1db0fc01ccd376922526f8e9d6c796a5adbac20" + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.4.2" build_runner: dependency: "direct dev" description: @@ -125,10 +130,10 @@ packages: dependency: transitive description: name: build_runner_core - sha256: "6d6ee4276b1c5f34f21fdf39425202712d2be82019983d52f351c94aafbc2c41" + sha256: e3c79f69a64bdfcd8a776a3c28db4eb6e3fb5356d013ae5eb2e52007706d5dbe url: "https://pub.dev" source: hosted - version: "7.2.10" + version: "7.3.1" built_collection: dependency: transitive description: @@ -141,10 +146,10 @@ packages: dependency: transitive description: name: built_value - sha256: "598a2a682e2a7a90f08ba39c0aaa9374c5112340f0a2e275f61b59389543d166" + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb url: "https://pub.dev" source: hosted - version: "8.6.1" + version: "8.9.2" cached_network_image: dependency: "direct main" description: @@ -165,10 +170,10 @@ packages: dependency: transitive description: name: cached_network_image_web - sha256: "42a835caa27c220d1294311ac409a43361088625a4f23c820b006dd9bffb3316" + sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" cancellation_token: dependency: transitive description: @@ -205,10 +210,10 @@ packages: dependency: "direct main" description: name: chewie - sha256: "3427e469d7cc99536ac4fbaa069b3352c21760263e65ffb4f0e1c054af43a73e" + sha256: "2243e41e79e865d426d9dd9c1a9624aa33c4ad11de2d0cd680f826e2cd30e879" url: "https://pub.dev" source: hosted - version: "1.7.4" + version: "1.8.3" ci: dependency: transitive description: @@ -221,10 +226,10 @@ packages: dependency: transitive description: name: cli_util - sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.4.1" clock: dependency: transitive description: @@ -309,10 +314,10 @@ packages: dependency: transitive description: name: cupertino_icons - sha256: e35129dc44c9118cee2a5603506d823bab99c68393879edb440e0090d07586be + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.8" custom_lint: dependency: "direct dev" description: @@ -365,18 +370,18 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "0042cb3b2a76413ea5f8a2b40cec2a33e01d0c937e91f0f7c211fde4f7739ba6" + sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110" url: "https://pub.dev" source: hosted - version: "9.1.1" + version: "9.1.2" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 + sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" dynamic_color: dependency: "direct main" description: @@ -389,10 +394,10 @@ packages: dependency: "direct main" description: name: easy_image_viewer - sha256: "6d765e9040a6e625796b387140b95f23318f25a448bf2647af30d17a77cea022" + sha256: fb6cb123c3605552cc91150dcdb50ca977001dcddfb71d20caa0c5edc9a80947 url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.1" easy_localization: dependency: "direct main" description: @@ -437,42 +442,42 @@ packages: dependency: "direct main" description: name: file_picker - sha256: d1d0ac3966b36dc3e66eeefb40280c17feb87fa2099c6e22e6a1fc959327bd03 + sha256: "825aec673606875c33cd8d3c4083f1a3c3999015a84178b317b7ef396b7384f3" url: "https://pub.dev" source: hosted - version: "8.0.0+1" + version: "8.0.7" file_selector_linux: dependency: transitive description: name: file_selector_linux - sha256: "770eb1ab057b5ae4326d1c24cc57710758b9a46026349d021d6311bd27580046" + sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" url: "https://pub.dev" source: hosted - version: "0.9.2" + version: "0.9.2+1" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: "4ada532862917bf16e3adb3891fe3a5917a58bae03293e497082203a80909412" + sha256: f42eacb83b318e183b1ae24eead1373ab1334084404c8c16e0354f9a3e55d385 url: "https://pub.dev" source: hosted - version: "0.9.3+1" + version: "0.9.4" file_selector_platform_interface: dependency: transitive description: name: file_selector_platform_interface - sha256: "412705a646a0ae90f33f37acfae6a0f7cbc02222d6cd34e479421c3e74d3853c" + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.6.2" file_selector_windows: dependency: transitive description: name: file_selector_windows - sha256: "1372760c6b389842b77156203308940558a2817360154084368608413835fc26" + sha256: "2ad726953f6e8affbc4df8dc78b77c3b4a060967a291e528ef72ae846c60fb69" url: "https://pub.dev" source: hosted - version: "0.9.3" + version: "0.9.3+2" fixnum: dependency: transitive description: @@ -511,10 +516,10 @@ packages: dependency: "direct main" description: name: flutter_hooks - sha256: "09f64db63fee3b2ab8b9038a1346be7d8986977fae3fec601275bf32455ccfc0" + sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 url: "https://pub.dev" source: hosted - version: "0.20.4" + version: "0.20.5" flutter_launcher_icons: dependency: "direct dev" description: @@ -535,26 +540,26 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: c18f1de98fe0bb9dd5ba91e1330d4febc8b6a7de6aae3ffe475ef423723e72f3 + sha256: "55b9b229307a10974b26296ff29f2e132256ba4bd74266939118eaefa941cb00" url: "https://pub.dev" source: hosted - version: "16.3.2" + version: "16.3.3" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: "33f741ef47b5f63cc7f78fe75eeeac7e19f171ff3c3df054d84c1e38bedb6a03" + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af url: "https://pub.dev" source: hosted - version: "4.0.0+1" + version: "4.0.1" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "7cf643d6d5022f3baed0be777b0662cce5919c0a7b86e700299f22dc4ae660ef" + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" url: "https://pub.dev" source: hosted - version: "7.0.0+1" + version: "7.2.0" flutter_localizations: dependency: transitive description: flutter @@ -564,18 +569,18 @@ packages: dependency: "direct dev" description: name: flutter_native_splash - sha256: "558f10070f03ee71f850a78f7136ab239a67636a294a44a06b6b7345178edb1e" + sha256: aa06fec78de2190f3db4319dd60fdc8d12b2626e93ef9828633928c2dcaea840 url: "https://pub.dev" source: hosted - version: "2.3.10" + version: "2.4.1" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: c6b0b4c05c458e1c01ad9bcc14041dd7b1f6783d487be4386f793f47a8a4d03e + sha256: "9d98bd47ef9d34e803d438f17fd32b116d31009f534a6fa5ce3a1167f189a6de" url: "https://pub.dev" source: hosted - version: "2.0.20" + version: "2.0.21" flutter_riverpod: dependency: transitive description: @@ -622,26 +627,26 @@ packages: dependency: "direct main" description: name: fluttertoast - sha256: dfdde255317af381bfc1c486ed968d5a43a2ded9c931e87cbecd88767d6a71c1 + sha256: "7eae679e596a44fdf761853a706f74979f8dd3cd92cf4e23cae161fda091b847" url: "https://pub.dev" source: hosted - version: "8.2.4" + version: "8.2.6" freezed_annotation: dependency: transitive description: name: freezed_annotation - sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.4" frontend_server_client: dependency: transitive description: name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -651,34 +656,34 @@ packages: dependency: "direct main" description: name: geolocator - sha256: "694ec58afe97787b5b72b8a0ab78c1a9244811c3c10e72c4362ef3c0ceb005cd" + sha256: "6cb9fb6e5928b58b9a84bdf85012d757fd07aab8215c5205337021c4999bad27" url: "https://pub.dev" source: hosted - version: "11.0.0" + version: "11.1.0" geolocator_android: dependency: transitive description: name: geolocator_android - sha256: "93906636752ea4d4e778afa981fdfe7409f545b3147046300df194330044d349" + sha256: "7aefc530db47d90d0580b552df3242440a10fe60814496a979aa67aa98b1fd47" url: "https://pub.dev" source: hosted - version: "4.3.1" + version: "4.6.1" geolocator_apple: dependency: transitive description: name: geolocator_apple - sha256: "79babf44b692ec5e789d322dc736ef71586056e8e6828f747c9e005456b248bf" + sha256: bc2aca02423ad429cb0556121f56e60360a2b7d694c8570301d06ea0c00732fd url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.3.7" geolocator_platform_interface: dependency: transitive description: name: geolocator_platform_interface - sha256: b8cc1d3be0ca039a3f2174b0b026feab8af3610e220b8532e42cff8ec6658535 + sha256: "386ce3d9cce47838355000070b1d0b13efb5bc430f8ecda7e9238c8409ace012" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.4" geolocator_web: dependency: transitive description: @@ -691,10 +696,10 @@ packages: dependency: transitive description: name: geolocator_windows - sha256: a92fae29779d5c6dc60e8411302f5221ade464968fe80a36d330e80a71f087af + sha256: "53da08937d07c24b0d9952eb57a3b474e29aae2abf9dd717f7e1230995f13f0e" url: "https://pub.dev" source: hosted - version: "0.2.2" + version: "0.2.3" glob: dependency: transitive description: @@ -707,10 +712,10 @@ packages: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" hooks_riverpod: dependency: "direct main" description: @@ -763,74 +768,74 @@ packages: dependency: transitive description: name: image - sha256: "004a2e90ce080f8627b5a04aecb4cdfac87d2c3f3b520aa291260be5a32c033d" + sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" url: "https://pub.dev" source: hosted - version: "4.1.4" + version: "4.2.0" image_picker: dependency: "direct main" description: name: image_picker - sha256: "26222b01a0c9a2c8fe02fc90b8208bd3325da5ed1f4a2acabf75939031ac0bdd" + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" url: "https://pub.dev" source: hosted - version: "1.0.7" + version: "1.1.2" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "8179b54039b50eee561676232304f487602e2950ffb3e8995ed9034d6505ca34" + sha256: c0e72ecd170b00a5590bb71238d57dc8ad22ee14c60c6b0d1a4e05cafbc5db4b url: "https://pub.dev" source: hosted - version: "0.8.7+4" + version: "0.8.12+11" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "869fe8a64771b7afbc99fc433a5f7be2fea4d1cb3d7c11a48b6b579eb9c797f0" + sha256: "65d94623e15372c5c51bebbcb820848d7bcb323836e12dfdba60b5d3a8b39e50" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.5" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: b3e2f21feb28b24dd73a35d7ad6e83f568337c70afab5eabac876e23803f264b + sha256: "6703696ad49f5c3c8356d576d7ace84d1faf459afb07accbb0fae780753ff447" url: "https://pub.dev" source: hosted - version: "0.8.8" + version: "0.8.12" image_picker_linux: dependency: transitive description: name: image_picker_linux - sha256: "02cbc21fe1706b97942b575966e5fbbeaac535e76deef70d3a242e4afb857831" + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" url: "https://pub.dev" source: hosted - version: "0.2.1" + version: "0.2.1+1" image_picker_macos: dependency: transitive description: name: image_picker_macos - sha256: cee2aa86c56780c13af2c77b5f2f72973464db204569e1ba2dd744459a065af4 + sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" url: "https://pub.dev" source: hosted - version: "0.2.1" + version: "0.2.1+1" image_picker_platform_interface: dependency: transitive description: name: image_picker_platform_interface - sha256: c1134543ae2187e85299996d21c526b2f403854994026d575ae4cf30d7bb2a32 + sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" url: "https://pub.dev" source: hosted - version: "2.9.0" + version: "2.10.0" image_picker_windows: dependency: transitive description: name: image_picker_windows - sha256: c3066601ea42113922232c7b7b3330a2d86f029f685bba99d82c30e799914952 + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" url: "https://pub.dev" source: hosted - version: "0.2.1" + version: "0.2.1+1" integration_test: dependency: "direct dev" description: flutter @@ -888,10 +893,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "4.8.1" + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -932,6 +937,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + macros: + dependency: transitive + description: + name: macros + sha256: "12e8a9842b5a7390de7a781ec63d793527582398d16ea26c60fed58833c9ae79" + url: "https://pub.dev" + source: hosted + version: "0.1.0-main.0" maplibre_gl: dependency: "direct main" description: @@ -976,26 +989,26 @@ packages: dependency: "direct overridden" description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.15.0" mime: dependency: transitive description: name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" mocktail: dependency: "direct dev" description: name: mocktail - sha256: c4b5007d91ca4f67256e720cb1b6d704e79a510183a12fa551021f652577dce6 + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" nested: dependency: transitive description: @@ -1016,10 +1029,10 @@ packages: dependency: "direct main" description: name: octo_image - sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" openapi: dependency: "direct main" description: @@ -1071,26 +1084,26 @@ packages: dependency: "direct main" description: name: path_provider - sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b + sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "5d44fc3314d969b84816b569070d7ace0f1dea04bd94a83f74c4829615d22ad8" + sha256: "490539678396d4c3c0b06efdaab75ae60675c3e0c66f72bc04c2e2c1e0e2abeb" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.9" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "1b744d3d774e5a879bb76d6cd1ecee2ba2c6960c03b1020cd35212f6aa267ac5" + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.0" path_provider_ios: dependency: "direct main" description: @@ -1103,66 +1116,66 @@ packages: dependency: transitive description: name: path_provider_linux - sha256: ba2b77f0c52a33db09fc8caf85b12df691bf28d983e84cf87ff6d693cfa007b3 + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: bced5679c7df11190e1ddc35f3222c858f328fff85c3942e46e7f5589bf9eb84 + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" path_provider_windows: dependency: transitive description: name: path_provider_windows - sha256: ee0e0d164516b90ae1f970bdf29f726f1aa730d7cfc449ecc74c495378b705da + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.0" permission_handler: dependency: "direct main" description: name: permission_handler - sha256: "45ff3fbcb99040fde55c528d5e3e6ca29171298a85436274d49c6201002087d6" + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" url: "https://pub.dev" source: hosted - version: "11.2.0" + version: "11.3.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "758284a0976772f9c744d6384fc5dc4834aa61e3f7aa40492927f244767374eb" + sha256: eaf2a1ec4472775451e88ca6a7b86559ef2f1d1ed903942ed135e38ea0097dca url: "https://pub.dev" source: hosted - version: "12.0.3" + version: "12.0.8" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: c6bf440f80acd2a873d3d91a699e4cc770f86e7e6b576dda98759e8b92b39830 + sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 url: "https://pub.dev" source: hosted - version: "9.3.0" + version: "9.4.5" permission_handler_html: dependency: transitive description: name: permission_handler_html - sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + sha256: "6cac773d389e045a8d4f85418d07ad58ef9e42a56e063629ce14c4c26344de24" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.2" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: "5c43148f2bfb6d14c5a8162c0a712afe891f2d847f35fcff29c406b37da43c3c" + sha256: fe0ffe274d665be8e34f9c59705441a7d248edebbe5d9e3ec2665f88b79358ea url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.2" permission_handler_windows: dependency: transitive description: @@ -1211,14 +1224,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" - url: "https://pub.dev" - source: hosted - version: "3.7.3" pool: dependency: transitive description: @@ -1239,10 +1244,10 @@ packages: dependency: transitive description: name: provider - sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c url: "https://pub.dev" source: hosted - version: "6.0.5" + version: "6.1.2" pub_semver: dependency: transitive description: @@ -1255,10 +1260,10 @@ packages: dependency: transitive description: name: pubspec_parse - sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 url: "https://pub.dev" source: hosted - version: "1.2.3" + version: "1.3.0" riverpod: dependency: transitive description: @@ -1271,34 +1276,34 @@ packages: dependency: transitive description: name: riverpod_analyzer_utils - sha256: "8b71f03fc47ae27d13769496a1746332df4cec43918aeba9aff1e232783a780f" + sha256: ee72770090078e6841d51355292335f1bc254907c6694283389dcb8156d99a4d url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.5.3" riverpod_annotation: dependency: "direct main" description: name: riverpod_annotation - sha256: b70e95fbd5ca7ce42f5148092022971bb2e9843b6ab71e97d479e8ab52e98979 + sha256: e5e796c0eba4030c704e9dae1b834a6541814963292839dcf9638d53eba84f5c url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.3.5" riverpod_generator: dependency: "direct dev" description: name: riverpod_generator - sha256: ff8f064f1d7ef3cc6af481bba8e9a3fcdb4d34df34fac1b39bbc003167065be0 + sha256: "1ad626afbd8b01d168870b13c0b036f8a5bdb57c14cd426dc5b4595466bd6e2f" url: "https://pub.dev" source: hosted - version: "2.3.9" + version: "2.4.2" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: "3c67c14ccd16f0c9d53e35ef70d06cd9d072e2fb14557326886bbde903b230a5" + sha256: b95a8cdc6102397f7d51037131c25ce7e51be900be021af4bf0c2d6f1b8f7aa7 url: "https://pub.dev" source: hosted - version: "2.3.10" + version: "2.3.12" rxdart: dependency: transitive description: @@ -1335,58 +1340,58 @@ packages: dependency: transitive description: name: shared_preferences - sha256: "0344316c947ffeb3a529eac929e1978fcd37c26be4e8468628bac399365a3ca1" + sha256: c272f9cabca5a81adc9b0894381e9c1def363e980f960fa903c604c471b22f68 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.1" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: fe8401ec5b6dcd739a0fe9588802069e608c3fdbfd3c3c93e546cf2f90438076 + sha256: "041be4d9d2dc6079cf342bc8b761b03787e3b71192d658220a56cac9c04a0294" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.0" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: d29753996d8eb8f7619a1f13df6ce65e34bc107bef6330739ed76f18b22310ef + sha256: "671e7a931f55a08aa45be2a13fe7247f2a41237897df434b30d2012388191833" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.5.0" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + sha256: "2ba0510d3017f91655b7543e9ee46d48619de2a2af38e5c790423f7007c7ccc1" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.0" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "23b052f17a25b90ff2b61aad4cc962154da76fb62848a9ce088efe30d7c50ab1" + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: "7347b194fb0bbeb4058e6a4e87ee70350b6b2b90f8ac5f8bd5b3a01548f6d33a" + sha256: "59dc807b94d29d52ddbb1b3c0d3b9d0a67fc535a64e62a5542c8db0513fcb6c2" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.1" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + sha256: "398084b47b7f92110683cac45c6dc4aae853db47e470e5ddcd52cab7f7196ab2" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.0" shelf: dependency: transitive description: @@ -1399,10 +1404,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "2.0.0" sky_engine: dependency: transitive description: flutter @@ -1440,22 +1445,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqflite: dependency: transitive description: name: sqflite - sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" + sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.3+1" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "1b92f368f44b0dee2425bb861cfa17b6f6cf3961f762ff6f941d20b33355660a" + sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.5.4" stack_trace: dependency: transitive description: @@ -1508,10 +1521,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.0+1" term_glyph: dependency: transitive description: @@ -1540,18 +1553,18 @@ packages: dependency: transitive description: name: time - sha256: "83427e11d9072e038364a5e4da559e85869b227cf699a541be0da74f14140124" + sha256: ad8e018a6c9db36cb917a031853a1aae49467a93e0d464683e029537d848c221 url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" timezone: dependency: "direct main" description: name: timezone - sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0" + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" url: "https://pub.dev" source: hosted - version: "0.9.2" + version: "0.9.4" timing: dependency: transitive description: @@ -1580,26 +1593,26 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: c512655380d241a337521703af62d2c122bf7b77a46ff7dd750092aa9433499c + sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.3.0" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "507dc655b1d9cb5ebc756032eb785f114e415f91557b73bf60b7e201dfedeb2f" + sha256: "94d8ad05f44c6d4e2ffe5567ab4d741b82d62e3c8e288cc1fcea45965edf47c9" url: "https://pub.dev" source: hosted - version: "6.2.2" + version: "6.3.8" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "75bb6fe3f60070407704282a2d295630cab232991eb52542b18347a8a941df03" + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.3.1" url_launcher_linux: dependency: transitive description: @@ -1612,42 +1625,42 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.2.0" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: "4aca1e060978e19b2998ee28503f40b5ba6226819c2b5e3e4d1821e8ccd92198" + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.2" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: a36e2d7981122fa185006b216eb6b5b97ede3f9a54b7a511bc966971ab98d049 + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" uuid: dependency: transitive description: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90" url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.4.2" vector_graphics: dependency: transitive description: @@ -1684,42 +1697,42 @@ packages: dependency: "direct main" description: name: video_player - sha256: fbf28ce8bcfe709ad91b5789166c832cb7a684d14f571a81891858fefb5bb1c2 + sha256: e30df0d226c4ef82e2c150ebf6834b3522cf3f654d8e2f9419d376cdc071425d url: "https://pub.dev" source: hosted - version: "2.8.2" + version: "2.9.1" video_player_android: dependency: transitive description: name: video_player_android - sha256: f338a5a396c845f4632959511cad3542cdf3167e1b2a1a948ef07f7123c03608 + sha256: "4de50df9ee786f5891d3281e1e633d7b142ef1acf47392592eb91cba5d355849" url: "https://pub.dev" source: hosted - version: "2.4.9" + version: "2.6.0" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "309e3962795e761be010869bae65c0b0e45b5230c5cee1bec72197ca7db040ed" + sha256: d1e9a824f2b324000dc8fb2dcb2a3285b6c1c7c487521c63306cc5b394f68a7c url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.6.1" video_player_platform_interface: dependency: transitive description: name: video_player_platform_interface - sha256: "1ca9acd7a0fb15fb1a990cb554e6f004465c6f37c99d2285766f08a4b2802988" + sha256: "236454725fafcacf98f0f39af0d7c7ab2ce84762e3b63f2cbb3ef9a7e0550bc6" url: "https://pub.dev" source: hosted - version: "6.2.0" + version: "6.2.2" video_player_web: dependency: transitive description: name: video_player_web - sha256: "44ce41424d104dfb7cf6982cc6b84af2b007a24d126406025bf40de5d481c74c" + sha256: "6dcdd298136523eaf7dfc31abaf0dfba9aa8a8dbc96670e87e9d42b6f2caf774" url: "https://pub.dev" source: hosted - version: "2.0.16" + version: "2.3.2" vm_service: dependency: transitive description: @@ -1760,14 +1773,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "3.0.1" webdriver: dependency: transitive description: @@ -1788,18 +1809,18 @@ packages: dependency: transitive description: name: win32_registry - sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" + sha256: "723b7f851e5724c55409bb3d5a32b203b3afe8587eaf5dafb93a5fed8ecda0d6" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.4" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: f0c26453a2d47aa4c2570c6a033246a3fc62da2fe23c7ffdd0a7495086dc0247 + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.0.4" xml: dependency: transitive description: From fb68da2b51d22e9ed53d56f8efd13750ead77937 Mon Sep 17 00:00:00 2001 From: Matthew Mirvish <5255209+mincrmatt12@users.noreply.github.com> Date: Wed, 7 Aug 2024 18:36:37 -0400 Subject: [PATCH 046/723] fix(server): avoid transcoding thumbnail streams (#11603) Co-authored-by: mincrmatt12 --- server/src/repositories/media.repository.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index da1cb7f411..a84ef6f596 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -76,6 +76,7 @@ export class MediaRepository implements IMediaRepository { }, videoStreams: results.streams .filter((stream) => stream.codec_type === 'video') + .filter((stream) => !stream.disposition?.attached_pic) .map((stream) => ({ index: stream.index, height: stream.height || 0, From 14689462f8c8f71e9fe4cb0486c554153fe837b4 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Wed, 7 Aug 2024 23:38:02 +0100 Subject: [PATCH 047/723] feat: change web asset detail map to zoom level 12.5 (#11643) --- web/src/lib/components/asset-viewer/detail-panel.svelte | 6 +++--- .../lib/components/shared-components/change-location.svelte | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 3a56e19d78..708d841a01 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -439,17 +439,17 @@ }, ]} center={latlng} - zoom={15} + zoom={12.5} simplified useLocationPin - onOpenInMapView={() => goto(`${AppRoute.MAP}#15/${latlng.lat}/${latlng.lng}`)} + onOpenInMapView={() => goto(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`)} > {@const { lat, lon } = marker}

{lat.toPrecision(6)}, {lon.toPrecision(6)}

diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index f8d86929cb..3b0cb7bcc1 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -37,7 +37,7 @@ $: lat = asset?.exifInfo?.latitude ?? undefined; $: lng = asset?.exifInfo?.longitude ?? undefined; - $: zoom = lat !== undefined && lng !== undefined ? 15 : 1; + $: zoom = lat !== undefined && lng !== undefined ? 12.5 : 1; $: { if (places) { From 96f80501430c3979af3a54a03c0370b6d550df03 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 8 Aug 2024 13:28:24 +0200 Subject: [PATCH 048/723] feat(web): improve group-tab accessibility (#11647) feat(web): improve GroupTab accessibility --- .../album-page/albums-controls.svelte | 1 + .../lib/components/elements/group-tab.svelte | 41 ++++++++++++------- web/src/lib/i18n/en.json | 1 + web/src/routes/(user)/albums/+page.svelte | 1 + 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/web/src/lib/components/album-page/albums-controls.svelte b/web/src/lib/components/album-page/albums-controls.svelte index 793c2b4970..ae8178a805 100644 --- a/web/src/lib/components/album-page/albums-controls.svelte +++ b/web/src/lib/components/album-page/albums-controls.svelte @@ -129,6 +129,7 @@ +
+ handleRatingChange(enabled)} + /> +
diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index 70f33111c0..781b8ce513 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -1021,6 +1021,8 @@ "purchase_server_title": "Server", "purchase_settings_server_activated": "Der Server Produktschlüssel wird durch den Administrator verwaltet", "range": "Reichweite", + "rating": "Bewertung", + "rating_description": "Stellt die Exif-Bewertung im Informationsbereich dar", "raw": "RAW", "reaction_options": "Reaktionsmöglichkeiten", "read_changelog": "Changelog lesen", diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 807a192013..8c08114feb 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -957,6 +957,8 @@ "purchase_server_description_2": "Supporter status", "purchase_server_title": "Server", "purchase_settings_server_activated": "The server product key is managed by the admin", + "rating": "Star rating", + "rating_description": "Display the exif rating in the info panel", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", "reassign": "Reassign", From 2dd551404360bd534ab5306b7a7a45769c947afe Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 9 Aug 2024 14:07:25 -0400 Subject: [PATCH 061/723] chore(deps): update prom/prometheus docker digest to cafe963 (#11673) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index bf794bf881..fd4ed4f1c9 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -79,7 +79,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:497fe921f22fea8535fa2bcb1c193dacc6ce98c08274257b3d18a4eaae0f9647 + image: prom/prometheus@sha256:cafe963e591c872d38f3ea41ff8eb22cee97917b7c97b5c0ccd43a419f11f613 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus From ca775ab3e946fd555285db655a1ae6472d704841 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Fri, 9 Aug 2024 17:36:32 -0400 Subject: [PATCH 062/723] docs: Update docs + example.env for DB_PASSWORD (#11678) --- docker/example.env | 1 + docs/docs/install/docker-compose.mdx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docker/example.env b/docker/example.env index 99b1a9bbd4..9ad3af3c0e 100644 --- a/docker/example.env +++ b/docker/example.env @@ -12,6 +12,7 @@ DB_DATA_LOCATION=./postgres IMMICH_VERSION=release # Connection secret for postgres. You should change it to a random password +# Please use only the characters `A-Za-z0-9`, without special characters or spaces DB_PASSWORD=postgres # The values below this line do not need to be changed diff --git a/docs/docs/install/docker-compose.mdx b/docs/docs/install/docker-compose.mdx index 9045891fd8..0b69bd8639 100644 --- a/docs/docs/install/docker-compose.mdx +++ b/docs/docs/install/docker-compose.mdx @@ -56,7 +56,8 @@ Optionally, you can enable hardware acceleration for machine learning and transc - Populate custom database information if necessary. - Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. -- Consider changing `DB_PASSWORD` to something randomly generated +- Consider changing `DB_PASSWORD` to a custom value. Postgres is not publically exposed, so this password is only used for local authentication. + To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`. ### Step 3 - Start the containers From 34c4fbf730bcd1c7c2725c133dbf1c076f834c07 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 11 Aug 2024 13:59:26 +0200 Subject: [PATCH 063/723] fix(web): asset viewer dynamic size (#11697) --- web/src/lib/components/asset-viewer/asset-viewer.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index a5485346ed..91238bb9e7 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -380,7 +380,7 @@
From efdf8bbca94525068fa38745aea1c1d98229ebf6 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 11 Aug 2024 14:01:16 +0200 Subject: [PATCH 064/723] refactor(web): simplify some stores (#11695) * refactor(web): simplify some stores * make writable --- web/src/lib/stores/asset-interaction.store.ts | 140 +++++------------- web/src/lib/stores/asset-viewing.store.ts | 15 +- web/src/lib/stores/download.ts | 6 +- web/src/lib/stores/purchase.store.ts | 6 +- web/src/lib/stores/upload.ts | 33 ++--- 5 files changed, 57 insertions(+), 143 deletions(-) diff --git a/web/src/lib/stores/asset-interaction.store.ts b/web/src/lib/stores/asset-interaction.store.ts index 9dd0ab9b8c..f7db5382b0 100644 --- a/web/src/lib/stores/asset-interaction.store.ts +++ b/web/src/lib/stores/asset-interaction.store.ts @@ -1,132 +1,70 @@ import type { AssetResponseDto } from '@immich/sdk'; -import { derived, writable } from 'svelte/store'; +import { derived, readonly, writable } from 'svelte/store'; -export interface AssetInteractionStore { - selectAsset: (asset: AssetResponseDto) => void; - selectAssets: (assets: AssetResponseDto[]) => void; - removeAssetFromMultiselectGroup: (asset: AssetResponseDto) => void; - addGroupToMultiselectGroup: (group: string) => void; - removeGroupFromMultiselectGroup: (group: string) => void; - setAssetSelectionCandidates: (assets: AssetResponseDto[]) => void; - clearAssetSelectionCandidates: () => void; - setAssetSelectionStart: (asset: AssetResponseDto | null) => void; - clearMultiselect: () => void; - isMultiSelectState: { - subscribe: (run: (value: boolean) => void, invalidate?: (value?: boolean) => void) => () => void; - }; - selectedAssets: { - subscribe: ( - run: (value: Set) => void, - invalidate?: (value?: Set) => void, - ) => () => void; - }; - selectedGroup: { - subscribe: (run: (value: Set) => void, invalidate?: (value?: Set) => void) => () => void; - }; - assetSelectionCandidates: { - subscribe: ( - run: (value: Set) => void, - invalidate?: (value?: Set) => void, - ) => () => void; - }; - assetSelectionStart: { - subscribe: ( - run: (value: AssetResponseDto | null) => void, - invalidate?: (value?: AssetResponseDto | null) => void, - ) => () => void; - }; -} +export type AssetInteractionStore = ReturnType; -export function createAssetInteractionStore(): AssetInteractionStore { - let _selectedAssets: Set; - let _selectedGroup: Set; - let _assetSelectionCandidates: Set; - let _assetSelectionStart: AssetResponseDto | null; - - // Selected assets - const selectedAssets = writable>(new Set()); - // Selected date groups - const selectedGroup = writable>(new Set()); - // If any asset selected +export function createAssetInteractionStore() { + const selectedAssets = writable(new Set()); + const selectedGroup = writable(new Set()); const isMultiSelectStoreState = derived(selectedAssets, ($selectedAssets) => $selectedAssets.size > 0); // Candidates for the range selection. This set includes only loaded assets, so it improves highlight // performance. From the user's perspective, range is highlighted almost immediately - const assetSelectionCandidates = writable>(new Set()); + const assetSelectionCandidates = writable(new Set()); // The beginning of the selection range const assetSelectionStart = writable(null); - selectedAssets.subscribe((assets) => { - _selectedAssets = assets; - }); - - selectedGroup.subscribe((group) => { - _selectedGroup = group; - }); - - assetSelectionCandidates.subscribe((assets) => { - _assetSelectionCandidates = assets; - }); - - assetSelectionStart.subscribe((asset) => { - _assetSelectionStart = asset; - }); - const selectAsset = (asset: AssetResponseDto) => { - _selectedAssets.add(asset); - selectedAssets.set(_selectedAssets); + selectedAssets.update(($selectedAssets) => $selectedAssets.add(asset)); }; const selectAssets = (assets: AssetResponseDto[]) => { - for (const asset of assets) { - _selectedAssets.add(asset); - } - selectedAssets.set(_selectedAssets); + selectedAssets.update(($selectedAssets) => { + for (const asset of assets) { + $selectedAssets.add(asset); + } + return $selectedAssets; + }); }; const removeAssetFromMultiselectGroup = (asset: AssetResponseDto) => { - _selectedAssets.delete(asset); - selectedAssets.set(_selectedAssets); + selectedAssets.update(($selectedAssets) => { + $selectedAssets.delete(asset); + return $selectedAssets; + }); }; const addGroupToMultiselectGroup = (group: string) => { - _selectedGroup.add(group); - selectedGroup.set(_selectedGroup); + selectedGroup.update(($selectedGroup) => $selectedGroup.add(group)); }; const removeGroupFromMultiselectGroup = (group: string) => { - _selectedGroup.delete(group); - selectedGroup.set(_selectedGroup); + selectedGroup.update(($selectedGroup) => { + $selectedGroup.delete(group); + return $selectedGroup; + }); }; const setAssetSelectionStart = (asset: AssetResponseDto | null) => { - _assetSelectionStart = asset; - assetSelectionStart.set(_assetSelectionStart); + assetSelectionStart.set(asset); }; const setAssetSelectionCandidates = (assets: AssetResponseDto[]) => { - _assetSelectionCandidates = new Set(assets); - assetSelectionCandidates.set(_assetSelectionCandidates); + assetSelectionCandidates.set(new Set(assets)); }; const clearAssetSelectionCandidates = () => { - _assetSelectionCandidates.clear(); - assetSelectionCandidates.set(_assetSelectionCandidates); + assetSelectionCandidates.set(new Set()); }; const clearMultiselect = () => { // Multi-selection - _selectedAssets.clear(); - _selectedGroup.clear(); + selectedAssets.set(new Set()); + selectedGroup.set(new Set()); // Range selection - _assetSelectionCandidates.clear(); - _assetSelectionStart = null; - - selectedAssets.set(_selectedAssets); - selectedGroup.set(_selectedGroup); - assetSelectionCandidates.set(_assetSelectionCandidates); - assetSelectionStart.set(_assetSelectionStart); + assetSelectionCandidates.set(new Set()); + assetSelectionStart.set(null); }; return { @@ -139,20 +77,10 @@ export function createAssetInteractionStore(): AssetInteractionStore { clearAssetSelectionCandidates, setAssetSelectionStart, clearMultiselect, - isMultiSelectState: { - subscribe: isMultiSelectStoreState.subscribe, - }, - selectedAssets: { - subscribe: selectedAssets.subscribe, - }, - selectedGroup: { - subscribe: selectedGroup.subscribe, - }, - assetSelectionCandidates: { - subscribe: assetSelectionCandidates.subscribe, - }, - assetSelectionStart: { - subscribe: assetSelectionStart.subscribe, - }, + isMultiSelectState: readonly(isMultiSelectStoreState), + selectedAssets: readonly(selectedAssets), + selectedGroup: readonly(selectedGroup), + assetSelectionCandidates: readonly(assetSelectionCandidates), + assetSelectionStart: readonly(assetSelectionStart), }; } diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index bb32149953..cabe2e85a1 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -1,6 +1,6 @@ import { getKey } from '$lib/utils'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; -import { writable } from 'svelte/store'; +import { readonly, writable } from 'svelte/store'; function createAssetViewingStore() { const viewingAssetStoreState = writable(); @@ -23,16 +23,9 @@ function createAssetViewingStore() { }; return { - asset: { - subscribe: viewingAssetStoreState.subscribe, - }, - preloadAssets: { - subscribe: preloadAssets.subscribe, - }, - isViewing: { - subscribe: viewState.subscribe, - set: viewState.set, - }, + asset: readonly(viewingAssetStoreState), + preloadAssets: readonly(preloadAssets), + isViewing: viewState, setAsset, setAssetId, showAssetViewer, diff --git a/web/src/lib/stores/download.ts b/web/src/lib/stores/download.ts index a37b351b44..ac57c76153 100644 --- a/web/src/lib/stores/download.ts +++ b/web/src/lib/stores/download.ts @@ -10,11 +10,7 @@ export interface DownloadProgress { export const downloadAssets = writable>({}); export const isDownloading = derived(downloadAssets, ($downloadAssets) => { - if (Object.keys($downloadAssets).length === 0) { - return false; - } - - return true; + return Object.keys($downloadAssets).length > 0; }); const update = (key: string, value: Partial | null) => { diff --git a/web/src/lib/stores/purchase.store.ts b/web/src/lib/stores/purchase.store.ts index e21a4b804b..4b9c61eed7 100644 --- a/web/src/lib/stores/purchase.store.ts +++ b/web/src/lib/stores/purchase.store.ts @@ -1,4 +1,4 @@ -import { writable } from 'svelte/store'; +import { readonly, writable } from 'svelte/store'; function createPurchaseStore() { const isPurcharsed = writable(false); @@ -8,9 +8,7 @@ function createPurchaseStore() { } return { - isPurchased: { - subscribe: isPurcharsed.subscribe, - }, + isPurchased: readonly(isPurcharsed), setPurchaseStatus, }; } diff --git a/web/src/lib/stores/upload.ts b/web/src/lib/stores/upload.ts index 93a1464b02..16f967edb6 100644 --- a/web/src/lib/stores/upload.ts +++ b/web/src/lib/stores/upload.ts @@ -1,4 +1,4 @@ -import { derived, get, writable } from 'svelte/store'; +import { derived, writable } from 'svelte/store'; import { UploadState, type UploadAsset } from '../models/upload-asset'; function createUploadStore() { @@ -22,23 +22,22 @@ function createUploadStore() { ); const addNewUploadAsset = (newAsset: UploadAsset) => { - const assets = get(uploadAssets); - const duplicate = assets.find((asset) => asset.id === newAsset.id); - if (duplicate) { - uploadAssets.update((assets) => assets.map((asset) => (asset.id === newAsset.id ? newAsset : asset))); - } else { + uploadAssets.update(($assets) => { + const duplicate = $assets.find((asset) => asset.id === newAsset.id); + if (duplicate) { + return $assets.map((asset) => (asset.id === newAsset.id ? newAsset : asset)); + } + totalUploadCounter.update((c) => c + 1); - uploadAssets.update((assets) => [ - ...assets, - { - ...newAsset, - speed: 0, - state: UploadState.PENDING, - progress: 0, - eta: 0, - }, - ]); - } + $assets.push({ + ...newAsset, + speed: 0, + state: UploadState.PENDING, + progress: 0, + eta: 0, + }); + return $assets; + }); }; const updateProgress = (id: string, loaded: number, total: number) => { From 7d320217b9adb6f71996e468b998be4a2362fcd7 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 11 Aug 2024 14:01:37 +0200 Subject: [PATCH 065/723] chore(web): remove unused file (#11696) --- .../album-page/thumbnail-selection.svelte | 53 ------------------- 1 file changed, 53 deletions(-) delete mode 100644 web/src/lib/components/album-page/thumbnail-selection.svelte diff --git a/web/src/lib/components/album-page/thumbnail-selection.svelte b/web/src/lib/components/album-page/thumbnail-selection.svelte deleted file mode 100644 index 9e6c786d22..0000000000 --- a/web/src/lib/components/album-page/thumbnail-selection.svelte +++ /dev/null @@ -1,53 +0,0 @@ - - -
- dispatch('close')}> - -

{$t('select_album_cover')}

-
- - - - -
- -
- -
- {#each album.assets as asset (asset.id)} - (selectedThumbnail = asset)} selected={isSelected(asset.id)} /> - {/each} -
-
-
From 9ed04588b8bd8df70586a99d1825a696f3d4da1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=BCtz?= Date: Sun, 11 Aug 2024 09:23:11 -0700 Subject: [PATCH 066/723] chore(deps): update pydantic to v2 (#11701) --- machine-learning/app/config.py | 2 +- machine-learning/app/main.py | 2 +- machine-learning/app/schemas.py | 2 +- machine-learning/poetry.lock | 192 +++++++++++++++++++++++--------- machine-learning/pyproject.toml | 2 +- 5 files changed, 144 insertions(+), 56 deletions(-) diff --git a/machine-learning/app/config.py b/machine-learning/app/config.py index af2d0aa4b9..5dec031529 100644 --- a/machine-learning/app/config.py +++ b/machine-learning/app/config.py @@ -6,7 +6,7 @@ from pathlib import Path from socket import socket from gunicorn.arbiter import Arbiter -from pydantic import BaseModel, BaseSettings +from pydantic.v1 import BaseModel, BaseSettings from rich.console import Console from rich.logging import RichHandler from uvicorn import Server diff --git a/machine-learning/app/main.py b/machine-learning/app/main.py index 000119937e..52b9a66c05 100644 --- a/machine-learning/app/main.py +++ b/machine-learning/app/main.py @@ -15,7 +15,7 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException from fastapi.responses import ORJSONResponse from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile from PIL.Image import Image -from pydantic import ValidationError +from pydantic.v1 import ValidationError from starlette.formparsers import MultiPartParser from app.models import get_model_deps diff --git a/machine-learning/app/schemas.py b/machine-learning/app/schemas.py index f051db12c3..e8a36ef44d 100644 --- a/machine-learning/app/schemas.py +++ b/machine-learning/app/schemas.py @@ -3,7 +3,7 @@ from typing import Any, Literal, Protocol, TypedDict, TypeGuard, TypeVar import numpy as np import numpy.typing as npt -from pydantic import BaseModel +from pydantic.v1 import BaseModel class StrEnum(str, Enum): diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index abe4003442..a44933cb52 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -40,6 +40,17 @@ develop = ["imgaug (>=0.4.0)", "pytest"] imgaug = ["imgaug (>=0.4.0)"] tests = ["pytest"] +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "anyio" version = "4.2.0" @@ -1551,8 +1562,8 @@ psutil = ">=5.9.1" pywin32 = {version = "*", markers = "sys_platform == \"win32\""} pyzmq = ">=25.0.0" requests = [ - {version = ">=2.26.0", markers = "python_full_version <= \"3.11.0\""}, {version = ">=2.32.2", markers = "python_full_version > \"3.11.0\""}, + {version = ">=2.26.0", markers = "python_full_version <= \"3.11.0\""}, ] tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.11\""} @@ -2074,10 +2085,10 @@ files = [ [package.dependencies] numpy = [ + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] [[package]] @@ -2117,6 +2128,8 @@ files = [ {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"}, {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"}, {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"}, + {file = "orjson-3.10.6-cp313-none-win32.whl", hash = "sha256:efdf2c5cde290ae6b83095f03119bdc00303d7a03b42b16c54517baa3c4ca3d0"}, + {file = "orjson-3.10.6-cp313-none-win_amd64.whl", hash = "sha256:8e190fe7888e2e4392f52cafb9626113ba135ef53aacc65cd13109eb9746c43e"}, {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"}, {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"}, {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"}, @@ -2367,62 +2380,126 @@ files = [ [[package]] name = "pydantic" -version = "1.10.17" -description = "Data validation and settings management using python type hints" +version = "2.8.2" +description = "Data validation using Python type hints" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pydantic-1.10.17-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fa51175313cc30097660b10eec8ca55ed08bfa07acbfe02f7a42f6c242e9a4b"}, - {file = "pydantic-1.10.17-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7e8988bb16988890c985bd2093df9dd731bfb9d5e0860db054c23034fab8f7a"}, - {file = "pydantic-1.10.17-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:371dcf1831f87c9e217e2b6a0c66842879a14873114ebb9d0861ab22e3b5bb1e"}, - {file = "pydantic-1.10.17-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4866a1579c0c3ca2c40575398a24d805d4db6cb353ee74df75ddeee3c657f9a7"}, - {file = "pydantic-1.10.17-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:543da3c6914795b37785703ffc74ba4d660418620cc273490d42c53949eeeca6"}, - {file = "pydantic-1.10.17-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7623b59876f49e61c2e283551cc3647616d2fbdc0b4d36d3d638aae8547ea681"}, - {file = "pydantic-1.10.17-cp310-cp310-win_amd64.whl", hash = "sha256:409b2b36d7d7d19cd8310b97a4ce6b1755ef8bd45b9a2ec5ec2b124db0a0d8f3"}, - {file = "pydantic-1.10.17-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fa43f362b46741df8f201bf3e7dff3569fa92069bcc7b4a740dea3602e27ab7a"}, - {file = "pydantic-1.10.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2a72d2a5ff86a3075ed81ca031eac86923d44bc5d42e719d585a8eb547bf0c9b"}, - {file = "pydantic-1.10.17-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4ad32aed3bf5eea5ca5decc3d1bbc3d0ec5d4fbcd72a03cdad849458decbc63"}, - {file = "pydantic-1.10.17-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb4e741782e236ee7dc1fb11ad94dc56aabaf02d21df0e79e0c21fe07c95741"}, - {file = "pydantic-1.10.17-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d2f89a719411cb234105735a520b7c077158a81e0fe1cb05a79c01fc5eb59d3c"}, - {file = "pydantic-1.10.17-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db3b48d9283d80a314f7a682f7acae8422386de659fffaba454b77a083c3937d"}, - {file = "pydantic-1.10.17-cp311-cp311-win_amd64.whl", hash = "sha256:9c803a5113cfab7bbb912f75faa4fc1e4acff43e452c82560349fff64f852e1b"}, - {file = "pydantic-1.10.17-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:820ae12a390c9cbb26bb44913c87fa2ff431a029a785642c1ff11fed0a095fcb"}, - {file = "pydantic-1.10.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1e51d1af306641b7d1574d6d3307eaa10a4991542ca324f0feb134fee259815"}, - {file = "pydantic-1.10.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e53fb834aae96e7b0dadd6e92c66e7dd9cdf08965340ed04c16813102a47fab"}, - {file = "pydantic-1.10.17-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e2495309b1266e81d259a570dd199916ff34f7f51f1b549a0d37a6d9b17b4dc"}, - {file = "pydantic-1.10.17-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:098ad8de840c92ea586bf8efd9e2e90c6339d33ab5c1cfbb85be66e4ecf8213f"}, - {file = "pydantic-1.10.17-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:525bbef620dac93c430d5d6bdbc91bdb5521698d434adf4434a7ef6ffd5c4b7f"}, - {file = "pydantic-1.10.17-cp312-cp312-win_amd64.whl", hash = "sha256:6654028d1144df451e1da69a670083c27117d493f16cf83da81e1e50edce72ad"}, - {file = "pydantic-1.10.17-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c87cedb4680d1614f1d59d13fea353faf3afd41ba5c906a266f3f2e8c245d655"}, - {file = "pydantic-1.10.17-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11289fa895bcbc8f18704efa1d8020bb9a86314da435348f59745473eb042e6b"}, - {file = "pydantic-1.10.17-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94833612d6fd18b57c359a127cbfd932d9150c1b72fea7c86ab58c2a77edd7c7"}, - {file = "pydantic-1.10.17-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d4ecb515fa7cb0e46e163ecd9d52f9147ba57bc3633dca0e586cdb7a232db9e3"}, - {file = "pydantic-1.10.17-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7017971ffa7fd7808146880aa41b266e06c1e6e12261768a28b8b41ba55c8076"}, - {file = "pydantic-1.10.17-cp37-cp37m-win_amd64.whl", hash = "sha256:e840e6b2026920fc3f250ea8ebfdedf6ea7a25b77bf04c6576178e681942ae0f"}, - {file = "pydantic-1.10.17-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bfbb18b616abc4df70591b8c1ff1b3eabd234ddcddb86b7cac82657ab9017e33"}, - {file = "pydantic-1.10.17-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ebb249096d873593e014535ab07145498957091aa6ae92759a32d40cb9998e2e"}, - {file = "pydantic-1.10.17-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8c209af63ccd7b22fba94b9024e8b7fd07feffee0001efae50dd99316b27768"}, - {file = "pydantic-1.10.17-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b40c9e13a0b61583e5599e7950490c700297b4a375b55b2b592774332798b7"}, - {file = "pydantic-1.10.17-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c31d281c7485223caf6474fc2b7cf21456289dbaa31401844069b77160cab9c7"}, - {file = "pydantic-1.10.17-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ae5184e99a060a5c80010a2d53c99aee76a3b0ad683d493e5f0620b5d86eeb75"}, - {file = "pydantic-1.10.17-cp38-cp38-win_amd64.whl", hash = "sha256:ad1e33dc6b9787a6f0f3fd132859aa75626528b49cc1f9e429cdacb2608ad5f0"}, - {file = "pydantic-1.10.17-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e17c0ee7192e54a10943f245dc79e36d9fe282418ea05b886e1c666063a7b54"}, - {file = "pydantic-1.10.17-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cafb9c938f61d1b182dfc7d44a7021326547b7b9cf695db5b68ec7b590214773"}, - {file = "pydantic-1.10.17-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95ef534e3c22e5abbdbdd6f66b6ea9dac3ca3e34c5c632894f8625d13d084cbe"}, - {file = "pydantic-1.10.17-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d96b8799ae3d782df7ec9615cb59fc32c32e1ed6afa1b231b0595f6516e8ab"}, - {file = "pydantic-1.10.17-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ab2f976336808fd5d539fdc26eb51f9aafc1f4b638e212ef6b6f05e753c8011d"}, - {file = "pydantic-1.10.17-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8ad363330557beac73159acfbeed220d5f1bfcd6b930302a987a375e02f74fd"}, - {file = "pydantic-1.10.17-cp39-cp39-win_amd64.whl", hash = "sha256:48db882e48575ce4b39659558b2f9f37c25b8d348e37a2b4e32971dd5a7d6227"}, - {file = "pydantic-1.10.17-py3-none-any.whl", hash = "sha256:e41b5b973e5c64f674b3b4720286ded184dcc26a691dd55f34391c62c6934688"}, - {file = "pydantic-1.10.17.tar.gz", hash = "sha256:f434160fb14b353caf634149baaf847206406471ba70e64657c1e8330277a991"}, + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, ] [package.dependencies] -typing-extensions = ">=4.2.0" +annotated-types = ">=0.4.0" +pydantic-core = "2.20.1" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] [package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.20.1" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, + {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, + {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, + {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, + {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, + {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, + {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, + {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, + {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pygments" @@ -3244,6 +3321,17 @@ files = [ {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, ] +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + [[package]] name = "urllib3" version = "2.1.0" @@ -3600,4 +3688,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "b2b053886ca1dd3a3305c63caf155b1976dfc4066f72f5d1ecfc42099db34aab" +content-hash = "187485f19267f2d0a01e38fc0c1f8911c07a29aee11080179a96a127abb9c11b" diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 37001ba2eb..e9a9708f15 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -13,7 +13,7 @@ opencv-python-headless = ">=4.7.0.72,<5.0" pillow = ">=9.5.0,<11.0" fastapi-slim = ">=0.95.2,<1.0" uvicorn = {extras = ["standard"], version = ">=0.22.0,<1.0"} -pydantic = "^1.10.8" +pydantic = "^2.8.2" aiocache = ">=0.12.1,<1.0" rich = ">=13.4.2" ftfy = ">=6.1.1" From 30aa2c9b82a2817170cf275b24ad8b3021107bf1 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 11 Aug 2024 21:43:07 +0200 Subject: [PATCH 067/723] fix(web): use fallback image if shared asset isn't resized (#11704) * fix(web): use fallback image if shared asset isn't resized * remove test-data index file --- .../album-page/__tests__/album-card.spec.ts | 2 +- .../album-page/__tests__/album-cover.spec.ts | 2 +- .../covers/__tests__/share-cover.spec.ts | 36 +++++++++---------- .../covers/share-cover.svelte | 2 +- web/src/test-data/index.ts | 1 - 5 files changed, 21 insertions(+), 22 deletions(-) delete mode 100644 web/src/test-data/index.ts diff --git a/web/src/lib/components/album-page/__tests__/album-card.spec.ts b/web/src/lib/components/album-page/__tests__/album-card.spec.ts index 6ffa273a4d..79136bca02 100644 --- a/web/src/lib/components/album-page/__tests__/album-card.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-card.spec.ts @@ -1,5 +1,5 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; -import { albumFactory } from '@test-data'; +import { albumFactory } from '@test-data/factories/album-factory'; import '@testing-library/jest-dom'; import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte'; import { init, register, waitLocale } from 'svelte-i18n'; diff --git a/web/src/lib/components/album-page/__tests__/album-cover.spec.ts b/web/src/lib/components/album-page/__tests__/album-cover.spec.ts index 4f5fb7e571..1688283116 100644 --- a/web/src/lib/components/album-page/__tests__/album-cover.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-cover.spec.ts @@ -1,6 +1,6 @@ import AlbumCover from '$lib/components/album-page/album-cover.svelte'; import { getAssetThumbnailUrl } from '$lib/utils'; -import { albumFactory } from '@test-data'; +import { albumFactory } from '@test-data/factories/album-factory'; import { render } from '@testing-library/svelte'; vi.mock('$lib/utils'); diff --git a/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts b/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts index 774c433562..c14b618dce 100644 --- a/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts +++ b/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts @@ -1,19 +1,16 @@ import ShareCover from '$lib/components/sharedlinks-page/covers/share-cover.svelte'; import { getAssetThumbnailUrl } from '$lib/utils'; -import type { SharedLinkResponseDto } from '@immich/sdk'; -import { albumFactory } from '@test-data'; -import { render } from '@testing-library/svelte'; +import { albumFactory } from '@test-data/factories/album-factory'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; +import { render, screen } from '@testing-library/svelte'; vi.mock('$lib/utils'); describe('ShareCover component', () => { it('renders an image when the shared link is an album', () => { const component = render(ShareCover, { - link: { - album: albumFactory.build({ - albumName: '123', - }), - } as SharedLinkResponseDto, + link: sharedLinkFactory.build({ album: albumFactory.build({ albumName: '123' }) }), preload: false, class: 'text', }); @@ -26,13 +23,7 @@ describe('ShareCover component', () => { it('renders an image when the shared link is an individual share', () => { vi.mocked(getAssetThumbnailUrl).mockReturnValue('/asdf'); const component = render(ShareCover, { - link: { - assets: [ - { - id: 'someId', - }, - ], - } as SharedLinkResponseDto, + link: sharedLinkFactory.build({ assets: [assetFactory.build({ id: 'someId' })] }), preload: false, class: 'text', }); @@ -46,9 +37,7 @@ describe('ShareCover component', () => { it('renders an image when the shared link has no album or assets', () => { const component = render(ShareCover, { - link: { - assets: [], - } as unknown as SharedLinkResponseDto, + link: sharedLinkFactory.build(), preload: false, class: 'text', }); @@ -57,4 +46,15 @@ describe('ShareCover component', () => { expect(img.getAttribute('loading')).toBe('lazy'); expect(img.className).toBe('z-0 rounded-xl object-cover text'); }); + + it('renders fallback image when asset is not resized', () => { + const link = sharedLinkFactory.build({ assets: [assetFactory.build({ resized: false })] }); + render(ShareCover, { + link: link, + preload: false, + }); + + const img = screen.getByTestId('album-image'); + expect(img.alt).toBe('unnamed_share'); + }); }); diff --git a/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte index 63d50d60e6..12d383476f 100644 --- a/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte @@ -15,7 +15,7 @@
{#if link?.album} - {:else if link.assets[0]} + {:else if link.assets[0]?.resized} Date: Mon, 12 Aug 2024 13:40:31 +0200 Subject: [PATCH 068/723] fix(web): hide import json button when using config file (#11714) --- web/src/routes/admin/system-settings/+page.svelte | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index eff9336121..0555bab256 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -187,12 +187,14 @@ {$t('export_as_json')}
- inputElement?.click()}> -
- - {$t('import_from_json')} -
-
+ {#if !$featureFlags.configFile} + inputElement?.click()}> +
+ + {$t('import_from_json')} +
+
+ {/if} From c2965c44084b495c23a4ed7222061eda42a3cbc3 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 12 Aug 2024 14:10:43 +0200 Subject: [PATCH 069/723] fix(web): detail panel out of sync when reopening (#11713) * fix(web): detail panel out of sync when reopening * extract event handler --- .../asset-viewer/detail-panel.e2e-spec.ts | 26 +++++++++++++++++++ .../asset-viewer/asset-viewer.svelte | 23 +++++++++++----- .../asset-viewer/detail-panel.svelte | 11 +------- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts index 072b48908e..2f90e4e3d8 100644 --- a/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts +++ b/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts @@ -1,16 +1,23 @@ import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; import { expect, test } from '@playwright/test'; +import type { Socket } from 'socket.io-client'; import { utils } from 'src/utils'; test.describe('Detail Panel', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; + let websocket: Socket; test.beforeAll(async () => { utils.initSdk(); await utils.resetDatabase(); admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); + websocket = await utils.connectWebsocket(admin.accessToken); + }); + + test.afterAll(() => { + utils.disconnectWebsocket(websocket); }); test('can be opened for shared links', async ({ page }) => { @@ -57,4 +64,23 @@ test.describe('Detail Panel', () => { await expect(textarea).toBeVisible(); await expect(textarea).not.toBeDisabled(); }); + + test('description changes are visible after reopening', async ({ context, page }) => { + await utils.setAuthCookies(context, admin.accessToken); + await page.goto(`/photos/${asset.id}`); + await page.waitForSelector('#immich-asset-viewer'); + + await page.getByRole('button', { name: 'Info' }).click(); + const textarea = page.getByRole('textbox', { name: 'Add a description' }); + await textarea.fill('new description'); + await expect(textarea).toHaveValue('new description'); + + await page.getByRole('button', { name: 'Info' }).click(); + await expect(textarea).not.toBeVisible(); + await page.getByRole('button', { name: 'Info' }).click(); + await expect(textarea).toBeVisible(); + + await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id }); + await expect(textarea).toHaveValue('new description'); + }); }); diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 91238bb9e7..2148ff7dda 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -83,7 +83,7 @@ let isLiked: ActivityResponseDto | null = null; let numberOfComments: number; let fullscreenElement: Element; - let unsubscribe: () => void; + let unsubscribes: (() => void)[] = []; let zoomToggle = () => void 0; let copyImage: () => Promise; @@ -172,6 +172,12 @@ } }; + const onAssetUpdate = (assetUpdate: AssetResponseDto) => { + if (assetUpdate.id === asset.id) { + asset = assetUpdate; + } + }; + $: { if (isShared && asset.id) { handlePromiseError(getFavorite()); @@ -180,11 +186,11 @@ } onMount(async () => { - unsubscribe = websocketEvents.on('on_upload_success', (assetUpdate) => { - if (assetUpdate.id === asset.id) { - asset = assetUpdate; - } - }); + unsubscribes.push( + websocketEvents.on('on_upload_success', onAssetUpdate), + websocketEvents.on('on_asset_update', onAssetUpdate), + ); + await navigate({ targetRoute: 'current', assetId: asset.id }); slideshowStateUnsubscribe = slideshowState.subscribe((value) => { if (value === SlideshowState.PlaySlideshow) { @@ -225,7 +231,10 @@ if (shuffleSlideshowUnsubscribe) { shuffleSlideshowUnsubscribe(); } - unsubscribe?.(); + + for (const unsubscribe of unsubscribes) { + unsubscribe(); + } }); $: { diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 268de61f04..2dd5ff1a4d 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -7,7 +7,6 @@ import { locale } from '$lib/stores/preferences.store'; import { featureFlags } from '$lib/stores/server-config.store'; import { user } from '$lib/stores/user.store'; - import { websocketEvents } from '$lib/stores/websocket'; import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError, isSharedLink } from '$lib/utils'; import { delay, isFlipped } from '$lib/utils/asset-utils'; import { @@ -30,7 +29,7 @@ mdiAccountOff, } from '@mdi/js'; import { DateTime } from 'luxon'; - import { createEventDispatcher, onMount } from 'svelte'; + import { createEventDispatcher } from 'svelte'; import { slide } from 'svelte/transition'; import { getByteUnitString } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; @@ -99,14 +98,6 @@ $: unassignedFaces = asset.unassignedFaces || []; - onMount(() => { - return websocketEvents.on('on_asset_update', (assetUpdate) => { - if (assetUpdate.id === asset.id) { - asset = assetUpdate; - } - }); - }); - const dispatch = createEventDispatcher<{ close: void; }>(); From 7eb004bd00dfd66928bfffbd581cb0d06fad4549 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 12 Aug 2024 14:49:07 -0400 Subject: [PATCH 070/723] chore: better release notes (#11726) * chore: better release notes * chore: remove 'tedious' commits --- .github/release.yml | 37 +++++++++++-------------------------- renovate.json | 2 +- 2 files changed, 12 insertions(+), 27 deletions(-) diff --git a/.github/release.yml b/.github/release.yml index 03483f9197..04038d22a9 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -1,41 +1,26 @@ changelog: categories: - - title: ⚠️ Breaking Changes + - title: 🚨 Breaking Changes labels: - breaking-change - - title: 🗄️ Server + - title: 🔒 Security labels: - - 🗄️server + - security - - title: 📱 Mobile + - title: 🚀 Features labels: - - 📱mobile + - feature + - enhancement - - title: 🖥️ Web + - title: 🐛 Bug fixes labels: - - 🖥️web + - bugfix - - title: 🧠 Machine Learning - labels: - - 🧠machine-learning - - - title: ⚡ CLI - labels: - - cli - - - title: 📓 Documentation + - title: 📚 Documentation labels: - documentation - - title: 🔨 Maintenance + - title: 🌐 Translations labels: - - deployment - - dependencies - - renovate - - maintenance - - tech-debt - - - title: Other changes - labels: - - "*" + - translation diff --git a/renovate.json b/renovate.json index c15aded006..6f5424023b 100644 --- a/renovate.json +++ b/renovate.json @@ -81,5 +81,5 @@ ], "ignorePaths": ["mobile/openapi/pubspec.yaml", "mobile/ios", "mobile/android"], "ignoreDeps": ["http", "intl"], - "labels": ["dependencies", "renovate"] + "labels": ["dependencies"] } From 54b276c984cf8329dec144ecb7039c160fdb5bb0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 12 Aug 2024 23:31:57 -0400 Subject: [PATCH 071/723] chore(deps): update dependency @types/node to ^20.14.14 (#11737) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 10 +++++----- cli/package.json | 2 +- e2e/package-lock.json | 12 ++++++------ e2e/package.json | 2 +- open-api/typescript-sdk/package-lock.json | 8 ++++---- open-api/typescript-sdk/package.json | 2 +- server/package-lock.json | 14 +++++++------- server/package.json | 2 +- 8 files changed, 26 insertions(+), 26 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 4e8ff311df..76993c5354 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -24,7 +24,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.13", + "@types/node": "^20.14.14", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -59,7 +59,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.13", + "@types/node": "^20.14.14", "typescript": "^5.3.3" } }, @@ -1269,9 +1269,9 @@ } }, "node_modules/@types/node": { - "version": "20.14.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", - "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", + "version": "20.14.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", + "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 805efb0124..491fd317e9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.13", + "@types/node": "^20.14.14", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 70ffcf8fc7..255e67356a 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.14.13", + "@types/node": "^20.14.14", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -64,7 +64,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.13", + "@types/node": "^20.14.14", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -99,7 +99,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.13", + "@types/node": "^20.14.14", "typescript": "^5.3.3" } }, @@ -1516,9 +1516,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.14.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", - "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", + "version": "20.14.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", + "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 1b272c7229..364dcc9682 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.14.13", + "@types/node": "^20.14.14", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 6dd2e5d3f4..4ddd61093b 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -12,7 +12,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.13", + "@types/node": "^20.14.14", "typescript": "^5.3.3" } }, @@ -22,9 +22,9 @@ "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" }, "node_modules/@types/node": { - "version": "20.14.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", - "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", + "version": "20.14.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", + "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 9f80d5b5f9..e699e94be1 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.13", + "@types/node": "^20.14.14", "typescript": "^5.3.3" }, "repository": { diff --git a/server/package-lock.json b/server/package-lock.json index 6d793bac9a..155a0fd293 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -83,7 +83,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.14.13", + "@types/node": "^20.14.14", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/semver": "^7.5.8", @@ -6014,9 +6014,9 @@ } }, "node_modules/@types/node": { - "version": "20.14.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", - "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", + "version": "20.14.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", + "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", "dependencies": { "undici-types": "~5.26.4" } @@ -20302,9 +20302,9 @@ } }, "@types/node": { - "version": "20.14.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", - "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", + "version": "20.14.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", + "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", "requires": { "undici-types": "~5.26.4" } diff --git a/server/package.json b/server/package.json index fb6563cdd0..008a386abf 100644 --- a/server/package.json +++ b/server/package.json @@ -109,7 +109,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.14.13", + "@types/node": "^20.14.14", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/semver": "^7.5.8", From e3846920257f08c8767a8557d4a47d80508feb10 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 08:17:17 -0400 Subject: [PATCH 072/723] chore(deps): update typescript-projects (#11743) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 18 ++-- docs/package-lock.json | 68 +++++++------ e2e/package-lock.json | 29 +++--- server/package-lock.json | 211 +++++++++++++++++++++------------------ web/package-lock.json | 119 ++++++++++++---------- 5 files changed, 242 insertions(+), 203 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 76993c5354..ffd0bc429d 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -3427,9 +3427,9 @@ } }, "node_modules/postcss": { - "version": "8.4.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", - "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", + "version": "8.4.41", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", + "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", "dev": true, "funding": [ { @@ -4206,14 +4206,14 @@ } }, "node_modules/vite": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz", - "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz", + "integrity": "sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.39", + "postcss": "^8.4.40", "rollup": "^4.13.0" }, "bin": { @@ -4233,6 +4233,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -4250,6 +4251,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, diff --git a/docs/package-lock.json b/docs/package-lock.json index d7af7be4cf..38750c6a66 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -4237,9 +4237,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.19", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", - "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", "funding": [ { "type": "opencollective", @@ -4254,12 +4254,13 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "browserslist": "^4.23.0", - "caniuse-lite": "^1.0.30001599", + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -4531,9 +4532,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", "funding": [ { "type": "opencollective", @@ -4548,11 +4549,12 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" }, "bin": { "browserslist": "cli.js" @@ -4699,9 +4701,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001614", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001614.tgz", - "integrity": "sha512-jmZQ1VpmlRwHgdP1/uiKzgiAuGOfLEJsYFP4+GBou/QQ4U6IOJCB4NP1c+1p9RGLpwObcT94jA5/uO+F1vBbog==", + "version": "1.0.30001651", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", + "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", "funding": [ { "type": "opencollective", @@ -4715,7 +4717,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/ccount": { "version": "2.0.1", @@ -6342,9 +6345,10 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.4.751", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.751.tgz", - "integrity": "sha512-2DEPi++qa89SMGRhufWTiLmzqyuGmNF3SK4+PQetW1JKiZdEpF4XQonJXJCzyuYSA6mauiMhbyVhqYAP45Hvfw==" + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz", + "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==", + "license": "ISC" }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -11958,9 +11962,10 @@ } }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "license": "MIT" }, "node_modules/nopt": { "version": "1.0.10", @@ -16015,9 +16020,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz", - "integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==", + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.8.tgz", + "integrity": "sha512-GkP17r9GQkxgZ9FKHJQEnjJuKBcbFhMFzKu5slmN6NjlCuFnYJMQ8N4AZ6VrUyiRXlDtPKHkesuQ/MS913Nvdg==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -16608,9 +16613,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "funding": [ { "type": "opencollective", @@ -16625,9 +16630,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 255e67356a..eed3ee6de8 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1113,13 +1113,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.3.tgz", - "integrity": "sha512-UKF4XsBfy+u3MFWEH44hva1Q8Da28G6RFtR2+5saw+jgAFQV5yYnB1fu68Mz7fO+5GJF3wgwAIs0UelU8TxFrA==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.0.tgz", + "integrity": "sha512-/QYft5VArOrGRP5pgkrfKksqsKA6CEFyGQ/gjNe6q0y4tZ1aaPfq4gIjudr1s3D+pXyrPRdsy4opKDrjBabE5w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.45.3" + "playwright": "1.46.0" }, "bin": { "playwright": "cli.js" @@ -4357,10 +4357,11 @@ "dev": true }, "node_modules/luxon": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", - "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -5177,13 +5178,13 @@ } }, "node_modules/playwright": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.3.tgz", - "integrity": "sha512-QhVaS+lpluxCaioejDZ95l4Y4jSFCsBvl2UZkpeXlzxmqS+aABr5c82YmfMHrL6x27nvrvykJAFpkzT2eWdJww==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.0.tgz", + "integrity": "sha512-XYJ5WvfefWONh1uPAUAi0H2xXV5S3vrtcnXe6uAOgdGi3aSpqOSXX08IAjXW34xitfuOJsvXU5anXZxPSEQiJw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.45.3" + "playwright-core": "1.46.0" }, "bin": { "playwright": "cli.js" @@ -5196,9 +5197,9 @@ } }, "node_modules/playwright-core": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.3.tgz", - "integrity": "sha512-+ym0jNbcjikaOwwSZycFbwkWgfruWvYlJfThKYAlImbxUgdWFO2oW70ojPm4OpE4t6TAo2FY/smM+hpVTtkhDA==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.0.tgz", + "integrity": "sha512-9Y/d5UIwuJk8t3+lhmMSAJyNP1BUC/DqP3cQJDQQL/oWqAiuPTLgy7Q5dzglmTLwcBRdetzgNM/gni7ckfTr6A==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/server/package-lock.json b/server/package-lock.json index 155a0fd293..db165eec46 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -5483,9 +5483,9 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "node_modules/@swc/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.5.tgz", - "integrity": "sha512-qKK0/Ta4qvxs/ok3XyYVPT7OBenwRn1sSINf1cKQTBHPqr7U/uB4k2GTl6JgEs8H4PiJrMTNWfMLTucIoVSfAg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.6.tgz", + "integrity": "sha512-FZxyao9eQks1MRmUshgsZTmlg/HB2oXK5fghkoWJm/1CU2q2kaJlVDll2as5j+rmWiwkp0Gidlq8wlXcEEAO+g==", "devOptional": true, "hasInstallScript": true, "dependencies": { @@ -5500,16 +5500,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.7.5", - "@swc/core-darwin-x64": "1.7.5", - "@swc/core-linux-arm-gnueabihf": "1.7.5", - "@swc/core-linux-arm64-gnu": "1.7.5", - "@swc/core-linux-arm64-musl": "1.7.5", - "@swc/core-linux-x64-gnu": "1.7.5", - "@swc/core-linux-x64-musl": "1.7.5", - "@swc/core-win32-arm64-msvc": "1.7.5", - "@swc/core-win32-ia32-msvc": "1.7.5", - "@swc/core-win32-x64-msvc": "1.7.5" + "@swc/core-darwin-arm64": "1.7.6", + "@swc/core-darwin-x64": "1.7.6", + "@swc/core-linux-arm-gnueabihf": "1.7.6", + "@swc/core-linux-arm64-gnu": "1.7.6", + "@swc/core-linux-arm64-musl": "1.7.6", + "@swc/core-linux-x64-gnu": "1.7.6", + "@swc/core-linux-x64-musl": "1.7.6", + "@swc/core-win32-arm64-msvc": "1.7.6", + "@swc/core-win32-ia32-msvc": "1.7.6", + "@swc/core-win32-x64-msvc": "1.7.6" }, "peerDependencies": { "@swc/helpers": "*" @@ -5521,9 +5521,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.5.tgz", - "integrity": "sha512-Y+bvW9C4/u26DskMbtQKT4FU6QQenaDYkKDi028vDIKAa7v1NZqYG9wmhD/Ih7n5EUy2uJ5I5EWD7WaoLzT6PA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.6.tgz", + "integrity": "sha512-6lYHey84ZzsdtC7UuPheM4Rm0Inzxm6Sb8U6dmKc4eCx8JL0LfWG4LC5RsdsrTxnjTsbriWlnhZBffh8ijUHIQ==", "cpu": [ "arm64" ], @@ -5537,9 +5537,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.5.tgz", - "integrity": "sha512-AuIbDlcaAhYS6mtF4UqvXgrLeAfXZbVf4pgtgShPbutF80VbCQiIB55zOFz5aZdCpsBVuCWcBq0zLneK+VQKkQ==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.6.tgz", + "integrity": "sha512-Fyl+8aH9O5rpx4O7r2KnsPpoi32iWoKOYKiipeTbGjQ/E95tNPxbmsz4yqE8Ovldcga60IPJ5OKQA3HWRiuzdw==", "cpu": [ "x64" ], @@ -5553,9 +5553,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.5.tgz", - "integrity": "sha512-99uBPHITRqgGwCXAjHY94VaV3Z40+D2NQNgR1t6xQpO8ZnevI6YSzX6GVZfBnV7+7oisiGkrVEwfIRRa+1s8FA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.6.tgz", + "integrity": "sha512-2WxYTqFaOx48GKC2cbO1/IntA+w+kfCFy436Ij7qRqqtV/WAvTM9TC1OmiFbqq436rSot52qYmX8fkwdB5UcLQ==", "cpu": [ "arm" ], @@ -5569,9 +5569,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.5.tgz", - "integrity": "sha512-xHL3Erlz+OGGCG4h6K2HWiR56H5UYMuBWWPbbUufi2bJpfhuKQy/X3vWffwL8ZVfJmCUwr4/G91GHcm32uYzRg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.6.tgz", + "integrity": "sha512-TBEGMSe0LhvPe4S7E68c7VzgT3OMu4VTmBLS7B2aHv4v8uZO92Khpp7L0WqgYU1y5eMjk+XLDLi4kokiNHv/Hg==", "cpu": [ "arm64" ], @@ -5585,9 +5585,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.5.tgz", - "integrity": "sha512-5ArGdqvFMszNHdi4a67vopeYq8d1K+FuTWDrblHrAvZFhAyv+GQz2PnKqYOgl0sWmQxsNPfNwBFtxACpUO3Jzg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.6.tgz", + "integrity": "sha512-QI8QGL0HGT42tj7F1A+YAzhGkJjUcvvTfI1e2m704W0Enl2/UIK9v5D1zvQzYwusRyKuaQfbeBRYDh0NcLOGLg==", "cpu": [ "arm64" ], @@ -5601,9 +5601,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.5.tgz", - "integrity": "sha512-mSVVV/PFzCGtI1nVQQyx34NwCMgSurF6ZX/me8pUAX054vsE/pSFL66xN+kQOe/1Z/LOd4UmXFkZ/EzOSnYcSg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.6.tgz", + "integrity": "sha512-61AYVzhjuNQAVIKKWOJu3H0/pFD28RYJGxnGg3YMhvRLRyuWNyY5Nyyj2WkKcz/ON+g38Arlz00NT1LDIViRLg==", "cpu": [ "x64" ], @@ -5617,9 +5617,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.5.tgz", - "integrity": "sha512-09hY3ZKMUORXVunESKS9yuP78+gQbr759GKHo8wyCdtAx8lCZdEjfI5NtC7/1VqwfeE32/U6u+5MBTVhZTt0AA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.6.tgz", + "integrity": "sha512-hQFznpfLK8XajfAAN9Cjs0w/aVmO7iu9VZvInyrTCRcPqxV5O+rvrhRxKvC1LRMZXr5M6JRSRtepp5w+TK4kAw==", "cpu": [ "x64" ], @@ -5633,9 +5633,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.5.tgz", - "integrity": "sha512-B/UDtPI3RlYRFW42xQxOpl6kI/9LtkD7No+XeRIKQTPe15EP2o+rUlv7CmKljVBXgJ8KmaQbZlaEh1YP+QZEEQ==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.6.tgz", + "integrity": "sha512-Aqsd9afykVMuekzjm4X4TDqwxmG4CrzoOSFe0hZrn9SMio72l5eAPnMtYoe5LsIqtjV8MNprLfXaNbjHjTegmA==", "cpu": [ "arm64" ], @@ -5649,9 +5649,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.5.tgz", - "integrity": "sha512-BgLesVGmIY6Nub/sURqtSRvWYcbCE/ACfuZB3bZHVKD6nsZJJuOpdB8oC41fZPyc8yZUzL3XTBIifkT2RP+w9w==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.6.tgz", + "integrity": "sha512-9h0hYnOeRVNeQgHQTvD1Im67faNSSzBZ7Adtxyu9urNLfBTJilMllFd2QuGHlKW5+uaT6ZH7ZWDb+c/enx7Lcg==", "cpu": [ "ia32" ], @@ -5665,9 +5665,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.5.tgz", - "integrity": "sha512-CnF557tidLfQRPczcqDJ8x+LBQYsFa0Ra6w2+YU1iFUboaI2jJVuqt3vEChu80y6JiRIBAaaV2L/GawDJh1dIQ==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.6.tgz", + "integrity": "sha512-izeoB8glCSe6IIDQmrVm6bvR9muk9TeKgmtY7b6l1BwL4BFnTUk4dMmpbntT90bEVQn3JPCaPtUG4HfL8VuyuA==", "cpu": [ "x64" ], @@ -8246,6 +8246,14 @@ "node": ">=12.0.0" } }, + "node_modules/cron/node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -10993,9 +11001,9 @@ } }, "node_modules/luxon": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", - "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", "engines": { "node": ">=12" } @@ -11512,9 +11520,9 @@ } }, "node_modules/nestjs-cls": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-4.4.0.tgz", - "integrity": "sha512-qxsptbCo8Cp7xnAxtWv9+pSqOtB2NCr9ekQDH3FhxPAmgOys8F4WEGhuLLQ9iyW4dwqCao0xXatqQyA4anedmQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-4.4.1.tgz", + "integrity": "sha512-4yhldwm/cJ02lQ8ZAdM8KQ7gMfjAc1z3fo5QAQgXNyN4N6X5So9BCwv+BTLRugDCkELUo3qtzQHnKhGYL/ftPg==", "engines": { "node": ">=16" }, @@ -19888,92 +19896,92 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "@swc/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.5.tgz", - "integrity": "sha512-qKK0/Ta4qvxs/ok3XyYVPT7OBenwRn1sSINf1cKQTBHPqr7U/uB4k2GTl6JgEs8H4PiJrMTNWfMLTucIoVSfAg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.6.tgz", + "integrity": "sha512-FZxyao9eQks1MRmUshgsZTmlg/HB2oXK5fghkoWJm/1CU2q2kaJlVDll2as5j+rmWiwkp0Gidlq8wlXcEEAO+g==", "devOptional": true, "requires": { - "@swc/core-darwin-arm64": "1.7.5", - "@swc/core-darwin-x64": "1.7.5", - "@swc/core-linux-arm-gnueabihf": "1.7.5", - "@swc/core-linux-arm64-gnu": "1.7.5", - "@swc/core-linux-arm64-musl": "1.7.5", - "@swc/core-linux-x64-gnu": "1.7.5", - "@swc/core-linux-x64-musl": "1.7.5", - "@swc/core-win32-arm64-msvc": "1.7.5", - "@swc/core-win32-ia32-msvc": "1.7.5", - "@swc/core-win32-x64-msvc": "1.7.5", + "@swc/core-darwin-arm64": "1.7.6", + "@swc/core-darwin-x64": "1.7.6", + "@swc/core-linux-arm-gnueabihf": "1.7.6", + "@swc/core-linux-arm64-gnu": "1.7.6", + "@swc/core-linux-arm64-musl": "1.7.6", + "@swc/core-linux-x64-gnu": "1.7.6", + "@swc/core-linux-x64-musl": "1.7.6", + "@swc/core-win32-arm64-msvc": "1.7.6", + "@swc/core-win32-ia32-msvc": "1.7.6", + "@swc/core-win32-x64-msvc": "1.7.6", "@swc/counter": "^0.1.3", "@swc/types": "^0.1.12" } }, "@swc/core-darwin-arm64": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.5.tgz", - "integrity": "sha512-Y+bvW9C4/u26DskMbtQKT4FU6QQenaDYkKDi028vDIKAa7v1NZqYG9wmhD/Ih7n5EUy2uJ5I5EWD7WaoLzT6PA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.6.tgz", + "integrity": "sha512-6lYHey84ZzsdtC7UuPheM4Rm0Inzxm6Sb8U6dmKc4eCx8JL0LfWG4LC5RsdsrTxnjTsbriWlnhZBffh8ijUHIQ==", "dev": true, "optional": true }, "@swc/core-darwin-x64": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.5.tgz", - "integrity": "sha512-AuIbDlcaAhYS6mtF4UqvXgrLeAfXZbVf4pgtgShPbutF80VbCQiIB55zOFz5aZdCpsBVuCWcBq0zLneK+VQKkQ==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.6.tgz", + "integrity": "sha512-Fyl+8aH9O5rpx4O7r2KnsPpoi32iWoKOYKiipeTbGjQ/E95tNPxbmsz4yqE8Ovldcga60IPJ5OKQA3HWRiuzdw==", "dev": true, "optional": true }, "@swc/core-linux-arm-gnueabihf": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.5.tgz", - "integrity": "sha512-99uBPHITRqgGwCXAjHY94VaV3Z40+D2NQNgR1t6xQpO8ZnevI6YSzX6GVZfBnV7+7oisiGkrVEwfIRRa+1s8FA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.6.tgz", + "integrity": "sha512-2WxYTqFaOx48GKC2cbO1/IntA+w+kfCFy436Ij7qRqqtV/WAvTM9TC1OmiFbqq436rSot52qYmX8fkwdB5UcLQ==", "dev": true, "optional": true }, "@swc/core-linux-arm64-gnu": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.5.tgz", - "integrity": "sha512-xHL3Erlz+OGGCG4h6K2HWiR56H5UYMuBWWPbbUufi2bJpfhuKQy/X3vWffwL8ZVfJmCUwr4/G91GHcm32uYzRg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.6.tgz", + "integrity": "sha512-TBEGMSe0LhvPe4S7E68c7VzgT3OMu4VTmBLS7B2aHv4v8uZO92Khpp7L0WqgYU1y5eMjk+XLDLi4kokiNHv/Hg==", "dev": true, "optional": true }, "@swc/core-linux-arm64-musl": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.5.tgz", - "integrity": "sha512-5ArGdqvFMszNHdi4a67vopeYq8d1K+FuTWDrblHrAvZFhAyv+GQz2PnKqYOgl0sWmQxsNPfNwBFtxACpUO3Jzg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.6.tgz", + "integrity": "sha512-QI8QGL0HGT42tj7F1A+YAzhGkJjUcvvTfI1e2m704W0Enl2/UIK9v5D1zvQzYwusRyKuaQfbeBRYDh0NcLOGLg==", "dev": true, "optional": true }, "@swc/core-linux-x64-gnu": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.5.tgz", - "integrity": "sha512-mSVVV/PFzCGtI1nVQQyx34NwCMgSurF6ZX/me8pUAX054vsE/pSFL66xN+kQOe/1Z/LOd4UmXFkZ/EzOSnYcSg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.6.tgz", + "integrity": "sha512-61AYVzhjuNQAVIKKWOJu3H0/pFD28RYJGxnGg3YMhvRLRyuWNyY5Nyyj2WkKcz/ON+g38Arlz00NT1LDIViRLg==", "dev": true, "optional": true }, "@swc/core-linux-x64-musl": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.5.tgz", - "integrity": "sha512-09hY3ZKMUORXVunESKS9yuP78+gQbr759GKHo8wyCdtAx8lCZdEjfI5NtC7/1VqwfeE32/U6u+5MBTVhZTt0AA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.6.tgz", + "integrity": "sha512-hQFznpfLK8XajfAAN9Cjs0w/aVmO7iu9VZvInyrTCRcPqxV5O+rvrhRxKvC1LRMZXr5M6JRSRtepp5w+TK4kAw==", "dev": true, "optional": true }, "@swc/core-win32-arm64-msvc": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.5.tgz", - "integrity": "sha512-B/UDtPI3RlYRFW42xQxOpl6kI/9LtkD7No+XeRIKQTPe15EP2o+rUlv7CmKljVBXgJ8KmaQbZlaEh1YP+QZEEQ==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.6.tgz", + "integrity": "sha512-Aqsd9afykVMuekzjm4X4TDqwxmG4CrzoOSFe0hZrn9SMio72l5eAPnMtYoe5LsIqtjV8MNprLfXaNbjHjTegmA==", "dev": true, "optional": true }, "@swc/core-win32-ia32-msvc": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.5.tgz", - "integrity": "sha512-BgLesVGmIY6Nub/sURqtSRvWYcbCE/ACfuZB3bZHVKD6nsZJJuOpdB8oC41fZPyc8yZUzL3XTBIifkT2RP+w9w==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.6.tgz", + "integrity": "sha512-9h0hYnOeRVNeQgHQTvD1Im67faNSSzBZ7Adtxyu9urNLfBTJilMllFd2QuGHlKW5+uaT6ZH7ZWDb+c/enx7Lcg==", "dev": true, "optional": true }, "@swc/core-win32-x64-msvc": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.5.tgz", - "integrity": "sha512-CnF557tidLfQRPczcqDJ8x+LBQYsFa0Ra6w2+YU1iFUboaI2jJVuqt3vEChu80y6JiRIBAaaV2L/GawDJh1dIQ==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.6.tgz", + "integrity": "sha512-izeoB8glCSe6IIDQmrVm6bvR9muk9TeKgmtY7b6l1BwL4BFnTUk4dMmpbntT90bEVQn3JPCaPtUG4HfL8VuyuA==", "dev": true, "optional": true }, @@ -21968,6 +21976,13 @@ "requires": { "@types/luxon": "~3.4.0", "luxon": "~3.4.0" + }, + "dependencies": { + "luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==" + } } }, "cron-parser": { @@ -23994,9 +24009,9 @@ } }, "luxon": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", - "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==" + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==" }, "magic-string": { "version": "0.30.8", @@ -24390,9 +24405,9 @@ } }, "nestjs-cls": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-4.4.0.tgz", - "integrity": "sha512-qxsptbCo8Cp7xnAxtWv9+pSqOtB2NCr9ekQDH3FhxPAmgOys8F4WEGhuLLQ9iyW4dwqCao0xXatqQyA4anedmQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-4.4.1.tgz", + "integrity": "sha512-4yhldwm/cJ02lQ8ZAdM8KQ7gMfjAc1z3fo5QAQgXNyN4N6X5So9BCwv+BTLRugDCkELUo3qtzQHnKhGYL/ftPg==", "requires": {} }, "nestjs-otel": { diff --git a/web/package-lock.json b/web/package-lock.json index 05cabc99ed..c02b0430ab 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -79,7 +79,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.13", + "@types/node": "^20.14.14", "typescript": "^5.3.3" } }, @@ -2082,9 +2082,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.5.19", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.19.tgz", - "integrity": "sha512-r/lah3nnYEZX1btlvpSy+Exkt1aWhmOP5pnCt+BBro+tZrh2Zci+26Xnm1fCBLLMeM5q7gHvWiS8c/UtrWjdvQ==", + "version": "2.5.20", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.20.tgz", + "integrity": "sha512-47rJ5BoYwURE/Rp7FNMLp3NzdbWC9DQ/PmKd0mebxT2D/PrPxZxcLImcD3zsWdX2iS6oJk8ITJbO/N2lWnnUqA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3025,9 +3025,9 @@ "peer": true }, "node_modules/autoprefixer": { - "version": "10.4.19", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", - "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", "dev": true, "funding": [ { @@ -3043,12 +3043,13 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "browserslist": "^4.23.0", - "caniuse-lite": "^1.0.30001599", + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -3107,9 +3108,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", "dev": true, "funding": [ { @@ -3125,11 +3126,12 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" }, "bin": { "browserslist": "cli.js" @@ -3210,9 +3212,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001600", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001600.tgz", - "integrity": "sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ==", + "version": "1.0.30001651", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", + "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", "dev": true, "funding": [ { @@ -3227,7 +3229,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chai": { "version": "5.1.1", @@ -3775,10 +3778,11 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.701", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.701.tgz", - "integrity": "sha512-K3WPQ36bUOtXg/1+69bFlFOvdSm0/0bGqmsfPDLRXLanoKXdA+pIWuf/VbA9b+2CwBFuONgl4NEz4OEm+OJOKA==", - "dev": true + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz", + "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==", + "dev": true, + "license": "ISC" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -3943,10 +3947,11 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -5672,9 +5677,10 @@ } }, "node_modules/luxon": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", - "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", "engines": { "node": ">=12" } @@ -5986,10 +5992,11 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", - "dev": true + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true, + "license": "MIT" }, "node_modules/normalize-package-data": { "version": "2.5.0", @@ -6388,9 +6395,9 @@ } }, "node_modules/postcss": { - "version": "8.4.40", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz", - "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==", + "version": "8.4.41", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", + "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", "dev": true, "funding": [ { @@ -8151,12 +8158,13 @@ } }, "node_modules/svelte-maplibre": { - "version": "0.9.9", - "resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.9.9.tgz", - "integrity": "sha512-y0NbKGquYCtQQi3vF1M09++Gg8TR5u/4zie1Rb2FIQI8XpvlBJJbBOsY8rkAGjRkH8t2BBtGstCRuoVHzkq3lA==", + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.9.10.tgz", + "integrity": "sha512-MYTMogRPzzgXDZGub4ivfdY1/P0uPxZfo/REQhne0zdBLc6cd4n1U4SqY9SoEGNN0CGW1KvSLfc7acx0kxzXlw==", "license": "MIT", "dependencies": { "d3-geo": "^3.1.0", + "dequal": "^2.0.3", "just-compare": "^2.3.0", "just-flush": "^2.3.0", "maplibre-gl": "^4.0.0", @@ -8264,9 +8272,9 @@ "peer": true }, "node_modules/tailwindcss": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz", - "integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==", + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.8.tgz", + "integrity": "sha512-GkP17r9GQkxgZ9FKHJQEnjJuKBcbFhMFzKu5slmN6NjlCuFnYJMQ8N4AZ6VrUyiRXlDtPKHkesuQ/MS913Nvdg==", "dev": true, "license": "MIT", "dependencies": { @@ -8682,9 +8690,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "dev": true, "funding": [ { @@ -8700,9 +8708,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -8749,14 +8758,14 @@ } }, "node_modules/vite": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz", - "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz", + "integrity": "sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.39", + "postcss": "^8.4.40", "rollup": "^4.13.0" }, "bin": { @@ -8776,6 +8785,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -8793,6 +8803,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, From 5acdc958b642ddc2057254e468a7b2eeb2366147 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Tue, 13 Aug 2024 14:20:08 +0200 Subject: [PATCH 073/723] fix(web): single row of items (#11729) * fix(web): single row of items * remove filterBoxWidth * slight size adjustment * rewrite action as component --- .../search-bar/search-filter-box.svelte | 5 +- .../search-bar/search-people-section.svelte | 13 ++-- .../shared-components/single-grid-row.svelte | 38 ++++++++++++ web/src/routes/(user)/explore/+page.svelte | 61 +++++++------------ 4 files changed, 68 insertions(+), 49 deletions(-) create mode 100644 web/src/lib/components/shared-components/single-grid-row.svelte diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte index 35e7ea7535..4fd85fa9bd 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte @@ -77,8 +77,6 @@ : MediaType.All, }; - let filterBoxWidth = 0; - const resetForm = () => { filter = { personIds: new Set(), @@ -120,7 +118,6 @@
@@ -132,7 +129,7 @@ >
- + diff --git a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte index 9eec526f4a..b6110c52b8 100644 --- a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte @@ -8,14 +8,14 @@ import { mdiClose, mdiArrowRight } from '@mdi/js'; import { handleError } from '$lib/utils/handle-error'; import { t } from 'svelte-i18n'; + import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte'; - export let width: number; export let selectedPeople: Set; let peoplePromise = getPeople(); let showAllPeople = false; let name = ''; - $: numberOfPeople = (width - 80) / 85; + let numberOfPeople = 1; function orderBySelectedPeopleFirst(people: PersonResponseDto[]) { return [ @@ -60,11 +60,14 @@
-
+ {#each peopleList as person (person.id)} {/each} -
+ {#if showAllPeople || people.length > peopleList.length}
diff --git a/web/src/lib/components/shared-components/single-grid-row.svelte b/web/src/lib/components/shared-components/single-grid-row.svelte new file mode 100644 index 0000000000..90020f2922 --- /dev/null +++ b/web/src/lib/components/shared-components/single-grid-row.svelte @@ -0,0 +1,38 @@ + + +
+ +
diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index 7c6424b5ac..591cb6876b 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -10,6 +10,7 @@ import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import { onMount } from 'svelte'; import { websocketEvents } from '$lib/stores/websocket'; + import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte'; export let data: PageData; @@ -19,25 +20,14 @@ OBJECTS = 'smartInfo.objects', } - let MAX_PEOPLE_ITEMS: number; - let MAX_PLACE_ITEMS: number; - let innerWidth: number; - let screenSize: number; const getFieldItems = (items: SearchExploreResponseDto[], field: Field) => { const targetField = items.find((item) => item.fieldName === field); return targetField?.items || []; }; - $: places = getFieldItems(data.items, Field.CITY).slice(0, MAX_PLACE_ITEMS); - $: people = data.response.people.slice(0, MAX_PEOPLE_ITEMS); + $: places = getFieldItems(data.items, Field.CITY); + $: people = data.response.people; $: hasPeople = data.response.total > 0; - $: { - if (innerWidth && screenSize) { - // Set the number of faces according to the screen size and the div size - MAX_PEOPLE_ITEMS = screenSize < 768 ? Math.floor(innerWidth / 96) : Math.floor(innerWidth / 120); - MAX_PLACE_ITEMS = screenSize < 768 ? Math.floor(innerWidth / 150) : Math.floor(innerWidth / 172); - } - } onMount(() => { return websocketEvents.on('on_person_thumbnail', (personId: string) => { @@ -52,8 +42,6 @@ }); - - {#if hasPeople}
-
- {#if MAX_PEOPLE_ITEMS} - {#each people as person (person.id)} - - -

{person.name}

-
- {/each} - {/if} -
+ {#each people.slice(0, itemCount) as person (person.id)} + + +

{person.name}

+
+ {/each} +
{/if} @@ -97,16 +77,17 @@ draggable="false">{$t('view_all')}
-
- {#each places as item (item.data.id)} + + {#each places.slice(0, itemCount) as item (item.data.id)} -
+
{item.value}
{/each} -
+
{/if} From 28b7443b92f907d5cc7a0ff6d48f84e2a9dce830 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 12:26:22 +0000 Subject: [PATCH 074/723] chore(deps): update base-image to v20240813 (major) (#11747) chore(deps): update base-image to v20240813 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 8d419b83f1..fe1a07bf92 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20240806@sha256:357c0e3a6b3cece3af7e9c46f5a2d11b6f032ded6a5b1de7706acf785b85a873 AS dev +FROM ghcr.io/immich-app/base-server-dev:20240813@sha256:2e204a2256c088c9e4a0cf34cc9f70f9196c05e8744004000e7d2889466fc735 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -41,7 +41,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20240806@sha256:c13555680d8b454a416fa0e8c0e9e33b348433793c29680231e83b08838f06ec +FROM ghcr.io/immich-app/base-server-prod:20240813@sha256:51537e98ac601aa8401604a6aa9421e94aa55e03c303f355cc5870142adcc471 WORKDIR /usr/src/app ENV NODE_ENV=production \ From 9837d600749e8f290c2a641227fcc7ff6f3a6325 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 08:40:22 -0400 Subject: [PATCH 075/723] chore(deps): update dependency vite-tsconfig-paths to v5 (#11746) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 9 +++++---- cli/package.json | 2 +- server/package-lock.json | 14 +++++++------- server/package.json | 2 +- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index ffd0bc429d..9a9bd1c88c 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -41,7 +41,7 @@ "prettier-plugin-organize-imports": "^4.0.0", "typescript": "^5.3.3", "vite": "^5.0.12", - "vite-tsconfig-paths": "^4.3.2", + "vite-tsconfig-paths": "^5.0.0", "vitest": "^2.0.5", "vitest-fetch-mock": "^0.3.0", "yaml": "^2.3.1" @@ -4288,10 +4288,11 @@ } }, "node_modules/vite-tsconfig-paths": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.2.tgz", - "integrity": "sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.0.1.tgz", + "integrity": "sha512-yqwv+LstU7NwPeNqajZzLEBVpUFU6Dugtb2P84FXuvaoYA+/70l9MHE+GYfYAycVyPSDYZ7mjOFuYBRqlEpTig==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", diff --git a/cli/package.json b/cli/package.json index 491fd317e9..31a50f9f79 100644 --- a/cli/package.json +++ b/cli/package.json @@ -37,7 +37,7 @@ "prettier-plugin-organize-imports": "^4.0.0", "typescript": "^5.3.3", "vite": "^5.0.12", - "vite-tsconfig-paths": "^4.3.2", + "vite-tsconfig-paths": "^5.0.0", "vitest": "^2.0.5", "vitest-fetch-mock": "^0.3.0", "yaml": "^2.3.1" diff --git a/server/package-lock.json b/server/package-lock.json index db165eec46..f8226d377e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -107,7 +107,7 @@ "typescript": "^5.3.3", "unplugin-swc": "^1.4.5", "utimes": "^5.2.1", - "vite-tsconfig-paths": "^4.3.2", + "vite-tsconfig-paths": "^5.0.0", "vitest": "^2.0.5" } }, @@ -16248,9 +16248,9 @@ } }, "node_modules/vite-tsconfig-paths": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.2.tgz", - "integrity": "sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.0.1.tgz", + "integrity": "sha512-yqwv+LstU7NwPeNqajZzLEBVpUFU6Dugtb2P84FXuvaoYA+/70l9MHE+GYfYAycVyPSDYZ7mjOFuYBRqlEpTig==", "dev": true, "dependencies": { "debug": "^4.1.1", @@ -27501,9 +27501,9 @@ } }, "vite-tsconfig-paths": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.2.tgz", - "integrity": "sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.0.1.tgz", + "integrity": "sha512-yqwv+LstU7NwPeNqajZzLEBVpUFU6Dugtb2P84FXuvaoYA+/70l9MHE+GYfYAycVyPSDYZ7mjOFuYBRqlEpTig==", "dev": true, "requires": { "debug": "^4.1.1", diff --git a/server/package.json b/server/package.json index 008a386abf..35f22cd2b4 100644 --- a/server/package.json +++ b/server/package.json @@ -133,7 +133,7 @@ "typescript": "^5.3.3", "unplugin-swc": "^1.4.5", "utimes": "^5.2.1", - "vite-tsconfig-paths": "^4.3.2", + "vite-tsconfig-paths": "^5.0.0", "vitest": "^2.0.5" }, "volta": { From 276101ee82ef61e285e1da8fdafcc29bc7f64f39 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Tue, 13 Aug 2024 16:37:47 +0200 Subject: [PATCH 076/723] feat(web): improve shared link management on mobile (#11720) * feat(web): improve shared link management on mobile * fix format --- .../album-page/__tests__/album-cover.spec.ts | 4 +- .../components/album-page/album-cover.svelte | 12 +- .../context-menu/button-context-menu.svelte | 53 +++---- .../actions/shared-link-copy.svelte | 22 +++ .../actions/shared-link-delete.svelte | 15 ++ .../actions/shared-link-edit.svelte | 15 ++ .../covers/__tests__/asset-cover.spec.ts | 2 +- .../covers/__tests__/no-cover.spec.ts | 2 +- .../covers/__tests__/share-cover.spec.ts | 6 +- .../covers/asset-cover.svelte | 2 +- .../sharedlinks-page/covers/no-cover.svelte | 2 +- .../covers/share-cover.svelte | 2 +- .../sharedlinks-page/shared-link-card.svelte | 134 ++++++++++-------- web/src/lib/i18n/en.json | 2 +- .../(user)/sharing/sharedlinks/+page.svelte | 22 +-- 15 files changed, 174 insertions(+), 121 deletions(-) create mode 100644 web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte create mode 100644 web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte create mode 100644 web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte diff --git a/web/src/lib/components/album-page/__tests__/album-cover.spec.ts b/web/src/lib/components/album-page/__tests__/album-cover.spec.ts index 1688283116..ec4878cd15 100644 --- a/web/src/lib/components/album-page/__tests__/album-cover.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-cover.spec.ts @@ -19,7 +19,7 @@ describe('AlbumCover component', () => { const img = component.getByTestId('album-image') as HTMLImageElement; expect(img.alt).toBe('someName'); expect(img.getAttribute('loading')).toBe('lazy'); - expect(img.className).toBe('z-0 rounded-xl object-cover text'); + expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square text'); expect(img.getAttribute('src')).toBe('/asdf'); expect(getAssetThumbnailUrl).toHaveBeenCalledWith({ id: '123' }); }); @@ -36,7 +36,7 @@ describe('AlbumCover component', () => { const img = component.getByTestId('album-image') as HTMLImageElement; expect(img.alt).toBe('unnamed_album'); expect(img.getAttribute('loading')).toBe('eager'); - expect(img.className).toBe('z-0 rounded-xl object-cover asdf'); + expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square asdf'); expect(img.getAttribute('src')).toStrictEqual(expect.any(String)); }); }); diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte index d6afba0a8b..d0444f3599 100644 --- a/web/src/lib/components/album-page/album-cover.svelte +++ b/web/src/lib/components/album-page/album-cover.svelte @@ -14,10 +14,8 @@ $: thumbnailUrl = album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null; -
- {#if thumbnailUrl} - - {:else} - - {/if} -
+{#if thumbnailUrl} + +{:else} + +{/if} diff --git a/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte b/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte index 8e6a1fd4fd..f1ee93cc50 100644 --- a/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte @@ -32,6 +32,7 @@ * Additional classes to apply to the button. */ export let buttonClass: string | undefined = undefined; + export let hideContent = false; let isOpen = false; let contextMenuPosition = { x: 0, y: 0 }; @@ -125,30 +126,32 @@ on:click={handleClick} /> -
- - - -
+ + + + + {/if} diff --git a/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte b/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte new file mode 100644 index 0000000000..f955d8479a --- /dev/null +++ b/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte @@ -0,0 +1,22 @@ + + +{#if menuItem} + +{:else} + +{/if} diff --git a/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte b/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte new file mode 100644 index 0000000000..d458d5d77a --- /dev/null +++ b/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte @@ -0,0 +1,15 @@ + + +{#if menuItem} + +{:else} + +{/if} diff --git a/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte b/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte new file mode 100644 index 0000000000..49c6105632 --- /dev/null +++ b/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte @@ -0,0 +1,15 @@ + + +{#if menuItem} + +{:else} + +{/if} diff --git a/web/src/lib/components/sharedlinks-page/covers/__tests__/asset-cover.spec.ts b/web/src/lib/components/sharedlinks-page/covers/__tests__/asset-cover.spec.ts index a7a2c85f8a..a7a4a069d3 100644 --- a/web/src/lib/components/sharedlinks-page/covers/__tests__/asset-cover.spec.ts +++ b/web/src/lib/components/sharedlinks-page/covers/__tests__/asset-cover.spec.ts @@ -13,6 +13,6 @@ describe('AssetCover component', () => { expect(img.alt).toBe('123'); expect(img.getAttribute('src')).toBe('wee'); expect(img.getAttribute('loading')).toBe('eager'); - expect(img.className).toBe('z-0 rounded-xl object-cover asdf'); + expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square asdf'); }); }); diff --git a/web/src/lib/components/sharedlinks-page/covers/__tests__/no-cover.spec.ts b/web/src/lib/components/sharedlinks-page/covers/__tests__/no-cover.spec.ts index 3dc7d56791..bdf0b8878c 100644 --- a/web/src/lib/components/sharedlinks-page/covers/__tests__/no-cover.spec.ts +++ b/web/src/lib/components/sharedlinks-page/covers/__tests__/no-cover.spec.ts @@ -10,7 +10,7 @@ describe('NoCover component', () => { }); const img = component.getByTestId('album-image') as HTMLImageElement; expect(img.alt).toBe('123'); - expect(img.className).toBe('z-0 rounded-xl object-cover asdf'); + expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square asdf'); expect(img.getAttribute('loading')).toBe('eager'); expect(img.src).toStrictEqual(expect.any(String)); }); diff --git a/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts b/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts index c14b618dce..1f1fa65cf8 100644 --- a/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts +++ b/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts @@ -17,7 +17,7 @@ describe('ShareCover component', () => { const img = component.getByTestId('album-image') as HTMLImageElement; expect(img.alt).toBe('123'); expect(img.getAttribute('loading')).toBe('lazy'); - expect(img.className).toBe('z-0 rounded-xl object-cover text'); + expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square text'); }); it('renders an image when the shared link is an individual share', () => { @@ -30,7 +30,7 @@ describe('ShareCover component', () => { const img = component.getByTestId('album-image') as HTMLImageElement; expect(img.alt).toBe('individual_share'); expect(img.getAttribute('loading')).toBe('lazy'); - expect(img.className).toBe('z-0 rounded-xl object-cover text'); + expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square text'); expect(img.getAttribute('src')).toBe('/asdf'); expect(getAssetThumbnailUrl).toHaveBeenCalledWith('someId'); }); @@ -44,7 +44,7 @@ describe('ShareCover component', () => { const img = component.getByTestId('album-image') as HTMLImageElement; expect(img.alt).toBe('unnamed_share'); expect(img.getAttribute('loading')).toBe('lazy'); - expect(img.className).toBe('z-0 rounded-xl object-cover text'); + expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square text'); }); it('renders fallback image when asset is not resized', () => { diff --git a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte index b0cd2dfdd5..b8335be6b0 100644 --- a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte @@ -8,7 +8,7 @@ -
+
{#if link?.album} {:else if link.assets[0]?.resized} diff --git a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte index 6f375ded48..40e95ad27a 100644 --- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte +++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte @@ -1,23 +1,20 @@
- + + -
-
-
- {#if isExpired} -

{$t('expired')}

- {:else if expiresAt} -

- {$t('expires_date', { values: { date: getCountDownExpirationDate(expiresAt, now) } })} -

- {:else} -

{$t('expires_date', { values: { date: '∞' } })}

- {/if} -
- -
-
- {#if link.type === SharedLinkType.Album} +
+
+
+ {#if isExpired} +

{$t('expired')}

+ {:else if expiresAt}

- {link.album?.albumName.toUpperCase()} + {$t('expires_date', { values: { date: getCountDownExpirationDate(expiresAt, now) } })}

- {:else if link.type === SharedLinkType.Individual} -

{$t('individual_share').toUpperCase()}

- {/if} - - {#if !isExpired} - - - + {:else} +

{$t('expires_date', { values: { date: '∞' } })}

{/if}
-

{link.description ?? ''}

+
+

+ {#if link.type === SharedLinkType.Album} + {link.album?.albumName} + {:else if link.type === SharedLinkType.Individual} + {$t('individual_share')} + {/if} +

+ +

{link.description ?? ''}

+
+
+ +
+ {#if link.allowUpload} + {$t('upload')} + {/if} + + {#if link.allowDownload} + {$t('download')} + {/if} + + {#if link.showMetadata} + {$t('exif').toUpperCase()} + {/if} + + {#if link.password} + {$t('password')} + {/if}
+ -
- {#if link.allowUpload} - {$t('upload')} - {/if} - - {#if link.allowDownload} - {$t('download')} - {/if} - - {#if link.showMetadata} - {$t('exif').toUpperCase()} - {/if} - - {#if link.password} - {$t('password')} - {/if} +
+ -
-
-
- dispatch('delete')} /> - dispatch('edit')} /> - dispatch('copy')} /> +
+ + + + +
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 8c08114feb..6796ae3a71 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -696,7 +696,6 @@ "getting_started": "Getting Started", "go_back": "Go back", "go_to_search": "Go to search", - "go_to_share_page": "Go to share page", "group_albums_by": "Group albums by...", "group_no": "No grouping", "group_owner": "Group by owner", @@ -1078,6 +1077,7 @@ "shared_by_user": "Shared by {user}", "shared_by_you": "Shared by you", "shared_from_partner": "Photos from {partner}", + "shared_link_options": "Shared link options", "shared_links": "Shared links", "shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}", "shared_with_partner": "Shared with {partner}", diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte index 09d3d2d400..5e934143df 100644 --- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte +++ b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte @@ -1,6 +1,5 @@ goto(AppRoute.SHARING)}> {$t('shared_links')} -
-
+
+

{$t('manage_shared_links')}

{#if sharedLinks.length === 0}

{$t('you_dont_have_any_shared_links')}

{:else} -
+
{#each sharedLinks as link (link.id)} - handleDeleteLink(link.id)} - on:edit={() => (editSharedLink = link)} - on:copy={() => handleCopyLink(link.key)} - /> + handleDeleteLink(link.id)} onEdit={() => (editSharedLink = link)} /> {/each}
{/if} From b0141620887d1e13e587c80ef7adfa1c5301c721 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Tue, 13 Aug 2024 17:36:46 +0200 Subject: [PATCH 077/723] refactor(web): add tailwind plugin for repeating grid cols (#11748) --- web/eslint.config.mjs | 2 +- .../album-page/album-card-group.svelte | 2 +- .../search-bar/search-camera-section.svelte | 2 +- .../search-bar/search-date-section.svelte | 2 +- .../search-bar/search-location-section.svelte | 2 +- .../search-bar/search-people-section.svelte | 2 +- web/src/routes/(user)/explore/+page.svelte | 10 ++-------- ...tailwind.config.cjs => tailwind.config.js} | 19 ++++++++++++++++++- 8 files changed, 26 insertions(+), 15 deletions(-) rename web/{tailwind.config.cjs => tailwind.config.js} (74%) diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index e7ce7e1388..f4aec0e728 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -32,7 +32,7 @@ export default [ '**/svelte.config.js', 'eslint.config.mjs', 'postcss.config.cjs', - 'tailwind.config.cjs', + 'tailwind.config.js', ], }, ...compat.extends( diff --git a/web/src/lib/components/album-page/album-card-group.svelte b/web/src/lib/components/album-page/album-card-group.svelte index 0e731a683c..f899cebd8c 100644 --- a/web/src/lib/components/album-page/album-card-group.svelte +++ b/web/src/lib/components/album-page/album-card-group.svelte @@ -50,7 +50,7 @@
{#if !isCollapsed} -
+
{#each albums as album, index (album.id)}

{$t('camera').toUpperCase()}

-
+
-
+
+ - + {#each places.slice(0, itemCount) as item (item.data.id)}
diff --git a/web/tailwind.config.cjs b/web/tailwind.config.js similarity index 74% rename from web/tailwind.config.cjs rename to web/tailwind.config.js index d46cd8ad5f..eb1ea78fae 100644 --- a/web/tailwind.config.cjs +++ b/web/tailwind.config.js @@ -1,5 +1,7 @@ +import plugin from 'tailwindcss/plugin'; + /** @type {import('tailwindcss').Config} */ -module.exports = { +export default { content: ['./src/**/*.{html,js,svelte,ts}'], darkMode: 'class', theme: { @@ -34,4 +36,19 @@ module.exports = { }, }, }, + plugins: [ + plugin(({ matchUtilities, theme }) => { + matchUtilities( + { + 'grid-auto-fit': (value) => ({ + gridTemplateColumns: `repeat(auto-fit, minmax(min(${value}, 100%), 1fr))`, + }), + 'grid-auto-fill': (value) => ({ + gridTemplateColumns: `repeat(auto-fill, minmax(min(${value}, 100%), 1fr))`, + }), + }, + { values: theme('width') }, + ); + }), + ], }; From 81c813a88292bd0df3d1136cb9db9aae34043a76 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 11:37:06 -0400 Subject: [PATCH 078/723] chore(deps): update dependency tailwindcss to v3.4.9 (#11750) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/package-lock.json | 6 +++--- web/package-lock.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 38750c6a66..e5fb9f8b2a 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -16020,9 +16020,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.8", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.8.tgz", - "integrity": "sha512-GkP17r9GQkxgZ9FKHJQEnjJuKBcbFhMFzKu5slmN6NjlCuFnYJMQ8N4AZ6VrUyiRXlDtPKHkesuQ/MS913Nvdg==", + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz", + "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", diff --git a/web/package-lock.json b/web/package-lock.json index c02b0430ab..c718fd1150 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8272,9 +8272,9 @@ "peer": true }, "node_modules/tailwindcss": { - "version": "3.4.8", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.8.tgz", - "integrity": "sha512-GkP17r9GQkxgZ9FKHJQEnjJuKBcbFhMFzKu5slmN6NjlCuFnYJMQ8N4AZ6VrUyiRXlDtPKHkesuQ/MS913Nvdg==", + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz", + "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==", "dev": true, "license": "MIT", "dependencies": { From df45ef0e35732a1a0537660a94073188797d5942 Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Tue, 13 Aug 2024 17:39:24 +0200 Subject: [PATCH 079/723] fix(server): follow symlinks when zipping assets (#11685) * follow symlinks when zipping assets fixes #9335 * chore: clean up --------- Co-authored-by: Jason Rasmussen --- server/src/interfaces/storage.interface.ts | 1 + server/src/repositories/storage.repository.ts | 6 ++++- server/src/services/download.service.spec.ts | 27 ++++++++++++++++++- server/src/services/download.service.ts | 14 ++++++++-- .../repositories/storage.repository.mock.ts | 1 + 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/server/src/interfaces/storage.interface.ts b/server/src/interfaces/storage.interface.ts index 1bd49a3f20..f27edaccc9 100644 --- a/server/src/interfaces/storage.interface.ts +++ b/server/src/interfaces/storage.interface.ts @@ -36,6 +36,7 @@ export interface IStorageRepository { createReadStream(filepath: string, mimeType?: string | null): Promise; readFile(filepath: string, options?: FileReadOptions): Promise; writeFile(filepath: string, buffer: Buffer): Promise; + realpath(filepath: string): Promise; unlink(filepath: string): Promise; unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise; removeEmptyDirs(folder: string, self?: boolean): Promise; diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 0d0be5c062..b310f2e110 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -24,6 +24,10 @@ export class StorageRepository implements IStorageRepository { this.logger.setContext(StorageRepository.name); } + realpath(filepath: string) { + return fs.realpath(filepath); + } + readdir(folder: string): Promise { return fs.readdir(folder); } @@ -52,7 +56,7 @@ export class StorageRepository implements IStorageRepository { const archive = archiver('zip', { store: true }); const addFile = (input: string, filename: string) => { - archive.file(input, { name: filename }); + archive.file(input, { name: filename, mode: 0o644 }); }; const finalize = () => archive.finalize(); diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 6216a4dc3a..2d3c11a6f1 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -2,12 +2,14 @@ import { BadRequestException } from '@nestjs/common'; import { DownloadResponseDto } from 'src/dtos/download.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { DownloadService } from 'src/services/download.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { Readable } from 'typeorm/platform/PlatformTools.js'; import { Mocked, vitest } from 'vitest'; @@ -26,6 +28,7 @@ describe(DownloadService.name, () => { let sut: DownloadService; let accessMock: IAccessRepositoryMock; let assetMock: Mocked; + let loggerMock: Mocked; let storageMock: Mocked; it('should work', () => { @@ -35,9 +38,10 @@ describe(DownloadService.name, () => { beforeEach(() => { accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); + loggerMock = newLoggerRepositoryMock(); storageMock = newStorageRepositoryMock(); - sut = new DownloadService(accessMock, assetMock, storageMock); + sut = new DownloadService(accessMock, assetMock, loggerMock, storageMock); }); describe('downloadArchive', () => { @@ -109,6 +113,27 @@ describe(DownloadService.name, () => { expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_123.jpg', 'IMG_123+1.jpg'); }); + + it('should resolve symlinks', async () => { + const archiveMock = { + addFile: vitest.fn(), + finalize: vitest.fn(), + stream: new Readable(), + }; + + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + assetMock.getByIds.mockResolvedValue([ + { ...assetStub.noResizePath, id: 'asset-1', originalPath: '/path/to/symlink.jpg' }, + ]); + storageMock.realpath.mockResolvedValue('/path/to/realpath.jpg'); + storageMock.createZipStream.mockReturnValue(archiveMock); + + await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1'] })).resolves.toEqual({ + stream: archiveMock.stream, + }); + + expect(archiveMock.addFile).toHaveBeenCalledWith('/path/to/realpath.jpg', 'IMG_123.jpg'); + }); }); describe('getDownloadInfo', () => { diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 07ef03efb5..11e4de83d9 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -7,7 +7,8 @@ import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/d import { AssetEntity } from 'src/entities/asset.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IStorageRepository, ImmichReadStream } from 'src/interfaces/storage.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ImmichReadStream, IStorageRepository } from 'src/interfaces/storage.interface'; import { HumanReadableSize } from 'src/utils/bytes'; import { usePagination } from 'src/utils/pagination'; @@ -18,9 +19,11 @@ export class DownloadService { constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { this.access = AccessCore.create(accessRepository); + this.logger.setContext(DownloadService.name); } async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise { @@ -83,7 +86,14 @@ export class DownloadService { filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`; } - zip.addFile(originalPath, filename); + let realpath = originalPath; + try { + realpath = await this.storageRepository.realpath(originalPath); + } catch { + this.logger.warn('Unable to resolve realpath', { originalPath }); + } + + zip.addFile(realpath, filename); } void zip.finalize(); diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 615fd5d8c9..5c2951e097 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -56,6 +56,7 @@ export const newStorageRepositoryMock = (reset = true): Mocked Promise.resolve(filepath)), stat: vitest.fn(), crawl: vitest.fn(), walk: vitest.fn().mockImplementation(async function* () {}), From c924f6c27c1583bbf8df32f86e63129f5b2d2439 Mon Sep 17 00:00:00 2001 From: Pierre Couy Date: Tue, 13 Aug 2024 18:05:36 +0200 Subject: [PATCH 080/723] docs: update custom map style guide (#11350) * docs:Reword "Custom Map Style" guide - Split setting a style.json in Immich and creating a style with Maptiler - Make it clearer that this is the way to change tile provider --------- Co-authored-by: Jason Rasmussen --- docs/docs/guides/custom-map-styles.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/docs/guides/custom-map-styles.md b/docs/docs/guides/custom-map-styles.md index 485daf1d40..9da9a34822 100644 --- a/docs/docs/guides/custom-map-styles.md +++ b/docs/docs/guides/custom-map-styles.md @@ -1,8 +1,22 @@ -# Create Custom Map Styles for Immich Using Maptiler +# Custom Map Styles -You may decide that you'd like to modify the style document which is used to draw the maps in Immich. This can be done easily using Maptiler, if you do not want to write an entire JSON document by hand. +You may decide that you'd like to modify the style document which is used to +draw the maps in Immich. In addition to visual customization, this also allows +you to pick your own map tile provider instead of the default one. The default +`style.json` for [light theme](https://github.com/immich-app/immich/tree/main/server/resources/style-light.json) +and [dark theme](https://github.com/immich-app/immich/blob/main/server/resources/style-dark.json) +can be used as a basis for creating your own style. -## Steps +There are several sources for already-made `style.json` map themes, as well as +online generators you can use. + +1. In **Immich**, navigate to **Administration --> Settings --> Map & GPS Settings** and expand the **Map Settings** subsection. +2. Paste the link to your JSON style in either the **Light Style** or **Dark Style**. (You can add different styles which will help make the map style more appropriate depending on whether you set **Immich** to Light or Dark mode.) +3. Save your selections. Reload the map, and enjoy your custom map style! + +## Use Maptiler to build a custom style + +Customizing the map style can be done easily using Maptiler, if you do not want to write an entire JSON document by hand. 1. Create a free account at https://cloud.maptiler.com 2. Once logged in, you can either create a brand new map by clicking on **New Map**, selecting a starter map, and then clicking **Customize**, OR by selecting a **Standard Map** and customizing it from there. @@ -11,6 +25,3 @@ You may decide that you'd like to modify the style document which is used to dra 5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. Maptiler will present an interactive side-by-side map with the original and your changes prior to publication.
![Maptiler Publication Settings](img/immich_map_styles_publish.png) 6. Maptiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay. 7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to Maptiler. -8. In **Immich**, navigate to **Administration --> Settings --> Map & GPS Settings** and expand the **Map Settings** subsection. -9. Paste the link to your JSON style in either the **Light Style** or **Dark Style**. (You can add different styles which will help make the map style more appropriate depending on whether you set **Immich** to Light or Dark mode. -10. Save your selections. Reload the map, and enjoy your custom map style! From fdf0b16fe353908fdf5215da26b33206bd95a912 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Tue, 13 Aug 2024 19:01:30 +0200 Subject: [PATCH 081/723] feat(web): add privacy step in the onboarding (#11359) * feat: add privacy step in the onboarding * fix: remove console.log * feat:Details the implications of enabling the map on the settings page Added a link to the guide on customizing map styles as well * feat: add map implication * refactor: onboarding style * fix: tile provider * fix: remove long explanations * chore: cleanup --------- Co-authored-by: pcouy Co-authored-by: Jason Rasmussen --- e2e/src/web/specs/auth.e2e-spec.ts | 1 + .../admin-page/settings/admin-settings.svelte | 14 +++-- .../settings/map-settings/map-settings.svelte | 7 ++- .../new-version-check-settings.svelte | 1 + .../storage-template-settings.svelte | 3 +- .../onboarding-page/onboarding-card.svelte | 18 +++++- .../onboarding-page/onboarding-hello.svelte | 9 +-- .../onboarding-page/onboarding-privacy.svelte | 63 +++++++++++++++++++ .../onboarding-storage-template.svelte | 29 ++++----- .../onboarding-page/onboarding-theme.svelte | 20 +++--- web/src/lib/i18n/en.json | 8 ++- web/src/routes/auth/onboarding/+page.svelte | 12 +++- 12 files changed, 136 insertions(+), 49 deletions(-) create mode 100644 web/src/lib/components/onboarding-page/onboarding-privacy.svelte diff --git a/e2e/src/web/specs/auth.e2e-spec.ts b/e2e/src/web/specs/auth.e2e-spec.ts index b616a365cf..e89f17a4e9 100644 --- a/e2e/src/web/specs/auth.e2e-spec.ts +++ b/e2e/src/web/specs/auth.e2e-spec.ts @@ -33,6 +33,7 @@ test.describe('Registration', () => { // onboarding await expect(page).toHaveURL('/auth/onboarding'); await page.getByRole('button', { name: 'Theme' }).click(); + await page.getByRole('button', { name: 'Privacy' }).click(); await page.getByRole('button', { name: 'Storage Template' }).click(); await page.getByRole('button', { name: 'Done' }).click(); diff --git a/web/src/lib/components/admin-page/settings/admin-settings.svelte b/web/src/lib/components/admin-page/settings/admin-settings.svelte index 55750a9737..21e70df950 100644 --- a/web/src/lib/components/admin-page/settings/admin-settings.svelte +++ b/web/src/lib/components/admin-page/settings/admin-settings.svelte @@ -8,7 +8,7 @@ import { handleError } from '$lib/utils/handle-error'; import { getConfig, getConfigDefaults, updateConfig, type SystemConfigDto } from '@immich/sdk'; import { loadConfig } from '$lib/stores/server-config.store'; - import { cloneDeep } from 'lodash-es'; + import { cloneDeep, isEqual } from 'lodash-es'; import { onMount } from 'svelte'; import type { SettingsResetOptions } from './admin-settings'; import { t } from 'svelte-i18n'; @@ -23,12 +23,16 @@ }; export const handleSave = async (update: Partial) => { + let systemConfigDto = { + ...savedConfig, + ...update, + }; + if (isEqual(systemConfigDto, savedConfig)) { + return; + } try { const newConfig = await updateConfig({ - systemConfigDto: { - ...savedConfig, - ...update, - }, + systemConfigDto, }); config = cloneDeep(newConfig); diff --git a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte index 74cbe2d9a1..7c2c5c856a 100644 --- a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte +++ b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte @@ -26,7 +26,12 @@
- +
diff --git a/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte index 4ef4804c3f..76c238df82 100644 --- a/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte +++ b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte @@ -21,6 +21,7 @@
diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index 1d0cec3296..4ebf4ed118 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -29,6 +29,7 @@ export let minified = false; export let onReset: SettingsResetEvent; export let onSave: SettingsSaveEvent; + export let duration: number = 500; let templateOptions: SystemConfigTemplateStorageOptionDto; let selectedPreset = ''; @@ -87,7 +88,7 @@
-
+

{#if tag === 'template-link'} diff --git a/web/src/lib/components/onboarding-page/onboarding-card.svelte b/web/src/lib/components/onboarding-page/onboarding-card.svelte index 8b2da48bb9..9b2378ccd8 100644 --- a/web/src/lib/components/onboarding-page/onboarding-card.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-card.svelte @@ -1,11 +1,27 @@

+ {#if title || icon} +
+ {#if icon} + + {/if} + {#if title} +

+ {title.toUpperCase()} +

+ {/if} +
+ {/if}
diff --git a/web/src/lib/components/onboarding-page/onboarding-hello.svelte b/web/src/lib/components/onboarding-page/onboarding-hello.svelte index c2d318ccda..466e1d29f7 100644 --- a/web/src/lib/components/onboarding-page/onboarding-hello.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-hello.svelte @@ -3,14 +3,11 @@ import Button from '$lib/components/elements/buttons/button.svelte'; import { mdiArrowRight } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; - import { createEventDispatcher } from 'svelte'; - import ImmichLogo from '../shared-components/immich-logo.svelte'; + import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte'; import { user } from '$lib/stores/user.store'; import { t } from 'svelte-i18n'; - const dispatch = createEventDispatcher<{ - done: void; - }>(); + export let onDone: () => void; @@ -21,7 +18,7 @@

{$t('onboarding_welcome_description')}

- diff --git a/web/src/lib/components/onboarding-page/onboarding-privacy.svelte b/web/src/lib/components/onboarding-page/onboarding-privacy.svelte new file mode 100644 index 0000000000..da36f741f1 --- /dev/null +++ b/web/src/lib/components/onboarding-page/onboarding-privacy.svelte @@ -0,0 +1,63 @@ + + + +

+ {$t('onboarding_privacy_description')} +

+ + {#if config && $user} + + + +
+
+ +
+
+ +
+
+
+ {/if} +
diff --git a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte index 096417d72a..69809dd39d 100644 --- a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte @@ -2,20 +2,18 @@ import { featureFlags } from '$lib/stores/server-config.store'; import { user } from '$lib/stores/user.store'; import { getConfig, type SystemConfigDto } from '@immich/sdk'; - import { mdiArrowLeft, mdiCheck } from '@mdi/js'; - import { createEventDispatcher, onMount } from 'svelte'; - import AdminSettings from '../admin-page/settings/admin-settings.svelte'; - import StorageTemplateSettings from '../admin-page/settings/storage-template/storage-template-settings.svelte'; - import Button from '../elements/buttons/button.svelte'; - import Icon from '../elements/icon.svelte'; + import { mdiArrowLeft, mdiCheck, mdiHarddisk } from '@mdi/js'; + import { onMount } from 'svelte'; + import AdminSettings from '$lib/components/admin-page/settings/admin-settings.svelte'; + import StorageTemplateSettings from '$lib/components/admin-page/settings/storage-template/storage-template-settings.svelte'; + import Button from '$lib/components/elements/buttons/button.svelte'; + import Icon from '$lib/components/elements/icon.svelte'; import OnboardingCard from './onboarding-card.svelte'; import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; - const dispatch = createEventDispatcher<{ - done: void; - previous: void; - }>(); + export let onDone: () => void; + export let onPrevious: () => void; let config: SystemConfigDto | null = null; @@ -24,11 +22,7 @@ }); - -

- {$t('admin.storage_template_settings').toUpperCase()} -

- +

{message} @@ -45,10 +39,11 @@ {savedConfig} onSave={(config) => handleSave(config)} onReset={(options) => handleReset(options)} + duration={0} >

- @@ -57,7 +52,7 @@
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 6796ae3a71..eaf5ffc1a4 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -127,12 +127,13 @@ "map_enable_description": "Enable map features", "map_gps_settings": "Map & GPS Settings", "map_gps_settings_description": "Manage Map & GPS (Reverse Geocoding) Settings", + "map_implications": "The map feature relies on an external tile service (tiles.immich.cloud)", "map_light_style": "Light style", "map_manage_reverse_geocoding_settings": "Manage Reverse Geocoding settings", "map_reverse_geocoding": "Reverse Geocoding", "map_reverse_geocoding_enable_description": "Enable reverse geocoding", "map_reverse_geocoding_settings": "Reverse Geocoding Settings", - "map_settings": "Map Settings", + "map_settings": "Map", "map_settings_description": "Manage map settings", "map_style_description": "URL to a style.json map theme", "metadata_extraction_job": "Extract metadata", @@ -317,7 +318,8 @@ "user_settings": "User Settings", "user_settings_description": "Manage user settings", "user_successfully_removed": "User {email} has been successfully removed.", - "version_check_enabled_description": "Enable periodic requests to GitHub to check for new releases", + "version_check_enabled_description": "Enable version check", + "version_check_implications": "The version check feature relies on periodic communication with github.com", "version_check_settings": "Version Check", "version_check_settings_description": "Enable/disable the new version notification", "video_conversion_job": "Transcode videos", @@ -850,6 +852,7 @@ "ok": "Ok", "oldest_first": "Oldest first", "onboarding": "Onboarding", + "onboarding_privacy_description": "The following (optional) features rely on external services, and can by disabled at any time in the administration settings.", "onboarding_theme_description": "Choose a color theme for your instance. You can change this later in your settings.", "onboarding_welcome_description": "Let's get your instance set up with some common settings.", "onboarding_welcome_user": "Welcome, {user}", @@ -920,6 +923,7 @@ "previous_memory": "Previous memory", "previous_or_next_photo": "Previous or next photo", "primary": "Primary", + "privacy": "Privacy", "profile_image_of_user": "Profile image of {user}", "profile_picture_set": "Profile picture set.", "public_album": "Public album", diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte index 4647ad8bde..0fe2c68c84 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte'; + import OnboardingPrivacy from '$lib/components/onboarding-page/onboarding-privacy.svelte'; import OnboadingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte'; import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; @@ -11,12 +12,17 @@ interface OnboardingStep { name: string; - component: typeof OnboardingHello | typeof OnboardingTheme | typeof OnboadingStorageTemplate; + component: + | typeof OnboardingHello + | typeof OnboardingTheme + | typeof OnboadingStorageTemplate + | typeof OnboardingPrivacy; } const onboardingSteps: OnboardingStep[] = [ { name: 'hello', component: OnboardingHello }, { name: 'theme', component: OnboardingTheme }, + { name: 'privacy', component: OnboardingPrivacy }, { name: 'storage', component: OnboadingStorageTemplate }, ]; @@ -55,8 +61,8 @@
From 5ec407b57c51e4905fb4f60dd28d762ad6b94b25 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 13 Aug 2024 14:39:25 -0500 Subject: [PATCH 082/723] chore(mobile): properly patch openapi with custom response dto (#11753) --- mobile/lib/utils/openapi_patching.dart | 12 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 1 + mobile/openapi/lib/model/rating_response.dart | 2 +- mobile/openapi/pubspec.yaml | 4 +- open-api/bin/generate-open-api.sh | 10 +- open-api/immich-openapi-specs.json | 1 + open-api/patch/api.dart.patch | 3 +- .../patch/pubspec_immich_mobile.yaml.patch | 9 + open-api/templates/mobile/api_client.mustache | 264 ++++++++++++++++++ .../mobile/api_client.mustache.patch | 10 + server/src/dtos/user-preferences.dto.ts | 2 +- 12 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 mobile/lib/utils/openapi_patching.dart create mode 100644 open-api/patch/pubspec_immich_mobile.yaml.patch create mode 100644 open-api/templates/mobile/api_client.mustache create mode 100644 open-api/templates/mobile/api_client.mustache.patch diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart new file mode 100644 index 0000000000..7b27f59aee --- /dev/null +++ b/mobile/lib/utils/openapi_patching.dart @@ -0,0 +1,12 @@ +import 'package:openapi/api.dart'; + +dynamic upgradeDto(dynamic value, String targetType) { + switch (targetType) { + case 'UserPreferencesResponseDto': + if (value is Map) { + if (value['rating'] == null) { + value['rating'] = RatingResponse().toJson(); + } + } + } +} diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 19ff7fc6d5..bbe680731e 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -16,6 +16,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/utils/openapi_patching.dart'; import 'package:http/http.dart'; import 'package:intl/intl.dart'; import 'package:meta/meta.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 346eee3f50..01c646d393 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -166,6 +166,7 @@ class ApiClient { /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { + upgradeDto(value, targetType); try { switch (targetType) { case 'String': diff --git a/mobile/openapi/lib/model/rating_response.dart b/mobile/openapi/lib/model/rating_response.dart index 80ef5980fb..31505550ef 100644 --- a/mobile/openapi/lib/model/rating_response.dart +++ b/mobile/openapi/lib/model/rating_response.dart @@ -13,7 +13,7 @@ part of openapi.api; class RatingResponse { /// Returns a new [RatingResponse] instance. RatingResponse({ - required this.enabled, + this.enabled = false, }); bool enabled; diff --git a/mobile/openapi/pubspec.yaml b/mobile/openapi/pubspec.yaml index f033028432..4a979bf5db 100644 --- a/mobile/openapi/pubspec.yaml +++ b/mobile/openapi/pubspec.yaml @@ -13,5 +13,5 @@ dependencies: http: '>=0.13.0 <0.14.0' intl: any meta: '^1.1.8' -dev_dependencies: - test: '>=1.21.6 <1.22.0' + immich_mobile: + path: ../ diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index a00d57d0ae..bf79b0bd82 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -8,12 +8,18 @@ function dart { cd ./templates/mobile/serialization/native wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache patch --no-backup-if-mismatch -u native_class.mustache =0.13.0 <0.14.0' + intl: any + meta: '^1.1.8' +-dev_dependencies: +- test: '>=1.21.6 <1.22.0' ++ immich_mobile: ++ path: ../ diff --git a/open-api/templates/mobile/api_client.mustache b/open-api/templates/mobile/api_client.mustache new file mode 100644 index 0000000000..7f464f026e --- /dev/null +++ b/open-api/templates/mobile/api_client.mustache @@ -0,0 +1,264 @@ +{{>header}} +{{>part_of}} +class ApiClient { + ApiClient({this.basePath = '{{{basePath}}}', this.authentication,}); + + final String basePath; + final Authentication? authentication; + + var _client = Client(); + final _defaultHeaderMap = {}; + + /// Returns the current HTTP [Client] instance to use in this class. + /// + /// The return value is guaranteed to never be null. + Client get client => _client; + + /// Requests to use a new HTTP [Client] in this class. + set client(Client newClient) { + _client = newClient; + } + + Map get defaultHeaderMap => _defaultHeaderMap; + + void addDefaultHeader(String key, String value) { + _defaultHeaderMap[key] = value; + } + + // We don't use a Map for queryParams. + // If collectionFormat is 'multi', a key might appear multiple times. + Future invokeAPI( + String path, + String method, + List queryParams, + Object? body, + Map headerParams, + Map formParams, + String? contentType, + ) async { + await authentication?.applyToParams(queryParams, headerParams); + + headerParams.addAll(_defaultHeaderMap); + if (contentType != null) { + headerParams['Content-Type'] = contentType; + } + + final urlEncodedQueryParams = queryParams.map((param) => '$param'); + final queryString = urlEncodedQueryParams.isNotEmpty ? '?${urlEncodedQueryParams.join('&')}' : ''; + final uri = Uri.parse('$basePath$path$queryString'); + + try { + // Special case for uploading a single file which isn't a 'multipart/form-data'. + if ( + body is MultipartFile && (contentType == null || + !contentType.toLowerCase().startsWith('multipart/form-data')) + ) { + final request = StreamedRequest(method, uri); + request.headers.addAll(headerParams); + request.contentLength = body.length; + body.finalize().listen( + request.sink.add, + onDone: request.sink.close, + // ignore: avoid_types_on_closure_parameters + onError: (Object error, StackTrace trace) => request.sink.close(), + cancelOnError: true, + ); + final response = await _client.send(request); + return Response.fromStream(response); + } + + if (body is MultipartRequest) { + final request = MultipartRequest(method, uri); + request.fields.addAll(body.fields); + request.files.addAll(body.files); + request.headers.addAll(body.headers); + request.headers.addAll(headerParams); + final response = await _client.send(request); + return Response.fromStream(response); + } + + final msgBody = contentType == 'application/x-www-form-urlencoded' + ? formParams + : await serializeAsync(body); + final nullableHeaderParams = headerParams.isEmpty ? null : headerParams; + + switch(method) { + case 'POST': return await _client.post(uri, headers: nullableHeaderParams, body: msgBody,); + case 'PUT': return await _client.put(uri, headers: nullableHeaderParams, body: msgBody,); + case 'DELETE': return await _client.delete(uri, headers: nullableHeaderParams, body: msgBody,); + case 'PATCH': return await _client.patch(uri, headers: nullableHeaderParams, body: msgBody,); + case 'HEAD': return await _client.head(uri, headers: nullableHeaderParams,); + case 'GET': return await _client.get(uri, headers: nullableHeaderParams,); + } + } on SocketException catch (error, trace) { + throw ApiException.withInner( + HttpStatus.badRequest, + 'Socket operation failed: $method $path', + error, + trace, + ); + } on TlsException catch (error, trace) { + throw ApiException.withInner( + HttpStatus.badRequest, + 'TLS/SSL communication failed: $method $path', + error, + trace, + ); + } on IOException catch (error, trace) { + throw ApiException.withInner( + HttpStatus.badRequest, + 'I/O operation failed: $method $path', + error, + trace, + ); + } on ClientException catch (error, trace) { + throw ApiException.withInner( + HttpStatus.badRequest, + 'HTTP connection failed: $method $path', + error, + trace, + ); + } on Exception catch (error, trace) { + throw ApiException.withInner( + HttpStatus.badRequest, + 'Exception occurred: $method $path', + error, + trace, + ); + } + + throw ApiException( + HttpStatus.badRequest, + 'Invalid HTTP operation: $method $path', + ); + } +{{#native_serialization}} + + Future deserializeAsync(String value, String targetType, {bool growable = false,}) async => + // ignore: deprecated_member_use_from_same_package + deserialize(value, targetType, growable: growable); + + @Deprecated('Scheduled for removal in OpenAPI Generator 6.x. Use deserializeAsync() instead.') + dynamic deserialize(String value, String targetType, {bool growable = false,}) { + // Remove all spaces. Necessary for regular expressions as well. + targetType = targetType.replaceAll(' ', ''); // ignore: parameter_assignments + + // If the expected target type is String, nothing to do... + return targetType == 'String' + ? value + : fromJson(json.decode(value), targetType, growable: growable); + } +{{/native_serialization}} + + // ignore: deprecated_member_use_from_same_package + Future serializeAsync(Object? value) async => serialize(value); + + @Deprecated('Scheduled for removal in OpenAPI Generator 6.x. Use serializeAsync() instead.') + String serialize(Object? value) => value == null ? '' : json.encode(value); + +{{#native_serialization}} + /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. + static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { + upgradeDto(value, targetType); + try { + switch (targetType) { + case 'String': + return value is String ? value : value.toString(); + case 'int': + return value is int ? value : int.parse('$value'); + case 'double': + return value is double ? value : double.parse('$value'); + case 'bool': + if (value is bool) { + return value; + } + final valueString = '$value'.toLowerCase(); + return valueString == 'true' || valueString == '1'; + case 'DateTime': + return value is DateTime ? value : DateTime.tryParse(value); + {{#models}} + {{#model}} + case '{{{classname}}}': + {{#isEnum}} + {{#native_serialization}}return {{{classname}}}TypeTransformer().decode(value);{{/native_serialization}} + {{/isEnum}} + {{^isEnum}} + return {{{classname}}}.fromJson(value); + {{/isEnum}} + {{/model}} + {{/models}} + default: + dynamic match; + if (value is List && (match = _regList.firstMatch(targetType)?.group(1)) != null) { + return value + .map((dynamic v) => fromJson(v, match, growable: growable,)) + .toList(growable: growable); + } + if (value is Set && (match = _regSet.firstMatch(targetType)?.group(1)) != null) { + return value + .map((dynamic v) => fromJson(v, match, growable: growable,)) + .toSet(); + } + if (value is Map && (match = _regMap.firstMatch(targetType)?.group(1)) != null) { + return Map.fromIterables( + value.keys.cast(), + value.values.map((dynamic v) => fromJson(v, match, growable: growable,)), + ); + } + } + } on Exception catch (error, trace) { + throw ApiException.withInner(HttpStatus.internalServerError, 'Exception during deserialization.', error, trace,); + } + throw ApiException(HttpStatus.internalServerError, 'Could not find a suitable class for deserialization',); + } +{{/native_serialization}} +} +{{#native_serialization}} + +/// Primarily intended for use in an isolate. +class DeserializationMessage { + const DeserializationMessage({ + required this.json, + required this.targetType, + this.growable = false, + }); + + /// The JSON value to deserialize. + final String json; + + /// Target type to deserialize to. + final String targetType; + + /// Whether to make deserialized lists or maps growable. + final bool growable; +} + +/// Primarily intended for use in an isolate. +Future decodeAsync(DeserializationMessage message) async { + // Remove all spaces. Necessary for regular expressions as well. + final targetType = message.targetType.replaceAll(' ', ''); + + // If the expected target type is String, nothing to do... + return targetType == 'String' + ? message.json + : json.decode(message.json); +} + +/// Primarily intended for use in an isolate. +Future deserializeAsync(DeserializationMessage message) async { + // Remove all spaces. Necessary for regular expressions as well. + final targetType = message.targetType.replaceAll(' ', ''); + + // If the expected target type is String, nothing to do... + return targetType == 'String' + ? message.json + : ApiClient.fromJson( + json.decode(message.json), + targetType, + growable: message.growable, + ); +} +{{/native_serialization}} + +/// Primarily intended for use in an isolate. +Future serializeAsync(Object? value) async => value == null ? '' : json.encode(value); diff --git a/open-api/templates/mobile/api_client.mustache.patch b/open-api/templates/mobile/api_client.mustache.patch new file mode 100644 index 0000000000..3805cd8f79 --- /dev/null +++ b/open-api/templates/mobile/api_client.mustache.patch @@ -0,0 +1,10 @@ +--- api_client.mustache 2024-08-13 14:29:04.056364916 -0500 ++++ api_client_new.mustache 2024-08-13 14:29:36.224410735 -0500 +@@ -159,6 +159,7 @@ + {{#native_serialization}} + /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. + static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { ++ upgradeDto(value, targetType); + try { + switch (targetType) { + case 'String': diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 8c50d00581..3305e1cce1 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -87,7 +87,7 @@ class AvatarResponse { } class RatingResponse { - enabled!: boolean; + enabled: boolean = false; } class MemoryResponse { From ab0ed11778cae661fa59204b97bf2a9c4949701c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 13 Aug 2024 16:39:25 -0400 Subject: [PATCH 083/723] chore: separate enhancement group in release notes (#11756) --- .github/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/release.yml b/.github/release.yml index 04038d22a9..4463555deb 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -11,6 +11,9 @@ changelog: - title: 🚀 Features labels: - feature + + - title: 🌟 Enhancements + labels: - enhancement - title: 🐛 Bug fixes From a8a63b24d0a7bc331b9f6a946a71b80e91816980 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Tue, 13 Aug 2024 22:48:17 +0200 Subject: [PATCH 084/723] chore(web): update translations (#11533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate-URL: https://hosted.weblate.org/projects/immich/immich/ar/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/cs/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/da/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/de/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/el/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/en_devel/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/es/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/fa/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/fi/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/he/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/hi/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/hu/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ko/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/lt/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/nb_NO/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/nl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/pl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt_BR/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ro/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ru/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sk/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Cyrl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Latn/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sv/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/te/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/tr/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/uk/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hant/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_SIMPLIFIED/ Translation: Immich/immich Co-authored-by: AMT AMT Co-authored-by: Adam Uchmanowicz Co-authored-by: António Santos Co-authored-by: Atakan Dulker Co-authored-by: Bezruchenko Simon Co-authored-by: CanbiZ Co-authored-by: Christoph Auer Co-authored-by: Cristian Florin Tănase Co-authored-by: Czerjak N Co-authored-by: Dmitry Co-authored-by: Dmitry Banny Co-authored-by: ElTopo Co-authored-by: Enoé Mugnaschi Co-authored-by: Felipe Silva Co-authored-by: Fjuro Co-authored-by: Florian Ostertag Co-authored-by: Furkan Yutup Co-authored-by: Hugo Cossard Co-authored-by: Ionut Co-authored-by: Joachim Klahr Co-authored-by: Junghyuk Kwon Co-authored-by: Lars Bernstein Co-authored-by: Laurentiu Co-authored-by: Lauritz Tieste Co-authored-by: Luna Kowalik <0skar16.contact@gmail.com> Co-authored-by: MM Co-authored-by: Majid Co-authored-by: Manar Aldroubi Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Co-authored-by: Miki Mrvos Co-authored-by: Oliver Larsson Co-authored-by: Peder Kollenborg Co-authored-by: Pheggas Co-authored-by: Ponas Co-authored-by: Pruthvi Bugidi Co-authored-by: Riccardo Co-authored-by: Rosu Iulian Co-authored-by: Rıfat Dinç Co-authored-by: Sam Smith Co-authored-by: Shawn Co-authored-by: Simmer Lajos Co-authored-by: Simon Zeeck Svärd Co-authored-by: Stan P Co-authored-by: TheScientistPT Co-authored-by: Tobias Frejo Co-authored-by: Tom Niget Co-authored-by: UTKARSH VISHNOI Co-authored-by: Varga Bence Levente Co-authored-by: Vincent Yeung Co-authored-by: Vladimir Petrov (Vlado) Co-authored-by: Voinea Laurentiu Gabriel Co-authored-by: Xo Co-authored-by: aarhor Co-authored-by: anton Co-authored-by: chapvic Co-authored-by: dkorecko Co-authored-by: dvbthien Co-authored-by: gallegonovato Co-authored-by: jocxfin Co-authored-by: manosrh Co-authored-by: oopzzozzo Co-authored-by: pyorot Co-authored-by: sibber5 Co-authored-by: thestrudl Co-authored-by: waclaw66 Co-authored-by: Åke Amcoff Co-authored-by: Вячеслав Лукьяненко Co-authored-by: 李奕寯 --- web/src/lib/i18n/ar.json | 82 +- web/src/lib/i18n/bg.json | 36 +- web/src/lib/i18n/cs.json | 21 +- web/src/lib/i18n/da.json | 72 +- web/src/lib/i18n/de.json | 65 +- web/src/lib/i18n/el.json | 553 ++++++++- web/src/lib/i18n/en.json | 2 +- web/src/lib/i18n/es.json | 80 +- web/src/lib/i18n/fa.json | 10 +- web/src/lib/i18n/fi.json | 17 +- web/src/lib/i18n/fr.json | 10 +- web/src/lib/i18n/he.json | 16 +- web/src/lib/i18n/hi.json | 1685 ++++++++++++++++----------- web/src/lib/i18n/hu.json | 64 +- web/src/lib/i18n/it.json | 19 +- web/src/lib/i18n/ko.json | 10 +- web/src/lib/i18n/lt.json | 256 ++-- web/src/lib/i18n/nb_NO.json | 26 +- web/src/lib/i18n/nl.json | 17 +- web/src/lib/i18n/pl.json | 4 + web/src/lib/i18n/pt.json | 316 +++-- web/src/lib/i18n/pt_BR.json | 5 +- web/src/lib/i18n/ro.json | 233 ++-- web/src/lib/i18n/ru.json | 34 +- web/src/lib/i18n/sl.json | 8 +- web/src/lib/i18n/sr_Cyrl.json | 39 +- web/src/lib/i18n/sr_Latn.json | 15 +- web/src/lib/i18n/sv.json | 33 +- web/src/lib/i18n/te.json | 270 ++++- web/src/lib/i18n/tr.json | 14 +- web/src/lib/i18n/uk.json | 62 +- web/src/lib/i18n/vi.json | 753 ++++++------ web/src/lib/i18n/zh_Hant.json | 1049 ++++++++++------- web/src/lib/i18n/zh_SIMPLIFIED.json | 6 + 34 files changed, 3962 insertions(+), 1920 deletions(-) diff --git a/web/src/lib/i18n/ar.json b/web/src/lib/i18n/ar.json index b695784f80..98f9bb2bd2 100644 --- a/web/src/lib/i18n/ar.json +++ b/web/src/lib/i18n/ar.json @@ -19,13 +19,13 @@ "add_more_users": "إضافة مستخدمين آخرين", "add_partner": "أضف شريكًا", "add_path": "إضافة مسار", - "add_photos": "إضافة صورة", + "add_photos": "إضافة صور", "add_to": "إضافة إلى…", "add_to_album": "إضافة إلى ألبوم", "add_to_shared_album": "إضافة إلى ألبوم مشترك", "added_to_archive": "أُضيفت للأرشيف", - "added_to_favorites": "أُضيفت للمفضلة", - "added_to_favorites_count": "تم إضافة {count} إلى المفضلة", + "added_to_favorites": "أُضيفت للمفضلات", + "added_to_favorites_count": "تم إضافة {count, number} إلى المفضلات", "admin": { "add_exclusion_pattern_description": "إضافة أنماط الاستبعاد. يدعم التمويه باستخدام *، **، و؟. لتجاهل جميع الملفات في أي دليل يسمى \"Raw\"، استخدم \"**/Raw/**\". لتجاهل جميع الملفات التي تنتهي بـ \".tif\"، استخدم \"**/*.tif\". لتجاهل مسار مطلق، استخدم \"/path/to/ignore/**\".", "authentication_settings": "إعدادات المصادقة", @@ -48,7 +48,7 @@ "exclusion_pattern_description": "تتيح لك أنماط الاستبعاد تجاهل الملفات والمجلدات عند فحص مكتبتك. يعد هذا مفيدًا إذا كان لديك مجلدات تحتوي على ملفات لا تريد استيرادها، مثل ملفات RAW.", "external_library_created_at": "مكتبة خارجية (أُنشئت في {date})", "external_library_management": "إدارة المكتبة الخارجية", - "face_detection": "اكتشاف الوجوه", + "face_detection": "إ‏كتشاف الوجوه", "face_detection_description": "اكتشف الوجوه في المحتويات باستخدام التعلم الآلي. بالنسبة للفيديوهات، سيتم فقط استخدام الصورة المصغرة. خيار \"الكل\" يعيد معالجة كل المحتويات. خيار \"مفقود\" يضع في قائمة الإنتظار المحتويات التي لم تعالج بعد. سيتم وضع الوجوه المكتشفة في قائمة إنتظار التعرف على الوجه بعد اكتمال اكتشاف الوجه، مما يجمعها بأشخاص موجودين أو جدد.", "facial_recognition_job_description": "تجميع الوجوه المكتشفة كأشخاص. يتم تنفيذ هذه الخطوة بعد اكتمال اكتشاف الوجه. خيار \"الكل\" يعيد تجميع جميع الوجوه. خيار \"المفقود\" يضع في قائمة الانتظار الوجوه التي لم يتم تعيين شخص لها.", "failed_job_command": "فشل الأمر {command} للمهمة: {job}", @@ -103,7 +103,7 @@ "machine_learning_enabled": "تفعيل التعلم الآلي", "machine_learning_enabled_description": "إذا تم تعطيله، سيتم تعطيل جميع ميزات التعلم الآلي بغض النظر عن الإعدادات أدناه.", "machine_learning_facial_recognition": "التعرف على الوجوه", - "machine_learning_facial_recognition_description": "الاكتشاف، والتعرف، وتجميع الوجوه في الصور", + "machine_learning_facial_recognition_description": "الاكتشاف، التعرف على، وتجميع الوجوه في الصور", "machine_learning_facial_recognition_model": "نموذج التعرف على الوجوه", "machine_learning_facial_recognition_model_description": "النماذج مدرجة بترتيب تنازلي حسب الحجم. النماذج الأكبر حجماً أبطأ وتستخدم ذاكرة أكثر، ولكنها تنتج نتائج أفضل. يرجى ملاحظة أنه يجب إعادة تشغيل وظيفة الكشف عن الوجوه لجميع الصور بعد تغيير النموذج.", "machine_learning_facial_recognition_setting": "تفعيل التعرف على الوجوه", @@ -278,7 +278,7 @@ "transcoding_preferred_hardware_device": "الجهاز المفضل", "transcoding_preferred_hardware_device_description": "ينطبق فقط على VAAPI وQSV. يضبط عقدة dri المستخدمة لتحويل ترميز الأجهزة.", "transcoding_preset_preset": "الضبط المُسبق (-preset)", - "transcoding_preset_preset_description": "سرعة الضغط. تؤدي الإعدادات المسبقة الأبطأ إلى إنتاج ملفات أصغر حجمًا، وزيادة الجودة عند استهداف معدل بت معين. يتجاهل VP9 السرعات الأعلى من \"الأسرع\".", + "transcoding_preset_preset_description": "سرعة الضغط. تؤدي الإعدادات المسبقة الأبطأ إلى إنتاج ملفات أصغر حجمًا، وزيادة الجودة عند استهداف معدل بت معين. يتجاهل VP9 السرعات الأعلى من 'الأسرع'.", "transcoding_reference_frames": "الإطارات المرجعية", "transcoding_reference_frames_description": "عدد الإطارات التي يجب الرجوع إليها عند ضغط إطار معين. تعمل القيم الأعلى على تحسين كفاءة الضغط، ولكنها تبطئ عملية التشفير. 0 يضبط هذه القيمة تلقائيًا.", "transcoding_required_description": "فقط مقاطع الفيديو ذات التنسيق غير المقبول", @@ -588,6 +588,7 @@ "failed_to_load_asset": "فشل تحميل المحتوى", "failed_to_load_assets": "فشل تحميل المحتويات", "failed_to_load_people": "فشل تحميل الأشخاص", + "failed_to_remove_product_key": "تعذر إزالة مفتاح المنتج", "failed_to_stack_assets": "فشل في تكديس المحتويات", "failed_to_unstack_assets": "فشل في فصل المحتويات", "import_path_already_exists": "مسار الاستيراد هذا موجود مسبقًا.", @@ -741,7 +742,16 @@ "host": "المضيف", "hour": "ساعة", "image": "صورة", - "image_alt_text_date": "في {date}", + "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} تم التقاطها مع {person1} في {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها مع {person1} و{person2} في {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها مع {person1} و{person2} و{person3} في {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها مع {person1} و{person2} و{additionalCount, number} آخرين في {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}، {country} في {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}، {country} مع {person1} في {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}، {country} مع {person1} و{person2} في {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}، {country} مع {person1}، {person2}، و{person3} في {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}, {country} with {person1}, {person2}, مع {additionalCount, number} آخرين في {date}", "image_alt_text_people": "{count, plural, =1 {مع {person1}} =2 {مع {person1} و {person2}} =3 {مع {person1} و {person2} و {person3}} other {مع {person1} و {person2} و {others, number} آخرين}}", "image_alt_text_place": "في {city}, {country}", "image_taken": "{isVideo, select, true {تم التقاط الفيديو} other {تم التقاط الصورة}}", @@ -847,7 +857,7 @@ "menu": "القائمة", "merge": "الدمج", "merge_people": "دمج الأشخاص", - "merge_people_limit": "يمكنك دمج ما يصل إلى 5 وجوه فقط في المرة الواحدة", + "merge_people_limit": "يمكنك دمج حتى 5 وجوه فقط في المرة الواحدة", "merge_people_prompt": "هل تريد دمج هؤلاء الناس؟ هذا الإجراء لا رجعة فيه.", "merge_people_successfully": "تم دمج الأشخاص بنجاح", "merged_people_count": "دمج {count, plural, one {شخص واحد} other {# أشخاص}}", @@ -862,6 +872,7 @@ "name": "الاسم", "name_or_nickname": "الاسم أو اللقب", "never": "أبداً", + "new_album": "البوم جديد", "new_api_key": "مفتاح API جديد", "new_password": "كلمة المرور الجديدة", "new_person": "شخص جديد", @@ -906,6 +917,7 @@ "online": "متصل", "only_favorites": "المفضلة فقط", "only_refreshes_modified_files": "تحديث الملفات المعدلة فقط", + "open_in_map_view": "فتح في عرض الخريطة", "open_in_openstreetmap": "فتح في OpenStreetMap", "open_the_search_filters": "افتح مرشحات البحث", "options": "خيارات", @@ -975,7 +987,41 @@ "profile_picture_set": "مجموعة الصور الشخصية.", "public_album": "الألبوم العام", "public_share": "مشاركة عامة", + "purchase_account_info": "داعم", + "purchase_activated_subtitle": "شكرًا لك على دعمك لـ Immich والبرمجيات مفتوحة المصدر", + "purchase_activated_time": "تم التفعيل في {date, date}", + "purchase_activated_title": "لقد تم تفعيل مفتاحك بنجاح", + "purchase_button_activate": "تنشيط", + "purchase_button_buy": "شراء", + "purchase_button_buy_immich": "شراء Immich", + "purchase_button_never_show_again": "لا تظهر مرة أخرى أبدا", + "purchase_button_reminder": "ذكّرني بعد 30 يومًا", + "purchase_button_remove_key": "إزالة المفتاح", + "purchase_button_select": "تحديد", + "purchase_failed_activation": "فشل التنشيط! يرجى التحقق من بريدك الإلكتروني للحصول على مفتاح المنتج الصحيح!", + "purchase_individual_description_1": "للفرد", + "purchase_individual_description_2": "حالة الداعم", + "purchase_individual_title": "فردي", + "purchase_input_suggestion": "هل لديك مفتاح المنتج؟ أدخل المفتاح أدناه", + "purchase_license_subtitle": "قم بشراء Immich لدعم التطوير المستمر للخدمة", + "purchase_lifetime_description": "الشراء لمدى الحياة", + "purchase_option_title": "خيارات الشراء", + "purchase_panel_info_1": "يتطلب بناء Immich الكثير من الوقت والجهد، ولدينا مهندسون يعملون بدوام كامل لجعله أفضل ما يمكن. مهمتنا هي أن تصبح البرمجيات مفتوحة المصدر وممارسات العمل الأخلاقية مصدر دخل مستدام للمطورين وإنشاء نظام بيئي يحترم الخصوصية مع بدائل حقيقية للخدمات السحابية الاستغلالية.", + "purchase_panel_info_2": "نظرًا لأننا ملتزمون بعدم إضافة نظام حظر الاشتراك غير المدفوع، فإن هذا الشراء لن يمنحك أي ميزات إضافية في Immich. نحن نعتمد على المستخدمين مثلك لدعم التطوير المستمر لـ Immich.", + "purchase_panel_title": "ادعم المشروع", + "purchase_per_server": "لكل خادم", + "purchase_per_user": "لكل مستخدم", + "purchase_remove_product_key": "إزالة مفتاح المنتج", + "purchase_remove_product_key_prompt": "هل أنت متأكد أنك تريد إزالة مفتاح المنتج؟", + "purchase_remove_server_product_key": "إزالة مفتاح منتج الخادم", + "purchase_remove_server_product_key_prompt": "هل أنت متأكد أنك تريد إزالة مفتاح منتج الخادم؟", + "purchase_server_description_1": "للخادم بأكمله", + "purchase_server_description_2": "حالة الداعم", + "purchase_server_title": "الخادم", + "purchase_settings_server_activated": "يتم إدارة مفتاح منتج الخادم من قبل مدير النظام", "range": "", + "rating": "تقييم نجمي", + "rating_description": "‫‌اعرض تقييم exif في لوحة المعلومات", "raw": "", "reaction_options": "خيارات رد الفعل", "read_changelog": "قراءة سجل التغيير", @@ -1020,6 +1066,7 @@ "reset_people_visibility": "إعادة ضبط ظهور الأشخاص", "reset_settings_to_default": "", "reset_to_default": "إعادة التعيين إلى الافتراضي", + "resolve_duplicates": "معالجة النسخ المكررة", "resolved_all_duplicates": "تم حل جميع التكرارات", "restore": "الاستعاده من سلة المهملات", "restore_all": "استعادة الكل", @@ -1064,13 +1111,14 @@ "see_all_people": "عرض جميع الأشخاص", "select_album_cover": "حدد غلاف الألبوم", "select_all": "تحديد الكل", - "select_avatar_color": "حدد اللون الرمزي", - "select_face": "حدد الوجه", + "select_all_duplicates": "تحديد جميع النسخ المكررة", + "select_avatar_color": "حدد لون الصورة الشخصية", + "select_face": "اختيار وجه", "select_featured_photo": "حدد الصورة المميزة", "select_from_computer": "اختر من الجهاز", "select_keep_all": "حدد الاحتفاظ بالكل", "select_library_owner": "اختر مالِك المكتبة", - "select_new_face": "حدد الوجه الجديد", + "select_new_face": "اختيار وجه جديد", "select_photos": "حدد الصور", "select_trash_all": "حدّد حذف الكلِ", "selected": "المُحدّد", @@ -1104,6 +1152,7 @@ "sharing_sidebar_description": "اعرض رابطًا للمشاركة في الشريط الجانبي", "shift_to_permanent_delete": "اضغط على ⇧ لحذف المحتوى نهائيًا", "show_album_options": "إظهار خيارات الألبوم", + "show_albums": "إظهار الألبومات", "show_all_people": "إظهار جميع الأشخاص", "show_and_hide_people": "إظهار وإخفاء الأشخاص", "show_file_location": "إظهار موقع الملف", @@ -1118,6 +1167,8 @@ "show_person_options": "إظهار خيارات الشخص", "show_progress_bar": "إظهار شريط التقدم", "show_search_options": "إظهار خيارات البحث", + "show_supporter_badge": "شارة المؤيد", + "show_supporter_badge_description": "إظهار شارة المؤيد", "shuffle": "خلط", "sign_out": "خروج", "sign_up": "تسجيل", @@ -1134,6 +1185,8 @@ "sort_title": "العنوان", "source": "المصدر", "stack": "تجميع", + "stack_duplicates": "تجميع النسخ المكررة", + "stack_select_one_photo": "حدد صورة رئيسية واحدة للمجموعة", "stack_selected_photos": "كدس الصور المحددة", "stacked_assets_count": "تم تكديس {count, plural, one {# المحتوى} other {# المحتويات}}", "stacktrace": "تتّبُع التكديس", @@ -1171,7 +1224,7 @@ "total_usage": "الاستخدام الإجمالي", "trash": "المهملات", "trash_all": "نقل الكل إلى سلة المهملات", - "trash_count": "{count} في المهملات", + "trash_count": "سلة المحملات {count, number}", "trash_delete_asset": "حذف/نقل المحتوى إلى سلة المهملات", "trash_no_results_message": "ستظهر هنا الصور ومقاطع الفيديو المحذوفة.", "trashed_items_will_be_permanently_deleted_after": "سيتم حذفُ العناصر المحذوفة نِهائيًا بعد {days, plural, one {# يوم} other {# أيام }}.", @@ -1191,6 +1244,7 @@ "unnamed_share": "مشاركة بلا إسم", "unsaved_change": "تغيير غير محفوظ", "unselect_all": "إلغاء تحديد الكل", + "unselect_all_duplicates": "إلغاء تحديد كافة النسخ المكررة", "unstack": "فك الكومه", "unstacked_assets_count": "تم إخراج {count, plural, one {# الأصل} other {# الأصول}} من التكديس", "untracked_files": "الملفات التي لم يتم تعقبها", @@ -1200,7 +1254,7 @@ "upload": "رفع", "upload_concurrency": "الرفع المتزامن", "upload_errors": "إكتمل الرفع مع {count, plural, one {# خطأ} other {# أخطاء}}, قم بتحديث الصفحة لرؤية المحتويات الجديدة التي تم رفعها.", - "upload_progress": "متبقية {remaining} - معالجة {processed}/{total}", + "upload_progress": "متبقية {remaining, number} - معالجة {processed, number}/{total, number}", "upload_skipped_duplicates": "تم تخطي {count, plural, one {# محتوى مكرر} other {# محتويات مكررة }}", "upload_status_duplicates": "التكرارات", "upload_status_errors": "الأخطاء", @@ -1214,6 +1268,8 @@ "user_license_settings": "رخصة", "user_license_settings_description": "ادر رخصتك", "user_liked": "قام {user} بالإعجاب {type, select, photo {بهذه الصورة} video {بهذا الفيديو} asset {بهذا المحتوى} other {بها}}", + "user_purchase_settings": "الشراء", + "user_purchase_settings_description": "إدارة عملية الشراء الخاصة بك", "user_role_set": "قم بتعيين {user} كـ {role}", "user_usage_detail": "تفاصيل استخدام المستخدم", "username": "اسم المستخدم", diff --git a/web/src/lib/i18n/bg.json b/web/src/lib/i18n/bg.json index 0cc583b194..c4b5304dd1 100644 --- a/web/src/lib/i18n/bg.json +++ b/web/src/lib/i18n/bg.json @@ -995,36 +995,38 @@ "state": "", "status": "Статус", "stop_motion_photo": "", - "stop_photo_sharing": "", - "stop_photo_sharing_description": "", - "stop_sharing_photos_with_user": "", - "storage": "Пространство", - "storage_label": "", - "storage_usage": "", + "stop_photo_sharing": "Да спрете ли споделянето на вашите снимки?", + "stop_photo_sharing_description": "{partner} вече няма достъп до вашите снимки.", + "stop_sharing_photos_with_user": "Прекратете споделянето на снимки с този потребител", + "storage": "Пространство на хранилището", + "storage_label": "Наименование на хранилището", + "storage_usage": "Използвани {used} от {available}", "submit": "Изпращане", "suggestions": "Предложения", - "sunrise_on_the_beach": "", - "swap_merge_direction": "", + "sunrise_on_the_beach": "Изгрев на плажа", + "swap_merge_direction": "Размяна посоката на сливане", "sync": "Синхронизиране", "template": "Шаблон", "theme": "Тема", - "theme_selection": "", - "theme_selection_description": "", - "time_based_memories": "", + "theme_selection": "Избор на тема", + "theme_selection_description": "Автоматично задаване на светла или тъмна тема въз основа на системните предпочитания на вашия браузър", + "they_will_be_merged_together": "Те ще бъдат обединени", + "time_based_memories": "Спомени, базирани на времето", "timezone": "Часова зона", "to_archive": "Архивирай", + "to_change_password": "Промяна на паролата", "to_favorite": "Любим", "to_login": "Вписване", "to_trash": "Кошче", - "toggle_settings": "", - "toggle_theme": "", + "toggle_settings": "Превключване на настройките", + "toggle_theme": "Превключване на тема", "toggle_visibility": "", - "total_usage": "", + "total_usage": "Общо използвано", "trash": "кошче", - "trash_all": "", + "trash_all": "Изхвърли всички", "trash_count": "", - "trash_no_results_message": "", - "trashed_items_will_be_permanently_deleted_after": "", + "trash_no_results_message": "Изтритите снимки и видеоклипове ще се показват тук.", + "trashed_items_will_be_permanently_deleted_after": "Изхвърлените в кошчето елементи ще бъдат изтрити за постоянно след {days, plural, one {# day} other {# days}}.", "type": "Тип", "unarchive": "Разархивирай", "unarchived": "", diff --git a/web/src/lib/i18n/cs.json b/web/src/lib/i18n/cs.json index 58cf97f80e..faa5af16d5 100644 --- a/web/src/lib/i18n/cs.json +++ b/web/src/lib/i18n/cs.json @@ -129,6 +129,7 @@ "map_enable_description": "Povolit funkce mapy", "map_gps_settings": "Mapa a GPS", "map_gps_settings_description": "Správa nastavení mapy a GPS (Reverzní geokódování)", + "map_implications": "Funkce mapy závisí na externí dlaždicové službě (tiles.immich.cloud)", "map_light_style": "Světlý motiv", "map_manage_reverse_geocoding_settings": "Správa nastavení Reverzního geokódování", "map_reverse_geocoding": "Reverzní geokódování", @@ -278,7 +279,7 @@ "transcoding_preferred_hardware_device": "Preferované hardwarové zařízení", "transcoding_preferred_hardware_device_description": "Platí pouze pro VAAPI a QSV. Nastaví dri uzel použitý pro hardwarové překódování.", "transcoding_preset_preset": "Preset (-preset)", - "transcoding_preset_preset_description": "Rychlost komprese. Pomalejší předvolby vytvářejí menší soubory a zvyšují kvalitu při dosažení určitého datového toku. VP9 ignoruje rychlosti vyšší než `faster`.", + "transcoding_preset_preset_description": "Rychlost komprese. Pomalejší předvolby vytvářejí menší soubory a zvyšují kvalitu při dosažení určitého datového toku. VP9 ignoruje rychlosti vyšší než 'faster'.", "transcoding_reference_frames": "Referenční snímky", "transcoding_reference_frames_description": "Počet referenčních snímků při kompresi daného snímku. Vyšší hodnoty zvyšují účinnost komprese, ale zpomalují kódování. Hodnota 0 toto nastavuje automaticky.", "transcoding_required_description": "Pouze videa, která nejsou v akceptovaném formátu", @@ -320,7 +321,7 @@ "user_settings": "Uživatelé", "user_settings_description": "Správa nastavení uživatelů", "user_successfully_removed": "Uživatel {email} byl úspěšně odstraněn.", - "version_check_enabled_description": "Povolení pravidelných požadavků na GitHub pro kontrolu nových verzí", + "version_check_enabled_description": "Povolit kontrolu verzí", "version_check_settings": "Kontrola verze", "version_check_settings_description": "Povolení/zakázání oznámení o nové verzi", "video_conversion_job": "Překódování videí", @@ -590,7 +591,7 @@ "failed_to_load_assets": "Nepodařilo se načíst položky", "failed_to_load_people": "Chyba načítání osob", "failed_to_remove_product_key": "Nepodařilo se odebrat klíč produktu", - "failed_to_stack_assets": "Nepodařilo se poskládat položky", + "failed_to_stack_assets": "Nepodařilo se seskupit položky", "failed_to_unstack_assets": "Nepodařilo se rozložit položky", "import_path_already_exists": "Tato cesta importu již existuje.", "incorrect_email_or_password": "Nesprávný e-mail nebo heslo", @@ -919,6 +920,7 @@ "online": "Online", "only_favorites": "Pouze oblíbené", "only_refreshes_modified_files": "Obnovuje pouze změněné soubory", + "open_in_map_view": "Otevřít v zobrazení mapy", "open_in_openstreetmap": "Otevřít v OpenStreetMap", "open_the_search_filters": "Otevřít vyhledávací filtry", "options": "Možnosti", @@ -1022,6 +1024,8 @@ "purchase_server_title": "Server", "purchase_settings_server_activated": "Produktový klíč serveru spravuje správce", "range": "Rozsah", + "rating": "Hodnocení hvězdičkami", + "rating_description": "Zobrazit exif hodnocení v informačním panelu", "raw": "Raw", "reaction_options": "Možnosti reakce", "read_changelog": "Přečtěte si seznam změn", @@ -1152,6 +1156,7 @@ "sharing_sidebar_description": "Zobrazit sekci Sdílení v postranním panelu", "shift_to_permanent_delete": "stiskněte ⇧ pro trvalé odstranění položky", "show_album_options": "Zobrazit možnosti alba", + "show_albums": "Zobrazit alba", "show_all_people": "Zobrazit všechny lidi", "show_and_hide_people": "Zobrazit a skrýt osoby", "show_file_location": "Zobrazit umístění souboru", @@ -1183,8 +1188,10 @@ "sort_recent": "Nejnovější fotka", "sort_title": "Název", "source": "Zdroj", - "stack": "Zásobník", - "stack_selected_photos": "Zásobník vybraných fotografií", + "stack": "Seskupit", + "stack_duplicates": "Seskupit duplicity", + "stack_select_one_photo": "Vyberte jednu hlavní fotografii pro seskupení", + "stack_selected_photos": "Seskupení vybraných fotografií", "stacked_assets_count": "{count, plural, one {Seskupena # položka} few {Seskupeny # položky} other {Seskupeno # položek}}", "stacktrace": "Výpis zásobníku", "start": "Start", @@ -1242,7 +1249,7 @@ "unsaved_change": "Neuložená změna", "unselect_all": "Zrušit výběr všech", "unselect_all_duplicates": "Zrušit výběr všech duplicit", - "unstack": "Zrušit zásobník", + "unstack": "Zrušit seskupení", "unstacked_assets_count": "{count, plural, one {Rozložena # položka} few {Rozloženy # položky} other {Rozloženo # položek}}", "untracked_files": "Nesledované soubory", "untracked_files_decription": "Tyto soubory nejsou aplikaci známy. Mohou být výsledkem neúspěšných přesunů, přerušeného nahrávání nebo mohou zůstat pozadu kvůli chybě", @@ -1289,7 +1296,7 @@ "view_links": "Zobrazit odkazy", "view_next_asset": "Zobrazit další položku", "view_previous_asset": "Zobrazit předchozí položku", - "view_stack": "Zobrazit zásobník", + "view_stack": "Zobrazit seskupení", "viewer": "Prohlížeč", "visibility_changed": "Viditelnost změněna u {count, plural, one {# osoby} few {# osob} other {# lidí}}", "waiting": "Čekající", diff --git a/web/src/lib/i18n/da.json b/web/src/lib/i18n/da.json index e7fb7bbf68..611a6e6472 100644 --- a/web/src/lib/i18n/da.json +++ b/web/src/lib/i18n/da.json @@ -7,7 +7,7 @@ "actions": "Handlinger", "active": "Aktiv", "activity": "Aktivitet", - "activity_changed": "Aktivitet er {enabled, select, true {enabled} other {disabled}}", + "activity_changed": "Aktivitet er {enabled, select, true {aktiveret} other {deaktiveret}}", "add": "Tilføj", "add_a_description": "Tilføj en beskrivelse", "add_a_location": "Tilføj en placering", @@ -25,31 +25,31 @@ "add_to_shared_album": "Tilføj til delt album", "added_to_archive": "Tilføjet til arkiv", "added_to_favorites": "Tilføjet til favoritter", - "added_to_favorites_count": "Tilføjet {count} til favoritter", + "added_to_favorites_count": "Tilføjet {count, number} til favoritter", "admin": { "add_exclusion_pattern_description": "Tilføj udelukkelsesmønstre. Globbing ved hjælp af *, ** og ? understøttes. For at ignorere alle filer i enhver mappe med navnet \"Raw\", brug \"**/Raw/**\". For at ignorere alle filer, der slutter på \".tif\", brug \"**/*.tif\". For at ignorere en absolut sti, brug \"/sti/til/ignoreret/**\".", "authentication_settings": "Godkendelsesindstillinger", "authentication_settings_description": "Administrer adgangskode, OAuth og andre godkendelsesindstillinger", - "authentication_settings_disable_all": "Er du sikker på at du vil deaktivere alle login muligheder? Login vil blive helt deaktiveret.", + "authentication_settings_disable_all": "Er du sikker på at du vil deaktivere alle loginmuligheder? Login vil blive helt deaktiveret.", "authentication_settings_reenable": "Brug en server-kommando for at genaktivere.", "background_task_job": "Baggrundsopgaver", "check_all": "Tjek Alle", "cleared_jobs": "Ryddet jobs til: {job}", "config_set_by_file": "konfigurationen er i øjeblikket indstillet af en konfigurations fil", "confirm_delete_library": "Er du sikker på, at du vil slette {library} bibliotek?", - "confirm_delete_library_assets": "Er du sikker på, at du vil slette dette bibliotek? Dette vil slette alle {count} indeholdte aktiver fra Immich og kan ikke gøres om. Filerne forbliver på disken.", + "confirm_delete_library_assets": "Er du sikker på, at du vil slette dette bibliotek? Dette vil slette {count, plural, one {# indeholdt mediefil} other {alle # indeholdte mediefiler}} fra Immich og kan ikke gøres om. Filerne forbliver på disken.", "confirm_email_below": "For at bekræfte, skriv \"{email}\" herunder", "confirm_reprocess_all_faces": "Er du sikker på, at du vil genbehandle alle ansigter? Dette vil også rydde navngivne personer.", "confirm_user_password_reset": "Er du sikker på, at du vil nulstille {user}s adgangskode?", "crontab_guru": "Crontab Guru", "disable_login": "Deaktiver login", "disabled": "", - "duplicate_detection_job_description": "Kør maskinlæring på aktiver for at opdage lignende billeder. Er afhængig af Smart Søgning", + "duplicate_detection_job_description": "Kør maskinlæring på mediefiler for at opdage lignende billeder. Er afhængig af Smart Søgning", "exclusion_pattern_description": "Ekskluderingsmønstre lader dig ignorere filer og mapper, når du scanner dit bibliotek. Dette er nyttigt, hvis du har mapper, der indeholder filer, du ikke vil importere, såsom RAW-filer.", "external_library_created_at": "Eksternt bibliotek (oprettet {date})", "external_library_management": "Ekstern biblioteksstyring", "face_detection": "Ansigtsopdagelse", - "face_detection_description": "Genkend ansigterne i aktiver via maskinlæring. For videoer er det kun miniaturebilledet som tages hensyn til. \"Alle\" (gen-)behandler alle aktiver. \"Mangler\" sætter aktiver i kø, som ikke er blevet behandlet endnu. Opdagede ansigter vil blive sat i kø til Ansigtsgenkendelse efter Ansigtsopdagelse er færdig, hvilket grupperer dem til eksisterende eller nye personer.", + "face_detection_description": "Genkend ansigterne i mediefiler via maskinlæring. For videoer er det kun miniaturebilledet som tages hensyn til. \"Alle\" (gen-)behandler alle mediefiler. \"Mangler\" sætter mediefiler i kø, som ikke er blevet behandlet endnu. Opdagede ansigter vil blive sat i kø til Ansigtsgenkendelse efter Ansigtsopdagelse er færdig, hvilket grupperer dem til eksisterende eller nye personer.", "facial_recognition_job_description": "Grupper opdagede ansigter i personer. Dette trin kører efter Ansigtsopdagelse er færdig. \"Alle\" (gen-)klumper alle ansigter sammen. \"Mangler\" sætter ansigter i kø, som ikke har en person tildelt.", "failed_job_command": "Kommando {command} mislykkedes for job: {job}", "force_delete_user_warning": "ADVARSEL: Dette vil øjeblikkeligt fjerne brugeren og alle Billeder/Videoer. Dette kan ikke fortrydes, og filerne kan ikke gendannes.", @@ -74,8 +74,8 @@ "job_settings": "Jobindstillinger", "job_settings_description": "Administrér samtidige opgaver", "job_status": "Opgave Status", - "jobs_delayed": "{jobCount} forsinket", - "jobs_failed": "{jobCount} fejlede", + "jobs_delayed": "{jobCount, plural, one {# forsinket} other {# forsinkede}}", + "jobs_failed": "{jobCount, plural, one {# fejlet} other {# fejlede}}", "library_created": "Skabte bibliotek: {library}", "library_cron_expression": "Cron-udtryk", "library_cron_expression_description": "Sæt skannings interval ved at bruge cron formatet. For mere information se dokumentation her Crontab Guru", @@ -97,7 +97,7 @@ "machine_learning_clip_model": "CLIP-model", "machine_learning_duplicate_detection": "Dubletdetektion", "machine_learning_duplicate_detection_enabled": "Aktiver duplikatdetektion", - "machine_learning_duplicate_detection_enabled_description": "Når slået fra, vil nøjagtigt identiske aktiver blive de-duplikeret.", + "machine_learning_duplicate_detection_enabled_description": "Når slået fra, vil nøjagtigt identiske mediefiler blive de-duplikerede.", "machine_learning_duplicate_detection_setting_description": "Brug CLIP-indlejringer til at finde sandsynlige duplikater", "machine_learning_enabled": "Aktivér maskinlæring", "machine_learning_enabled_description": "Hvis deaktiveret, vil alle ML-funktioner blive deaktiveret uanset nedenstående indstillinger.", @@ -125,12 +125,15 @@ "manage_concurrency": "Administrer antallet af samtidige opgaver", "manage_log_settings": "Administrer logindstillinger", "map_dark_style": "Mørk tema", - "map_enable_description": "Aktiver kort funktioner", + "map_enable_description": "Aktivér kortfunktioner", + "map_gps_settings": "Kort- og GPS-indstillinger", + "map_gps_settings_description": "Håndter indstillinger for Kort og GPS (Omvendt Geokodning)", "map_light_style": "Lyst tema", + "map_manage_reverse_geocoding_settings": "Håndtér indstillinger for Omvendt Geokoding", "map_reverse_geocoding": "Omvendt geokodning", "map_reverse_geocoding_enable_description": "Aktiver omvendt geokodning", "map_reverse_geocoding_settings": "Omvendt geokodningsindstillinger", - "map_settings": "Kort og GPS-indstillinger", + "map_settings": "Kortindstillinger", "map_settings_description": "Administrer kortindstillinger", "map_style_description": "URL til en style.json for et korttema", "metadata_extraction_job": "Udtræk metadata", @@ -139,7 +142,7 @@ "migration_job_description": "Migrér miniaturebilleder for aktiver og ansigter til den seneste mappestruktur", "no_paths_added": "Ingen stier tilføjet", "no_pattern_added": "Intet mønster tilføjet", - "note_apply_storage_label_previous_assets": "Bemærk: For at anvende Lagringsmærkatet på tidligere uploadede aktiver, kør", + "note_apply_storage_label_previous_assets": "Bemærk: For at anvende Lagringsmærkatet på tidligere uploadede mediefiler, kør", "note_cannot_be_changed_later": "BEMÆRK: Dette kan ikke ændres senere!", "note_unlimited_quota": "Bemærk: Indsæt 0 for uendelig kvote", "notification_email_from_address": "Fra adressse", @@ -206,14 +209,14 @@ "sidecar_job": "Medfølgende metadata", "sidecar_job_description": "Opdag eller synkroniser medfølgende metadata fra filsystemet", "slideshow_duration_description": "Antal sekunder at vise hvert billede", - "smart_search_job_description": "Kør maskinlæring på aktiver for at understøtte smart søgning", + "smart_search_job_description": "Kør maskinlæring på mediefiler for at understøtte smart søgning", "storage_template_enable_description": "Slå lagringsskabelonredskab til", "storage_template_hash_verification_enabled": "Hash-verifikation slog fejl", "storage_template_hash_verification_enabled_description": "Slår hash-verifikation til, slå ikke dette fra med mindre du er sikker på dets konsekvenser", "storage_template_migration": "Lagringsskabelonmigration", "storage_template_migration_job": "Lagringsmigrationsopgave", "storage_template_settings": "Lagringsskabelon", - "storage_template_settings_description": "Administrer mappestrukturen og filnavnet for det uploadede aktiv", + "storage_template_settings_description": "Administrer mappestrukturen og filnavnet for den uploadede mediefil", "system_settings": "Systemindstillinger", "theme_custom_css_settings": "Brugerdefineret CSS", "theme_custom_css_settings_description": "Cascading Style Sheets tillader at give Immich et brugerdefineret look.", @@ -221,12 +224,12 @@ "theme_settings_description": "Administrér brugertilpasningen af Immich's webinterface", "these_files_matched_by_checksum": "Disse filer er blevet matchet med deres checksummer", "thumbnail_generation_job": "Generér miniaturebilleder", - "thumbnail_generation_job_description": "Generér store, små og slørede miniaturebilleder for hvert aktiv, såvel som miniaturebilleder for hver person", + "thumbnail_generation_job_description": "Generér store, små og slørede miniaturebilleder for hver mediefil, såvel som miniaturebilleder for hver person", "transcode_policy_description": "", "transcoding_acceleration_api": "Accelerations-API", "transcoding_acceleration_api_description": "API'en som interagerer med din enhed for at accelerere transkodning. Denne er indstilling er \"i bedste fald\": Den vil falde tilbage til software-transkodning ved svigt. VP9 virker måske, måske ikke, afhængigt af dit hardware.", "transcoding_acceleration_nvenc": "NVENC (kræver NVIDIA GPU)", - "transcoding_acceleration_qsv": "Hurtigsynkronisering (kræver 7. generation Intel CPU eller senere)", + "transcoding_acceleration_qsv": "Quick Sync (kræver 7. generation Intel CPU eller senere)", "transcoding_acceleration_rkmpp": "RKMPP (kun på Rockchip SOC'er)", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Accepterede lyd-codecs", @@ -287,7 +290,7 @@ "untracked_files": "Utrackede filer", "untracked_files_description": "Applikationen holder ikke styr på disse filer. De kan være resultatet af mislykkede flytninger, afbrudte uploads eller være efterladt på grund af en fejl", "user_delete_delay_settings": "Slet forsinkelse", - "user_delete_delay_settings_description": "Antal dage efter fjernelse for permanent at slette en brugers konto og aktiver. Opgaven for sletning af brugere kører ved midnat for at tjekke efter brugere, der er klar til sletning. Ændringer i denne indstilling vil blive evalueret ved næste udførelse.", + "user_delete_delay_settings_description": "Antal dage efter fjernelse for permanent at slette en brugers konto og mediefiler. Opgaven for sletning af brugere kører ved midnat for at tjekke efter brugere, der er klar til sletning. Ændringer i denne indstilling vil blive evalueret ved næste udførelse.", "user_management": "Brugeradministration", "user_password_has_been_reset": "Brugerens adgangskode er blevet nulstillet:", "user_password_reset_description": "Venligst oplys brugeren om den midlertidige adgangskode og informér dem, at de vil være nødt til at ændre adgangskoden ved næste login.", @@ -311,7 +314,7 @@ "album_name": "Albumnavn", "album_options": "Albumindstillinger", "album_updated": "Album opdateret", - "album_updated_setting_description": "Modtag en emailnotifikation når et delt album har nye aktiver", + "album_updated_setting_description": "Modtag en emailnotifikation når et delt album får nye mediefiler", "albums": "Albummer", "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albummer}}", "all": "Alt", @@ -325,7 +328,7 @@ "archive": "Arkiv", "archive_or_unarchive_photo": "Arkivér eller dearkivér billede", "archived": "Arkiveret", - "asset_offline": "Aktiv offline", + "asset_offline": "Mediefil offline", "assets": "elementer", "authorized_devices": "Tilladte enheder", "back": "Tilbage", @@ -385,8 +388,9 @@ "create": "Opret", "create_album": "Opret album", "create_library": "Opret bibliotek", - "create_link": "Oprat link", + "create_link": "Opret link", "create_link_to_share": "Opret link for at dele", + "create_link_to_share_description": "Lad alle med linket se de(t) valgte billede(r)", "create_new_person": "Opret ny person", "create_new_user": "Opret ny bruger", "create_user": "Opret bruger", @@ -422,7 +426,7 @@ "display_options": "Display-indstillinger", "display_order": "Display-rækkefølge", "display_original_photos": "Vis originale billeder", - "display_original_photos_setting_description": "Foretræk at vise det originale billede når et aktiv anskues fremfor miniaturebillederne når det originale aktiv er web-kompatibelt. Dette kan munde ud i langsommere billedvisningshastigheder.", + "display_original_photos_setting_description": "Foretræk at vise det originale billede frem for miniaturebilleder når den originale fil er web-kompatibelt. Dette kan gøre billedvisning langsommere.", "done": "Færdig", "download": "Hent", "downloading": "Downloader", @@ -486,7 +490,7 @@ "unable_to_create_library": "Ikke i stand til at oprette bibliotek", "unable_to_create_user": "Ikke i stand til at oprette bruger", "unable_to_delete_album": "Ikke i stand til at slette album", - "unable_to_delete_asset": "Ikke i stand til slette aktiv", + "unable_to_delete_asset": "Kan ikke slette mediefil", "unable_to_delete_exclusion_pattern": "Kunne ikke slette udelukkelsesmønster", "unable_to_delete_import_path": "Kunne ikke slette importsti", "unable_to_delete_shared_link": "Kunne ikke slette delt link", @@ -494,12 +498,12 @@ "unable_to_edit_exclusion_pattern": "Kunne ikke redigere udelukkelsesmønster", "unable_to_edit_import_path": "Kunne ikke redigere importsti", "unable_to_empty_trash": "Ikke i stand til at tømme skraldespand", - "unable_to_enter_fullscreen": "Ikke i stand til aktivere fuldskærmstilstand", - "unable_to_exit_fullscreen": "Ikke i stand til deaktivere fuldskærmstilstand", + "unable_to_enter_fullscreen": "Kan ikke aktivere fuldskærmstilstand", + "unable_to_exit_fullscreen": "Kan ikke forlade fuldskærmstilstand", "unable_to_hide_person": "Ikke i stand til at gemme person", "unable_to_link_oauth_account": "Kunne ikke tilkoble OAuth-konto", "unable_to_load_album": "Ikke i stand til hente album", - "unable_to_load_asset_activity": "Ikke i stand til at hente aktivets aktivitet", + "unable_to_load_asset_activity": "Kunne ikke hente aktivitet for mediet", "unable_to_load_items": "Ikke i stand til at hente ting", "unable_to_load_liked_status": "Ikke i stand til hente synes-om-status", "unable_to_play_video": "Ikke i stand til at afspille video", @@ -515,7 +519,7 @@ "unable_to_repair_items": "Ikke i stand til at reparere ting", "unable_to_reset_password": "Ikke i stand til at nulstille adgangskode", "unable_to_resolve_duplicate": "Kunne ikke opklare duplikat", - "unable_to_restore_assets": "Ikke i stand til at genoprette aktiver", + "unable_to_restore_assets": "Kunne ikke genoprette medier", "unable_to_restore_trash": "Ikke i stand til at genoprette skrald", "unable_to_restore_user": "Ikke i stand til at genoprette bruger", "unable_to_save_album": "Ikke i stand til at gemme album", @@ -527,7 +531,7 @@ "unable_to_scan_library": "Ikke i stand til at skanne bibliotek", "unable_to_set_profile_picture": "Ikke i stand til at sætte profilbillede", "unable_to_submit_job": "Ikke i stand til at indsende opgave", - "unable_to_trash_asset": "Ikke i stand til at smide aktiv ud", + "unable_to_trash_asset": "Kunne ikke slette medie", "unable_to_unlink_account": "Ikke i stand til at frakoble konto", "unable_to_update_library": "Ikke i stand til at opdatere bibliotek", "unable_to_update_location": "Ikke i stand til at opdatere sted", @@ -588,7 +592,7 @@ "in_archive": "I arkiv", "include_archived": "Inkluder arkiveret", "include_shared_albums": "Inkludér delte albummer", - "include_shared_partner_assets": "Inkludér delte partneraktiver", + "include_shared_partner_assets": "Inkludér delte partnermedier", "individual_share": "Individuel andel", "info": "Info", "interval": { @@ -675,7 +679,7 @@ "no_results": "Ingen resultater", "no_shared_albums_message": "Opret et album for at dele billeder og videoer med personer i dit netværk", "not_in_any_album": "Ikke i noget album", - "note_apply_storage_label_to_previously_uploaded assets": "Bemærk: For at anvende Lagringsmærkat på tidligere uploadede aktiver, kør", + "note_apply_storage_label_to_previously_uploaded assets": "Bemærk: For at anvende Lagringsmærkat på tidligere uploadede medier, kør", "note_unlimited_quota": "Bemærk: Indsæt 0 for ubegrænset kvote", "notes": "Noter", "notification_toggle_setting_description": "Aktivér emailnotifikationer", @@ -722,9 +726,9 @@ "people_sidebar_description": "Vis et link til Personer i sidepanelet", "perform_library_tasks": "", "permanent_deletion_warning": "Advarsel om permanent sletning", - "permanent_deletion_warning_setting_description": "Vis en advarsel, når aktiver slettes permanent", + "permanent_deletion_warning_setting_description": "Vis en advarsel, når medier slettes permanent", "permanently_delete": "Slet permanent", - "permanently_deleted_asset": "Permanent slettet aktiv", + "permanently_deleted_asset": "Permanent slettet medie", "photos": "Billeder", "photos_count": "{count, plural, one {{count, number} Billede} other {{count, number} Billeder}}", "photos_from_previous_years": "Billeder fra tidligere år", @@ -891,7 +895,7 @@ "trash": "Papirkurv", "trash_all": "Smid alle ud", "trash_no_results_message": "Udsmidte billeder og videoer vil kunne findes her.", - "trashed_items_will_be_permanently_deleted_after": "Aktiver i skraldespand vil blive slettet permanent efter {days, plural, one {# dag} other {# dage}}.", + "trashed_items_will_be_permanently_deleted_after": "Mediefiler i skraldespanden vil blive slettet permanent efter {days, plural, one {# dag} other {# dage}}.", "type": "Type", "unarchive": "Afakivér", "unarchived": "Uarkiveret", @@ -930,8 +934,8 @@ "view_all": "Se alle", "view_all_users": "Se alle brugere", "view_links": "Vis links", - "view_next_asset": "Se næste aktiv", - "view_previous_asset": "Se forrige aktiv", + "view_next_asset": "Se næste medie", + "view_previous_asset": "Se forrige medie", "viewer": "Viewer", "waiting": "Venter", "week": "Uge", diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index 781b8ce513..36acb646aa 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -11,12 +11,12 @@ "add": "Hinzufügen", "add_a_description": "Beschreibung hinzufügen", "add_a_location": "Standort hinzufügen", - "add_a_name": "Name hinzufügen", + "add_a_name": "Namen hinzufügen", "add_a_title": "Titel hinzufügen", "add_exclusion_pattern": "Ausschlussmuster hinzufügen", "add_import_path": "Importpfad hinzufügen", "add_location": "Ort hinzufügen", - "add_more_users": "Mehr Nutzer hinzufügen", + "add_more_users": "Weitere Nutzer hinzufügen", "add_partner": "Partner hinzufügen", "add_path": "Pfad hinzufügen", "add_photos": "Fotos hinzufügen", @@ -27,7 +27,7 @@ "added_to_favorites": "Zu Favoriten hinzugefügt", "added_to_favorites_count": "{count, number} zu Favoriten hinzugefügt", "admin": { - "add_exclusion_pattern_description": "Ausschlussmuster hinzufügen. Platzhalter, wie *, **, und ? werden unterstützt. Um alle Dateien in einem Verzeichnis namens \"Raw\" zu ignorieren, \"**/Raw/**\" verwenden. Um alle Dateien zu ignorieren, die auf \".tif\" enden, \"**/*.tif\" verwenden. Um einen absoluten Pfad zu ignorieren, \"/pfad/zum/ignorieren/**\" verwenden.", + "add_exclusion_pattern_description": "Ausschlussmuster hinzufügen. Platzhalter, wie *, **, und ? werden unterstützt. Um alle Dateien in einem Verzeichnis namens „Raw\" zu ignorieren, „**/Raw/**“ verwenden. Um alle Dateien zu ignorieren, die auf „.tif“ enden, „**/*.tif“ verwenden. Um einen absoluten Pfad zu ignorieren, „/pfad/zum/ignorieren/**“ verwenden.", "authentication_settings": "Authentifizierungseinstellungen", "authentication_settings_description": "Verwaltung von Passwort-, OAuth- und sonstigen Authentifizierungseinstellungen", "authentication_settings_disable_all": "Bist du sicher, dass du alle Anmeldemethoden deaktivieren willst? Die Anmeldung wird vollständig deaktiviert.", @@ -58,21 +58,21 @@ "image_prefer_embedded_preview": "Eingebettete Vorschau bevorzugen", "image_prefer_embedded_preview_setting_description": "Verwende eingebettete Vorschaubilder in RAW-Fotos als Grundlage für die Bildverarbeitung, sofern diese zur Verfügung stehen. Dies kann bei einigen Bildern genauere Farben erzeugen, allerdings ist die Qualität der Vorschau kameraabhängig und das Bild kann mehr Kompressionsartefakte aufweisen.", "image_prefer_wide_gamut": "Breites Spektrum bevorzugen", - "image_prefer_wide_gamut_setting_description": "Verwendung von Display P3 (DCI-P3) für Vorschaubilder. Dadurch bleibt die Lebendigkeit von Bildern mit breiten Farbräumen besser erhalten, aber die Bilder können auf älteren Geräten mit einer älteren Browserversion etwas anders aussehen. sRGB-Bilder werden im sRGB-Format belassen, um Farbverschiebungen zu vermeiden.", - "image_preview_format": "Format-Vorschau", - "image_preview_resolution": "Vorschau der Auflösung", + "image_prefer_wide_gamut_setting_description": "Verwendung von Display P3 (DCI-P3) für Miniaturansichten. Dadurch bleibt die Lebendigkeit von Bildern mit breiten Farbräumen besser erhalten, aber die Bilder können auf älteren Geräten mit einer älteren Browserversion etwas anders aussehen. sRGB-Bilder werden im sRGB-Format belassen, um Farbverschiebungen zu vermeiden.", + "image_preview_format": "Vorschauformat", + "image_preview_resolution": "Vorschau-Auflösung", "image_preview_resolution_description": "Dies wird beim Anzeigen eines einzelnen Fotos und für das maschinelle Lernen verwendet. Höhere Auflösungen können mehr Details beibehalten, benötigen aber mehr Zeit für die Kodierung, haben größere Dateigrößen und können die Reaktionsfähigkeit der App beeinträchtigen.", "image_quality": "Qualität", "image_quality_description": "Bildqualität von 1-100. Höher bedeutet bessere Qualität, erzeugt aber größere Dateien. Diese Option betrifft die Vorschaubilder und Miniaturansichten.", "image_settings": "Bildeinstellungen", "image_settings_description": "Verwaltung der Qualität und Auflösung von generierten Bildern", - "image_thumbnail_format": "Vorschaubildformat", - "image_thumbnail_resolution": "Vorschaubildauflösung", + "image_thumbnail_format": "Miniaturansichts-Format", + "image_thumbnail_resolution": "Miniaturansichts-Auflösung", "image_thumbnail_resolution_description": "Dies wird bei der Anzeige von Bildergruppen („Zeitleiste“, „Albumansicht“ usw.) verwendet. Höhere Auflösungen können mehr Details beibehalten, benötigen aber mehr Zeit für die Kodierung, haben größere Dateigrößen und können die Reaktionsfähigkeit der App beeinträchtigen.", - "job_concurrency": "{job} - (Anzahl der Parallelitäten)", + "job_concurrency": "{job} - (Anzahl gleichzeitiger Prozesse)", "job_not_concurrency_safe": "Dieser Job ist nicht parallelisierungssicher.", "job_settings": "Job-Einstellungen", - "job_settings_description": "Verwaltung von Parallelitäten von Jobs", + "job_settings_description": "Verwaltung von gleichzeitigen Job-Prozessen", "job_status": "Job-Status", "jobs_delayed": "{jobCount, plural, other {# verzögert}}", "jobs_failed": "{jobCount, plural, other {# fehlgeschlagen}}", @@ -129,18 +129,19 @@ "map_enable_description": "Kartenfunktionen aktivieren", "map_gps_settings": "Karten & GPS Einstellungen", "map_gps_settings_description": "Karten & GPS Einstellungen verwalten", + "map_implications": "Die Kartenfunktion verwendet einen externen Tile-Service (tiles.immich.cloud)", "map_light_style": "Heller Stil", "map_manage_reverse_geocoding_settings": "Einstellungen für die Umgekehrte Geokodierung verwalten", "map_reverse_geocoding": "Umgekehrte Geokodierung", "map_reverse_geocoding_enable_description": "Umgekehrte Geokodierung aktivieren", "map_reverse_geocoding_settings": "Einstellungen für Umgekehrte Geokodierung", - "map_settings": "Karten Einstellungen", + "map_settings": "Karten", "map_settings_description": "Verwaltung der Karten- & GPS Einstellungen", "map_style_description": "URL zu einem style.json Karten-Theme", "metadata_extraction_job": "Metadaten extrahieren", "metadata_extraction_job_description": "Extrahieren von Metadaten, wie zum Beispiel GPS und Auflösung aus jeder Datei", "migration_job": "Migration", - "migration_job_description": "Diese Aufgabe migriert Vorschaubilder für Dateien und Gesichter in die neueste Ordnerstruktur", + "migration_job_description": "Diese Aufgabe migriert Miniaturansichten für Dateien und Gesichter in die neueste Ordnerstruktur", "no_paths_added": "Keine Pfade hinzugefügt", "no_pattern_added": "Kein Pattern hinzugefügt", "note_apply_storage_label_previous_assets": "Hinweis: Um das Storage Label auf die vorher hochgeladenen Dateien anzuwenden, starte den", @@ -161,7 +162,7 @@ "notification_email_username_description": "Benutzername, der bei der Anmeldung am E-Mail-Server verwendet wird", "notification_enable_email_notifications": "E-Mail-Benachrichtigungen aktivieren", "notification_settings": "Benachrichtigungseinstellungen", - "notification_settings_description": "Verwaltung der Benachrichtigungseinstellungen (incl. E-Mail)", + "notification_settings_description": "Verwaltung der Benachrichtigungseinstellungen (inkl. E-Mail)", "oauth_auto_launch": "Auto-Start", "oauth_auto_launch_description": "Automatischer Start des OAuth-Anmeldevorgangs beim Aufrufen der Anmeldeseite", "oauth_auto_register": "Automatische Registrierung", @@ -232,14 +233,14 @@ "storage_template_settings": "Speichervorlage", "storage_template_settings_description": "Die Ordnerstruktur und den Dateinamen der hochgeladenen Datei verwalten", "storage_template_user_label": "{label} is das Speicher-Label des Benutzers", - "system_settings": "System-Einstellungen", + "system_settings": "Systemeinstellungen", "theme_custom_css_settings": "Benutzerdefiniertes CSS", "theme_custom_css_settings_description": "Mit Cascading Style Sheets (CSS) kann das Design von Immich angepasst werden.", "theme_settings": "Theme-Einstellungen", "theme_settings_description": "Anpassung der Immich-Web-Oberfläche", "these_files_matched_by_checksum": "Diese Dateien wurden anhand ihrer Prüfsummen abgeglichen", - "thumbnail_generation_job": "Vorschaubilder generieren", - "thumbnail_generation_job_description": "Diese Aufgabe erzeugt große, kleine und unscharfe Miniaturbilder für jede einzelne Datei, sowie Miniaturbilder für jede Person", + "thumbnail_generation_job": "Miniaturansichten generieren", + "thumbnail_generation_job_description": "Diese Aufgabe erzeugt große, kleine und unscharfe Miniaturansichten für jede einzelne Datei, sowie Miniaturansichten für jede Person", "transcode_policy_description": "Richtlinien, wann ein Video transkodiert werden soll. HDR-Videos werden immer transkodiert (außer wenn die Transkodierung deaktiviert ist).", "transcoding_acceleration_api": "Beschleunigungs-API", "transcoding_acceleration_api_description": "Die Schnittstelle welche mit dem Gerät interagiert, um die Transkodierung zu beschleunigen. Bei dieser Einstellung handelt es sich um die \"bestmögliche Lösung\": Bei einem Fehler wird auf die Software-Transkodierung zurückgegriffen. Abhängig von der verwendeten Hardware kann VP9 funktionieren oder auch nicht.", @@ -277,7 +278,7 @@ "transcoding_optimal_description": "Videos mit einer höheren Auflösung als der Zielauflösung oder in einem nicht akzeptierten Format", "transcoding_preferred_hardware_device": "Bevorzugtes Hardwaregerät", "transcoding_preferred_hardware_device_description": "Gilt nur für VAAPI und QSV. Legt den für die Hardware-Transkodierung verwendeten dri-Node fest.", - "transcoding_preset_preset": "Voreinstellung (-voreinstellung)", + "transcoding_preset_preset": "Voreinstellung (-preset)", "transcoding_preset_preset_description": "Komprimierungsgeschwindigkeit. Eine langsamere Voreinstellungen erzeugt kleinere Dateien und erhöht die Qualität, wenn man eine gewisse Bitrate anstrebt. VP9 ignoriert Geschwindigkeiten über „Schneller“.", "transcoding_reference_frames": "Referenz-Frames", "transcoding_reference_frames_description": "Die Anzahl der Bilder, auf die bei der Komprimierung eines bestimmten Bildes Bezug genommen wird. Höhere Werte verbessern die Komprimierungseffizienz, verlangsamen aber die Kodierung. 0 setzt diesen Wert automatisch.", @@ -290,9 +291,9 @@ "transcoding_temporal_aq_description": "Gilt nur für NVENC. Verbessert die Qualität von Szenen mit hohem Detailreichtum und geringen Bewegungen. Dies ist möglicherweise nicht mit älteren Geräten kompatibel.", "transcoding_threads": "Threads", "transcoding_threads_description": "Höhere Werte führen zu einer schnelleren Codierung, lassen dem Server aber weniger Spielraum für die Verarbeitung anderer Aufgaben, solange dies aktiv ist. Dieser Wert sollte nicht höher sein als die Anzahl der CPU-Kerne. Nutzt die maximale Auslastung, wenn der Wert auf 0 gesetzt ist.", - "transcoding_tone_mapping": "Farbton-mapping", + "transcoding_tone_mapping": "Farbton-Mapping", "transcoding_tone_mapping_description": "Versucht, das Aussehen von HDR-Videos bei der Konvertierung in SDR beizubehalten. Jeder Algorithmus geht unterschiedliche Kompromisse bei Farbe, Details und Helligkeit ein. Hable bewahrt Details, Mobius bewahrt die Farbe und Reinhard bewahrt die Helligkeit.", - "transcoding_tone_mapping_npl": "Farbton-mapping NPL", + "transcoding_tone_mapping_npl": "Farbton-Mapping NPL", "transcoding_tone_mapping_npl_description": "Die Farben werden so angepasst, dass sie für einen Bildschirm mit entsprechender Helligkeit normal aussehen. Entgegen der Annahme, dass niedrigere Werte die Helligkeit des Videos erhöhen und umgekehrt, wird die Helligkeit des Bildschirms ausgeglichen. Mit 0 wird dieser Wert automatisch eingestellt.", "transcoding_transcode_policy": "Transcodierungsrichtlinie", "transcoding_transcode_policy_description": "Richtlinie, wann ein Video transkodiert werden soll. HDR-Videos werden immer transkodiert (außer wenn die Transkodierung deaktiviert ist).", @@ -320,7 +321,8 @@ "user_settings": "Benutzer-Einstellungen", "user_settings_description": "Benutzer-Einstellungen verwalten", "user_successfully_removed": "Benutzer {email} wurde erfolgreich entfernt.", - "version_check_enabled_description": "Regelmäßige Abfragen gegen GitHub aktivieren, um nach neueren Versionen zu prüfen", + "version_check_enabled_description": "Versionsprüfung aktivieren", + "version_check_implications": "Die Funktion zur Versionsprüfung basiert auf regelmäßiger Kommunikation mit GitHub.com", "version_check_settings": "Versionsprüfung", "version_check_settings_description": "Aktivieren/Deaktivieren der Benachrichtigung über neue Versionen", "video_conversion_job": "Videos transkodieren", @@ -453,7 +455,7 @@ "confirm_admin_password": "Administrator Passwort bestätigen", "confirm_delete_shared_link": "Bist du sicher, dass du diesen geteilten Link löschen willst?", "confirm_password": "Passwort bestätigen", - "contain": "Enthält", + "contain": "Vollständig", "context": "Kontext", "continue": "Fortsetzen", "copied_image_to_clipboard": "Das Bild wurde in die Zwischenablage kopiert.", @@ -912,12 +914,14 @@ "ok": "Ok", "oldest_first": "Älteste zuerst", "onboarding": "Einstieg", + "onboarding_privacy_description": "Die folgenden (optionalen) Funktionen basieren auf externen Services und könnem jederzeit in den Administrationseinstellungen deaktiviert werden.", "onboarding_theme_description": "Wähle ein Farbschema für deine Instanz aus. Du kannst dies später in deinen Einstellungen ändern.", "onboarding_welcome_description": "Lass uns deine Instanz mit einigen allgemeinen Einstellungen konfigurieren.", "onboarding_welcome_user": "Willkommen, {user}", "online": "Online", "only_favorites": "Nur Favoriten", "only_refreshes_modified_files": "Nur geänderte Dateien aktualisieren", + "open_in_map_view": "In Kartenansicht öffnen", "open_in_openstreetmap": "In OpenStreetMap öffnen", "open_the_search_filters": "Die Suchfilter öffnen", "options": "Optionen", @@ -984,6 +988,7 @@ "previous_memory": "Vorherige Erinnerung", "previous_or_next_photo": "Vorheriges oder nächstes Foto", "primary": "Primär", + "privacy": "Privatsphäre", "profile_image_of_user": "Profilbild von {user}", "profile_picture_set": "Profilbild gesetzt.", "public_album": "Öffentliches Album", @@ -1001,13 +1006,13 @@ "purchase_button_select": "Auswählen", "purchase_failed_activation": "Aktivieren fehlgeschlagen! Überprüfe bitte den Produktschlüssel in der E-Mail!", "purchase_individual_description_1": "Für eine Einzelperson", - "purchase_individual_description_2": "Unterstützer Status", + "purchase_individual_description_2": "Unterstützerstatus", "purchase_individual_title": "Einzelperson", "purchase_input_suggestion": "Besitzen Sie bereits einen Produktschlüssel? Bitte geben Sie diesen unten ein", "purchase_license_subtitle": "Kaufe Immich um eine fortlaufende Entwicklung zu unterstützen", "purchase_lifetime_description": "Lebenslange Gültigkeit", "purchase_option_title": "KAUF OPTIONEN", - "purchase_panel_info_1": "Das Entwickeln von Immich ist aufwendig und nimmt viel Zeit in Anspruch, deshalb haben wir ein Team von Vollzeit-Entwickler*innen, welche ihr Bestes geben. Unser Ziel ist es, mit Open-Source Software und ethischen Unternehmenspraktiken eine nachhaltige Einkommensquelle für unsere Entwickler und ein privatsphäre-respektierendes Ökosystem für unsere Nutzenden zu schaffen. Wir wollen eine kompetitive Alternative zu ausbeuterischen Cloud-Diensten erschaffen.", + "purchase_panel_info_1": "Die Entwicklung von Immich erfordert viel Zeit und Mühe, und wir haben Vollzeit- Entwickler, die so gut wie möglich daran arbeiten. Unser Ziel ist es, dass Open-Source-Software und moralische Geschäftsmethoden zu einer nachhaltigen Einkommensquelle für Entwickler werden und ein datenschutzfreundliches Ökosystem mit echten Alternativen zu ausbeuterischen Cloud-Diensten geschaffen wird.", "purchase_panel_info_2": "Weil wir davon überzeugt sind keine Paywalls zu haben, wird dieser Kauf keine zusätzlichen Funktionen in Immich freischalten. Wir verlassen uns auf Nutzende wie dich, um Entwicklung von Immich zu unterstützen.", "purchase_panel_title": "Das Projekt unterstützen", "purchase_per_server": "Pro Server", @@ -1017,7 +1022,7 @@ "purchase_remove_server_product_key": "Server Produktschlüssel entfernen", "purchase_remove_server_product_key_prompt": "Sicher, dass der Server Produktschlüssel entfernt werden soll?", "purchase_server_description_1": "Für den gesamten Server", - "purchase_server_description_2": "Unterstützer Status", + "purchase_server_description_2": "Unterstützerstatus", "purchase_server_title": "Server", "purchase_settings_server_activated": "Der Server Produktschlüssel wird durch den Administrator verwaltet", "range": "Reichweite", @@ -1035,12 +1040,12 @@ "refresh": "Aktualisieren", "refresh_encoded_videos": "Codierte Videos aktualisieren", "refresh_metadata": "Metadaten aktualisieren", - "refresh_thumbnails": "Vorschaubilder aktualisieren", + "refresh_thumbnails": "Miniaturansichten aktualisieren", "refreshed": "Aktualisiert", "refreshes_every_file": "Jede Datei aktualisieren", "refreshing_encoded_video": "Codierte Videos werden aktualisiert", "refreshing_metadata": "Metadaten werden aktualisiert", - "regenerating_thumbnails": "Vorschaubilder werden neu erstellt", + "regenerating_thumbnails": "Miniaturansichten werden neu erstellt", "remove": "Entfernen", "remove_assets_album_confirmation": "Bist du sicher, dass du {count, plural, one {# Datei} other {# Dateien}} aus dem Album entfernen willst?", "remove_assets_shared_link_confirmation": "Bist du sicher, dass du {count, plural, one {# Datei} other {# Dateien}} von diesem geteilten Link entfernen willst?", @@ -1145,6 +1150,7 @@ "shared_by_user": "Von {user} geteilt", "shared_by_you": "Geteilt von dir", "shared_from_partner": "Fotos von {partner}", + "shared_link_options": "Optionen für geteilten Link", "shared_links": "Geteilte Links", "shared_photos_and_videos_count": "{assetCount, plural, one {# geteiltes Foto oder Video.} other {# geteilte Fotos & Videos.}}", "shared_with_partner": "Geteilt mit {partner}", @@ -1153,6 +1159,7 @@ "sharing_sidebar_description": "Eine Verknüpfung zu Geteiltem in der Seitenleiste anzeigen", "shift_to_permanent_delete": "Drücke ⇧, um die Datei endgültig zu löschen", "show_album_options": "Album-Optionen anzeigen", + "show_albums": "Alben anzeigen", "show_all_people": "Alle Personen anzeigen", "show_and_hide_people": "Personen ein- & ausblenden", "show_file_location": "Dateispeicherort anzeigen", @@ -1167,8 +1174,8 @@ "show_person_options": "Personen-Optionen anzeigen", "show_progress_bar": "Fortschrittsbalken anzeigen", "show_search_options": "Suchoptionen anzeigen", - "show_supporter_badge": "Unterstützer Abzeichen", - "show_supporter_badge_description": "Zeige Unterstützer Abzeichen", + "show_supporter_badge": "Unterstützerabzeichen", + "show_supporter_badge_description": "Zeige Unterstützerabzeichen", "shuffle": "Durchmischen", "sign_out": "Abmelden", "sign_up": "Registrieren", @@ -1185,6 +1192,8 @@ "sort_title": "Titel", "source": "Quelle", "stack": "Stapel", + "stack_duplicates": "Duplikate stapeln", + "stack_select_one_photo": "Hauptfoto für den Stapel auswählen", "stack_selected_photos": "Ausgewählte Fotos stapeln", "stacked_assets_count": "{count, plural, one {# Datei} other {# Dateien}} gestapelt", "stacktrace": "Stacktrace", diff --git a/web/src/lib/i18n/el.json b/web/src/lib/i18n/el.json index 0967ef424b..5ac37616ca 100644 --- a/web/src/lib/i18n/el.json +++ b/web/src/lib/i18n/el.json @@ -1 +1,552 @@ -{} +{ + "about": "Σχετικά", + "account": "Λογαριασμός", + "account_settings": "Ρυθμίσεις Λογαριασμού", + "acknowledge": "Έλαβα γνώση", + "action": "Ενέργεια", + "actions": "Ενέργειες", + "active": "Ενεργά", + "activity": "Δραστηριότητα", + "add": "Προσθήκη", + "add_a_description": "Προσθήκη περιγραφής", + "add_a_location": "Προσθήκη μιας τοποθεσίας", + "add_a_name": "Προσθήκη Ονόματος", + "add_a_title": "Προσθήκη τίτλου", + "add_location": "Προσθήκη τοποθεσίας", + "add_more_users": "Προσθήκη επιπλέον χρηστών", + "add_partner": "Προσθήκη συνεργάτη", + "add_path": "Προσθήκη διαδρομής", + "add_photos": "Προσθήκη φωτογραφιών", + "add_to": "Προσθήκη σε...", + "add_to_album": "Προσθήκη σε άλμπουμ", + "add_to_shared_album": "Προσθήκη σε κοινόχρηστο άλμπουμ", + "added_to_archive": "Αρχειοθέτηση", + "added_to_favorites": "Προστέθηκε στα αγαπημένα", + "added_to_favorites_count": "Προστέθηκαν {count, number} στα αγαπημένα", + "admin": { + "authentication_settings": "Ρυθμίσεις ελέγχου ταυτότητας", + "authentication_settings_description": "Διαχείριση κωδικού πρόσβασης, OAuth και άλλες ρυθμίσεις ελέγχου ταυτότητας", + "authentication_settings_disable_all": "Είστε βέβαιοι ότι θέλετε να απενεργοποιήσετε όλες τις μεθόδους σύνδεσης; Η σύνδεση θα απενεργοποιηθεί πλήρως.", + "background_task_job": "Εργασίες Παρασκηνίου", + "check_all": "Έλεγχος Όλων", + "confirm_delete_library": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τη βιβλιοθήκη {library};", + "confirm_email_below": "Για επιβεβαίωση, πληκτρολογήστε \"{email}\" παρακάτω", + "confirm_reprocess_all_faces": "Είστε βέβαιοι ότι θέλετε να επεξεργαστείτε ξανά όλα τα πρόσωπα; Αυτό θα διαγράψει επίσης άτομα με όνομα.", + "confirm_user_password_reset": "Είστε βέβαιοι ότι θέλετε να επαναφέρετε τον κωδικό πρόσβασης του χρήστη {user};", + "duplicate_detection_job_description": "Εκτελέστε τη εκμάθηση μηχανής σε στοιχεία για να εντοπίσετε παρόμοιες εικόνες. Βασίζεται στην Έξυπνη Αναζήτηση", + "external_library_management": "Διαχείριση Εξωτερικών Βιβλιοθηκών", + "face_detection": "Αναγνώριση προσώπου", + "face_detection_description": "Εντοπίστε τα πρόσωπα σε στοιχεία χρησιμοποιώντας μηχανική εκμάθηση. Για βίντεο, λαμβάνεται υπόψη μόνο η μικρογραφία. Η επιλογή \"Όλα\" επεξεργάζεται εκ νέου όλα τα στοιχεία. Η επιλογή \"Όσα Λείπουν\" προσθέτει στην ουρά στοιχεία που δεν έχουν υποστεί ακόμη επεξεργασία. Τα πρόσωπα που έχουν εντοπιστεί θα μπουν στην ουρά για την Αναγνώριση Προσώπου μετά την ολοκλήρωση της Ανίχνευσης Προσώπου, ομαδοποιώντας τα σε υπάρχοντα ή νέα άτομα.", + "facial_recognition_job_description": "Ομαδοποιήστε εντοπισμένα πρόσωπα σε άτομα. Αυτό το βήμα εκτελείται αφού ολοκληρωθεί η Ανίχνευση προσώπου. Η επιλογή \"Όλα\" ομαδοποιεί εκ νέου όλα τα πρόσωπα. Η επιλογή \"Όσα Λείπουν\" ομαδοποιεί πρόσωπα που δεν έχουν αντιστοιχηθεί σε κάποιο άτομο.", + "failed_job_command": "Η Εντολή {command} απέτυχε για την εργασία: {job}", + "force_delete_user_warning": "ΠΡΟΕΙΔΟΠΟΙΗΣΗ: Αυτό θα αφαιρέσει άμεσα το χρήστη και όλα τα στοιχεία. Αυτό δεν μπορεί να αναιρεθεί και τα αρχεία δεν μπορούν να ανακτηθούν.", + "forcing_refresh_library_files": "Επιβολή ανανέωσης όλων των αρχείων της βιβλιοθήκης", + "image_format_description": "Η μορφή WebP παράγει μικρότερα αρχεία από τη μορφή JPEG, αλλά είναι πιο αργή στην κωδικοποίηση.", + "image_prefer_embedded_preview": "Προτίμηση ενσωματωμένης προεπισκόπησης", + "image_prefer_wide_gamut": "Προτίμηση ευρείας γκάμας", + "image_preview_format": "Μορφή προεπισκόπησης", + "image_preview_resolution": "Ανάλυση προεπισκόπησης", + "image_preview_resolution_description": "Χρησιμοποιείται κατά την προβολή μιας φωτογραφίας και για μηχανική εκμάθηση. Οι υψηλότερες αναλύσεις μπορούν να διατηρήσουν περισσότερες λεπτομέρειες, αλλά χρειάζονται περισσότερο χρόνο για την κωδικοποίηση, έχουν μεγαλύτερα μεγέθη αρχείων και μπορούν να μειώσουν την απόκριση της εφαρμογής.", + "image_quality": "Ποιότητα", + "image_quality_description": "Ποιότητα εικόνας από 1-100. Μεγαλύτερη τιμή σημαίνει καλύτερη ποιότητα, αλλά παράγει μεγαλύτερα αρχεία. Αυτή η επιλογή επηρεάζει τις εικόνες προεπισκόπησης και μικρογραφιών.", + "image_settings": "Ρυθμίσεις Εικόνας", + "image_settings_description": "Διαχείριση της ποιότητας και της ανάλυσης των εικόνων που δημιουργούνται", + "image_thumbnail_format": "Μορφή μικρογραφίας", + "image_thumbnail_resolution": "Ανάλυση μικρογραφίας", + "image_thumbnail_resolution_description": "Χρησιμοποιείται κατά την προβολή ομάδων φωτογραφιών (κύριο χρονολόγιο, προβολή άλμπουμ κλπ.). Υψηλότερες αναλύσεις μπορούν να διατηρήσουν περισσότερες λεπτομέρειες, αλλά χρειάζονται περισσότερο χρόνο για την κωδικοποίηση, έχουν μεγαλύτερα μεγέθη αρχείων και μπορούν να μειώσουν την απόκριση της εφαρμογής.", + "job_settings": "Ρυθμίσεις Εργασιών", + "job_status": "Κατάσταση Εργασιών", + "library_created": "Δημιουργήθηκε η βιβλιοθήκη: {library}", + "library_deleted": "Η βιβλιοθήκη διαγράφηκε", + "library_scanning": "Περιοδική Σάρωση", + "library_scanning_description": "Διαμόρφωση περιοδικής σάρωσης βιβλιοθήκης", + "library_scanning_enable_description": "Ενεργοποίηση περιοδικής σάρωσης βιβλιοθήκης", + "library_settings": "Εξωτερική Βιβλιοθήκη", + "library_settings_description": "Διαχείριση ρυθμίσεων εξωτερικής βιβλιοθήκης", + "library_tasks_description": "Εκτέλεση εργασιών βιβλιοθήκης", + "library_watching_enable_description": "Παρακολούθηση εξωτερικών βιβλιοθηκών για τροποποιήσεις αρχείων", + "library_watching_settings": "Παρακολούθηση βιβλιοθήκης (ΠΕΙΡΑΜΑΤΙΚΟ)", + "library_watching_settings_description": "Αυτόματη παρακολούθηση για τροποποιημένα αρχεία", + "logging_enable_description": "Ενεργοποίηση καταγραφής", + "logging_level_description": "Όταν είναι ενεργοποιημένο, τι επίπεδο καταγραφής να εφαρμοστεί.", + "logging_settings": "Καταγραφή", + "machine_learning_duplicate_detection": "Εντοπισμός Διπλότυπων", + "machine_learning_duplicate_detection_enabled": "Ενεργοποίηση εντοπισμού διπλότυπων", + "machine_learning_enabled": "Ενεργοποίηση μηχανικής εκμάθησης", + "machine_learning_enabled_description": "Εάν απενεργοποιηθεί, όλες οι λειτουργίες μηχανικής εκμάθησης θα απενεργοποιηθούν, ανεξάρτητα από τις παρακάτω ρυθμίσεις.", + "machine_learning_facial_recognition": "Αναγνώριση προσώπου", + "machine_learning_facial_recognition_description": "Εντοπισμός, αναγνώριση και ομαδοποίηση προσώπων σε εικόνες", + "machine_learning_facial_recognition_model": "Μοντέλο αναγνώρισης προσώπου", + "machine_learning_facial_recognition_model_description": "Τα μοντέλα παρατίθενται με φθίνουσα σειρά μεγέθους. Τα μεγαλύτερα μοντέλα είναι πιο αργά και χρησιμοποιούν περισσότερη μνήμη, αλλά παράγουν καλύτερα αποτελέσματα. Σημειώστε ότι πρέπει να εκτελέσετε ξανά την εργασία Ανίχνευση προσώπου για όλες τις εικόνες κατά την αλλαγή ενός μοντέλου.", + "machine_learning_facial_recognition_setting": "Ενεργοποίηση αναγνώρισης προσώπου", + "machine_learning_facial_recognition_setting_description": "Αν απενεργοποιηθεί, οι εικόνες δεν θα κωδικοποιούνται για αναγνώριση προσώπου και δεν θα συμπληρώνουν την ενότητα Άτομα στη σελίδα Εξερεύνηση.", + "machine_learning_max_detection_distance": "Μέγιστη απόσταση ανίχνευσης", + "machine_learning_max_detection_distance_description": "Η μέγιστη απόσταση μεταξύ δύο εικόνων για να θεωρηθούν διπλότυπες, που κυμαίνεται από 0,001-0,1. Οι υψηλότερες τιμές θα εντοπίσουν περισσότερες διπλότυπες, αλλά μπορεί να οδηγήσουν σε ψευδώς θετικά αποτελέσματα.", + "machine_learning_max_recognition_distance": "Μέγιστη απόσταση αναγνώρισης", + "machine_learning_max_recognition_distance_description": "Η μέγιστη απόσταση μεταξύ δύο προσώπων για να θεωρείται το ίδιο άτομο, που κυμαίνεται από 0-2. Η μείωση αυτή μπορεί να αποτρέψει την επισήμανση δύο ατόμων ως το ίδιο άτομο, ενώ η αύξηση της μπορεί να αποτρέψει την επισήμανση του ίδιου ατόμου ως δύο διαφορετικών ατόμων. Λάβετε υπόψη ότι είναι πιο εύκολο να συγχωνεύσετε δύο άτομα παρά να χωρίσετε ένα άτομο στα δύο, οπότε προτιμήστε ένα χαμηλότερο όριο, όταν αυτό είναι δυνατό.", + "machine_learning_min_detection_score": "Ελάχιστο σκορ ανίχνευσης", + "machine_learning_min_detection_score_description": "Ελάχιστο σκορ εμπιστοσύνης για ανίχνευση προσώπου από 0-1. Οι χαμηλότερες τιμές θα εντοπίσουν περισσότερα πρόσωπα, αλλά μπορεί να οδηγήσουν σε ψευδώς θετικά αποτελέσματα.", + "machine_learning_min_recognized_faces": "Ελάχιστα αναγνωρισμένα πρόσωπα", + "machine_learning_min_recognized_faces_description": "Ο ελάχιστος αριθμός αναγνωρισμένων προσώπων για ένα άτομο που θα δημιουργηθεί. Η αύξηση αυτή καθιστά την Αναγνώριση Προσώπου πιο ακριβή με το κόστος να αυξηθεί η πιθανότητα να μην εκχωρηθεί ένα πρόσωπο σε ένα άτομο.", + "machine_learning_settings": "Ρυθμίσεις Μηχανικής Εκμάθησης", + "machine_learning_settings_description": "Διαχειριστείτε τις λειτουργίες και τις ρυθμίσεις μηχανικής εκμάθησης", + "machine_learning_smart_search": "Έξυπνη Αναζήτηση", + "machine_learning_smart_search_description": "Αναζητήστε εικόνες σημασιολογικά χρησιμοποιώντας ενσωματώσεις CLIP", + "machine_learning_smart_search_enabled": "Ενεργοποίηση έξυπνης αναζήτησης", + "machine_learning_smart_search_enabled_description": "Αν απενεργοποιηθεί, οι εικόνες δεν θα κωδικοποιούνται για έξυπνη αναζήτηση.", + "machine_learning_url_description": "URL του διακομιστή μηχανικής εκμάθησης", + "manage_log_settings": "Διαχείριση ρυθμίσεων αρχείου καταγραφής", + "map_dark_style": "Σκούρο Θέμα", + "map_enable_description": "Ενεργοποίηση λειτουργιών χάρτη", + "map_gps_settings": "Ρυθμίσεις Χάρτη & GPS", + "map_gps_settings_description": "Διαχείριση Ρυθμίσεων Χάρτη & GPS (Αντίστροφη γεωκωδικοποίηση)", + "map_light_style": "Φωτεινό Θέμα", + "note_unlimited_quota": "Σημείωση: Εισαγάγετε 0 για απεριόριστο όριο", + "notification_email_from_address": "Διεύθυνση αποστολέα" + }, + "assets_restore_confirmation": "Είστε βέβαιοι ότι θέλετε να επαναφέρετε όλα τα στοιχεία που βρίσκονται στον κάδο απορριμμάτων; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί!", + "assets_restored_count": "Έγινε επαναφορά {count, plural, one {# στοιχείου} other {# στοιχείων}}", + "assets_trashed_count": "Μετακιν. στον κάδο απορριμάτων {count, plural, one {# στοιχείο} other {# στοιχεία}}", + "assets_were_part_of_album_count": "{count, plural, one {Το στοιχείο ανήκει} other {Τα στοιχεία ανήκουν}} ήδη στο άλμπουμ", + "authorized_devices": "Εξουσιοδοτημένες Συσκευές", + "back": "Πίσω", + "backward": "Προς τα πίσω", + "birthdate_saved": "Η ημερομηνία γέννησης αποθηκεύτηκε επιτυχώς", + "birthdate_set_description": "Η ημερομηνία γέννησης χρησιμοποιείται για τον υπολογισμό της ηλικίας αυτού του ατόμου, τη χρονική στιγμή μιας φωτογραφίας.", + "blurred_background": "Θολό φόντο", + "dismiss_error": "Παράβλεψη σφάλματος", + "display_options": "Επιλογές εμφάνισης", + "display_original_photos": "Εμφάνιση πρωτότυπων φωτογραφιών", + "do_not_show_again": "Να μην εμφανιστεί ξανά αυτό το μήνυμα", + "done": "Έγινε", + "download": "Λήψη", + "download_settings": "Λήψη", + "duplicates": "Διπλότυπα", + "duplicates_description": "Επιλύστε κάθε ομάδα υποδεικνύοντας ποιες είναι διπλότυπες, εάν υπάρχουν", + "duration": "Διάρκεια", + "edit": "Επεξεργασία", + "edit_album": "Επεξεργασία άλμπουμ", + "edit_avatar": "Επεξεργασία άβαταρ", + "edit_date": "Επεξεργασία ημερομηνίας", + "edit_date_and_time": "Επεξεργασία ημερομηνίας και ώρας", + "edit_faces": "Επεξεργασία προσώπων", + "edit_import_path": "Επεξεργασία διαδρομής εισαγωγής", + "edit_import_paths": "Επεξεργασία Διαδρομών Εισαγωγής", + "edit_link": "Επεξεργασία συνδέσμου", + "edit_location": "Επεξεργασία τοποθεσίας", + "edit_name": "Επεξεργασία ονόματος", + "edit_people": "Επεξεργασία ατόμων", + "edit_title": "Επεξεργασία Τίτλου", + "edit_user": "Επεξεργασία χρήστη", + "email": "Email", + "empty_trash": "Άδειασμα κάδου απορριμμάτων", + "enable": "Ενεργοποίηση", + "enabled": "Ενεργοποιημένο", + "error": "Σφάλμα", + "error_loading_image": "Σφάλμα κατά τη φόρτωση της εικόνας", + "error_title": "Σφάλμα - Κάτι πήγε στραβά", + "errors": { + "cannot_navigate_next_asset": "Δεν είναι δυνατή η πλοήγηση στο επόμενο στοιχείο", + "cannot_navigate_previous_asset": "Δεν είναι δυνατή η πλοήγηση στο προηγούμενο στοιχείο", + "cant_apply_changes": "Δεν είναι δυνατή η εφαρμογή αλλαγών" + }, + "jobs": "Εργασίες", + "keep": "Διατήρηση", + "keep_all": "Διατήρηση Όλων", + "keyboard_shortcuts": "Συντομεύσεις πληκτρολογίου", + "language": "Γλώσσα", + "language_setting_description": "Επιλέξτε τη γλώσσα που προτιμάτε", + "latest_version": "Τελευταία Έκδοση", + "latitude": "Γεωγραφικό πλάτος", + "level": "Επίπεδο", + "library": "Βιβλιοθήκη", + "library_options": "Επιλογές βιβλιοθήκης", + "link_options": "Επιλογές συνδέσμου", + "list": "Λίστα", + "loading": "Φόρτωση", + "loading_search_results_failed": "Η φόρτωση αποτελεσμάτων αναζήτησης απέτυχε", + "log_out": "Αποσύνδεση", + "log_out_all_devices": "Αποσύνδεση από Όλες τις Συσκευές", + "logged_out_all_devices": "Όλες οι συσκευές αποσυνδέθηκαν", + "logged_out_device": "Αποσυνδεδεμένη συσκευή", + "login": "Είσοδος", + "login_has_been_disabled": "Η σύνδεση έχει απενεργοποιηθεί.", + "logout_all_device_confirmation": "Είστε βέβαιοι ότι θέλετε να αποσυνδεθείτε από όλες τις συσκευές;", + "logout_this_device_confirmation": "Είστε βέβαιοι ότι θέλετε να αποσυνδεθείτε από αυτήν τη συσκευή;", + "longitude": "Γεωγραφικό μήκος", + "look": "Εμφάνιση", + "loop_videos": "Επανάληψη βίντεο", + "loop_videos_description": "Ενεργοποιήστε την αυτόματη επανάληψη ενός βίντεο στο πρόγραμμα προβολής λεπτομερειών.", + "make": "Κατασκευαστής", + "manage_shared_links": "Διαχείριση κοινόχρηστων συνδέσμων", + "manage_sharing_with_partners": "Διαχειριστείτε την κοινή χρήση με συνεργάτες", + "manage_the_app_settings": "Διαχειριστείτε τις ρυθμίσεις της εφαρμογής", + "manage_your_account": "Διαχειριστείτε τον λογαριασμό σας", + "manage_your_api_keys": "Διαχειριστείτε τα κλειδιά API", + "manage_your_devices": "Διαχειριστείτε τις συνδεδεμένες συσκευές σας", + "manage_your_oauth_connection": "Διαχειριστείτε τη σύνδεσή σας OAuth", + "map": "Χάρτης", + "map_marker_for_images": "Δείκτης χάρτη για εικόνες που τραβήχτηκαν σε {city}, {country}", + "map_marker_with_image": "Χάρτης δείκτη με εικόνα", + "map_settings": "Ρυθμίσεις χάρτη", + "matches": "Αντιστοιχίες", + "media_type": "Τύπος πολυμέσου", + "memories": "Αναμνήσεις", + "memories_setting_description": "Διαχειριστείτε τι θα εμφανίζεται στις αναμνήσεις σας", + "memory": "Ανάμνηση", + "menu": "Μενού", + "merge": "Συγχώνευση", + "merge_people": "Συγχώνευση ατόμων", + "merge_people_limit": "Μπορείτε να συγχωνεύσετε μόνο έως και 5 πρόσωπα τη φορά", + "merge_people_prompt": "Θέλετε να συγχωνεύσετε αυτά τα άτομα; Αυτή η ενέργεια είναι μη αναστρέψιμη.", + "merge_people_successfully": "Τα άτομα συγχωνεύθηκαν με επιτυχία", + "merged_people_count": "Έγινε συγχώνευση {count, plural, one {# ατόμου} other {# ατόμων}}", + "minimize": "Ελαχιστοποίηση", + "minute": "Λεπτό", + "missing": "Όσα Λείπουν", + "model": "Μοντέλο", + "month": "Μήνας", + "more": "Περισσότερα", + "moved_to_trash": "Μετακινήθηκε στον κάδο απορριμμάτων", + "my_albums": "Τα άλμπουμ μου", + "name": "Όνομα", + "name_or_nickname": "Όνομα ή ψευδώνυμο", + "never": "Ποτέ", + "new_album": "Νέο Άλμπουμ", + "new_api_key": "Νέο API Key", + "new_password": "Νέος κωδικός πρόσβασης", + "new_person": "Νέο άτομο", + "new_user_created": "Ο νέος χρήστης δημιουργήθηκε", + "new_version_available": "ΔΙΑΘΕΣΙΜΗ ΝΕΑ ΕΚΔΟΣΗ", + "newest_first": "Τα νεότερα πρώτα", + "next": "Επόμενο", + "next_memory": "Επόμενη ανάμνηση", + "no": "Όχι", + "no_albums_message": "Δημιουργήστε ένα άλμπουμ για να οργανώσετε τις φωτογραφίες και τα βίντεό σας", + "no_albums_with_name_yet": "Φαίνεται ότι δεν έχετε κανένα άλμπουμ με αυτό το όνομα ακόμα.", + "no_albums_yet": "Φαίνεται ότι δεν έχετε κανένα άλμπουμ ακόμα.", + "no_archived_assets_message": "Αρχειοθετήστε φωτογραφίες και βίντεο για να τα αποκρύψετε από την Προβολή Φωτογραφιών", + "no_assets_message": "ΚΑΝΤΕ ΚΛΙΚ ΓΙΑ ΝΑ ΑΝΕΒΑΣΕΤΕ ΤΗΝ ΠΡΩΤΗ ΣΑΣ ΦΩΤΟΓΡΑΦΙΑ", + "no_duplicates_found": "Δεν βρέθηκαν διπλότυπα.", + "no_exif_info_available": "Καμία πληροφορία exif διαθέσιμη", + "no_explore_results_message": "Ανεβάστε περισσότερες φωτογραφίες για να εξερευνήσετε τη συλλογή σας.", + "no_favorites_message": "Προσθέστε αγαπημένα για να βρείτε γρήγορα τις καλύτερες φωτογραφίες και τα βίντεό σας", + "no_libraries_message": "Δημιουργήστε μια εξωτερική βιβλιοθήκη για να προβάλετε τις φωτογραφίες και τα βίντεό σας", + "no_name": "Χωρίς Όνομα", + "no_results": "Κανένα αποτέλεσμα", + "no_results_description": "Δοκιμάστε ένα συνώνυμο ή πιο γενική λέξη-κλειδί", + "no_shared_albums_message": "Δημιουργήστε ένα άλμπουμ για να μοιράζεστε φωτογραφίες και βίντεο με άτομα στο δίκτυό σας", + "not_in_any_album": "Σε κανένα άλμπουμ", + "note_apply_storage_label_to_previously_uploaded assets": "Σημείωση: Για να εφαρμόσετε την Ετικέτα Αποθήκευσης σε στοιχεία που έχουν μεταφορτωθεί προηγουμένως, εκτελέστε το", + "note_unlimited_quota": "Σημείωση: Εισαγάγετε 0 για απεριόριστο όριο", + "notes": "Σημειώσεις", + "notification_toggle_setting_description": "Ενεργοποίηση ειδοποιήσεων μέσω email", + "notifications": "Ειδοποιήσεις", + "notifications_setting_description": "Διαχείριση ειδοποιήσεων", + "oauth": "OAuth", + "offline": "Εκτός σύνδεσης", + "offline_paths": "Διαδρομές εκτός σύνδεσης", + "offline_paths_description": "Αυτά τα αποτελέσματα μπορεί να οφείλονται στη μη αυτόματη διαγραφή αρχείων που δεν αποτελούν μέρος μιας εξωτερικής βιβλιοθήκης.", + "ok": "Έγινε", + "oldest_first": "Τα παλαιότερα πρώτα", + "onboarding_theme_description": "Επιλέξτε ένα θέμα χρώματος για το προφίλ σας. Μπορείτε να το αλλάξετε αργότερα στις ρυθμίσεις σας.", + "onboarding_welcome_description": "Ας ρυθμίσουμε το προφίλ σας με ορισμένες κοινές ρυθμίσεις.", + "onboarding_welcome_user": "Καλωσόρισες, {user}", + "online": "Σε σύνδεση", + "only_favorites": "Μόνο αγαπημένα", + "only_refreshes_modified_files": "Ανανεώνει μόνο τροποποιημένα αρχεία", + "open_in_map_view": "Άνοιγμα σε προβολή χάρτη", + "open_in_openstreetmap": "Άνοιγμα στο OpenStreetMap", + "open_the_search_filters": "Ανοίξτε τα φίλτρα αναζήτησης", + "options": "Επιλογές", + "or": "ή", + "organize_your_library": "Οργανώστε τη βιβλιοθήκη σας", + "original": "πρωτότυπο", + "other": "Άλλες", + "other_devices": "Άλλες συσκευές", + "other_variables": "Άλλες μεταβλητές", + "owned": "Δικά μου", + "owner": "Κάτοχος", + "partner": "Συνεργάτης", + "partner_can_access": "Ο χρήστης {partner} έχει πρόσβαση", + "partner_can_access_assets": "Όλες οι φωτογραφίες και τα βίντεό σας εκτός από αυτά που βρίσκονται στο Αρχείο και τα Διαγραμμένα", + "partner_can_access_location": "Η τοποθεσία όπου τραβήχτηκαν οι φωτογραφίες σας", + "partner_sharing": "Κοινή Χρήση Συνεργατών", + "partners": "Συνεργάτες", + "password": "Κωδικός Πρόσβασης", + "password_does_not_match": "Ο κωδικός πρόσβασης δεν ταιριάζει", + "password_required": "Απαιτείται Κωδικός Πρόσβασης", + "password_reset_success": "Επιτυχής επαναφορά κωδικού πρόσβασης", + "path": "Διαδρομή", + "pattern": "Μοτίβο", + "pause": "Πάυση", + "pause_memories": "Παύση αναμνήσεων", + "paused": "Σε Πάυση", + "pending": "Εκκρεμεί", + "people": "Άτομα", + "people_edits_count": "Έγινε επεξεργασία {count, plural, one {# ατόμου} other {# ατόμων}}", + "people_sidebar_description": "Εμφάνιση Ατόμων στην πλαϊνή γραμμή", + "permanent_deletion_warning": "Προειδοποίηση οριστικής διαγραφής", + "permanent_deletion_warning_setting_description": "Εμφάνιση προειδοποίησης κατά την οριστική διαγραφή στοιχείων", + "permanently_delete": "Οριστική διαγραφή", + "permanently_delete_assets_count": "Οριστική διαγραφή {count, plural, one {στοιχείου} other {στοιχείων}}", + "permanently_delete_assets_prompt": "Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά {count, plural, one {αυτό το στοιχείο;} other {αυτά τα # στοιχεία;}} Αυτό θα {count, plural, one {το} other {τα}} αφαιρέσει επίσης από τα άλμπουμ στα οποία {count, plural, one {ανήκει} other {ανήκουν}} .", + "permanently_deleted_asset": "Οριστικά διαγραμμένο στοιχείο", + "permanently_deleted_assets_count": "Οριστική διαγραφή {count, plural, one {# στοιχείου} other {# στοιχείων}}", + "person": "Άτομο", + "photo_shared_all_users": "Φαίνεται ότι μοιραστήκατε τις φωτογραφίες σας με όλους τους χρήστες ή δεν έχετε κανέναν χρήστη για κοινή χρήση.", + "photos": "Φωτογραφίες", + "photos_and_videos": "Φωτογραφίες & Βίντεο", + "photos_count": "{count, plural, one {{count, number} Φωτογραφία} other {{count, number} Φωτογραφίες}}", + "photos_from_previous_years": "Φωτογραφίες προηγούμενων ετών", + "pick_a_location": "Επιλέξτε μια τοποθεσία", + "place": "Τοποθεσία", + "places": "Τοποθεσίες", + "play": "Αναπαραγωγή", + "play_memories": "Αναπαραγωγή αναμνήσεων", + "play_motion_photo": "Αναπαραγωγή Κινούμενης Φωτογραφίας", + "play_or_pause_video": "Αναπαραγωγή ή παύση βίντεο", + "preview": "Προεπισκόπηση", + "previous": "Προηγούμενο", + "previous_memory": "Προηγούμενη ανάμνηση", + "previous_or_next_photo": "Προηγούμενη ή επόμενη φωτογραφία", + "profile_image_of_user": "Εικόνα προφίλ του χρήστη {user}", + "profile_picture_set": "Ορισμός εικόνας προφίλ.", + "public_album": "Δημόσιο άλμπουμ", + "public_share": "Δημόσια Κοινή Χρήση", + "purchase_account_info": "Υποστηρικτής", + "purchase_activated_subtitle": "Σας ευχαριστούμε για την υποστήριξη του Immich και λογισμικών ανοιχτού κώδικα", + "purchase_activated_time": "Ενεργοποιήθηκε στις {date, date}", + "purchase_activated_title": "Το κλειδί σας ενεργοποιήθηκε με επιτυχία", + "purchase_button_activate": "Ενεργοποίηση", + "purchase_button_buy": "Αγορά", + "purchase_button_buy_immich": "Αγορά Immich", + "purchase_button_never_show_again": "Να μην εμφανιστεί ποτέ ξανά", + "purchase_button_reminder": "Υπενθύμιση σε 30 μέρες", + "purchase_button_remove_key": "Αφαίρεση κλειδιού", + "purchase_button_select": "Επιλέξτε", + "purchase_failed_activation": "Η ενεργοποίηση απέτυχε! Ελέγξτε το email σας για το σωστό κλειδί προϊόντος!", + "purchase_individual_description_1": "Για ένα άτομο", + "purchase_individual_description_2": "Κατάσταση υποστηρικτή", + "purchase_individual_title": "Ατομο", + "purchase_input_suggestion": "Έχετε ένα κλειδί προϊόντος; Εισαγάγετε το κλειδί παρακάτω", + "purchase_license_subtitle": "Αγοράστε το Immich για να υποστηρίξετε τη συνεχή ανάπτυξη της υπηρεσίας", + "purchase_lifetime_description": "Αγορά εφ' όρου ζωής", + "purchase_option_title": "ΕΠΙΛΟΓΕΣ ΑΓΟΡΑΣ", + "purchase_panel_info_1": "Η ανάπτυξη του Immich απαιτεί πολύ χρόνο και προσπάθεια, και έχουμε μηχανικούς πλήρους απασχόλησης που εργάζονται σε αυτό για να το κάνουμε όσο το δυνατόν καλύτερο. Η αποστολή μας είναι το λογισμικό ανοιχτού κώδικα και οι ηθικές επιχειρηματικές πρακτικές να γίνουν βιώσιμη πηγή εισοδήματος για προγραμματιστές και να δημιουργήσουμε ένα οικοσύστημα που σέβεται το απόρρητο, με πραγματικές εναλλακτικές λύσεις στις υπηρεσίες cloud που παρουσιάζουν συμπεριφορές εκμετάλλευσης.", + "purchase_panel_info_2": "Καθώς δεσμευόμαστε να μην προσθέσουμε φραγμούς με σκοπό το κέρδος, αυτή η αγορά δεν θα σας προσφέρει πρόσθετες δυνατότητες στο Immich. Βασιζόμαστε σε χρήστες όπως εσείς για την υποστήριξη της συνεχούς ανάπτυξης του Immich.", + "purchase_panel_title": "Υποστηρίξτε το πρότζεκτ", + "purchase_per_server": "Ανά διακομιστή", + "purchase_per_user": "Ανά χρήστη", + "purchase_remove_product_key": "Κατάργηση κλειδιού προϊόντος", + "purchase_remove_product_key_prompt": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τον αριθμό-κλειδί προϊόντος;", + "purchase_remove_server_product_key": "Κατάργηση κλειδιού προϊόντος διακομιστή", + "purchase_remove_server_product_key_prompt": "Είστε βέβαιοι ότι θέλετε να καταργήσετε το κλειδί προϊόντος διακομιστή;", + "purchase_server_description_1": "Για ολόκληρο τον διακομιστή", + "purchase_server_description_2": "Κατάσταση υποστηρικτή", + "purchase_server_title": "Διακομιστής", + "purchase_settings_server_activated": "Η διαχείριση του κλειδιού προϊόντος του διακομιστή γίνεται από τον διαχειριστή", + "reaction_options": "Επιλογές αντίδρασης", + "read_changelog": "Διαβάστε το Αρχείο Καταγραφής Αλλαγών", + "restore_user": "Επαναφορά χρήστη", + "retry_upload": "Επανάληψη ανεβάσματος", + "review_duplicates": "Προβολή διπλότυπων", + "save": "Αποθήκευση", + "saved_profile": "Αποθηκευμένο προφίλ", + "saved_settings": "Αποθηκευμένες ρυθμίσεις", + "say_something": "Πείτε κάτι", + "scan_all_libraries": "Σάρωση Όλων των Βιβλιοθηκών", + "scan_new_library_files": "Σάρωση Νέων Αρχείων Βιβλιοθήκης", + "scan_settings": "Ρυθμίσεις Σάρωσης", + "scanning_for_album": "Σάρωση για άλμπουμ...", + "search": "Αναζήτηση", + "search_albums": "Αναζήτηση άλμπουμ", + "search_by_filename": "Αναζήτηση βάσει ονόματος αρχείου ή επέκτασης αρχείου", + "search_by_filename_example": "π.χ. IMG_1234.JPG ή PNG", + "search_camera_make": "Αναζήτηση κατασκευαστή κάμερας...", + "search_camera_model": "Αναζήτηση μοντέλου κάμερας...", + "search_city": "Αναζήτηση πόλης...", + "search_country": "Αναζήτηση χώρας...", + "search_for_existing_person": "Αναζήτηση υπάρχοντος ατόμου", + "search_no_people": "Κανένα άτομο", + "search_no_people_named": "Κανένα άτομο με όνομα \"{name}\"", + "search_people": "Αναζήτηση ατόμων", + "search_places": "Αναζήτηση τοποθεσιών", + "search_state": "Αναζήτηση νομού...", + "search_timezone": "Αναζήτηση ζώνης ώρας...", + "search_type": "Τύπος αναζήτησης", + "search_your_photos": "Αναζήτηση φωτογραφιών", + "second": "Δευτερόλεπτο", + "see_all_people": "Προβολή όλων των ατόμων", + "select_album_cover": "Επιλέξτε εξώφυλλο άλμπουμ", + "select_all": "Επιλογή όλων", + "select_all_duplicates": "Επιλογή όλων των διπλότυπων", + "select_avatar_color": "Επιλέξτε χρώμα avatar", + "select_face": "Επιλογή προσώπου", + "select_from_computer": "Επιλέξτε από υπολογιστή", + "select_keep_all": "Επιλέξτε διατήρηση όλων", + "select_library_owner": "Επιλέξτε κάτοχο βιβλιοθήκης", + "select_new_face": "Επιλέξτε νέο πρόσωπο", + "select_photos": "Επιλέξτε φωτογραφίες", + "select_trash_all": "Επιλέξτε διαγραφή όλων", + "selected": "Επιλεγμένοι", + "selected_count": "{count, plural, other {# επιλεγμένοι}}", + "send_message": "Αποστολή μηνύματος", + "send_welcome_email": "Αποστολή email καλωσορίσματος", + "server_offline": "Διακομιστής Εκτός Σύνδεσης", + "server_online": "Διακομιστής Σε Σύνδεση", + "server_stats": "Στατιστικά Διακομιστή", + "server_version": "Έκδοση Διακομιστή", + "set": "Ορισμός", + "set_as_album_cover": "Ορισμός ως εξώφυλλο άλμπουμ", + "set_as_profile_picture": "Ορισμός ως εικόνα προφίλ", + "set_date_of_birth": "Ορισμός ημερομηνίας γέννησης", + "set_profile_picture": "Ορισμός εικόνας προφίλ", + "settings": "Ρυθμίσεις", + "settings_saved": "Οι ρυθμίσεις αποθηκεύτηκαν", + "share": "Κοινοποίηση", + "shared": "Σε κοινή χρήση", + "shared_by": "Σε κοινή χρήση από", + "shared_by_user": "Σε κοινή χρήση από {user}", + "shared_by_you": "Σε κοινή χρήση από εσάς", + "shared_from_partner": "Φωτογραφίες από {partner}", + "shared_links": "Κοινόχρηστοι σύνδεσμοι", + "shared_photos_and_videos_count": "{assetCount, plural, other {# κοινόχρηστες φωτογραφίες & βίντεο.}}", + "shared_with_partner": "Σε κοινή χρήση με {partner}", + "sharing": "Κοινοποίηση", + "sharing_enter_password": "Εισαγάγετε τον κωδικό πρόσβασης για να δείτε αυτήν τη σελίδα.", + "sharing_sidebar_description": "Εμφανίστε έναν σύνδεσμο για Κοινή χρήση στην πλαϊνή γραμμή", + "shift_to_permanent_delete": "πατήστε ⇧ για οριστική διαγραφή στοιχείου", + "show_album_options": "Εμφάνιση επιλογών άλμπουμ", + "show_all_people": "Προβολή όλων των ατόμων", + "show_and_hide_people": "Εμφάνιση & απόκρυψη ατόμων", + "show_file_location": "Εμφάνιση θέσης αρχείου", + "show_gallery": "Εμφάνιση γκαλερί", + "show_hidden_people": "Εμφάνιση κρυμμένων ατόμων", + "show_in_timeline": "Εμφάνιση στο χρονολόγιο", + "show_in_timeline_setting_description": "Εμφάνιση φωτογραφιών και βίντεο από αυτόν τον χρήστη στο χρονολόγιό σας", + "show_keyboard_shortcuts": "Εμφάνιση συντομεύσεων πληκτρολογίου", + "show_metadata": "Εμφάνιση μεταδεδομένων", + "show_or_hide_info": "Εμφάνιση ή απόκρυψη πληροφοριών", + "show_password": "Εμφάνιση κωδικού", + "show_person_options": "Εμφάνιση επιλογών ατόμου", + "show_progress_bar": "Εμφάνιση γραμμής προόδου", + "show_search_options": "Εμφάνιση επιλογών αναζήτησης", + "show_supporter_badge": "Σήμα υποστηρικτή", + "show_supporter_badge_description": "Εμφάνιση σήματος υποστηρικτή", + "shuffle": "Ανάμειξη", + "sign_out": "Αποσύνδεση", + "sign_up": "Εγγραφή", + "size": "Μέγεθος", + "skip_to_content": "Μετάβαση στο περιεχόμενο", + "slideshow": "Παρουσίαση", + "slideshow_settings": "Ρυθμίσεις παρουσίασης", + "sort_albums_by": "Ταξινόμηση άλμπουμ κατά...", + "sort_created": "Ημερομηνία Δημιουργίας", + "sort_items": "Αριθμός αντικειμένων", + "sort_modified": "Ημερομηνία τροποποίησης", + "sort_oldest": "Η πιο παλιά φωτογραφία", + "sort_recent": "Η πιο πρόσφατη φωτογραφία", + "sort_title": "Τίτλος", + "source": "Πηγή", + "start_date": "Από", + "state": "Νομός", + "status": "Κατάσταση", + "stop_photo_sharing": "Διακοπή κοινής χρήσης των φωτογραφιών σας;", + "stop_photo_sharing_description": "Ο χρήστης {partner} δεν θα έχει πλέον πρόσβαση στις φωτογραφίες σας.", + "stop_sharing_photos_with_user": "Διακοπή κοινής χρήσης των φωτογραφιών σας με αυτό το χρήστη", + "storage": "Χώρος αποθήκευσης", + "storage_label": "Ετικέτα αποθήκευσης", + "storage_usage": "{used} από {available} σε χρήση", + "submit": "Υποβολή", + "suggestions": "Προτάσεις", + "sunrise_on_the_beach": "Ηλιοβασίλεμα στην παραλία", + "swap_merge_direction": "Εναλλαγή κατεύθυνσης συγχώνευσης", + "sync": "Συγχρονισμός", + "template": "Πρότυπο", + "theme": "Θέμα", + "theme_selection": "Επιλογή θέματος", + "theme_selection_description": "Ρυθμίστε αυτόματα το θέμα σε ανοιχτό ή σκούρο με βάση τις προτιμήσεις συστήματος του προγράμματος περιήγησής σας", + "they_will_be_merged_together": "Θα συγχωνευθούν μαζί", + "time_based_memories": "Μνήμες βασισμένες στο χρόνο", + "timezone": "Ζώνη ώρας", + "to_archive": "Αρχειοθέτηση", + "to_change_password": "Αλλαγή κωδικού πρόσβασης", + "to_favorite": "Αγαπημένο", + "to_login": "Είσοδος", + "to_trash": "Κάδος απορριμμάτων", + "toggle_settings": "Εναλλαγή ρυθμίσεων", + "toggle_theme": "Εναλλαγή θέματος", + "total_usage": "Συνολική χρήση", + "trash": "Κάδος απορριμμάτων", + "trash_all": "Διαγραφή Όλων", + "trash_count": "Διαγραφή {count, number}", + "trash_delete_asset": "Διαγραφή/Οριστ. Διαγραφή Αντικειμένου", + "trash_no_results_message": "Οι φωτογραφίες και τα βίντεο που βρίσκονται στον κάδο απορριμμάτων θα εμφανίζονται εδώ.", + "trashed_items_will_be_permanently_deleted_after": "Τα στοιχεία που βρίσκονται στον κάδο απορριμμάτων θα διαγραφούν οριστικά μετά από {days, plural, one {# ημέρα} other {# ημέρες}}.", + "unarchive": "Αναίρεση αρχειοθέτησης", + "unarchived_count": "{count, plural, other {Αρχειοθετήσεις αναιρέθηκαν #}}", + "unfavorite": "Αφαίρεση από τα αγαπημένα", + "unhide_person": "Αναίρεση απόκρυψης ατόμου", + "unknown": "Άγνωστο", + "unknown_year": "Άγνωστο Έτος", + "unlimited": "Απεριόριστο", + "unlink_oauth": "Αποσύνδεση OAuth", + "unlinked_oauth_account": "Ο λογαριασμός OAuth αποσυνδέθηκε", + "unnamed_album": "Ανώνυμο Άλμπουμ", + "unnamed_share": "Ανώνυμη Κοινή Χρήση", + "unsaved_change": "Μη αποθηκευμένη αλλαγή", + "unselect_all": "Αποεπιλογή όλων", + "unselect_all_duplicates": "Αποεπιλογή όλων των διπλότυπων", + "untracked_files": "Μη παρακολουθούμενα αρχεία", + "untracked_files_decription": "Αυτά τα αρχεία δεν παρακολουθούνται από την εφαρμογή. Μπορεί να είναι αποτελέσματα αποτυχημένων μετακινήσεων, αποτυχημένες μεταφορτώσεις ή εναπομείναντα λόγω σφάλματος", + "updated_password": "Ο κωδικός πρόσβασης ενημερώθηκε", + "upload": "Μεταφόρτωση", + "upload_errors": "Η μεταφόρτωση ολοκληρώθηκε με {count, plural, one {# σφάλμα} other {# σφάλματα}}, ανανεώστε τη σελίδα για να δείτε νέα στοιχεία μεταφόρτωσης.", + "upload_progress": "Απομένουν {remaining, number} - Ολοκληρώθηκαν {processed, number}/{total, number}", + "upload_skipped_duplicates": "Παραλείφθηκαν {count, plural, one {# διπλότυπο στοιχείο} other {# διπλότυπα στοιχεία}}", + "upload_status_duplicates": "Διπλότυπα", + "upload_status_errors": "Σφάλματα", + "upload_status_uploaded": "Μεταφορτώθηκαν", + "upload_success": "Η μεταφόρτωση ολοκληρώθηκε, ανανεώστε τη σελίδα για να δείτε τα νέα αντικείμενα.", + "url": "URL", + "usage": "Χρήση", + "use_custom_date_range": "Χρήση προσαρμοσμένου εύρους ημερομηνιών", + "user": "Χρήστης", + "user_id": "ID Χρήστη", + "user_liked": "Στο χρήστη {user} αρέσει {type, select, photo {αυτή η φωτογραφία} video {αυτό το βίντεο} asset {αυτό το αντικείμενο} other {it}}", + "user_purchase_settings": "Αγορά", + "user_purchase_settings_description": "Διαχείριση Αγοράς", + "user_role_set": "Ορισμός {user} ως {role}", + "username": "Όνομα Χρήστη", + "users": "Χρήστες", + "utilities": "Βοηθητικά προγράμματα", + "validate": "Επικύρωση", + "variables": "Μεταβλητές", + "version": "Έκδοση", + "version_announcement_closing": "Ο φίλος σου, Alex", + "version_announcement_message": "Γεια σου φίλε, υπάρχει μια νέα έκδοση της εφαρμογής, αφιέρωσε λίγο χρόνο για να επισκεφθείς την τοποθεσία release notes και να βεβαιωθείς ότι τα docker-compose.yml, και .env είναι ενημερωμένα για την αποτροπή τυχόν εσφαλμένων διαμορφώσεων, ειδικά εάν χρησιμοποιείτε το WatchTower ή οποιονδήποτε μηχανισμό που χειρίζεται την αυτόματη ενημέρωση της εφαρμογής σας.", + "video": "Βίντεο", + "video_hover_setting": "Προεπισκόπηση βίντεο με το δείκτη του ποντικιού", + "video_hover_setting_description": "Προεπισκόπηση βίντεο όταν το ποντίκι βρίσκεται πάνω από το στοιχείο. Ακόμη και όταν είναι απενεργοποιημένη, η αναπαραγωγή μπορεί να ξεκινήσει τοποθετώντας το δείκτη του ποντικιού πάνω από το εικονίδιο αναπαραγωγής.", + "videos": "Βίντεο", + "videos_count": "{count, plural, one {# Βίντεο} other {# Βίντεο}}", + "view": "Προβολή", + "view_album": "Προβολή Άλμπουμ", + "view_all": "Προβολή Όλων", + "view_all_users": "Προβολή όλων των χρηστών", + "view_links": "Προβολή συνδέσμων", + "view_next_asset": "Προβολή επόμενου στοιχείου", + "view_previous_asset": "Προβολή προηγούμενου στοιχείου", + "visibility_changed": "Η ορατότητα άλλαξε για {count, plural, one {# άτομο} other {# άτομα}}", + "waiting": "Σε αναμονή", + "warning": "Προειδοποίηση", + "week": "Εβδομάδα", + "welcome": "Καλωσορίσατε", + "welcome_to_immich": "Καλωσορίσατε στο immich", + "year": "Έτος", + "years_ago": "πριν από {years, plural, one {# χρόνο} other {# χρόνια}}", + "yes": "Ναι", + "you_dont_have_any_shared_links": "Δεν έχετε κοινόχρηστους συνδέσμους", + "zoom_image": "Ζουμ Εικόνας" +} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index eaf5ffc1a4..f424e60a66 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -276,7 +276,7 @@ "transcoding_preferred_hardware_device": "Preferred hardware device", "transcoding_preferred_hardware_device_description": "Applies only to VAAPI and QSV. Sets the dri node used for hardware transcoding.", "transcoding_preset_preset": "Preset (-preset)", - "transcoding_preset_preset_description": "Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above `faster`.", + "transcoding_preset_preset_description": "Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above 'faster'.", "transcoding_reference_frames": "Reference frames", "transcoding_reference_frames_description": "The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically.", "transcoding_required_description": "Only videos not in an accepted format", diff --git a/web/src/lib/i18n/es.json b/web/src/lib/i18n/es.json index c7abc16758..1b4aebe804 100644 --- a/web/src/lib/i18n/es.json +++ b/web/src/lib/i18n/es.json @@ -2,12 +2,12 @@ "about": "Acerca de", "account": "Cuenta", "account_settings": "Ajustes de la cuenta", - "acknowledge": "Acuerdo", + "acknowledge": "De acuerdo", "action": "Acción", "actions": "Acciones", "active": "Activo", "activity": "Actividad", - "activity_changed": "Actividad es {enabled, select, true {enabled} other {disabled}}", + "activity_changed": "La actividad {enabled, select, true {activada} other {desactivada}}", "add": "Añadir", "add_a_description": "Añadir una descripción", "add_a_location": "Añadir una ubicación", @@ -28,12 +28,12 @@ "added_to_favorites_count": "Añadido {count, number} a favoritos", "admin": { "add_exclusion_pattern_description": "Añade patrones de exclusión. Puedes utilizar los caracteres *, ** y ? (globbing). Para ignorar los archivos en cualquier ruta llamada \"Raw\", utiliza \"**/Raw/**\". Para ignorar todos los archivos que terminan en \".tif\", utiliza \"**/*.tif\". Para ignorar una ruta desde la raíz, utiliza \"/carpeta/a/ignorar/**\".", - "authentication_settings": "Configuración de Autenticación", + "authentication_settings": "Configuración de autenticación", "authentication_settings_description": "Gestionar clave, Oauth y otros configuraciones de autenticación", "authentication_settings_disable_all": "¿Estás seguro de que deseas desactivar todos los métodos de inicio de sesión? Se desactivará el inicio de sesión.", "authentication_settings_reenable": "Para volver a habilitar, utilice un Comando del servidor .", "background_task_job": "Tareas en segundo plano", - "check_all": "Comprobar Todo", + "check_all": "Comprobar todo", "cleared_jobs": "Trabajos realizados para: {job}", "config_set_by_file": "La configuración está fijada actualmente en base a un archivo", "confirm_delete_library": "¿Estás seguro de que quieres eliminar la biblioteca {library}?", @@ -47,13 +47,13 @@ "duplicate_detection_job_description": "Lanza el aprendizaje automático para detectar imágenes similares. Necesita que esté activa la Búsqueda Inteligente", "exclusion_pattern_description": "Los patrones de exclusión te permiten ignorar archivos y carpetas al escanear tu biblioteca. Esto es útil hay carpetas que contienen archivos que no quieres importar (por ejemplo los ficheros RAW).", "external_library_created_at": "Biblioteca externa (creado el {date})", - "external_library_management": "Gestión de Biblioteca Externa", + "external_library_management": "Gestión de bibliotecas externas", "face_detection": "Detección de caras", "face_detection_description": "Detecta las caras usando aprendizaje automático. Para los vídeos sólo se tiene en cuenta la imagen de previsualización. \"Todo\" implica volver a procesar todos los elementos. \"Missing\" pone en la cola los elementos que aún no han sido procesados. Las caras detectadas serán añadidas a la cola para ser procesadas posteriormente mediante Reconocimiento Facial y agrupadas en las personas que ya existan o en nuevas personas detectadas.", "facial_recognition_job_description": "Agrupa las caras detectadas en las personas. Este paso se lanza tras las Detección de Caras. \"All\" reagrupa todas las caras. \"Pendiente\" añade a la colas aquellas caras que no fueron asignadas a ninguna persona.", "failed_job_command": "El comando {command} ha fallado para la tarea: {job}", "force_delete_user_warning": "CUIDADO: Esta acción eliminará inmediatamente el usuario y los elementos. Esta accion no se puede deshacer y los archivos no pueden ser recuperados.", - "forcing_refresh_library_files": "Forzar actualización de todos los archivos en las bibliotecas", + "forcing_refresh_library_files": "Forzar la recarga de todos los archivos de la biblioteca", "image_format_description": "WebP genera archivos más pequeños que JPEG, pero es más lento al codificar.", "image_prefer_embedded_preview": "Preferir vista previa incrustada", "image_prefer_embedded_preview_setting_description": "Usar vistas previas incrustadas en fotos RAW como entrada para el procesamiento de imágenes cuando estén disponibles. Esto puede producir colores más precisos en algunas imágenes, pero la calidad de la vista previa depende de la cámara y la imagen puede tener más artefactos de compresión.", @@ -73,9 +73,9 @@ "job_not_concurrency_safe": "Esta tarea no es segura para la simultaneidad.", "job_settings": "Configuración tareas", "job_settings_description": "Administrar tareas simultáneas", - "job_status": "Estado de la Tarea", - "jobs_delayed": "{jobCount, plural, other {# delayed}}", - "jobs_failed": "{jobCount, plural, other {# failed}}", + "job_status": "Estado de la tarea", + "jobs_delayed": "{jobCount, plural, one {# retrasado} other {# retrasados}}", + "jobs_failed": "{jobCount, plural, one {# fallido} other {# fallidos}}", "library_created": "La biblioteca ha sido creada: {library}", "library_cron_expression": "Expresión cron", "library_cron_expression_description": "Establece el intervalo de escaneo utilizando el formato cron. Para más información puede consultar, por ejemplo, Crontab Guru", @@ -85,7 +85,7 @@ "library_scanning": "Escaneado periódico", "library_scanning_description": "Configura el escaneo periódico de la biblioteca", "library_scanning_enable_description": "Activar el escaneo periódico de la biblioteca", - "library_settings": "Biblioteca Externa", + "library_settings": "Biblioteca externa", "library_settings_description": "Administrar configuración biblioteca externa", "library_tasks_description": "Realizar tareas de biblioteca", "library_watching_enable_description": "Ver las bibliotecas externas para detectar cambios en los archivos", @@ -176,7 +176,7 @@ "oauth_mobile_redirect_uri_override_description": "Habilítelo cuando 'app.immich:/' sea un URI de redireccionamiento no válido.", "oauth_profile_signing_algorithm": "Algoritmo de firma de perfiles", "oauth_profile_signing_algorithm_description": "Algoritmo utilizado para firmar el perfil del usuario.", - "oauth_scope": "Scope", + "oauth_scope": "Ámbito", "oauth_settings": "OAuth", "oauth_settings_description": "Administrar la configuración de inicio de sesión de OAuth", "oauth_settings_more_details": "Para más detalles acerca de esta característica, consulte la documentación.", @@ -187,19 +187,19 @@ "oauth_storage_quota_claim_description": "Establezca automáticamente la cuota de almacenamiento del usuario al valor de esta solicitud.", "oauth_storage_quota_default": "Cuota de almacenamiento predeterminada (GiB)", "oauth_storage_quota_default_description": "Cuota en GiB que se utilizará cuando no se proporcione ninguna por defecto (ingrese 0 para una cuota ilimitada).", - "offline_paths": "Carpetas sin conexión", + "offline_paths": "Rutas sin conexión", "offline_paths_description": "Estos resultados pueden deberse al eliminar manualmente archivos que no son parte de una biblioteca externa.", "password_enable_description": "Iniciar sesión con correo electrónico y contraseña", "password_settings": "Contraseña de Acceso", "password_settings_description": "Administrar la configuración de inicio de sesión con contraseña", "paths_validated_successfully": "Todas las carpetas se han validado satisfactoriamente", "quota_size_gib": "Tamaño de Quota (GiB)", - "refreshing_all_libraries": "Actualizando todas las bibliotecas", - "registration": "Registrar Administrador", - "registration_description": "Dado que usted es el primer usuario del sistema, se le asignará como administrador y será responsable de las tareas administrativas, y usted creará usuarios adicionales.", - "removing_offline_files": "Eliminando los archivos offline", - "repair_all": "Reparar Todo", - "repair_matched_items": "Coincidencia {count, plural, one {# item} other {# items}}", + "refreshing_all_libraries": "Recargando todas las bibliotecas", + "registration": "Registrar administrador", + "registration_description": "Dado que eres el primer usuario del sistema, se te asignará como Admin y serás responsable de las tareas administrativas, y de crear a los usuarios adicionales.", + "removing_offline_files": "Eliminando archivos sin conexión", + "repair_all": "Reparar todo", + "repair_matched_items": "Coincidencia {count, plural, one {# elemento} other {# elementos}}", "repaired_items": "Reparado {count, plural, one {# elemento} other {# elementos}}", "require_password_change_on_login": "Requerir que el usuario cambie la contraseña en el primer inicio de sesión", "reset_settings_to_default": "Restablecer la configuración predeterminada", @@ -278,7 +278,7 @@ "transcoding_preferred_hardware_device": "Dispositivo de hardware preferido", "transcoding_preferred_hardware_device_description": "Se aplica únicamente a VAAPI y QSV. Establece el nodo dri utilizado para la transcodificación de hardware.", "transcoding_preset_preset": "Configuración predefinida (-preset)", - "transcoding_preset_preset_description": "Velocidad de compresión. Los ajustes preestablecidos más lentos producen archivos más pequeños y aumentan la calidad cuando se apunta a una determinada tasa de bits. VP9 ignora las velocidades superiores a \"más rápidas\".", + "transcoding_preset_preset_description": "Velocidad de compresión. Los preajustes más lentos producen archivos más pequeños, y aumentan la calidad cuando se apunta a una determinada tasa de bits. VP9 ignora las velocidades superiores a 'más rápido'.", "transcoding_reference_frames": "Frames de referencia", "transcoding_reference_frames_description": "El número de fotogramas a los que hacer referencia al comprimir un fotograma determinado. Los valores más altos mejoran la eficiencia de la compresión, pero ralentizan la codificación. 0 establece este valor automáticamente.", "transcoding_required_description": "Sólo vídeos que no estén en un formato soportado", @@ -286,7 +286,7 @@ "transcoding_settings_description": "Administrar la resolución y la información de codificación de los archivos de video", "transcoding_target_resolution": "Resolución deseada", "transcoding_target_resolution_description": "Las resoluciones más altas pueden conservar más detalles, pero la codificación tarda más, tienen tamaños de archivo más grandes y pueden reducir la capacidad de respuesta de la aplicación.", - "transcoding_temporal_aq": "Temporal AQ", + "transcoding_temporal_aq": "AQ temporal", "transcoding_temporal_aq_description": "Se aplica únicamente a NVENC. Aumenta la calidad de escenas con mucho detalle y poco movimiento. Puede que no sea compatible con dispositivos más antiguos.", "transcoding_threads": "Hilos", "transcoding_threads_description": "Los valores más altos conducen a una codificación más rápida, pero dejan menos espacio para que el servidor procese otras tareas mientras está activo. Este valor no debe ser mayor que la cantidad de núcleos de CPU. Maximiza la utilización si se establece en 0.", @@ -332,7 +332,7 @@ "advanced": "Avanzada", "age_months": "Tiempo {months, plural, one {# month} other {# months}}", "age_year_months": "1 año, {months, plural, one {# month} other {# months}}", - "age_years": "{years, plural, other {Age #}}", + "age_years": "Edad {years, plural, one {# año} other {# años}}", "album_added": "Álbum añadido", "album_added_notification_setting_description": "Reciba una notificación por correo electrónico cuando lo agreguen a un álbum compartido", "album_cover_updated": "Portada del álbum actualizada", @@ -347,10 +347,10 @@ "album_share_no_users": "Parece que has compartido este álbum con todos los usuarios o no tienes ningún usuario con quien compartirlo.", "album_updated": "Album actualizado", "album_updated_setting_description": "Reciba una notificación por correo electrónico cuando un álbum compartido tenga nuevos archivos", - "album_user_left": "Izquierda {album}", + "album_user_left": "Salida {album}", "album_user_removed": "Eliminado a {user}", "album_with_link_access": "Permita que cualquier persona con el enlace vea fotos y personas en este álbum.", - "albums": "Albums", + "albums": "Álbumes", "albums_count": "{count, plural, one {{count, number} Álbum} other {{count, number} Álbumes}}", "all": "Todos", "all_albums": "Todos los albums", @@ -371,7 +371,7 @@ "archive_size": "Tamaño de archivo", "archive_size_description": "Configure el tamaño del archivo para descargas (en GB)", "archived": "Archivado", - "archived_count": "{count, plural, other {Archived #}}", + "archived_count": "{count, plural, one {# archivado} other {# archivados}}", "are_these_the_same_person": "¿Son la misma persona?", "are_you_sure_to_do_this": "¿Estas seguro de que quieres hacer esto?", "asset_added_to_album": "Añadido al álbum", @@ -389,7 +389,7 @@ "assets_added_count": "Añadido {count, plural, one {# asset} other {# assets}}", "assets_added_to_album_count": "Añadido {count, plural, one {# asset} other {# assets}} al álbum", "assets_added_to_name_count": "Añadido {count, plural, one {# asset} other {# assets}} a {hasName, select, true {{name}} other {new album}}", - "assets_count": "{count, plural, one {# asset} other {# assets}}", + "assets_count": "{count, plural, one {# activo} other {# activos}}", "assets_moved_to_trash": "Se movió {count, plural, one {# activo} other {# activos}} a la papelera", "assets_moved_to_trash_count": "Movido {count, plural, one {# asset} other {# assets}} a la papelera", "assets_permanently_deleted_count": "Eliminado permanentemente {count, plural, one {# asset} other {# assets}}", @@ -776,7 +776,7 @@ }, "invite_people": "Invitar a Personas", "invite_to_album": "Invitar al álbum", - "items_count": "{count, plural, one {# item} other {# items}}", + "items_count": "{count, plural, one {# elemento} other {# elementos}}", "job_settings_description": "", "jobs": "Tareas", "keep": "Conservar", @@ -918,6 +918,7 @@ "online": "En línea", "only_favorites": "Solo favoritos", "only_refreshes_modified_files": "Solo actualiza los archivos modificados", + "open_in_map_view": "Abrir en la vista del mapa", "open_in_openstreetmap": "Abrir en OpenStreetMap", "open_the_search_filters": "Abre los filtros de búsqueda", "options": "Opciones", @@ -963,7 +964,7 @@ "permanently_deleted_assets": "Eliminado permanentemente {count, plural, one {# activo} other {# activos}}", "permanently_deleted_assets_count": "Eliminado permanentemente {count, plural, one {# asset} other {# assets}}", "person": "Persona", - "person_hidden": "{name}{hidden, select, true { (hidden)} other {}}", + "person_hidden": "{name}{hidden, select, true { (oculto)} other {}}", "photo_shared_all_users": "Parece que compartiste tus fotos con todos los usuarios o no tienes ningún usuario con quien compartirlas.", "photos": "Fotos", "photos_and_videos": "Fotos y Videos", @@ -1030,15 +1031,15 @@ "reassing_hint": "Asignar archivos seleccionados a una persona existente", "recent": "Reciente", "recent_searches": "Búsquedas recientes", - "refresh": "Actualizar", - "refresh_encoded_videos": "Actualizar vídeos codificados", - "refresh_metadata": "Actualizar metadatos", - "refresh_thumbnails": "Actualizar miniaturas", - "refreshed": "Actualizado", - "refreshes_every_file": "Actualiza cada archivo", - "refreshing_encoded_video": "Actualizando videos codificados", - "refreshing_metadata": "Actualizando metadatos", - "regenerating_thumbnails": "Actualizando miniaturas", + "refresh": "Recargar", + "refresh_encoded_videos": "Recargar los vídeos codificados", + "refresh_metadata": "Recargar los metadatos", + "refresh_thumbnails": "Recargar miniaturas", + "refreshed": "Recargado", + "refreshes_every_file": "Recargar cada archivo", + "refreshing_encoded_video": "Recargando los videos codificados", + "refreshing_metadata": "Recargando metadatos", + "regenerating_thumbnails": "Recargando miniaturas", "remove": "Eliminar", "remove_assets_album_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# asset} other {# assets}} del álbum?", "remove_assets_shared_link_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# asset} other {# assets}} del enlace compartido?", @@ -1121,7 +1122,7 @@ "select_photos": "Seleccionar Fotos", "select_trash_all": "Enviar la selección a la papelera", "selected": "Seleccionado", - "selected_count": "{count, plural, other {# selected}}", + "selected_count": "{count, plural, one {# seleccionado} other {# seleccionados}}", "send_message": "Enviar mensaje", "send_welcome_email": "Enviar correo de bienvenida", "server": "Servidor", @@ -1151,6 +1152,7 @@ "sharing_sidebar_description": "Muestra un enlace a \"Compartido\" en el menú lateral", "shift_to_permanent_delete": "presiona ⇧ para eliminar permanentemente el archivo", "show_album_options": "Mostrar ajustes del álbum", + "show_albums": "Mostrar álbumes", "show_all_people": "Mostrar todas las personas", "show_and_hide_people": "Mostrar y ocultar personas", "show_file_location": "Mostrar carpeta del archivo", @@ -1183,6 +1185,8 @@ "sort_title": "Título", "source": "Fuente", "stack": "Apilar", + "stack_duplicates": "Apilar duplicados", + "stack_select_one_photo": "Selecciona una imagen principal para la pila", "stack_selected_photos": "Apilar fotos seleccionadas", "stacked_assets_count": "Apilados {count, plural, one {# asset} other {# assets}}", "stacktrace": "Stacktrace", @@ -1227,7 +1231,7 @@ "type": "Tipo", "unarchive": "Desarchivar", "unarchived": "Restaurado", - "unarchived_count": "{count, plural, other {Unarchived #}}", + "unarchived_count": "{count, plural, one {# No archivado} other {# No archivados}}", "unfavorite": "Retirar favorito", "unhide_person": "Mostrar persona", "unknown": "Desconocido", diff --git a/web/src/lib/i18n/fa.json b/web/src/lib/i18n/fa.json index f410cfb14e..2c297ce36e 100644 --- a/web/src/lib/i18n/fa.json +++ b/web/src/lib/i18n/fa.json @@ -169,7 +169,7 @@ "oauth_enable_description": "ورود توسط OAuth", "oauth_issuer_url": "نشانی وب صادر کننده", "oauth_mobile_redirect_uri": "تغییر مسیر URI موبایل", - "oauth_mobile_redirect_uri_override": "", + "oauth_mobile_redirect_uri_override": "تغییر مسیر URI تلفن همراه", "oauth_mobile_redirect_uri_override_description": "زمانی که 'app.immich:/' یک URI پرش نامعتبر است، فعال کنید.", "oauth_profile_signing_algorithm": "الگوریتم امضای پروفایل", "oauth_profile_signing_algorithm_description": "الگوریتم مورد استفاده برای امضای پروفایل کاربر.", @@ -210,10 +210,10 @@ "server_settings_description": "مدیریت تنظیمات سرور", "server_welcome_message": "پیام خوش آمد گویی", "server_welcome_message_description": "پیامی که در صفحه ورود به سیستم نمایش داده می شود.", - "sidecar_job": "", - "sidecar_job_description": "", - "slideshow_duration_description": "", - "smart_search_job_description": "", + "sidecar_job": "اطلاعات جانبی", + "sidecar_job_description": "یافتن یا همگام‌سازی اطلاعات جانبی از فایل سیستم", + "slideshow_duration_description": "زمان ( به ثانیه ) نشان دادن هر عکس", + "smart_search_job_description": "اجرای یادگیری ماشین بر روی عکسها برای پشتیبانی از جستجوی هوشمند", "storage_template_enable_description": "", "storage_template_hash_verification_enabled": "", "storage_template_hash_verification_enabled_description": "", diff --git a/web/src/lib/i18n/fi.json b/web/src/lib/i18n/fi.json index aa2e01952f..f87e2eed4e 100644 --- a/web/src/lib/i18n/fi.json +++ b/web/src/lib/i18n/fi.json @@ -31,6 +31,7 @@ "authentication_settings": "Autentikointiasetukset", "authentication_settings_description": "Hallitse salasana-, OAuth- ja muut autentikoinnin asetukset", "authentication_settings_disable_all": "Haluatko varmasti poistaa kaikki kirjautumistavat käytöstä? Kirjautuminen on tämän jälkeen mahdotonta.", + "authentication_settings_reenable": "Ottaaksesi uudestaan käyttöön, käytä Palvelin Komentoa.", "background_task_job": "Taustatyöt", "check_all": "Tarkista kaikki", "cleared_jobs": "Työn {job} tehtävät tyhjennetty", @@ -73,7 +74,7 @@ "job_settings": "Tehtävän asetukset", "job_settings_description": "Hallitse tehtävän samanaikaisuusasetuksia", "job_status": "Tehtävän tila", - "jobs_delayed": "{jobCount} tehtävää vivästetty", + "jobs_delayed": "{jobCount} tehtävää viivästetty", "jobs_failed": "{jobCount} epäonnistui", "library_created": "Kirjasto {library} luotu", "library_cron_expression": "Cron-lauseke", @@ -126,12 +127,14 @@ "manage_log_settings": "Hallitse lokien asetuksia", "map_dark_style": "Tumma teema", "map_enable_description": "Ota käyttöön karttatoiminnot", + "map_gps_settings": "Kartta & GPS- asetukset", + "map_gps_settings_description": "Hallitse Kartan & GPS (Käänteinen Geokoodaus) Asetuksia", "map_light_style": "Vaalea teema", "map_manage_reverse_geocoding_settings": "Hallitse käänteisen geokoodauksen asetuksia", "map_reverse_geocoding": "Käänteinen Geokoodaus", "map_reverse_geocoding_enable_description": "Ota käyttöön osoitteiden poiminta karttakoordinaateista", "map_reverse_geocoding_settings": "Käänteisen Geokoodauksen asetukset", - "map_settings": "Kartta- ja GPS asetukset", + "map_settings": "Kartta-asetukset", "map_settings_description": "Hallitse kartan asetuksia", "map_style_description": "style.json -karttateeman URL", "metadata_extraction_job": "Kerää metadata", @@ -171,6 +174,8 @@ "oauth_mobile_redirect_uri": "Mobiilin uudellenohjaus-URI", "oauth_mobile_redirect_uri_override": "Ohita mobiilin uudelleenohjaus-URI", "oauth_mobile_redirect_uri_override_description": "Ota käyttöön kun 'app.immich:/' -ohjausta ei tueta.", + "oauth_profile_signing_algorithm": "Profiilin allekirjoitusalgoritmi", + "oauth_profile_signing_algorithm_description": "Algoritmi, jota käytetään käyttäjäprofiilin allekirjoituksessa", "oauth_scope": "Skooppi (Scope)", "oauth_settings": "OAuth", "oauth_settings_description": "Hallitse OAuth kirjautumisen asetuksia", @@ -226,6 +231,7 @@ "storage_template_path_length": "Arvioitu tiedostopolun pituusrajoitus: {length, number}/{limit, number}", "storage_template_settings": "Tallennustilan malli", "storage_template_settings_description": "Hallitse palvelimelle ladatun aineiston kansiorakennetta ja tiedostonimiä", + "storage_template_user_label": "{label} on käyttäjän Tallennustilan Tunniste", "system_settings": "Järjestelmäasetukset", "theme_custom_css_settings": "Mukautettu CSS", "theme_custom_css_settings_description": "Kustomoi Immichin ulkoasua Cascading Style Sheets:llä.", @@ -243,12 +249,15 @@ "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Sallitut äänikoodekit", "transcoding_accepted_audio_codecs_description": "Valitse mitä äänikoodekkeja ei tarvitse muuntaa. Käytetään vain tiettyjen koodauskäytäntöjen kanssa.", + "transcoding_accepted_containers": "Hyväksytyt kontit", + "transcoding_accepted_containers_description": "Valitse, mitä formaatteja ei tarvitse kääntää MP4- muotoon. Käytössä vain tietyille muunnos säännöille.", "transcoding_accepted_video_codecs": "Sallitut videokoodekit", "transcoding_accepted_video_codecs_description": "Valitse mitä videokoodekkeja ei tarvitse muuntaa. Käytetään vain tiettyjen koodauskäytäntöjen kanssa.", "transcoding_advanced_options_description": "Asetukset, joita useimpien käyttäjien ei tulisi muuttaa", "transcoding_audio_codec": "Äänikoodekki", "transcoding_audio_codec_description": "Opus on paras laadultaan, mutta ei välttämättä ole yhteensopiva vanhempien laitteiden tai sovellusten kanssa.", "transcoding_bitrate_description": "Videot, jotka ylittävät enimmäisbittinopeuden tai eivät ole hyväksytyssä muodossa", + "transcoding_codecs_learn_more": "Oppiaksesi lisää tässä käytetystä terminologiasta, tutustu FFmpeg- dokumentaatioon H.264 koodaaja, HEVC koodaaja sekä VP9 koodaaja.", "transcoding_constant_quality_mode": "Tasaisen laadun tyyppi", "transcoding_constant_quality_mode_description": "ICQ on parempi kuin CQP, mutta jotkut laitteistokiihdyttimet eivät tue sitä. Tätä asetusta käytetään oletuksena laatuun pohjautuvissa muunnoksissa, paitsi NVENC mikä ei tue ICQ:ta.", "transcoding_constant_rate_factor": "", @@ -257,7 +266,7 @@ "transcoding_hardware_acceleration": "Laitteistokiihdytys", "transcoding_hardware_acceleration_description": "Kokeellinen. Paljon nopeampi, mutta huonompaa laatua samalla bittinopeudella", "transcoding_hardware_decoding": "Laitteiston dekoodaus", - "transcoding_hardware_decoding_setting_description": "Vaikuttaa vain NVENC ja RKMPP -moottoreihin. Ottaa käyttöön end-to-end kiihdytyksen pelkän enkoodauksen sijasta. Ei välttämättä toimi kaikissa videoissa.", + "transcoding_hardware_decoding_setting_description": "Vaikuttaa vain NVENC ja RKMPP -moottoreihin. Ottaa käyttöön end-to-end kiihdytyksen pelkän muuntamisen sijasta. Ei välttämättä toimi kaikissa videoissa.", "transcoding_hevc_codec": "HEVC koodekki", "transcoding_max_b_frames": "B-kehysten enimmäismäärä", "transcoding_max_b_frames_description": "Korkeampi arvo parantaa pakkausta, mutta hidastaa enkoodausta. Ei välttämättä ole yhteensopiva vanhempien laitteiden kanssa. 0 poistaa B-kehykset käytöstä, -1 määrittää arvon automaattisesti.", @@ -265,7 +274,7 @@ "transcoding_max_bitrate_description": "Suurimman sallitun bittinopeuden asettaminen tekee tiedostojen koosta ennustettavampaa vaikka laatu voi hieman heiketä. 720p videossa tyypilliset arvot ovat 2600k VP9:lle ja HEVC:lle, tai 4500k H.254:lle. Jos 0, ei käytössä.", "transcoding_max_keyframe_interval": "Suurin avainkehysten väli", "transcoding_max_keyframe_interval_description": "Asettaa avainkehysten välin maksimiarvon. Alempi arvo huonontaa pakkauksen tehoa, mutta parantaa hakuaikoja ja voi parantaa laatua nopealiikkeisissä kohtauksissa. 0 asettaa arvon automaattisesti.", - "transcoding_optimal_description": "", + "transcoding_optimal_description": "Videot, joiden resoluutio on korkeampi kuin kohteen, tai ei hyväksytyssä formaatissa", "transcoding_preferred_hardware_device": "Ensisijainen laite", "transcoding_preferred_hardware_device_description": "On voimassa vain VAAPI ja QSV -määritteille. Asettaa laitteistokoodauksessa käytetyn DRI noodin.", "transcoding_preset_preset": "Esiasetus (-asetus)", diff --git a/web/src/lib/i18n/fr.json b/web/src/lib/i18n/fr.json index 5cae4b6ecf..0aaa160729 100644 --- a/web/src/lib/i18n/fr.json +++ b/web/src/lib/i18n/fr.json @@ -278,7 +278,7 @@ "transcoding_preferred_hardware_device": "Matériel préféré", "transcoding_preferred_hardware_device_description": "S'applique uniquement à VAAPI et QSV. Définit le nœud DRI utilisé pour le transcodage matériel.", "transcoding_preset_preset": "Présélection (-preset)", - "transcoding_preset_preset_description": "Vitesse de compression. Les préréglages les plus lents produisent des fichiers plus petits, et augmentent la qualité lorsque l'on vise un certain débit. Le codec vidéo VP9 ignore les vitesses supérieures à « rapide (faster) ».", + "transcoding_preset_preset_description": "Vitesse de compression. Les préréglages les plus lents produisent des fichiers plus petits, et augmentent la qualité lorsqu'un certain débit est défini. Le codec vidéo VP9 ignore les vitesses supérieures à « rapide (faster) ».", "transcoding_reference_frames": "Trames de référence", "transcoding_reference_frames_description": "Le nombre d'images à prendre en référence lors de la compression d'une image donnée. Des valeurs élevées améliorent l'efficacité de la compression, mais ralentissent l'encodage. 0 fixe cette valeur automatiquement.", "transcoding_required_description": "Seulement les vidéos dans un format non accepté", @@ -761,7 +761,7 @@ "immich_web_interface": "Interface Web Immich", "import_from_json": "Importer depuis un fichier JSON", "import_path": "Chemin d'importation", - "in_albums": "Dans {count, plural, one {# un album} other {# des albums}}", + "in_albums": "Dans {count, plural, one {# album} other {# albums}}", "in_archive": "Dans les archives", "include_archived": "Inclure les archives", "include_shared_albums": "Inclure les albums partagés", @@ -918,6 +918,7 @@ "online": "En ligne", "only_favorites": "Uniquement les favoris", "only_refreshes_modified_files": "Actualise les fichiers modifiés uniquement", + "open_in_map_view": "Montrer sur la carte", "open_in_openstreetmap": "Ouvrir dans OpenStreetMap", "open_the_search_filters": "Ouvrir les filtres de recherche", "options": "Options", @@ -1021,6 +1022,8 @@ "purchase_server_title": "Serveur", "purchase_settings_server_activated": "La clé du produit pour le Serveur est gérée par l'administrateur", "range": "", + "rating": "Étoile d'évaluation", + "rating_description": "Afficher l'évaluation d'exif dans le panneau d'information", "raw": "", "reaction_options": "Options de réaction", "read_changelog": "Lire les changements", @@ -1151,6 +1154,7 @@ "sharing_sidebar_description": "Afficher un lien vers Partage dans la barre latérale", "shift_to_permanent_delete": "appuyez sur ⇧ pour supprimer définitivement le média", "show_album_options": "Afficher les options de l'album", + "show_albums": "Montrer les albums", "show_all_people": "Montrer toutes les personnes", "show_and_hide_people": "Afficher / Masquer les personnes", "show_file_location": "Afficher l'emplacement du fichier", @@ -1183,6 +1187,8 @@ "sort_title": "Titre", "source": "Source", "stack": "Empiler", + "stack_duplicates": "Empiler les duplications", + "stack_select_one_photo": "Sélectionnez une photo principale pour la pile", "stack_selected_photos": "Empiler les photos sélectionnées", "stacked_assets_count": "{count, plural, one {# média empilé} other {# médias empilés}}", "stacktrace": "Trace de la pile", diff --git a/web/src/lib/i18n/he.json b/web/src/lib/i18n/he.json index 867dc38ba7..d73b06ad70 100644 --- a/web/src/lib/i18n/he.json +++ b/web/src/lib/i18n/he.json @@ -250,7 +250,7 @@ "transcoding_accepted_audio_codecs": "קודקים מקובלים של שמע", "transcoding_accepted_audio_codecs_description": "בחר אילו קודקים של שמע אינם צריכים לעבור המרת קידוד. משמש רק עבור פוליסות המרת קידוד מסוימות.", "transcoding_accepted_containers": "מכולות מקובלות", - "transcoding_accepted_containers_description": "בחר אילו פורמטי מכולות אינם צריכים לעבור עיבוד מחדש לפורמט MP4. משתמשים בכך רק עבור מדיניות קידוד מחדש מסוימות.", + "transcoding_accepted_containers_description": "בחר אילו פורמטי מכולה אין צורך לשנות ל-MP4. משמש רק עבור מדיניות קידוד מסוימות.", "transcoding_accepted_video_codecs": "קודקים מקובלים של סרטונים", "transcoding_accepted_video_codecs_description": "בחר אילו קודקים של סרטונים אינם צריכים לעבור המרת קידוד. משמש רק עבור פוליסות המרת קידוד מסוימות.", "transcoding_advanced_options_description": "אפשרויות שרוב המשתמשים לא צריכים לשנות", @@ -406,7 +406,7 @@ "birthdate_set_description": "תאריך לידה משמש לחישוב הגיל של האדם הזה בזמן תצלום.", "blurred_background": "רקע מטושטש", "build": "Build", - "build_image": "בניית Image", + "build_image": "Build Image", "bulk_delete_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך למחוק בכמות גדולה {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה ישמור על הנכס הכי גדול של כל קבוצה וימחק לצמיתות את כל שאר הכפילויות. את/ה לא יכול/ה לבטל את הפעולה הזו!", "bulk_keep_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך להשאיר {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה יפתור את כל הקבוצות הכפולות מבלי למחוק דבר.", "bulk_trash_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך להעביר לאשפה בכמות גדולה {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה ישמור על הנכס הגדול ביותר של כל קבוצה ויעביר לאשפה את כל שאר הכפילויות.", @@ -707,7 +707,7 @@ "face_unassigned": "לא מוקצה", "failed_to_get_people": "נכשל באחזור אנשים", "favorite": "מועדף", - "favorite_or_unfavorite_photo": "תמונה מועדפת או לא מועדפת", + "favorite_or_unfavorite_photo": "הוסף או הסר תמונה מהמועדפים", "favorites": "מועדפים", "feature": "", "feature_photo_updated": "תמונה מייצגת עודכנה", @@ -918,6 +918,7 @@ "online": "מקוון", "only_favorites": "רק מועדפים", "only_refreshes_modified_files": "מרענן רק קבצים שהשתנו", + "open_in_map_view": "פתח בתצוגת מפה", "open_in_openstreetmap": "פתח ב-OpenStreetMap", "open_the_search_filters": "פתח את מסנני החיפוש", "options": "אפשרויות", @@ -1007,7 +1008,7 @@ "purchase_license_subtitle": "קנה את Immich כדי לתמוך בפיתוח המתמשך של השירות", "purchase_lifetime_description": "רכישה לכל החיים", "purchase_option_title": "אפשרויות רכישה", - "purchase_panel_info_1": "בניית Immich לוקחת הרבה זמן ומאמץ, ויש לנו מהנדסים במשרה מלאה שעובדים על זה כדי לעשות את זה הכי טוב שאנחנו יכולים. המשימה שלנו היא שתוכנות קוד-פתוח ושיטות עסקיות אתיות יהיו מקור הכנסה בר-קיימא למפתחים וליצור מערכת אקולוגית שמכבדת פרטיות עם חלופות אמיתיות לשירותי ענן נצלנים.", + "purchase_panel_info_1": "בניית Immich לוקחת הרבה זמן ומאמץ, ויש לנו מהנדסים במשרה מלאה שעובדים על זה כדי לעשות את זה הכי טוב שאנחנו יכולים. המשימה שלנו היא שתוכנות קוד-פתוח ושיטות עסקיות אתיות יהיו מקור הכנסה בר-קיימא למפתחים וליצור אקוסיסטם המכבדת פרטיות עם חלופות אמיתיות לשירותי ענן נצלנים.", "purchase_panel_info_2": "מכיוון שאנחנו מחויבים לא להוסיף חומות תשלום, הרכישה הזאת לא תקנה לך תכונות נוספות כלשהן ב-Immich. אנחנו סומכים על משתמשים כמוך שיתמכו בפיתוח המתמשך של Immich.", "purchase_panel_title": "תמוך בפרויקט", "purchase_per_server": "עבור שרת", @@ -1021,6 +1022,8 @@ "purchase_server_title": "שרת", "purchase_settings_server_activated": "מפתח המוצר של השרת מנוהל על ידי מנהל המערכת", "range": "", + "rating": "דירוג כוכב", + "rating_description": "הצג את דירוג ה-exif בלוח המידע", "raw": "", "reaction_options": "אפשרויות הגבה", "read_changelog": "קרא את יומן השינויים", @@ -1151,6 +1154,7 @@ "sharing_sidebar_description": "הצג קישור אל שיתוף בסרגל הצד", "shift_to_permanent_delete": "לחץ ⇧ כדי למחוק לצמיתות נכס", "show_album_options": "הצג אפשרויות אלבום", + "show_albums": "הצג אלבומים", "show_all_people": "הצג את כל האנשים", "show_and_hide_people": "הצג & הסתר אנשים", "show_file_location": "הצג את מיקום הקובץ", @@ -1183,6 +1187,8 @@ "sort_title": "כותרת", "source": "מקור", "stack": "ערימה", + "stack_duplicates": "צור ערימת כפילויות", + "stack_select_one_photo": "בחר תמונה ראשית אחת עבור הערימה", "stack_selected_photos": "צור ערימת תמונות נבחרות", "stacked_assets_count": "{count, plural, one {נכס # נערם} other {# נכסים נערמו}}", "stacktrace": "Stacktrace", @@ -1220,7 +1226,7 @@ "total_usage": "שימוש כולל", "trash": "אשפה", "trash_all": "העבר הכל לאשפה", - "trash_count": "{count, number} קבצים לאשפה", + "trash_count": "העבר לאשפה {count, number}", "trash_delete_asset": "העבר לאשפה/מחק נכס", "trash_no_results_message": "תמונות וסרטונים שהועברו לאשפה יופיעו כאן.", "trashed_items_will_be_permanently_deleted_after": "פריטים באשפה ימחקו לצמיתות לאחר {days, plural, one {יום #} other {# ימים}}.", diff --git a/web/src/lib/i18n/hi.json b/web/src/lib/i18n/hi.json index 7ae72f7f64..99f2ef2458 100644 --- a/web/src/lib/i18n/hi.json +++ b/web/src/lib/i18n/hi.json @@ -25,7 +25,7 @@ "add_to_shared_album": "साझा एल्बम में जोड़ें", "added_to_archive": "संग्रहीत कर दिया गया है", "added_to_favorites": "पसंदीदा में जोड़ा गया", - "added_to_favorites_count": "पसंदीदा में {count} जोड़ा गया", + "added_to_favorites_count": "पसंदीदा में {count, number} जोड़ा गया", "admin": { "add_exclusion_pattern_description": "बहिष्करण पैटर्न जोड़ें. *, **, और ? का उपयोग करके ग्लोबिंग करना समर्थित है। \"Raw\" नामक किसी भी निर्देशिका की सभी फ़ाइलों को अनदेखा करने के लिए, \"**/Raw/**\" का उपयोग करें। \".tif\" से समाप्त होने वाली सभी फ़ाइलों को अनदेखा करने के लिए, \"**/*.tif\" का उपयोग करें। किसी पूर्ण पथ को अनदेखा करने के लिए, \"/path/to/ignore/**\" का उपयोग करें।", "authentication_settings": "प्रमाणीकरण सेटिंग्स", @@ -74,8 +74,8 @@ "job_settings": "कार्य (जॉब) सेटिंग्स", "job_settings_description": "कार्य (जॉब) समवर्तीता प्रबंधित करें", "job_status": "कार्य (जॉब) स्थिति", - "jobs_delayed": "{jobCount, plural, other {# delayed}}", - "jobs_failed": "{jobCount, plural, other {# failed}}", + "jobs_delayed": "{jobCount, plural, other {# विलंबित}}", + "jobs_failed": "{jobCount, plural, other {# असफल}}", "library_created": "निर्मित संग्रह: {library}", "library_cron_expression": "क्रॉन व्यंजक", "library_cron_expression_description": "क्रॉन प्रारूप का उपयोग करके स्कैनिंग अंतराल सेट करें। अधिक जानकारी के लिए कृपया उदाहरण के लिए Crontab Guru देखें", @@ -88,298 +88,405 @@ "library_settings": "बाहरी संग्रह", "library_settings_description": "बाहरी संग्रह सेटिंग प्रबंधित करें", "library_tasks_description": "संग्रह कार्य निष्पादित करें", - "library_watching_enable_description": "", - "library_watching_settings": "", - "library_watching_settings_description": "", - "logging_enable_description": "", - "logging_level_description": "", - "logging_settings": "", - "machine_learning_clip_model": "", - "machine_learning_duplicate_detection": "", - "machine_learning_duplicate_detection_enabled_description": "", - "machine_learning_duplicate_detection_setting_description": "", - "machine_learning_enabled_description": "", - "machine_learning_facial_recognition": "", - "machine_learning_facial_recognition_description": "", - "machine_learning_facial_recognition_model": "", - "machine_learning_facial_recognition_model_description": "", - "machine_learning_facial_recognition_setting_description": "", - "machine_learning_max_detection_distance": "", - "machine_learning_max_detection_distance_description": "", - "machine_learning_max_recognition_distance": "", - "machine_learning_max_recognition_distance_description": "", - "machine_learning_min_detection_score": "", - "machine_learning_min_detection_score_description": "", - "machine_learning_min_recognized_faces": "", - "machine_learning_min_recognized_faces_description": "", - "machine_learning_settings": "", - "machine_learning_settings_description": "", - "machine_learning_smart_search": "", - "machine_learning_smart_search_description": "", - "machine_learning_smart_search_enabled_description": "", - "machine_learning_url_description": "", - "manage_log_settings": "", - "map_dark_style": "", - "map_enable_description": "", - "map_light_style": "", - "map_reverse_geocoding": "", - "map_reverse_geocoding_enable_description": "", - "map_reverse_geocoding_settings": "", - "map_settings": "", - "map_settings_description": "", - "map_style_description": "", - "metadata_extraction_job_description": "", - "migration_job_description": "", - "notification_email_from_address": "", - "notification_email_from_address_description": "", - "notification_email_host_description": "", - "notification_email_ignore_certificate_errors": "", - "notification_email_ignore_certificate_errors_description": "", - "notification_email_password_description": "", - "notification_email_port_description": "", - "notification_email_sent_test_email_button": "", - "notification_email_setting_description": "", - "notification_email_test_email_failed": "", - "notification_email_test_email_sent": "", - "notification_email_username_description": "", - "notification_enable_email_notifications": "", - "notification_settings": "", - "notification_settings_description": "", - "oauth_auto_launch": "", - "oauth_auto_launch_description": "", - "oauth_auto_register": "", - "oauth_auto_register_description": "", - "oauth_button_text": "", - "oauth_client_id": "", - "oauth_client_secret": "", - "oauth_enable_description": "", - "oauth_issuer_url": "", - "oauth_mobile_redirect_uri": "", - "oauth_mobile_redirect_uri_override": "", - "oauth_mobile_redirect_uri_override_description": "", - "oauth_scope": "", - "oauth_settings": "", - "oauth_settings_description": "", - "oauth_signing_algorithm": "", - "oauth_storage_label_claim": "", - "oauth_storage_label_claim_description": "", - "oauth_storage_quota_claim": "", - "oauth_storage_quota_claim_description": "", - "oauth_storage_quota_default": "", - "oauth_storage_quota_default_description": "", - "password_enable_description": "", - "password_settings": "", - "password_settings_description": "", - "server_external_domain_settings": "", - "server_external_domain_settings_description": "", - "server_settings": "", - "server_settings_description": "", - "server_welcome_message": "", - "server_welcome_message_description": "", - "sidecar_job_description": "", - "slideshow_duration_description": "", - "smart_search_job_description": "", - "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", - "storage_template_hash_verification_enabled_description": "", - "storage_template_migration_job": "", - "storage_template_settings": "", - "storage_template_settings_description": "", - "theme_custom_css_settings": "", - "theme_custom_css_settings_description": "", - "theme_settings": "", - "theme_settings_description": "", - "thumbnail_generation_job_description": "", + "library_watching_enable_description": "एक्सटर्नल लाइब्रेरीज में बदलावों के लिए निगरानी रखें", + "library_watching_settings": "पुस्तकालय निगरानी (प्रायोगिक)", + "library_watching_settings_description": "परिवर्तित फ़ाइलों पर स्वचालित रूप से नज़र रखें", + "logging_enable_description": "लॉगिंग करने देना", + "logging_level_description": "सक्षम होने पर, किस लॉग स्तर का उपयोग करना है।", + "logging_settings": "लॉगिंग", + "machine_learning_clip_model": "क्लिप मॉडल", + "machine_learning_clip_model_description": "CLIP मॉडल का नाम यहां सूचीबद्ध है। ध्यान दें कि मॉडल बदलने पर आपको सभी छवियों के लिए 'स्मार्ट सर्च' जोब फिर से चलाना होगा।", + "machine_learning_duplicate_detection": "डुप्लिकेट का पता लगाना", + "machine_learning_duplicate_detection_enabled": "डुप्लिकेट पहचान सक्षम करें", + "machine_learning_duplicate_detection_enabled_description": "यदि अक्षम किया गया है, तो बिल्कुल समान चित्र अभी भी डी-डुप्लिकेट किया जाएगा।", + "machine_learning_duplicate_detection_setting_description": "संभावित डुप्लिकेट खोजने के लिए CLIP एम्बेडिंग का उपयोग करें", + "machine_learning_enabled": "मशीन लर्निंग सक्षम करें", + "machine_learning_enabled_description": "यदि अक्षम किया गया है, तो नीचे दी गई सेटिंग्स पर ध्यान दिए बिना सभी एमएल सुविधाएं अक्षम कर दी जाएंगी।", + "machine_learning_facial_recognition": "चेहरे की पहचान", + "machine_learning_facial_recognition_description": "छवियों में चेहरे का पता लगाना, पहचानना और समूह बनाना", + "machine_learning_facial_recognition_model": "चेहरे की पहचान मॉडल", + "machine_learning_facial_recognition_model_description": "मॉडल आकार के अवरोही क्रम में सूचीबद्ध हैं। बड़े मॉडल धीमी हैं और अधिक स्मृति का उपयोग करते हैं, लेकिन बेहतर परिणाम देते हैं। ध्यान दें कि आपको एक मॉडल बदलने पर सभी छवियों के लिए फेस डिटेक्शन जॉब को फिर से शुरू करना होगा।।", + "machine_learning_facial_recognition_setting": "चेहरे की पहचान सक्षम करें", + "machine_learning_facial_recognition_setting_description": "यदि अक्षम किया गया है, तो छवियों को चेहरे की पहचान के लिए एन्कोड नहीं किया जाएगा और एक्सप्लोर पेज में लोग अनुभाग को पॉप्युलेट नहीं किया जाएगा।", + "machine_learning_max_detection_distance": "अधिकतम पता लगाने की दूरी", + "machine_learning_max_detection_distance_description": "दो छवियों को डुप्लिकेट मानने के लिए उनके बीच की अधिकतम दूरी 0.001-0.1 के बीच है।", + "machine_learning_max_recognition_distance": "अधिकतम पहचान दूरी", + "machine_learning_max_recognition_distance_description": "एक ही व्यक्ति माने जाने वाले दो चेहरों के बीच अधिकतम दूरी 0-2 के बीच है।", + "machine_learning_min_detection_score": "न्यूनतम पहचान स्कोर", + "machine_learning_min_detection_score_description": "किसी चेहरे का पता लगाने के लिए न्यूनतम आत्मविश्वास स्कोर 0-1 होना चाहिए।", + "machine_learning_min_recognized_faces": "न्यूनतम पहचाने गए चेहरे", + "machine_learning_min_recognized_faces_description": "किसी व्यक्ति के लिए पहचाने जाने वाले चेहरों की न्यूनतम संख्या।", + "machine_learning_settings": "मशीन लर्निंग सेटिंग्स", + "machine_learning_settings_description": "मशीन लर्निंग सुविधाओं और सेटिंग्स को प्रबंधित करें", + "machine_learning_smart_search": "स्मार्ट खोज", + "machine_learning_smart_search_description": "CLIP एम्बेडिंग का उपयोग करके शब्दार्थ रूप से छवियां खोजें", + "machine_learning_smart_search_enabled": "स्मार्ट खोज सक्षम करें", + "machine_learning_smart_search_enabled_description": "यदि अक्षम किया गया है, तो स्मार्ट खोज के लिए छवियों को एन्कोड नहीं किया जाएगा।", + "machine_learning_url_description": "मशीन लर्निंग सर्वर का यूआरएल", + "manage_concurrency": "समवर्तीता प्रबंधित करें", + "manage_log_settings": "लॉग सेटिंग प्रबंधित करें", + "map_dark_style": "डार्क शैली", + "map_enable_description": "मानचित्र सुविधाएँ सक्षम करें", + "map_gps_settings": "मानचित्र एवं जीपीएस सेटिंग्स", + "map_gps_settings_description": "मानचित्र और जीपीएस (रिवर्स जियोकोडिंग) सेटिंग्स प्रबंधित करें", + "map_light_style": "हल्की शैली", + "map_manage_reverse_geocoding_settings": "प्रबंधित करना रिवर्स जियोकोडिंग समायोजन", + "map_reverse_geocoding": "रिवर्स जियोकोडिंग", + "map_reverse_geocoding_enable_description": "रिवर्स जियोकोडिंग सक्षम करें", + "map_reverse_geocoding_settings": "जियोकोडिंग सेटिंग्स को उल्टा करें", + "map_settings": "मानचित्र सेटिंग", + "map_settings_description": "मानचित्र सेटिंग प्रबंधित करें", + "map_style_description": "style.json मैप थीम का URL", + "metadata_extraction_job": "मेटाडेटा निकालें", + "metadata_extraction_job_description": "प्रत्येक परिसंपत्ति से जीपीएस और रिज़ॉल्यूशन जैसी मेटाडेटा जानकारी निकालें", + "migration_job": "प्रवास", + "migration_job_description": "संपत्तियों और चेहरों के थंबनेल को नवीनतम फ़ोल्डर संरचना में माइग्रेट करें", + "no_paths_added": "कोई पथ नहीं जोड़ा गया", + "no_pattern_added": "कोई पैटर्न नहीं जोड़ा गया", + "note_apply_storage_label_previous_assets": "नोट: पहले अपलोड की गई संपत्तियों पर स्टोरेज लेबल लागू करने के लिए, चलाएँ", + "note_cannot_be_changed_later": "नोट: इसे बाद में बदला नहीं जा सकता!", + "note_unlimited_quota": "नोट: असीमित कोटा के लिए 0 दर्ज करें", + "notification_email_from_address": "इस पते से", + "notification_email_from_address_description": "प्रेषक का ईमेल पता, उदाहरण के लिए: \"इमिच फोटो सर्वर \"", + "notification_email_host_description": "ईमेल सर्वर का होस्ट (उदा. smtp.immitch.app)", + "notification_email_ignore_certificate_errors": "प्रमाणपत्र त्रुटियों पर ध्यान न दें", + "notification_email_ignore_certificate_errors_description": "टीएलएस प्रमाणपत्र सत्यापन त्रुटियों पर ध्यान न दें (अनुशंसित नहीं)", + "notification_email_password_description": "ईमेल सर्वर से प्रमाणीकरण करते समय उपयोग किया जाने वाला पासवर्ड", + "notification_email_port_description": "ईमेल सर्वर का पोर्ट (जैसे 25, 465, या 587)", + "notification_email_sent_test_email_button": "परीक्षण ईमेल भेजें और सहेजें", + "notification_email_setting_description": "ईमेल सूचनाएं भेजने के लिए सेटिंग्स", + "notification_email_test_email": "परीक्षण ईमेल भेजें", + "notification_email_test_email_failed": "परीक्षण ईमेल भेजने में विफल, अपने मूल्यों की जाँच करें", + "notification_email_test_email_sent": "{email} पर एक परीक्षण ईमेल भेजा गया है। कृपया अपना इनबॉक्स देखें।", + "notification_email_username_description": "ईमेल सर्वर से प्रमाणीकरण करते समय उपयोग किया जाने वाला उपयोगकर्ता नाम", + "notification_enable_email_notifications": "ईमेल सूचनाएं सक्षम करें", + "notification_settings": "अधिसूचना सेटिंग्स", + "notification_settings_description": "ईमेल सहित अधिसूचना सेटिंग्स प्रबंधित करें", + "oauth_auto_launch": "ऑटो लांच", + "oauth_auto_launch_description": "लॉगिन पृष्ठ पर नेविगेट करने पर OAuth लॉगिन प्रवाह स्वचालित रूप से प्रारंभ करें", + "oauth_auto_register": "ऑटो रजिस्टर", + "oauth_auto_register_description": "OAuth के साथ साइन इन करने के बाद स्वचालित रूप से नए उपयोगकर्ताओं को पंजीकृत करें", + "oauth_button_text": "टेक्स्ट बटन", + "oauth_client_id": "ग्राहक ID", + "oauth_client_secret": "ग्राहक गुप्त", + "oauth_enable_description": "OAuth से लॉगिन करें", + "oauth_issuer_url": "जारीकर्ता URL", + "oauth_mobile_redirect_uri": "मोबाइल रीडायरेक्ट यूआरआई", + "oauth_mobile_redirect_uri_override": "मोबाइल रीडायरेक्ट यूआरआई ओवरराइड", + "oauth_mobile_redirect_uri_override_description": "सक्षम करें जब 'app.immitch:/' एक अमान्य रीडायरेक्ट यूआरआई हो।", + "oauth_profile_signing_algorithm": "प्रोफ़ाइल हस्ताक्षर एल्गोरिथ्म", + "oauth_profile_signing_algorithm_description": "उपयोगकर्ता प्रोफ़ाइल पर हस्ताक्षर करने के लिए एल्गोरिदम का उपयोग किया जाता है।", + "oauth_scope": "स्कोप", + "oauth_settings": "ओऑथ", + "oauth_settings_description": "OAuth लॉगिन सेटिंग प्रबंधित करें", + "oauth_settings_more_details": "इस सुविधा के बारे में अधिक जानकारी के लिए, देखें डॉक्स।", + "oauth_signing_algorithm": "हस्ताक्षर एल्गोरिथ्म", + "oauth_storage_label_claim": "भंडारण लेबल का दावा", + "oauth_storage_label_claim_description": "इस दावे के मूल्य पर उपयोगकर्ता के भंडारण लेबल को स्वचालित रूप से सेट करें।", + "oauth_storage_quota_claim": "भंडारण कोटा का दावा", + "oauth_storage_quota_claim_description": "उपयोगकर्ता के संग्रहण कोटा को इस दावे के मूल्य पर स्वचालित रूप से सेट करें।", + "oauth_storage_quota_default": "डिफ़ॉल्ट संग्रहण कोटा (GiB)", + "oauth_storage_quota_default_description": "GiB में कोटा का उपयोग तब किया जाएगा जब कोई दावा प्रदान नहीं किया गया हो (असीमित कोटा के लिए 0 दर्ज करें)।", + "offline_paths": "ऑफ़लाइन पथ", + "offline_paths_description": "ये परिणाम उन फ़ाइलों को मैन्युअल रूप से हटाने के कारण हो सकते हैं जो बाहरी लाइब्रेरी का हिस्सा नहीं हैं।", + "password_enable_description": "ईमेल और पासवर्ड से लॉगिन करें", + "password_settings": "पासवर्ड लॉग इन", + "password_settings_description": "पासवर्ड लॉगिन सेटिंग प्रबंधित करें", + "paths_validated_successfully": "सभी पथ सफलतापूर्वक मान्य किए गए", + "quota_size_gib": "कोटा आकार (GiB)", + "refreshing_all_libraries": "सभी पुस्तकालयों को ताज़ा किया जा रहा है", + "registration": "व्यवस्थापक पंजीकरण", + "registration_description": "चूंकि आप सिस्टम पर पहले उपयोगकर्ता हैं, इसलिए आपको व्यवस्थापक के रूप में नियुक्त किया जाएगा और आप प्रशासनिक कार्यों के लिए जिम्मेदार होंगे, और अतिरिक्त उपयोगकर्ता आपके द्वारा बनाए जाएंगे।", + "removing_offline_files": "ऑफ़लाइन फ़ाइलें हटाना", + "repair_all": "सभी की मरम्मत", + "require_password_change_on_login": "उपयोगकर्ता को पहले लॉगिन पर पासवर्ड बदलने की आवश्यकता है", + "reset_settings_to_default": "सेटिंग्स को डिफ़ॉल्ट पर रीसेट करें", + "reset_settings_to_recent_saved": "सेटिंग्स को हाल ही में सहेजी गई सेटिंग्स पर रीसेट करें", + "scanning_library_for_changed_files": "परिवर्तित फ़ाइलों के लिए लाइब्रेरी को स्कैन करना", + "scanning_library_for_new_files": "नई फ़ाइलों के लिए लाइब्रेरी को स्कैन करना", + "send_welcome_email": "स्वागत ईमेल भेजें", + "server_external_domain_settings": "बाहरी डोमेन", + "server_external_domain_settings_description": "सार्वजनिक साझा लिंक के लिए डोमेन, जिसमें http(s):// शामिल है", + "server_settings": "सर्वर सेटिंग्स", + "server_settings_description": "सर्वर सेटिंग्स प्रबंधित करें", + "server_welcome_message": "स्वागत संदेश", + "server_welcome_message_description": "एक संदेश जो लॉगिन पृष्ठ पर प्रदर्शित होता है।", + "sidecar_job": "साइडकार मेटाडेटा", + "sidecar_job_description": "फ़ाइल सिस्टम से साइडकार मेटाडेटा खोजें या सिंक्रनाइज़ करें", + "slideshow_duration_description": "प्रत्येक छवि को प्रदर्शित करने के लिए सेकंड की संख्या", + "smart_search_job_description": "स्मार्ट खोज का समर्थन करने के लिए संपत्तियों पर मशीन लर्निंग चलाएं", + "storage_template_date_time_description": "एसेट के निर्माण टाइमस्टैम्प का उपयोग दिनांक समय की जानकारी के लिए किया जाता है", + "storage_template_enable_description": "भंडारण टेम्पलेट इंजन सक्षम करें", + "storage_template_hash_verification_enabled": "हैश सत्यापन सक्षम किया गया", + "storage_template_hash_verification_enabled_description": "हैश सत्यापन सक्षम करता है, जब तक आप इसके निहितार्थों के बारे में निश्चित न हों, इसे अक्षम न करें", + "storage_template_migration": "भंडारण टेम्पलेट माइग्रेशन", + "storage_template_migration_job": "संग्रहण टेम्पलेट माइग्रेशन कार्य", + "storage_template_more_details": "इस सुविधा के बारे में अधिक जानकारी के लिए, देखें भंडारण टेम्पलेट और इसके आशय", + "storage_template_onboarding_description": "सक्षम होने पर, यह सुविधा उपयोगकर्ता द्वारा परिभाषित टेम्पलेट के आधार पर फ़ाइलों को स्वतः व्यवस्थित कर देगी। स्थिरता संबंधी समस्याओं के कारण यह सुविधा डिफ़ॉल्ट रूप से बंद कर दी गई है। अधिक जानकारी के लिए, कृपया दस्तावेज़ीकरण देखें।", + "storage_template_settings": "भंडारण टेम्पलेट", + "storage_template_settings_description": "अपलोड संपत्ति की फ़ोल्डर संरचना और फ़ाइल नाम प्रबंधित करें", + "system_settings": "प्रणाली व्यवस्था", + "theme_custom_css_settings": "कस्टम सीएसएस", + "theme_custom_css_settings_description": "कैस्केडिंग स्टाइल शीट्स इमिच के डिज़ाइन को अनुकूलित करने की अनुमति देती हैं।", + "theme_settings": "थीम सेटिंग", + "theme_settings_description": "इम्मीच वेब इंटरफ़ेस का अनुकूलन प्रबंधित करें", + "these_files_matched_by_checksum": "इन फ़ाइलों का मिलान उनके चेकसम से किया जाता है", + "thumbnail_generation_job": "थंबनेल उत्पन्न करें", + "thumbnail_generation_job_description": "प्रत्येक संपत्ति के लिए बड़े, छोटे और धुंधले थंबनेल, साथ ही प्रत्येक व्यक्ति के लिए थंबनेल बनाएं", "transcode_policy_description": "", - "transcoding_acceleration_api": "", - "transcoding_acceleration_api_description": "", - "transcoding_acceleration_nvenc": "", - "transcoding_acceleration_qsv": "", - "transcoding_acceleration_rkmpp": "", - "transcoding_acceleration_vaapi": "", - "transcoding_accepted_audio_codecs": "", - "transcoding_accepted_audio_codecs_description": "", - "transcoding_accepted_video_codecs": "", - "transcoding_accepted_video_codecs_description": "", - "transcoding_advanced_options_description": "", - "transcoding_audio_codec": "", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", - "transcoding_hardware_acceleration": "", - "transcoding_hardware_acceleration_description": "", - "transcoding_hardware_decoding": "", - "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", - "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", - "transcoding_tone_mapping_npl": "", - "transcoding_tone_mapping_npl_description": "", - "transcoding_transcode_policy": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", - "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", - "user_settings": "", - "user_settings_description": "", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", - "video_conversion_job_description": "" + "transcoding_acceleration_api": "त्वरण एपीआई", + "transcoding_acceleration_api_description": "एपीआई जो ट्रांसकोडिंग को तेज करने के लिए आपके डिवाइस के साथ इंटरैक्ट करेगा।", + "transcoding_acceleration_nvenc": "NVENC (NVIDIA GPU की आवश्यकता है)", + "transcoding_acceleration_qsv": "त्वरित सिंक (सातवीं पीढ़ी के इंटेल सीपीयू या बाद के संस्करण की आवश्यकता है)", + "transcoding_acceleration_rkmpp": "आरकेएमपीपी (केवल रॉकचिप एसओसी पर)", + "transcoding_acceleration_vaapi": "वीएएपीआई", + "transcoding_accepted_audio_codecs": "स्वीकृत ऑडियो कोडेक्स", + "transcoding_accepted_audio_codecs_description": "चुनें कि किन ऑडियो कोडेक्स को ट्रांसकोड करने की आवश्यकता नहीं है।", + "transcoding_accepted_containers": "स्वीकृत कंटेनर", + "transcoding_accepted_containers_description": "चुनें कि किन कंटेनर प्रारूपों को MP4 में रीमक्स करने की आवश्यकता नहीं है।", + "transcoding_accepted_video_codecs": "स्वीकृत वीडियो कोडेक्स", + "transcoding_accepted_video_codecs_description": "चुनें कि किन वीडियो कोडेक्स को ट्रांसकोड करने की आवश्यकता नहीं है।", + "transcoding_advanced_options_description": "अधिकांश उपयोगकर्ताओं को विकल्प बदलने की आवश्यकता नहीं होनी चाहिए", + "transcoding_audio_codec": "ऑडियो कोडेक", + "transcoding_audio_codec_description": "ओपस उच्चतम गुणवत्ता वाला विकल्प है, लेकिन पुराने उपकरणों या सॉफ़्टवेयर के साथ इसकी अनुकूलता कम है।", + "transcoding_bitrate_description": "अधिकतम बिटरेट से अधिक या स्वीकृत प्रारूप में नहीं होने वाले वीडियो", + "transcoding_codecs_learn_more": "यहां प्रयुक्त शब्दावली के बारे में अधिक जानने के लिए, FFmpeg दस्तावेज़ देखें H.264 कोडेक, एचईवीसी कोडेक और VP9 कोडेक।", + "transcoding_constant_quality_mode": "लगातार गुणवत्ता मोड", + "transcoding_constant_quality_mode_description": "ICQ CQP से बेहतर है, लेकिन कुछ हार्डवेयर एक्सेलेरेशन डिवाइस इस मोड का समर्थन नहीं करते हैं।", + "transcoding_constant_rate_factor": "स्थिर दर कारक (-सीआरएफ)", + "transcoding_constant_rate_factor_description": "वीडियो गुणवत्ता स्तर।", + "transcoding_disabled_description": "किसी भी वीडियो को ट्रांसकोड न करें, इससे कुछ क्लाइंट पर प्लेबैक बाधित हो सकता है", + "transcoding_hardware_acceleration": "हार्डवेयर एक्सिलरेशन", + "transcoding_hardware_acceleration_description": "प्रायोगिक; बहुत तेजी से, लेकिन एक ही बिटरेट में कम गुणवत्ता होगी", + "transcoding_hardware_decoding": "हार्डवेयर डिकोडिंग", + "transcoding_hardware_decoding_setting_description": "केवल एनवीईएनसी, क्यूएसवी और आरकेएमपीपी पर लागू होता है।", + "transcoding_hevc_codec": "एचईवीसी कोडेक", + "transcoding_max_b_frames": "अधिकतम बी-फ्रेम", + "transcoding_max_b_frames_description": "उच्च मान संपीड़न दक्षता में सुधार करते हैं, लेकिन एन्कोडिंग को धीमा कर देते हैं।", + "transcoding_max_bitrate": "अधिकतम बिटरेट", + "transcoding_max_bitrate_description": "अधिकतम बिटरेट सेट करने से गुणवत्ता की मामूली लागत पर फ़ाइल आकार को अधिक पूर्वानुमानित बनाया जा सकता है।", + "transcoding_max_keyframe_interval": "अधिकतम मुख्यफ़्रेम अंतराल", + "transcoding_max_keyframe_interval_description": "मुख्यफ़्रेम के बीच अधिकतम फ़्रेम दूरी निर्धारित करता है।", + "transcoding_optimal_description": "लक्ष्य रिज़ॉल्यूशन से अधिक ऊंचे वीडियो या स्वीकृत प्रारूप में नहीं", + "transcoding_preferred_hardware_device": "पसंदीदा हार्डवेयर डिवाइस", + "transcoding_preferred_hardware_device_description": "केवल VAAPI और QSV पर लागू होता है।", + "transcoding_preset_preset": "प्रीसेट (-preset)", + "transcoding_preset_preset_description": "संपीड़न गति।", + "transcoding_reference_frames": "संदर्भ फ्रेम", + "transcoding_reference_frames_description": "किसी दिए गए फ़्रेम को संपीड़ित करते समय संदर्भित किए जाने वाले फ़्रेमों की संख्या।", + "transcoding_required_description": "केवल वे वीडियो जो स्वीकृत प्रारूप में नहीं हैं", + "transcoding_settings": "वीडियो ट्रांसकोडिंग सेटिंग्स", + "transcoding_settings_description": "वीडियो फ़ाइलों के रिज़ॉल्यूशन और एन्कोडिंग जानकारी को प्रबंधित करें", + "transcoding_target_resolution": "लक्ष्य संकल्प", + "transcoding_target_resolution_description": "उच्च रिज़ॉल्यूशन अधिक विवरण संरक्षित कर सकते हैं लेकिन एन्कोड करने में अधिक समय लेते हैं, फ़ाइल आकार बड़े होते हैं, और ऐप प्रतिक्रियाशीलता को कम कर सकते हैं।", + "transcoding_temporal_aq": "अस्थायी AQ", + "transcoding_temporal_aq_description": "केवल एनवीईएनसी पर लागू होता है।", + "transcoding_threads": "थ्रेड्स", + "transcoding_threads_description": "उच्च मान तेज़ एन्कोडिंग की ओर ले जाते हैं, लेकिन सक्रिय रहते हुए सर्वर के लिए अन्य कार्यों को संसाधित करने के लिए कम जगह छोड़ते हैं।", + "transcoding_tone_mapping": "टोन-मैपिंग", + "transcoding_tone_mapping_description": "एसडीआर में परिवर्तित होने पर एचडीआर वीडियो की उपस्थिति को संरक्षित करने का प्रयास।", + "transcoding_tone_mapping_npl": "टोन-मैपिंग एनपीएल", + "transcoding_tone_mapping_npl_description": "इस चमक के प्रदर्शन को सामान्य दिखाने के लिए रंगों को समायोजित किया जाएगा।", + "transcoding_transcode_policy": "ट्रांसकोड नीति", + "transcoding_transcode_policy_description": "किसी वीडियो को कब ट्रांसकोड किया जाना चाहिए, इसके लिए नीति।", + "transcoding_two_pass_encoding": "दो-पास एन्कोडिंग", + "transcoding_two_pass_encoding_setting_description": "बेहतर एन्कोडेड वीडियो बनाने के लिए दो पासों में ट्रांसकोड करें।", + "transcoding_video_codec": "वीडियो कोडेक", + "transcoding_video_codec_description": "VP9 में उच्च दक्षता और वेब अनुकूलता है, लेकिन ट्रांसकोड करने में अधिक समय लगता है।", + "trash_enabled_description": "ट्रैश सुविधाएँ सक्षम करें", + "trash_number_of_days": "दिनों की संख्या", + "trash_number_of_days_description": "संपत्तियों को स्थायी रूप से हटाने से पहले उन्हें कूड़ेदान में रखने के लिए दिनों की संख्या", + "trash_settings": "ट्रैश सेटिंग", + "trash_settings_description": "ट्रैश सेटिंग प्रबंधित करें", + "untracked_files": "ट्रैक न की गई फ़ाइलें", + "untracked_files_description": "इन फ़ाइलों को एप्लिकेशन द्वारा ट्रैक नहीं किया जाता है. वे असफल चालों, बाधित अपलोड या किसी बग के कारण पीछे छूट जाने का परिणाम हो सकते हैं", + "user_delete_delay_settings": "हटाने में देरी", + "user_delete_delay_settings_description": "किसी उपयोगकर्ता के खाते और संपत्तियों को स्थायी रूप से हटाने के लिए हटाने के बाद दिनों की संख्या।", + "user_delete_immediately_checkbox": "तत्काल विलोपन के लिए उपयोगकर्ता और परिसंपत्तियों को कतारबद्ध करें", + "user_management": "प्रयोक्ता प्रबंधन", + "user_password_has_been_reset": "उपयोगकर्ता का पासवर्ड रीसेट कर दिया गया है:", + "user_password_reset_description": "कृपया उपयोगकर्ता को अस्थायी पासवर्ड प्रदान करें और उन्हें सूचित करें कि उन्हें अपने अगले लॉगिन पर पासवर्ड बदलने की आवश्यकता होगी।", + "user_settings": "उपयोगकर्ता सेटिंग", + "user_settings_description": "उपयोगकर्ता सेटिंग प्रबंधित करें", + "version_check_enabled_description": "नई रिलीज़ की जाँच के लिए GitHub पर आवधिक अनुरोध सक्षम करें", + "version_check_settings": "संस्करण चेक", + "version_check_settings_description": "नए संस्करण अधिसूचना को सक्षम/अक्षम करें", + "video_conversion_job": "ट्रांसकोड वीडियो", + "video_conversion_job_description": "ब्राउज़रों और उपकरणों के साथ व्यापक अनुकूलता के लिए वीडियो ट्रांसकोड करें" }, - "admin_email": "", - "admin_password": "", - "administration": "", - "advanced": "", - "album_added": "", - "album_added_notification_setting_description": "", - "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", - "album_options": "", - "album_updated": "", - "album_updated_setting_description": "", - "albums": "", - "all": "", - "all_people": "", - "allow_dark_mode": "", - "allow_edits": "", - "api_key": "", - "api_keys": "", - "app_settings": "", - "appears_in": "", - "archive": "", - "archive_or_unarchive_photo": "", + "admin_email": "व्यवस्थापक ईमेल", + "admin_password": "व्यवस्थापक पासवर्ड", + "administration": "प्रशासन", + "advanced": "विकसित", + "album_added": "एल्बम जोड़ा गया", + "album_added_notification_setting_description": "जब आपको किसी साझा एल्बम में जोड़ा जाए तो एक ईमेल सूचना प्राप्त करें", + "album_cover_updated": "एल्बम कवर अपडेट किया गया", + "album_info_updated": "एल्बम की जानकारी अपडेट की गई", + "album_leave": "एल्बम छोड़ें?", + "album_name": "एल्बम का नाम", + "album_options": "एल्बम विकल्प", + "album_remove_user": "उपयोगकर्ता हटाएं?", + "album_share_no_users": "ऐसा लगता है कि आपने यह एल्बम सभी उपयोगकर्ताओं के साथ साझा कर दिया है या आपके पास साझा करने के लिए कोई उपयोगकर्ता नहीं है।", + "album_updated": "एल्बम अपडेट किया गया", + "album_updated_setting_description": "जब किसी साझा एल्बम में नई संपत्तियाँ हों तो एक ईमेल सूचना प्राप्त करें", + "album_with_link_access": "लिंक वाले किसी भी व्यक्ति को इस एल्बम में फ़ोटो और लोगों को देखने दें।", + "albums": "एलबम", + "all": "सभी", + "all_albums": "सभी एलबम", + "all_people": "सभी लोग", + "all_videos": "सभी वीडियो", + "allow_dark_mode": "डार्क मोड की अनुमति दें", + "allow_edits": "संपादन की अनुमति दें", + "allow_public_user_to_download": "सार्वजनिक उपयोगकर्ता को डाउनलोड करने की अनुमति दें", + "allow_public_user_to_upload": "सार्वजनिक उपयोगकर्ता को अपलोड करने की अनुमति दें", + "api_key": "एपीआई की", + "api_key_description": "यह की केवल एक बार दिखाई जाएगी। विंडो बंद करने से पहले कृपया इसे कॉपी करना सुनिश्चित करें।।", + "api_key_empty": "आपका एपीआई कुंजी नाम खाली नहीं होना चाहिए", + "api_keys": "एपीआई कीज", + "app_settings": "एप्लिकेशन सेटिंग", + "appears_in": "प्रकट होता है", + "archive": "संग्रहालय", + "archive_or_unarchive_photo": "फ़ोटो को संग्रहीत या असंग्रहीत करें", + "archive_size": "पुरालेख आकार", + "archive_size_description": "डाउनलोड के लिए संग्रह आकार कॉन्फ़िगर करें (GiB में)", "archived": "", - "asset_offline": "", - "assets": "", - "authorized_devices": "", + "are_these_the_same_person": "क्या ये वही व्यक्ति हैं?", + "are_you_sure_to_do_this": "क्या आप वास्तव में इसे करना चाहते हैं?", + "asset_added_to_album": "एल्बम में जोड़ा गया", + "asset_adding_to_album": "एल्बम में जोड़ा जा रहा है..।", + "asset_description_updated": "संपत्ति विवरण अद्यतन कर दिया गया है", + "asset_has_unassigned_faces": "एसेट में अनिर्धारित चेहरे हैं", + "asset_hashing": "हैशिंग..।", + "asset_offline": "संपत्ति ऑफ़लाइन", + "asset_offline_description": "यह संपत्ति ऑफ़लाइन है।", + "asset_skipped": "छोड़ा गया", + "asset_uploaded": "अपलोड किए गए", + "asset_uploading": "अपलोड हो रहा है..।", + "assets": "संपत्तियां", + "assets_restore_confirmation": "क्या आप वाकई अपनी सभी नष्ट की गई संपत्तियों को पुनर्स्थापित करना चाहते हैं? आप इस क्रिया को पूर्ववत नहीं कर सकते!", + "authorized_devices": "अधिकृत उपकरण", "back": "वापस", - "backward": "", - "blurred_background": "", - "camera": "", - "camera_brand": "", - "camera_model": "", - "cancel": "", - "cancel_search": "", - "cannot_merge_people": "", - "cannot_update_the_description": "", + "back_close_deselect": "वापस जाएँ, बंद करें, या अचयनित करें", + "backward": "पिछला", + "birthdate_saved": "जन्मतिथि सफलतापूर्वक सहेजी गई", + "birthdate_set_description": "जन्मतिथि का उपयोग फोटो के समय इस व्यक्ति की आयु की गणना करने के लिए किया जाता है।", + "blurred_background": "धुंधली पृष्ठभूमि", + "build": "निर्माण", + "build_image": "छवि बनाएँ", + "buy": "इम्मीच खरीदो", + "camera": "कैमरा", + "camera_brand": "कैमरा ब्रांड", + "camera_model": "कैमरा मॉडल", + "cancel": "रद्द करना", + "cancel_search": "खोज रद्द करें", + "cannot_merge_people": "लोगों का विलय नहीं हो सकता", + "cannot_undo_this_action": "आप इस क्रिया को पूर्ववत नहीं कर सकते!", + "cannot_update_the_description": "विवरण अद्यतन नहीं किया जा सकता", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", "cant_search_places": "", - "change_date": "", + "change_date": "बदलाव दिनांक", "change_expiration_time": "समाप्ति समय बदलें", - "change_location": "", - "change_name": "", - "change_name_successfully": "", - "change_password": "", - "change_your_password": "", - "changed_visibility_successfully": "", - "check_logs": "", - "city": "", - "clear": "", - "clear_all": "", - "clear_message": "", - "clear_value": "", - "close": "", - "collapse_all": "", - "color_theme": "", - "comment_options": "", - "comments_are_disabled": "", - "confirm": "", - "confirm_admin_password": "", - "confirm_password": "", - "contain": "", - "context": "", - "continue": "", - "copied_image_to_clipboard": "", - "copy_error": "", - "copy_file_path": "", - "copy_image": "", - "copy_link": "", - "copy_link_to_clipboard": "", - "copy_password": "", - "copy_to_clipboard": "", - "country": "", - "cover": "", - "covers": "", - "create": "", - "create_album": "", - "create_library": "", + "change_location": "स्थान बदलें", + "change_name": "नाम परिवर्तन करें", + "change_name_successfully": "नाम सफलतापूर्वक बदलें", + "change_password": "पासवर्ड बदलें", + "change_password_description": "यह या तो पहली बार है जब आप सिस्टम में साइन इन कर रहे हैं या आपका पासवर्ड बदलने का अनुरोध किया गया है।", + "change_your_password": "अपना पासवर्ड बदलें", + "changed_visibility_successfully": "दृश्यता सफलतापूर्वक परिवर्तित", + "check_all": "सभी चेक करें", + "check_logs": "लॉग जांचें", + "choose_matching_people_to_merge": "मर्ज करने के लिए मिलते-जुलते लोगों को चुनें", + "city": "शहर", + "clear": "स्पष्ट", + "clear_all": "सभी साफ करें", + "clear_all_recent_searches": "सभी हालिया खोजें साफ़ करें", + "clear_message": "स्पष्ट संदेश", + "clear_value": "स्पष्ट मूल्य", + "close": "बंद", + "collapse": "गिर जाना", + "collapse_all": "सभी को संकुचित करें", + "color_theme": "रंग थीम", + "comment_deleted": "टिप्पणी हटा दी गई", + "comment_options": "टिप्पणी विकल्प", + "comments_and_likes": "टिप्पणियाँ और पसंद", + "comments_are_disabled": "टिप्पणियाँ अक्षम हैं", + "confirm": "पुष्टि", + "confirm_admin_password": "एडमिन पासवर्ड की पुष्टि करें", + "confirm_delete_shared_link": "क्या आप वाकई इस साझा लिंक को हटाना चाहते हैं?", + "confirm_password": "पासवर्ड की पुष्टि कीजिये", + "contain": "समाहित", + "context": "संदर्भ", + "continue": "जारी", + "copied_image_to_clipboard": "छवि को क्लिपबोर्ड पर कॉपी किया गया।", + "copied_to_clipboard": "क्लिपबोर्ड पर नकल!", + "copy_error": "प्रतिलिपि त्रुटि", + "copy_file_path": "फ़ाइल पथ कॉपी करें", + "copy_image": "नकल छवि", + "copy_link": "लिंक की प्रतिलिपि करें", + "copy_link_to_clipboard": "लिंक को क्लिपबोर्ड पर कॉपी करें", + "copy_password": "पासवर्ड कॉपी करें", + "copy_to_clipboard": "क्लिपबोर्ड पर कॉपी करें", + "country": "देश", + "cover": "पूर्ण आवरण", + "covers": "आवरण", + "create": "तैयार करें", + "create_album": "एल्बम बनाओ", + "create_library": "लाइब्रेरी बनाएं", "create_link": "लिंक बनाएं", "create_link_to_share": "शेयर करने के लिए लिंक बनाएं", - "create_new_person": "", - "create_new_user": "", - "create_user": "", - "created": "", - "current_device": "", - "custom_locale": "", - "custom_locale_description": "", - "dark": "", - "date_after": "", - "date_and_time": "", - "date_before": "", - "date_range": "", - "day": "", - "default_locale": "", - "default_locale_description": "", - "delete": "", - "delete_album": "", - "delete_key": "", - "delete_library": "", - "delete_link": "", + "create_link_to_share_description": "लिंक वाले किसी भी व्यक्ति को चयनित फ़ोटो देखने दें", + "create_new_person": "नया व्यक्ति बनाएं", + "create_new_person_hint": "चयनित संपत्तियों को एक नए व्यक्ति को सौंपें", + "create_new_user": "नया उपयोगकर्ता बनाएं", + "create_user": "उपयोगकर्ता बनाइये", + "created": "बनाया", + "current_device": "वर्तमान उपकरण", + "custom_locale": "कस्टम लोकेल", + "custom_locale_description": "भाषा और क्षेत्र के आधार पर दिनांक और संख्याएँ प्रारूपित करें", + "dark": "डार्क", + "date_after": "इसके बाद की तारीख", + "date_and_time": "तिथि और समय", + "date_before": "पहले की तारीख", + "date_of_birth_saved": "जन्मतिथि सफलतापूर्वक सहेजी गई", + "date_range": "तिथि सीमा", + "day": "दिन", + "deduplicate_all": "सभी को डुप्लिकेट करें", + "default_locale": "डिफ़ॉल्ट स्थान", + "default_locale_description": "अपने ब्राउज़र स्थान के आधार पर दिनांक और संख्याएँ प्रारूपित करें", + "delete": "हटाएँ", + "delete_album": "एल्बम हटाएँ", + "delete_api_key_prompt": "क्या आप वाकई इस एपीआई कुंजी को हटाना चाहते हैं?", + "delete_duplicates_confirmation": "क्या आप वाकई इन डुप्लिकेट को स्थायी रूप से हटाना चाहते हैं?", + "delete_key": "कुंजी हटाएँ", + "delete_library": "लाइब्रेरी हटाएँ", + "delete_link": "लिंक हटाएँ", "delete_shared_link": "साझा किए गए लिंक को हटाएं", - "delete_user": "", - "deleted_shared_link": "", - "description": "विवरण", - "details": "", - "direction": "", - "disallow_edits": "", - "discover": "", - "dismiss_all_errors": "", - "dismiss_error": "", - "display_options": "", - "display_order": "", - "display_original_photos": "", - "display_original_photos_setting_description": "", - "done": "", - "download": "", - "downloading": "", - "duration": "", + "delete_user": "उपभोक्ता मिटायें", + "deleted_shared_link": "साझा किया गया लिंक हटा दिया गया", + "description": "वर्णन", + "details": "विवरण", + "direction": "दिशा", + "disabled": "अक्षम", + "disallow_edits": "संपादनों की अनुमति न दें", + "discover": "खोजें", + "dismiss_all_errors": "सभी त्रुटियाँ ख़ारिज करें", + "dismiss_error": "त्रुटि ख़ारिज करें", + "display_options": "प्रदर्शन चुनाव", + "display_order": "आदेश को प्रदर्शित करें", + "display_original_photos": "मूल फ़ोटो प्रदर्शित करें", + "display_original_photos_setting_description": "किसी संपत्ति को देखते समय थंबनेल के बजाय मूल तस्वीर प्रदर्शित करना पसंद करें जब मूल संपत्ति वेब-संगत हो।", + "do_not_show_again": "इस संदेश को दुबारा मत दिखाना", + "done": "ठीक है", + "download": "डाउनलोड करें", + "download_settings": "डाउनलोड करना", + "download_settings_description": "संपत्ति डाउनलोड से संबंधित सेटिंग्स प्रबंधित करें", + "downloading": "डाउनलोड", + "drop_files_to_upload": "अपलोड करने के लिए फ़ाइलें कहीं भी छोड़ें", + "duplicates": "डुप्लिकेट", + "duplicates_description": "प्रत्येक समूह को यह इंगित करके हल करें कि कौन सा, यदि कोई है, डुप्लिकेट है", + "duration": "अवधि", "durations": { "days": "", "hours": "", @@ -387,443 +494,675 @@ "months": "", "years": "" }, - "edit_album": "", - "edit_avatar": "", - "edit_date": "", - "edit_date_and_time": "", - "edit_exclusion_pattern": "", - "edit_faces": "", - "edit_import_path": "", - "edit_import_paths": "", - "edit_key": "", + "edit": "संपादन करना", + "edit_album": "एल्बम संपादित करें", + "edit_avatar": "अवतार को एडिट करें", + "edit_date": "संपादन की तारीख", + "edit_date_and_time": "दिनांक और समय संपादित करें", + "edit_exclusion_pattern": "बहिष्करण पैटर्न संपादित करें", + "edit_faces": "चेहरे संपादित करें", + "edit_import_path": "आयात पथ संपादित करें", + "edit_import_paths": "आयात पथ संपादित करें", + "edit_key": "कुंजी संपादित करें", "edit_link": "लिंक संपादित करें", - "edit_location": "", - "edit_name": "", - "edit_people": "", - "edit_title": "", - "edit_user": "", - "edited": "", + "edit_location": "स्थान संपादित करें", + "edit_name": "नाम संपादित करें", + "edit_people": "लोगों को संपादित करें", + "edit_title": "शीर्षक संपादित करें", + "edit_user": "यूजर को संपादित करो", + "edited": "संपादित", "editor": "", - "email": "", + "email": "ईमेल", "empty": "", "empty_album": "", "empty_trash": "कूड़ेदान खाली करें", - "enable": "", - "enabled": "", - "end_date": "", - "error": "", - "error_loading_image": "", + "empty_trash_confirmation": "क्या आपको यकीन है कि आप कचरा खाली करना चाहते हैं? यह इमिच से स्थायी रूप से कचरा में सभी संपत्तियों को हटा देगा।\nआप इस कार्रवाई को नहीं रोक सकते!", + "enable": "सक्षम", + "enabled": "सक्रिय", + "end_date": "अंतिम तिथि", + "error": "गलती", + "error_loading_image": "छवि लोड करने में त्रुटि", + "error_title": "त्रुटि - कुछ गलत हो गया", "errors": { - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", + "cannot_navigate_next_asset": "अगली संपत्ति पर नेविगेट नहीं किया जा सकता", + "cannot_navigate_previous_asset": "पिछली संपत्ति पर नेविगेट नहीं किया जा सकता", + "cant_apply_changes": "परिवर्तन लागू नहीं कर सकते", + "cant_change_asset_favorite": "संपत्ति के लिए पसंदीदा नहीं बदला जा सकता", + "cant_get_faces": "चेहरे नहीं मिल सके", + "cant_get_number_of_comments": "टिप्पणियों की संख्या नहीं मिल सकी", + "cant_search_people": "लोगों को खोजा नहीं जा सकता", + "cant_search_places": "स्थान खोज नहीं सकते", + "error_adding_assets_to_album": "एल्बम में संपत्ति जोड़ने में त्रुटि", + "error_adding_users_to_album": "एल्बम में उपयोगकर्ताओं को जोड़ने में त्रुटि", + "error_deleting_shared_user": "साझा उपयोगकर्ता को हटाने में त्रुटि", + "error_hiding_buy_button": "खरीदें बटन छिपाने में त्रुटि", + "error_removing_assets_from_album": "एल्बम से संपत्तियों को हटाने में त्रुटि, अधिक विवरण के लिए कंसोल की जाँच करें", + "error_selecting_all_assets": "सभी परिसंपत्तियों का चयन करने में त्रुटि", + "exclusion_pattern_already_exists": "यह बहिष्करण पैटर्न पहले से मौजूद है।", + "failed_to_create_album": "एल्बम बनाने में विफल", + "failed_to_create_shared_link": "साझा लिंक बनाने में विफल", + "failed_to_edit_shared_link": "साझा लिंक संपादित करने में विफल", + "failed_to_get_people": "लोगों को पाने में विफल", + "failed_to_load_asset": "परिसंपत्ति लोड करने में विफल", + "failed_to_load_assets": "परिसंपत्तियाँ लोड करने में विफल", + "failed_to_load_people": "लोगों को लोड करने में विफल", + "failed_to_remove_product_key": "उत्पाद कुंजी निकालने में विफल", + "failed_to_stack_assets": "परिसंपत्तियों का ढेर लगाने में विफल", + "failed_to_unstack_assets": "परिसंपत्तियों का ढेर खोलने में विफल", + "import_path_already_exists": "यह आयात पथ पहले से मौजूद है।", + "incorrect_email_or_password": "गलत ईमेल या पासवर्ड", + "profile_picture_transparent_pixels": "प्रोफ़ाइल चित्रों में पारदर्शी पिक्सेल नहीं हो सकते।", + "quota_higher_than_disk_size": "आपने डिस्क आकार से अधिक कोटा निर्धारित किया है", + "unable_to_add_album_users": "उपयोगकर्ताओं को एल्बम में जोड़ने में असमर्थ", + "unable_to_add_assets_to_shared_link": "साझा लिंक में संपत्ति जोड़ने में असमर्थ", + "unable_to_add_comment": "टिप्पणी जोड़ने में असमर्थ", + "unable_to_add_exclusion_pattern": "बहिष्करण पैटर्न जोड़ने में असमर्थ", + "unable_to_add_import_path": "आयात पथ जोड़ने में असमर्थ", + "unable_to_add_partners": "साझेदार जोड़ने में असमर्थ", + "unable_to_change_album_user_role": "एल्बम उपयोगकर्ता की भूमिका बदलने में असमर्थ", + "unable_to_change_date": "दिनांक बदलने में असमर्थ", + "unable_to_change_favorite": "संपत्ति के लिए पसंदीदा बदलने में असमर्थ", + "unable_to_change_location": "स्थान बदलने में असमर्थ", + "unable_to_change_password": "पासवर्ड बदलने में असमर्थ", "unable_to_check_item": "", "unable_to_check_items": "", - "unable_to_create_admin_account": "", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", - "unable_to_delete_user": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", + "unable_to_complete_oauth_login": "OAuth लॉगिन पूर्ण करने में असमर्थ", + "unable_to_connect": "कनेक्ट करने में असमर्थ", + "unable_to_connect_to_server": "सर्वर से कनेक्ट करने में असमर्थ है", + "unable_to_copy_to_clipboard": "क्लिपबोर्ड पर कॉपी नहीं किया जा सकता, सुनिश्चित करें कि आप https के माध्यम से पेज तक पहुंच रहे हैं", + "unable_to_create_admin_account": "व्यवस्थापक खाता बनाने में असमर्थ", + "unable_to_create_api_key": "नई API कुंजी बनाने में असमर्थ", + "unable_to_create_library": "लाइब्रेरी बनाने में असमर्थ", + "unable_to_create_user": "उपयोगकर्ता बनाने में असमर्थ", + "unable_to_delete_album": "एल्बम हटाने में असमर्थ", + "unable_to_delete_asset": "संपत्ति हटाने में असमर्थ", + "unable_to_delete_assets": "संपत्तियों को हटाने में त्रुटि", + "unable_to_delete_exclusion_pattern": "बहिष्करण पैटर्न को हटाने में असमर्थ", + "unable_to_delete_import_path": "आयात पथ हटाने में असमर्थ", + "unable_to_delete_shared_link": "साझा लिंक हटाने में असमर्थ", + "unable_to_delete_user": "उपयोगकर्ता को हटाने में असमर्थ", + "unable_to_download_files": "फ़ाइलें डाउनलोड करने में असमर्थ", + "unable_to_edit_exclusion_pattern": "बहिष्करण पैटर्न संपादित करने में असमर्थ", + "unable_to_edit_import_path": "आयात पथ संपादित करने में असमर्थ", + "unable_to_empty_trash": "कचरा खाली करने में असमर्थ", + "unable_to_enter_fullscreen": "फ़ुलस्क्रीन दर्ज करने में असमर्थ", + "unable_to_exit_fullscreen": "फ़ुलस्क्रीन से बाहर निकलने में असमर्थ", + "unable_to_get_comments_number": "टिप्पणियों की संख्या प्राप्त करने में असमर्थ", + "unable_to_get_shared_link": "साझा लिंक प्राप्त करने में विफल", + "unable_to_hide_person": "व्यक्ति को छुपाने में असमर्थ", + "unable_to_link_oauth_account": "OAuth खाता लिंक करने में असमर्थ", + "unable_to_load_album": "एल्बम लोड करने में असमर्थ", + "unable_to_load_asset_activity": "परिसंपत्ति गतिविधि लोड करने में असमर्थ", + "unable_to_load_items": "आइटम लोड करने में असमर्थ", + "unable_to_load_liked_status": "पसंद की गई स्थिति लोड करने में असमर्थ", + "unable_to_log_out_all_devices": "सभी डिवाइसों को लॉग आउट करने में असमर्थ", + "unable_to_log_out_device": "डिवाइस लॉग आउट करने में असमर्थ", + "unable_to_login_with_oauth": "OAuth से लॉगिन करने में असमर्थ", + "unable_to_play_video": "वीडियो चलाने में असमर्थ", + "unable_to_reassign_assets_new_person": "किसी नये व्यक्ति को संपत्ति पुनः सौंपने में असमर्थ", + "unable_to_refresh_user": "उपयोगकर्ता को ताज़ा करने में असमर्थ", + "unable_to_remove_album_users": "उपयोगकर्ताओं को एल्बम से निकालने में असमर्थ", + "unable_to_remove_api_key": "API कुंजी निकालने में असमर्थ", + "unable_to_remove_assets_from_shared_link": "साझा लिंक से संपत्तियों को निकालने में असमर्थ", "unable_to_remove_comment": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_library": "लाइब्रेरी हटाने में असमर्थ", + "unable_to_remove_offline_files": "ऑफ़लाइन फ़ाइलें निकालने में असमर्थ", + "unable_to_remove_partner": "पार्टनर को हटाने में असमर्थ", + "unable_to_remove_reaction": "प्रतिक्रिया निकालने में असमर्थ", "unable_to_remove_user": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", - "unable_to_submit_job": "", - "unable_to_trash_asset": "", - "unable_to_unlink_account": "", - "unable_to_update_library": "", - "unable_to_update_location": "", - "unable_to_update_settings": "", - "unable_to_update_user": "" + "unable_to_repair_items": "वस्तुओं की मरम्मत करने में असमर्थ", + "unable_to_reset_password": "पासवर्ड रीसेट करने में असमर्थ", + "unable_to_resolve_duplicate": "डुप्लिकेट का समाधान करने में असमर्थ", + "unable_to_restore_assets": "संपत्तियों को पुनर्स्थापित करने में असमर्थ", + "unable_to_restore_trash": "कचरा पुनर्स्थापित करने में असमर्थ", + "unable_to_restore_user": "उपयोगकर्ता को पुनर्स्थापित करने में असमर्थ", + "unable_to_save_album": "एल्बम सहेजने में असमर्थ", + "unable_to_save_api_key": "एपीआई कुंजी सहेजने में असमर्थ", + "unable_to_save_date_of_birth": "जन्मतिथि सहेजने में असमर्थ", + "unable_to_save_name": "नाम सहेजने में असमर्थ", + "unable_to_save_profile": "प्रोफ़ाइल सहेजने में असमर्थ", + "unable_to_save_settings": "सेटिंग्स सहेजने में असमर्थ", + "unable_to_scan_libraries": "पुस्तकालयों को स्कैन करने में असमर्थ", + "unable_to_scan_library": "लाइब्रेरी स्कैन करने में असमर्थ", + "unable_to_set_feature_photo": "फ़ीचर फ़ोटो सेट करने में असमर्थ", + "unable_to_set_profile_picture": "प्रोफ़ाइल चित्र सेट करने में असमर्थ", + "unable_to_submit_job": "कार्य प्रस्तुत करने में असमर्थ", + "unable_to_trash_asset": "संपत्ति को ट्रैश करने में असमर्थ", + "unable_to_unlink_account": "खाता अनलिंक करने में असमर्थ", + "unable_to_update_album_cover": "एल्बम कवर अपडेट करने में असमर्थ", + "unable_to_update_album_info": "एल्बम जानकारी अद्यतन करने में असमर्थ", + "unable_to_update_library": "लाइब्रेरी अद्यतन करने में असमर्थ", + "unable_to_update_location": "स्थान अद्यतन करने में असमर्थ", + "unable_to_update_settings": "सेटिंग्स अपडेट करने में असमर्थ", + "unable_to_update_timeline_display_status": "समयरेखा प्रदर्शन स्थिति अद्यतन करने में असमर्थ", + "unable_to_update_user": "उपयोगकर्ता को अद्यतन करने में असमर्थ", + "unable_to_upload_file": "फाइल अपलोड करने में असमर्थ" }, "every_day_at_onepm": "", "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", - "exit_slideshow": "", - "expand_all": "", + "exif": "एक्सिफ", + "exit_slideshow": "स्लाइड शो से बाहर निकलें", + "expand_all": "सभी का विस्तार", "expire_after": "एक्सपायर आफ्टर", - "expired": "", - "explore": "", - "extension": "", - "external_libraries": "", + "expired": "खत्म हो चुका", + "explore": "अन्वेषण करना", + "export": "निर्यात", + "export_as_json": "JSON के रूप में निर्यात करें", + "extension": "विस्तार", + "external": "बाहरी", + "external_libraries": "बाहरी पुस्तकालय", + "face_unassigned": "सौंपे नहीं गए", "failed_to_get_people": "", - "favorite": "", - "favorite_or_unfavorite_photo": "", - "favorites": "", + "favorite": "पसंदीदा", + "favorite_or_unfavorite_photo": "पसंदीदा या नापसंद फोटो", + "favorites": "पसंदीदा", "feature": "", - "feature_photo_updated": "", + "feature_photo_updated": "फ़ीचर फ़ोटो अपडेट किया गया", "featurecollection": "", - "file_name": "", - "file_name_or_extension": "", - "filename": "", + "file_name": "फ़ाइल का नाम", + "file_name_or_extension": "फ़ाइल का नाम या एक्सटेंशन", + "filename": "फ़ाइल का नाम", "files": "", - "filetype": "", - "filter_people": "", - "fix_incorrect_match": "", - "force_re-scan_library_files": "", - "forward": "", - "general": "", - "get_help": "", - "getting_started": "", - "go_back": "", - "go_to_search": "", - "go_to_share_page": "", - "group_albums_by": "", - "has_quota": "", - "hide_gallery": "", - "hide_password": "", - "hide_person": "", - "host": "", - "hour": "", - "image": "", + "filetype": "फाइल का प्रकार", + "filter_people": "लोगों को फ़िल्टर करें", + "find_them_fast": "खोज के साथ नाम से उन्हें तेजी से ढूंढें", + "fix_incorrect_match": "ग़लत मिलान ठीक करें", + "force_re-scan_library_files": "सभी लाइब्रेरी फ़ाइलों को बलपूर्वक पुनः स्कैन करें", + "forward": "आगे", + "general": "सामान्य", + "get_help": "मदद लें", + "getting_started": "शुरू करना", + "go_back": "वापस जाओ", + "go_to_search": "खोज पर जाएँ", + "go_to_share_page": "शेयर पेज पर जाएं", + "group_albums_by": "इनके द्वारा समूह एल्बम..।", + "group_no": "कोई समूहीकरण नहीं", + "group_owner": "स्वामी द्वारा समूह", + "group_year": "वर्ष के अनुसार समूह", + "has_quota": "कोटा है", + "hide_all_people": "सभी लोगों को छुपाएं", + "hide_gallery": "गैलरी छिपाएँ", + "hide_password": "पासवर्ड छिपाएं", + "hide_person": "व्यक्ति छिपाएँ", + "hide_unnamed_people": "अनाम लोगों को छुपाएं", + "host": "मेज़बान", + "hour": "घंटा", + "image": "छवि", "img": "", - "immich_logo": "", - "import_path": "", - "in_archive": "", - "include_archived": "", - "include_shared_albums": "", - "include_shared_partner_assets": "", - "individual_share": "", - "info": "", + "immich_logo": "Immich लोगो", + "immich_web_interface": "इमिच वेब इंटरफ़ेस", + "import_from_json": "JSON से आयात करें", + "import_path": "आयात पथ", + "in_archive": "पुरालेख में", + "include_archived": "संग्रहीत शामिल करें", + "include_shared_albums": "साझा किए गए एल्बम शामिल करें", + "include_shared_partner_assets": "साझा भागीदार संपत्तियां शामिल करें", + "individual_share": "व्यक्तिगत हिस्सेदारी", + "info": "जानकारी", "interval": { - "day_at_onepm": "", + "day_at_onepm": "हर दिन दोपहर 1 बजे", "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" + "night_at_midnight": "हर रात आधी रात को", + "night_at_twoam": "हर रात 2 बजे" }, - "invite_people": "", - "invite_to_album": "", + "invite_people": "लोगो को निमंत्रण भेजो", + "invite_to_album": "एल्बम के लिए आमंत्रित करें", "job_settings_description": "", - "jobs": "", - "keep": "", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", - "leave": "", + "jobs": "नौकरियां", + "keep": "रखना", + "keep_all": "सभी रखना", + "keyboard_shortcuts": "कुंजीपटल अल्प मार्ग", + "language": "भाषा", + "language_setting_description": "अपनी पसंदीदा भाषा चुनें", + "last_seen": "अंतिम बार देखा गया", + "latest_version": "नवीनतम संस्करण", + "latitude": "अक्षांश", + "leave": "छुट्टी", "let_others_respond": "दूसरों को जवाब देने दें", - "level": "", - "library": "", - "library_options": "", - "light": "", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", - "list": "", - "loading": "", - "loading_search_results_failed": "", - "log_out": "", - "log_out_all_devices": "", - "login_has_been_disabled": "", - "look": "", - "loop_videos": "", - "loop_videos_description": "", - "make": "", + "level": "स्तर", + "library": "पुस्तकालय", + "library_options": "पुस्तकालय विकल्प", + "light": "रोशनी", + "like_deleted": "जैसे हटा दिया गया", + "link_options": "लिंक विकल्प", + "link_to_oauth": "OAuth से लिंक करें", + "linked_oauth_account": "लिंक किया गया OAuth खाता", + "list": "सूची", + "loading": "लोड हो रहा है", + "loading_search_results_failed": "खोज परिणाम लोड करना विफल रहा", + "log_out": "लॉग आउट", + "log_out_all_devices": "सभी डिवाइस लॉग आउट करें", + "logged_out_all_devices": "सभी डिवाइस लॉग आउट कर दिए गए", + "logged_out_device": "लॉग आउट डिवाइस", + "login": "लॉग इन करें", + "login_has_been_disabled": "लॉगिन अक्षम कर दिया गया है।", + "logout_all_device_confirmation": "क्या आप वाकई सभी डिवाइस से लॉग आउट करना चाहते हैं?", + "logout_this_device_confirmation": "क्या आप वाकई इस डिवाइस को लॉग आउट करना चाहते हैं?", + "longitude": "देशान्तर", + "look": "देखना", + "loop_videos": "लूप वीडियो", + "loop_videos_description": "विवरण व्यूअर में किसी वीडियो को स्वचालित रूप से लूप करने में सक्षम करें।", + "make": "बनाना", "manage_shared_links": "साझा किए गए लिंक का प्रबंधन करें", - "manage_sharing_with_partners": "", - "manage_the_app_settings": "", - "manage_your_account": "", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", - "map": "", - "map_marker_with_image": "", - "map_settings": "", - "media_type": "", - "memories": "", - "memories_setting_description": "", - "menu": "", - "merge": "", - "merge_people": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", - "missing": "", - "model": "", - "month": "", - "more": "", - "moved_to_trash": "", - "my_albums": "", - "name": "", - "name_or_nickname": "", - "never": "", - "new_api_key": "", - "new_password": "", - "new_person": "", - "new_user_created": "", - "newest_first": "", - "next": "", - "next_memory": "", - "no": "", - "no_albums_message": "", - "no_archived_assets_message": "", - "no_assets_message": "", - "no_exif_info_available": "", - "no_explore_results_message": "", - "no_favorites_message": "", - "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", - "no_shared_albums_message": "", - "not_in_any_album": "", - "notes": "", - "notification_toggle_setting_description": "", - "notifications": "", - "notifications_setting_description": "", - "oauth": "", - "offline": "", + "manage_sharing_with_partners": "साझेदारों के साथ साझाकरण प्रबंधित करें", + "manage_the_app_settings": "ऐप सेटिंग प्रबंधित करें", + "manage_your_account": "अपना खाता प्रबंधित करें", + "manage_your_api_keys": "अपनी एपीआई कुंजियाँ प्रबंधित करें", + "manage_your_devices": "अपने लॉग-इन डिवाइस प्रबंधित करें", + "manage_your_oauth_connection": "अपना OAuth कनेक्शन प्रबंधित करें", + "map": "नक्शा", + "map_marker_with_image": "छवि के साथ मानचित्र मार्कर", + "map_settings": "मानचित्र सेटिंग", + "matches": "माचिस", + "media_type": "मीडिया प्रकार", + "memories": "यादें", + "memories_setting_description": "आप अपनी यादों में जो देखते हैं उसे प्रबंधित करें", + "memory": "याद", + "menu": "मेन्यू", + "merge": "मर्ज", + "merge_people": "लोगों को मिलाओ", + "merge_people_limit": "आप एक समय में अधिकतम 5 चेहरों को ही मर्ज कर सकते हैं", + "merge_people_prompt": "क्या आप इन लोगों का विलय करना चाहते हैं? यह कार्रवाई अपरिवर्तनीय है।", + "merge_people_successfully": "लोगों को सफलतापूर्वक मर्ज करें", + "minimize": "छोटा करना", + "minute": "मिनट", + "missing": "गुम", + "model": "मॉडल", + "month": "महीना", + "more": "अधिक", + "moved_to_trash": "कूड़ेदान में ले जाया गया", + "my_albums": "मेरे एल्बम", + "name": "नाम", + "name_or_nickname": "नाम या उपनाम", + "never": "कभी नहीं", + "new_album": "नयी एल्बम", + "new_api_key": "नई एपीआई कुंजी", + "new_password": "नया पासवर्ड", + "new_person": "नया व्यक्ति", + "new_user_created": "नया उपयोगकर्ता बनाया गया", + "new_version_available": "नया संस्करण उपलब्ध है", + "newest_first": "नवीनतम पहले", + "next": "अगला", + "next_memory": "अगली स्मृति", + "no": "नहीं", + "no_albums_message": "अपनी फ़ोटो और वीडियो को व्यवस्थित करने के लिए एक एल्बम बनाएं", + "no_albums_with_name_yet": "ऐसा लगता है कि आपके पास अभी तक इस नाम का कोई एल्बम नहीं है।", + "no_albums_yet": "ऐसा लगता है कि आपके पास अभी तक कोई एल्बम नहीं है।", + "no_archived_assets_message": "फ़ोटो और वीडियो को अपने फ़ोटो दृश्य से छिपाने के लिए उन्हें संग्रहीत करें", + "no_assets_message": "अपना पहला फोटो अपलोड करने के लिए क्लिक करें", + "no_duplicates_found": "कोई नकलची नहीं मिला।", + "no_exif_info_available": "कोई एक्सिफ़ जानकारी उपलब्ध नहीं है", + "no_explore_results_message": "अपने संग्रह का पता लगाने के लिए और फ़ोटो अपलोड करें।", + "no_favorites_message": "अपनी सर्वश्रेष्ठ तस्वीरें और वीडियो तुरंत ढूंढने के लिए पसंदीदा जोड़ें", + "no_libraries_message": "अपनी फ़ोटो और वीडियो देखने के लिए एक बाहरी लाइब्रेरी बनाएं", + "no_name": "कोई नाम नहीं", + "no_places": "कोई जगह नहीं", + "no_results": "कोई परिणाम नहीं", + "no_results_description": "कोई पर्यायवाची या अधिक सामान्य कीवर्ड आज़माएँ", + "no_shared_albums_message": "अपने नेटवर्क में लोगों के साथ फ़ोटो और वीडियो साझा करने के लिए एक एल्बम बनाएं", + "not_in_any_album": "किसी एलबम में नहीं", + "note_apply_storage_label_to_previously_uploaded assets": "नोट: पहले अपलोड की गई संपत्तियों पर स्टोरेज लेबल लागू करने के लिए, चलाएँ", + "note_unlimited_quota": "नोट: असीमित कोटा के लिए 0 दर्ज करें", + "notes": "टिप्पणियाँ", + "notification_toggle_setting_description": "ईमेल सूचनाएं सक्षम करें", + "notifications": "सूचनाएं", + "notifications_setting_description": "सूचनाएं प्रबंधित करें", + "oauth": "OAuth", + "offline": "ऑफलाइन", + "offline_paths": "ऑफ़लाइन पथ", + "offline_paths_description": "ये परिणाम उन फ़ाइलों को मैन्युअल रूप से हटाने के कारण हो सकते हैं जो बाहरी लाइब्रेरी का हिस्सा नहीं हैं।", "ok": "ठीक है", - "oldest_first": "", - "online": "", - "only_favorites": "", - "only_refreshes_modified_files": "", - "open_the_search_filters": "", - "options": "", - "organize_your_library": "", - "other": "", - "other_devices": "", - "other_variables": "", - "owned": "", - "owner": "", - "partner_sharing": "", - "partners": "", + "oldest_first": "सबसे पुराना पहले", + "onboarding": "ज्ञानप्राप्ति", + "onboarding_theme_description": "अपने उदाहरण के लिए एक रंग थीम चुनें।", + "onboarding_welcome_description": "आइए कुछ सामान्य सेटिंग्स के साथ अपना इंस्टेंस सेट अप करें।", + "online": "ऑनलाइन", + "only_favorites": "केवल पसंदीदा", + "only_refreshes_modified_files": "केवल संशोधित फ़ाइलों को ताज़ा करता है", + "open_in_openstreetmap": "OpenStreetMap में खोलें", + "open_the_search_filters": "खोज फ़िल्टर खोलें", + "options": "विकल्प", + "or": "या", + "organize_your_library": "अपनी लाइब्रेरी व्यवस्थित करें", + "original": "मूल", + "other": "अन्य", + "other_devices": "अन्य उपकरण", + "other_variables": "अन्य चर", + "owned": "स्वामित्व", + "owner": "मालिक", + "partner": "साथी", + "partner_can_access_assets": "संग्रहीत और हटाए गए को छोड़कर आपके सभी फ़ोटो और वीडियो", + "partner_can_access_location": "वह स्थान जहां आपकी तस्वीरें ली गईं थीं", + "partner_sharing": "पार्टनर शेयरिंग", + "partners": "भागीदारों", "password": "पासवर्ड", - "password_does_not_match": "", - "password_required": "", - "password_reset_success": "", + "password_does_not_match": "पासवर्ड मैच नहीं कर रहा है", + "password_required": "पासवर्ड आवश्यक", + "password_reset_success": "पासवर्ड रीसेट सफल", "past_durations": { "days": "", "hours": "", "years": "" }, - "path": "", - "pattern": "", - "pause": "", - "pause_memories": "", - "paused": "", - "pending": "", - "people": "", - "people_sidebar_description": "", + "path": "पथ", + "pattern": "नमूना", + "pause": "विराम", + "pause_memories": "यादें रोकें", + "paused": "रोके गए", + "pending": "लंबित", + "people": "लोग", + "people_sidebar_description": "साइडबार में लोगों के लिए एक लिंक प्रदर्शित करें", "perform_library_tasks": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", - "permanently_deleted_asset": "", - "photos": "", - "photos_from_previous_years": "", - "pick_a_location": "", - "place": "", - "places": "", - "play": "", - "play_memories": "", - "play_motion_photo": "", - "play_or_pause_video": "", + "permanent_deletion_warning": "स्थायी विलोपन चेतावनी", + "permanent_deletion_warning_setting_description": "संपत्तियों को स्थायी रूप से हटाते समय एक चेतावनी दिखाएं", + "permanently_delete": "स्थायी रूप से हटाना", + "permanently_deleted_asset": "स्थायी रूप से हटाई गई संपत्ति", + "person": "व्यक्ति", + "photo_shared_all_users": "ऐसा लगता है कि आपने अपनी तस्वीरें सभी उपयोगकर्ताओं के साथ साझा कीं या आपके पास साझा करने के लिए कोई उपयोगकर्ता नहीं है।", + "photos": "तस्वीरें", + "photos_and_videos": "तस्वीरें और वीडियो", + "photos_from_previous_years": "पिछले वर्षों की तस्वीरें", + "pick_a_location": "एक स्थान चुनें", + "place": "जगह", + "places": "स्थानों", + "play": "खेल", + "play_memories": "यादें खेलें", + "play_motion_photo": "मोशन फ़ोटो चलाएं", + "play_or_pause_video": "वीडियो चलाएं या रोकें", "point": "", - "port": "", - "preset": "", - "preview": "", - "previous": "", - "previous_memory": "", - "previous_or_next_photo": "", - "primary": "", - "profile_picture_set": "", - "public_share": "", + "port": "पत्तन", + "preset": "प्रीसेट", + "preview": "पूर्व दर्शन", + "previous": "पहले का", + "previous_memory": "पिछली स्मृति", + "previous_or_next_photo": "पिछला या अगला फ़ोटो", + "primary": "प्राथमिक", + "profile_picture_set": "प्रोफ़ाइल चित्र सेट।", + "public_album": "सार्वजनिक एल्बम", + "public_share": "सार्वजनिक शेयर", + "purchase_account_info": "समर्थक", + "purchase_activated_subtitle": "इमिच और ओपन-सोर्स सॉफ़्टवेयर का समर्थन करने के लिए धन्यवाद", + "purchase_activated_title": "आपकी कुंजी सफलतापूर्वक सक्रिय कर दी गई है", + "purchase_button_activate": "सक्रिय", + "purchase_button_buy": "खरीदना", + "purchase_button_buy_immich": "इमिच खरीदें", + "purchase_button_never_show_again": "फिर कभी दिखाई मत देना", + "purchase_button_reminder": "मुझे 30 दिन में याद दिलाएं", + "purchase_button_remove_key": "कुंजी निकालें", + "purchase_button_select": "चुनना", + "purchase_failed_activation": "सक्रिय करने में विफल!", + "purchase_individual_description_1": "एक व्यक्ति के लिए", + "purchase_individual_description_2": "समर्थक स्थिति", + "purchase_individual_title": "व्यक्ति", + "purchase_input_suggestion": "क्या आपके पास उत्पाद कुंजी है? नीचे कुंजी दर्ज करें", + "purchase_license_subtitle": "सेवा के निरंतर विकास का समर्थन करने के लिए इमिच खरीदें", + "purchase_lifetime_description": "जीवन भर की खरीदारी", + "purchase_option_title": "खरीद विकल्प", + "purchase_panel_info_1": "इमिच को बनाने में बहुत समय और प्रयास लगता है, और हमारे पास इसे जितना संभव हो सके उतना अच्छा बनाने के लिए पूर्णकालिक इंजीनियर इस पर काम कर रहे हैं।", + "purchase_panel_info_2": "चूंकि हम पेवॉल नहीं जोड़ने के लिए प्रतिबद्ध हैं, इसलिए यह खरीदारी आपको इमिच में कोई अतिरिक्त सुविधाएं नहीं देगी।", + "purchase_panel_title": "परियोजना का समर्थन करें", + "purchase_per_server": "प्रति सर्वर", + "purchase_per_user": "प्रति उपयोगकर्ता", + "purchase_remove_product_key": "उत्पाद कुंजी निकालें", + "purchase_remove_product_key_prompt": "क्या आप वाकई उत्पाद कुंजी हटाना चाहते हैं?", + "purchase_remove_server_product_key": "सर्वर उत्पाद कुंजी निकालें", + "purchase_remove_server_product_key_prompt": "क्या आप वाकई सर्वर उत्पाद कुंजी को हटाना चाहते हैं?", + "purchase_server_description_1": "पूरे सर्वर के लिए", + "purchase_server_description_2": "समर्थक स्थिति", + "purchase_server_title": "सर्वर", + "purchase_settings_server_activated": "सर्वर उत्पाद कुंजी व्यवस्थापक द्वारा प्रबंधित की जाती है", "range": "", "raw": "", - "reaction_options": "", - "read_changelog": "", - "recent": "", - "recent_searches": "", - "refresh": "", - "refreshed": "", - "refreshes_every_file": "", - "remove": "", - "remove_from_album": "", - "remove_from_favorites": "", - "remove_from_shared_link": "", - "remove_offline_files": "", - "repair": "", - "repair_no_results_message": "", - "replace_with_upload": "", - "require_password": "", - "reset": "", - "reset_password": "", - "reset_people_visibility": "", + "reaction_options": "प्रतिक्रिया विकल्प", + "read_changelog": "चेंजलॉग पढ़ें", + "reassign": "पुनः असाइन", + "reassing_hint": "चयनित संपत्तियों को किसी मौजूदा व्यक्ति को सौंपें", + "recent": "हाल ही का", + "recent_searches": "हाल की खोजें", + "refresh": "ताज़ा करना", + "refresh_encoded_videos": "एन्कोडेड वीडियो ताज़ा करें", + "refresh_metadata": "मेटाडेटा ताज़ा करें", + "refresh_thumbnails": "थंबनेल ताज़ा करें", + "refreshed": "ताज़ा किया", + "refreshes_every_file": "प्रत्येक फ़ाइल को ताज़ा करता है", + "refreshing_encoded_video": "ताज़ा किया जा रहा एन्कोडेड वीडियो", + "refreshing_metadata": "ताज़ा मेटाडेटा", + "regenerating_thumbnails": "पुनर्जीवित थंबनेल", + "remove": "निकालना", + "remove_assets_title": "संपत्तियाँ हटाएँ?", + "remove_custom_date_range": "कस्टम दिनांक सीमा हटाएँ", + "remove_from_album": "एल्बम से हटाएँ", + "remove_from_favorites": "पसंदीदा से निकालें", + "remove_from_shared_link": "साझा लिंक से हटाएँ", + "remove_offline_files": "ऑफ़लाइन फ़ाइलें हटाएँ", + "remove_user": "उपयोगकर्ता को हटाएँ", + "removed_from_archive": "संग्रह से हटा दिया गया", + "removed_from_favorites": "पसंदीदा से हटाया गया", + "rename": "नाम बदलें", + "repair": "मरम्मत", + "repair_no_results_message": "ट्रैक न की गई और गुम फ़ाइलें यहां दिखाई देंगी", + "replace_with_upload": "अपलोड के साथ बदलें", + "repository": "कोष", + "require_password": "पासवर्ड की आवश्यकता है", + "require_user_to_change_password_on_first_login": "उपयोगकर्ता को पहले लॉगिन पर पासवर्ड बदलने की आवश्यकता है", + "reset": "रीसेट", + "reset_password": "पासवर्ड रीसेट", + "reset_people_visibility": "लोगों की दृश्यता रीसेट करें", "reset_settings_to_default": "", - "restore": "", - "restore_user": "", - "retry_upload": "", - "review_duplicates": "", - "role": "", - "save": "", - "saved_profile": "", - "saved_settings": "", + "reset_to_default": "वितथ पर ले जाएं", + "resolve_duplicates": "डुप्लिकेट का समाधान करें", + "resolved_all_duplicates": "सभी डुप्लिकेट का समाधान किया गया", + "restore": "पुनर्स्थापित करना", + "restore_all": "सभी बहाल करो", + "restore_user": "उपयोगकर्ता को पुनर्स्थापित करें", + "restored_asset": "पुनर्स्थापित संपत्ति", + "resume": "फिर शुरू करना", + "retry_upload": "पुनः अपलोड करने का प्रयास करें", + "review_duplicates": "डुप्लिकेट की समीक्षा करें", + "role": "भूमिका", + "role_editor": "संपादक", + "role_viewer": "दर्शक", + "save": "बचाना", + "saved_api_key": "सहेजी गई एपीआई कुंजी", + "saved_profile": "प्रोफ़ाइल सहेजी गई", + "saved_settings": "सहेजी गई सेटिंग्स", "say_something": "कुछ कहें", - "scan_all_libraries": "", - "scan_all_library_files": "", - "scan_new_library_files": "", - "scan_settings": "", - "search": "", - "search_albums": "", - "search_by_context": "", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", - "search_for_existing_person": "", - "search_people": "", - "search_places": "", - "search_state": "", - "search_timezone": "", - "search_type": "", - "search_your_photos": "", - "searching_locales": "", - "second": "", - "select_album_cover": "", - "select_all": "", - "select_avatar_color": "", - "select_face": "", - "select_featured_photo": "", - "select_library_owner": "", - "select_new_face": "", - "select_photos": "", - "selected": "", - "send_message": "", + "scan_all_libraries": "सभी पुस्तकालयों को स्कैन करें", + "scan_all_library_files": "सभी लाइब्रेरी फ़ाइलों को पुनः स्कैन करें", + "scan_new_library_files": "नई लाइब्रेरी फ़ाइलें स्कैन करें", + "scan_settings": "सेटिंग्स स्कैन करें", + "scanning_for_album": "एल्बम के लिए स्कैन किया जा रहा है..।", + "search": "खोज", + "search_albums": "एल्बम खोजें", + "search_by_context": "संदर्भ के आधार पर खोजें", + "search_by_filename": "फ़ाइल नाम या एक्सटेंशन के आधार पर खोजें", + "search_by_filename_example": "यानी IMG_1234.JPG या PNG", + "search_camera_make": "कैमरा निर्माण खोजें..।", + "search_camera_model": "कैमरा मॉडल खोजें..।", + "search_city": "शहर खोजें..।", + "search_country": "देश खोजें..।", + "search_for_existing_person": "मौजूदा व्यक्ति को खोजें", + "search_no_people": "कोई लोग नहीं", + "search_people": "लोगों को खोजें", + "search_places": "स्थान खोजें", + "search_state": "स्थिति खोजें..।", + "search_timezone": "समयक्षेत्र खोजें..।", + "search_type": "तलाश की विधि", + "search_your_photos": "अपनी फ़ोटो खोजें", + "searching_locales": "स्थान खोजे जा रहे हैं..।", + "second": "दूसरा", + "see_all_people": "सभी लोगों को देखें", + "select_album_cover": "एल्बम कवर चुनें", + "select_all": "सबका चयन करें", + "select_all_duplicates": "सभी डुप्लिकेट का चयन करें", + "select_avatar_color": "अवतार रंग चुनें", + "select_face": "चेहरा चुनें", + "select_featured_photo": "चुनिंदा फ़ोटो चुनें", + "select_from_computer": "कंप्यूटर से चयन करें", + "select_keep_all": "सभी रखें का चयन करें", + "select_library_owner": "लाइब्रेरी स्वामी का चयन करें", + "select_new_face": "नया चेहरा चुनें", + "select_photos": "फ़ोटो चुनें", + "select_trash_all": "ट्रैश ऑल का चयन करें", + "selected": "चयनित", + "send_message": "मेसेज भेजें", + "send_welcome_email": "स्वागत ईमेल भेजें", "server": "", - "server_stats": "", - "set": "", - "set_as_album_cover": "", - "set_as_profile_picture": "", - "set_date_of_birth": "", - "set_profile_picture": "", - "set_slideshow_to_fullscreen": "", - "settings": "", - "settings_saved": "", - "share": "", - "shared": "", - "shared_by": "", - "shared_by_you": "", + "server_offline": "सर्वर ऑफ़लाइन", + "server_online": "सर्वर ऑनलाइन", + "server_stats": "सर्वर आँकड़े", + "server_version": "सर्वर संस्करण", + "set": "तय करना", + "set_as_album_cover": "एल्बम कवर के रूप में सेट करें", + "set_as_profile_picture": "प्रोफाइल चित्र के रूप में सेट", + "set_date_of_birth": "जन्मतिथि निर्धारित करें", + "set_profile_picture": "प्रोफ़ाइल चित्र सेट करें", + "set_slideshow_to_fullscreen": "स्लाइड शो को फ़ुलस्क्रीन पर सेट करें", + "settings": "समायोजन", + "settings_saved": "सेटिंग्स को सहेजा गया", + "share": "शेयर करना", + "shared": "साझा", + "shared_by": "द्वारा साझा", + "shared_by_you": "आपके द्वारा साझा किया गया", "shared_links": "साझा किए गए लिंक", - "sharing": "", - "sharing_sidebar_description": "", - "show_album_options": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", - "show_in_timeline": "", - "show_in_timeline_setting_description": "", - "show_keyboard_shortcuts": "", + "sharing": "शेयरिंग", + "sharing_enter_password": "कृपया इस पृष्ठ को देखने के लिए पासवर्ड दर्ज करें।", + "sharing_sidebar_description": "साइडबार में शेयरिंग के लिए एक लिंक प्रदर्शित करें", + "shift_to_permanent_delete": "संपत्ति को स्थायी रूप से हटाने के लिए ⇧ दबाएँ", + "show_album_options": "एल्बम विकल्प दिखाएँ", + "show_all_people": "सभी लोगों को दिखाओ", + "show_and_hide_people": "लोगों को दिखाएँ और छिपाएँ", + "show_file_location": "फ़ाइल स्थान दिखाएँ", + "show_gallery": "गैलरी दिखाएँ", + "show_hidden_people": "छुपे हुए लोगों को दिखाएं", + "show_in_timeline": "टाइमलाइन में दिखाएँ", + "show_in_timeline_setting_description": "अपनी टाइमलाइन में इस उपयोगकर्ता के फ़ोटो और वीडियो दिखाएं", + "show_keyboard_shortcuts": "कुंजीपटल शॉर्टकट दिखाएँ", "show_metadata": "मेटाडेटा दिखाएं", - "show_or_hide_info": "", - "show_password": "", - "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", - "shuffle": "", - "sign_up": "", - "size": "", - "skip_to_content": "", - "slideshow": "", - "slideshow_settings": "", - "sort_albums_by": "", - "stack": "", - "stack_selected_photos": "", - "stacktrace": "", - "start_date": "", - "state": "", - "status": "", - "stop_motion_photo": "", - "storage": "", - "storage_label": "", - "submit": "", - "suggestions": "", - "sunrise_on_the_beach": "", - "swap_merge_direction": "", - "sync": "", - "template": "", - "theme": "", - "theme_selection": "", - "theme_selection_description": "", - "time_based_memories": "", - "timezone": "", - "toggle_settings": "", - "toggle_theme": "", + "show_or_hide_info": "जानकारी दिखाएँ या छिपाएँ", + "show_password": "पासवर्ड दिखाए", + "show_person_options": "व्यक्ति विकल्प दिखाएँ", + "show_progress_bar": "प्रगति पट्टी दिखाएँ", + "show_search_options": "खोज विकल्प दिखाएँ", + "show_supporter_badge": "समर्थक बिल्ला", + "show_supporter_badge_description": "समर्थक बैज दिखाएँ", + "shuffle": "मिश्रण", + "sign_out": "साइन आउट", + "sign_up": "साइन अप करें", + "size": "आकार", + "skip_to_content": "इसे छोड़कर सामग्री पर बढ़ने के लिए", + "slideshow": "स्लाइड शो", + "slideshow_settings": "स्लाइड शो सेटिंग्स", + "sort_albums_by": "एल्बम को क्रमबद्ध करें..।", + "sort_created": "बनाया गया दिनांक", + "sort_items": "मदों की संख्या", + "sort_modified": "डेटा संशोधित", + "sort_oldest": "सबसे पुरानी तस्वीर", + "sort_recent": "सबसे ताज़ा फ़ोटो", + "sort_title": "शीर्षक", + "source": "स्रोत", + "stack": "ढेर", + "stack_selected_photos": "चयनित फ़ोटो को ढेर करें", + "stacktrace": "स्टैक ट्रेस", + "start": "शुरू", + "start_date": "आरंभ करने की तिथि", + "state": "राज्य", + "status": "स्थिति", + "stop_motion_photo": "स्टॉप मोशन फोटो", + "stop_photo_sharing": "अपनी तस्वीरें साझा करना बंद करें?", + "stop_sharing_photos_with_user": "इस उपयोगकर्ता के साथ अपनी तस्वीरें साझा करना बंद करें", + "storage": "स्टोरेज की जगह", + "storage_label": "भंडारण लेबल", + "submit": "जमा करना", + "suggestions": "सुझाव", + "sunrise_on_the_beach": "समुद्र तट पर सूर्योदय", + "swap_merge_direction": "मर्ज दिशा स्वैप करें", + "sync": "साथ-साथ करना", + "template": "खाका", + "theme": "विषय", + "theme_selection": "थीम चयन", + "theme_selection_description": "आपके ब्राउज़र की सिस्टम प्राथमिकता के आधार पर थीम को स्वचालित रूप से प्रकाश या अंधेरे पर सेट करें", + "they_will_be_merged_together": "इन्हें एक साथ मिला दिया जाएगा", + "time_based_memories": "समय आधारित यादें", + "timezone": "समय क्षेत्र", + "to_archive": "पुरालेख", + "to_change_password": "पासवर्ड बदलें", + "to_favorite": "पसंदीदा", + "to_login": "लॉग इन करें", + "to_trash": "कचरा", + "toggle_settings": "सेटिंग्स टॉगल करें", + "toggle_theme": "थीम टॉगल करें", "toggle_visibility": "", - "total_usage": "", - "trash": "", - "trash_all": "", - "trash_no_results_message": "", - "type": "", - "unarchive": "", + "total_usage": "कुल उपयोग", + "trash": "कचरा", + "trash_all": "सब कचरा", + "trash_delete_asset": "संपत्ति को ट्रैश/डिलीट करें", + "trash_no_results_message": "ट्रैश की गई फ़ोटो और वीडियो यहां दिखाई देंगे।", + "type": "प्रकार", + "unarchive": "संग्रह से निकालें", "unarchived": "", - "unfavorite": "", - "unhide_person": "", - "unknown": "", + "unfavorite": "नापसंद करें", + "unhide_person": "व्यक्ति को उजागर करें", + "unknown": "अज्ञात", "unknown_album": "", - "unknown_year": "", - "unlink_oauth": "", - "unlinked_oauth_account": "", - "unselect_all": "", + "unknown_year": "अज्ञात वर्ष", + "unlimited": "असीमित", + "unlink_oauth": "OAuth को अनलिंक करें", + "unlinked_oauth_account": "OAuth खाता अनलिंक किया गया", + "unnamed_album": "अनाम एल्बम", + "unnamed_share": "अनाम साझा करें", + "unsaved_change": "सहेजा न गया परिवर्तन", + "unselect_all": "सभी को अचयनित करें", + "unselect_all_duplicates": "सभी डुप्लिकेट को अचयनित करें", "unstack": "स्टैक रद्द करें", - "up_next": "", - "updated_password": "", - "upload": "", - "upload_concurrency": "", - "url": "", - "usage": "", - "user": "", - "user_id": "", - "user_usage_detail": "", - "username": "", - "users": "", - "utilities": "", - "validate": "", - "variables": "", - "version": "", - "video": "", - "video_hover_setting_description": "", - "videos": "", - "view_all": "", - "view_all_users": "", - "view_links": "", - "view_next_asset": "", - "view_previous_asset": "", + "untracked_files": "ट्रैक न की गई फ़ाइलें", + "untracked_files_decription": "इन फ़ाइलों को एप्लिकेशन द्वारा ट्रैक नहीं किया जाता है. वे असफल चालों, बाधित अपलोड या किसी बग के कारण पीछे छूट जाने का परिणाम हो सकते हैं", + "up_next": "अब अगला", + "updated_password": "अद्यतन पासवर्ड", + "upload": "डालना", + "upload_concurrency": "समवर्ती अपलोड करें", + "upload_status_duplicates": "डुप्लिकेट", + "upload_status_errors": "त्रुटियाँ", + "upload_status_uploaded": "अपलोड किए गए", + "upload_success": "अपलोड सफल रहा, नई अपलोड संपत्तियां देखने के लिए पेज को रीफ्रेश करें।", + "url": "यूआरएल", + "usage": "प्रयोग", + "use_custom_date_range": "इसके बजाय कस्टम दिनांक सीमा का उपयोग करें", + "user": "उपयोगकर्ता", + "user_id": "उपयोगकर्ता पहचान", + "user_purchase_settings": "खरीदना", + "user_purchase_settings_description": "अपनी खरीदारी प्रबंधित करें", + "user_usage_detail": "उपयोगकर्ता उपयोग विवरण", + "username": "उपयोगकर्ता नाम", + "users": "उपयोगकर्ताओं", + "utilities": "उपयोगिताओं", + "validate": "मान्य", + "variables": "चर", + "version": "संस्करण", + "version_announcement_closing": "आपका मित्र, एलेक्स", + "version_announcement_message": "नमस्कार मित्र, एप्लिकेशन का एक नया संस्करण है, कृपया अपना समय निकालकर इसे देखें रिलीज नोट्स और अपना सुनिश्चित करें docker-compose.yml, और .env किसी भी गलत कॉन्फ़िगरेशन को रोकने के लिए सेटअप अद्यतित है, खासकर यदि आप वॉचटावर या किसी भी तंत्र का उपयोग करते हैं जो आपके एप्लिकेशन को स्वचालित रूप से अपडेट करने का प्रबंधन करता है।", + "video": "वीडियो", + "video_hover_setting": "होवर पर वीडियो थंबनेल चलाएं", + "video_hover_setting_description": "जब माउस आइटम पर घूम रहा हो तो वीडियो थंबनेल चलाएं।", + "videos": "वीडियो", + "view": "देखना", + "view_album": "एल्बम देखें", + "view_all": "सभी को देखें", + "view_all_users": "सभी उपयोगकर्ताओं को देखें", + "view_links": "लिंक देखें", + "view_next_asset": "अगली संपत्ति देखें", + "view_previous_asset": "पिछली संपत्ति देखें", + "view_stack": "ढेर देखें", "viewer": "", - "waiting": "", - "week": "", - "welcome_to_immich": "", - "year": "", + "waiting": "इंतज़ार में", + "warning": "चेतावनी", + "week": "सप्ताह", + "welcome": "स्वागत", + "welcome_to_immich": "इमिच में आपका स्वागत है", + "year": "वर्ष", "yes": "हाँ", - "zoom_image": "" + "you_dont_have_any_shared_links": "आपके पास कोई साझा लिंक नहीं है", + "zoom_image": "छवि ज़ूम करें" } diff --git a/web/src/lib/i18n/hu.json b/web/src/lib/i18n/hu.json index 9006dd6060..c754035c7a 100644 --- a/web/src/lib/i18n/hu.json +++ b/web/src/lib/i18n/hu.json @@ -277,7 +277,7 @@ "transcoding_optimal_description": "A célfelbontást meghaladó vagy el nem fogadott formátumú videókat", "transcoding_preferred_hardware_device": "Átkódoláshoz preferált hardver eszköz", "transcoding_preferred_hardware_device_description": "Csak VAAPI vagy QSV esetén. Beállítja a hardveres transzkódoláshoz használt DRI node-ot.", - "transcoding_preset_preset": "", + "transcoding_preset_preset": "Beállítás (-preset)", "transcoding_preset_preset_description": "Tömörítési gyorsaság. Lassabb beállítások esetén kisebb fájlokat generál, valamint növeli a minőséget megcélzott bitráta esetén. A VP9 kódolás figyelmen kívül hagyja a `faster`-nél gyorsabb beállításokat.", "transcoding_reference_frames": "Referencia képkockák", "transcoding_reference_frames_description": "Ennyi képkockára hivatkozzon egy képkocka tömörítéséhez. Magasabb értékek növelik a tömörítési hatékonyságot, de lelassítják a kódolási folyamatot. 0 esetén a szoftver magának beállítja az értéket.", @@ -370,18 +370,33 @@ "archive_size": "Archívum mérete", "archive_size_description": "Beállítja letöltésnél az archívum méretét (GiB)", "archived": "Archíválva", - "asset_offline": "", + "archived_count": "{count, plural, other {Archived #}}", + "are_these_the_same_person": "Ugyanaz a személy?", + "are_you_sure_to_do_this": "Biztosan ezt akarod csinálni?", + "asset_added_to_album": "Hozzáadva az albumhoz", + "asset_adding_to_album": "Hozzáadás az albumhoz...", + "asset_description_updated": "A leírás frissült", + "asset_filename_is_offline": "A(z) {filename} elem offline állapotban van", + "asset_has_unassigned_faces": "Az elemnek hozzá nem rendelt arcai vannak", + "asset_hashing": "Hash számítása...", + "asset_offline": "Elem offline", + "asset_offline_description": "Ez az elem nem elérhető. Immich nem képes elérni a file helyét. Győződjön meg az elem elérhetőségéről és szkennelje újra a könyvtárat.", + "asset_skipped": "Kihagyva", + "asset_uploaded": "Feltöltve", + "asset_uploading": "Feltöltés...", "assets": "elemek", "assets_moved_to_trash": "{count, plural, one {# fájl} other {# fájl}} a lomtárba mozgatva", "assets_restore_confirmation": "Biztosan visszaállítja a lomtárbeli elemeket? Ez a művelet nem visszavonható!", "authorized_devices": "Engedélyezett készülékek", "back": "Vissza", + "back_close_deselect": "Vissza, bezárás, vagy kijelölés törlése", "backward": "Visszafele", "birthdate_saved": "Születésnap elmentve", "birthdate_set_description": "A születés napját a rendszer annak kijelzésére használja, hogy a fénykép készítésének idejében az illető hány éves volt.", "blurred_background": "Homályos háttér", "bulk_delete_duplicates_confirmation": "Biztosan kitöröl {count, plural, one {# duplikált fájlt} other {# duplikált fájlt}}? A művelet során minden hasonló fájlcsoportból a legnagyobb méretű fájlt megtartja, minden másik duplikált fájlt kitörli. Ez a művelet nem visszavonható!", "bulk_trash_duplicates_confirmation": "Biztosan kitöröl {count, plural, one {# duplikált fájlt} other {# duplikált fájlt}}? Ez a művelet megtartja minden fájlcsoportból a legnagyobb méretű elemet, és kitörli minden másik duplikáltat.", + "buy": "Immich megvásárlása", "camera": "Fényképezőgép", "camera_brand": "Fényképezőgép márka", "camera_model": "Fényképezőgép modell", @@ -403,11 +418,13 @@ "change_password_description": "Most jelentkezik be a rendszerbe első alkalommal, vagy valaki jelszóváltoztatást kezdeményezett. Kérem, írjon be új jelszót.", "change_your_password": "Jelszó megváltoztatása", "changed_visibility_successfully": "Láthatóság sikeresen megváltoztatva", + "check_all": "Jelenleg nincs használatban (v1.106.4)", "check_logs": "Hibajegyzék", "choose_matching_people_to_merge": "Válassza ki a megegyező személyeket összevonásra", "city": "Város", "clear": "Kitöröl", "clear_all": "Alaphelyzet", + "clear_all_recent_searches": "Legutóbbi keresések törlése", "clear_message": "Üzenet törlése", "clear_value": "Érték törlése", "close": "Bezárás", @@ -490,6 +507,7 @@ "download_settings_description": "Képi vagyontárgyak letöltésére vonatkozó beállítások", "downloading": "Letöltés", "downloading_asset_filename": "Fájl letöltése {filename}", + "drop_files_to_upload": "Húzza a fájlokat bárhova a feltöltéshez", "duplicates": "Duplikátumok", "duration": "Időtartam", "durations": { @@ -527,14 +545,44 @@ "end_date": "", "error": "Hiba", "error_loading_image": "Hiba a kép betöltése közben", + "error_title": "Hiba - valami félresikerült", "errors": { + "cannot_navigate_next_asset": "Nem lehet a következő elemhez navigálni", + "cannot_navigate_previous_asset": "Nem lehet az előző elemhez navigálni", + "cant_apply_changes": "Nem lehet alkalmazni a változtatásokat", + "cant_change_asset_favorite": "Nem lehet a kedvenc állapotot megváltoztatni ehhez az elemhez", + "cant_get_faces": "Arcok lekérdezése sikertelen", + "cant_get_number_of_comments": "Hozzászólások számának lekérdezése sikertelen", + "cant_search_people": "Emberek keresése sikertelen", + "cant_search_places": "Helyek keresése sikertelen", + "cleared_jobs": "A {job} munkák törölve", + "error_adding_assets_to_album": "Hiba történt az elemek albumhoz való hozzáadása során", + "error_adding_users_to_album": "Hiba történt a felhasználók albumhoz való hozzáadása során", + "error_deleting_shared_user": "Hiba történt megosztott felhasználó törlése során", + "error_downloading": "{filename} letöltése sikertelen", + "error_hiding_buy_button": "Hiba történt a megvásárlás gomb elrejtése során", + "error_removing_assets_from_album": "Hiba történt az elemek albumból való eltávolítása során, további információért ellenőrizze a logokat", + "error_selecting_all_assets": "Minden elem kijelölése közben hiba lépett fel", "exclusion_pattern_already_exists": "Ez a kizárási minta már létezik.", + "failed_to_create_album": "Album készítése sikertelen", + "failed_to_create_shared_link": "Megosztott link készítése sikertelen", + "failed_to_edit_shared_link": "Megosztott link szerkesztése sikertelen", + "failed_to_get_people": "Emberek lekérdezése sikertelen", + "failed_to_load_asset": "Elem betöltése sikertelen", + "failed_to_load_assets": "Elemek betöltése sikertelen", + "failed_to_load_people": "Emberek betöltése sikertelen", + "failed_to_remove_product_key": "Termékkulcs eltávolítása sikertelen", "import_path_already_exists": "Ez az importálási útvonal már létezik.", + "incorrect_email_or_password": "Helytelen e-mail vagy jelszó", "paths_validation_failed": "Sikertelen érvényesítés {paths, plural, one {# elérési útvonalon} other {# elérési útvonalon}}", + "profile_picture_transparent_pixels": "Profilképek nem tartalmazhatnak átlátszó pixeleket. Közelítsen rá és/vagy mozgassa a képet.", "quota_higher_than_disk_size": "Az elérhető háttértárnál nagyobb kvótát állított be", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_partners": "", + "unable_to_add_album_users": "Felhasználók hozzáadása albumhoz sikertelen", + "unable_to_add_assets_to_shared_link": "Felhasználók hozzáadása megosztott linkhez sikertelen", + "unable_to_add_comment": "Hozzászólás sikertelen", + "unable_to_add_exclusion_pattern": "Kivétel minta hozzáadása sikertelen", + "unable_to_add_import_path": "Importálási útvonal hozzáadása sikertelen", + "unable_to_add_partners": "Partnerek hozzáadása sikertelen", "unable_to_change_album_user_role": "", "unable_to_change_date": "", "unable_to_change_location": "", @@ -546,10 +594,10 @@ "unable_to_create_user": "", "unable_to_delete_album": "", "unable_to_delete_asset": "", - "unable_to_delete_user": "", + "unable_to_delete_user": "Nem sikerült törölni a felhasználót", "unable_to_empty_trash": "Nem sikerült a lomtár ürítése", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", + "unable_to_enter_fullscreen": "Nem lehet belépni a teljes képernyőre", + "unable_to_exit_fullscreen": "Nem lehet kilépni a teljes képernyőről", "unable_to_hide_person": "", "unable_to_load_album": "", "unable_to_load_asset_activity": "", diff --git a/web/src/lib/i18n/it.json b/web/src/lib/i18n/it.json index cac78ad32b..86c0079e96 100644 --- a/web/src/lib/i18n/it.json +++ b/web/src/lib/i18n/it.json @@ -129,12 +129,13 @@ "map_enable_description": "Abilita funzionalità della mappa", "map_gps_settings": "Impostazioni Mappe & GPS", "map_gps_settings_description": "Gestisci le impostazioni di Mappe & GPS (Geocoding Inverso)", + "map_implications": "La fnzione della mappa fa uso di un servizio tile esterno (tiles.immich.cloud)", "map_light_style": "Tema chiaro", "map_manage_reverse_geocoding_settings": "Gestisci impostazioni Geocodifica inversa", "map_reverse_geocoding": "Geocodifica inversa", "map_reverse_geocoding_enable_description": "Abilita geocodifica inversa", "map_reverse_geocoding_settings": "Impostazioni Geocodifica Inversa", - "map_settings": "Impostazioni Mappa e GPS", + "map_settings": "Impostazioni Mappa e Posizione", "map_settings_description": "Gestisci impostazioni mappa", "map_style_description": "URL per un tema della mappa style.json", "metadata_extraction_job": "Estrazione Metadata", @@ -278,7 +279,7 @@ "transcoding_preferred_hardware_device": "Dispositivo hardware preferito", "transcoding_preferred_hardware_device_description": "Si applica solo a VAAPI e QSV. Imposta il nodo DRI utilizzato per la transcodifica hardware.", "transcoding_preset_preset": "Preset (-preset)", - "transcoding_preset_preset_description": "Velocità di compressione. Preset più lenti producono file più piccoli e aumentano la qualità quando si punta ad ottenere un certo bitrate. VP9 ignora velocità superiori a `faster`.", + "transcoding_preset_preset_description": "Velocità di compressione. Presets più lenti producono file più piccoli e aumentano la qualità quando si punta a ottenere un certo bitrate. VP9 ignora velocità superiori a `faster`.", "transcoding_reference_frames": "Frame di riferimento", "transcoding_reference_frames_description": "Il numero di frame da prendere in considerazione nel comprimere un determinato frame. Valori più alti migliorano l'efficienza di compressione, ma rallentano la codifica. 0 imposta questo valore automaticamente.", "transcoding_required_description": "Solo video che non sono in un formato accettato", @@ -320,7 +321,8 @@ "user_settings": "Impostazione Utente", "user_settings_description": "Gestisci impostazioni utente", "user_successfully_removed": "L'utente {email} è stato rimosso con successo.", - "version_check_enabled_description": "Abilita richieste periodiche a Github per verificare se esistono nuove versioni", + "version_check_enabled_description": "Abilita controllo della versione", + "version_check_implications": "La funzione di controllo della versione fa uso di una comunicazione periodica con github.com", "version_check_settings": "Controllo Versione", "version_check_settings_description": "Abilita/disabilita la notifica per nuove versioni", "video_conversion_job": "Trascodifica video", @@ -912,12 +914,14 @@ "ok": "Ok", "oldest_first": "Prima vecchi", "onboarding": "Inserimento", + "onboarding_privacy_description": "Le seguenti funzioni (opzionali) fanno uso di servizi esterni, e possono essere disabilitate in qualsiasi momento dalle impostazioni d'amministratore.", "onboarding_theme_description": "Scegli un tema colore per la tua istanza. Potrai cambiarlo nelle impostazioni.", "onboarding_welcome_description": "Andiamo ad impostare la tua istanza con alcuni settaggi comuni.", "onboarding_welcome_user": "Benvenuto, {user}", "online": "Online", "only_favorites": "Solo preferiti", "only_refreshes_modified_files": "Aggiorna solo i file modificati", + "open_in_map_view": "Apri nella visualizzazione mappa", "open_in_openstreetmap": "Apri su OpenStreetMap", "open_the_search_filters": "Apri filtri di ricerca", "options": "Opzioni", @@ -983,6 +987,7 @@ "previous_memory": "Ricordo precedente", "previous_or_next_photo": "Precedente o prossima foto", "primary": "Primario", + "privacy": "Privacy", "profile_image_of_user": "Immagine profilo di {user}", "profile_picture_set": "Foto profilo impostata.", "public_album": "Album pubblico", @@ -1011,7 +1016,7 @@ "purchase_panel_title": "Contribuisci al progetto", "purchase_per_server": "Per server", "purchase_per_user": "Per utente", - "purchase_remove_product_key": "Rimuovi Chiave del Prodotto", + "purchase_remove_product_key": "Rimuovi la Chiave del Prodotto", "purchase_remove_product_key_prompt": "Sei sicuro di voler rimuovere la chiave del prodotto?", "purchase_remove_server_product_key": "Rimuovi la chiave del prodotto per Server", "purchase_remove_server_product_key_prompt": "Sei sicuro di voler rimuovere la chiave del prodotto per Server?", @@ -1020,6 +1025,8 @@ "purchase_server_title": "Server", "purchase_settings_server_activated": "La chiave del prodotto del server è gestita dall'amministratore", "range": "", + "rating": "Valutazione a stelle", + "rating_description": "Visualizza la valutazione EXIF nel pannello informazioni", "raw": "", "reaction_options": "Impostazioni Reazioni", "read_changelog": "Leggi Riepilogo Modifiche", @@ -1142,6 +1149,7 @@ "shared_by_user": "Condiviso da {user}", "shared_by_you": "Condiviso da te", "shared_from_partner": "Foto da {partner}", + "shared_link_options": "Opzioni link condiviso", "shared_links": "Link condivisi", "shared_photos_and_videos_count": "{assetCount, plural, other {# foto & video condivisi.}}", "shared_with_partner": "Condiviso con {partner}", @@ -1150,6 +1158,7 @@ "sharing_sidebar_description": "Mostra un link a Condivisione nella barra laterale", "shift_to_permanent_delete": "premi ⇧ per cancellare definitivamente l'asset", "show_album_options": "Mostra opzioni album", + "show_albums": "Mostra gli album", "show_all_people": "Mostra tutte le persone", "show_and_hide_people": "Mostra & nascondi persone", "show_file_location": "Mostra percorso file", @@ -1182,6 +1191,8 @@ "sort_title": "Titolo", "source": "Fonte", "stack": "Raggruppa", + "stack_duplicates": "Raggruppa i duplicati", + "stack_select_one_photo": "Seleziona una foto principale per il gruppo", "stack_selected_photos": "Impila foto selezionate", "stacked_assets_count": "{count, plural, one {Raggruppato # asset} other {Raggruppati # assets}}", "stacktrace": "Traccia dell'errore", diff --git a/web/src/lib/i18n/ko.json b/web/src/lib/i18n/ko.json index 94a6702fe0..9d94a918fb 100644 --- a/web/src/lib/i18n/ko.json +++ b/web/src/lib/i18n/ko.json @@ -95,7 +95,7 @@ "logging_level_description": "로깅이 활성화된 경우 사용할 로그 레벨을 선택합니다.", "logging_settings": "로깅", "machine_learning_clip_model": "CLIP 모델", - "machine_learning_clip_model_description": "CLIP 모델의 종류는 이곳을 참조하세요. 변경 후 모든 항목의 스마트 검색 작업을 다시 진행해야 합니다.", + "machine_learning_clip_model_description": "CLIP 모델의 종류는 이곳을 참조하세요. 한국어로 검색하려면 Multilingual CLIP 모델을 선택하세요. 변경 후 모든 항목에 대한 스마트 검색 작업을 다시 진행해야 합니다.", "machine_learning_duplicate_detection": "비슷한 항목 감지", "machine_learning_duplicate_detection_enabled": "비슷한 항목 감지 활성화", "machine_learning_duplicate_detection_enabled_description": "비활성화된 경우에도 완전히 일치하는 항목은 여전히 감지됩니다.", @@ -278,7 +278,7 @@ "transcoding_preferred_hardware_device": "선호하는 하드웨어 기기", "transcoding_preferred_hardware_device_description": "하드웨어 트랜스코딩에 사용할 dri 노드를 설정합니다. (VAAPI와 QSV만 해당)", "transcoding_preset_preset": "프리셋 (-preset)", - "transcoding_preset_preset_description": "압축 속도를 설정합니다. 동일 비트레이트 기준 느린 속도를 선택한 경우 파일 크기가 감소하고 품질이 향상됩니다. VP9는 `faster` 이상의 속도가 적용되지 않습니다.", + "transcoding_preset_preset_description": "압축 속도를 설정합니다. 동일 비트레이트 기준에서 느린 속도를 선택하면 파일 크기가 감소하고 품질이 향상됩니다. VP9는 'faster' 이상의 속도가 적용되지 않습니다.", "transcoding_reference_frames": "참조 프레임", "transcoding_reference_frames_description": "특정 프레임을 압축할 때 참조하는 프레임 수를 설정합니다. 값이 높으면 압축 효율이 향상되나 인코딩 속도가 저하됩니다. 0을 입력한 경우 자동으로 설정합니다.", "transcoding_required_description": "허용된 형식이 아닌 동영상만", @@ -902,6 +902,7 @@ "online": "온라인", "only_favorites": "즐겨찾기만 표시", "only_refreshes_modified_files": "변경된 파일만 다시 스캔", + "open_in_map_view": "지도 뷰에서 보기", "open_in_openstreetmap": "OpenStreetMap에서 열기", "open_the_search_filters": "검색 필터 열기", "options": "옵션", @@ -1005,6 +1006,8 @@ "purchase_server_title": "서버", "purchase_settings_server_activated": "서버 제품 키는 관리자가 관리합니다.", "range": "", + "rating": "등급", + "rating_description": "상세 정보에 EXIF의 등급 정보 표시", "raw": "", "reaction_options": "반응 옵션", "read_changelog": "변경 사항 보기", @@ -1135,6 +1138,7 @@ "sharing_sidebar_description": "사이드바에 공유 링크 표시", "shift_to_permanent_delete": "⇧를 눌러 항목을 영구적으로 삭제", "show_album_options": "앨범 옵션 표시", + "show_albums": "앨범 표시", "show_all_people": "모든 인물 보기", "show_and_hide_people": "인물 숨기기", "show_file_location": "파일 위치 표시", @@ -1167,6 +1171,8 @@ "sort_title": "제목", "source": "소스", "stack": "스택", + "stack_duplicates": "비슷한 항목 스택", + "stack_select_one_photo": "스택의 대표 사진 선택", "stack_selected_photos": "선택한 이미지 스택", "stacked_assets_count": "항목 {count, plural, one {#개} other {#개}}의 스택을 만들었습니다.", "stacktrace": "스택 추적", diff --git a/web/src/lib/i18n/lt.json b/web/src/lib/i18n/lt.json index 9578806bdb..e656754c7d 100644 --- a/web/src/lib/i18n/lt.json +++ b/web/src/lib/i18n/lt.json @@ -117,21 +117,24 @@ "map_style_description": "", "metadata_extraction_job_description": "", "migration_job_description": "", + "no_paths_added": "Keliai nepridėti", + "no_pattern_added": "Šablonas nepridėtas", "notification_email_from_address": "", "notification_email_from_address_description": "", "notification_email_host_description": "", - "notification_email_ignore_certificate_errors": "", - "notification_email_ignore_certificate_errors_description": "", + "notification_email_ignore_certificate_errors": "Nepaisyti sertifikatų klaidų", + "notification_email_ignore_certificate_errors_description": "Nepaisyti TLS sertifikato patvirtinimo klaidų (nerekomenduojama)", "notification_email_password_description": "", - "notification_email_port_description": "", - "notification_email_sent_test_email_button": "", - "notification_email_setting_description": "", - "notification_email_test_email_failed": "", - "notification_email_test_email_sent": "", + "notification_email_port_description": "El. pašto serverio prievadas (pvz. 25, 465 arba 587)", + "notification_email_sent_test_email_button": "Siųsti bandomąjį el. laišką ir išsaugoti", + "notification_email_setting_description": "El. pašto pranešimų siuntimo nustatymai", + "notification_email_test_email": "Išsiųsti bandomąjį el. laišką", + "notification_email_test_email_failed": "Nepavyko išsiųsti bandomojo el. laiško, patikrinkite savo nustatymus", + "notification_email_test_email_sent": "Bandomasis el. laiškas buvo išsiųstas į {email}. Patikrinkite savo pašto dėžutę.", "notification_email_username_description": "", "notification_enable_email_notifications": "", "notification_settings": "Pranešimų nustatymai", - "notification_settings_description": "Tvarkyti pranešimų parametrus, įskaitant el. pašto", + "notification_settings_description": "Tvarkyti pranešimų nustatymus, įskaitant el. pašto", "oauth_auto_launch": "Paleisti automatiškai", "oauth_auto_launch_description": "", "oauth_auto_register": "", @@ -146,7 +149,7 @@ "oauth_mobile_redirect_uri_override_description": "", "oauth_scope": "", "oauth_settings": "", - "oauth_settings_description": "", + "oauth_settings_description": "Tvarkyti OAuth prisijungimo nustatymus", "oauth_signing_algorithm": "", "oauth_storage_label_claim": "", "oauth_storage_label_claim_description": "", @@ -157,13 +160,19 @@ "offline_paths_description": "Šie rezultatai gali būti dėl rankinio failų ištrynimo, kurie nėra išorinės bibliotekos dalis.", "password_enable_description": "Prisijungti su el. paštu ir slaptažodžiu", "password_settings": "Prisijungimas slaptažodžiu", - "password_settings_description": "", + "password_settings_description": "Tvarkyti prisijungimo slaptažodžiu nustatymus", + "paths_validated_successfully": "Visi keliai patvirtinti sėkmingai", + "refreshing_all_libraries": "Perkraunamos visos bibliotekos", + "registration_description": "Kadangi esate pirmasis šio sistemos vartotojas, jums bus priskirta administratorius rolė, ir būsite atsakingas už administracines užduotis ir papildomų vartotojų kūrimą.", + "repair_all": "Pataisyti visus", + "require_password_change_on_login": "Reikalauti, kad vartotojas pasikeistų slaptažodį po pirmojo prisijungimo", + "reset_settings_to_default": "Atstatyti nustatymus į numatytuosius", "server_external_domain_settings": "Išorinis domenas", "server_external_domain_settings_description": "", "server_settings": "Serverio nustatymai", "server_settings_description": "Tvarkyti serverio nustatymus", "server_welcome_message": "", - "server_welcome_message_description": "", + "server_welcome_message_description": "Žinutė, rodoma prisijungimo puslapyje.", "sidecar_job_description": "", "slideshow_duration_description": "", "smart_search_job_description": "", @@ -173,10 +182,12 @@ "storage_template_migration_job": "", "storage_template_settings": "", "storage_template_settings_description": "", + "system_settings": "Sistemos nustatymai", "theme_custom_css_settings": "", "theme_custom_css_settings_description": "", - "theme_settings": "", + "theme_settings": "Temos nustatymai", "theme_settings_description": "", + "thumbnail_generation_job": "Generuoti miniatiūras", "thumbnail_generation_job_description": "", "transcode_policy_description": "", "transcoding_acceleration_api": "", @@ -189,11 +200,11 @@ "transcoding_accepted_audio_codecs_description": "", "transcoding_accepted_video_codecs": "", "transcoding_accepted_video_codecs_description": "", - "transcoding_advanced_options_description": "", + "transcoding_advanced_options_description": "Parinktys, kurių daugelis vartotojų keisti neturėtų", "transcoding_audio_codec": "Garso kodekas", "transcoding_audio_codec_description": "", "transcoding_bitrate_description": "", - "transcoding_constant_quality_mode": "", + "transcoding_constant_quality_mode": "Pastovios kokybės režimas", "transcoding_constant_quality_mode_description": "", "transcoding_constant_rate_factor": "", "transcoding_constant_rate_factor_description": "", @@ -239,9 +250,11 @@ "trash_number_of_days_description": "", "trash_settings": "Šiukšliadėžės nustatymai", "trash_settings_description": "Tvarkyti šiukšliadėžės nustatymus", - "user_delete_delay_settings": "", + "untracked_files": "Nesekami failai", + "user_delete_delay_settings": "Ištrynimo delsa", "user_delete_delay_settings_description": "", "user_password_has_been_reset": "Vartotojo slaptažodis buvo iš naujo nustatytas:", + "user_restore_description": "Vartotojo {user} paskyra bus atkurta.", "user_settings": "Vartotojo nustatymai", "user_settings_description": "Valdyti vartotojo nustatymus", "user_successfully_removed": "Vartotojas {email} sėkmingai pašalintas.", @@ -255,8 +268,9 @@ "administration": "Administravimas", "advanced": "", "album_added": "Albumas pridėtas", - "album_added_notification_setting_description": "", + "album_added_notification_setting_description": "Gauti el. pašto pranešimą, kai būsite pridėtas prie bendrinamo albumo", "album_cover_updated": "Albumo viršelis atnaujintas", + "album_delete_confirmation": "Ar tikrai norite ištrinti albumą {album}?\nJei šis albumas yra bendrinamas, kiti vartotojai nebegalės jo pasiekti.", "album_info_updated": "Albumo informacija atnaujinta", "album_leave": "Palikti albumą?", "album_leave_confirmation": "Ar tikrai norite palikti albumą {album}?", @@ -264,8 +278,9 @@ "album_options": "Albumo parinktys", "album_remove_user": "Pašalinti vartotoją?", "album_remove_user_confirmation": "Ar tikrai norite pašalinti vartotoją {user}?", + "album_share_no_users": "Atrodo, kad bendrinate šį albumą su visais vartotojais, arba neturite vartotojų, su kuriais galėtumėte bendrinti.", "album_updated": "Albumas atnaujintas", - "album_updated_setting_description": "", + "album_updated_setting_description": "Gauti pranešimą el. paštu, kai bendrinamas albumas turi naujų elementų", "album_user_removed": "Pašalintas {user}", "album_with_link_access": "Tegul visi, turintys nuorodą, mato šio albumo nuotraukas ir žmones.", "albums": "Albumai", @@ -278,17 +293,20 @@ "allow_public_user_to_download": "Leisti viešam naudotojui atsisiųsti", "allow_public_user_to_upload": "Leisti viešam naudotojui įkelti", "api_key": "API raktas", + "api_key_empty": "Jūsų API rakto pavadinimas netūrėtų būti tuščias", "api_keys": "API raktai", "app_settings": "Programos nustatymai", "appears_in": "", "archive": "", - "archive_or_unarchive_photo": "", + "archive_or_unarchive_photo": "Archyvuoti arba išarchyvuoti nuotrauką", "archive_size": "Archyvo dydis", + "archive_size_description": "Konfigūruoti archyvo dydį atsisiuntimams (GiB)", "archived": "", "are_these_the_same_person": "Ar tai tas pats asmuo?", "are_you_sure_to_do_this": "Ar tikrai norite tai daryti?", "asset_added_to_album": "Pridėta į albumą", "asset_adding_to_album": "Pridedama į albumą...", + "asset_description_updated": "Elemento aprašymas buvo atnaujintas", "asset_offline": "", "asset_uploaded": "Įkelta", "asset_uploading": "Įkeliama...", @@ -299,13 +317,14 @@ "backward": "", "birthdate_saved": "Sėkmingai išsaugota gimimo data", "blurred_background": "Neryškus fonas", + "buy": "Įsigyti Immich", "camera": "Fotoaparatas", "camera_brand": "Fotoaparato prekės ženklas", "camera_model": "Fotoaparato modelis", "cancel": "Atšaukti", "cancel_search": "Atšaukti paiešką", "cannot_merge_people": "Negalima sujungti asmenų", - "cannot_update_the_description": "", + "cannot_update_the_description": "Negalima atnaujinti aprašymo", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", @@ -316,8 +335,9 @@ "change_name": "Pakeisti vardą", "change_name_successfully": "", "change_password": "Pakeisti slaptažodį", + "change_password_description": "Tai arba pirmas kartas, kai jungiatės prie sistemos, arba buvo pateikta užklausa pakeisti jūsų slaptažodį. Prašome įvesti naują slaptažodį žemiau.", "change_your_password": "Pakeisti slaptažodį", - "changed_visibility_successfully": "", + "changed_visibility_successfully": "Matomumas pakeistas sėkmingai", "check_all": "Žymėti viską", "check_logs": "Tikrinti žurnalus", "city": "Miestas", @@ -338,14 +358,15 @@ "confirm_delete_shared_link": "Ar tikrai norite ištrinti šią bendrinamą nuorodą?", "confirm_password": "Patvirtinti slaptažodį", "contain": "", - "context": "", + "context": "Kontekstas", "continue": "Tęsti", - "copied_image_to_clipboard": "", + "copied_image_to_clipboard": "Nuotrauka nukopijuota į iškarpinę.", + "copied_to_clipboard": "Nukopijuota į iškapinę!", "copy_error": "Kopijavimo klaida", "copy_file_path": "Kopijuoti failo kelią", "copy_image": "Kopijuoti vaizdą", "copy_link": "Kopijuoti nuorodą", - "copy_link_to_clipboard": "", + "copy_link_to_clipboard": "Kopijuoti nuorodą į iškarpinę", "copy_password": "Kopijuoti slaptažodį", "copy_to_clipboard": "Kopijuoti į iškarpinę", "country": "Šalis", @@ -356,7 +377,9 @@ "create_library": "Sukurti biblioteką", "create_link": "Sukurti nuorodą", "create_link_to_share": "Sukurti bendrinimo nuorodą", - "create_new_person": "", + "create_link_to_share_description": "Leisti bet kam su nuoroda matyti pažymėtą(-as) nuotrauką(-as)", + "create_new_person": "Sukurti naują žmogų", + "create_new_person_hint": "Priskirti pasirinktus elementus naujam žmogui", "create_new_user": "Sukurti naują varotoją", "create_user": "Sukurti vartotoją", "created": "Sukurta", @@ -364,16 +387,18 @@ "custom_locale": "", "custom_locale_description": "Formatuoti datas ir skaičius pagal kalbą ir regioną", "dark": "", - "date_after": "", + "date_after": "Data po", "date_and_time": "Data ir laikas", - "date_before": "", + "date_before": "Data prieš", + "date_of_birth_saved": "Gimimo data sėkmingai išsaugota", "date_range": "", "day": "Diena", "default_locale": "", - "default_locale_description": "", + "default_locale_description": "Formatuoti datas ir skaičius pagal jūsų naršyklės lokalę", "delete": "Ištrinti", "delete_album": "Ištrinti albumą", "delete_api_key_prompt": "Ar tikrai norite ištrinti šį API raktą?", + "delete_duplicates_confirmation": "Ar tikrai norite visam laikui ištrinti šiuos dublikatus?", "delete_key": "Ištrinti raktą", "delete_library": "Ištrinti biblioteką", "delete_link": "Ištrinti nuorodą", @@ -381,13 +406,13 @@ "delete_user": "Ištrinti vartotoją", "deleted_shared_link": "Bendrinama nuoroda ištrinta", "description": "Aprašymas", - "details": "", + "details": "Detalės", "direction": "Kryptis", "disabled": "Išjungta", - "disallow_edits": "", - "discover": "", - "dismiss_all_errors": "", - "dismiss_error": "", + "disallow_edits": "Neleisti redaguoti", + "discover": "Atrasti", + "dismiss_all_errors": "Nepaisyti visų klaidų", + "dismiss_error": "Nepaisyti klaidos", "display_options": "", "display_order": "Atvaizdavimo tvarka", "display_original_photos": "Rodyti originalias nuotraukas", @@ -397,6 +422,7 @@ "download": "Atsisiųsti", "download_settings": "Atsisiųsti", "downloading": "Siunčiama", + "duplicates": "Dublikatai", "duration": "Trukmė", "durations": { "days": "", @@ -410,18 +436,18 @@ "edit_avatar": "Redaguoti avatarą", "edit_date": "Redaguoti datą", "edit_date_and_time": "Redaguoti datą ir laiką", - "edit_exclusion_pattern": "", - "edit_faces": "", - "edit_import_path": "", - "edit_import_paths": "", - "edit_key": "", + "edit_exclusion_pattern": "Redaguoti išimčių šabloną", + "edit_faces": "Redaguoti veidus", + "edit_import_path": "Redaguoti importavimo kelią", + "edit_import_paths": "Redaguoti importavimo kelius", + "edit_key": "Redaguoti raktą", "edit_link": "Redaguoti nuorodą", "edit_location": "Redaguoti vietovę", "edit_name": "Redaguoti vardą", - "edit_people": "", + "edit_people": "Redaguoti žmones", "edit_title": "Redaguoti antraštę", "edit_user": "Redaguoti vartotoją", - "edited": "", + "edited": "Redaguota", "editor": "", "email": "El. paštas", "empty": "", @@ -431,18 +457,31 @@ "enabled": "Įgalintas", "end_date": "Pabaigos data", "error": "Klaida", - "error_loading_image": "", + "error_loading_image": "Klaida įkeliant vaizdą", "error_title": "Klaida - Kažkas nutiko ne taip", "errors": { "cant_apply_changes": "Negalima taikyti pakeitimų", + "error_adding_assets_to_album": "Klaida pridedant elementus į albumą", + "error_adding_users_to_album": "Klaida pridedant vartotojus prie albumo", + "error_downloading": "Klaida atsisiunčiant {filename}", + "error_hiding_buy_button": "Klaida slepiant pirkimo mygtuką", + "error_removing_assets_from_album": "Klaida šalinant elementus iš albumo, patikrinkite konsolę dėl išsamesnės informacijos", + "exclusion_pattern_already_exists": "Šis išimčių šablonas jau egzistuoja.", "failed_to_create_album": "Nepavyko sukurti albumo", "failed_to_create_shared_link": "Nepavyko sukurti bendrinamos nuorodos", "failed_to_edit_shared_link": "Nepavyko redaguoti bendrinamos nuorodos", + "failed_to_load_people": "Nepavyko užkrauti žmonių", + "failed_to_remove_product_key": "Nepavyko pašalinti produkto rakto", + "import_path_already_exists": "Šis importavimo kelias jau egzistuoja.", "incorrect_email_or_password": "Neteisingas el. pašto adresas arba slaptažodis", - "unable_to_add_album_users": "", + "profile_picture_transparent_pixels": "Profilio nuotrauka negali turėti permatomų pikselių. Prašome priartinti ir/arba perkelkite nuotrauką.", + "quota_higher_than_disk_size": "Nustatyta kvota, viršija disko dydį", + "unable_to_add_album_users": "Nepavyksta pridėti vartotojų prie albumo", "unable_to_add_comment": "Nepavyksta pridėti komentaro", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", + "unable_to_add_exclusion_pattern": "Nepavyksta pridėti išimčių šablono", + "unable_to_add_import_path": "Nepavyksta pridėti importavimo kelio", + "unable_to_add_partners": "Nepavyksta pridėti partnerių", + "unable_to_change_album_user_role": "Nepavyksta pakeisti albumo vartoto rolės", "unable_to_change_date": "Negalima pakeisti datos", "unable_to_change_location": "Negalima pakeisti vietos", "unable_to_change_password": "Negalima pakeisti slaptažodžio", @@ -450,28 +489,39 @@ "unable_to_check_items": "", "unable_to_connect": "Nepavyko prisijungti", "unable_to_connect_to_server": "Nepavyko prisijungti prie serverio", + "unable_to_copy_to_clipboard": "Negalima kopijuoti į iškarpinę, įsitikinkite, kad prie puslapio prieinate per https", "unable_to_create_admin_account": "Nepavyko sukurti administratoriaus paskyros", "unable_to_create_api_key": "Nepavyko sukurti naujo API rakto", "unable_to_create_library": "Nepavyko sukurti bibliotekos", "unable_to_create_user": "Nepavyko sukurti vartotojo", - "unable_to_delete_album": "", + "unable_to_delete_album": "Nepavyksta ištrinti albumo", "unable_to_delete_asset": "", - "unable_to_delete_user": "", + "unable_to_delete_exclusion_pattern": "Nepavyksta ištrinti išimčių šablono", + "unable_to_delete_import_path": "Nepavyksta ištrinti importavimo kelio", + "unable_to_delete_shared_link": "Nepavyksta ištrinti bendrinimo nuorodos", + "unable_to_delete_user": "Nepavyksta ištrinti vartotojo", + "unable_to_edit_exclusion_pattern": "Nepavyksta redaguoti išimčių šablono", + "unable_to_edit_import_path": "Nepavyksta redaguoti išimčių kelio", "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_load_album": "", + "unable_to_enter_fullscreen": "Nepavyksta pereiti į viso ekrano režimą", + "unable_to_exit_fullscreen": "Nepavyksta išeiti iš viso ekrano režimo", + "unable_to_get_shared_link": "Nepavyksta gauti bendrinamos nuorodos", + "unable_to_hide_person": "Nepavyksta paslėpti žmogaus", + "unable_to_load_album": "Nepavyksta užkrauti albumo", "unable_to_load_asset_activity": "", "unable_to_load_items": "", "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", + "unable_to_log_out_all_devices": "Nepavyksta atjungti visų įrenginių", + "unable_to_log_out_device": "Nepavyksta atjungti įrenginio", + "unable_to_login_with_oauth": "Nepavyksta prisijungti su OAuth", + "unable_to_play_video": "Nepavyksta paleisti vaizdo įrašo", + "unable_to_refresh_user": "Nepavyksta atnaujinti vartotojo", "unable_to_remove_album_users": "", + "unable_to_remove_api_key": "Nepavyko pašalinti API rakto", "unable_to_remove_comment": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_library": "Nepavyksta pašalinti bibliotekos", + "unable_to_remove_partner": "Nepavyksta pašalinti partnerio", + "unable_to_remove_reaction": "Nepavyksta pašalinti reakcijos", "unable_to_remove_user": "", "unable_to_repair_items": "", "unable_to_reset_password": "", @@ -500,14 +550,17 @@ "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", + "exif": "Exif", "exit_slideshow": "", "expand_all": "Išskleisti viską", "expire_after": "", - "expired": "", + "expired": "Nebegalioja", + "expires_date": "Nebegalios už {date}", "explore": "Naršyti", "export": "Eksportuoti", "export_as_json": "Eksportuoti kaip JSON", "extension": "Plėtinys", + "external": "Išorinis", "external_libraries": "Išorinės bibliotekos", "face_unassigned": "Nepriskirta", "failed_to_get_people": "", @@ -517,23 +570,26 @@ "feature": "", "feature_photo_updated": "", "featurecollection": "", - "file_name": "", - "file_name_or_extension": "", + "file_name": "Failo pavadinimas", + "file_name_or_extension": "Failo pavadinimas arba plėtinys", "filename": "", "files": "", - "filetype": "", - "filter_people": "", + "filetype": "Failo tipas", + "filter_people": "Filtruoti žmones", "fix_incorrect_match": "", "force_re-scan_library_files": "", "forward": "", "general": "", - "get_help": "", + "get_help": "Gauti pagalbos", "getting_started": "", "go_back": "", "go_to_search": "", "go_to_share_page": "", - "group_albums_by": "", - "has_quota": "", + "group_albums_by": "Grupuoti albumus pagal...", + "group_no": "Negrupuoti", + "group_owner": "Grupuoti pagal savininką", + "group_year": "Grupuoti pagal metus", + "has_quota": "Turi kvotą", "hi_user": "Labas {name} ({email})", "hide_all_people": "Slėpti visus asmenis", "hide_gallery": "Slėpti galeriją", @@ -545,7 +601,7 @@ "hour": "Valanda", "image": "Nuotrauka", "img": "", - "immich_logo": "", + "immich_logo": "Immich logotipas", "import_from_json": "Importuoti iš JSON", "import_path": "Importavimo kelias", "in_archive": "Archyve", @@ -574,7 +630,7 @@ "latitude": "Platuma", "leave": "Išeiti", "let_others_respond": "Leisti kitiems reaguoti", - "level": "", + "level": "Lygis", "library": "Biblioteka", "library_options": "Bibliotekos pasirinktys", "light": "", @@ -583,7 +639,7 @@ "linked_oauth_account": "", "list": "Sąrašas", "loading": "Kraunama", - "loading_search_results_failed": "", + "loading_search_results_failed": "Nepavyko užkrauti paieškos rezultatų", "log_out": "Atsijungti", "log_out_all_devices": "Atsijungti iš visų įrenginių", "logged_out_all_devices": "Atsijungta iš visų įrenginių", @@ -593,9 +649,9 @@ "logout_this_device_confirmation": "Ar tikrai norite atsijungti iš šio prietaiso?", "longitude": "Ilguma", "look": "", - "loop_videos": "", + "loop_videos": "Kartoti vaizdo įrašus", "loop_videos_description": "", - "make": "", + "make": "Gamintojas", "manage_shared_links": "Bendrai naudojamų nuorodų tvarkymas", "manage_sharing_with_partners": "Valdyti dalijimąsi su partneriais", "manage_the_app_settings": "Valdyti programos nustatymus", @@ -628,12 +684,13 @@ "name": "Vardas", "name_or_nickname": "Vardas arba slapyvardis", "never": "Niekada", + "new_album": "", "new_api_key": "Naujas API raktas", "new_password": "Naujas slaptažodis", "new_person": "Naujas asmuo", "new_user_created": "Sukurtas naujas vartotojas", "new_version_available": "PRIEINAMA NAUJA VERSIJA", - "newest_first": "", + "newest_first": "Pirmiausia naujausi", "next": "Sekantis", "next_memory": "Sekantis atsiminimas", "no": "Ne", @@ -653,6 +710,7 @@ "no_results_description": "Pabandykite sinonimą arba bendresnį raktažodį", "no_shared_albums_message": "", "not_in_any_album": "Nė viename albume", + "note_unlimited_quota": "Pastaba: Įveskite 0, jei norite neribotos kvotos", "notes": "Pastabos", "notification_toggle_setting_description": "Įjungti el. pašto pranešimus", "notifications": "Pranešimai", @@ -664,11 +722,12 @@ "onboarding_welcome_user": "Sveiki atvykę, {user}", "online": "Prisijungęs", "only_favorites": "Tik mėgstamiausi", - "only_refreshes_modified_files": "", - "open_the_search_filters": "", + "only_refreshes_modified_files": "Atnaujina tik modifikuotus failus", + "open_the_search_filters": "Atidaryti paieškos filtrus", "options": "Pasirinktys", "or": "arba", "organize_your_library": "Tvarkykite savo biblioteką", + "original": "Originalas", "other": "", "other_devices": "Kiti įrenginiai", "other_variables": "Kiti kintamieji", @@ -691,24 +750,24 @@ }, "path": "Kelias", "pattern": "", - "pause": "", + "pause": "Sustabdyti", "pause_memories": "", - "paused": "", + "paused": "Sustabdyta", "pending": "Laukiama", "people": "Asmenys", "people_sidebar_description": "", "perform_library_tasks": "", "permanent_deletion_warning": "", "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", + "permanently_delete": "Ištrinti visam laikui", "permanently_deleted_asset": "", "photos": "Nuotraukos", "photos_and_videos": "Nuotraukos ir vaizdo įrašai", "photos_count": "{count, plural, one {{count, number} nuotrauka} few {{count, number} nuotraukos} other {{count, number} nuotraukų}}", "photos_from_previous_years": "Ankstesnių metų nuotraukos", "pick_a_location": "", - "place": "", - "places": "", + "place": "Vieta", + "places": "Vietos", "play": "", "play_memories": "", "play_motion_photo": "", @@ -721,8 +780,26 @@ "previous_memory": "", "previous_or_next_photo": "", "primary": "", - "profile_picture_set": "", + "profile_picture_set": "Profilio nuotrauka nustatyta.", + "public_album": "Viešas albumas", "public_share": "", + "purchase_account_info": "Rėmėjas", + "purchase_activated_subtitle": "Dėkojame, kad remiate Immich ir atviro kodo programinę įrangą", + "purchase_activated_title": "Jūsų raktas sėkmingai aktyvuotas", + "purchase_button_activate": "Aktyvuoti", + "purchase_button_buy": "Pirkti", + "purchase_button_buy_immich": "Pirkti Immich", + "purchase_button_select": "Pasirinkti", + "purchase_individual_description_2": "Rėmėjo statusas", + "purchase_input_suggestion": "Turite produkto raktą? Įveskite jį žemiau", + "purchase_remove_product_key": "Pašalinti produkto raktą", + "purchase_remove_product_key_prompt": "Ar tikrai norite pašalinti produkto raktą?", + "purchase_remove_server_product_key": "Pašalinti serverio produkto raktą", + "purchase_remove_server_product_key_prompt": "Ar tikrai norite pašalinti serverio produkto raktą?", + "purchase_server_description_1": "Visam serveriui", + "purchase_server_description_2": "Rėmėjo statusas", + "purchase_server_title": "Serveris", + "purchase_settings_server_activated": "Serverio produkto raktas yra tvarkomas administratoriaus", "range": "", "raw": "", "reaction_options": "", @@ -732,19 +809,23 @@ "refresh": "", "refreshed": "", "refreshes_every_file": "", - "remove": "", - "remove_from_album": "", - "remove_from_favorites": "", + "remove": "Pašalinti", + "remove_from_album": "Pašalinti iš albumo", + "remove_from_favorites": "Pašalinti iš mėgstamiausių", "remove_from_shared_link": "", "remove_offline_files": "", - "repair": "", + "remove_user": "Pašalinti vartotoją", + "removed_api_key": "Pašalintas API Raktas: {name}", + "rename": "Pervadinti", + "repair": "Pataisyti", "repair_no_results_message": "", "replace_with_upload": "", - "require_password": "", + "require_password": "Reikalauti slaptažodžio", "reset": "", "reset_password": "", "reset_people_visibility": "", "reset_settings_to_default": "", + "resolved_all_duplicates": "Išspręsti visi dublikatai", "restore": "Atkurti", "restore_all": "Atkurti visus", "restore_user": "Atkurti vartotoją", @@ -752,10 +833,11 @@ "review_duplicates": "", "role": "", "save": "Išsaugoti", - "saved_profile": "", - "saved_settings": "", - "say_something": "", - "scan_all_libraries": "", + "saved_api_key": "Išsaugotas API raktas", + "saved_profile": "Išsaugotas profilis", + "saved_settings": "Išsaugoti nustatymai", + "say_something": "Ką nors pasakykite", + "scan_all_libraries": "Skenuoti visas bibliotekas", "scan_all_library_files": "", "scan_new_library_files": "", "scan_settings": "", @@ -769,6 +851,7 @@ "search_city": "", "search_country": "", "search_for_existing_person": "", + "search_no_people_named": "Nėra žmonių vardu „{name}“", "search_people": "", "search_places": "", "search_state": "", @@ -779,6 +862,7 @@ "second": "", "select_album_cover": "", "select_all": "", + "select_all_duplicates": "Pasirinkti visus dublikatus", "select_avatar_color": "Pasirinkti avataro spalvą", "select_face": "Pasirinkti veidą", "select_featured_photo": "", @@ -900,7 +984,7 @@ "variables": "Kintamieji", "version": "Versija", "version_announcement_closing": "Tavo draugas, Alex", - "video": "", + "video": "Vaizdo įrašas", "video_hover_setting_description": "", "videos": "Video", "view": "Rodyti", diff --git a/web/src/lib/i18n/nb_NO.json b/web/src/lib/i18n/nb_NO.json index 357f2b0b3f..b3851a2247 100644 --- a/web/src/lib/i18n/nb_NO.json +++ b/web/src/lib/i18n/nb_NO.json @@ -127,6 +127,7 @@ "manage_log_settings": "Administrer logginnstillinger", "map_dark_style": "Mørk stil", "map_enable_description": "Aktiver kartfunksjoner", + "map_gps_settings": "Kart & GPS Innstillinger", "map_light_style": "Lys stil", "map_reverse_geocoding": "Omvendt geokoding", "map_reverse_geocoding_enable_description": "Aktiver omvendt geokoding", @@ -220,10 +221,10 @@ "storage_template_hash_verification_enabled": "Hash verifisering aktivert", "storage_template_hash_verification_enabled_description": "Aktiver hasjverifisering. Ikke deaktiver dette med mindre du er sikker på konsekvensene", "storage_template_migration": "Lagringsmal migrering", - "storage_template_migration_description": "Bruk gjeldende {template} på tidligere opplastede bilder.", + "storage_template_migration_description": "Bruk gjeldende {mal} på tidligere opplastede bilder.", "storage_template_migration_info": "Malendringer vil kun gjelde nye ressurser. For å anvende malen på tidligere opplastede ressurser, kjør {job}.", "storage_template_migration_job": "Migreringsjobb for lagringsmal", - "storage_template_more_details": "For mer informasjon om denne funksjonen, se Storage Template og dens implications", + "storage_template_more_details": "For mer informasjon om denne funksjonen, se lagringsmalen og dens konsekvenser", "storage_template_onboarding_description": "Når aktivert, vil denne funksjonen automatisk organisere filer basert på en brukerdefinert mal. På grunn av stabilitetsproblemer er funksjonen deaktivert som standard. For mer informasjon, se documentation.", "storage_template_path_length": "Omtrentlig stilengdebegrensning: {length, number}/{limit, number}", "storage_template_settings": "Lagringsmal", @@ -246,6 +247,8 @@ "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Godkjente lydkodeker", "transcoding_accepted_audio_codecs_description": "Velg hvilke lydkodeker som ikke trenger å transkodes. Brukes kun for visse transkode retningslinjer.", + "transcoding_accepted_containers": "aksepterte kontainere", + "transcoding_accepted_containers_description": "Velg hvilke containerformater som ikke trenger å bli remuxet til MP4. Brukes kun for visse transkoderingspolicyer.", "transcoding_accepted_video_codecs": "Godkjente videokodeker", "transcoding_accepted_video_codecs_description": "Velg hvilke videokodeker som ikke trenger å transkodes. Brukes kun for visse transcoding-regler.", "transcoding_advanced_options_description": "Valg som de fleste brukere ikke trenger å endre", @@ -261,7 +264,7 @@ "transcoding_hardware_acceleration": "Maskinvareakselerasjon", "transcoding_hardware_acceleration_description": "Eksperimentell; mye raskere, men vil ha lavere kvalitet ved samme bithastighet", "transcoding_hardware_decoding": "Maskinvaredekoding", - "transcoding_hardware_decoding_setting_description": "Gjelder bare for NVENC og RKMPP. Aktiverer ende-til-ende akselerasjon i stedet for bare akselerering av koding. Vil ikke fungere med alle videoer.", + "transcoding_hardware_decoding_setting_description": "Gjelder bare for NVENC,QSV og RKMPP. Aktiverer ende-til-ende akselerasjon i stedet for bare akselerering av koding. Vil ikke fungere med alle videoer.", "transcoding_hevc_codec": "HEVC-codec", "transcoding_max_b_frames": "Maksimalt antall B-frames", "transcoding_max_b_frames_description": "Høyere verdier forbedrer komprimeringseffektiviteten, men senker ned kodingen. Kan være inkompatibelt med maskinvareakselerasjon på eldre enheter. 0 deaktiverer B-rammer, mens -1 setter verdien automatisk.", @@ -382,11 +385,14 @@ "assets": "Filer", "assets_added_count": "Lagt til {count, plural, one {# element} other {# elementer}}", "assets_moved_to_trash": "Flyttet {count, plural, one {# fil} other {# filer}} til papirkurv", + "assets_restore_confirmation": "Er du sikker på at du vil gjenopprette alle slettede eiendeler? Denne handlingen kan ikke angres!", "authorized_devices": "Autoriserte enheter", "back": "Tilbake", "backward": "Bakover", + "birthdate_saved": "Fødselsdato er lagret vellykket.", + "birthdate_set_description": "Fødelsdatoen er brukt for å beregne alderen til denne personen ved tidspunktet til bildet.", "blurred_background": "Uskarp bakgrunn", - "bulk_delete_duplicates_confirmation": "Er du sikker på at du vil slette {count} dupliserte filer? Dette vil beholde største filen fra hver gruppe og vil permament slette alle andre duplikater. Du kan ikke angre denne handlingen!", + "bulk_delete_duplicates_confirmation": "Er du sikker på at du vil slette {count} dupliserte filer? Dette vil beholde største filen fra hver gruppe og vil permanent slette alle andre duplikater. Du kan ikke angre denne handlingen!", "bulk_keep_duplicates_confirmation": "Er du sikker på at du vil beholde {count} dupliserte filer? Dette vil løse alle dupliserte grupper uten å slette noe.", "bulk_trash_duplicates_confirmation": "Er du sikker på ønsker å slette {count} dupliserte filer? Dette vil beholde største filen fra hver gruppe, samt slette alle andre duplikater.", "camera": "Kamera", @@ -395,6 +401,7 @@ "cancel": "Avbryt", "cancel_search": "Avbryt søk", "cannot_merge_people": "Kan ikke slå sammen personer", + "cannot_undo_this_action": "Du kan ikke gjøre om denne handlingen!", "cannot_update_the_description": "Kan ikke oppdatere beskrivelsen", "cant_apply_changes": "Kan ikke gjennomføre endringene", "cant_get_faces": "Kan ikke hente ansikter", @@ -405,7 +412,8 @@ "change_location": "Endre sted", "change_name": "Endre navn", "change_name_successfully": "Navneendring vellykket", - "change_password": "Endre passord", + "change_password": "Endre Passord", + "change_password_description": "Dette er enten første gang du logger inn i systemet, eller det har blitt gjort en forespørsel om å endre passordet ditt. Vennligst skriv inn det nye passordet nedenfor.", "change_your_password": "Endre passordet ditt", "changed_visibility_successfully": "Endret synlighet vellykket", "check_all": "Sjekk alle", @@ -414,12 +422,15 @@ "city": "By", "clear": "Tøm", "clear_all": "Tøm alt", + "clear_all_recent_searches": "Fjern alle nylige søk", "clear_message": "Fjern melding", "clear_value": "Fjern verdi", "close": "Lukk", "collapse_all": "Kollaps alt", "color_theme": "Fargetema", + "comment_deleted": "Kommentar slettet", "comment_options": "Kommentaralternativer", + "comments_and_likes": "Kommentarer & likes", "comments_are_disabled": "Kommentarer er deaktivert", "confirm": "Bekreft", "confirm_admin_password": "Bekreft administratorpassord", @@ -445,7 +456,9 @@ "create_library": "Opprett Bibliotek", "create_link": "Opprett link", "create_link_to_share": "Opprett delelink", + "create_link_to_share_description": "La alle med lenken se de(t) valgte bildet/bildene", "create_new_person": "Opprett ny person", + "create_new_person_hint": "Tildel valgte eiendeler til en ny person", "create_new_user": "Opprett ny bruker", "create_user": "Opprett Bruker", "created": "Opprettet", @@ -456,8 +469,10 @@ "date_after": "Dato etter", "date_and_time": "Dato og tid", "date_before": "Dato før", + "date_of_birth_saved": "Fødselsdatoen ble lagret vellykket", "date_range": "Datoområde", "day": "Dag", + "deduplicate_all": "De-dupliser alle", "default_locale": "Standard språkinnstilling", "default_locale_description": "Formater datoer og tall basert på nettleserens språkinnstilling", "delete": "Slett", @@ -482,6 +497,7 @@ "display_order": "Visningsrekkefølge", "display_original_photos": "Vis opprinnelige bilder", "display_original_photos_setting_description": "Foretrekk å vise det opprinnelige bildet når du ser på en fil i stedet for miniatyrbilder når den opprinnelige filen er kompatibel med nettet. Dette kan føre til tregere visning av bilder.", + "do_not_show_again": "Ikke vis denne meldingen igjen", "done": "Ferdig", "download": "Last ned", "download_settings": "Last ned", diff --git a/web/src/lib/i18n/nl.json b/web/src/lib/i18n/nl.json index 613f1e38cf..36f9886b04 100644 --- a/web/src/lib/i18n/nl.json +++ b/web/src/lib/i18n/nl.json @@ -129,12 +129,13 @@ "map_enable_description": "Kaartfuncties inschakelen", "map_gps_settings": "Kaart & GPS Instellingen", "map_gps_settings_description": "Beheer kaart & GPS (omgekeerde geocodering) instellingen", + "map_implications": "De kaartfunctie is afhankelijk van een externe service (tiles.immich.cloud)", "map_light_style": "Lichte stijl", "map_manage_reverse_geocoding_settings": "Beheer omgekeerde geocodering instellingen", "map_reverse_geocoding": "Omgekeerde geocodering", "map_reverse_geocoding_enable_description": "Omgekeerde geocodering inschakelen", "map_reverse_geocoding_settings": "Instellingen voor omgekeerde geocodering", - "map_settings": "Kaart instellingen", + "map_settings": "Kaart", "map_settings_description": "Beheer kaartinstellingen", "map_style_description": "URL naar een style.json kaartthema", "metadata_extraction_job": "Metadata ophalen", @@ -278,7 +279,7 @@ "transcoding_preferred_hardware_device": "Voorkeur hardwareapparaat", "transcoding_preferred_hardware_device_description": "Geldt alleen voor VAAPI en QSV. Stelt de dri node in die wordt gebruikt voor hardwaretranscodering.", "transcoding_preset_preset": "Preset (-preset)", - "transcoding_preset_preset_description": "Compressiesnelheid. Langzamere presets produceren kleinere bestanden en verhogen de kwaliteit bij het targeten van een bepaalde bitrate. VP9 negeert snelheden boven `faster`.", + "transcoding_preset_preset_description": "Compressiesnelheid. Langzamere presets produceren kleinere bestanden en verhogen de kwaliteit bij het targeten van een bepaalde bitrate. VP9 negeert snelheden boven 'faster'.", "transcoding_reference_frames": "Reference frames", "transcoding_reference_frames_description": "Het aantal frames om naar te verwijzen bij het comprimeren van een bepaald frame. Hogere waarden verbeteren de compressie-efficiëntie, maar vertragen de codering. Bij 0 wordt deze waarde automatisch ingesteld.", "transcoding_required_description": "Alleen video's die geen geaccepteerd formaat hebben", @@ -320,7 +321,8 @@ "user_settings": "Gebruikersinstellingen", "user_settings_description": "Gebruikersinstellingen beheren", "user_successfully_removed": "Gebruiker {email} is succesvol verwijderd.", - "version_check_enabled_description": "Periodieke verzoeken aan GitHub inschakelen om te controleren op nieuwe releases", + "version_check_enabled_description": "Versiecontrole inschakelen", + "version_check_implications": "De versiecontrole is afhankelijk van periodieke communicatie met github.com", "version_check_settings": "Versiecontrole", "version_check_settings_description": "Melding voor een nieuwe versie in-/uitschakelen", "video_conversion_job": "Transcodeer video's", @@ -912,6 +914,7 @@ "ok": "Ok", "oldest_first": "Oudste eerst", "onboarding": "Onboarding", + "onboarding_privacy_description": "De volgende (optionele) functies zijn afhankelijk van externe services en kunnen op elk moment worden uitgeschakeld in de beheerdersinstellingen.", "onboarding_storage_template_description": "Wanneer ingeschakeld, zal deze functie bestanden automatisch organiseren gebaseerd op een gebruiker-definieerd template. Gezien de stabiliteitsproblemen is de functie standaard uitgeschakeld. Voor meer informatie, bekijk de [documentatie].", "onboarding_theme_description": "Kies een kleurenthema voor de applicatie. Dit kun je later wijzigen in je instellingen.", "onboarding_welcome_description": "Laten we de applicatie instellen met enkele veelgebruikte instellingen.", @@ -919,6 +922,7 @@ "online": "Online", "only_favorites": "Alleen favorieten", "only_refreshes_modified_files": "Vernieuwt alleen gewijzigde bestanden", + "open_in_map_view": "Openen in kaartweergave", "open_in_openstreetmap": "Openen met OpenStreetMap", "open_the_search_filters": "Open de zoekfilters", "options": "Opties", @@ -985,6 +989,7 @@ "previous_memory": "Vorige herinnering", "previous_or_next_photo": "Vorige of volgende foto", "primary": "Primair", + "privacy": "Privacy", "profile_image_of_user": "Profielfoto van {user}", "profile_picture_set": "Profielfoto ingesteld.", "public_album": "Openbaar album", @@ -1022,6 +1027,8 @@ "purchase_server_title": "Server", "purchase_settings_server_activated": "De productcode van de server wordt beheerd door de beheerder", "range": "", + "rating": "Ster waardering", + "rating_description": "De exif-waardering weergeven in het infopaneel", "raw": "", "reaction_options": "Reactie opties", "read_changelog": "Lees wijzigingen", @@ -1144,6 +1151,7 @@ "shared_by_user": "Gedeeld door {user}", "shared_by_you": "Gedeeld door jou", "shared_from_partner": "Foto's van {partner}", + "shared_link_options": "Opties voor gedeelde links", "shared_links": "Gedeelde links", "shared_photos_and_videos_count": "{assetCount, plural, other {# gedeelde foto's & video's.}}", "shared_with_partner": "Gedeeld met {partner}", @@ -1152,6 +1160,7 @@ "sharing_sidebar_description": "Toon een link naar Delen in de zijbalk", "shift_to_permanent_delete": "druk op ⇧ om assets permanent te verwijderen", "show_album_options": "Toon albumopties", + "show_albums": "Toon albums", "show_all_people": "Toon alle mensen", "show_and_hide_people": "Toon & verberg mensen", "show_file_location": "Toon bestandslocatie", @@ -1184,6 +1193,8 @@ "sort_title": "Titel", "source": "Bron", "stack": "Stapel", + "stack_duplicates": "Stapel duplicaten", + "stack_select_one_photo": "Selecteer één primaire foto voor de stapel", "stack_selected_photos": "Geselecteerde foto's stapelen", "stacked_assets_count": "{count, plural, one {# asset} other {# assets}} gestapeld", "stacktrace": "Stacktrace", diff --git a/web/src/lib/i18n/pl.json b/web/src/lib/i18n/pl.json index 0cedb632a7..682f6fcb55 100644 --- a/web/src/lib/i18n/pl.json +++ b/web/src/lib/i18n/pl.json @@ -893,6 +893,7 @@ "online": "Połączony", "only_favorites": "Tylko ulubione", "only_refreshes_modified_files": "Odświeża tylko zmodyfikowane pliki", + "open_in_map_view": "Otwórz w widoku mapy", "open_in_openstreetmap": "Otwórz w OpenStreetMap", "open_the_search_filters": "Otwórz filtry wyszukiwania", "options": "Opcje", @@ -1125,6 +1126,7 @@ "sharing_sidebar_description": "Wyświetl link do udostępniania na pasku bocznym", "shift_to_permanent_delete": "naciśnij ⇧, aby trwale usunąć zasób", "show_album_options": "Pokaż opcje albumu", + "show_albums": "Pokaż albumy", "show_all_people": "Pokaż wszystkie osoby", "show_and_hide_people": "Pokaż lub ukryj osoby", "show_file_location": "Pokaż ścieżkę pliku", @@ -1157,6 +1159,8 @@ "sort_title": "Tytuł", "source": "Źródło", "stack": "Stos", + "stack_duplicates": "Stos duplikatów", + "stack_select_one_photo": "Wybierz jedno główne zdjęcie do stosu", "stack_selected_photos": "Układaj wybrane zdjęcia", "stacked_assets_count": "Ułożone {count, plural, one {# zasób} other{# zasoby}}", "stacktrace": "Stacktrace", diff --git a/web/src/lib/i18n/pt.json b/web/src/lib/i18n/pt.json index 8d551df9ae..b146e2ee2f 100644 --- a/web/src/lib/i18n/pt.json +++ b/web/src/lib/i18n/pt.json @@ -7,7 +7,7 @@ "actions": "Ações", "active": "Ativo", "activity": "Atividade", - "activity_changed": "A atividade está {ativada, selecionada, verdadeira {ativada} outra {desativada}}", + "activity_changed": "A actividade está {enabled, select, true {ativada} other {desativada}}", "add": "Adicionar", "add_a_description": "Adicionar uma descrição", "add_a_location": "Adicionar localização", @@ -25,7 +25,7 @@ "add_to_shared_album": "Adicionar ao álbum compartilhado", "added_to_archive": "Adicionado ao arquivo", "added_to_favorites": "Adicionado aos favoritos", - "added_to_favorites_count": "Adicionados {count} aos favoritos", + "added_to_favorites_count": "{count, plural, one {{count, number} adicionado aos favoritos} other {{count, number} adicionados aos favoritos}}", "admin": { "add_exclusion_pattern_description": "Adicione padrões de exclusão. Utilizar *, ** ou ? são suportados. Para ignorar todos os arquivos em qualquer diretório chamado \"Raw\", use \"**/Raw/**'. Para ignorar todos os arquivos que finalizam em \".tif\", use \"**/*.tif\". Para ignorar um caminho absoluto, use \"/caminho/para/ignorar/**\".", "authentication_settings": "Configurações de Autenticação", @@ -37,22 +37,22 @@ "cleared_jobs": "Eliminadas as tarefas de: {job}", "config_set_by_file": "A configuração está atualmente definida por um arquivo de configuração", "confirm_delete_library": "Você tem certeza que deseja excluir a biblioteca {library} ?", - "confirm_delete_library_assets": "Você tem certeza que deseja eliminar esta biblioteca? Isto eliminará todos os {count} ficheiros do Immich e esta ação não pode ser revertida. Os ficheiros permanecerão no disco.", + "confirm_delete_library_assets": "Você tem certeza que deseja eliminar esta biblioteca? Isto eliminará {count, plural, one {# arquivo incluído} other {todos os # arquivos incluídos}} do Immich e esta ação não pode ser revertida. Os ficheiros permanecerão no disco.", "confirm_email_below": "Para confirmar, digite o {email} abaixo", "confirm_reprocess_all_faces": "Tem certeza de que deseja reprocessar todos as faces? Isso também limpará as pessoas nomeadas.", "confirm_user_password_reset": "Tem certeza de que deseja redefinir a senha de {user}?", "crontab_guru": "Guru do Crontab", "disable_login": "Desabilitar login", "disabled": "", - "duplicate_detection_job_description": "Execute o aprendizado de máquina em ativos para detectar imagens semelhantes. Depende da pesquisa inteligente", + "duplicate_detection_job_description": "Execute o aprendizado de máquina em arquivos para detectar imagens semelhantes. Depende da pesquisa inteligente", "exclusion_pattern_description": "Os padrões de exclusão permitem ignorar arquivos e pastas ao escanear sua biblioteca. Isso é útil se você tiver pastas que contenham arquivos que não deseja importar, como arquivos RAW.", "external_library_created_at": "Biblioteca externa (criada em {date})", "external_library_management": "Gerenciamento de bibliotecas externas", "face_detection": "Detecção de faces", - "face_detection_description": "Detecta faces em ativos com inteligência artificial. Para vídeos, apenas a miniatura é considerada. \"Todos\" (re)processa todos os ativos. \"Ausente\" enfileira ativos que ainda não foram processados. As faces detectadas serão enfileiradas para reconhecimento facial após a conclusão da detecção de faces, agrupando-os em pessoas novas ou existentes.", - "facial_recognition_job_description": "Agrupa faces detectados em pessoas. Esta etapa é executada após a conclusão da detecção de faces. \"Todos\" (re)agrupa todos os rostos. \"Ausentes\" enfileira faces que ainda não têm uma pessoa atribuída.", + "face_detection_description": "Deteta rostos em arquivos com aprendizagem automática. Para vídeos, apenas a miniatura é considerada. \"Todos\" (re)processa todos os arquivos. \"Ausente\" enfileira arquivos que ainda não foram processados. Os rostos detetados serão enfileirados para reconhecimento facial após a conclusão da deteção de rostos, agrupando-os em pessoas novas ou já existentes.", + "facial_recognition_job_description": "Agrupa rostos detectados em pessoas. Esta etapa é executada após a conclusão da deteção de faces. \"Todos\" (re)agrupa todos os rostos. \"Ausentes\" enfileira rostos que ainda não têm uma pessoa atribuída.", "failed_job_command": "Comando {command} falhou para a tarefa: {job}", - "force_delete_user_warning": "AVISO: Isso removerá imediatamente o usuário e todos os ativos. Isso não pode ser desfeito e os arquivos não podem ser recuperados.", + "force_delete_user_warning": "AVISO: Isso removerá imediatamente o utilizador e todos os arquivos. Isso não pode ser desfeito e os ficheiros não poderão ser recuperados.", "forcing_refresh_library_files": "Forçando a atualização de todos os arquivos da biblioteca", "image_format_description": "WebP produz arquivos menores que JPEG, mas é mais lento para codificar.", "image_prefer_embedded_preview": "Prefira visualização incorporada", @@ -74,8 +74,8 @@ "job_settings": "Configurações de trabalho", "job_settings_description": "Gerenciar simultaneidade dos trabalhos", "job_status": "Status do trabalho", - "jobs_delayed": "{jobCount} adiado", - "jobs_failed": "{jobCount} falhou", + "jobs_delayed": "{jobCount, plural, one {# adiado} other {# adiados}}", + "jobs_failed": "{jobCount, plural, one {# falhou} other {# falharam}}", "library_created": "Criado biblioteca: {library}", "library_cron_expression": "Expressão Cron", "library_cron_expression_description": "Defina o intervalo de procura utilizando o formato cron. Para mais informações consulte Guru Crontab", @@ -98,12 +98,12 @@ "machine_learning_clip_model_description": "O nome do modelo CLIP definido aqui. Note que é necessário voltar a executar a \"Pesquisa Inteligente\" para todas as imagens depois de alterar um modelo.", "machine_learning_duplicate_detection": "Detecção de duplicidade", "machine_learning_duplicate_detection_enabled": "Habilitar detecção de duplicidade", - "machine_learning_duplicate_detection_enabled_description": "Se desativado, ativos exatamente idênticos ainda serão desduplicados.", + "machine_learning_duplicate_detection_enabled_description": "Se desativado, arquivos exatamente idênticos ainda serão desduplicados.", "machine_learning_duplicate_detection_setting_description": "Use embeddings CLIP para encontrar prováveis duplicidades", "machine_learning_enabled": "Habilitar o aprendizado da máquina", "machine_learning_enabled_description": "Se desativado, todos os recursos de ML serão desativados, independentemente das configurações abaixo.", "machine_learning_facial_recognition": "Reconhecimento Facial", - "machine_learning_facial_recognition_description": "Detecte, reconheça e agrupe faces em imagens", + "machine_learning_facial_recognition_description": "Deteta, reconhece e agrupa rostos em imagens", "machine_learning_facial_recognition_model": "Modelo de reconhecimento facial", "machine_learning_facial_recognition_model_description": "Os modelos estão listados em ordem decrescente de tamanho. Modelos maiores são mais lentos e utilizam mais memória, mas produzem melhores resultados. Observe que ao alterar um modelo, você deve executar novamente o trabalho de Detecção de faces para todas as imagens.", "machine_learning_facial_recognition_setting": "Ativar reconhecimento facial", @@ -140,10 +140,10 @@ "metadata_extraction_job": "Extrair metadados", "metadata_extraction_job_description": "Extraia informações de metadados de cada ativo, como GPS e resolução", "migration_job": "Migração", - "migration_job_description": "Migre miniaturas de ativos e faces para a estrutura de pastas mais recente", + "migration_job_description": "Migre miniaturas de arquivos e rostos para a estrutura de pastas mais recente", "no_paths_added": "Nenhum caminho adicionado", "no_pattern_added": "Nenhum padrão adicionado", - "note_apply_storage_label_previous_assets": "Observação: Para aplicar o rótulo de armazenamento a ativos carregados anteriormente, execute o", + "note_apply_storage_label_previous_assets": "Observação: Para aplicar o rótulo de armazenamento a arquivos carregados anteriormente, execute o", "note_cannot_be_changed_later": "NOTA: Isto não pode ser alterado posteriormente!", "note_unlimited_quota": "Observação: insira 0 para cota ilimitada", "notification_email_from_address": "A partir do endereço", @@ -158,14 +158,14 @@ "notification_email_test_email": "Enviar e-mail de teste", "notification_email_test_email_failed": "Falha ao enviar e-mail de teste. Verifique seus valores", "notification_email_test_email_sent": "Um email de teste foi enviado para {email}. Por favor, verifique sua caixa de entrada.", - "notification_email_username_description": "Nome de usuário a ser usado ao autenticar com o servidor de e-mail", + "notification_email_username_description": "Nome de utilizador a ser usado ao autenticar com o servidor de e-mail", "notification_enable_email_notifications": "Habilitar notificações por e-mail", "notification_settings": "Configurações de notificação", "notification_settings_description": "Gerenciar configurações de notificação, incluindo e-mail", "oauth_auto_launch": "Inicialização automática", "oauth_auto_launch_description": "Inicie o fluxo de login do OAuth automaticamente ao navegar até a página de login", "oauth_auto_register": "Registro automático", - "oauth_auto_register_description": "Registre automaticamente novos usuários após fazer login com OAuth", + "oauth_auto_register_description": "Registre automaticamente novos utilizadores após fazer login com OAuth", "oauth_button_text": "Botão de texto", "oauth_client_id": "ID do Cliente", "oauth_client_secret": "Segredo do cliente", @@ -182,9 +182,9 @@ "oauth_settings_more_details": "Para mais informações sobre esta funcionalidade, veja a documentação.", "oauth_signing_algorithm": "Algoritmo de assinatura", "oauth_storage_label_claim": "Reivindicação de rótulo de armazenamento", - "oauth_storage_label_claim_description": "Defina automaticamente o rótulo de armazenamento do usuário para o valor desta declaração.", + "oauth_storage_label_claim_description": "Defina automaticamente o rótulo de armazenamento do utilizador para o valor desta declaração.", "oauth_storage_quota_claim": "Reivindicação de cota de armazenamento", - "oauth_storage_quota_claim_description": "Defina automaticamente a cota de armazenamento do usuário para o valor desta declaração.", + "oauth_storage_quota_claim_description": "Defina automaticamente a cota de armazenamento do utilizador para o valor desta declaração.", "oauth_storage_quota_default": "Cota de armazenamento padrão (GiB)", "oauth_storage_quota_default_description": "Cota em GiB a ser usada quando nenhuma reivindicação for fornecida (insira 0 para cota ilimitada).", "offline_paths": "Caminhos off-line", @@ -201,7 +201,7 @@ "repair_all": "Reparar tudo", "repair_matched_items": "Encontrado {count, plural, one {# item} other {# itens}}", "repaired_items": "Reparado {count, plural, one {# item} other {# itens}}", - "require_password_change_on_login": "Exigir que o usuário altere a senha no primeiro login", + "require_password_change_on_login": "Exigir que o utilizador altere a senha no primeiro início de sessão", "reset_settings_to_default": "Redefinir as configurações para o padrão", "reset_settings_to_recent_saved": "Redefinir as configurações para as configurações salvas recentemente", "scanning_library_for_changed_files": "Escaneando a biblioteca em busca de arquivos alterados", @@ -216,17 +216,21 @@ "sidecar_job": "Metadados secundários", "sidecar_job_description": "Descubra ou sincronize metadados secundários do sistema de arquivos", "slideshow_duration_description": "Tempo em segundos para exibir cada imagem", - "smart_search_job_description": "Execute o aprendizado de máquina em ativos para oferecer suporte à pesquisa inteligente", + "smart_search_job_description": "Execute a aprendizagem automática em arquivos para oferecer suporte à pesquisa inteligente", + "storage_template_date_time_sample": "Exemplo de tempo {date}", "storage_template_enable_description": "Habilitar mecanismo de modelo de armazenamento", "storage_template_hash_verification_enabled": "Verificação de hash ativada", "storage_template_hash_verification_enabled_description": "Ativa a verificação de hash, não desative esta opção a menos que tenha certeza das implicações", "storage_template_migration": "Migração de modelo de armazenamento", "storage_template_migration_description": "Aplicar o {template} atual para arquivos previamente carregados", + "storage_template_migration_info": "As mudanças do modelo apenas se aplicarão a novos arquivos. Para aplicar o modelo retroativamente para os arquivos carregados anteriormente, execute o {job}.", "storage_template_migration_job": "Trabalho de migração do modelo de armazenamento", "storage_template_more_details": "Para mais informações sobre esta funcionalidade, dirija-se a Modelo de Armazenamento e as suas implicações", "storage_template_onboarding_description": "Quando ativada, esta funcionalidade irá organizar os ficheiros automaticamente baseando-se num modelo definido pelo utilizador. Devido a problemas de estabilidade esta funcionalidade está desativada por defeito. Para mais informações, por favor leia a documentação.", + "storage_template_path_length": "Limite aproximado do tamanho do caminho: {length, number}{limit, number}", "storage_template_settings": "Modelo de armazenamento", "storage_template_settings_description": "Gerenciar a estrutura de pastas e o nome do arquivo dos ativos carregados", + "storage_template_user_label": "{label} é o Rótulo do Armazenamento do utilizador", "system_settings": "Configurações de Sistema", "theme_custom_css_settings": "CSS customizado", "theme_custom_css_settings_description": "Folhas de estilo em cascata permitem que o design do Immich seja personalizado.", @@ -244,12 +248,15 @@ "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Codecs de áudio aceitos", "transcoding_accepted_audio_codecs_description": "Selecione quais codecs de áudio não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", + "transcoding_accepted_containers": "Contentores aceites", + "transcoding_accepted_containers_description": "Selecione os formatos de contentores que não precisam de ser remuxed para MP4. Apenas usados para algumas políticas de transcodificação.", "transcoding_accepted_video_codecs": "Codecs de vídeo aceitos", "transcoding_accepted_video_codecs_description": "Selecione quais codecs de vídeo não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", - "transcoding_advanced_options_description": "Opções que a maioria dos usuários não deveria precisar alterar", + "transcoding_advanced_options_description": "Opções que a maioria dos utilizadores não deveria precisar alterar", "transcoding_audio_codec": "Codec de áudio", "transcoding_audio_codec_description": "Opus é a opção de mais alta qualidade, mas tem menor compatibilidade com dispositivos ou softwares antigos.", "transcoding_bitrate_description": "Vídeos com taxa de bits superior à máxima ou que não estão em um formato aceito", + "transcoding_codecs_learn_more": "Para aprender mais sobre as terminologias utilizadas aqui, consulte a documentação do FFmpeg para o codec H.264, codec HEVC e codec VP9.", "transcoding_constant_quality_mode": "Modo de qualidade constante", "transcoding_constant_quality_mode_description": "ICQ é melhor que CQP, mas alguns dispositivos de aceleração de hardware não suportam este modo. Definir esta opção dará preferência ao modo especificado ao usar codificação baseada em qualidade. Ignorado pelo NVENC porque não suporta ICQ.", "transcoding_constant_rate_factor": "Fator de taxa constante (-crf)", @@ -258,7 +265,7 @@ "transcoding_hardware_acceleration": "Aceleração de hardware", "transcoding_hardware_acceleration_description": "Experimental; muito mais rápido, mas terá qualidade inferior com a mesma taxa de bits", "transcoding_hardware_decoding": "Decodificação de hardware", - "transcoding_hardware_decoding_setting_description": "Aplica-se apenas a NVENC e RKMPP. Permite aceleração ponta a ponta em vez de apenas acelerar a codificação. Pode não funcionar em todos os vídeos.", + "transcoding_hardware_decoding_setting_description": "Aplica-se apenas a NVENC, QSV e RKMPP. Permite aceleração ponta a ponta em vez de apenas acelerar a codificação. Pode não funcionar em todos os vídeos.", "transcoding_hevc_codec": "Codec HEVC", "transcoding_max_b_frames": "Máximo de quadros B", "transcoding_max_b_frames_description": "Valores mais altos melhoram a eficiência da compactação, mas retardam a codificação. Pode não ser compatível com aceleração de hardware em dispositivos mais antigos. 0 desativa os quadros B, enquanto -1 define esse valor automaticamente.", @@ -270,7 +277,7 @@ "transcoding_preferred_hardware_device": "Dispositivo de hardware preferido", "transcoding_preferred_hardware_device_description": "Aplica-se apenas a VAAPI e QSV. Define o nó dri usado para transcodificação de hardware.", "transcoding_preset_preset": "Predefinido (-preset)", - "transcoding_preset_preset_description": "Velocidade de compressão. Predefinições mais lentas produzem arquivos menores e aumentam a qualidade ao atingir uma determinada taxa de bits. VP9 ignora velocidades acima de `mais rápidas`.", + "transcoding_preset_preset_description": "Velocidade de compressão. Predefinições mais lentas produzem arquivos menores e aumentam a qualidade ao atingir uma determinada taxa de bits. VP9 ignora velocidades acima de \"mais rápidas\".", "transcoding_reference_frames": "Quadros de referência", "transcoding_reference_frames_description": "O número de quadros a serem referenciados ao compactar um determinado quadro. Valores mais altos melhoram a eficiência da compactação, mas retardam a codificação. 0 define esse valor automaticamente.", "transcoding_required_description": "Somente vídeos que não estejam em um formato aceito", @@ -294,19 +301,23 @@ "transcoding_video_codec_description": "O VP9 tem alta eficiência e compatibilidade com a web, mas leva mais tempo para transcodificar. HEVC tem desempenho semelhante, mas tem menor compatibilidade com a web. H.264 é amplamente compatível e rápido de transcodificar, mas produz arquivos muito maiores. AV1 é o codec mais eficiente, mas não possui suporte em dispositivos mais antigos.", "trash_enabled_description": "Ativar recursos da Lixeira", "trash_number_of_days": "Número de dias", - "trash_number_of_days_description": "Número de dias para manter os ativos na lixeira antes de deletar permanentemente", + "trash_number_of_days_description": "Número de dias para manter os arquivos na lixeira antes de eliminar permanentemente", "trash_settings": "Configurações da Lixeira", "trash_settings_description": "Gerenciar configurações da lixeira", "untracked_files": "Arquivos não rastreados", "untracked_files_description": "Esses arquivos não são rastreados pelo aplicativo. Eles podem ser o resultado de movimentos malsucedidos, carregamentos interrompidos ou deixados para trás devido a um bug", + "user_delete_delay": "A conta e os arquivos de {user} serão agendados para eliminação permanente em {delay, plural, one {# dia} other {# dias}}.", "user_delete_delay_settings": "Excluir atraso", - "user_delete_delay_settings_description": "Número de dias após a remoção para excluir permanentemente a conta e os ativos de um usuário. O trabalho de exclusão de usuário é executado à meia-noite para verificar usuários que estão prontos para exclusão. As alterações nesta configuração serão avaliadas na próxima execução.", - "user_management": "Gerenciamento de usuários", - "user_password_has_been_reset": "A senha do usuário foi redefinida:", - "user_password_reset_description": "Forneça a senha temporária ao usuário e informe que ele precisará alterar a senha no próximo login.", - "user_settings": "Configurações do Usuário", - "user_settings_description": "Gerenciar configurações do usuário", - "user_successfully_removed": "O usuário {email} foi removido com sucesso.", + "user_delete_delay_settings_description": "Número de dias após a remoção para excluir permanentemente a conta e os arquivos de um utilizador. O trabalho de exclusão de utilizadores é executado à meia-noite para verificar utilizadores que estão prontos para exclusão. As alterações nesta configuração serão avaliadas na próxima execução.", + "user_delete_immediately": "A conta e os arquivos de {user} serão enfileirados para exclusão permanente imediatamente.", + "user_delete_immediately_checkbox": "Adicionar utilizador e arquivos à fila para eliminação imediata", + "user_management": "Gerenciamento de utilizadores", + "user_password_has_been_reset": "A senha do utilizador foi redefinida:", + "user_password_reset_description": "Forneça a senha temporária ao utilizador e informe que ele precisará alterar a senha no próximo início de sessão.", + "user_restore_description": "A conta de {user} será restaurada.", + "user_settings": "Configurações do Utilizador", + "user_settings_description": "Gerenciar configurações do utilizador", + "user_successfully_removed": "O utilizador {email} foi removido com sucesso.", "version_check_enabled_description": "Ativa verificações periódicas no GitHub para novas versões", "version_check_settings": "Verificação de versão", "version_check_settings_description": "Ativar/desativar a notificação de nova versão", @@ -317,21 +328,35 @@ "admin_password": "Senha do administrador", "administration": "Administração", "advanced": "Avançado", + "age_months": "Idade {months, plural, one {# mês} other {# meses}}", + "age_year_months": "Idade 1 ano, {months, plural, one {# mês} other {# meses}}", + "age_years": "Idade {years, plural, one{# ano} other {# anos}}", "album_added": "Álbum adicionado", "album_added_notification_setting_description": "Receba uma notificação por e-mail quando você for adicionado a um álbum compartilhado", "album_cover_updated": "Capa do álbum atualizada", + "album_delete_confirmation": "De certeza que quer apagar o álbum {album}?\nSe o álbum for partilhado, os outros utilizadores não poderão acessá-lo novamente.", "album_info_updated": "Informações do álbum atualizadas", + "album_leave": "Sair do álbum?", + "album_leave_confirmation": "Tem a certeza que quer sair de {album}?", "album_name": "Nome do álbum", "album_options": "Opções de álbum", + "album_remove_user": "Remover utilizador?", + "album_remove_user_confirmation": "Tem a certeza que quer remover {user}?", + "album_share_no_users": "Parece que tem este álbum partilhado com todos os utilizadores ou que não existem utilizadores para o partilhar.", "album_updated": "Álbum atualizado", - "album_updated_setting_description": "Receba uma notificação por e-mail quando um álbum compartilhado tiver novos recursos", + "album_updated_setting_description": "Receba uma notificação por e-mail quando um álbum compartilhado tiver novos arquivos", + "album_user_left": "Saída {album}", + "album_user_removed": "Utilizador {user} removido", "albums": "Álbuns", "albums_count": "{count, plural, one {{count, number} Álbum} other {{count, number} Álbuns}}", "all": "Todos", + "all_albums": "Todos os álbuns", "all_people": "Todas as pessoas", + "all_videos": "Todos os vídeos", "allow_dark_mode": "Permitir modo escuro", "allow_edits": "Permitir edições", "api_key": "Chave de API", + "api_key_description": "Este valor será apresentado apenas uma única vez. Por favor, certifique-se que o copiou antes de fechar a janela.", "api_keys": "Chaves de API", "app_settings": "Configurações do Aplicativo", "appears_in": "Aparece em", @@ -340,16 +365,34 @@ "archive_size": "Tamanho do Arquivo", "archive_size_description": "Configure o tamanho do arquivo para downloads (em GiB)", "archived": "Arquivado", + "are_these_the_same_person": "São a mesma pessoa?", + "asset_added_to_album": "Adicionado ao álbum", + "asset_adding_to_album": "A adicionar ao álbum...", + "asset_description_updated": "A descrição do arquivo foi atualizada", + "asset_filename_is_offline": "O arquivo {filename} está offline", + "asset_has_unassigned_faces": "O arquivo tem rostos sem atribuição", "asset_offline": "Ativo off-line", - "assets": "Ativos", + "assets": "Arquivos", + "assets_added_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}}", + "assets_added_to_album_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}} ao álbum", + "assets_added_to_name_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}} a {hasName, select, true {{name}} other {novo álbum}}", + "assets_count": "{count, plural, one {# arquivo} other {# arquivos}}", "assets_moved_to_trash": "{count, plural, one {# ativo enviado} other {# ativos enviados}} para a lixeira", + "assets_moved_to_trash_count": "{count, plural, one {# arquivo movido} other {# arquivos movidos}} para a lixeira", + "assets_permanently_deleted_count": "{count, plural, one {# arquivo} other {# arquivos}} excluídos permanentemente", + "assets_removed_count": "{count, plural, one {# arquivo excluído} other {# arquivos excluídos}}", + "assets_restored_count": "{count, plural, one {# arquivo restaurado} other {# arquivos restaurados}}", + "assets_trashed_count": "{count, plural, one {# arquivo enviado} other {# arquivos enviados}} para a lixeira", "authorized_devices": "Dispositivos Autorizados", "back": "Voltar", "backward": "Para trás", + "birthdate_saved": "Data de nascimento guardada com sucesso", + "birthdate_set_description": "A data de nascimento é usada para calcular a idade desta pessoa no momento em que uma fotografia foi tirada.", "blurred_background": "Fundo desfocado", - "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja deletar todos os {count} ativos duplicados? Esta ação mantém o maior ativo de cada grupo e deleta permanentemente todas as outras duplicidades. Você não pode desfazer esta ação!", - "bulk_keep_duplicates_confirmation": "Tem certeza de que deseja manter os {count} ativos duplicados? Isso resolverá todos os grupos duplicados sem excluir nada.", - "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a lixeira todos os {count} ativos duplicados? Isso manterá o maior ativo de cada grupo e moverá para a lixeira todas as outras duplicidades.", + "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja excluir {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Esta ação mantém o maior arquivo de cada grupo e exclui permanentemente todas as outras duplicidades. Você não pode desfazer esta ação!", + "bulk_keep_duplicates_confirmation": "Tem certeza de que deseja manter {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso resolverá todos os grupos duplicados sem excluir nada.", + "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a lixeira {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso manterá o maior arquivo de cada grupo e moverá para a lixeira todas as outras duplicidades.", + "buy": "Comprar Immich", "camera": "Câmera", "camera_brand": "Marca da câmera", "camera_model": "Modelo da câmera", @@ -375,11 +418,14 @@ "city": "Cidade", "clear": "Limpar", "clear_all": "Limpar tudo", + "clear_all_recent_searches": "Limpar todas as pesquisas recentes", "clear_message": "Limpar mensagem", "clear_value": "Limpar valor", "close": "Fechar", + "collapse": "Colapsar", "collapse_all": "Colapsar tudo", "color_theme": "Tema de cores", + "comment_deleted": "Comentário eliminado", "comment_options": "Opções de comentário", "comments_are_disabled": "Comentários estão desativados", "confirm": "Confirmar", @@ -407,8 +453,8 @@ "create_link": "Criar link", "create_link_to_share": "Criar link para partilhar", "create_new_person": "Criar nova pessoa", - "create_new_user": "Criar novo usuário", - "create_user": "Criar usuário", + "create_new_user": "Criar novo utilizador", + "create_user": "Criar utilizador", "created": "Criado", "current_device": "Dispositivo atual", "custom_locale": "Localização Customizada", @@ -417,6 +463,7 @@ "date_after": "Data após", "date_and_time": "Data e Hora", "date_before": "Data antes", + "date_of_birth_saved": "Data de nascimento guardada com sucesso", "date_range": "Intervalo de datas", "day": "Dia", "deduplicate_all": "Limpar todas Duplicidades", @@ -430,7 +477,7 @@ "delete_library": "Excluir biblioteca", "delete_link": "Excluir link", "delete_shared_link": "Excluir link de compartilhamento", - "delete_user": "Excluir usuário", + "delete_user": "Excluir utilizador", "deleted_shared_link": "Link de compartilhamento excluído", "description": "Descrição", "details": "Detalhes", @@ -444,13 +491,15 @@ "display_order": "Ordem de exibição", "display_original_photos": "Exibir fotos originais", "display_original_photos_setting_description": "Prefira exibir a foto original ao visualizar um ativo em vez de miniaturas quando o ativo original é compatível com a web. Isso pode diminuir a velocidade de exibição das fotos.", + "do_not_show_again": "Não mostrar esta mensagem novamente", "done": "Feito", - "download": "Baixar", - "download_settings": "Baixar", - "download_settings_description": "Gerenciar configurações relacionadas a baixar ativos", + "download": "Transferir", + "download_settings": "Transferir", + "download_settings_description": "Gerenciar configurações relacionadas a transferir ativos", "downloading": "Baixando", + "downloading_asset_filename": "A transferir o arquivo {filename}", "duplicates": "Duplicados", - "duplicates_description": "Marque cada grupo indicando quais ativos, se algum, são duplicados", + "duplicates_description": "Marque cada grupo indicando quais arquivos, se algum, são duplicados", "duration": "Duração", "durations": { "days": "", @@ -459,6 +508,7 @@ "months": "", "years": "" }, + "edit": "Editar", "edit_album": "Editar álbum", "edit_avatar": "Editar foto de perfil", "edit_date": "Editar data", @@ -473,62 +523,88 @@ "edit_name": "Editar nome", "edit_people": "Editar pessoas", "edit_title": "Editar Título", - "edit_user": "Editar usuário", + "edit_user": "Editar utilizador", "edited": "Editado", "editor": "Editar", "email": "E-mail", "empty": "", "empty_album": "", "empty_trash": "Esvaziar lixo", - "enable": "", - "enabled": "", + "enable": "Ativar", + "enabled": "Ativado", "end_date": "Data final", "error": "Erro", "error_loading_image": "Erro ao carregar a página", + "error_title": "Erro - Algo correu mal", "errors": { + "cant_apply_changes": "Não foi possível aplicar as alterações", + "cant_change_metadata_assets_count": "Não foi possível alterar os metadados de {count, plural, one {# arquivo} other {# arquivos}}", + "cant_get_faces": "Não foi possível obter os rostos", + "cant_get_number_of_comments": "Não foi possível obter o número de comentários", + "cant_search_people": "Não foi possível pesquisar pessoas", + "cant_search_places": "Não foi possível pesquisar locais", "cleared_jobs": "Trabalhos eliminados para: {job}", + "error_adding_assets_to_album": "Erro ao adicionar arquivos ao álbum", + "error_downloading": "Erro a transferir {filename}", + "error_hiding_buy_button": "Erro ao esconder botão de compra", + "error_selecting_all_assets": "Erro ao selecionar todos os arquivos", "exclusion_pattern_already_exists": "Este padrão de exclusão já existe.", "failed_job_command": "Comando {command} falhou para o trabalho: {job}", + "failed_to_create_album": "Falha ao criar álbum", + "failed_to_get_people": "Falha na obtenção de pessoas", + "failed_to_load_asset": "Falha ao carregar arquivo", + "failed_to_load_assets": "Falha ao carregar arquivos", + "failed_to_load_people": "Falha ao carregar pessoas", + "failed_to_remove_product_key": "Falha ao remover chave de produto", "import_path_already_exists": "Este caminho de importação já existe.", "paths_validation_failed": "a validação de {paths, plural, one {# caminho falhou} other {# caminhos falharam}}", "quota_higher_than_disk_size": "Você definiu uma cota maior do que o tamanho do disco", "repair_unable_to_check_items": "Não foi possível verificar {count, select, one {um item} other {alguns itens}}", - "unable_to_add_album_users": "Não foi possível adicionar usuários ao álbum", + "unable_to_add_album_users": "Não foi possível adicionar utilizadores ao álbum", "unable_to_add_comment": "Não foi possível adicionar o comentário", "unable_to_add_exclusion_pattern": "Não foi possível adicionar o padrão de exclusão", "unable_to_add_import_path": "Não foi possível adicionar o caminho de importação", "unable_to_add_partners": "Não foi possível adicionar parceiros", - "unable_to_change_album_user_role": "Não foi possível alterar a permissão do usuário no álbum", + "unable_to_add_remove_favorites": "Não foi possível {favorite, select, true {adicionar arquivo aos} other {remover arquivo dos}} favoritos", + "unable_to_change_album_user_role": "Não foi possível alterar a permissão do utilizador no álbum", "unable_to_change_date": "Não foi possível alterar a data", "unable_to_change_location": "Não foi possível alterar a localização", "unable_to_change_password": "Não foi possível alterar a senha", "unable_to_check_item": "", "unable_to_check_items": "", + "unable_to_complete_oauth_login": "Não foi possível completar início de sessão com OAuth", + "unable_to_connect_to_server": "Não foi possível ligar ao servidor", "unable_to_copy_to_clipboard": "Não é possível copiar para a área de transferência, certifique-se que está acessando a pagina através de https", - "unable_to_create_admin_account": "", + "unable_to_create_admin_account": "Não foi possível criar conta de administrador", "unable_to_create_api_key": "Não foi possível criar uma nova Chave de API", "unable_to_create_library": "Não foi possível criar a biblioteca", - "unable_to_create_user": "Não foi possível criar o usuário", + "unable_to_create_user": "Não foi possível criar o utilizador", "unable_to_delete_album": "Não foi possível deletar o álbum", "unable_to_delete_asset": "Não foi possível deletar o ativo", + "unable_to_delete_assets": "Erro ao eliminar arquivos", "unable_to_delete_exclusion_pattern": "Não foi possível deletar o padrão de exclusão", "unable_to_delete_import_path": "Não foi possível deletar o caminho de importação", "unable_to_delete_shared_link": "Não foi possível deletar o link compartilhado", - "unable_to_delete_user": "Não foi possível deletar o usuário", + "unable_to_delete_user": "Não foi possível deletar o utilizador", + "unable_to_download_files": "Não foi possível transferir ficheiros", "unable_to_edit_exclusion_pattern": "Não foi possível editar o padrão de exclusão", "unable_to_edit_import_path": "Não foi possível editar o caminho de importação", "unable_to_empty_trash": "Não foi possível esvaziar a lixeira", "unable_to_enter_fullscreen": "Não foi possível entrar em modo de tela cheia", "unable_to_exit_fullscreen": "Não foi possível sair do modo de tela cheia", + "unable_to_get_comments_number": "Não foi possível obter número de comentários", "unable_to_hide_person": "Não foi possível esconder a pessoa", "unable_to_link_oauth_account": "Não foi possível associar a conta OAuth", "unable_to_load_album": "Não foi possível carregar o álbum", "unable_to_load_asset_activity": "Não foi possível carregar as atividades do ativo", "unable_to_load_items": "Não foi possível carregar os items", "unable_to_load_liked_status": "Não foi possível carregar os status de gostei", + "unable_to_log_out_all_devices": "Não foi possível terminar a sessão em todos os dispositivos", + "unable_to_log_out_device": "Não foi possível terminar a sessão no dispositivo", + "unable_to_login_with_oauth": "Não foi possível iniciar sessão com OAuth", "unable_to_play_video": "Não foi possível reproduzir o vídeo", - "unable_to_refresh_user": "Não foi possível atualizar o usuário", - "unable_to_remove_album_users": "Não foi possível remover usuários do álbum", + "unable_to_refresh_user": "Não foi possível atualizar o utilizador", + "unable_to_remove_album_users": "Não foi possível remover utilizador do álbum", "unable_to_remove_api_key": "Não foi possível a Chave de API", "unable_to_remove_comment": "", "unable_to_remove_library": "Não foi possível remover a biblioteca", @@ -539,11 +615,12 @@ "unable_to_repair_items": "Não foi possível reparar os itens", "unable_to_reset_password": "Não foi possível resetar a senha", "unable_to_resolve_duplicate": "Não foi possível resolver a duplicidade", - "unable_to_restore_assets": "Não foi possível restaurar ativos", + "unable_to_restore_assets": "Não foi possível restaurar arquivos", "unable_to_restore_trash": "Não foi possível restaurar itens da lixeira", - "unable_to_restore_user": "Não foi possível restaurar usuário", + "unable_to_restore_user": "Não foi possível restaurar utilizador", "unable_to_save_album": "Não foi possível salvar o álbum", "unable_to_save_api_key": "Não foi possível salvar a Chave de API", + "unable_to_save_date_of_birth": "Não foi possível guardar a data de nascimento", "unable_to_save_name": "Não foi possível salvar o nome", "unable_to_save_profile": "Não foi possível salvar o perfil", "unable_to_save_settings": "Não foi possível salvar as configurações", @@ -553,26 +630,32 @@ "unable_to_submit_job": "Não foi possível enviar o trabalho", "unable_to_trash_asset": "Não foi possível enviar o ativo para a lixeira", "unable_to_unlink_account": "Não foi possível desvincular conta", + "unable_to_update_album_cover": "Não foi possível atualizar a capa do álbum", + "unable_to_update_album_info": "Não foi possível atualizar informações do álbum", "unable_to_update_library": "Não foi possível atualizar a biblioteca", "unable_to_update_location": "Não foi possível atualizar a localização", "unable_to_update_settings": "Não foi possível atualizar as configurações", "unable_to_update_timeline_display_status": "Não foi possível atualizar o modo de visualização da linha do tempo", - "unable_to_update_user": "Não foi possível atualizar o usuário" + "unable_to_update_user": "Não foi possível atualizar o usuário", + "unable_to_upload_file": "Não foi possível carregar o ficheiro" }, "every_day_at_onepm": "", "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", + "exif": "Exif", "exit_slideshow": "Sair da apresentação", "expand_all": "Expandir tudo", "expire_after": "Expira depois", "expired": "Expirou", + "expires_date": "Expira em {date}", "explore": "Explorar", "export": "Exportar", "export_as_json": "Exportar como JSON", "extension": "Extensão", "external": "Externo", "external_libraries": "Bibliotecas externas", + "face_unassigned": "Sem atribuição", "failed_to_get_people": "Falha ao carregar as pessoas", "favorite": "Favorito", "favorite_or_unfavorite_photo": "Marque ou desmarque a foto como favorita", @@ -597,13 +680,30 @@ "go_to_search": "Ir para a pesquisa", "go_to_share_page": "Ir para a página de compartilhamento", "group_albums_by": "Agrupar álbuns por...", + "group_no": "Sem agrupamento", + "group_owner": "Agrupar por dono", + "group_year": "Agrupar por ano", "has_quota": "Há cota", + "hi_user": "Olá {name} ({email})", + "hide_all_people": "Ocultar todas as pessoas", "hide_gallery": "Ocultar galeria", + "hide_named_person": "Ocultar pessoa {name}", "hide_password": "Ocultar senha", "hide_person": "Ocultar pessoa", + "hide_unnamed_people": "Ocultar pessoas sem nome", "host": "Host", "hour": "Hora", "image": "Imagem", + "image_alt_text_date": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1} em {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1} e {person2} em {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1}, {person2}, e {person3} em {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1}, {person2}, e outras {additionalCount, number} pessoas em {date}", + "image_alt_text_date_place": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} em {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1} em {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1} e {person2} em {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e {person3} em {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e outras {additionalCount, number} pessoas em {date}", "img": "", "immich_logo": "Logo do Immich", "immich_web_interface": "Interface web do Immich", @@ -613,7 +713,7 @@ "in_archive": "Arquivado", "include_archived": "Incluir arquivados", "include_shared_albums": "Incluir álbuns compartilhados", - "include_shared_partner_assets": "Incluir ativos compartilhados por parceiros", + "include_shared_partner_assets": "Incluir arquivos compartilhados por parceiros", "individual_share": "Compartilhamento único", "info": "Informações", "interval": { @@ -632,6 +732,8 @@ "language": "Idioma", "language_setting_description": "Selecione seu Idioma preferido", "last_seen": "Visto pela ultima vez", + "latest_version": "Versão mais recente", + "latitude": "Latitude", "leave": "Sair", "let_others_respond": "Permitir respostas", "level": "Nível", @@ -646,7 +748,11 @@ "loading_search_results_failed": "Falha ao carregar os resultados da pesquisa", "log_out": "Sair", "log_out_all_devices": "Sair de todos dispositivos", + "logged_out_all_devices": "Sessão terminada em todos os dispositivos", + "logged_out_device": "Sessão terminada no dispositivo", + "login": "Iniciar sessão", "login_has_been_disabled": "Login foi desativado.", + "longitude": "Longitude", "look": "Estilo", "loop_videos": "Repetir vídeos", "loop_videos_description": "Ative para repetir os vídeos automaticamente durante a exibição.", @@ -659,12 +765,14 @@ "manage_your_devices": "Gerenciar seus dispositivos logados", "manage_your_oauth_connection": "Gerenciar sua conexão OAuth", "map": "Mapa", + "map_marker_for_images": "Marcador no mapa para fotos tiradas em {city}, {country}", "map_marker_with_image": "Marcador de mapa com imagem", "map_settings": "Definições do mapa", "matches": "Correspondências", "media_type": "Tipo de mídia", "memories": "Memórias", "memories_setting_description": "Gerencie o que vê em suas memórias", + "memory": "Memória", "menu": "Menu", "merge": "Mesclar", "merge_people": "Mesclar pessoas", @@ -682,10 +790,12 @@ "name": "Nome", "name_or_nickname": "Nome ou apelido", "never": "Nunca", + "new_album": "Novo Álbum", "new_api_key": "Nova Chave de API", "new_password": "Nova senha", "new_person": "Nova Pessoa", - "new_user_created": "Novo usuário criado", + "new_user_created": "Novo utilizador criado", + "new_version_available": "NOVA VERSÃO DISPONÍVEL", "newest_first": "Mais recente primeiro", "next": "Avançar", "next_memory": "Próxima memória", @@ -703,7 +813,7 @@ "no_results": "Sem resultados", "no_shared_albums_message": "Crie um álbum para compartilhar fotos e vídeos com pessoas em sua rede", "not_in_any_album": "Fora de álbum", - "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar o rótulo de armazenamento a ativos carregados anteriormente, execute o", + "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar o rótulo de armazenamento a arquivos carregados anteriormente, execute o", "note_unlimited_quota": "Nota: Digite 0 para cota ilimitada", "notes": "Notas", "notification_toggle_setting_description": "Habilitar notificações por e-mail", @@ -715,19 +825,23 @@ "offline_paths_description": "Estes resultados podem ser devidos a arquivos deletados manualmente e que não são parte de uma biblioteca externa.", "ok": "Ok", "oldest_first": "Mais antigo primeiro", + "onboarding_welcome_user": "Bem-vindo(a), {user}", "online": "Online", "only_favorites": "Somente favoritos", "only_refreshes_modified_files": "Somente atualize arquivos modificados", + "open_in_openstreetmap": "Abrir no OpenStreetMap", "open_the_search_filters": "Abre os filtros de pesquisa", "options": "Opções", + "or": "ou", "organize_your_library": "Organize sua biblioteca", + "original": "original", "other": "Outro", "other_devices": "Outros dispositivos", "other_variables": "Outras variáveis", "owned": "Seu", "owner": "Dono", "partner_can_access": "{partner} pode acessar", - "partner_can_access_assets": "Todas suas fotos e vídeos, excetos os Arquivados ou Excluídos", + "partner_can_access_assets": "Todas as suas fotos e vídeos, exceto os Arquivados ou Excluídos", "partner_can_access_location": "A localização onde as fotos foram tiradas", "partner_sharing": "Compartilhamento com Parceiro", "partners": "Parceiros", @@ -747,14 +861,19 @@ "paused": "Interrompido", "pending": "Pendente", "people": "Pessoas", + "people_edits_count": "{count, plural, one {# pessoa editada} other {# pessoas editadas}}", "people_sidebar_description": "Exibe o link Pessoas na barra lateral", "perform_library_tasks": "", "permanent_deletion_warning": "Aviso para deletar permanentemente", - "permanent_deletion_warning_setting_description": "Exibe um aviso ao deletar ativos de forma permanente", + "permanent_deletion_warning_setting_description": "Exibe um aviso ao excluir arquivos de forma permanente", "permanently_delete": "Deletar permanentemente", + "permanently_delete_assets_count": "Excluir permanentemente {count, plural, one {arquivo} other {arquivos}}", "permanently_deleted_asset": "Ativo deletado permanentemente", "permanently_deleted_assets": "{count, plural, one {# ativo deletado} other {# ativos deletados}} permanentemente", + "permanently_deleted_assets_count": "{count, plural, one {# arquivo excluído} other {# arquivos excluídos}} permanentemente", + "person": "Pessoa", "photos": "Fotos", + "photos_and_videos": "Fotos & Vídeos", "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}", "photos_from_previous_years": "Fotos de anos anteriores", "pick_a_location": "Selecione uma localização", @@ -772,8 +891,27 @@ "previous_memory": "Memória anterior", "previous_or_next_photo": "Foto anterior ou próxima", "primary": "Primário", + "profile_image_of_user": "Imagem de perfil de {user}", "profile_picture_set": "Foto de perfil definida.", + "public_album": "Álbum público", "public_share": "Compartilhar Publicamente", + "purchase_activated_subtitle": "Agradecemos por apoiar o Immich e software de código aberto", + "purchase_button_activate": "Ativar", + "purchase_button_buy": "Comprar", + "purchase_button_buy_immich": "Comprar Immich", + "purchase_button_never_show_again": "Nunca mostrar novamente", + "purchase_button_reminder": "Relembrar-me daqui a 30 dias", + "purchase_individual_title": "Individual", + "purchase_lifetime_description": "Compra vitalícia", + "purchase_option_title": "OPÇÕES DE COMPRA", + "purchase_panel_info_1": "O desenvolvimento do Immich requer muito tempo e esforço, e temos engenheiros a tempo inteiro a trabalhar nele para melhorá-lo quanto possível. A nossa missão é para que o software de código aberto e práticas de negócio éticas se tornem numa fonte de rendimento sustentável para os desenvolvedores e criar um ecossistema que respeite a privacidade dos utilizadores e que ofereça alternativas reais a serviços cloud explorativos.", + "purchase_panel_title": "Apoie o projeto", + "purchase_per_server": "Por servidor", + "purchase_per_user": "Por utilizador", + "purchase_remove_product_key": "Remover chave de produto", + "purchase_server_description_1": "Para o servidor inteiro", + "purchase_server_title": "Servidor", + "purchase_settings_server_activated": "A chave de produto para servidor é gerida pelo administrador", "range": "", "raw": "", "reaction_options": "Opções de reação", @@ -781,32 +919,43 @@ "recent": "Recente", "recent_searches": "Pesquisas recentes", "refresh": "Atualizar", + "refresh_metadata": "Atualizar metadados", + "refresh_thumbnails": "Atualizar miniaturas", "refreshed": "Atualizado", "refreshes_every_file": "Atualiza todos arquivos", + "refreshing_metadata": "A atualizar metadados", + "regenerating_thumbnails": "A atualizar miniaturas", "remove": "Remover", + "remove_assets_title": "Remover arquivos?", + "remove_custom_date_range": "Remover intervalo de datas personalizado", "remove_from_album": "Remover do álbum", "remove_from_favorites": "Remover dos favoritos", "remove_from_shared_link": "Remover do link compartilhado", "remove_offline_files": "Remover arquivos offline", + "remove_user": "Remover utilizador", "removed_api_key": "Removido a Chave de API: {name}", + "removed_from_favorites": "Removido dos favoritos", "rename": "Renomear", "repair": "Reparar", "repair_no_results_message": "Arquivos perdidos ou não rastreados aparecem aqui", "replace_with_upload": "Substituir", + "repository": "Repositório", "require_password": "Proteger com senha", - "require_user_to_change_password_on_first_login": "Obrigar usuário a alterar a senha após primeiro login", + "require_user_to_change_password_on_first_login": "Obrigar utilizador a alterar a senha após primeiro início de sessão", "reset": "Resetar", "reset_password": "Resetar senha", "reset_people_visibility": "Resetar pessoas ocultas", "reset_settings_to_default": "", + "reset_to_default": "Repor predefinições", "resolved_all_duplicates": "Todas duplicidades resolvidas", "restore": "Restaurar", "restore_all": "Restaurar tudo", - "restore_user": "Restaurar usuário", + "restore_user": "Restaurar utilizador", "resume": "Continuar", "retry_upload": "Tentar carregar novamente", "review_duplicates": "Revisar duplicidade", "role": "Função", + "role_editor": "Editor", "save": "Guardar", "saved_api_key": "Chave de API salva", "saved_profile": "Perfil Salvo", @@ -820,6 +969,8 @@ "search": "Pesquisar", "search_albums": "Pesquisar álbuns", "search_by_context": "Pesquisar por contexto", + "search_by_filename": "Pesquisar por nome de ficheiro ou extensão", + "search_by_filename_example": "por exemplo, IMG_1234.JPG ou PNG", "search_camera_make": "Pesquisar câmeras da marca...", "search_camera_model": "Pesquisar câmera do modelo...", "search_city": "Pesquisar cidade...", @@ -833,6 +984,7 @@ "search_your_photos": "Pesquisar fotos", "searching_locales": "Pesquisar Lugares....", "second": "Segundo", + "see_all_people": "Ver todas as pessoas", "select_album_cover": "Escolher capa do álbum", "select_all": "Selecionar todos", "select_avatar_color": "Selecionar cor do avatar", @@ -847,7 +999,10 @@ "send_message": "Enviar mensagem", "send_welcome_email": "Enviar E-mail de boas vindas", "server": "Servidor", + "server_offline": "Servidor Offline", + "server_online": "Servidor Online", "server_stats": "Status do servidor", + "server_version": "Versão do servidor", "set": "Definir", "set_as_album_cover": "Definir como capa do álbum", "set_as_profile_picture": "Definir como foto de perfil", @@ -859,6 +1014,7 @@ "share": "Compartilhar", "shared": "Compartilhado", "shared_by": "Compartilhado por", + "shared_by_user": "Partilhado por {user}", "shared_by_you": "Compartilhado por você", "shared_from_partner": "Fotos de {partner}", "shared_links": "Links compartilhados", @@ -867,12 +1023,13 @@ "sharing": "Compartilhar", "sharing_sidebar_description": "Exibe o link Compartilhar na barra lateral", "show_album_options": "Exibir opções do álbum", + "show_all_people": "Mostrar todas as pessoas", "show_and_hide_people": "Mostrar & ocultar pessoas", "show_file_location": "Exibir local do arquivo", "show_gallery": "Exibir galeria", "show_hidden_people": "Exibir pessoas ocultadas", "show_in_timeline": "Exibir na linha do tempo", - "show_in_timeline_setting_description": "Exibe fotos e vídeos deste usuário na sua linha do tempo", + "show_in_timeline_setting_description": "Exibe fotos e vídeos deste utilizador na sua linha do tempo", "show_keyboard_shortcuts": "Exibir atalhos do teclado", "show_metadata": "Mostrar metadados", "show_or_hide_info": "Exibir ou ocultar informações", @@ -888,6 +1045,12 @@ "slideshow": "Apresentação", "slideshow_settings": "Opções de apresentação", "sort_albums_by": "Ordenar álbuns por...", + "sort_created": "Data de criação", + "sort_modified": "Data de modificação", + "sort_oldest": "Foto mais antiga", + "sort_recent": "Foto mais recente", + "sort_title": "Título", + "source": "Fonte", "stack": "Empilhar", "stack_selected_photos": "Empilhar fotos selecionadas", "stacktrace": "Stacktrace", @@ -898,8 +1061,8 @@ "stop_motion_photo": "Parar foto em movimento", "stop_photo_sharing": "Parar de partilhar as suas fotos?", "stop_photo_sharing_description": "{partner} não terá mais acesso às suas fotos.", - "stop_sharing_photos_with_user": "Parar de compartilhar as fotos com este usuário", - "storage": "Armazenamento", + "stop_sharing_photos_with_user": "Parar de compartilhar as fotos com este utilizador", + "storage": "Espaço de armazenamento", "storage_label": "Rótulo de armazenamento", "storage_usage": "utilizado {used} de {available}", "submit": "Enviar", @@ -915,6 +1078,7 @@ "timezone": "Fuso horário", "to_archive": "Arquivar", "to_favorite": "Favorito", + "to_login": "Iniciar sessão", "to_trash": "Lixo", "toggle_settings": "Alternar configurações", "toggle_theme": "Alternar tema", @@ -922,7 +1086,7 @@ "total_usage": "Total utilizado", "trash": "Lixeira", "trash_all": "Todos para o lixo", - "trash_count": "Lixo {count}", + "trash_count": "Lixeira {count, number}", "trash_no_results_message": "Fotos e vídeos enviados para o lixo aparecem aqui.", "trashed_items_will_be_permanently_deleted_after": "Os itens da lixeira são deletados permanentemente após {days, plural, one {# dia} other {# dias}}.", "type": "Tipo", @@ -938,6 +1102,7 @@ "unlinked_oauth_account": "Conta OAuth desvinculada", "unnamed_album": "Álbum sem nome", "unnamed_share": "Compartilhamento sem nome", + "unsaved_change": "Alteração não guardada", "unselect_all": "Limpar seleção", "unstack": "Desempilhar", "untracked_files": "Arquivos não monitorados", @@ -946,13 +1111,19 @@ "updated_password": "Senha atualizada", "upload": "Carregar", "upload_concurrency": "Carregar simultâneo", + "upload_progress": "Restante(s) {remaining, number} - Processado(s) {processed, number}/{total, number}", + "upload_status_duplicates": "Duplicados", + "upload_status_errors": "Erros", "url": "URL", "usage": "Uso", - "user": "Usuário", - "user_id": "ID do usuário", - "user_usage_detail": "Detalhes de uso do usuário", - "username": "Nome do usuário", - "users": "Usuários", + "use_custom_date_range": "Usar um intervalo de datas personalizado", + "user": "Utilizador", + "user_id": "ID do utilizador", + "user_purchase_settings": "Compra", + "user_role_set": "Definir {user} como {role}", + "user_usage_detail": "Detalhes de uso do utilizador", + "username": "Nome do utilizador", + "users": "Utilizadores", "utilities": "Utilitários", "validate": "Validar", "variables": "Variáveis", @@ -965,7 +1136,7 @@ "view": "Ver", "view_album": "Ver Álbum", "view_all": "Ver tudo", - "view_all_users": "Ver todos usuários", + "view_all_users": "Ver todos os utilizadores", "view_links": "Ver links", "view_next_asset": "Ver próximo ativo", "view_previous_asset": "Ver ativo anterior", @@ -976,6 +1147,7 @@ "welcome": "Bem-vindo", "welcome_to_immich": "Bem-vindo ao Immich", "year": "Ano", + "years_ago": "Há {years, plural, one {# ano} other {# anos}}", "yes": "Sim", "you_dont_have_any_shared_links": "Não há links compartilhados", "zoom_image": "Ampliar imagem" diff --git a/web/src/lib/i18n/pt_BR.json b/web/src/lib/i18n/pt_BR.json index 725b9daab5..ba0698d7c5 100644 --- a/web/src/lib/i18n/pt_BR.json +++ b/web/src/lib/i18n/pt_BR.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Adicionar ao álbum compartilhado", "added_to_archive": "Adicionado ao arquivo", "added_to_favorites": "Adicionado aos favoritos", - "added_to_favorites_count": "{count, plural, one {{count, number} adicionado(a) aos favoritos} other {{count, number} adicionados(as) aos favoritos}}", + "added_to_favorites_count": "{count, plural, one {{count, number} adicionado aos favoritos} other {{count, number} adicionados aos favoritos}}", "admin": { "add_exclusion_pattern_description": "Adicione padrões de exclusão. Utilizar *, ** ou ? são suportados. Para ignorar todos os arquivos em qualquer diretório chamado \"Raw\", use \"**/Raw/**'. Para ignorar todos os arquivos que terminam em \".tif\", use \"**/*.tif\". Para ignorar um caminho absoluto, use \"/caminho/para/ignorar/**\".", "authentication_settings": "Configurações de Autenticação", @@ -918,6 +918,7 @@ "online": "Online", "only_favorites": "Somente favoritos", "only_refreshes_modified_files": "Somente atualize arquivos modificados", + "open_in_map_view": "Mostrar no mapa", "open_in_openstreetmap": "Abrir no OpenStreetMap", "open_the_search_filters": "Abre os filtros de pesquisa", "options": "Opções", @@ -1250,7 +1251,7 @@ "upload": "Carregar", "upload_concurrency": "Carregar simultâneo", "upload_errors": "Envio concluído com {count, plural, one {# erro} other {# erros}}, atualize a página para ver os novos arquivos carregados.", - "upload_progress": "Restando {remaining, number} - Processando(a)(s) {processed, number}/{total, number}", + "upload_progress": "{remaining, number} processando - {processed, number}/{total, number} já processados.", "upload_skipped_duplicates": "{count, plural, one {# arquivo duplicado foi ignorado} other {# arquivos duplicados foram ignorados}}", "upload_status_duplicates": "Duplicados", "upload_status_errors": "Erros", diff --git a/web/src/lib/i18n/ro.json b/web/src/lib/i18n/ro.json index 242767b9dc..eaa2f02595 100644 --- a/web/src/lib/i18n/ro.json +++ b/web/src/lib/i18n/ro.json @@ -125,15 +125,15 @@ "machine_learning_url_description": "Adresa URL a serverului de învățare automată", "manage_concurrency": "Gestionarea simultaneității", "manage_log_settings": "Administrați setările jurnalului", - "map_dark_style": "", + "map_dark_style": "Mod întunecat", "map_enable_description": "Activare hartă", "map_gps_settings": "Setări Hartă & GPS", "map_gps_settings_description": "Gestionare setări Hartă & GPS (localizare inversă)", - "map_light_style": "", + "map_light_style": "Mod deschis", "map_manage_reverse_geocoding_settings": "Gestionare setări Localizare Inversă", "map_reverse_geocoding": "Localizare Inversă", - "map_reverse_geocoding_enable_description": "", - "map_reverse_geocoding_settings": "", + "map_reverse_geocoding_enable_description": "Activați geocodarea inversă", + "map_reverse_geocoding_settings": "Setări geocodare inversă", "map_settings": "Setări Hartă", "map_settings_description": "Gestionare setări hartă", "map_style_description": "URL-ul style.json către o temă pentru hartă", @@ -164,36 +164,53 @@ "oauth_auto_launch": "Pornire automată", "oauth_auto_launch_description": "Lansează automat autorizarea OAuth la accesarea paginii de login", "oauth_auto_register": "Auto înregistrare", - "oauth_auto_register_description": "", - "oauth_button_text": "", - "oauth_client_id": "", - "oauth_client_secret": "", - "oauth_enable_description": "", - "oauth_issuer_url": "", - "oauth_mobile_redirect_uri": "", - "oauth_mobile_redirect_uri_override": "", - "oauth_mobile_redirect_uri_override_description": "", - "oauth_scope": "", - "oauth_settings": "", - "oauth_settings_description": "", - "oauth_signing_algorithm": "", + "oauth_auto_register_description": "Înregistrează automat utilizatori noi după autentificarea cu OAuth", + "oauth_button_text": "Text buton", + "oauth_client_id": "ID Client", + "oauth_client_secret": "Secret Client", + "oauth_enable_description": "Autentifică-te cu OAuth", + "oauth_issuer_url": "Emitentul URL", + "oauth_mobile_redirect_uri": "URI de redirecționare mobilă", + "oauth_mobile_redirect_uri_override": "Înlocuire URI de redirecționare mobilă", + "oauth_mobile_redirect_uri_override_description": "Activați când „app.immich:/” este un URI de redirecționare nevalid.", + "oauth_profile_signing_algorithm": "Algoritm de semnare a profilului", + "oauth_profile_signing_algorithm_description": "Algoritm folosit pentru a semna profilul utilizatorului.", + "oauth_scope": "Domeniul de aplicare", + "oauth_settings": "OAuth", + "oauth_settings_description": "Gestionați setările de conectare OAuth", + "oauth_settings_more_details": "Pentru mai multe detalii despre aceastǎ funcționalitate, verificǎ documentația.", + "oauth_signing_algorithm": "Algoritm de semnare", "oauth_storage_label_claim": "", - "oauth_storage_label_claim_description": "", + "oauth_storage_label_claim_description": "Setați automat eticheta de stocare a utilizatorului la valoarea acestei revendicări.", "oauth_storage_quota_claim": "", "oauth_storage_quota_claim_description": "", "oauth_storage_quota_default": "", "oauth_storage_quota_default_description": "", - "password_enable_description": "", - "password_settings": "", - "password_settings_description": "", - "server_external_domain_settings": "", - "server_external_domain_settings_description": "", - "server_settings": "", - "server_settings_description": "", - "server_welcome_message": "", - "server_welcome_message_description": "", + "offline_paths": "Cǎi invalide", + "offline_paths_description": "Acestea pot fi rezultate în urma ștergerii manuale a fișierelor ce nu fac parte dintr-o bibliotecǎ externǎ.", + "password_enable_description": "Autentificare cu email și parolǎ", + "password_settings": "Autentificare cu parolǎ", + "password_settings_description": "Gestioneazǎ setǎrile de autentificare cu parola", + "paths_validated_successfully": "Toate cǎile au fost validate cu succes", + "quota_size_gib": "Spațiu de stocare alocat (GiB)", + "refreshing_all_libraries": "Bibliotecile sunt în curs de reîmprospǎtare", + "registration_description": "Deoarece sunteți primul utilizator de pe sistem, veți fi desemnat ca administrator și sunteți responsabil pentru sarcinile administrative, iar utilizatorii suplimentari vor fi creați de dumneavoastra.", + "removing_offline_files": "Eliminarea fișierelor offline", + "repair_all": "Reparǎ toate", + "require_password_change_on_login": "Obligǎ utilizatorul sǎ își schimbe parola la prima autentificare", + "reset_settings_to_default": "Reseteazǎ setǎrile la valorile implicite", + "reset_settings_to_recent_saved": "Reseteazǎ setǎrile la valorile salvate recent", + "scanning_library_for_changed_files": "Se scaneazǎ biblioteca pentru fișiere modificate", + "scanning_library_for_new_files": "Se scaneazǎ biblioteca pentru fișiere noi", + "send_welcome_email": "Trimite email de bun-venit", + "server_external_domain_settings": "Domeniu extern", + "server_external_domain_settings_description": "Domeniu pentru distribuire publicǎ a scurtǎturilor, incluzând http(s)://", + "server_settings": "Setǎri server", + "server_settings_description": "Gestioneazǎ setǎrile serverului", + "server_welcome_message": "Mesaj de bun-venit", + "server_welcome_message_description": "Un mesaj ce este afișat pe pagina de autentificare.", "sidecar_job_description": "", - "slideshow_duration_description": "", + "slideshow_duration_description": "Numǎrul de secunde pentru afișarea fiecǎrei imagini", "smart_search_job_description": "", "storage_template_enable_description": "", "storage_template_hash_verification_enabled": "", @@ -201,7 +218,8 @@ "storage_template_migration_job": "", "storage_template_settings": "", "storage_template_settings_description": "", - "theme_custom_css_settings": "", + "system_settings": "Setǎri de sistem", + "theme_custom_css_settings": "CSS personalizat", "theme_custom_css_settings_description": "", "theme_settings": "", "theme_settings_description": "", @@ -209,16 +227,16 @@ "transcode_policy_description": "", "transcoding_acceleration_api": "", "transcoding_acceleration_api_description": "", - "transcoding_acceleration_nvenc": "", - "transcoding_acceleration_qsv": "", - "transcoding_acceleration_rkmpp": "", - "transcoding_acceleration_vaapi": "", - "transcoding_accepted_audio_codecs": "", + "transcoding_acceleration_nvenc": "NVENC (necesitǎ GPU NVIDIA)", + "transcoding_acceleration_qsv": "Quick Sync (necesitǎ CPU Intel de generația a 7-a sau mai mare)", + "transcoding_acceleration_rkmpp": "RKMPP (doar pe SOC-uri Rockchip)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "Codec-uri audio acceptate", "transcoding_accepted_audio_codecs_description": "", - "transcoding_accepted_video_codecs": "", + "transcoding_accepted_video_codecs": "Codec-uri video acceptate", "transcoding_accepted_video_codecs_description": "", "transcoding_advanced_options_description": "", - "transcoding_audio_codec": "", + "transcoding_audio_codec": "Codec audio", "transcoding_audio_codec_description": "", "transcoding_bitrate_description": "", "transcoding_constant_quality_mode": "", @@ -260,25 +278,25 @@ "transcoding_transcode_policy": "", "transcoding_two_pass_encoding": "", "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", - "transcoding_video_codec_description": "", + "transcoding_video_codec": "Codec video", + "transcoding_video_codec_description": "VP9 are eficiențǎ mare și compatibilitate web, însǎ transcodarea este de duratǎ mai mare. HEVC se comportǎ asemǎnǎtor, însǎ are compatibilitate web mai micǎ. H.264 este foarte compatibil și rapid în transcodare, însǎ genereazǎ fișiere mult mai mari. AV1 este cel mai eficient codec dar nu este compatibil cu dispozitivele mai vechi.", "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", + "trash_number_of_days": "Numǎr de zile", + "trash_number_of_days_description": "Numǎr de zile pentru pǎstrarea fișierelor în coșul de gunoi pânǎ la ștergerea permanentǎ", + "trash_settings": "Setǎri coș de gunoi", + "trash_settings_description": "Gestioneazǎ setǎrile coșului de gunoi", "user_delete_delay_settings": "", "user_delete_delay_settings_description": "", - "user_settings": "", - "user_settings_description": "", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", - "video_conversion_job_description": "" + "user_settings": "Setǎri utilizator", + "user_settings_description": "Gestioneazǎ setǎrile utilizatorului", + "version_check_enabled_description": "Activeazǎ verificarea periodicǎ pe GitHub pentru versiuni noi", + "version_check_settings": "Verificare versiune", + "version_check_settings_description": "Activeazǎ/dezactiveazǎ notificarea unei noi versiuni", + "video_conversion_job_description": "Transcodeazǎ videoclipurile pentru compatibilitate cu browsere și dispozitive" }, "admin_email": "E-mailul administratorului", "admin_password": "Parola administratorului", - "administration": "Administraţie", + "administration": "Administrare", "advanced": "Avansat", "album_added": "Album adăugat", "album_added_notification_setting_description": "Primiți o notificare prin e-mail când sunteți adăugat la un album partajat", @@ -289,6 +307,7 @@ "album_updated": "Album actualizat", "album_updated_setting_description": "Primiți o notificare prin e-mail când un album partajat are elemente noi", "albums": "Albume", + "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albume}}", "all": "Toate", "all_albums": "Toate albumele", "all_people": "Toți oamenii", @@ -300,13 +319,13 @@ "api_key": "Cheie API", "api_key_description": "Această valoare va fi afișată o singură dată. Vă rugăm să vă asigurați că o copiați înainte de a închide fereastra.", "api_key_empty": "Numele cheii API nu trebuie să fie gol", - "api_keys": "API Cheie", + "api_keys": "Chei API", "app_settings": "Setări în aplicație", "appears_in": "Apare în", "archive": "Arhivă", - "archive_or_unarchive_photo": "Să arhivezi sau să nu arhivezi imagine", + "archive_or_unarchive_photo": "Arhiveazǎ sau dezarhiveazǎ fotografia", "archived": "", - "archived_count": "{count, plural, other {Archived #}}", + "archived_count": "{count, plural, one {S-a arhivat #}, other {S-au arhivat #}}", "are_you_sure_to_do_this": "Sunteți sigur că doriți să faceți acest lucru?", "asset_added_to_album": "Adăugat la album", "asset_adding_to_album": "Se adauga la album...", @@ -319,7 +338,7 @@ "asset_skipped": "Sărit", "asset_uploaded": "Încărcat", "asset_uploading": "Se incărca...", - "assets": "resurse", + "assets": "Resurse", "authorized_devices": "Dispozitive autorizate", "back": "Înapoi", "back_close_deselect": "Înapoi, închidere sau deselectare", @@ -331,9 +350,9 @@ "build_image": "Construiți o imagine", "bulk_delete_duplicates_confirmation": "Sunteți sigur că doriți să ștergeți în masă {count, plural, one {# duplicate asset} other {# duplicate assets}}? Acest lucru va păstra cel mai mare activ din fiecare grup și va șterge definitiv toate celelalte duplicate. Nu puteți anula această acțiune!", "buy": "Cumpără Immich", - "camera": "Camera", - "camera_brand": "Brand de cameră", - "camera_model": "Model de cameră", + "camera": "Camerǎ", + "camera_brand": "Marcǎ cameră", + "camera_model": "Model cameră", "cancel": "Anulează", "cancel_search": "Anulează căutarea", "cannot_merge_people": "Nu se pot îmbina oamenii", @@ -345,12 +364,12 @@ "cant_search_places": "", "change_date": "Schimbă dată", "change_expiration_time": "Shimbă data expirării", - "change_location": "Schimbă locație", - "change_name": "Schimbă nume", - "change_name_successfully": "Schimbă nume cu succes", + "change_location": "Schimbă locația", + "change_name": "Schimbă numele", + "change_name_successfully": "Schimbă numele cu succes", "change_password": "Schimbă parola", "change_password_description": "Aceasta este fie prima dată când vă conectați la sistem, fie vi s-a solicitat să vă schimbați parola. Vă rugăm să introduceți noua parolă mai jos.", - "change_your_password": "Schimbă-ți parolele", + "change_your_password": "Schimbă-ți parola", "changed_visibility_successfully": "Schimbă visibilitate cu succes", "check_logs": "Verificarea logurilor", "choose_matching_people_to_merge": "Alegeți persoanele potrivite pentru fuzionare", @@ -487,8 +506,8 @@ "cant_get_faces": "Nu pot obține fețe", "cant_get_number_of_comments": "Nu pot obține numărul de comentarii", "cant_search_people": "Nu pot căuta oameni", - "cant_search_places": "Nu pot căuta locuri", - "cleared_jobs": "Locuri de muncă compensate pentru: {job}", + "cant_search_places": "Nu se pot căuta locații", + "cleared_jobs": "Joburi terminate pentru: {job}", "error_adding_assets_to_album": "Eroare la adăugarea activelor la album", "error_adding_users_to_album": "Eroare la adăugarea utilizatorilor la album", "error_deleting_shared_user": "Eroare la ștergerea utilizatorului partajat", @@ -560,8 +579,9 @@ "expand_all": "", "expire_after": "Expiră după", "expired": "Expirat", - "explore": "", - "extension": "", + "explore": "Exploreazǎ", + "extension": "Extensie", + "external": "Extern", "external_libraries": "", "failed_to_get_people": "", "favorite": "", @@ -646,28 +666,29 @@ "map": "", "map_marker_with_image": "", "map_settings": "Setările hărții", - "media_type": "", - "memories": "", - "memories_setting_description": "", - "menu": "", + "media_type": "Tip fișier", + "memories": "Amintiri", + "memories_setting_description": "Administreazǎ ce vezi în amintiri", + "memory": "Amintire", + "menu": "Meniu", "merge": "", "merge_people": "", "merge_people_successfully": "", "minimize": "", "minute": "", - "missing": "", - "model": "", + "missing": "Absente", + "model": "Model", "month": "Lună", - "more": "", + "more": "Mai multe", "moved_to_trash": "", - "my_albums": "", + "my_albums": "Albumele mele", "name": "Nume", - "name_or_nickname": "", - "never": "niciodată", - "new_api_key": "", + "name_or_nickname": "Nume sau poreclǎ", + "never": "Niciodată", + "new_api_key": "Cheie API nouǎ", "new_password": "Parolă nouă", - "new_person": "", - "new_user_created": "", + "new_person": "Persoanǎ nouǎ", + "new_user_created": "Utilizator nou creat", "newest_first": "", "next": "Următorul", "next_memory": "", @@ -703,6 +724,7 @@ "other_variables": "", "owned": "Deținut", "owner": "Admin", + "partner": "Partener", "partner_sharing": "", "partners": "", "password": "Parolă", @@ -727,11 +749,12 @@ "permanent_deletion_warning_setting_description": "", "permanently_delete": "", "permanently_deleted_asset": "", + "person": "Persoanǎ", "photos": "Fotografii", "photos_from_previous_years": "", "pick_a_location": "", "place": "", - "places": "Locuri", + "places": "Locații", "play": "", "play_memories": "", "play_motion_photo": "", @@ -793,19 +816,23 @@ "search_places": "", "search_state": "", "search_timezone": "", - "search_type": "", + "search_type": "Tip cǎutare", "search_your_photos": "Căutare fotografii", "searching_locales": "", - "second": "", + "second": "Secundǎ", "select_album_cover": "", "select_all": "", + "select_all_duplicates": "Selecteazǎ toate duplicatele", "select_avatar_color": "", - "select_face": "", + "select_face": "Selecteazǎ fațǎ", "select_featured_photo": "", - "select_library_owner": "", - "select_new_face": "", + "select_from_computer": "Selecteazǎ din calculator", + "select_keep_all": "Selecteazǎ tot pentru salvare", + "select_library_owner": "Selecteazǎ proprietarul bibliotecii", + "select_new_face": "Selecteazǎ o nouǎ fațǎ", "select_photos": "Selectează fotografii", - "selected": "", + "select_trash_all": "Selecteazǎ tot pentru ștergere", + "selected": "Selectați", "send_message": "", "server": "", "server_stats": "", @@ -852,25 +879,28 @@ "status": "", "stop_motion_photo": "", "stop_photo_sharing": "Încetezi distribuirea fotografiilor?", - "storage": "", + "storage": "Spațiu de stocare", "storage_label": "", + "storage_usage": "{used} din {available} utilizați", "submit": "", "suggestions": "Sugestii", - "sunrise_on_the_beach": "", + "sunrise_on_the_beach": "Rǎsǎrit pe plajǎ", "swap_merge_direction": "", - "sync": "", + "sync": "Sincronizare", "template": "", "theme": "Temă", "theme_selection": "", "theme_selection_description": "", "time_based_memories": "", "timezone": "Fus orar", + "to_favorite": "Favorit", "toggle_settings": "", "toggle_theme": "", "toggle_visibility": "", "total_usage": "", "trash": "Coș", - "trash_all": "", + "trash_all": "Șterge tot", + "trash_count": "Șterge {count, number}", "trash_no_results_message": "", "type": "", "unarchive": "Șterge din arhivă", @@ -894,24 +924,29 @@ "user_id": "", "user_usage_detail": "", "username": "", - "users": "", - "utilities": "", - "validate": "", - "variables": "", - "version": "", - "video": "", + "users": "Utilizatori", + "utilities": "Utilitǎți", + "validate": "Valideazǎ", + "variables": "Variabile", + "version": "Versiune", + "version_announcement_closing": "Prietenul tǎu, Alex", + "video": "Videoclip", "video_hover_setting_description": "", "videos": "Videoclipuri", + "view_album": "Vezi album", "view_all": "Vezi toate", - "view_all_users": "", - "view_links": "", + "view_all_users": "Vezi toți utilizatorii", + "view_links": "Vezi scurtǎturi", "view_next_asset": "", "view_previous_asset": "", "viewer": "", - "waiting": "", - "week": "", - "welcome_to_immich": "", + "waiting": "În așteptare", + "warning": "Avertisment", + "week": "Sǎptǎmânǎ", + "welcome": "Salutare", + "welcome_to_immich": "Bun venit în Immich", "year": "An", + "years_ago": "acum {years, plural, one {# an} other {# ani}}", "yes": "Da", "you_dont_have_any_shared_links": "Nu aveți niciun link partajat", "zoom_image": "Mărește imaginea" diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 81fabfe444..6a31d297af 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -92,10 +92,10 @@ "library_watching_settings": "Слежение за библиотекой (ЭКСПЕРИМЕНТАЛЬНОЕ)", "library_watching_settings_description": "Автоматически следить за изменениями файлов", "logging_enable_description": "Включить ведение журнала", - "logging_level_description": "Если включено, какой уровень логирования использовать.", + "logging_level_description": "Если включено, выберите желаемый уровень журналирования.", "logging_settings": "Ведение журнала", "machine_learning_clip_model": "CLIP модель", - "machine_learning_clip_model_description": "Название модели CLIP указано здесь. Обратите внимание, что при изменении модели необходимо заново запустить задачу «Умный поиск» для всех изображений.", + "machine_learning_clip_model_description": "Названия моделей CLIP размещены здесь. Обратите внимание, что при изменении модели необходимо заново запустить задачу «Интеллектуальный поиск» для всех изображений.", "machine_learning_duplicate_detection": "Поиск дубликатов", "machine_learning_duplicate_detection_enabled": "Включить обнаружение дубликатов", "machine_learning_duplicate_detection_enabled_description": "Если этот параметр отключен, абсолютно идентичные ресурсы всё равно будут удалены из дубликатов.", @@ -110,7 +110,7 @@ "machine_learning_facial_recognition_setting_description": "Если отключить эту функцию, изображения не будут кодироваться для распознавания лиц и не будут заполнять раздел Люди на обзорной странице.", "machine_learning_max_detection_distance": "Максимальное различие изображений", "machine_learning_max_detection_distance_description": "Максимальное различие между двумя изображениями, чтобы считать их дубликатами, в диапазоне 0,001-0,1. Более высокие значения позволяют обнаружить больше дубликатов, но могут привести к ложным срабатываниям.", - "machine_learning_max_recognition_distance": "Порог узнавания", + "machine_learning_max_recognition_distance": "Порог распознавания", "machine_learning_max_recognition_distance_description": "Максимальное различие между двумя лицами, которые можно считать одним и тем же человеком, в диапазоне 0-2. Понижение этого параметра может предотвратить распознавание двух людей как одного и того же человека, а повышение - как двух разных людей. Обратите внимание, что проще объединить двух людей, чем разделить одного человека на два, поэтому по возможности выбирайте меньший порог.", "machine_learning_min_detection_score": "Минимальный порог распознавания", "machine_learning_min_detection_score_description": "Минимальный порог для обнаружения лица от 0 до 1. Более низкие значения позволяют обнаружить больше лиц, но могут привести к ложным срабатываниям.", @@ -118,7 +118,7 @@ "machine_learning_min_recognized_faces_description": "Минимальное количество распознанных лиц для создания человека. Увеличение этого параметра делает распознавание лиц более точным, но при этом увеличивается вероятность того, что лицо не будет присвоено человеку.", "machine_learning_settings": "Настройки машинного обучения", "machine_learning_settings_description": "Управление функциями и настройками машинного обучения", - "machine_learning_smart_search": "Умный Поиск", + "machine_learning_smart_search": "Интеллектуальный поиск", "machine_learning_smart_search_description": "Семантический поиск изображений с использованием вложений CLIP", "machine_learning_smart_search_enabled": "Включить интеллектуальный поиск", "machine_learning_smart_search_enabled_description": "Если этот параметр отключен, изображения не будут кодироваться для интеллектуального поиска.", @@ -130,10 +130,10 @@ "map_gps_settings": "Настройки карты и GPS", "map_gps_settings_description": "Управление настройками карты и GPS (обратный геокодинг)", "map_light_style": "Светлый стиль", - "map_manage_reverse_geocoding_settings": "Настройки Обратного геокодинга", + "map_manage_reverse_geocoding_settings": "Управление настройками Обратного геокодирования", "map_reverse_geocoding": "Обратное Геокодирование", "map_reverse_geocoding_enable_description": "Включить обратное геокодирование", - "map_reverse_geocoding_settings": "Настройки Обратного Геокодирования", + "map_reverse_geocoding_settings": "Настройки обратного геокодирования", "map_settings": "Настройки карты", "map_settings_description": "Управление настройками карты", "map_style_description": "URL-адрес темы карты style.json", @@ -220,13 +220,13 @@ "storage_template_date_time_description": "Время создание объекта использовано как информация о времени съемки", "storage_template_date_time_sample": "Время выборки {date}", "storage_template_enable_description": "Включить механизм шаблонов хранилища", - "storage_template_hash_verification_enabled": "Включено проверку хеша", + "storage_template_hash_verification_enabled": "Включить проверку хеша", "storage_template_hash_verification_enabled_description": "Включает проверку хэша, не отключайте ее, если вы не уверены в последствиях", "storage_template_migration": "Применение шаблона хранилища", "storage_template_migration_description": "Применяет текущий {template} к ранее загруженным ресурсам", - "storage_template_migration_info": "Изменения шаблона будут применяться только к новым ресурсам. Чтобы применить шаблон к ранее загруженным ресурсам, запустите {job}.", + "storage_template_migration_info": "Изменения в шаблоне будут применяться только к новым ресурсам. Чтобы применить шаблон к ранее загруженным ресурсам, запустите {job}.", "storage_template_migration_job": "Задание миграции шаблона хранилища", - "storage_template_more_details": "Для получения дополнительной информации об этой функции обратитесь к Шаблону Хранилища и его последствиям", + "storage_template_more_details": "Для получения дополнительной информации об этой функции обратитесь к Шаблону хранилища и месту его хранения", "storage_template_onboarding_description": "При включении этой функции файлы будут автоматически организованы в соответствии с пользовательским шаблоном. Из-за проблем со стабильностью функция по умолчанию отключена. Дополнительную информацию можно найти в документации.", "storage_template_path_length": "Примерная длина пути: {length, number}/{limit, number}", "storage_template_settings": "Шаблон хранилища", @@ -283,7 +283,7 @@ "transcoding_reference_frames_description": "Количество кадров, на которые следует ссылаться при сжатии данного кадра. Более высокие значения повышают эффективность сжатия, но замедляют кодирование. 0 устанавливает это значение автоматически.", "transcoding_required_description": "Только видео в нестандартном формате", "transcoding_settings": "Настройки транскодирования видео", - "transcoding_settings_description": "Управляйте разрешением и кодированием видеофайлов", + "transcoding_settings_description": "Управление разрешением и кодированием видеофайлов", "transcoding_target_resolution": "Целевое разрешение", "transcoding_target_resolution_description": "Более высокие разрешения позволяют сохранить больше деталей, но требуют больше времени для кодирования, имеют больший размер файлов и могут снизить скорость отклика приложения.", "transcoding_temporal_aq": "Временной AQ", @@ -298,7 +298,7 @@ "transcoding_transcode_policy_description": "Правила, определяющие когда видео должно быть перекодировано. HDR-видео всегда будут перекодироваться (за исключением случаев, когда перекодирование отключено).", "transcoding_two_pass_encoding": "Двухпроходное кодирование", "transcoding_two_pass_encoding_setting_description": "Перекодируйте за два прохода, чтобы получить более качественное кодирование видео. Когда включен максимальный битрейт (необходим для работы с H.264 и HEVC), в этом режиме используется диапазон битрейта, основанный на максимальном битрейте, и игнорируется CRF. Для VP9 можно использовать CRF, если отключен максимальный битрейт.", - "transcoding_video_codec": "Видео Кодек", + "transcoding_video_codec": "Видеокодек", "transcoding_video_codec_description": "VP9 обладает высокой эффективностью и веб-совместимостью, но перекодирование занимает больше времени. HEVC работает аналогично, но имеет меньшую веб-совместимость. H.264 широко совместим и быстро перекодируется, но создает файлы гораздо большего размера. AV1 — наиболее эффективный кодек, но он не поддерживается на старых устройствах.", "trash_enabled_description": "Включить корзину", "trash_number_of_days": "Срок хранения", @@ -852,7 +852,7 @@ "matches": "Совпадения", "media_type": "Тип медиа", "memories": "Воспоминания", - "memories_setting_description": "Управляйте тем, что вы видите в своих воспоминаниях", + "memories_setting_description": "Управление тем, что вы видите в своих воспоминаниях", "memory": "Память", "memory_lane_title": "Воспоминание {title}", "menu": "Меню", @@ -866,7 +866,7 @@ "minute": "Минута", "missing": "Отсутствующие", "model": "Модель", - "month": "Месяцу", + "month": "Месяц", "more": "Больше", "moved_to_trash": "Перенесено в корзину", "my_albums": "Мои альбомы", @@ -901,7 +901,7 @@ "not_in_any_album": "Ни в одном альбоме", "note_apply_storage_label_to_previously_uploaded assets": "Примечание: Чтобы применить тег хранилища к ранее загруженным ресурсам запустите", "note_unlimited_quota": "Примечание: Введите 0 для неограниченной квоты", - "notes": "Записки", + "notes": "Примечание", "notification_toggle_setting_description": "Включить уведомления по электронной почте", "notifications": "Уведомления", "notifications_setting_description": "Управление уведомлениями", @@ -918,6 +918,7 @@ "online": "Доступен", "only_favorites": "Только избранное", "only_refreshes_modified_files": "Обновляет только измененные файлы", + "open_in_map_view": "Открыть в режиме просмотра карты", "open_in_openstreetmap": "Открыть в OpenStreetMap", "open_the_search_filters": "Открыть фильтры поиска", "options": "Опции", @@ -1021,6 +1022,8 @@ "purchase_server_title": "Сервер", "purchase_settings_server_activated": "Ключ продукта сервера управляется администратором", "range": "", + "rating": "Рейтинг звёзд", + "rating_description": "Показывать рейтинг exif в панели информации", "raw": "", "reaction_options": "Опции реакций", "read_changelog": "Прочитать список изменений", @@ -1151,6 +1154,7 @@ "sharing_sidebar_description": "Отображать пункт меню \"Общие\" в боковой панели", "shift_to_permanent_delete": "нажмите ⇧ чтобы удалить объект навсегда", "show_album_options": "Показать параметры альбома", + "show_albums": "Показать альбомы", "show_all_people": "Показать всех людей", "show_and_hide_people": "Показать и скрыть людей", "show_file_location": "Показать расположение файла", @@ -1183,6 +1187,8 @@ "sort_title": "Заголовок", "source": "Источник", "stack": "Стек", + "stack_duplicates": "Стек дубликатов", + "stack_select_one_photo": "Выберите одну главную фотографию для стека", "stack_selected_photos": "Сложить выбранные фотографии в стопку", "stacked_assets_count": "{count, plural, one {# объект добавлен} few {# объекта добавлено} other {# объектов добавлено}} в стек", "stacktrace": "Трассировка стека", diff --git a/web/src/lib/i18n/sl.json b/web/src/lib/i18n/sl.json index d4e50ac8f4..bf8c55e5c4 100644 --- a/web/src/lib/i18n/sl.json +++ b/web/src/lib/i18n/sl.json @@ -22,6 +22,9 @@ "add_to": "Dodaj k...", "add_to_album": "Dodaj v album", "add_to_shared_album": "Dodaj k deljenemu albumu", + "added_to_archive": "Dodano v arhiv", + "added_to_favorites": "Dodano med priljubljene", + "added_to_favorites_count": "{count, number} dodanih med priljubljene", "admin": { "add_exclusion_pattern_description": "Dodajte vzorec izključitev. Globiranje z uporabo *, ** in ? je podprto. Če želite prezreti vse datoteke v katerem koli imeniku z imenom \"Raw\", uporabite \"**/Raw/**\". Če želite prezreti vse datoteke, ki se končajo na \".tif\", uporabite \"**/*.tif\". Če želite prezreti absolutno pot, uporabite \"/pot/za/ignoriranje/**\".", "authentication_settings": "Nastavitve preverjanja pristnosti", @@ -820,8 +823,9 @@ "viewer": "", "waiting": "", "week": "", + "welcome": "Dobrodošli", "welcome_to_immich": "", - "year": "", + "year": "Leto", "yes": "Da", - "zoom_image": "" + "zoom_image": "Povečava slike" } diff --git a/web/src/lib/i18n/sr_Cyrl.json b/web/src/lib/i18n/sr_Cyrl.json index 761668d386..1c7b66df01 100644 --- a/web/src/lib/i18n/sr_Cyrl.json +++ b/web/src/lib/i18n/sr_Cyrl.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Додај у дељен албум", "added_to_archive": "Додато у архиву", "added_to_favorites": "Додато у фаворите", - "added_to_favorites_count": "Додато {count} у фаворите", + "added_to_favorites_count": "Додато {count, number} у фаворите", "admin": { "add_exclusion_pattern_description": "Додајте обрасце искључења. Кориштење *, ** и ? је подржано. Да бисте игнорисали све датотеке у било ком директоријуму под називом „Рав“, користите „**/Рав/**“. Да бисте игнорисали све датотеке које се завршавају на „.тиф“, користите „**/*.тиф“. Да бисте игнорисали апсолутну путању, користите „/path/to/ignore/**“.", "authentication_settings": "Подешавања за аутентификацију", @@ -278,7 +278,7 @@ "transcoding_preferred_hardware_device": "Жељени хардверски уређај", "transcoding_preferred_hardware_device_description": "Односи се само на ВААПИ и QSV. Поставља дри ноде који се користи за хардверско транскодирање.", "transcoding_preset_preset": "Унапред подешена подешавања (-пресет)", - "transcoding_preset_preset_description": "Брзина компресије. Спорије унапред подешене вредности производе мање датотеке и повећавају квалитет када циљате одређену брзину преноса. ВП9 игнорише брзине изнад `брже`.", + "transcoding_preset_preset_description": "Брзина компресије. Спорије унапред подешене вредности производе мање датотеке и повећавају квалитет када циљате одређену брзину преноса. ВП9 игнорише брзине изнад 'брже'.", "transcoding_reference_frames": "Референтни оквири (фрамес)", "transcoding_reference_frames_description": "Број оквира (фрамес) за референцу приликом компресије датог оквира. Више вредности побољшавају ефикасност компресије, али успоравају кодирање. 0 аутоматски поставља ову вредност.", "transcoding_required_description": "Само видео снимци који нису у прихваћеном формату", @@ -410,7 +410,7 @@ "bulk_delete_duplicates_confirmation": "Да ли сте сигурни да желите групно да избришете {count, plural, one {# дуплиран елеменат} few {# дуплирана елемента} other {# дуплираних елемената}}? Ово ће задржати највеће средство сваке групе и трајно избрисати све друге дупликате. Не можете поништити ову радњу!", "bulk_keep_duplicates_confirmation": "Да ли сте сигурни да желите да задржите {count, plural, one {1 дуплирану датотеку} few {# дуплиране датотеке} other {# дуплираних датотека}}? Ово ће решити све дуплиране групе без брисања било чега.", "bulk_trash_duplicates_confirmation": "Да ли сте сигурни да желите групно да одбаците {count, plural, one {1 дуплирану датотеку} few {# дуплиране датотеке} other {# дуплираних датотека}}? Ово ће задржати највећу датотеку сваке групе и одбацити све остале дупликате.", - "buy": "Купите лиценцу", + "buy": "Купите лиценцу Имич-а", "camera": "Камера", "camera_brand": "Бренд камере", "camera_model": "Модел камере", @@ -744,6 +744,11 @@ "hour": "Сат", "image": "Фотографија", "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} снимљено {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} снимљено {person1} {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} снимили {person1} и {person2} {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} снимили {person1}, {person2}, и {person3} {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} снимили {person1}, {person2}, и {additionalCount, number} осталих {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} снимљено у {city}, {country} {date}", "image_alt_text_people": "{count, plural, =1 {са {person1}} =2 {са {person1} и {person2}} =3 {са {person1}, {person2}, и {person3}} other {са {person1}, {person2}, и {others, number} остали}}", "image_alt_text_place": "у {city}, {country}", "image_taken": "{isVideo, select, true {Видео запис снимљен} other {Фотографија усликана}}", @@ -864,6 +869,7 @@ "name": "Име", "name_or_nickname": "Име или надимак", "never": "Никада", + "new_album": "Нови албум", "new_api_key": "Нови АПИ кључ (key)", "new_password": "Нова шифра", "new_person": "Нова особа", @@ -908,6 +914,7 @@ "online": "Доступан (Онлине)", "only_favorites": "Само фаворити", "only_refreshes_modified_files": "Освежава само измењене датотеке", + "open_in_map_view": "Отвори у приказу мапе", "open_in_openstreetmap": "Отворите у ОпенСтреетМап-у", "open_the_search_filters": "Отворите филтере за претрагу", "options": "Опције", @@ -1000,7 +1007,19 @@ "purchase_panel_info_1": "Изградња Имич-а захтева много времена и труда, а имамо инжењере који раде на томе са пуним радним временом како бисмо је учинили што је могуће бољом. Наша мисија је да софтвер отвореног кода и етичке пословне праксе постану одржив извор прихода за програмере и да створимо екосистем који поштује приватност са стварним алтернативама експлоатативним услугама у облаку.", "purchase_panel_info_2": "Пошто смо се обавезали да нећемо додавати платне зидове, ова куповина вам неће дати никакве додатне функције у Имич-у. Ослањамо се на кориснике попут вас да подрже Имич-ов стални развој.", "purchase_panel_title": "Подржите пројекат", + "purchase_per_server": "По серверу", + "purchase_per_user": "По кориснику", + "purchase_remove_product_key": "Уклоните кључ производа", + "purchase_remove_product_key_prompt": "Да ли сте сигурни да желите да уклоните шифру производа?", + "purchase_remove_server_product_key": "Уклоните шифру производа сервера", + "purchase_remove_server_product_key_prompt": "Да ли сте сигурни да желите да уклоните шифру производа сервера?", + "purchase_server_description_1": "За цео сервер", + "purchase_server_description_2": "Значка подршке", + "purchase_server_title": "Сервер", + "purchase_settings_server_activated": "Кључем производа сервера управља администратор", "range": "", + "rating": "Оцена звездица", + "rating_description": "Прикажите exif оцену у инфо панелу", "raw": "", "reaction_options": "Опције реакције", "read_changelog": "Прочитајте дневник промена", @@ -1045,6 +1064,7 @@ "reset_people_visibility": "Ресетујте видљивост особа", "reset_settings_to_default": "", "reset_to_default": "Ресетујте на подразумеване вредности", + "resolve_duplicates": "Реши дупликате", "resolved_all_duplicates": "Сви дупликати су разрешени", "restore": "Поврати", "restore_all": "Поврати све", @@ -1089,6 +1109,7 @@ "see_all_people": "Види све особе", "select_album_cover": "Изаберите омот албума", "select_all": "Изабери све", + "select_all_duplicates": "Изаберите све дупликате", "select_avatar_color": "Изаберите боју аватара", "select_face": "Изаберите лице", "select_featured_photo": "Изаберите истакнуту фотографију", @@ -1129,6 +1150,7 @@ "sharing_sidebar_description": "Прикажите везу до Дељења на бочној траци", "shift_to_permanent_delete": "притисните ⇧ да трајно избришете датотеку", "show_album_options": "Прикажи опције албума", + "show_albums": "Прикажи албуме", "show_all_people": "Покажи све особе", "show_and_hide_people": "Откриј и сакриј особе", "show_file_location": "Прикажи локацију датотеке", @@ -1143,6 +1165,8 @@ "show_person_options": "Прикажи опције особе", "show_progress_bar": "Прикажи траку напретка", "show_search_options": "Прикажи опције претраге", + "show_supporter_badge": "Значка подршке", + "show_supporter_badge_description": "Покажите значку подршке", "shuffle": "Мешање", "sign_out": "Одјава", "sign_up": "Пријави се", @@ -1159,6 +1183,8 @@ "sort_title": "Наслов", "source": "Извор", "stack": "Слагање", + "stack_duplicates": "Дупликати гомиле", + "stack_select_one_photo": "Изаберите једну главну фотографију за гомилу", "stack_selected_photos": "Сложите изабране фотографије", "stacked_assets_count": "Наслагано {count, plural, one {# датотека} other {# датотеке}}", "stacktrace": "Веза до гомиле", @@ -1196,7 +1222,7 @@ "total_usage": "Укупна употреба", "trash": "Отпад", "trash_all": "Баци све у отпад", - "trash_count": "Отпад {count}", + "trash_count": "Отпад {count, number}", "trash_delete_asset": "Отпад/Избриши датотеку", "trash_no_results_message": "Слике и видео записи у отпаду ће се појавити овде.", "trashed_items_will_be_permanently_deleted_after": "Датотеке у отпаду ће бити трајно избрисане након {days, plural, one {# дан} few {# дана} other {# дана}}.", @@ -1216,6 +1242,7 @@ "unnamed_share": "Неименовано делење", "unsaved_change": "Несачувана промена", "unselect_all": "Поништи све", + "unselect_all_duplicates": "Поништи избор свих дупликата", "unstack": "Разгомилај (Ун-стацк)", "unstacked_assets_count": "Несложено {count, plural, one {# датотека} other {# датотеке}}", "untracked_files": "Непраћене Датотеке", @@ -1225,7 +1252,7 @@ "upload": "Уплоадуј", "upload_concurrency": "Паралелно уплоадовање", "upload_errors": "Отпремање је завршено са {count, plural, one {# грешком} other {# грешака}}, освежите страницу да бисте видели нове датотеке за отпремање (уплоад).", - "upload_progress": "Преостало {remaining} – Обрађено {processed}/{total}", + "upload_progress": "Преостало {remaining, number} – Обрађено {processed, number}/{total, number}", "upload_skipped_duplicates": "Прескочено {count, plural, one {# дупла датотека} other {# дуплих датотека}}", "upload_status_duplicates": "Дупликати", "upload_status_errors": "Грешке", @@ -1239,6 +1266,8 @@ "user_license_settings": "Лиценца", "user_license_settings_description": "Управљајте својом лиценцом", "user_liked": "{user} је лајковао {type, select, photo {ову фотографију} video {овај видео запис} asset {ову датотеку} other {ово}}", + "user_purchase_settings": "Куповина", + "user_purchase_settings_description": "Управљајте куповином", "user_role_set": "Постави {user} као {role}", "user_usage_detail": "Детаљи коришћења корисника", "username": "Корисничко име", diff --git a/web/src/lib/i18n/sr_Latn.json b/web/src/lib/i18n/sr_Latn.json index eb9320ae48..5741354bde 100644 --- a/web/src/lib/i18n/sr_Latn.json +++ b/web/src/lib/i18n/sr_Latn.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Dodaj u deljen album", "added_to_archive": "Dodato u arhivu", "added_to_favorites": "Dodato u favorite", - "added_to_favorites_count": "Dodato {count} u favorite", + "added_to_favorites_count": "Dodato {count, number} u favorite", "admin": { "add_exclusion_pattern_description": "Dodajte obrasce isključenja. Korištenje *, ** i ? je podržano. Da biste ignorisali sve datoteke u bilo kom direktorijumu pod nazivom „Rav“, koristite „**/Rav/**“. Da biste ignorisali sve datoteke koje se završavaju na „.tif“, koristite „**/*.tif“. Da biste ignorisali apsolutnu putanju, koristite „/path/to/ignore/**“.", "authentication_settings": "Podešavanja za autentifikaciju", @@ -278,7 +278,7 @@ "transcoding_preferred_hardware_device": "Željeni hardverski uređaj", "transcoding_preferred_hardware_device_description": "Odnosi se samo na VAAPI i QSV. Postavlja dri node koji se koristi za hardversko transkodiranje.", "transcoding_preset_preset": "Unapred podešena podešavanja (-preset)", - "transcoding_preset_preset_description": "Brzina kompresije. Sporije unapred podešene vrednosti proizvode manje datoteke i povećavaju kvalitet kada ciljate određenu brzinu prenosa. VP9 ignoriše brzine iznad `brže`.", + "transcoding_preset_preset_description": "Brzina kompresije. Sporije unapred podešene vrednosti proizvode manje datoteke i povećavaju kvalitet kada ciljate određenu brzinu prenosa. VP9 ignoriše brzine iznad 'brže'.", "transcoding_reference_frames": "Referentni okviri (frames)", "transcoding_reference_frames_description": "Broj okvira (frames) za referencu prilikom kompresije datog okvira. Više vrednosti poboljšavaju efikasnost kompresije, ali usporavaju kodiranje. 0 automatski postavlja ovu vrednost.", "transcoding_required_description": "Samo video snimci koji nisu u prihvaćenom formatu", @@ -873,6 +873,7 @@ "name": "Ime", "name_or_nickname": "Ime ili nadimak", "never": "Nikada", + "new_album": "Novi Album", "new_api_key": "Novi API ključ (key)", "new_password": "Nova šifra", "new_person": "Nova osoba", @@ -917,6 +918,7 @@ "online": "Dostupan (Online)", "only_favorites": "Samo favoriti", "only_refreshes_modified_files": "Osvežava samo izmenjene datoteke", + "open_in_map_view": "Otvorite u prikaz karte", "open_in_openstreetmap": "Otvorite u OpenStreetMap-u", "open_the_search_filters": "Otvorite filtere za pretragu", "options": "Opcije", @@ -1020,6 +1022,8 @@ "purchase_server_title": "Server", "purchase_settings_server_activated": "Ključem proizvoda servera upravlja administrator", "range": "", + "rating": "Ocena zvezdica", + "rating_description": "Prikažite exif ocenu u info panelu", "raw": "", "reaction_options": "Opcije reakcije", "read_changelog": "Pročitajte dnevnik promena", @@ -1150,6 +1154,7 @@ "sharing_sidebar_description": "Prikažite vezu do Deljenja na bočnoj traci", "shift_to_permanent_delete": "pritisnite ⇧ da trajno izbrišete datoteku", "show_album_options": "Prikaži opcije albuma", + "show_albums": "Prikaži albume", "show_all_people": "Pokaži sve osobe", "show_and_hide_people": "Otkrij i sakrij osobe", "show_file_location": "Prikaži lokaciju datoteke", @@ -1182,6 +1187,8 @@ "sort_title": "Naslov", "source": "Izvor", "stack": "Slaganje", + "stack_duplicates": "Duplikati gomile", + "stack_select_one_photo": "Izaberite jednu glavnu fotografiju za gomilu", "stack_selected_photos": "Složite izabrane fotografije", "stacked_assets_count": "Naslagano {count, plural, one {# datoteka} other {# datoteke}}", "stacktrace": "Veza do gomile", @@ -1219,7 +1226,7 @@ "total_usage": "Ukupna upotreba", "trash": "Otpad", "trash_all": "Baci sve u otpad", - "trash_count": "Otpad {count}", + "trash_count": "Otpad {count, number}", "trash_delete_asset": "Otpad/Izbriši datoteku", "trash_no_results_message": "Slike i video zapisi u otpadu će se pojaviti ovde.", "trashed_items_will_be_permanently_deleted_after": "Datoteke u otpadu će biti trajno izbrisane nakon {days, plural, one {# dan} few {# dana} other {# dana}}.", @@ -1249,7 +1256,7 @@ "upload": "Uploaduj", "upload_concurrency": "Paralelno uploadovanje", "upload_errors": "Otpremanje je završeno sa {count, plural, one {# greškom} other {# grešaka}}, osvežite stranicu da biste videli nove datoteke za otpremanje (upload).", - "upload_progress": "Preostalo {remaining} – Obrađeno {processed}/{total}", + "upload_progress": "Preostalo {remaining, number} – Obrađeno {processed, number}/{total, number}", "upload_skipped_duplicates": "Preskočeno {count, plural, one {# dupla datoteka} other {# duplih datoteka}}", "upload_status_duplicates": "Duplikati", "upload_status_errors": "Greške", diff --git a/web/src/lib/i18n/sv.json b/web/src/lib/i18n/sv.json index 290182153b..3eec79b615 100644 --- a/web/src/lib/i18n/sv.json +++ b/web/src/lib/i18n/sv.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Lägg till i delat album", "added_to_archive": "Tillagd i arkiv", "added_to_favorites": "Tillagd till favoriter", - "added_to_favorites_count": "{count} tillagda till favoriter", + "added_to_favorites_count": "{count, number} tillagda till favoriter", "admin": { "add_exclusion_pattern_description": "Lägg till exkluderande mönster. Matchning med jokertecken *, ** samt ? är supporterat. För att ignorera alla filer i samtliga mappar som heter \"Raw\", använd \"**/Raw/**\". För att ignorera alla filer som slutar med \".tif\", använd \"**/*.tif\". För att ignorera en absolut sökväg, använd \"/sökväg/att/ignorera/**\".", "authentication_settings": "Autentiseringsinställningar", @@ -79,7 +79,7 @@ "library_created": "Skapat bibliotek: {library}", "library_cron_expression": "Cron-uttryck", "library_cron_expression_description": "Ställ in intervallet för skanningen med cron-formatet. För mer information gå till t.ex. Crontab Guru ", - "library_cron_expression_presets": "Cron Uttrycksförinställningar", + "library_cron_expression_presets": "Cron-uttrycksförinställningar", "library_deleted": "Biblioteket har tagits bort", "library_import_path_description": "Ange en mapp att importera. Den här mappen, inklusive undermappar, skannas efter bilder och videor.", "library_scanning": "Periodisk skanning", @@ -157,7 +157,7 @@ "notification_email_setting_description": "Inställningar för att skicka epostnotiser", "notification_email_test_email": "Skicka test-epost", "notification_email_test_email_failed": "Misslyckades med att skicka test-epost, undersök dina värden", - "notification_email_test_email_sent": "Ett test-epostmeddelande has skickats till {epost}. Kolla din inbox.", + "notification_email_test_email_sent": "Ett test-epostmeddelande has skickats till {epost}. Kolla din inkorg.", "notification_email_username_description": "Användarnamn att använda vid autentisering med epost-servern", "notification_enable_email_notifications": "Aktivera epost-notiser", "notification_settings": "Notisinställningar", @@ -171,27 +171,38 @@ "oauth_client_secret": "Klienthemlighet", "oauth_enable_description": "Logga in med OAuth", "oauth_issuer_url": "Utfärdar-URL", - "oauth_mobile_redirect_uri": "Telefonomdirigernings URI", + "oauth_mobile_redirect_uri": "Telefonomdirigernings-URI", "oauth_mobile_redirect_uri_override": "Telefonomdirigerings-URI överrskridning", "oauth_mobile_redirect_uri_override_description": "Sätt på när 'app.immich:/' är en ogiltig omdirigernings-URI.", "oauth_profile_signing_algorithm": "Profilsigneringsalgorithm", "oauth_profile_signing_algorithm_description": "Algorithm som används för att signera användarprofilen.", - "oauth_scope": "", + "oauth_scope": "Omfattning", "oauth_settings": "OAuth", "oauth_settings_description": "Hantera OAuth-logininställningar", - "oauth_settings_more_details": "För flera detaljer om denna funktion, hänvisa till docs", + "oauth_settings_more_details": "För ytterligare detaljer om denna funktion, se dokumentationen.", "oauth_signing_algorithm": "Signeringsalgoritm", "oauth_storage_label_claim": "", "oauth_storage_label_claim_description": "", "oauth_storage_quota_claim": "", "oauth_storage_quota_claim_description": "", - "oauth_storage_quota_default": "", + "oauth_storage_quota_default": "Standardlagringskvot (GiB)", "oauth_storage_quota_default_description": "", + "offline_paths": "Offline-sökvägar", + "offline_paths_description": "Dessa resultat kan bero på manuell borttagning av filer som inte är en del av ett externt bibliotek.", "password_enable_description": "Logga in med epost och lösenord", - "password_settings": "", - "password_settings_description": "", + "password_settings": "Lösenords-inloggning", + "password_settings_description": "Hantera inställningar för lösenords-inloggning", + "paths_validated_successfully": "Samtliga sökvägar kunde bekräftas", + "quota_size_gib": "Lagringskvot (GiB)", + "refreshing_all_libraries": "Samtliga bibliotek uppdateras", + "registration": "Administratörsregistrering", + "registration_description": "Du utses till administratör eftersom du är systemets första användare. Du ansvarar för administration och kan skapa ytterligare användare.", + "removing_offline_files": "Tar Bort Offline-Filer", + "repair_all": "Reparera alla", + "reset_settings_to_default": "Återställ inställningar till standard", + "scanning_library_for_new_files": "Skannar biblioteket efter nya filer", "server_external_domain_settings": "Extern domän", - "server_external_domain_settings_description": "", + "server_external_domain_settings_description": "Domän för publikt delade länkar, inklusive http(s)://", "server_settings": "Serverinställningar", "server_settings_description": "Hantera serverinställningar", "server_welcome_message": "Välkomstmeddelande", @@ -201,7 +212,7 @@ "smart_search_job_description": "", "storage_template_enable_description": "", "storage_template_hash_verification_enabled": "", - "storage_template_hash_verification_enabled_description": "", + "storage_template_hash_verification_enabled_description": "Aktiverar hash-verifiering, deaktiviera inte om du inte är säker på implikationerna", "storage_template_migration_job": "", "storage_template_settings": "Lagringsmall", "storage_template_settings_description": "", diff --git a/web/src/lib/i18n/te.json b/web/src/lib/i18n/te.json index 0967ef424b..dc92a56d57 100644 --- a/web/src/lib/i18n/te.json +++ b/web/src/lib/i18n/te.json @@ -1 +1,269 @@ -{} +{ + "about": "గురించి", + "account": "ఖాతా", + "account_settings": "ఖాతా సెట్టింగ్‌లు", + "acknowledge": "గుర్తించండి", + "action": "చర్య", + "actions": "చర్యలు", + "active": "చురుకుగా", + "activity": "కార్యాచరణ", + "activity_changed": "కార్యకలాపం {enabled, select, true {enabled} other {disabled}}", + "add": "జోడించు", + "add_a_description": "వివరణ జోడించండి", + "add_a_location": "స్థానాన్ని జోడించండి", + "add_a_name": "పేరును జోడించండి", + "add_a_title": "శీర్షికను జోడించండి", + "add_exclusion_pattern": "మినహాయింపు నమూనాను జోడించండి", + "add_import_path": "దిగుమతి మార్గాన్ని జోడించండి", + "add_location": "స్థానాన్ని జోడించండి", + "add_more_users": "మరింత మంది వినియోగదారులను జోడించండి", + "add_partner": "భాగస్వామిని జోడించండి", + "add_path": "మార్గాన్ని జోడించండి", + "add_photos": "ఫోటోలను జోడించండి", + "add_to": "జోడించండి...", + "add_to_album": "ఆల్బమ్‌కు జోడించండి", + "add_to_shared_album": "భాగస్వామ్య ఆల్బమ్‌కు జోడించండి", + "added_to_archive": "ఆర్కైవ్‌కి జోడించబడింది", + "added_to_favorites": "ఇష్టమైన వాటికి జోడించబడింది", + "added_to_favorites_count": "ఇష్టమైన వాటికి {count, number} జోడించబడింది", + "admin": { + "add_exclusion_pattern_description": "మినహాయింపు నమూనాలను జోడించండి. *, ** మరియు ?ని ఉపయోగించి గ్లోబింగ్‌కు మద్దతు ఉంది. \"Raw\" అనే పేరు గల ఏదైనా డైరెక్టరీలోని అన్ని ఫైల్‌లను విస్మరించడానికి, \"**/Raw/**\"ని ఉపయోగించండి. \".tif\"తో ముగిసే అన్ని ఫైల్‌లను విస్మరించడానికి, \"**/*.tif\"ని ఉపయోగించండి. సంపూర్ణ మార్గాన్ని విస్మరించడానికి, \"/path/to/ignore/**\"ని ఉపయోగించండి.", + "authentication_settings": "ప్రమాణీకరణ సెట్టింగ్‌లు", + "authentication_settings_description": "పాస్‌వర్డ్, OAuth మరియు ఇతర ప్రమాణీకరణ సెట్టింగ్‌లను నిర్వహించండి", + "authentication_settings_disable_all": "మీరు ఖచ్చితంగా అన్ని లాగిన్ పద్ధతులను నిలిపివేయాలనుకుంటున్నారా? లాగిన్ పూర్తిగా నిలిపివేయబడుతుంది.", + "authentication_settings_reenable": "మళ్లీ ప్రారంబించటానికి, Server Commandని ఉపయోగించండి.", + "background_task_job": "నేపథ్య పనులు", + "check_all": "అన్నీ తనిఖీ చేయండి", + "cleared_jobs": "దీని కోసం ఉద్యోగాలు క్లియర్ చేయబడ్డాయి: {job}", + "config_set_by_file": "కాన్ఫిగరేషన్ ప్రస్తుతం కాన్ఫిగరేషన్ ఫైల్ ద్వారా సెట్ చేయబడింది", + "confirm_delete_library": "మీరు ఖచ్చితంగా {library} లైబ్రరీని తొలగించాలనుకుంటున్నారా?", + "confirm_delete_library_assets": "మీరు ఖచ్చితంగా ఈ లైబ్రరీని తొలగించాలనుకుంటున్నారా? ఇది Immich నుండి {count, plural, one {# కలిగి ఉన్న ఆస్తి} other {all # కలిగి ఉన్న ఆస్తులు}} తొలగిస్తుంది మరియు రద్దు చేయబడదు. ఫైల్‌లు డిస్క్‌లో ఉంటాయి.", + "confirm_email_below": "నిర్ధారించడానికి, క్రింద \"{email}\" టైప్ చేయండి", + "confirm_reprocess_all_faces": "మీరు ఖచ్చితంగా అన్ని ముఖాలను రీప్రాసెస్ చేయాలనుకుంటున్నారా? ఇది పేరున్న వ్యక్తులను కూడా క్లియర్ చేస్తుంది.", + "confirm_user_password_reset": "మీరు ఖచ్చితంగా {user} పాస్‌వర్డ్‌ని రీసెట్ చేయాలనుకుంటున్నారా?", + "disable_login": "లాగిన్‌ను నిలిపివేయండి", + "duplicate_detection_job_description": "సారూప్య చిత్రాలను గుర్తించడానికి ఆస్తులపై యంత్ర అభ్యాసాన్ని అమలు చేయండి. స్మార్ట్ శోధనపై ఆధారపడుతుంది", + "exclusion_pattern_description": "మినహాయింపు నమూనాలు మీ లైబ్రరీని స్కాన్ చేస్తున్నప్పుడు ఫైల్‌లు మరియు ఫోల్డర్‌లను విస్మరించడానికి మిమ్మల్ని అనుమతిస్తాయి. మీరు దిగుమతి చేయకూడదనుకునే RAW ఫైల్‌లు వంటి ఫోల్డర్‌లను కలిగి ఉన్నట్లయితే ఇది ఉపయోగకరంగా ఉంటుంది.", + "external_library_created_at": "బాహ్య లైబ్రరీ ({date}న సృష్టించబడింది)", + "external_library_management": "బాహ్య లైబ్రరీ నిర్వహణ", + "face_detection": "ముఖ గుర్తింపు", + "face_detection_description": "మెషిన్ లెర్నింగ్ ఉపయోగించి ఆస్తులలో ముఖాలను గుర్తించండి. వీడియోల కోసం, సూక్ష్మచిత్రం మాత్రమే పరిగణించబడుతుంది. \"అన్నీ\" (పునః) అన్ని ఆస్తులను ప్రాసెస్ చేస్తుంది. ఇంకా ప్రాసెస్ చేయని ఆస్తులను \"మిస్సింగ్\" క్యూలు చేస్తుంది. గుర్తించబడిన ముఖాలు ఇప్పటికే ఉన్న లేదా కొత్త వ్యక్తులతో సమూహపరచడం పూర్తయిన తర్వాత ముఖ గుర్తింపు కోసం క్యూలో ఉంచబడతాయి.", + "facial_recognition_job_description": "సమూహం వ్యక్తుల ముఖాలను గుర్తించింది. ఫేస్ డిటెక్షన్ పూర్తయిన తర్వాత ఈ దశ అమలవుతుంది. \"అన్ని\" (పునః) అన్ని ముఖాలను క్లస్టర్‌లు చేస్తుంది. \"తప్పిపోయిన\" వ్యక్తిని కేటాయించని ముఖాలను క్యూలో ఉంచుతుంది.", + "failed_job_command": "ఉద్యోగం కోసం కమాండ్ {command} విఫలమైంది: {job}", + "force_delete_user_warning": "హెచ్చరిక: ఇది వినియోగదారుని మరియు అన్ని ఆస్తులను వెంటనే తీసివేస్తుంది. ఇది రద్దు చేయబడదు మరియు ఫైల్‌లను తిరిగి పొందడం సాధ్యం కాదు.", + "forcing_refresh_library_files": "అన్ని లైబ్రరీ ఫైల్‌లను రిఫ్రెష్ చేయమని బలవంతం చేస్తోంది", + "image_format_description": "WebP JPEG కంటే చిన్న ఫైల్‌లను ఉత్పత్తి చేస్తుంది, కానీ ఎన్‌కోడ్ చేయడం నెమ్మదిగా ఉంటుంది.", + "image_prefer_embedded_preview": "పొందుపరిచిన పరిదృశ్యానికి ప్రాధాన్యత ఇవ్వండి", + "image_prefer_embedded_preview_setting_description": "అందుబాటులో ఉన్నప్పుడు ఇమేజ్ ప్రాసెసింగ్‌కు ఇన్‌పుట్‌గా RAW ఫోటోలలో ఎంబెడెడ్ ప్రివ్యూలను ఉపయోగించండి. ఇది కొన్ని చిత్రాలకు మరింత ఖచ్చితమైన రంగులను ఉత్పత్తి చేయగలదు, అయితే ప్రివ్యూ నాణ్యత కెమెరాపై ఆధారపడి ఉంటుంది మరియు చిత్రం మరిన్ని కుదింపు కళాఖండాలను కలిగి ఉండవచ్చు.", + "image_prefer_wide_gamut": "విస్తృత స్వరసప్తకానికి ప్రాధాన్యత ఇవ్వండి", + "image_prefer_wide_gamut_setting_description": "థంబ్‌నెయిల్‌ల కోసం డిస్‌ప్లే P3ని ఉపయోగించండి. ఇది విస్తృత రంగుల ఖాళీలతో చిత్రాల వైబ్రెన్స్‌ను మెరుగ్గా భద్రపరుస్తుంది, అయితే పాత బ్రౌజర్ వెర్షన్‌తో పాత పరికరాల్లో చిత్రాలు విభిన్నంగా కనిపించవచ్చు. రంగు మార్పులను నివారించడానికి sRGB చిత్రాలు sRGB వలె ఉంచబడతాయి.", + "image_preview_format": "ప్రివ్యూ ఫార్మాట్", + "image_preview_resolution": "ప్రివ్యూ రిజల్యూషన్", + "image_preview_resolution_description": "ఒకే ఫోటోను చూసేటప్పుడు మరియు మెషిన్ లెర్నింగ్ కోసం ఉపయోగించబడుతుంది. అధిక రిజల్యూషన్‌లు మరింత వివరాలను భద్రపరుస్తాయి కానీ ఎన్‌కోడ్ చేయడానికి ఎక్కువ సమయం పడుతుంది, పెద్ద ఫైల్ పరిమాణాలను కలిగి ఉంటాయి మరియు యాప్ ప్రతిస్పందనను తగ్గించవచ్చు.", + "image_quality": "నాణ్యత", + "image_quality_description": "1-100 నుండి చిత్ర నాణ్యత. నాణ్యత కోసం అధికమైనది ఉత్తమం కానీ పెద్ద ఫైల్‌లను ఉత్పత్తి చేస్తుంది, ఈ ఎంపిక ప్రివ్యూ మరియు థంబ్‌నెయిల్ చిత్రాలను ప్రభావితం చేస్తుంది.", + "image_settings": "చిత్రం సెట్టింగ్‌లు", + "image_settings_description": "రూపొందించబడిన చిత్రాల నాణ్యత మరియు రిజల్యూషన్‌ను నిర్వహించండి", + "image_thumbnail_format": "థంబ్‌నెయిల్ ఫార్మాట్", + "image_thumbnail_resolution": "థంబ్‌నెయిల్ రిజల్యూషన్", + "image_thumbnail_resolution_description": "ఫోటోల సమూహాలను వీక్షిస్తున్నప్పుడు ఉపయోగించబడుతుంది (ప్రధాన టైమ్‌లైన్, ఆల్బమ్ వీక్షణ మొదలైనవి). అధిక రిజల్యూషన్‌లు మరింత వివరాలను భద్రపరుస్తాయి కానీ ఎన్‌కోడ్ చేయడానికి ఎక్కువ సమయం పడుతుంది, పెద్ద ఫైల్ పరిమాణాలను కలిగి ఉంటాయి మరియు యాప్ ప్రతిస్పందనను తగ్గించవచ్చు.", + "job_concurrency": "{job} సమ్మతి", + "job_not_concurrency_safe": "ఈ ఉద్యోగం సమ్మతి-సురక్షితమైనది కాదు.", + "job_settings": "ఉద్యోగ సెట్టింగ్‌లు", + "job_settings_description": "ఉద్యోగ సమ్మతిని నిర్వహించండి", + "job_status": "ఉద్యోగ స్థితి", + "jobs_delayed": "{jobCount, plural, other {# ఆలస్యమైంది}}", + "jobs_failed": "{jobCount, plural, other {# విఫలమైంది}}", + "library_created": "లైబ్రరీ సృష్టించబడింది: {library}", + "library_cron_expression": "క్రాన్ వ్యక్తీకరణ", + "library_cron_expression_description": "క్రాన్ ఆకృతిని ఉపయోగించి స్కానింగ్ విరామాన్ని సెట్ చేయండి. మరింత సమాచారం కోసం దయచేసి చూడండి ఉదా. Crontab Guru", + "library_cron_expression_presets": "క్రాన్ వ్యక్తీకరణ ప్రీసెట్లు", + "library_deleted": "లైబ్రరీ తొలగించబడింది", + "library_import_path_description": "దిగుమతి చేయడానికి ఫోల్డర్‌ను పేర్కొనండి. సబ్ ఫోల్డర్‌లతో సహా ఈ ఫోల్డర్ చిత్రాలు మరియు వీడియోల కోసం స్కాన్ చేయబడుతుంది.", + "library_scanning": "ఆవర్తన స్కానింగ్", + "library_scanning_description": "ఆవర్తన లైబ్రరీ స్కానింగ్‌ని కాన్ఫిగర్ చేయండి", + "library_scanning_enable_description": "ఆవర్తన లైబ్రరీ స్కానింగ్‌ని ప్రారంభించండి", + "library_settings": "బాహ్య లైబ్రరీ", + "library_settings_description": "బాహ్య లైబ్రరీ సెట్టింగ్‌లను నిర్వహించండి", + "library_tasks_description": "లైబ్రరీ పనులను నిర్వహించండి", + "library_watching_enable_description": "ఫైల్ మార్పుల కోసం బాహ్య లైబ్రరీలను చూడండి", + "library_watching_settings": "లైబ్రరీ చూడటం (ప్రయోగాత్మకం)", + "library_watching_settings_description": "మారిన ఫైల్‌ల కోసం ఆటోమేటిక్‌గా చూడండి", + "logging_enable_description": "లాగింగ్‌ని ప్రారంభించండి", + "logging_level_description": "ప్రారంభించబడినప్పుడు, ఏ లాగ్ స్థాయిని ఉపయోగించాలి.", + "logging_settings": "లాగింగ్", + "machine_learning_clip_model": "CLIP మోడల్", + "machine_learning_clip_model_description": "ఇక్కడ జాబితా చేయబడిన CLIP మోడల్ పేరు. మీరు మోడల్‌ను మార్చిన తర్వాత అన్ని చిత్రాల కోసం 'స్మార్ట్ సెర్చ్' జాబ్‌ని మళ్లీ అమలు చేయాలని గుర్తుంచుకోండి.", + "machine_learning_duplicate_detection": "డూప్లికేట్ డిటెక్షన్", + "machine_learning_duplicate_detection_enabled": "నకిలీ గుర్తింపును ప్రారంభించండి", + "machine_learning_duplicate_detection_enabled_description": "నిలిపివేసినట్లయితే, సరిగ్గా ఒకేలాంటి ఆస్తులు ఇప్పటికీ డీ-డూప్లికేట్ చేయబడతాయి.", + "machine_learning_duplicate_detection_setting_description": "సంభావ్య నకిలీలను కనుగొనడానికి CLIP ఎంబెడ్డింగ్‌లను ఉపయోగించండి", + "machine_learning_enabled": "మెషిన్ లెర్నింగ్ ప్రారంభించండి", + "machine_learning_enabled_description": "డిజేబుల్ చేయబడితే, దిగువ సెట్టింగ్‌లతో సంబంధం లేకుండా అన్ని ML ఫీచర్‌లు నిలిపివేయబడతాయి.", + "machine_learning_facial_recognition": "ముఖ గుర్తింపు", + "machine_learning_facial_recognition_description": "చిత్రాలలో ముఖాలను గుర్తించండి, గుర్తించండి మరియు సమూహపరచండి", + "machine_learning_facial_recognition_model": "ముఖ గుర్తింపు మోడల్", + "machine_learning_facial_recognition_model_description": "నమూనాలు పరిమాణం యొక్క అవరోహణ క్రమంలో జాబితా చేయబడ్డాయి. పెద్ద మోడల్‌లు నెమ్మదిగా ఉంటాయి మరియు ఎక్కువ మెమరీని ఉపయోగిస్తాయి, కానీ మంచి ఫలితాలను ఇస్తాయి. మీరు మోడల్‌ను మార్చిన తర్వాత అన్ని చిత్రాల కోసం తప్పనిసరిగా ఫేస్ డిటెక్షన్ జాబ్‌ని మళ్లీ అమలు చేయాలని గుర్తుంచుకోండి.", + "machine_learning_facial_recognition_setting": "ముఖ గుర్తింపును ప్రారంభించండి", + "machine_learning_facial_recognition_setting_description": "నిలిపివేయబడితే, ముఖ గుర్తింపు కోసం చిత్రాలు ఎన్‌కోడ్ చేయబడవు మరియు అన్వేషణ పేజీలోని వ్యక్తుల విభాగాన్ని నింపవు.", + "machine_learning_max_detection_distance": "గరిష్ట గుర్తింపు దూరం", + "machine_learning_max_detection_distance_description": "రెండు చిత్రాల మధ్య గరిష్ట దూరం 0.001-0.1 వరకు నకిలీలుగా పరిగణించబడుతుంది. అధిక విలువలు మరిన్ని నకిలీలను గుర్తిస్తాయి, కానీ తప్పుడు పాజిటివ్‌లకు దారితీయవచ్చు.", + "machine_learning_max_recognition_distance": "గరిష్ట గుర్తింపు దూరం", + "machine_learning_max_recognition_distance_description": "ఒకే వ్యక్తిగా పరిగణించబడే రెండు ముఖాల మధ్య గరిష్ట దూరం 0-2 వరకు ఉంటుంది. దీన్ని తగ్గించడం ద్వారా ఇద్దరు వ్యక్తులను ఒకే వ్యక్తిగా లేబుల్ చేయడాన్ని నిరోధించవచ్చు, అయితే పెంచడం ద్వారా ఒకే వ్యక్తిని ఇద్దరు వేర్వేరు వ్యక్తులుగా పేర్కొనడాన్ని నిరోధించవచ్చు. ఒక వ్యక్తిని రెండుగా విభజించడం కంటే ఇద్దరు వ్యక్తులను విలీనం చేయడం సులభమని గుర్తుంచుకోండి, కాబట్టి సాధ్యమైనప్పుడు తక్కువ థ్రెషోల్డ్ వైపు తప్పు చేయండి.", + "machine_learning_min_detection_score": "కనిష్ట గుర్తింపు స్కోర్", + "machine_learning_min_detection_score_description": "ముఖం కోసం కనిష్ట విశ్వాస స్కోరు 0-1 నుండి గుర్తించబడుతుంది. తక్కువ విలువలు ఎక్కువ ముఖాలను గుర్తిస్తాయి కానీ తప్పుడు పాజిటివ్‌లకు దారితీయవచ్చు.", + "machine_learning_min_recognized_faces": "కనిష్టంగా గుర్తించబడిన ముఖాలు", + "machine_learning_min_recognized_faces_description": "ఒక వ్యక్తి సృష్టించడానికి గుర్తించబడిన ముఖాల కనీస సంఖ్య. దీన్ని పెంచడం వలన ఒక వ్యక్తికి ముఖం కేటాయించబడని అవకాశాన్ని పెంచే ఖర్చుతో ఫేషియల్ రికగ్నిషన్ మరింత ఖచ్చితమైనదిగా చేస్తుంది.", + "machine_learning_settings": "మెషిన్ లెర్నింగ్ సెట్టింగ్‌లు", + "machine_learning_settings_description": "మెషిన్ లెర్నింగ్ ఫీచర్‌లు మరియు సెట్టింగ్‌లను నిర్వహించండి", + "machine_learning_smart_search": "స్మార్ట్ శోధన", + "machine_learning_smart_search_description": "CLIP ఎంబెడ్డింగ్‌లను ఉపయోగించి అర్థపరంగా చిత్రాల కోసం శోధించండి", + "machine_learning_smart_search_enabled": "స్మార్ట్ శోధనను ప్రారంభించండి", + "machine_learning_smart_search_enabled_description": "నిలిపివేయబడితే, స్మార్ట్ శోధన కోసం చిత్రాలు ఎన్‌కోడ్ చేయబడవు.", + "machine_learning_url_description": "మెషిన్ లెర్నింగ్ సర్వర్ యొక్క URL", + "manage_concurrency": "కరెన్సీని నిర్వహించండి", + "manage_log_settings": "లాగ్ సెట్టింగ్‌లను నిర్వహించండి", + "map_dark_style": "చీకటి శైలి", + "map_enable_description": "మ్యాప్ లక్షణాలను ప్రారంభించండి", + "map_gps_settings": "మ్యాప్ & GPS సెట్టింగ్‌లు", + "map_gps_settings_description": "మ్యాప్ & GPS (రివర్స్ జియోకోడింగ్) సెట్టింగ్‌లను నిర్వహించండి", + "map_light_style": "పగటి శైలి", + "map_manage_reverse_geocoding_settings": "రివర్స్ జియోకోడింగ్ సెట్టింగ్‌లను నిర్వహించండి", + "map_reverse_geocoding": "రివర్స్ జియోకోడింగ్", + "map_reverse_geocoding_enable_description": "రివర్స్ జియోకోడింగ్‌ని ప్రారంభించండి", + "map_reverse_geocoding_settings": "రివర్స్ జియోకోడింగ్ సెట్టింగ్‌లు", + "map_settings": "మ్యాప్ సెట్టింగ్‌లు" + }, + "invite_to_album": "ఆల్బమ్‌కు ఆహ్వానించండి", + "jobs": "ఉద్యోగాలు", + "keep": "ఉంచండి", + "keep_all": "అన్ని ఉంచు", + "keyboard_shortcuts": "కీబోర్డ్ సత్వరమార్గాలు", + "language": "భాష", + "language_setting_description": "మీకు ఇష్టమైన భాషను ఎంచుకోండి", + "last_seen": "ఆఖరి సారిగా చూచింది", + "latitude": "అక్షాంశం", + "leave": "వదిలేయ్", + "let_others_respond": "ఇతరులు ప్రతిస్పందించనివ్వండి", + "level": "స్థాయి", + "library": "గ్రంధాలయం", + "library_options": "లైబ్రరీ ఎంపికలు", + "light": "వెలుతురు", + "link_options": "లింక్ ఎంపికలు", + "linked_oauth_account": "లింక్ చేయబడిన OAuth ఖాతా", + "list": "జాబితా", + "loading": "లోడ్", + "loading_search_results_failed": "శోధన ఫలితాలను లోడ్ చేయడం విఫలమైంది", + "log_out": "లాగ్ అవుట్", + "log_out_all_devices": "అన్ని పరికరాలను లాగ్ అవుట్ చేయండి", + "logged_out_all_devices": "అన్ని పరికరాలను లాగ్ అవుట్ చేసారు", + "logged_out_device": "పరికరం లాగ్ అవుట్ చేయబడింది", + "logout_this_device_confirmation": "మీరు ఖచ్చితంగా ఈ పరికరాన్ని లాగ్ అవుట్ చేయాలనుకుంటున్నారా?", + "longitude": "రేఖాంశం", + "look": "చూడు", + "loop_videos": "లూప్ వీడియోలు", + "loop_videos_description": "వివరాల వ్యూయర్‌లో వీడియోను స్వయంచాలకంగా లూప్ చేయడానికి ప్రారంభించండి.", + "make": "తయారు చేయండి", + "manage_shared_links": "భాగస్వామ్య లింక్‌లను నిర్వహించండి", + "manage_sharing_with_partners": "భాగస్వాములతో భాగస్వామ్యాన్ని నిర్వహించండి", + "manage_the_app_settings": "యాప్ సెట్టింగ్‌లను నిర్వహించండి", + "manage_your_account": "మీ ఖాతా నిర్వహించుకొనండి", + "manage_your_oauth_connection": "మీ OAuth కనెక్షన్‌ని నిర్వహించండి", + "map": "మ్యాప్", + "map_marker_with_image": "చిత్రంతో మ్యాప్ మార్కర్", + "map_settings": "మ్యాప్ సెట్టింగ్‌లు", + "matches": "మ్యాచ్‌లు", + "media_type": "మీడియా రకం", + "memories": "జ్ఞాపకాలు", + "memories_setting_description": "మీ జ్ఞాపకాలలో మీరు చూసే వాటిని నిర్వహించండి", + "memory": "గ్నాపకం", + "menu": "మెను", + "merge": "విలీనం", + "merge_people": "వ్యక్తులను విలీనం చేయండి", + "merge_people_limit": "మీరు ఒకేసారి 5 ముఖాలను మాత్రమే విలీనం చేయగలరు", + "merge_people_prompt": "మీరు ఈ వ్యక్తులను విలీనం చేయాలనుకుంటున్నారా? ఈ చర్య తిరుగులేనిది.", + "merge_people_successfully": "వ్యక్తులను విజయవంతంగా విలీనం చేసారు", + "minimize": "తగ్గించండి", + "minute": "నిమిషం", + "missing": "తప్పిపోయింది", + "model": "మోడల్", + "month": "నెల", + "more": "మరింత", + "moved_to_trash": "ట్రాష్‌కి తరలించబడింది", + "my_albums": "నా ఆల్బమ్‌లు", + "name": "పేరు", + "name_or_nickname": "పేరు లేదా మారుపేరు", + "never": "ఎప్పుడు కాదు", + "new_album": "కొత్త ఆల్బమ్", + "new_password": "కొత్త పాస్వర్డ్", + "new_person": "కొత్త వ్యక్తి", + "new_user_created": "కొత్త వినియోగదారి సృష్టించబడ్డారు", + "newest_first": "మొదటిది సరికొత్తది", + "next": "తరువాత", + "next_memory": "తదుపరి జ్ఞాపకం", + "no": "కాదు", + "no_albums_message": "మీ ఫోటోలు మరియు వీడియోలను నిర్వహించడానికి ఆల్బమ్‌ను సృష్టించండి", + "no_albums_with_name_yet": "మీకు ఇంకా ఈ పేరుతో ఆల్బమ్‌లు ఏవీ లేనట్లు కనిపిస్తోంది.", + "no_albums_yet": "మీ వద్ద ఇంకా ఆల్బమ్‌లు ఏవీ లేనట్లు కనిపిస్తోంది.", + "no_archived_assets_message": "మీ ఫోటోల వీక్షణ నుండి వాటిని దాచడానికి ఫోటోలు మరియు వీడియోలను ఆర్కైవ్ చేయండి", + "no_assets_message": "మీ మొదటి ఫోటోను అప్‌లోడ్ చేయడానికి క్లిక్ చేయండి", + "no_duplicates_found": "నకిలీలు ఏవీ కనుగొనబడలేదు.", + "no_explore_results_message": "మీ సేకరణను అన్వేషించడానికి మరిన్ని ఫోటోలను అప్‌లోడ్ చేయండి.", + "no_favorites_message": "మీ ఉత్తమ చిత్రాలు మరియు వీడియోలను త్వరగా కనుగొనడానికి ఇష్టమైన వాటిని జోడించండి", + "no_libraries_message": "మీ ఫోటోలు మరియు వీడియోలను వీక్షించడానికి బాహ్య లైబ్రరీని సృష్టించండి", + "no_name": "పేరు లేదు", + "no_places": "స్థలాలు లేవు", + "no_results": "ఫలితాలు లేవు", + "no_results_description": "పర్యాయపదం లేదా మరింత సాధారణ కీవర్డ్‌ని ప్రయత్నించండి", + "no_shared_albums_message": "మీ నెట్‌వర్క్‌లోని వ్యక్తులతో ఫోటోలు మరియు వీడియోలను భాగస్వామ్యం చేయడానికి ఆల్బమ్‌ను సృష్టించండి", + "not_in_any_album": "ఏ ఆల్బమ్‌లోనూ లేదు", + "note_unlimited_quota": "గమనిక: అపరిమిత కోటా కోసం 0ని నమోదు చేయండి", + "notes": "గమనికలు", + "notification_toggle_setting_description": "ఇమెయిల్ నోటిఫికేషన్‌లను ప్రారంభించండి", + "notifications": "నోటిఫికేషన్‌లు", + "notifications_setting_description": "నోటిఫికేషన్‌లను నిర్వహించండి", + "oauth": "OAuth", + "unsaved_change": "సేవ్ చేయని మార్పు", + "unselect_all": "ఎంచుకున్నవన్నీ తొలగించు", + "unselect_all_duplicates": "అన్ని నకిలీల ఎంపికను తీసివేయండి", + "unstack": "అన్-స్టాక్", + "untracked_files": "అన్‌ట్రాక్ చేయబడిన ఫైల్‌లు", + "untracked_files_decription": "ఈ ఫైల్‌లు అప్లికేషన్ ద్వారా ట్రాక్ చేయబడవు. అవి విఫలమైన కదలికలు, అంతరాయం కలిగించిన అప్‌లోడ్‌లు లేదా బగ్ కారణంగా మిగిలిపోయిన ఫలితాలు కావచ్చు", + "up_next": "తదుపరి", + "updated_password": "నవీకరించబడిన పాస్‌వర్డ్", + "upload": "అప్‌లోడ్", + "upload_concurrency": "కాన్కరెన్సీని అప్‌లోడ్", + "upload_status_duplicates": "నకిలీలు", + "upload_status_errors": "లోపాలు", + "upload_status_uploaded": "అప్‌లోడ్ చేయబడింది", + "upload_success": "అప్‌లోడ్ విజయవంతమైంది, కొత్త అప్‌లోడ్ ఆస్తులను చూడటానికి పేజీని రిఫ్రెష్ చేయండి.", + "url": "URL", + "usage": "వాడుక", + "use_custom_date_range": "బదులుగా అనుకూల తేదీ పరిధిని ఉపయోగించండి", + "user": "విన్యోగధారి", + "user_id": "విన్యోగధారి గుర్తింపు", + "user_purchase_settings": "కొనుగోలు", + "user_purchase_settings_description": "మీ కొనుగోలును నిర్వహించండి", + "user_usage_detail": "వినియోగదారు వినియోగ వివరాలు", + "username": "వినియోగదారి పేరు", + "users": "వినియోగదారులు", + "utilities": "యుటిలిటీస్", + "validate": "ధృవీకరించండి", + "variables": "వేరియబుల్స్", + "video": "వీడియో", + "video_hover_setting": "థంబ్‌నెయిల్ పైనా హోవర్ చేయగానే వీడియో ప్లే చెయ్", + "video_hover_setting_description": "థంబ్‌నెయిల్ పైనా హోవర్ చేయగానే చిహ్నం ప్లే చేయు. నిలిపివేయబడినప్పటికీ, ప్లే చిహ్నంపై హోవర్ చేయడం ద్వారా ప్లేబ్యాక్ ప్రారంభించబడుతుంది.", + "videos": "వీడియోలు", + "view": "చూడండి", + "view_album": "ఆల్బమ్‌ని వీక్షించండి", + "view_all": "అన్నీ వీక్షించండి", + "view_all_users": "వినియోగదారులందరినీ వీక్షించండి", + "view_links": "లింక్‌లను వీక్షించండి", + "view_next_asset": "తదుపరి ఆస్తిని వీక్షించండి", + "view_previous_asset": "మునుపటి ఆస్తిని వీక్షించండి", + "view_stack": "స్టాక్ చూడండి", + "waiting": "వేచి ఉంది", + "warning": "హెచ్చరిక", + "week": "వారం", + "welcome": "స్వాగతం" +} diff --git a/web/src/lib/i18n/tr.json b/web/src/lib/i18n/tr.json index 9e1e9acd41..2960af9ff5 100644 --- a/web/src/lib/i18n/tr.json +++ b/web/src/lib/i18n/tr.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Paylaşılan albüme ekle", "added_to_archive": "Arşive eklendi", "added_to_favorites": "Favorilere eklendi", - "added_to_favorites_count": "{count} fotoğraf favorilere eklendi", + "added_to_favorites_count": "{count, number} fotoğraf favorilere eklendi", "admin": { "add_exclusion_pattern_description": "Dışlama desenleri ekleyin. *, ** ve ? kullanılarak globbing desteklenir. Herhangi bir \"Raw\" adlı dizindeki tüm dosyaları yoksaymak için \"**/Raw/**\" kullanın. \".tif\" ile biten tüm dosyaları yoksaymak için \"**/*.tif\" kullanın. Mutlak yolu yoksaymak için \"/path/to/ignore/**\" kullanın.", "authentication_settings": "Yetkilendirme ayarları", @@ -112,7 +112,7 @@ "machine_learning_max_recognition_distance": "Maksimum tanıma uzaklığı", "machine_learning_max_recognition_distance_description": "İki suretin aynı kişi olarak kabul edildiği azami benzerlik oranı; 0-2 aralığında bir değerdir. Düşük değerler iki farklı kişinin sehven aynı kişi olarak algılanmasını engeller ama aynı kişinin farklı pozlarının farklı suretler olarak algılanmasına sebep olabilir. İki sureti birleştirmek daha kolay olduğu için mümkün olduğunca düşük değerler seçin.", "machine_learning_min_detection_score": "Minimum tespit skoru", - "machine_learning_min_detection_score_description": "Bir suretin algılanması için gerekli asgari kararlılık miktarı; 0-1 aralığında bir değerdir. Düşük değerler daha fazla suret tanır ama hatalı tanıma oranı artar.", + "machine_learning_min_detection_score_description": "Bir yüzün algılanması için gerekli asgari kararlılık miktarı; 0-1 aralığında bir değerdir. Düşük değerler daha fazla yüz tanır ama hatalı tanıma oranı artar.", "machine_learning_min_recognized_faces": "Minimum tanınan yüzler", "machine_learning_min_recognized_faces_description": "Kişi oluşturulması için gereken minimum yüzler. Bu değeri yükseltmek yüz tanıma doğruluğunu arttırır fakat yüzün bir kişiye atanmama olasılığını arttırır.", "machine_learning_settings": "Makine Öğrenmesi ayarları", @@ -258,18 +258,18 @@ "transcoding_codecs_learn_more": "Buradaki terminolojiyi öğrenmek için FFmpeg dokümantasyonlarına bakabilirsiniz: H.264, HEVC ve VP9.", "transcoding_constant_quality_mode": "Sabit kalite modu", "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", + "transcoding_constant_rate_factor": "Sabit oran faktörü (-SOF)", + "transcoding_constant_rate_factor_description": "Video kalite seviyesi. Tipik değerler H.264 için 23, HEVC için 28, VP9 için 31 ve AV1 için 35'tir. Daha düşük değerler daha iyi kalite sağlar, ancak daha büyük dosyalar üretir.", + "transcoding_disabled_description": "Videoları dönüştürmeyin, bazı istemcilerde oynatma bozulabilir", "transcoding_hardware_acceleration": "Donanım Hızlandırma", "transcoding_hardware_acceleration_description": "Deneysel; daha hızlı, fakat aynı bitrate ayarlarında daha düşük kaliteye sahip", "transcoding_hardware_decoding": "Donanım çözücü", "transcoding_hardware_decoding_setting_description": "Sadece NVENC, QSV ve RKMPP için geçerli. Sadece işlemeyi hızlandırmak yerine uçtan uca hızlandırmayı etkinleştirir. Tüm videolarda çalışmayabilir.", "transcoding_hevc_codec": "HEVC kodek", "transcoding_max_b_frames": "Maksimum B-kareler", - "transcoding_max_b_frames_description": "", + "transcoding_max_b_frames_description": "Daha yüksek değerler sıkıştırma verimliliğini artırır, ancak kodlamayı yavaşlatır. Eski cihazlarda donanım hızlandırma ile uyumlu olmayabilir. 0, B-çerçevelerini devre dışı bırakır, -1 ise bu değeri otomatik olarak ayarlar.", "transcoding_max_bitrate": "Maksimum bitrate", - "transcoding_max_bitrate_description": "", + "transcoding_max_bitrate_description": "Maksimum bit hızı ayarlamak, kaliteye küçük bir maliyetle dosya boyutlarını daha öngörülebilir hale getirebilir.", "transcoding_max_keyframe_interval": "", "transcoding_max_keyframe_interval_description": "", "transcoding_optimal_description": "", diff --git a/web/src/lib/i18n/uk.json b/web/src/lib/i18n/uk.json index 33af6f13bb..0b8241d89e 100644 --- a/web/src/lib/i18n/uk.json +++ b/web/src/lib/i18n/uk.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Додати у спільний альбом", "added_to_archive": "Додано до архіву", "added_to_favorites": "Додано до обраного", - "added_to_favorites_count": "Додано {count} до обраного", + "added_to_favorites_count": "Додано {count, number} до обраного", "admin": { "add_exclusion_pattern_description": "Додайте шаблони виключень. Підстановка з використанням *, ** та ? підтримується. Для ігнорування всіх файлів у будь-якому каталозі з ім'ям «Raw», використовуйте \"**/Raw/**\". Для ігнорування всіх файлів, що закінчуються на \".tif\", використовуйте \"**/*.tif\". Для ігнорування абсолютного шляху використовуйте \"/path/to/ignore/**\".", "authentication_settings": "Налаштування аутентифікації", @@ -409,7 +409,7 @@ "bulk_delete_duplicates_confirmation": "Ви впевнені, що хочете масово видалити {count, plural, one {# дубльований ресурс} few {# дубльовані ресурси} other {# дубльованих ресурсів}}? Це дія залишить найбільший ресурс у кожній групі і остаточно видалить всі інші дублікати. Цю дію неможливо скасувати!", "bulk_keep_duplicates_confirmation": "Ви впевнені, що хочете залишити {count, plural, one {# дубльований ресурс} few {# дубльовані ресурси} other {# дубльованих ресурсів}}? Це дозволить вирішити всі групи дублікатів без видалення чого-небудь.", "bulk_trash_duplicates_confirmation": "Ви впевнені, що хочете викинути в кошик {count, plural, one {# дубльований ресурс} few {# дубльовані ресурси} other {# дубльованих ресурсів}} масово? Це залишить найбільший ресурс у кожній групі і викине в кошик всі інші дублікати.", - "buy": "Ліцензія на придбання", + "buy": "Придбайте Immich", "camera": "Камера", "camera_brand": "Марка камери", "camera_model": "Модель камери", @@ -588,6 +588,7 @@ "failed_to_load_asset": "Не вдалося завантажити ресурс", "failed_to_load_assets": "Не вдалося завантажити ресурси", "failed_to_load_people": "Не вдалося завантажити людей", + "failed_to_remove_product_key": "Не вдалося видалити ключ продукту", "failed_to_stack_assets": "Не вдалося згорнути ресурси", "failed_to_unstack_assets": "Не вдалося розгорнути ресурси", "import_path_already_exists": "Цей шлях імпорту вже існує.", @@ -741,7 +742,16 @@ "host": "Хост", "hour": "Година", "image": "Зображення", - "image_alt_text_date": "{date}", + "image_alt_text_date": "{isVideo, select, true {Відео} other {Зображення}} знято {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Відео} other {Зображення}} з {person1} зроблено {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Відео} other {Зображення}} з {person1} та {person2} зроблено {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Відео} other {Зображення}} з {person1}, {person2} і {person3} зроблено {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Відео} other {Зображення}} з {person1}, {person2} та ще {additionalCount, number} особами зроблено {date}", + "image_alt_text_date_place": "{isVideo, select, true {Відео} other {Зображення}} зроблено в {city}, {country} {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Відео} other {Зображення}} зроблено в {city}, {country} з {person1} {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Відео} other {Зображення}} зроблено в {city}, {country} з {person1} та {person2} {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Відео} other {Зображення}} зроблено в {city}, {country} з {person1}, {person2} та {person3} {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Відео} other {Зображення}} зроблено в {city}, {country} з {person1}, {person2} та ще {additionalCount, number} особами {date}", "image_alt_text_people": "{count, plural, =1 {з {person1}} =2 {з {person1} та {person2}} =3 {з {person1}, {person2}, та {person3}} other {з {person1}, {person2}, та {others, number} ін.}}", "image_alt_text_place": "у {city}, {country}", "image_taken": "{isVideo, select, true {Зняте відео} other {Зроблений знімок}}", @@ -862,6 +872,7 @@ "name": "Ім'я", "name_or_nickname": "Ім'я або псевдонім", "never": "ніколи", + "new_album": "Новий альбом", "new_api_key": "Новий ключ API", "new_password": "Новий пароль", "new_person": "Нова людина", @@ -906,6 +917,7 @@ "online": "Доступний", "only_favorites": "Лише обрані", "only_refreshes_modified_files": "Оновлює лише змінені файли", + "open_in_map_view": "Відкрити у перегляді мапи", "open_in_openstreetmap": "Відкрити в OpenStreetMap", "open_the_search_filters": "Відкрийте фільтри пошуку", "options": "Налаштування", @@ -975,7 +987,40 @@ "profile_picture_set": "Зображення профілю встановлено.", "public_album": "Публічний альбом", "public_share": "Публічний доступ", + "purchase_account_info": "Підтримка", + "purchase_activated_subtitle": "Дякуємо за підтримку Immich та програмного забезпечення з відкритим кодом", + "purchase_activated_time": "Активовано {date, date}", + "purchase_activated_title": "Ваш ключ було успішно активовано", + "purchase_button_activate": "Активувати", + "purchase_button_buy": "Купити", + "purchase_button_buy_immich": "Купити Immich", + "purchase_button_never_show_again": "Ніколи більше не показувати", + "purchase_button_reminder": "Нагадати через 30 днів", + "purchase_button_remove_key": "Видалити ключ", + "purchase_button_select": "Обрати", + "purchase_failed_activation": "Не вдалося активувати! Будь ласка, перевірте свою електронну пошту для отримання правильного ключа продукту!", + "purchase_individual_description_1": "Для індивідуального використання", + "purchase_individual_description_2": "Статус підтримки", + "purchase_individual_title": "Індивідуальний", + "purchase_input_suggestion": "Маєте ключ продукту? Введіть ключ нижче", + "purchase_license_subtitle": "Купіть Immich, щоб підтримати подальший розвиток сервісу", + "purchase_lifetime_description": "Назавжди", + "purchase_option_title": "ВАРІАНТИ КУПІВЛІ", + "purchase_panel_info_1": "Розробка Immich вимагає багато часу та зусиль. Ми маємо штатних інженерів, які працюють над тим, щоб зробити його якомога кращим. Наша місія — зробити програмне забезпечення з відкритим кодом та етичні бізнес-практики стійким джерелом доходу для розробників і створити екосистему, що поважає приватність, з реальними альтернативами експлуататорським хмарним сервісам.", + "purchase_panel_info_2": "Оскільки ми зобов'язалися не додавати платних блокувань, ця покупка не надасть вам додаткових функцій у Immich. Ми покладаємося на користувачів, таких як ви, щоб підтримувати постійний розвиток Immich.", + "purchase_panel_title": "Підтримати проєкт", + "purchase_per_server": "На сервер", + "purchase_per_user": "На користувача", + "purchase_remove_product_key": "Видалити ключ продукту", + "purchase_remove_product_key_prompt": "Ви впевнені, що хочете видалити ключ продукту?", + "purchase_remove_server_product_key": "Видалити ключ продукту для сервера", + "purchase_remove_server_product_key_prompt": "Ви впевнені, що хочете видалити ключ продукту для сервера?", + "purchase_server_description_1": "Для всього сервера", + "purchase_server_description_2": "Статус підтримки", + "purchase_server_title": "Сервер", + "purchase_settings_server_activated": "Ключ продукту сервера керується адміністратором", "range": "", + "rating": "Зоряний рейтинг", "raw": "", "reaction_options": "Опції реакції", "read_changelog": "Прочитати зміни в оновленні", @@ -1020,6 +1065,7 @@ "reset_people_visibility": "Відновити видимість людей", "reset_settings_to_default": "", "reset_to_default": "Скидання до налаштувань за замовчуванням", + "resolve_duplicates": "Усунути дублікати", "resolved_all_duplicates": "Усі дублікати усунуто", "restore": "Відновити", "restore_all": "Відновити все", @@ -1064,6 +1110,7 @@ "see_all_people": "Переглянути всіх людей", "select_album_cover": "Обрати обкладинку альбому", "select_all": "Вибрати все", + "select_all_duplicates": "Вибрати всі дублікати", "select_avatar_color": "Вибрати колір аватара", "select_face": "Виберіть обличчя", "select_featured_photo": "Обрати обране фото", @@ -1118,6 +1165,8 @@ "show_person_options": "Показати параметри людини", "show_progress_bar": "Показати індикатор прогресу", "show_search_options": "Показати параметри пошуку", + "show_supporter_badge": "Значок підтримки", + "show_supporter_badge_description": "Показати значок підтримки", "shuffle": "Перемішати", "sign_out": "Вихід", "sign_up": "Зареєструватися", @@ -1171,7 +1220,7 @@ "total_usage": "Загальне використання", "trash": "Кошик", "trash_all": "Видалити все", - "trash_count": "Сміття {count}", + "trash_count": "Видалити {count, number}", "trash_delete_asset": "Смітник/Видалити ресурс", "trash_no_results_message": "Тут з'являтимуться видалені фото та відео.", "trashed_items_will_be_permanently_deleted_after": "Видалені елементи будуть остаточно видалені через {days, plural, one {# день} few {# дні} many {# днів} other {# днів}}.", @@ -1191,6 +1240,7 @@ "unnamed_share": "Спільний доступ без назви", "unsaved_change": "Незбережена зміна", "unselect_all": "Зняти все", + "unselect_all_duplicates": "Скасувати вибір усіх дублікатів", "unstack": "Розібрати стек", "unstacked_assets_count": "Розгорнути {count, plural, one {# ресурс} few {# ресурси} many {# ресурсів} other {# ресурсів}}", "untracked_files": "Файли, що не відстежуються", @@ -1200,7 +1250,7 @@ "upload": "Завантажити", "upload_concurrency": "Паралельність завантаження", "upload_errors": "Завантаження завершено з {count, plural, one {# помилкою} few {# помилками} many {# помилками} other {# помилками}}, оновіть сторінку, щоб побачити нові завантажені ресурси.", - "upload_progress": "Залишилося {remaining} - Оброблено {processed}/{total}", + "upload_progress": "Залишилось {remaining, number} - Опрацьовано {processed, number}/{total, number}", "upload_skipped_duplicates": "Пропущено {count, plural, one {# дубльований ресурс} few {# дубльовані ресурси} many {# дубльованих ресурсів} other {# дубльованих ресурсів}}", "upload_status_duplicates": "Дублікати", "upload_status_errors": "Помилки", @@ -1214,6 +1264,8 @@ "user_license_settings": "Ліцензія", "user_license_settings_description": "Керування ліцензією", "user_liked": "{user} вподобав {type, select, photo {це фото} video {це відео} asset {цей ресурс} other {це}}", + "user_purchase_settings": "Придбати", + "user_purchase_settings_description": "Керувати вашою покупкою", "user_role_set": "Призначити {user} на роль {role}", "user_usage_detail": "Деталі використання користувача", "username": "Ім'я користувача", diff --git a/web/src/lib/i18n/vi.json b/web/src/lib/i18n/vi.json index d13d9fddd6..58fb4a85f3 100644 --- a/web/src/lib/i18n/vi.json +++ b/web/src/lib/i18n/vi.json @@ -22,23 +22,23 @@ "add_photos": "Thêm ảnh", "add_to": "Thêm vào...", "add_to_album": "Thêm vào album", - "add_to_shared_album": "Thêm vào album đã chia sẻ", + "add_to_shared_album": "Thêm vào album chia sẻ", "added_to_archive": "Đã thêm vào Kho lưu trữ", "added_to_favorites": "Đã thêm vào Mục yêu thích", "added_to_favorites_count": "Đã thêm {count, number} vào Mục yêu thích", "admin": { - "add_exclusion_pattern_description": "Thêm quy tắc loại trừ. Hỗ trợ sử dụng ký tự *, **, và ?. Để bỏ qua tất cả các tệp bất kỳ trong thư mục tên \"Raw\", hãy dùng \"**/Raw/**\". Để bỏ qua các tệp có đuôi \".tif\", hãy dùng \"**/*.tif\". Để bỏ qua một đường dẫn cố định, hãy dùng \"/path/to/ignore/**\".", - "authentication_settings": "Cài đặt đăng nhập", - "authentication_settings_description": "Quản lý mật khẩu, OAuth và các cài đặt đăng nhập khác", + "add_exclusion_pattern_description": "Thêm quy tắc loại trừ. Hỗ trợ sử dụng ký tự *, **, và ?. Để bỏ qua tất cả các tập tin bất kỳ trong thư mục tên \"Raw\", hãy dùng \"**/Raw/**\". Để bỏ qua các tập tin có đuôi \".tif\", hãy dùng \"**/*.tif\". Để bỏ qua một đường dẫn cố định, hãy dùng \"/path/to/ignore/**\".", + "authentication_settings": "Đăng nhập", + "authentication_settings_description": "Quản lý mật khẩu, OAuth và các cài đặt xác thực khác", "authentication_settings_disable_all": "Bạn có chắc chắn muốn vô hiệu hoá tất cả các phương thức đăng nhập? Đăng nhập sẽ bị vô hiệu hóa hoàn toàn.", - "authentication_settings_reenable": "Để bật lại, dùng Lệnh máy chủ.", + "authentication_settings_reenable": "Để bật lại, dùng Lệnh Máy chủ.", "background_task_job": "Các tác vụ nền", "check_all": "Chọn tất cả", "cleared_jobs": "Đã xoá các tác vụ: {job}", - "config_set_by_file": "Cấu hình hiện tại đang được đặt bởi tệp cấu hình", + "config_set_by_file": "Cấu hình hiện tại đang được đặt bởi một tập tin cấu hình", "confirm_delete_library": "Bạn có chắc chắn muốn xóa thư viện {library} không?", - "confirm_delete_library_assets": "Bạn có chắc chắn muốn xóa thư viện này không? Thao tác này sẽ xóa {count, plural, one {# ảnh được chứa} other {tất cả # ảnh được chứa}} khỏi Immich và không thể hoàn tác. Các tệp sẽ vẫn còn trên đĩa.", - "confirm_email_below": "Để xác nhận, nhập \"{email}\" ở dưới", + "confirm_delete_library_assets": "Bạn có chắc chắn muốn xóa thư viện này không? Thao tác này sẽ xóa {count, plural, one {# ảnh} other {tất cả # ảnh}} có trong Immich và không thể hoàn tác. Các tập tin sẽ vẫn còn trên ổ đĩa.", + "confirm_email_below": "Để xác nhận, nhập \"{email}\" bên dưới", "confirm_reprocess_all_faces": "Bạn có chắc chắn muốn xử lý lại tất cả các khuôn mặt? Thao tác này sẽ xoá tên người đã được gán.", "confirm_user_password_reset": "Bạn có chắc chắn muốn đặt lại mật khẩu của {user}?", "crontab_guru": "Crontab Guru", @@ -48,14 +48,14 @@ "exclusion_pattern_description": "Quy tắc loại trừ cho bạn bỏ qua các tập tin và thư mục khi quét thư viện của bạn. Điều này hữu ích nếu bạn có các thư mục chứa tập tin bạn không muốn nhập, chẳng hạn như các tập tin RAW.", "external_library_created_at": "Thư viện bên ngoài (được tạo vào {date})", "external_library_management": "Quản lý thư viện bên ngoài", - "face_detection": "Nhận diện khuôn mặt", + "face_detection": "Phát hiện khuôn mặt", "face_detection_description": "Sử dụng machine learning để phát hiện các khuôn mặt trong ảnh. Với video, chỉ thực hiện trên ảnh thu nhỏ. Xử lý lại tất cả các hình ảnh. Các hỉnh ảnh trong hàng đợi bị bỏ lỡ chưa được xử lý. Các khuôn mặt được phát hiện sẽ được xếp vào hàng đợi cho quá trình Nhận dạng khuôn mặt sau khi quá trình Phát hiện khuôn mặt hoàn tất, nhóm chúng vào người hiện có hoặc tạo người mới.", "facial_recognition_job_description": "Nhóm các khuôn mặt đã phát hiện thành người. Bước này được thực hiện sau khi Phát hiện khuôn mặt hoàn tất. Xử lý lại việc nhóm cho toàn bộ khuôn mặt. Các khuôn mặt trong hàng đợi bị bỏ lỡ chưa được gán cho người nào.", "failed_job_command": "Lệnh {command} không thực hiện được tác vụ: {job}", "force_delete_user_warning": "CẢNH BÁO: Thao tác này sẽ ngay lập tức xoá người dùng và tất cả ảnh. Hành động này không thể hoàn tác và các tập tin không thể khôi phục.", "forcing_refresh_library_files": "Làm mới toàn bộ thư viện ảnh", "image_format_description": "Định dạng WebP dung lượng nhỏ hơn JPEG, nhưng mã hóa chậm hơn.", - "image_prefer_embedded_preview": "Ưu tiên ảnh xem trước đã nhúng", + "image_prefer_embedded_preview": "Ưu tiên ảnh xem trước đi kèm", "image_prefer_embedded_preview_setting_description": "Ứng dụng sẽ sử dụng ảnh xem trước trong ảnh RAW khi có sẵn để xử lý hình ảnh. Điều này có thể giúp tái tạo màu sắc chính xác hơn cho một số hình ảnh, nhưng chất lượng của ảnh xem trước phụ thuộc vào máy ảnh và có thể bị nén.", "image_prefer_wide_gamut": "Ưu tiên gam màu mở rộng", "image_prefer_wide_gamut_setting_description": "Hiển thị ảnh thu nhỏ ở gam màu Display P3. Điều này giúp giữ màu sắc rực rỡ của những hình ảnh có gam màu rộng, nhưng hình ảnh có thể trông khác trên các thiết bị cũ và trình duyệt cũ. Hình ảnh sRGB được giữ nguyên để tránh thay đổi màu sắc.", @@ -63,34 +63,34 @@ "image_preview_resolution": "Độ phân giải xem trước", "image_preview_resolution_description": "Được sử dụng khi xem một bức ảnh và cho machine learning. Độ phân giải cao hơn có thể giữ lại nhiều chi tiết hơn nhưng mất nhiều thời gian mã hóa, có kích thước lớn hơn và có thể làm giảm khả năng phản hồi của ứng dụng.", "image_quality": "Chất lượng", - "image_quality_description": "Chất lượng hình ảnh từ 1 - 100. Giá trị càng cao hình ảnh đẹp hơn nhưng kích thước tệp sẽ lớn, lựa chọn này ảnh hưởng tới ảnh xem trước và ảnh thu nhỏ.", - "image_settings": "Cài đặt hình ảnh", + "image_quality_description": "Chất lượng hình ảnh từ 1 - 100. Giá trị càng cao hình ảnh đẹp hơn nhưng kích thước tập tin sẽ lớn, lựa chọn này ảnh hưởng tới ảnh xem trước và ảnh thu nhỏ.", + "image_settings": "Hình ảnh", "image_settings_description": "Quản lý chất lượng và độ phân giải của hình ảnh được tạo", "image_thumbnail_format": "Định dạng ảnh thu nhỏ", "image_thumbnail_resolution": "Độ phân giải ảnh thu nhỏ", "image_thumbnail_resolution_description": "Dùng khi xem một nhóm các ảnh (dòng thời gian chính, xem album, v.v.). Độ phân giải cao hơn có thể giữ lại nhiều chi tiết hơn nhưng mất nhiều thời gian mã hóa, có kích thước lớn hơn và có thể làm giảm khả năng phản hồi của ứng dụng.", "job_concurrency": "{job} thực hiện đồng thời", "job_not_concurrency_safe": "Tác vụ này không an toàn để thực hiện đồng thời.", - "job_settings": "Cài đặt tác vụ công việc", - "job_settings_description": "Quản lý tác vụ thực hiện đồng thời", + "job_settings": "Tác vụ", + "job_settings_description": "Quản lý mức độ thực hiện đồng thời của tác vụ", "job_status": "Trạng thái tác vụ", - "jobs_delayed": "{jobCount, plural, other {# bị hoãn lại}}", - "jobs_failed": "{jobCount, plural, other {# bị thất bại}}", - "library_created": "Thư viện được tạo: {library}", + "jobs_delayed": "{jobCount, plural, other {# tác vụ bị hoãn lại}}", + "jobs_failed": "{jobCount, plural, other {# tác vụ bị thất bại}}", + "library_created": "Đã tạo thư viện: {library}", "library_cron_expression": "Cú pháp Cron", "library_cron_expression_description": "Đặt lịch quét bằng định dạng cron. Để biết thêm thông tin, vui lòng tham khảo ví dụ. Crontab Guru", - "library_cron_expression_presets": "Thiết lập lịch quét", + "library_cron_expression_presets": "Các mẫu biểu thức Cron", "library_deleted": "Thư viện đã bị xoá", - "library_import_path_description": "Chọn thư mục để nhập. Ứng dụng sẽ quét tất cả hình ảnh và video trong thư mục này và các thư mục con.", + "library_import_path_description": "Chọn thư mục để nhập. Ứng dụng sẽ quét tất cả hình ảnh và video trong thư mục này bao gồm các thư mục con.", "library_scanning": "Quét định kỳ", "library_scanning_description": "Cấu hình quét thư viện định kỳ", "library_scanning_enable_description": "Bật quét thư viện định kỳ", "library_settings": "Thư viện bên ngoài", "library_settings_description": "Quản lý cài đặt thư viện bên ngoài", - "library_tasks_description": "Xử lý các tác vụ thư viện", - "library_watching_enable_description": "Tự động cập nhật các tệp bị thay đổi trong thư viện bên ngoài", + "library_tasks_description": "Thực hiện các tác vụ thư viện", + "library_watching_enable_description": "Tự động cập nhật các tập tin bị thay đổi trong thư viện bên ngoài", "library_watching_settings": "Theo dõi thư viện (THỬ NGHIỆM)", - "library_watching_settings_description": "Tự động cập nhật khi các tệp bị thay đổi", + "library_watching_settings_description": "Tự động cập nhật khi các tập tin bị thay đổi", "logging_enable_description": "Bật ghi nhật ký", "logging_level_description": "Khi được bật, thiết lập mức ghi nhật ký.", "logging_settings": "Ghi nhật ký", @@ -98,54 +98,54 @@ "machine_learning_clip_model_description": "Tên của mô hình CLIP được liệt kê tại đây. Bạn cần chạy lại tác vụ \"Tìm kiếm thông minh\" cho tất cả hình ảnh sau khi thay đổi mô hình.", "machine_learning_duplicate_detection": "Phát hiện ảnh trùng lặp", "machine_learning_duplicate_detection_enabled": "Bật phát hiện ảnh trùng lặp", - "machine_learning_duplicate_detection_enabled_description": "Nếu bị vô hiệu hoá, các ảnh trùng lặp giống hệt nhau vẫn sẽ bị loại bỏ.", + "machine_learning_duplicate_detection_enabled_description": "Nếu bị tắt, các ảnh trùng lặp giống hệt nhau vẫn sẽ bị loại bỏ.", "machine_learning_duplicate_detection_setting_description": "Sử dụng vector nhúng CLIP để tìm kiếm ảnh trùng lặp", "machine_learning_enabled": "Bật machine learning", - "machine_learning_enabled_description": "Nếu bị vô hiệu hoá, tất cả các tính năng ML sẽ bị vô hiệu hoá kể các cài đặt bên dưới.", + "machine_learning_enabled_description": "Nếu bị tắt, tất cả các tính năng ML sẽ bị vô hiệu hoá kể các cài đặt bên dưới.", "machine_learning_facial_recognition": "Nhận dạng khuôn mặt", "machine_learning_facial_recognition_description": "Phát hiện, nhận dạng và nhóm các khuôn mặt trong ảnh", "machine_learning_facial_recognition_model": "Mô hình nhận dạng khuôn mặt", - "machine_learning_facial_recognition_model_description": "Các mô hình được liệt kê theo thứ tự kích thước giảm dần. Mô hình càng lớn, kết quả càng chính xác nhưng sẽ chạy chậm và tốn nhiều bộ nhớ hơn. Lưu ý rằng sau khi thay đổi mô hình, bạn cần chạy lại tính năng \"Phát hiện Khuôn mặt\" cho tất cả hình ảnh.", + "machine_learning_facial_recognition_model_description": "Các mô hình được liệt kê theo thứ tự kích thước giảm dần. Mô hình càng lớn, kết quả càng chính xác nhưng sẽ chạy chậm và tốn nhiều bộ nhớ hơn. Lưu ý rằng sau khi thay đổi mô hình, bạn cần chạy lại tác vụ \"Phát hiện Khuôn mặt\" cho tất cả hình ảnh.", "machine_learning_facial_recognition_setting": "Bật nhận dạng khuôn mặt", - "machine_learning_facial_recognition_setting_description": "Nếu tính năng này bị vô hiệu hóa, hình ảnh sẽ không được mã hóa để nhận diện khuôn mặt và sẽ không xuất hiện trong phần Mọi người trong trang Khám phá.", + "machine_learning_facial_recognition_setting_description": "Nếu tính năng này bị tắt, hình ảnh sẽ không được mã hóa để nhận dạng khuôn mặt và sẽ không xuất hiện trong mục Mọi người trên trang Khám phá.", "machine_learning_max_detection_distance": "Khoảng cách phát hiện tối đa", "machine_learning_max_detection_distance_description": "Khoảng cách tối đa để hai ảnh được coi là trùng lặp, dao động từ 0,001 đến 0,1. Giá trị càng cao sẽ phát hiện được nhiều ảnh trùng lặp hơn, nhưng có thể bao gồm cả ảnh không thực sự giống nhau.", "machine_learning_max_recognition_distance": "Khoảng cách nhận dạng tối đa", - "machine_learning_max_recognition_distance_description": "Khoảng cách tối đa để hai khuôn mặt được coi là cùng một người, dao động từ 0-2. Giảm giá trị này có thể ngăn chặn việc gán nhãn hai người cùng một người, trong khi tăng giá trị này có thể ngăn chặn việc gán nhãn cùng một người là hai người khác nhau. Lưu ý rằng việc gộp hai người lại với nhau dễ dàng hơn là tách một người thành hai, vì vậy hãy ưu tiên giá trị thấp khi có thể.", - "machine_learning_min_detection_score": "Hệ số nhận dạng tối thiểu", - "machine_learning_min_detection_score_description": "Hệ số tự tin tối thiểu để khuôn mặt được phát hiện, từ 0 - 1. Hệ số càng thấp, nhiều khuôn mặt sẽ được nhận diện hơn nhưng có thể xảy ra sai sót.", - "machine_learning_min_recognized_faces": "Số khuôn mặt nhận được tối thiểu", - "machine_learning_min_recognized_faces_description": "Tối thiểu bao nhiêu khuôn mặt được nhận diện để tạo một người. Tăng giá trị này sẽ khiến cho Nhận diện Khuôn mặt chính xác hơn, nhưng sẽ tăng khả năng một khuôn mặt sẽ không được gán với 1 người.", - "machine_learning_settings": "Cài đặt Machine Learning", + "machine_learning_max_recognition_distance_description": "Khoảng cách tối đa để hai khuôn mặt được coi là cùng một người, dao động từ 0-2. Giảm giá trị này có thể ngăn chặn việc gán nhãn hai người cùng một người, trong khi tăng giá trị này có thể ngăn chặn việc gán nhãn cùng một người là hai người khác nhau. Lưu ý rằng việc hợp nhất hai người lại với nhau dễ dàng hơn là tách một người thành hai, vì vậy hãy ưu tiên giá trị thấp khi có thể.", + "machine_learning_min_detection_score": "Mức phát hiện tối thiểu", + "machine_learning_min_detection_score_description": "Mức điểm tin cậy tối thiểu để phát hiện khuôn mặt, từ 0 đến 1. Giá trị càng thấp, nhiều khuôn mặt sẽ được phát hiện nhưng có thể tăng khả năng phát hiện sai.", + "machine_learning_min_recognized_faces": "Số khuôn mặt tối thiểu để nhận dạng", + "machine_learning_min_recognized_faces_description": "Số khuôn mặt tối thiểu cần nhận dạng để tạo thành một người. Tăng số lượng này sẽ làm cho Nhận dạng khuôn mặt chính xác hơn, nhưng sẽ tăng khả năng một khuôn mặt không được gán cho người phù hợp.", + "machine_learning_settings": "Machine Learning", "machine_learning_settings_description": "Quản lý các tính năng và cài đặt của machine learning", "machine_learning_smart_search": "Tìm kiếm thông minh", - "machine_learning_smart_search_description": "Tìm kiếm hình ảnh theo ngữ nghĩa với CLIP", + "machine_learning_smart_search_description": "Tìm kiếm hình ảnh theo ngữ cảnh với CLIP", "machine_learning_smart_search_enabled": "Bật tìm kiếm thông minh", - "machine_learning_smart_search_enabled_description": "Nếu vô hiệu hoá, hình ảnh sẽ không được mã hoá để tìm kiếm thông minh.", + "machine_learning_smart_search_enabled_description": "Nếu tắt, hình ảnh sẽ không được mã hoá để tìm kiếm thông minh.", "machine_learning_url_description": "Địa chỉ máy chủ machine learning", "manage_concurrency": "Quản lý tác vụ", "manage_log_settings": "Quản lý cài đặt nhật ký", "map_dark_style": "Giao diện tối", "map_enable_description": "Bật tính năng bản đồ", - "map_gps_settings": "Cài đặt bản đồ & GPS", - "map_gps_settings_description": "Quản lý cài đặt bản đồ & GPS (Mã hóa địa lý đảo ngược)", + "map_gps_settings": "Bản đồ & GPS", + "map_gps_settings_description": "Quản lý cài đặt Bản đồ & GPS (Mã hóa địa lý ngược)", "map_light_style": "Giao diện sáng", - "map_manage_reverse_geocoding_settings": "Quản lý cài đặtMã hóa Địa lý Đảo ngược (Reverse Geocoding)", - "map_reverse_geocoding": "Mã hoá Địa lý Đảo ngược", - "map_reverse_geocoding_enable_description": "Bật mã hoá địa lý đảo ngược", - "map_reverse_geocoding_settings": "Cài đặt Mã hoá Địa lý Đảo ngược", - "map_settings": "Cài đặt Bản đồ", - "map_settings_description": "Quản lý các cài đặt bản đồ", - "map_style_description": "Đường dẫn URL đến file tuỳ biến bản đồ style.json", + "map_manage_reverse_geocoding_settings": "Quản lý cài đặt Mã hóa địa lý ngược", + "map_reverse_geocoding": "Mã hoá địa lý ngược (Reverse Geocoding)", + "map_reverse_geocoding_enable_description": "Bật mã hoá địa lý ngược", + "map_reverse_geocoding_settings": "Mã hoá địa lý ngược (Reverse Geocoding)", + "map_settings": "Bản đồ", + "map_settings_description": "Quản lý cài đặt bản đồ", + "map_style_description": "Đường dẫn URL đến tập tin tuỳ biến bản đồ style.json", "metadata_extraction_job": "Trích xuất metadata", - "metadata_extraction_job_description": "Trích xuất metadata từ mỗi ảnh, ví dụ như GPS và kích thước", + "metadata_extraction_job_description": "Trích xuất metadata từ mỗi ảnh, chẳng hạn như GPS và độ phân giải", "migration_job": "Di chuyển dữ liệu", - "migration_job_description": "Di chuyển hình thu nhỏ của nội dung và khuôn mặt sang cấu trúc thư mục mới nhất", + "migration_job_description": "Di chuyển hình thu nhỏ của các ảnh và khuôn mặt sang cấu trúc thư mục mới", "no_paths_added": "Không có đường dẫn nào được thêm vào", - "no_pattern_added": "Không có mẫu nào được thêm vào", + "no_pattern_added": "Không có quy tắc nào được thêm vào", "note_apply_storage_label_previous_assets": "Lưu ý: Để áp dụng Nhãn lưu trữ cho nội dung đã tải lên trước đó, hãy chạy", "note_cannot_be_changed_later": "LƯU Ý: Cài đặt này không thể thay đổi được sau khi lưu!", - "note_unlimited_quota": "Lưu ý: Nhập 0 để không giới hạn", + "note_unlimited_quota": "Lưu ý: Nhập 0 để hạn mức không giới hạn", "notification_email_from_address": "Địa chỉ email người gửi", "notification_email_from_address_description": "Địa chỉ email của người gửi, ví dụ: \"Immich Photo Server \"", "notification_email_host_description": "Địa chỉ máy chủ email (ví dụ: smtp.immich.app)", @@ -153,14 +153,14 @@ "notification_email_ignore_certificate_errors_description": "Bỏ qua lỗi xác thực chứng chỉ TLS (không khuyến nghị)", "notification_email_password_description": "Mật khẩu dùng để xác thực với máy chủ email", "notification_email_port_description": "Cổng của máy chủ email (ví dụ 25, 465, hoặc 587)", - "notification_email_sent_test_email_button": "Gửi email thử nghiệm và lưu", + "notification_email_sent_test_email_button": "Gửi email kiểm tra và lưu", "notification_email_setting_description": "Cài đặt gửi thông báo qua email", - "notification_email_test_email": "Gửi email thử nghiệm", - "notification_email_test_email_failed": "Gửi email thử nghiệm thất bại, vui lòng kiểm tra các thông tin của bạn", + "notification_email_test_email": "Đã gửi email kiểm tra", + "notification_email_test_email_failed": "Gửi email thử nghiệm thất bại, vui lòng kiểm tra các giá trị của bạn", "notification_email_test_email_sent": "Một email thử nghiệm đã được gửi tới {email}. Vui lòng kiểm tra hộp thư của bạn.", "notification_email_username_description": "Tên đăng nhập email để xác thực với máy chủ email", "notification_enable_email_notifications": "Bật thông báo qua email", - "notification_settings": "Cài đặt thông báo", + "notification_settings": "Thông báo", "notification_settings_description": "Quản lý các cài đặt thông báo, bao gồm email", "oauth_auto_launch": "Tự động khởi chạy OAuth", "oauth_auto_launch_description": "Tự động đăng nhập bằng tài khoản OAuth khi bạn truy cập trang đăng nhập", @@ -173,22 +173,22 @@ "oauth_issuer_url": "Địa chỉ nhà cung cấp OAuth", "oauth_mobile_redirect_uri": "URI chuyển hướng trên thiết bị di động", "oauth_mobile_redirect_uri_override": "Ghi đè URI chuyển hướng cho thiết bị di động", - "oauth_mobile_redirect_uri_override_description": "Bật khi URI chuyển hướng 'app.immich:/' không hợp lệ.", - "oauth_profile_signing_algorithm": "Thuật toán ký hồ sơ người dùng", - "oauth_profile_signing_algorithm_description": "Thuật toán được sử dụng để ký hồ sơ người dùng.", + "oauth_mobile_redirect_uri_override_description": "Bật khi 'app.immich:/' là URI chuyển hướng không hợp lệ.", + "oauth_profile_signing_algorithm": "Thuật toán ký vào hồ sơ người dùng", + "oauth_profile_signing_algorithm_description": "Thuật toán được sử dụng để ký vào hồ sơ người dùng.", "oauth_scope": "Phạm vi", "oauth_settings": "OAuth", "oauth_settings_description": "Quản lý cài đặt đăng nhập OAuth", "oauth_settings_more_details": "Để biết thêm chi tiết về tính năng này, hãy tham khảo tài liệu.", "oauth_signing_algorithm": "Thuật toán ký", "oauth_storage_label_claim": "Claim cho nhãn lưu trữ", - "oauth_storage_label_claim_description": "Tự động gán nhãn cho nơi lưu trữ của người dùng theo giá trị của claim này.", + "oauth_storage_label_claim_description": "Tự động đặt nhãn lưu trữ của người dùng theo giá trị của claim này.", "oauth_storage_quota_claim": "Claim cho hạn mức lưu trữ", - "oauth_storage_quota_claim_description": "Tự động đặt dung lượng lưu trữ của người dùng theo giá trị của claim này.", + "oauth_storage_quota_claim_description": "Tự động đặt hạn mức lưu trữ của người dùng theo giá trị của claim này.", "oauth_storage_quota_default": "Hạn mức lưu trữ mặc định (GiB)", - "oauth_storage_quota_default_description": "Hạn mức (GiB) sẽ được sử dụng khi không có yêu cầu nào được cung cấp (Nhập 0 để không giới hạn).", + "oauth_storage_quota_default_description": "Hạn mức (GiB) sẽ được sử dụng khi không có yêu cầu nào được cung cấp (Nhập 0 để hạn mức không giới hạn).", "offline_paths": "Các đường dẫn ngoại tuyến", - "offline_paths_description": "Những đường dẫn này có thể do những file không nằm trong nơi lưu trữ ngoài bị xoá thủ công.", + "offline_paths_description": "Những đường dẫn này có thể là do việc xóa thủ công các tập tin không thuộc thư viện bên ngoài.", "password_enable_description": "Đăng nhập với email và mật khẩu", "password_settings": "Mật khẩu đăng nhập", "password_settings_description": "Quản lý cài đặt mật khẩu đăng nhập", @@ -197,147 +197,147 @@ "refreshing_all_libraries": "Làm mới tất cả các thư viện", "registration": "Đăng ký Quản trị viên", "registration_description": "Vì bạn là người dùng đầu tiên, bạn sẽ trở thành Quản trị viên và chịu trách nhiệm cho việc quản lý hệ thống. Ngoài ra, bạn có thể thêm các người dùng khác.", - "removing_offline_files": "Đang xoá các tệp ngoại tuyến", + "removing_offline_files": "Đang xoá các tập tin ngoại tuyến", "repair_all": "Sửa chữa tất cả", - "repair_matched_items": "Đã tìm thấy {count, plural, one {# một} other {# các}} file trùng khớp", - "repaired_items": "Đã khôi phục {count, plural, one{# một} other {# các}} file", + "repair_matched_items": "Đã tìm thấy {count, plural, one {# mục} other {# mục}} trùng khớp", + "repaired_items": "Đã sửa chữa {count, plural, one{# mục} other {# mục}}", "require_password_change_on_login": "Yêu cầu người dùng thay đổi mật khẩu trong lần đăng nhập đầu tiên", "reset_settings_to_default": "Đặt lại cài đặt về mặc định", "reset_settings_to_recent_saved": "Đặt lại cài đặt về cài đặt trước đó", - "scanning_library_for_changed_files": "Đang quét thư viện để tìm các tệp đã thay đổi", - "scanning_library_for_new_files": "Đang quét thư viện để tìm các tệp mới", + "scanning_library_for_changed_files": "Đang quét thư viện để tìm các tập tin đã thay đổi", + "scanning_library_for_new_files": "Đang quét thư viện để tìm các tập tin mới", "send_welcome_email": "Gửi email chào mừng", "server_external_domain_settings": "Tên miền công khai", - "server_external_domain_settings_description": "Tên miền dành cho các liên kết được chia sẻ công khai, bao gồm http(s)://", - "server_settings": "Cài đặt máy chủ", + "server_external_domain_settings_description": "Tên miền dành cho các liên kết chia sẻ công khai, bao gồm http(s)://", + "server_settings": "Máy chủ", "server_settings_description": "Quản lý cài đặt máy chủ", - "server_welcome_message": "Tin nhắn chào mừng", - "server_welcome_message_description": "Thêm tin nhắn được hiển thị trên trang đăng nhập.", - "sidecar_job": "Siêu dữ liệu đi kèm", - "sidecar_job_description": "Tìm hoặc đồng bộ các file metadata sidecar từ hệ thống", - "slideshow_duration_description": "Số giây để hiển thị mỗi hình ảnh", + "server_welcome_message": "Thông điệp chào mừng", + "server_welcome_message_description": "Thông điệp chào mừng được hiển thị trên trang đăng nhập.", + "sidecar_job": "Metadata đi kèm", + "sidecar_job_description": "Tìm hoặc đồng bộ các tập tin metadata đi kèm từ hệ thống", + "slideshow_duration_description": "Số giây để hiển thị cho từng ảnh", "smart_search_job_description": "Chạy machine learning trên toàn bộ ảnh để hỗ trợ tìm kiếm thông minh", - "storage_template_date_time_description": "Dấu thời gian tạo tệp tin được sử dụng cho thông tin ngày giờ", + "storage_template_date_time_description": "Dấu thời gian tạo ảnh được sử dụng cho thông tin ngày giờ", "storage_template_date_time_sample": "Thời gian mẫu {date}", "storage_template_enable_description": "Bật công cụ mẫu lưu trữ", "storage_template_hash_verification_enabled": "Bật xác minh băm", "storage_template_hash_verification_enabled_description": "Bật xác minh băm, không tắt tính năng này trừ khi bạn chắc chắn về các rủi ro có thể xảy ra", "storage_template_migration": "Dịch chuyển mẫu lưu trữ", - "storage_template_migration_description": "Áp dụng {template} hiện tại cho các tệp tin đã được tải lên trước đây", - "storage_template_migration_info": "Các thay đổi mẫu chỉ áp dụng cho các tệp tin mới. Để áp dụng mẫu một cách ngược lại cho các tệp tin đã được tải lên trước đây, hãy chạy {job}.", - "storage_template_migration_job": "Công việc dịch chuyển mẫu lưu trữ", - "storage_template_more_details": "Cần thêm thông tin chi tiết về tính năng này, vui lòng tham khảo Mẫu Lưu trữ và các hệ quả của nó", - "storage_template_onboarding_description": "Khi được bật, tính năng này sẽ tự động tổ chức các tệp tin dựa trên mẫu do người dùng định nghĩa. Do các vấn đề về tính ổn định, tính năng này đã bị tắt theo mặc định. Để biết thêm thông tin, vui lòng xem tài liệu.", + "storage_template_migration_description": "Áp dụng {template} hiện tại cho các ảnh đã được tải lên trước đây", + "storage_template_migration_info": "Các thay đổi mẫu chỉ áp dụng cho các ảnh mới. Để áp dụng lại mẫu cho các ảnh đã được tải lên trước đây, hãy chạy {job}.", + "storage_template_migration_job": "Tác vụ di chuyển mẫu lưu trữ", + "storage_template_more_details": "Cần thêm thông tin chi tiết về tính năng này, vui lòng tham khảo Mẫu lưu trữ và các hệ quả của nó", + "storage_template_onboarding_description": "Khi được bật, tính năng này sẽ tự động sắp xếp các tập tin dựa trên mẫu do người dùng định nghĩa. Do các vấn đề về độ ổn định nên tính năng này đã bị tắt theo mặc định. Để biết thêm thông tin, vui lòng xem tài liệu.", "storage_template_path_length": "Giới hạn độ dài đường dẫn xấp xỉ: {length, number}/{limit, number}", - "storage_template_settings": "Mẫu Lưu trữ", - "storage_template_settings_description": "Quản lý cấu trúc thư mục và tên tệp của nội dung tải lên", - "storage_template_user_label": "Cụm từ {label} là Nhãn Lưu trữ của người dùng", + "storage_template_settings": "Mẫu lưu trữ", + "storage_template_settings_description": "Quản lý cấu trúc thư mục và tên tập tin của ảnh tải lên", + "storage_template_user_label": "Cụm từ {label} là Nhãn lưu trữ của người dùng", "system_settings": "Cài đặt hệ thống", - "theme_custom_css_settings": "Tuỳ chỉnh CSS", + "theme_custom_css_settings": "CSS tùy chỉnh", "theme_custom_css_settings_description": "Cascading Style Sheets cho phép tùy chỉnh thiết kế của Immich.", - "theme_settings": "Cài đặt chủ đề", + "theme_settings": "Chủ đề", "theme_settings_description": "Quản lý tùy chỉnh giao diện web của Immich", - "these_files_matched_by_checksum": "Các tệp tin này khớp với các giá trị băm của chúng", - "thumbnail_generation_job": "Tạo Hình thu nhỏ", - "thumbnail_generation_job_description": "Tạo hình thu nhỏ lớn, nhỏ và mờ cho mỗi tệp tin, cũng như hình thu nhỏ cho mỗi người", + "these_files_matched_by_checksum": "Các tập tin này khớp với các giá trị băm của chúng", + "thumbnail_generation_job": "Tạo hình thu nhỏ", + "thumbnail_generation_job_description": "Tạo hình thu nhỏ lớn, nhỏ và mờ cho mỗi ảnh, cũng như hình thu nhỏ cho mỗi người", "transcode_policy_description": "", "transcoding_acceleration_api": "API Tăng tốc", - "transcoding_acceleration_api_description": "API này sẽ tương tác với thiết bị của bạn để tăng tốc quá trình chuyển mã. Cài đặt này là 'cố gắng tốt nhất': nó sẽ quay lại chuyển mã phần mềm nếu gặp lỗi. VP9 có thể hoạt động hoặc không tùy thuộc vào phần cứng của bạn.", + "transcoding_acceleration_api_description": "API này sẽ tương tác với thiết bị của bạn để tăng tốc quá trình chuyển mã. Cài đặt này hoạt động theo nguyên tắc 'cố gắng hết sức'': nó sẽ quay lại chuyển mã phần mềm nếu gặp lỗi. VP9 có thể hoạt động hoặc không tùy thuộc vào phần cứng của bạn.", "transcoding_acceleration_nvenc": "NVENC (yêu cầu GPU NVIDIA)", "transcoding_acceleration_qsv": "Quick Sync (yêu cầu CPU Intel thế hệ 7 hoặc mới hơn)", "transcoding_acceleration_rkmpp": "RKMPP (chỉ trên các SOC của Rockchip)", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Các codec âm thanh được chấp nhận", - "transcoding_accepted_audio_codecs_description": "Chọn các codec âm thanh không cần phải chuyển mã. Chỉ được sử dụng cho một số chính sách chuyển mã nhất định.", - "transcoding_accepted_containers": "Các định dạng container được chấp nhận", - "transcoding_accepted_containers_description": "Chọn các định dạng container không cần phải chuyển mã sang MP4. Chỉ được sử dụng cho một số chính sách chuyển mã nhất định.", + "transcoding_accepted_audio_codecs_description": "Chọn các codec âm thanh không cần phải chuyển mã. Chỉ được sử dụng cho một số quy tắc chuyển mã nhất định.", + "transcoding_accepted_containers": "Các định dạng video được chấp nhận", + "transcoding_accepted_containers_description": "Chọn các định dạng tập tin không cần chuyển đổi sang MP4. Chỉ được sử dụng cho một số quy tắc chuyển mã nhất định.", "transcoding_accepted_video_codecs": "Các codec video được chấp nhận", - "transcoding_accepted_video_codecs_description": "Chọn các codec video không cần phải chuyển mã. Chỉ được sử dụng cho một số chính sách chuyển mã nhất định.", + "transcoding_accepted_video_codecs_description": "Chọn các codec video không cần phải chuyển mã. Chỉ được sử dụng cho một số quy tắc chuyển mã nhất định.", "transcoding_advanced_options_description": "Các tùy chọn mà hầu hết người dùng không cần phải thay đổi", "transcoding_audio_codec": "Codec âm thanh", "transcoding_audio_codec_description": "Opus là tùy chọn chất lượng cao nhất, nhưng có tính tương thích thấp hơn với các thiết bị hoặc phần mềm cũ.", "transcoding_bitrate_description": "Video có bitrate cao hơn hoặc không ở định dạng được chấp nhận", "transcoding_codecs_learn_more": "Để tìm hiểu thêm về thuật ngữ được sử dụng ở đây, hãy tham khảo tài liệu FFmpeg cho codec H.264, codec HEVCcodec VP9.", "transcoding_constant_quality_mode": "Chế độ chất lượng cố định", - "transcoding_constant_quality_mode_description": "ICQ tốt hơn CQP, nhưng một số thiết bị tăng tốc phần cứng không hỗ trợ chế độ này. Cài đặt tùy chọn này sẽ ưu tiên chế độ đã chỉ định khi sử dụng mã hóa dựa trên chất lượng. Bị bỏ qua bởi NVENC vì nó không hỗ trợ ICQ.", + "transcoding_constant_quality_mode_description": "ICQ tốt hơn CQP, nhưng một số thiết bị tăng tốc phần cứng không hỗ trợ chế độ này. Cài đặt tùy chọn này sẽ ưu tiên chế độ được chỉ định khi sử dụng mã hóa dựa trên chất lượng. Bị bỏ qua bởi NVENC vì nó không hỗ trợ ICQ.", "transcoding_constant_rate_factor": "Hệ số tỷ lệ cố định (-crf)", - "transcoding_constant_rate_factor_description": "Mức chất lượng video. Các giá trị điển hình là 23 cho H.264, 28 cho HEVC, 31 cho VP9 và 35 cho AV1. Giá trị thấp hơn thì tốt hơn, nhưng tạo ra các tệp lớn hơn.", + "transcoding_constant_rate_factor_description": "Mức chất lượng video. Các giá trị điển hình là 23 cho H.264, 28 cho HEVC, 31 cho VP9 và 35 cho AV1. Giá trị thấp hơn thì tốt hơn, nhưng tạo ra các tập tin lớn hơn.", "transcoding_disabled_description": "Không chuyển mã bất kỳ video nào, có thể gây lỗi phát lại trên một số thiết bị", "transcoding_hardware_acceleration": "Tăng tốc phần cứng", - "transcoding_hardware_acceleration_description": "Thí nghiệm; nhanh hơn nhiều, nhưng chất lượng thấp hơn với cùng một bitrate", + "transcoding_hardware_acceleration_description": "(Thử nghiệm) nhanh hơn nhiều nhưng sẽ có chất lượng thấp hơn ở cùng bitrate", "transcoding_hardware_decoding": "Giải mã phần cứng", - "transcoding_hardware_decoding_setting_description": "Chỉ áp dụng cho NVENC, QSV và RKMPP. Kích hoạt tăng tốc end-to-end thay vì chỉ tăng tốc mã hóa. Có thể không hoạt động trên tất cả các video.", + "transcoding_hardware_decoding_setting_description": "Chỉ áp dụng cho NVENC, QSV và RKMPP. Kích hoạt tăng tốc toàn bộ quá trình xử lý video chứ không chỉ là mã hóa. Điều này có thể không áp dụng được cho mọi video.", "transcoding_hevc_codec": "Codec HEVC", - "transcoding_max_b_frames": "Số lượng B-frame tối đa", - "transcoding_max_b_frames_description": "Giá trị cao hơn cải thiện hiệu quả nén, nhưng làm chậm mã hóa. Có thể không tương thích với tăng tốc phần cứng trên các thiết bị cũ. 0 tắt B-frames, trong khi -1 tự động thiết lập giá trị này.", + "transcoding_max_b_frames": "Số B-frame tối đa", + "transcoding_max_b_frames_description": "Giá trị cao hơn cải thiện hiệu quả nén, nhưng làm chậm mã hóa. Có thể không tương thích với tăng tốc phần cứng trên các thiết bị cũ. Giá trị 0 để tắt B-frames, trong khi giá trị -1 để tự động thiết lập giá trị này.", "transcoding_max_bitrate": "Bitrate tối đa", - "transcoding_max_bitrate_description": "Cài đặt một bitrate tối đa có thể làm cho kích thước tệp dự đoán hơn với một chi phí nhỏ cho chất lượng. Tại 720p, các giá trị điển hình là 2600k cho VP9 hoặc HEVC, hoặc 4500k cho H.264. Bị vô hiệu hóa nếu thiết lập là 0.", - "transcoding_max_keyframe_interval": "Khoảng thời gian giữa các keyframe tối đa", - "transcoding_max_keyframe_interval_description": "Thiết lập khoảng thời gian tối đa giữa các keyframe. Giá trị thấp hơn làm giảm hiệu quả nén, nhưng cải thiện thời gian tìm kiếm và có thể cải thiện chất lượng trong các cảnh có chuyển động nhanh. 0 tự động thiết lập giá trị này.", + "transcoding_max_bitrate_description": "Cài đặt giới hạn bitrate tối đa có thể giúp kích thước video dễ dự đoán hơn, với một chút hy sinh về chất lượng. Ở độ phân giải 720p, giá trị điển hình là 2600k cho VP9 hoặc HEVC, hoặc 4500k cho H.264. Nếu đặt thành 0, chức năng này sẽ bị vô hiệu hóa.", + "transcoding_max_keyframe_interval": "Khoảng cách tối đa giữa các khung hình chính", + "transcoding_max_keyframe_interval_description": "Thiết lập khoảng thời gian tối đa giữa các khung hình chính. Giá trị thấp hơn làm giảm hiệu suất nén, nhưng cải thiện thời gian tìm kiếm và có thể cải thiện chất lượng trong các cảnh có chuyển động nhanh. Giá trị 0 để tự động thiết lập giá trị này.", "transcoding_optimal_description": "Video có độ phân giải cao hơn mục tiêu hoặc không ở định dạng được chấp nhận", "transcoding_preferred_hardware_device": "Thiết bị phần cứng ưa thích", "transcoding_preferred_hardware_device_description": "Chỉ áp dụng cho VAAPI và QSV. Thiết lập nút dri được sử dụng cho chuyển mã phần cứng.", "transcoding_preset_preset": "Preset (-preset)", - "transcoding_preset_preset_description": "Tốc độ nén. Các preset chậm hơn tạo ra các tệp nhỏ hơn, và tăng chất lượng khi nhắm đến một bitrate cụ thể. VP9 bỏ qua tốc độ trên `faster`.", - "transcoding_reference_frames": "Khung tham chiếu", - "transcoding_reference_frames_description": "Số lượng khung để tham chiếu khi nén một khung nhất định. Giá trị cao hơn cải thiện hiệu quả nén, nhưng làm chậm mã hóa. 0 tự động thiết lập giá trị này.", + "transcoding_preset_preset_description": "Tốc độ nén. Các preset chậm hơn tạo ra các tập tin nhỏ hơn và cải thiện chất lượng khi mục tiêu là một bitrate cụ thể. VP9 chỉ hỗ trợ các preset từ 'ultrafast' đến 'faster'.", + "transcoding_reference_frames": "Khung hình tham chiếu", + "transcoding_reference_frames_description": "Số lượng khung hình tham chiếu khi nén một khung hình nhất định. Giá trị cao hơn cải thiện hiệu suất nén nhưng làm chậm quá trình mã hóa. Giá trị 0 để tự động thiết lập giá trị này.", "transcoding_required_description": "Chỉ video không ở định dạng được chấp nhận", - "transcoding_settings": "Cài đặt Chuyển mã Video", - "transcoding_settings_description": "Quản lý thông tin về độ phân giải và mã hóa của các tệp video", + "transcoding_settings": "Chuyển mã video", + "transcoding_settings_description": "Quản lý thông tin độ phân giải và mã hóa của các video", "transcoding_target_resolution": "Độ phân giải mục tiêu", - "transcoding_target_resolution_description": "Độ phân giải cao hơn có thể giữ lại nhiều chi tiết hơn nhưng mất nhiều thời gian hơn để mã hóa, có kích thước tệp lớn hơn và có thể làm giảm khả năng phản hồi của ứng dụng.", - "transcoding_temporal_aq": "AQ tạm thời", - "transcoding_temporal_aq_description": "Chỉ áp dụng cho NVENC. Tăng chất lượng của các cảnh chi tiết cao, chuyển động thấp. Có thể không tương thích với các thiết bị cũ.", + "transcoding_target_resolution_description": "Độ phân giải cao hơn có thể giữ lại nhiều chi tiết hơn nhưng mất nhiều thời gian hơn để mã hóa, có kích thước tập tin lớn hơn và có thể làm giảm khả năng phản hồi của ứng dụng.", + "transcoding_temporal_aq": "Lượng tử hóa thích ứng (Temporal AQ)", + "transcoding_temporal_aq_description": "Chỉ áp dụng cho NVENC. Tăng chất lượng cho các cảnh có nhiều chi tiết và ít chuyển động. Có thể không tương thích với các thiết bị cũ.", "transcoding_threads": "Luồng", - "transcoding_threads_description": "Giá trị cao hơn dẫn đến mã hóa nhanh hơn, nhưng để lại ít không gian hơn cho máy chủ xử lý các tác vụ khác khi hoạt động. Giá trị này không nên vượt quá số lượng lõi CPU. Tối đa hóa việc sử dụng nếu thiết lập là 0.", - "transcoding_tone_mapping": "Đồ họa sắc thái", - "transcoding_tone_mapping_description": "Cố gắng duy trì sự xuất hiện của video HDR khi chuyển đổi sang SDR. Mỗi thuật toán thực hiện các thỏa thuận khác nhau về màu sắc, chi tiết và độ sáng. Hable giữ chi tiết, Mobius giữ màu sắc và Reinhard giữ độ sáng.", - "transcoding_tone_mapping_npl": "Đồ họa sắc thái NPL", - "transcoding_tone_mapping_npl_description": "Màu sắc sẽ được điều chỉnh để trông bình thường với độ sáng của màn hình này. Theo cách trái ngược, giá trị thấp hơn làm tăng độ sáng của video và ngược lại vì nó bù đắp cho độ sáng của màn hình. 0 tự động thiết lập giá trị này.", - "transcoding_transcode_policy": "Chính sách chuyển mã", - "transcoding_transcode_policy_description": "Chính sách khi nào video nên được chuyển mã. Video HDR luôn luôn được chuyển mã (ngoại trừ khi chuyển mã bị tắt).", + "transcoding_threads_description": "Giá trị cao hơn dẫn đến mã hóa nhanh hơn nhưng để lại ít không gian hơn cho máy chủ xử lý các tác vụ khác khi đang hoạt động. Giá trị này không nên vượt quá số lượng lõi CPU. Tối đa hóa sử dụng nếu đặt thành 0.", + "transcoding_tone_mapping": "Ánh Xạ Sắc Thái (Tone-mapping)", + "transcoding_tone_mapping_description": "Cố gắng duy trì chất lượng video tốt nhất khi chuyển đổi từ HDR sang SDR. Mỗi thuật toán có sự đánh đổi khác nhau về màu sắc, chi tiết và độ sáng. Hable giữ chi tiết, Mobius giữ màu sắc và Reinhard giữ độ sáng.", + "transcoding_tone_mapping_npl": "Ánh Xạ Sắc Thái NPL (Tone-mapping NPL)", + "transcoding_tone_mapping_npl_description": "Màu sắc sẽ được điều chỉnh để trông bình thường với độ sáng của màn hình này. Theo cách trái ngược, giá trị thấp hơn sẽ tăng độ sáng của video và ngược lại vì nó bù đắp cho độ sáng của màn hình. Giá trị 0 để tự động thiết lập giá trị này.", + "transcoding_transcode_policy": "Quy tắc chuyển mã", + "transcoding_transcode_policy_description": "Quy tắc khi nào video nên được chuyển mã. Các video HDR luôn được chuyển mã (ngoại trừ khi tính năng chuyển mã bị tắt).", "transcoding_two_pass_encoding": "Mã hóa hai lần", - "transcoding_two_pass_encoding_setting_description": "Chuyển mã trong hai lần để tạo ra video được mã hóa tốt hơn. Khi bitrate tối đa được bật (cần thiết để hoạt động với H.264 và HEVC), chế độ này sử dụng một phạm vi bitrate dựa trên bitrate tối đa và bỏ qua CRF. Đối với VP9, CRF có thể được sử dụng nếu bitrate tối đa bị tắt.", + "transcoding_two_pass_encoding_setting_description": "Chuyển mã hai lần để tạo ra video được mã hóa tốt hơn. Khi bitrate tối đa được bật (bắt buộc để hoạt động với H.264 và HEVC), chế độ này sử dụng một phạm vi bitrate dựa trên bitrate tối đa và bỏ qua CRF. Đối với VP9, CRF có thể được sử dụng nếu bitrate tối đa bị tắt.", "transcoding_video_codec": "Codec Video", - "transcoding_video_codec_description": "VP9 có hiệu suất cao và tương thích với web, nhưng mất nhiều thời gian hơn để chuyển mã. HEVC hoạt động tương tự, nhưng có độ tương thích web thấp hơn. H.264 là tương thích rộng rãi và nhanh chóng để chuyển mã, nhưng tạo ra các tệp lớn hơn nhiều. AV1 là codec hiệu quả nhất nhưng thiếu hỗ trợ trên các thiết bị cũ.", - "trash_enabled_description": "Kích hoạt tính năng Thùng rác", + "transcoding_video_codec_description": "VP9 có hiệu suất cao và tương thích tốt với web, nhưng thời gian chuyển mã lâu hơn. HEVC có hiệu suất tương tự, nhưng tương thích web thấp hơn. H.264 tương thích rộng rãi và chuyển mã nhanh, nhưng tạo ra các tập tin có kích thước lớn. AV1 là codec hiệu quả nhất nhưng không được hỗ trợ trên các thiết bị cũ.", + "trash_enabled_description": "Bật tính năng Thùng rác", "trash_number_of_days": "Số ngày", - "trash_number_of_days_description": "Số ngày giữ các tệp tin trong thùng rác trước khi xóa chúng vĩnh viễn", - "trash_settings": "Cài đặt Thùng rác", + "trash_number_of_days_description": "Số ngày giữ các ảnh trong thùng rác trước khi xóa chúng vĩnh viễn", + "trash_settings": "Thùng rác", "trash_settings_description": "Quản lý cài đặt thùng rác", - "untracked_files": "Các tệp tin không được theo dõi", - "untracked_files_description": "Những tệp tin này không được ứng dụng theo dõi. Chúng có thể là kết quả của các thao tác di chuyển thất bại, tải lên bị gián đoạn, hoặc bị bỏ lại do lỗi", - "user_delete_delay": "Tài khoản và các tệp tin của {user} sẽ được lên lịch xóa vĩnh viễn sau {delay, plural, one {# ngày} other {# ngày}}.", + "untracked_files": "Các tập tin không được theo dõi", + "untracked_files_description": "Những tập tin này không được ứng dụng theo dõi. Chúng có thể là kết quả của các thao tác di chuyển thất bại, tải lên bị gián đoạn, hoặc bị bỏ lại do lỗi", + "user_delete_delay": "Tài khoản và các ảnh của {user} sẽ được lên lịch xóa vĩnh viễn sau {delay, plural, one {# ngày} other {# ngày}}.", "user_delete_delay_settings": "Thời gian xóa", - "user_delete_delay_settings_description": "Số ngày sau khi xóa để xóa vĩnh viễn tài khoản và các tệp tin của người dùng. Công việc xóa người dùng chạy vào giữa đêm để kiểm tra các người dùng sẵn sàng bị xóa. Thay đổi cài đặt này sẽ được đánh giá vào lần thực hiện tiếp theo.", - "user_delete_immediately": "Tài khoản và các tệp tin của {user} sẽ được xếp hàng để xóa vĩnh viễn ngay lập tức.", - "user_delete_immediately_checkbox": "Xếp hàng người dùng và các tệp tin để xóa ngay lập tức", - "user_management": "Quản lý Người dùng", + "user_delete_delay_settings_description": "Số ngày chờ xóa để xóa vĩnh viễn tài khoản và các ảnh của người dùng. Tác vụ xóa người dùng chạy vào giữa đêm để kiểm tra các người dùng sẵn sàng bị xóa. Thay đổi cài đặt này sẽ được đánh giá vào lần thực hiện tiếp theo.", + "user_delete_immediately": "Tài khoản và các ảnh của {user} sẽ được xếp hàng để xóa vĩnh viễn ngay lập tức.", + "user_delete_immediately_checkbox": "Xếp hàng người dùng và các ảnh để xóa ngay lập tức", + "user_management": "Quản lý người dùng", "user_password_has_been_reset": "Mật khẩu của người dùng đã được đặt lại:", - "user_password_reset_description": "Vui lòng cung cấp mật khẩu tạm thời cho người dùng và thông báo cho họ rằng họ sẽ cần thay đổi mật khẩu khi đăng nhập lần tiếp theo.", + "user_password_reset_description": "Vui lòng cung cấp mật khẩu tạm thời cho người dùng và thông báo rằng họ cần thay đổi mật khẩu khi đăng nhập lần tiếp theo.", "user_restore_description": "Tài khoản của {user} sẽ được khôi phục.", - "user_restore_scheduled_removal": "Khôi phục người dùng - xóa dự kiến vào {date, date, long}", - "user_settings": "Cài đặt Người dùng", + "user_restore_scheduled_removal": "Khôi phục người dùng - đã lên lịch xóa vào {date, date, long}", + "user_settings": "Người dùng", "user_settings_description": "Quản lý cài đặt người dùng", "user_successfully_removed": "Người dùng {email} đã được xóa thành công.", - "version_check_enabled_description": "Bật yêu cầu định kỳ đến GitHub để kiểm tra các bản phát hành mới", - "version_check_settings": "Kiểm tra Phiên bản", + "version_check_enabled_description": "Bật gửi yêu cầu định kỳ đến GitHub để kiểm tra các bản phát hành mới", + "version_check_settings": "Kiểm tra phiên bản", "version_check_settings_description": "Bật/tắt thông báo phiên bản mới", - "video_conversion_job": "Chuyển đổi video", - "video_conversion_job_description": "Chuyển đổi video để tương thích rộng rãi hơn với các trình duyệt và thiết bị" + "video_conversion_job": "Chuyển mã video", + "video_conversion_job_description": "Chuyển đổi định dạng video để tương thích rộng rãi hơn với trình duyệt và thiết bị" }, "admin_email": "Email Quản trị viên", "admin_password": "Mật khẩu Quản trị viên", "administration": "Quản trị", "advanced": "Nâng cao", - "age_months": "Tuổi {months, plural, one {# tháng} other {# tháng}}", - "age_year_months": "Tuổi 1 năm, {months, plural, one {# tháng} other {# tháng}}", - "age_years": "{years, plural, other {Tuổi #}}", - "album_added": "Album đã được thêm", + "age_months": "{months, plural, one {# tháng} other {# tháng}} tuổi", + "age_year_months": "1 tuổi, {months, plural, one {# tháng} other {# tháng}}", + "age_years": "{years, plural, other {# tuổi}}", + "album_added": "Đã thêm album", "album_added_notification_setting_description": "Nhận thông báo qua email khi bạn được thêm vào một album chia sẻ", - "album_cover_updated": "Bìa album đã được cập nhật", + "album_cover_updated": "Đã cập nhật ảnh bìa album", "album_delete_confirmation": "Bạn có chắc chắn muốn xóa album {album} không?\nNếu album này đang được chia sẻ, các người dùng khác sẽ không còn truy cập được nữa.", - "album_info_updated": "Thông tin album đã được cập nhật", + "album_info_updated": "Đã cập nhật thông tin album", "album_leave": "Rời album?", "album_leave_confirmation": "Bạn có chắc chắn muốn rời khỏi {album} không?", "album_name": "Tên album", @@ -345,9 +345,9 @@ "album_remove_user": "Xóa người dùng?", "album_remove_user_confirmation": "Bạn có chắc chắn muốn xóa {user} không?", "album_share_no_users": "Có vẻ như bạn đã chia sẻ album này với tất cả người dùng hoặc bạn không có người dùng nào để chia sẻ.", - "album_updated": "Album đã được cập nhật", - "album_updated_setting_description": "Nhận thông báo qua email khi một album chia sẻ có tệp tin mới", - "album_user_left": "Rời khỏi {album}", + "album_updated": "Đã cập nhật album", + "album_updated_setting_description": "Nhận thông báo qua email khi một album chia sẻ có các ảnh mới", + "album_user_left": "Đã rời khỏi {album}", "album_user_removed": "Đã xóa {user}", "album_with_link_access": "Cho phép bất kỳ ai có liên kết xem ảnh và người trong album này.", "albums": "Album", @@ -364,58 +364,58 @@ "api_key_description": "Giá trị này chỉ được hiển thị một lần. Vui lòng sao chép nó trước khi đóng cửa sổ.", "api_key_empty": "Tên khóa API của bạn không được để trống", "api_keys": "Khóa API", - "app_settings": "Cài đặt Ứng dụng", + "app_settings": "Ứng dụng", "appears_in": "Xuất hiện trong", "archive": "Lưu trữ", - "archive_or_unarchive_photo": "Lưu trữ hoặc gỡ lưu trữ ảnh", - "archive_size": "Kích thước lưu trữ", - "archive_size_description": "Cấu hình kích thước lưu trữ cho các tệp tải xuống (trong GiB)", + "archive_or_unarchive_photo": "Lưu trữ hoặc huỷ lưu trữ ảnh", + "archive_size": "Kích thước gói nén", + "archive_size_description": "Cấu hình kích thước cho các tập tin nén tải về (đơn vị GiB)", "archived": "", "archived_count": "{count, plural, other {Đã lưu trữ # mục}}", - "are_these_the_same_person": "Có phải đây là cùng một người không?", + "are_these_the_same_person": "Đây có phải cùng một người không?", "are_you_sure_to_do_this": "Bạn có chắc chắn muốn thực hiện điều này không?", "asset_added_to_album": "Đã thêm vào album", "asset_adding_to_album": "Đang thêm vào album...", - "asset_description_updated": "Mô tả tệp tin đã được cập nhật", - "asset_filename_is_offline": "tệp tin {filename} đang ngoại tuyến", - "asset_has_unassigned_faces": "tệp tin có các khuôn mặt chưa được gán", + "asset_description_updated": "Mô tả ảnh đã được cập nhật", + "asset_filename_is_offline": "Ảnh {filename} đang ngoại tuyến", + "asset_has_unassigned_faces": "Ảnh chưa được gán khuôn mặt", "asset_hashing": "Đang băm...", - "asset_offline": "tệp tin ngoại tuyến", - "asset_offline_description": "tệp tin này đang ngoại tuyến. Immich không thể truy cập vị trí tệp của nó. Vui lòng đảm bảo tệp tin có sẵn và sau đó quét lại thư viện.", + "asset_offline": "Ảnh ngoại tuyến", + "asset_offline_description": "Tập tin này đang ngoại tuyến. Immich không thể truy cập vị trí tập tin của nó. Vui lòng đảm bảo tập tin có sẵn và sau đó quét lại thư viện.", "asset_skipped": "Đã bỏ qua", "asset_uploaded": "Đã tải lên", "asset_uploading": "Đang tải lên...", - "assets": "Các tệp tin", - "assets_added_count": "Đã thêm {count, plural, one {# tệp tin} other {# tệp tin}}", - "assets_added_to_album_count": "Đã thêm {count, plural, one {# tệp tin} other {# tệp tin}} vào album", - "assets_added_to_name_count": "Đã thêm {count, plural, one {# tệp tin} other {# tệp tin}} vào {hasName, select, true {{name}} other {album mới}}", - "assets_count": "{count, plural, one {# tệp tin} other {# tệp tin}}", + "assets": "Các tập tin", + "assets_added_count": "Đã thêm {count, plural, one {# mục} other {# mục}}", + "assets_added_to_album_count": "Đã thêm {count, plural, one {# mục} other {# mục}} vào album", + "assets_added_to_name_count": "Đã thêm {count, plural, one {# mục} other {# mục}} vào {hasName, select, true {{name}} other {album mới}}", + "assets_count": "{count, plural, one {# mục} other {# mục}}", "assets_moved_to_trash_count": "Đã chuyển {count, plural, one {# mục} other {# mục}} vào thùng rác", - "assets_permanently_deleted_count": "Đã xóa vĩnh viễn {count, plural, one {# tệp tin} other {# tệp tin}}", - "assets_removed_count": "Đã xóa {count, plural, one {# tệp tin} other {# tệp tin}}", + "assets_permanently_deleted_count": "Đã xóa vĩnh viễn {count, plural, one {# mục} other {# mục}}", + "assets_removed_count": "Đã xóa {count, plural, one {# mục} other {# mục}}", "assets_restore_confirmation": "Bạn có chắc chắn muốn khôi phục tất cả các mục đã xóa của mình không? Bạn không thể hoàn tác hành động này!", - "assets_restored_count": "Đã khôi phục {count, plural, one {# tệp tin} other {# tệp tin}}", + "assets_restored_count": "Đã khôi phục {count, plural, one {# mục} other {# mục}}", "assets_trashed_count": "Đã chuyển {count, plural, one {# mục} other {# mục}} vào thùng rác", - "assets_were_part_of_album_count": "{count, plural, one {tệp tin đã} other {Các tệp tin đã}} là một phần của album", - "authorized_devices": "Thiết bị đã được ủy quyền", + "assets_were_part_of_album_count": "{count, plural, one {Mục đã} other {Các mục đã}} có trong album", + "authorized_devices": "Thiết bị được ủy quyền", "back": "Quay lại", "back_close_deselect": "Quay lại, đóng, hoặc bỏ chọn", "backward": "Lùi lại", "birthdate_saved": "Ngày sinh đã được lưu thành công", "birthdate_set_description": "Ngày sinh được sử dụng để tính tuổi của người này tại thời điểm chụp ảnh.", "blurred_background": "Nền mờ", - "build": "Build", - "build_image": "Build Image", + "build": "Dựng", + "build_image": "Bản dựng", "bulk_delete_duplicates_confirmation": "Bạn có chắc chắn muốn xóa hàng loạt {count, plural, one {# mục trùng lặp} other {# mục trùng lặp}} không? Điều này sẽ giữ lại ảnh chất lượng nhất của mỗi nhóm và xóa vĩnh viễn tất cả các bản trùng lặp khác. Bạn không thể hoàn tác hành động này!", - "bulk_keep_duplicates_confirmation": "Bạn có chắc chắn muốn giữ lại {count, plural, one {# mục trùng lặp} other {#mục trùng lặp}} không? Điều này sẽ giải quyết tất cả các nhóm ảnh trùng lặp mà không xóa bất kỳ thứ gì.", + "bulk_keep_duplicates_confirmation": "Bạn có chắc chắn muốn giữ lại {count, plural, one {# mục trùng lặp} other {# mục trùng lặp}} không? Điều này sẽ xử lý tất cả các nhóm ảnh trùng lặp mà không xóa bất kỳ thứ gì.", "bulk_trash_duplicates_confirmation": "Bạn có chắc chắn muốn đưa {count, plural, one {# mục trùng lặp} other {# mục trùng lặp}} vào thùng rác không? Điều này sẽ giữ lại ảnh chất lượng nhất của mỗi nhóm và đưa tất cả các bản trùng lặp khác vào thùng rác.", "buy": "Mua Immich", "camera": "Máy ảnh", - "camera_brand": "Hãng máy ảnh", - "camera_model": "Mẫu máy ảnh", + "camera_brand": "Thương hiệu máy ảnh", + "camera_model": "Dòng máy ảnh", "cancel": "Hủy", "cancel_search": "Hủy tìm kiếm", - "cannot_merge_people": "Không thể gộp người", + "cannot_merge_people": "Không thể hợp nhất người", "cannot_undo_this_action": "Bạn không thể hoàn tác hành động này!", "cannot_update_the_description": "Không thể cập nhật mô tả", "cant_apply_changes": "", @@ -424,16 +424,16 @@ "cant_search_places": "", "change_date": "Thay đổi ngày", "change_expiration_time": "Thay đổi thời gian hết hạn", - "change_location": "Thay đổi địa điểm", + "change_location": "Thay đổi vị trí", "change_name": "Thay đổi tên", "change_name_successfully": "Đã thay đổi tên thành công", "change_password": "Thay đổi mật khẩu", - "change_password_description": "Đây là lần đầu tiên bạn đăng nhập vào hệ thống hoặc có yêu cầu thay đổi mật khẩu. Vui lòng nhập mật khẩu mới dưới đây.", + "change_password_description": "Đây có thể là lần đầu tiên bạn đăng nhập vào hệ thống hoặc có yêu cầu thay đổi mật khẩu của bạn. Vui lòng nhập mật khẩu mới bên dưới.", "change_your_password": "Thay đổi mật khẩu của bạn", - "changed_visibility_successfully": "Đã thay đổi quyền hiển thị thành công", + "changed_visibility_successfully": "Đã thay đổi trạng thái hiển thị thành công", "check_all": "Chọn tất cả", "check_logs": "Kiểm tra nhật ký", - "choose_matching_people_to_merge": "Chọn những người trùng khớp để gộp", + "choose_matching_people_to_merge": "Chọn những người trùng khớp để hợp nhất", "city": "Thành phố", "clear": "Xóa", "clear_all": "Xóa tất cả", @@ -457,8 +457,8 @@ "continue": "Tiếp tục", "copied_image_to_clipboard": "Đã sao chép hình ảnh vào clipboard.", "copied_to_clipboard": "Đã sao chép vào clipboard!", - "copy_error": "Lỗi sao chép", - "copy_file_path": "Sao chép đường dẫn tệp", + "copy_error": "Sao chép lỗi", + "copy_file_path": "Sao chép đường dẫn tập tin", "copy_image": "Sao chép hình ảnh", "copy_link": "Sao chép liên kết", "copy_link_to_clipboard": "Sao chép liên kết vào clipboard", @@ -472,14 +472,14 @@ "create_library": "Tạo thư viện", "create_link": "Tạo liên kết", "create_link_to_share": "Tạo liên kết để chia sẻ", - "create_link_to_share_description": "Cho phép bất kỳ ai có liên kết xem ảnh đã chọn", + "create_link_to_share_description": "Cho phép bất kỳ ai có liên kết xem các ảnh đã chọn", "create_new_person": "Tạo người mới", - "create_new_person_hint": "Gán các tệp tin đã chọn cho một người mới", + "create_new_person_hint": "Gán các ảnh đã chọn cho một người mới", "create_new_user": "Tạo người dùng mới", "create_user": "Tạo người dùng", "created": "Đã tạo", "current_device": "Thiết bị hiện tại", - "custom_locale": "Định dạng địa phương tùy chỉnh", + "custom_locale": "Ngôn ngữ và khu vực tùy chỉnh", "custom_locale_description": "Định dạng ngày tháng và số dựa trên ngôn ngữ và khu vực", "dark": "Tối", "date_after": "Ngày sau", @@ -489,7 +489,7 @@ "date_range": "Khoảng thời gian", "day": "Ngày", "deduplicate_all": "Xóa tất cả mục trùng lặp", - "default_locale": "Ngôn ngữ mặc định", + "default_locale": "Ngôn ngữ và khu vực mặc định", "default_locale_description": "Định dạng ngày tháng và số dựa trên ngôn ngữ của trình duyệt của bạn", "delete": "Xóa", "delete_album": "Xóa album", @@ -512,15 +512,15 @@ "display_options": "Tùy chọn hiển thị", "display_order": "Thứ tự hiển thị", "display_original_photos": "Hiển thị ảnh gốc", - "display_original_photos_setting_description": "Ưu tiên hiển thị ảnh gốc khi xem tệp tin thay vì ảnh thu nhỏ khi tệp tin gốc tương thích với web. Điều này có thể dẫn đến tốc độ hiển thị ảnh chậm hơn.", + "display_original_photos_setting_description": "Ưu tiên hiển thị ảnh gốc khi xem ảnh thay vì hình thu nhỏ khi ảnh gốc tương thích với web. Điều này có thể dẫn đến tốc độ hiển thị ảnh chậm hơn.", "do_not_show_again": "Không hiển thị thông báo này nữa", "done": "Xong", "download": "Tải xuống", "download_settings": "Tải xuống", - "download_settings_description": "Quản lý các cài đặt liên quan đến việc tải xuống tệp tin", + "download_settings_description": "Quản lý cài đặt liên quan đến việc tải ảnh xuống", "downloading": "Đang tải xuống", - "downloading_asset_filename": "Đang tải xuống tệp tin {filename}", - "drop_files_to_upload": "Kéo thả các tệp để tải lên", + "downloading_asset_filename": "Đang tải xuống tập tin {filename}", + "drop_files_to_upload": "Kéo thả các tập tin để tải lên", "duplicates": "Mục trùng lặp", "duplicates_description": "Xem lại các nhóm ảnh bị nghi ngờ trùng lặp và chọn những mục bạn muốn giữ hoặc xóa", "duration": "Thời gian", @@ -536,7 +536,7 @@ "edit_avatar": "Chỉnh sửa ảnh đại diện", "edit_date": "Chỉnh sửa ngày", "edit_date_and_time": "Chỉnh sửa ngày và giờ", - "edit_exclusion_pattern": "Chỉnh sửa mẫu loại trừ", + "edit_exclusion_pattern": "Chỉnh sửa quy tắc loại trừ", "edit_faces": "Chỉnh sửa khuôn mặt", "edit_import_path": "Chỉnh sửa đường dẫn nhập", "edit_import_paths": "Chỉnh sửa các đường dẫn nhập", @@ -554,64 +554,64 @@ "empty_album": "", "empty_trash": "Dọn sạch thùng rác", "empty_trash_confirmation": "Bạn có chắc chắn muốn dọn sạch thùng rác không? Điều này sẽ xóa vĩnh viễn tất cả các mục trong thùng rác khỏi Immich.\nBạn không thể hoàn tác hành động này!", - "enable": "Kích hoạt", - "enabled": "Đã kích hoạt", + "enable": "Bật", + "enabled": "Đã bật", "end_date": "Ngày kết thúc", "error": "Lỗi", "error_loading_image": "Lỗi tải ảnh", "error_title": "Lỗi - Có điều gì đó không đúng", "errors": { - "cannot_navigate_next_asset": "Không thể điều hướng đến tệp tin tiếp theo", - "cannot_navigate_previous_asset": "Không thể điều hướng đến tệp tin trước đó", + "cannot_navigate_next_asset": "Không thể điều hướng đến ảnh tiếp theo", + "cannot_navigate_previous_asset": "Không thể điều hướng đến ảnh trước đó", "cant_apply_changes": "Không thể áp dụng thay đổi", "cant_change_activity": "Không thể {enabled, select, true {disable} other {enable}} hoạt động", - "cant_change_asset_favorite": "Không thể thay đổi yêu thích cho tệp tin", - "cant_change_metadata_assets_count": "Không thể thay đổi siêu dữ liệu của {count, plural, one {# tệp tin} other {# tệp tin}}", - "cant_get_faces": "Không thể lấy khuôn mặt", - "cant_get_number_of_comments": "Không thể lấy số lượng bình luận", + "cant_change_asset_favorite": "Không thể thay đổi yêu thích cho ảnh", + "cant_change_metadata_assets_count": "Không thể thay đổi metadata của {count, plural, one {# mục} other {# mục}}", + "cant_get_faces": "Không thể tải khuôn mặt", + "cant_get_number_of_comments": "Không thể tải số lượng bình luận", "cant_search_people": "Không thể tìm kiếm người", "cant_search_places": "Không thể tìm kiếm địa điểm", - "cleared_jobs": "Đã xóa các công việc cho: {job}", - "error_adding_assets_to_album": "Lỗi khi thêm tệp tin vào album", + "cleared_jobs": "Đã xoá các tác vụ: {job}", + "error_adding_assets_to_album": "Lỗi khi thêm ảnh vào album", "error_adding_users_to_album": "Lỗi khi thêm người dùng vào album", "error_deleting_shared_user": "Lỗi khi xóa người dùng chia sẻ", "error_downloading": "Lỗi khi tải xuống {filename}", "error_hiding_buy_button": "Lỗi khi ẩn nút mua", - "error_removing_assets_from_album": "Lỗi khi xóa tệp tin khỏi album, kiểm tra bảng điều khiển để biết thêm chi tiết", - "error_selecting_all_assets": "Lỗi khi chọn tất cả các tệp tin", - "exclusion_pattern_already_exists": "Mẫu loại trừ này đã tồn tại.", - "failed_job_command": "Lệnh {command} không thành công cho công việc: {job}", + "error_removing_assets_from_album": "Lỗi khi xóa ảnh khỏi album, kiểm tra bảng điều khiển để biết thêm chi tiết", + "error_selecting_all_assets": "Lỗi khi chọn tất cả ảnh", + "exclusion_pattern_already_exists": "Quy tắc loại trừ này đã tồn tại.", + "failed_job_command": "Lệnh {command} không thành công cho tác vụ: {job}", "failed_to_create_album": "Không thể tạo album", "failed_to_create_shared_link": "Không thể tạo liên kết chia sẻ", "failed_to_edit_shared_link": "Không thể chỉnh sửa liên kết chia sẻ", - "failed_to_get_people": "Không thể lấy người", - "failed_to_load_asset": "Không thể tải tệp tin", - "failed_to_load_assets": "Không thể tải các tệp tin", + "failed_to_get_people": "Không thể tải người", + "failed_to_load_asset": "Không thể tải ảnh", + "failed_to_load_assets": "Không thể tải các ảnh", "failed_to_load_people": "Không thể tải người", "failed_to_remove_product_key": "Không thể xóa khóa sản phẩm", - "failed_to_stack_assets": "Không thể xếp nhóm các tệp tin", - "failed_to_unstack_assets": "Không thể huỷ xếp nhóm các tệp tin", + "failed_to_stack_assets": "Không thể nhóm các ảnh", + "failed_to_unstack_assets": "Không thể huỷ xếp nhóm các ảnh", "import_path_already_exists": "Đường dẫn nhập này đã tồn tại.", "incorrect_email_or_password": "Email hoặc mật khẩu không chính xác", "paths_validation_failed": "{paths, plural, one {# đường dẫn} other {# đường dẫn}} không hợp lệ", "profile_picture_transparent_pixels": "Ảnh đại diện không thể có điểm ảnh trong suốt. Vui lòng phóng to và/hoặc di chuyển hình ảnh.", - "quota_higher_than_disk_size": "Bạn đã đặt hạn mức cao hơn kích thước đĩa", - "repair_unable_to_check_items": "Không thể kiểm tra {count, select, one {mục} other {các mục}}", + "quota_higher_than_disk_size": "Bạn đã đặt hạn mức cao hơn kích thước ổ đĩa", + "repair_unable_to_check_items": "Không thể kiểm tra {count, select, one {mục} other {mục}}", "unable_to_add_album_users": "Không thể thêm người dùng vào album", - "unable_to_add_assets_to_shared_link": "Không thể thêm tệp tin vào liên kết chia sẻ", + "unable_to_add_assets_to_shared_link": "Không thể thêm ảnh vào liên kết chia sẻ", "unable_to_add_comment": "Không thể thêm bình luận", - "unable_to_add_exclusion_pattern": "Không thể thêm mẫu loại trừ", + "unable_to_add_exclusion_pattern": "Không thể thêm quy tắc loại trừ", "unable_to_add_import_path": "Không thể thêm đường dẫn nhập", - "unable_to_add_partners": "Không thể thêm đối tác", - "unable_to_add_remove_archive": "Không thể {archived, select, true {xóa ảnh khỏi} other {thêm ảnh vào}} kho lưu trữ", - "unable_to_add_remove_favorites": "Không thể {favorite, select, true {thêm tệp tin vào} other {xóa tệp tin khỏi}} Mục yêu thích", + "unable_to_add_partners": "Không thể thêm người thân", + "unable_to_add_remove_archive": "Không thể {archived, select, true {xóa ảnh khỏi} other {thêm ảnh vào}} Kho lưu trữ", + "unable_to_add_remove_favorites": "Không thể {favorite, select, true {thêm ảnh vào} other {xóa ảnh khỏi}} Mục yêu thích", "unable_to_archive_unarchive": "Không thể {archived, select, true {lưu trữ} other {huỷ lưu trữ}}", "unable_to_change_album_user_role": "Không thể thay đổi vai trò của người dùng album", "unable_to_change_date": "Không thể thay đổi ngày", - "unable_to_change_favorite": "Không thể thay đổi yêu thích cho tệp tin", - "unable_to_change_location": "Không thể thay đổi địa điểm", + "unable_to_change_favorite": "Không thể thay đổi yêu thích cho ảnh", + "unable_to_change_location": "Không thể thay đổi vị trí", "unable_to_change_password": "Không thể thay đổi mật khẩu", - "unable_to_change_visibility": "Không thể thay đổi quyền truy cập cho {count, plural, one {# người} other {# người}}", + "unable_to_change_visibility": "Không thể thay đổi trạng thái hiển thị cho {count, plural, one {# người} other {# người}}", "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_complete_oauth_login": "Không thể hoàn tất đăng nhập OAuth", @@ -623,14 +623,14 @@ "unable_to_create_library": "Không thể tạo thư viện", "unable_to_create_user": "Không thể tạo người dùng", "unable_to_delete_album": "Không thể xóa album", - "unable_to_delete_asset": "Không thể xóa tệp tin", - "unable_to_delete_assets": "Lỗi khi xóa các tệp tin", - "unable_to_delete_exclusion_pattern": "Không thể xóa mẫu loại trừ", + "unable_to_delete_asset": "Không thể xóa ảnh", + "unable_to_delete_assets": "Lỗi khi xóa các ảnh", + "unable_to_delete_exclusion_pattern": "Không thể xóa quy tắc loại trừ", "unable_to_delete_import_path": "Không thể xóa đường dẫn nhập", "unable_to_delete_shared_link": "Không thể xóa liên kết chia sẻ", "unable_to_delete_user": "Không thể xóa người dùng", - "unable_to_download_files": "Không thể tải xuống tệp tin", - "unable_to_edit_exclusion_pattern": "Không thể chỉnh sửa mẫu loại trừ", + "unable_to_download_files": "Không thể tải xuống tập tin", + "unable_to_edit_exclusion_pattern": "Không thể chỉnh sửa quy tắc loại trừ", "unable_to_edit_import_path": "Không thể chỉnh sửa đường dẫn nhập", "unable_to_empty_trash": "Không thể dọn sạch thùng rác", "unable_to_enter_fullscreen": "Không thể vào chế độ toàn màn hình", @@ -640,29 +640,29 @@ "unable_to_hide_person": "Không thể ẩn người", "unable_to_link_oauth_account": "Không thể liên kết tài khoản OAuth", "unable_to_load_album": "Không thể tải album", - "unable_to_load_asset_activity": "Không thể tải hoạt động của tệp tin", + "unable_to_load_asset_activity": "Không thể tải hoạt động của ảnh", "unable_to_load_items": "Không thể tải các mục", "unable_to_load_liked_status": "Không thể tải trạng thái thích", "unable_to_log_out_all_devices": "Không thể đăng xuất khỏi tất cả các thiết bị", "unable_to_log_out_device": "Không thể đăng xuất khỏi thiết bị", "unable_to_login_with_oauth": "Không thể đăng nhập với OAuth", "unable_to_play_video": "Không thể phát video", - "unable_to_reassign_assets_existing_person": "Không thể phân công lại các tệp tin cho {name, select, null {một người đã tồn tại} other {{name}}}", - "unable_to_reassign_assets_new_person": "Không thể phân công lại các tệp tin cho một người mới", + "unable_to_reassign_assets_existing_person": "Không thể gán lại ảnh cho {name, select, null {một người hiện có} other {{name}}}", + "unable_to_reassign_assets_new_person": "Không thể gán lại ảnh cho một người mới", "unable_to_refresh_user": "Không thể làm mới người dùng", "unable_to_remove_album_users": "Không thể xóa người dùng khỏi album", "unable_to_remove_api_key": "Không thể xóa khóa API", - "unable_to_remove_assets_from_shared_link": "Không thể xóa tệp tin khỏi liên kết chia sẻ", + "unable_to_remove_assets_from_shared_link": "Không thể xóa các mục đã chọn khỏi liên kết chia sẻ", "unable_to_remove_comment": "", "unable_to_remove_library": "Không thể xóa thư viện", - "unable_to_remove_offline_files": "Không thể xóa tệp tin ngoại tuyến", - "unable_to_remove_partner": "Không thể xóa đối tác", + "unable_to_remove_offline_files": "Không thể xóa tập tin ngoại tuyến", + "unable_to_remove_partner": "Không thể xóa người thân", "unable_to_remove_reaction": "Không thể xóa phản ứng", "unable_to_remove_user": "", "unable_to_repair_items": "Không thể sửa chữa các mục", "unable_to_reset_password": "Không thể đặt lại mật khẩu", "unable_to_resolve_duplicate": "Không thể xử lý trùng lặp", - "unable_to_restore_assets": "Không thể khôi phục các tệp tin", + "unable_to_restore_assets": "Không thể khôi phục ảnh", "unable_to_restore_trash": "Không thể khôi phục thùng rác", "unable_to_restore_user": "Không thể khôi phục người dùng", "unable_to_save_album": "Không thể lưu album", @@ -673,19 +673,19 @@ "unable_to_save_settings": "Không thể lưu cài đặt", "unable_to_scan_libraries": "Không thể quét các thư viện", "unable_to_scan_library": "Không thể quét thư viện", - "unable_to_set_feature_photo": "Không thể đặt ảnh đại diện", + "unable_to_set_feature_photo": "Không thể đặt ảnh nổi bật", "unable_to_set_profile_picture": "Không thể đặt ảnh đại diện", - "unable_to_submit_job": "Không thể gửi công việc", + "unable_to_submit_job": "Không thể gửi tác vụ", "unable_to_trash_asset": "Không thể chuyển ảnh vào thùng rác", "unable_to_unlink_account": "Không thể hủy liên kết tài khoản", - "unable_to_update_album_cover": "Không thể cập nhật bìa album", + "unable_to_update_album_cover": "Không thể cập nhật ảnh bìa album", "unable_to_update_album_info": "Không thể cập nhật thông tin album", "unable_to_update_library": "Không thể cập nhật thư viện", - "unable_to_update_location": "Không thể cập nhật địa điểm", + "unable_to_update_location": "Không thể cập nhật vị trí", "unable_to_update_settings": "Không thể cập nhật cài đặt", "unable_to_update_timeline_display_status": "Không thể cập nhật trạng thái hiển thị dòng thời gian", "unable_to_update_user": "Không thể cập nhật người dùng", - "unable_to_upload_file": "Không thể tải lên tệp tin" + "unable_to_upload_file": "Không thể tải tập tin lên" }, "every_day_at_onepm": "", "every_night_at_midnight": "", @@ -700,30 +700,30 @@ "explore": "Khám phá", "export": "Xuất", "export_as_json": "Xuất dưới dạng JSON", - "extension": "Mở rộng", - "external": "Ngoài", - "external_libraries": "Thư viện ngoài", - "face_unassigned": "Chưa gán", + "extension": "Phần mở rộng", + "external": "Bên ngoài", + "external_libraries": "Thư viện bên ngoài", + "face_unassigned": "Chưa được gán", "failed_to_get_people": "", "favorite": "Yêu thích", - "favorite_or_unfavorite_photo": "Đánh dấu hoặc bỏ dấu ảnh yêu thích", + "favorite_or_unfavorite_photo": "Yêu thích hoặc bỏ yêu thích ảnh", "favorites": "Ảnh yêu thích", "feature": "", - "feature_photo_updated": "Ảnh đặc trưng đã được cập nhật", + "feature_photo_updated": "Đã cập nhật ảnh nổi bật", "featurecollection": "", - "file_name": "Tên tệp", - "file_name_or_extension": "Tên tệp hoặc phần mở rộng", - "filename": "Tên tệp", + "file_name": "Tên tập tin", + "file_name_or_extension": "Tên hoặc phần mở rộng tập tin", + "filename": "Tên tập tin", "files": "", - "filetype": "Loại tệp", + "filetype": "Loại tập tin", "filter_people": "Lọc người", "find_them_fast": "Tìm nhanh bằng tên với tìm kiếm", "fix_incorrect_match": "Sửa lỗi trùng khớp không chính xác", - "force_re-scan_library_files": "Yêu cầu quét lại tất cả các tệp thư viện", + "force_re-scan_library_files": "Yêu cầu quét lại tất cả các tập tin thư viện", "forward": "Tiến về phía trước", "general": "Chung", "get_help": "Nhận trợ giúp", - "getting_started": "Hướng dẫn bắt đầu", + "getting_started": "Bắt đầu", "go_back": "Quay lại", "go_to_search": "Đi đến tìm kiếm", "go_to_share_page": "Đi đến trang chia sẻ", @@ -733,7 +733,7 @@ "group_year": "Nhóm theo năm", "has_quota": "Có hạn mức", "hi_user": "Chào {name} ({email})", - "hide_all_people": "Ẩn tất cả người", + "hide_all_people": "Ẩn tất cả mọi người", "hide_gallery": "Ẩn thư viện", "hide_named_person": "Ẩn người {name}", "hide_password": "Ẩn mật khẩu", @@ -742,26 +742,26 @@ "host": "Máy chủ", "hour": "Giờ", "image": "Hình ảnh", - "image_alt_text_date": "{isVideo, select, true {Video} other {Hình ảnh}} chụp vào {date}", - "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Hình ảnh}} chụp với {person1} vào {date}", - "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Hình ảnh}} chụp với {person1} và {person2} vào {date}", - "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Hình ảnh}} chụp với {person1}, {person2}, và {person3} vào {date}", - "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Hình ảnh}} chụp với {person1}, {person2}, và {additionalCount, number} người khác vào {date}", - "image_alt_text_date_place": "{isVideo, select, true {Video} other {Hình ảnh}} chụp tại {city}, {country} vào {date}", - "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Hình ảnh}} chụp tại {city}, {country} với {person1} vào {date}", - "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Hình ảnh}} chụp tại {city}, {country} với {person1} và {person2} vào {date}", - "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Hình ảnh}} chụp tại {city}, {country} với {person1}, {person2}, và {person3} vào {date}", - "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Hình ảnh}} chụp tại {city}, {country} với {person1}, {person2}, và {additionalCount, number} người khác vào {date}", + "image_alt_text_date": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp vào {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp với {person1} vào {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp với {person1} và {person2} vào {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp với {person1}, {person2}, và {person3} vào {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp với {person1}, {person2}, và {additionalCount, number} người khác vào {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp tại {city}, {country} vào {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp tại {city}, {country} với {person1} vào {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp tại {city}, {country} với {person1} và {person2} vào {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp tại {city}, {country} với {person1}, {person2}, và {person3} vào {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp tại {city}, {country} với {person1}, {person2}, và {additionalCount, number} người khác vào {date}", "img": "", "immich_logo": "Logo Immich", "immich_web_interface": "Giao diện web Immich", "import_from_json": "Nhập từ JSON", "import_path": "Đường dẫn nhập", "in_albums": "Trong {count, plural, one {# album} other {# album}}", - "in_archive": "Trong lưu trữ", - "include_archived": "Bao gồm các mục đã lưu trữ", - "include_shared_albums": "Bao gồm các album đã chia sẻ", - "include_shared_partner_assets": "Bao gồm các tài nguyên đối tác đã chia sẻ", + "in_archive": "Trong kho lưu trữ", + "include_archived": "Bao gồm các ảnh lưu trữ", + "include_shared_albums": "Bao gồm các album chia sẻ", + "include_shared_partner_assets": "Bao gồm các ảnh người thân chia sẻ", "individual_share": "Chia sẻ cá nhân", "info": "Thông tin", "interval": { @@ -774,7 +774,7 @@ "invite_to_album": "Mời vào album", "items_count": "{count, plural, one {# mục} other {# mục}}", "job_settings_description": "", - "jobs": "Công việc", + "jobs": "Tác vụ", "keep": "Giữ", "keep_all": "Giữ tất cả", "keyboard_shortcuts": "Phím tắt", @@ -783,13 +783,13 @@ "last_seen": "Lần cuối nhìn thấy", "latest_version": "Phiên bản mới nhất", "latitude": "Vĩ độ", - "leave": "Rời bỏ", + "leave": "Rời khỏi", "let_others_respond": "Cho phép người khác phản hồi", "level": "Cấp độ", "library": "Thư viện", "library_options": "Tùy chọn thư viện", "light": "Sáng", - "like_deleted": "Thích đã bị xóa", + "like_deleted": "Đã xoá thích", "link_options": "Tùy chọn liên kết", "link_to_oauth": "Liên kết đến OAuth", "linked_oauth_account": "Tài khoản OAuth đã liên kết", @@ -797,12 +797,12 @@ "loading": "Đang tải", "loading_search_results_failed": "Tải kết quả tìm kiếm không thành công", "log_out": "Đăng xuất", - "log_out_all_devices": "Đăng xuất tất cả thiết bị", - "logged_out_all_devices": "Đã đăng xuất tất cả thiết bị", - "logged_out_device": "Đã đăng xuất thiết bị", + "log_out_all_devices": "Đăng xuất tất cả các thiết bị", + "logged_out_all_devices": "Tất cả các thiết bị đã đăng xuất", + "logged_out_device": "Thiết bị đã đăng xuất", "login": "Đăng nhập", "login_has_been_disabled": "Đăng nhập đã bị vô hiệu hóa.", - "logout_all_device_confirmation": "Bạn có chắc chắn muốn đăng xuất tất cả thiết bị không?", + "logout_all_device_confirmation": "Bạn có chắc chắn muốn đăng xuất tất cả các thiết bị không?", "logout_this_device_confirmation": "Bạn có chắc chắn muốn đăng xuất thiết bị này không?", "longitude": "Kinh độ", "look": "Xem", @@ -810,7 +810,7 @@ "loop_videos_description": "Bật để video tự động lặp lại trong trình xem chi tiết.", "make": "Thương hiệu", "manage_shared_links": "Quản lý liên kết chia sẻ", - "manage_sharing_with_partners": "Quản lý chia sẻ với đối tác", + "manage_sharing_with_partners": "Quản lý chia sẻ với người thân", "manage_the_app_settings": "Quản lý cài đặt ứng dụng", "manage_your_account": "Quản lý tài khoản của bạn", "manage_your_api_keys": "Quản lý các khóa API của bạn", @@ -827,16 +827,16 @@ "memory": "Kỷ niệm", "memory_lane_title": "Kỷ niệm {title}", "menu": "Menu", - "merge": "Gộp", - "merge_people": "Gộp người", - "merge_people_limit": "Bạn chỉ có thể gộp tối đa 5 khuôn mặt cùng một lúc", - "merge_people_prompt": "Bạn có muốn gộp những người này không? Hành động này không thể hoàn tác.", - "merge_people_successfully": "Gộp người thành công", - "merged_people_count": "Đã gộp {count, plural, one {# người} other {# người}}", + "merge": "Hợp nhất", + "merge_people": "Hợp nhất người", + "merge_people_limit": "Bạn chỉ có thể hợp nhất tối đa 5 khuôn mặt cùng một lúc", + "merge_people_prompt": "Bạn có muốn hợp nhất những người này không? Hành động này không thể hoàn tác.", + "merge_people_successfully": "Hợp nhất người thành công", + "merged_people_count": "Đã hợp nhất {count, plural, one {# người} other {# người}}", "minimize": "Thu nhỏ", "minute": "Phút", "missing": "Thiếu", - "model": "Mẫu", + "model": "Dòng", "month": "Tháng", "more": "Thêm", "moved_to_trash": "Đã chuyển vào thùng rác", @@ -854,15 +854,15 @@ "next": "Tiếp theo", "next_memory": "Kỷ niệm tiếp theo", "no": "Không", - "no_albums_message": "Tạo album để tổ chức ảnh và video của bạn", + "no_albums_message": "Tạo album để tổ sắp xếp ảnh và video của bạn", "no_albums_with_name_yet": "Có vẻ như bạn chưa có bất kỳ album nào với tên này.", "no_albums_yet": "Có vẻ như bạn chưa có bất kỳ album nào.", - "no_archived_assets_message": "Lưu trữ ảnh và video để ẩn chúng khỏi mục Ảnh của bạn", - "no_assets_message": "NHẤP VÀO ĐỂ TẢI ẢNH ĐẦU TIÊN CỦA BẠN", + "no_archived_assets_message": "Lưu trữ ảnh và video để ẩn chúng khỏi thư viện Ảnh của bạn", + "no_assets_message": "NHẤP VÀO ĐỂ TẢI LÊN ẢNH ĐẦU TIÊN CỦA BẠN", "no_duplicates_found": "Không tìm thấy các mục trùng lặp.", "no_exif_info_available": "Không có thông tin exif", - "no_explore_results_message": "Tải thêm ảnh để khám phá bộ sưu tập của bạn.", - "no_favorites_message": "Thêm ảnh yêu thích để nhanh chóng tìm thấy những bức ảnh và video tốt nhất của bạn", + "no_explore_results_message": "Tải thêm ảnh lên để khám phá bộ sưu tập của bạn.", + "no_favorites_message": "Thêm ảnh yêu thích để nhanh chóng tìm thấy những bức ảnh và video đẹp nhất của bạn", "no_libraries_message": "Tạo một thư viện bên ngoài để xem ảnh và video của bạn", "no_name": "Không có tên", "no_places": "Không có địa điểm", @@ -870,7 +870,7 @@ "no_results_description": "Thử một từ đồng nghĩa hoặc từ khóa tổng quát hơn", "no_shared_albums_message": "Tạo một album để chia sẻ ảnh và video với mọi người trong mạng của bạn", "not_in_any_album": "Không thuộc album nào", - "note_apply_storage_label_to_previously_uploaded assets": "Lưu ý: Để áp dụng Nhãn lưu trữ cho các tệp tin đã tải lên trước đó, hãy chạy", + "note_apply_storage_label_to_previously_uploaded assets": "Lưu ý: Để áp dụng Nhãn lưu trữ cho các ảnh đã tải lên trước đó, hãy chạy", "note_unlimited_quota": "Lưu ý: Nhập 0 để có hạn mức không giới hạn", "notes": "Ghi chú", "notification_toggle_setting_description": "Bật thông báo qua email", @@ -883,29 +883,30 @@ "ok": "Đồng ý", "oldest_first": "Cũ nhất trước", "onboarding": "Hướng dẫn sử dụng", - "onboarding_theme_description": "Chọn chủ đề màu sắc cho instance của bạn. Bạn có thể thay đổi điều này sau trong cài đặt của bạn.", - "onboarding_welcome_description": "Hãy thiết lập instance của bạn với một số cài đặt chung.", + "onboarding_theme_description": "Chọn chủ đề màu sắc cho tài khoản riêng của bạn. Bạn có thể thay đổi điều này sau trong cài đặt của bạn.", + "onboarding_welcome_description": "Hãy thiết lập tài khoản riêng của bạn với một số cài đặt cơ bản.", "onboarding_welcome_user": "Chào mừng, {user}", "online": "Trực tuyến", "only_favorites": "Chỉ yêu thích", - "only_refreshes_modified_files": "Chỉ làm mới các tập tin đã được chỉnh sửa", + "only_refreshes_modified_files": "Chỉ làm mới các tập tin đã thay đổi", + "open_in_map_view": "Mở trong bản đồ", "open_in_openstreetmap": "Mở trong OpenStreetMap", "open_the_search_filters": "Mở bộ lọc tìm kiếm", "options": "Tùy chọn", "or": "hoặc", - "organize_your_library": "Tổ chức thư viện của bạn", + "organize_your_library": "Sắp xếp thư viện của bạn", "original": "Gốc", "other": "Khác", "other_devices": "Các thiết bị khác", "other_variables": "Các tham số khác", "owned": "Sở hữu", "owner": "Chủ sở hữu", - "partner": "Đối tác", + "partner": "Người thân", "partner_can_access": "{partner} có thể truy cập", "partner_can_access_assets": "Tất cả ảnh và video của bạn ngoại trừ những ảnh và video trong mục Đã lưu trữ và Đã xóa", - "partner_can_access_location": "Địa điểm nơi ảnh của bạn được chụp", - "partner_sharing": "Chia sẻ đối tác", - "partners": "Đối tác", + "partner_can_access_location": "Vị trí nơi ảnh của bạn được chụp", + "partner_sharing": "Chia sẻ với người thân", + "partners": "Người thân", "password": "Mật khẩu", "password_does_not_match": "Mật khẩu không khớp", "password_required": "Yêu cầu mật khẩu", @@ -916,7 +917,7 @@ "years": "Cách đây {years, plural, one {năm} other {# năm}}" }, "path": "Đường dẫn", - "pattern": "Mẫu", + "pattern": "Quy tắc", "pause": "Tạm dừng", "pause_memories": "Tạm dừng kỷ niệm", "paused": "Đã tạm dừng", @@ -926,20 +927,20 @@ "people_sidebar_description": "Hiển thị mục Mọi người trong thanh bên", "perform_library_tasks": "", "permanent_deletion_warning": "Cảnh báo xóa vĩnh viễn", - "permanent_deletion_warning_setting_description": "Hiển thị cảnh báo khi xóa tệp tin vĩnh viễn", + "permanent_deletion_warning_setting_description": "Hiển thị cảnh báo khi xóa vĩnh viễn ảnh", "permanently_delete": "Xóa vĩnh viễn", - "permanently_delete_assets_count": "Xóa vĩnh viễn {count, plural, one {tệp tin} other {tệp tin}}", - "permanently_delete_assets_prompt": "Bạn có chắc chắn muốn xóa vĩnh viễn {count, plural, one {tệp tin này?} other {các tệp tin # này?}} Điều này cũng sẽ xóa {count, plural, one {nó khỏi} other {chúng khỏi}} album(s).", - "permanently_deleted_asset": "tệp tin đã bị xóa vĩnh viễn", - "permanently_deleted_assets_count": "Đã xóa vĩnh viễn {count, plural, one {# tệp tin} other {# tệp tin}}", - "person": "Người", - "person_hidden": "{name}{hidden, select, true { (ẩn)} other {}}", + "permanently_delete_assets_count": "Xóa vĩnh viễn {count, plural, one {mục} other {mục}}", + "permanently_delete_assets_prompt": "Bạn có chắc chắn muốn xóa vĩnh viễn {count, plural, one {mục này?} other {# mục này?}} Điều này cũng sẽ xóa {count, plural, one {nó khỏi} other {chúng khỏi}} các album.", + "permanently_deleted_asset": "Ảnh đã bị xóa vĩnh viễn", + "permanently_deleted_assets_count": "Đã xóa vĩnh viễn {count, plural, one {# mục} other {# mục}}", + "person": "Mọi người", + "person_hidden": "{name}{hidden, select, true { (đã ẩn)} other {}}", "photo_shared_all_users": "Có vẻ như bạn đã chia sẻ ảnh của mình với tất cả người dùng hoặc bạn không có người dùng nào để chia sẻ.", "photos": "Ảnh", "photos_and_videos": "Ảnh & Video", "photos_count": "{count, plural, one {{count, number} Ảnh} other {{count, number} Ảnh}}", "photos_from_previous_years": "Ảnh từ các năm trước", - "pick_a_location": "Chọn một địa điểm", + "pick_a_location": "Chọn một vị trí", "place": "Địa điểm", "places": "Địa điểm", "play": "Phát", @@ -948,91 +949,93 @@ "play_or_pause_video": "Phát hoặc tạm dừng video", "point": "", "port": "Cổng", - "preset": "Cài đặt sẵn", + "preset": "Mẫu có sẵn", "preview": "Xem trước", "previous": "Trước", "previous_memory": "Kỷ niệm trước", "previous_or_next_photo": "Ảnh trước hoặc sau", "primary": "Chính", - "profile_image_of_user": "Ảnh hồ sơ của {user}", + "profile_image_of_user": "Ảnh đại diệncủa {user}", "profile_picture_set": "Ảnh đại diện đã được đặt.", "public_album": "Album công khai", "public_share": "Chia sẻ công khai", "purchase_account_info": "Người hỗ trợ", "purchase_activated_subtitle": "Cảm ơn bạn đã hỗ trợ Immich và phần mềm mã nguồn mở", - "purchase_activated_time": "Kích hoạt vào {date, date}", + "purchase_activated_time": "Đã kích hoạt vào {date, date}", "purchase_activated_title": "Khóa của bạn đã được kích hoạt thành công", "purchase_button_activate": "Kích hoạt", "purchase_button_buy": "Mua", "purchase_button_buy_immich": "Mua Immich", "purchase_button_never_show_again": "Không hiển thị lại", "purchase_button_reminder": "Nhắc tôi trong 30 ngày", - "purchase_button_remove_key": "Gỡ khóa", + "purchase_button_remove_key": "Xoá khóa", "purchase_button_select": "Chọn", - "purchase_failed_activation": "Kích hoạt thất bại! Vui lòng kiểm tra email của bạn để có khóa sản phẩm chính xác!", + "purchase_failed_activation": "Kích hoạt thất bại! Vui lòng kiểm tra email của bạn để biết khóa sản phẩm chính xác!", "purchase_individual_description_1": "Dành cho cá nhân", "purchase_individual_description_2": "Trạng thái người hỗ trợ", "purchase_individual_title": "Cá nhân", "purchase_input_suggestion": "Có khóa sản phẩm? Nhập khóa bên dưới", - "purchase_license_subtitle": "Mua Immich để hỗ trợ phát triển dịch vụ liên tục", + "purchase_license_subtitle": "Mua Immich để hỗ trợ sự phát triển liên tục của dịch vụ", "purchase_lifetime_description": "Mua trọn đời", "purchase_option_title": "TÙY CHỌN MUA HÀNG", - "purchase_panel_info_1": "Việc xây dựng Immich tốn nhiều thời gian và công sức, và chúng tôi có các kỹ sư toàn thời gian làm việc để làm cho nó tốt nhất có thể. Sứ mệnh của chúng tôi là phần mềm mã nguồn mở và thực hành kinh doanh đạo đức trở thành nguồn thu nhập bền vững cho các nhà phát triển và tạo ra một hệ sinh thái tôn trọng quyền riêng tư với các lựa chọn thay thế thực sự cho các dịch vụ đám mây khai thác.", + "purchase_panel_info_1": "Việc xây dựng Immich tốn nhiều thời gian và công sức, và chúng tôi có các kỹ sư toàn thời gian làm việc để làm cho nó tốt nhất có thể. Sứ mệnh của chúng tôi là phần mềm mã nguồn mở và các hoạt động kinh doanh có đạo đức trở thành nguồn thu nhập bền vững cho các nhà phát triển, đồng thời tạo ra một hệ sinh thái bảo vệ quyền riêng tư với các lựa chọn thay thế thực sự cho các dịch vụ đám mây lợi dụng người dùng.", "purchase_panel_info_2": "Vì chúng tôi cam kết không thêm các tường thu phí, việc mua này sẽ không cấp cho bạn bất kỳ tính năng bổ sung nào trong Immich. Chúng tôi phụ thuộc vào những người dùng như bạn để hỗ trợ sự phát triển liên tục của Immich.", "purchase_panel_title": "Hỗ trợ dự án", "purchase_per_server": "Mỗi máy chủ", "purchase_per_user": "Mỗi người dùng", - "purchase_remove_product_key": "Gỡ khóa sản phẩm", - "purchase_remove_product_key_prompt": "Bạn có chắc chắn muốn gỡ khóa sản phẩm?", - "purchase_remove_server_product_key": "Gỡ khóa sản phẩm máy chủ", - "purchase_remove_server_product_key_prompt": "Bạn có chắc chắn muốn gỡ khóa sản phẩm máy chủ?", + "purchase_remove_product_key": "Xoá khóa sản phẩm", + "purchase_remove_product_key_prompt": "Bạn có chắc chắn muốn xoá khóa sản phẩm?", + "purchase_remove_server_product_key": "Xoá khóa sản phẩm máy chủ", + "purchase_remove_server_product_key_prompt": "Bạn có chắc chắn muốn xoá khóa sản phẩm máy chủ?", "purchase_server_description_1": "Dành cho toàn bộ máy chủ", "purchase_server_description_2": "Trạng thái người hỗ trợ", "purchase_server_title": "Máy chủ", "purchase_settings_server_activated": "Khóa sản phẩm máy chủ được quản lý bởi quản trị viên", "range": "", + "rating": "Xếp hạng sao", + "rating_description": "Hiển thị xếp hạng ảnh trong bảng thông tin", "raw": "", "reaction_options": "Tùy chọn phản ứng", - "read_changelog": "Đọc bản thay đổi", + "read_changelog": "Đọc nhật ký thay đổi", "reassign": "Gán lại", - "reassigned_assets_to_existing_person": "Đã gán lại {count, plural, one {# tệp tin} other {# tệp tin}} cho {name, select, null {một người hiện có} other {{name}}}", - "reassigned_assets_to_new_person": "Đã gán lại {count, plural, one {# tệp tin} other {# tệp tin}} cho một người mới", - "reassing_hint": "Gán các tệp tin đã chọn cho một người hiện có", + "reassigned_assets_to_existing_person": "Đã gán lại {count, plural, one {# ảnh} other {# ảnh}} cho {name, select, null {một người hiện có} other {{name}}}", + "reassigned_assets_to_new_person": "Đã gán lại {count, plural, one {# ảnh} other {# ảnh}} cho một người mới", + "reassing_hint": "Gán các ảnh đã chọn cho một người hiện có", "recent": "Gần đây", "recent_searches": "Tìm kiếm gần đây", "refresh": "Làm mới", "refresh_encoded_videos": "Làm mới video đã mã hóa", - "refresh_metadata": "Làm mới dữ liệu siêu tập tin", + "refresh_metadata": "Làm mới metadata", "refresh_thumbnails": "Làm mới hình thu nhỏ", "refreshed": "Đã làm mới", - "refreshes_every_file": "Làm mới mọi tệp tin", + "refreshes_every_file": "Làm mới mọi tập tin", "refreshing_encoded_video": "Đang làm mới video đã mã hóa", - "refreshing_metadata": "Đang làm mới dữ liệu siêu tập tin", - "regenerating_thumbnails": "Đang tái tạo hình thu nhỏ", - "remove": "Gỡ bỏ", - "remove_assets_album_confirmation": "Bạn có chắc chắn muốn gỡ bỏ {count, plural, one {# tệp tin} other {# tệp tin}} khỏi album?", - "remove_assets_shared_link_confirmation": "Bạn có chắc chắn muốn gỡ bỏ {count, plural, one {# tệp tin} other {# tệp tin}} khỏi liên kết chia sẻ này?", - "remove_assets_title": "Gỡ bỏ tệp tin?", - "remove_custom_date_range": "Gỡ bỏ phạm vi ngày tùy chỉnh", - "remove_from_album": "Gỡ bỏ khỏi album", - "remove_from_favorites": "Gỡ bỏ khỏi Mục yêu thích", - "remove_from_shared_link": "Gỡ bỏ khỏi liên kết chia sẻ", - "remove_offline_files": "Gỡ bỏ tệp tin ngoại tuyến", - "remove_user": "Gỡ bỏ người dùng", - "removed_api_key": "Đã gỡ khóa API: {name}", - "removed_from_archive": "Đã gỡ bỏ khỏi lưu trữ", - "removed_from_favorites": "Đã gỡ bỏ khỏi Mục yêu thích", - "removed_from_favorites_count": "{count, plural, other {Đã gỡ bỏ #}} khỏi Mục yêu thích", + "refreshing_metadata": "Đang làm mới metadata", + "regenerating_thumbnails": "Đang tạo lại hình thu nhỏ", + "remove": "Xoá", + "remove_assets_album_confirmation": "Bạn có chắc chắn muốn xoá {count, plural, one {# mục} other {# mục}} khỏi album?", + "remove_assets_shared_link_confirmation": "Bạn có chắc chắn muốn xoá {count, plural, one {# mục} other {# mục}} khỏi liên kết chia sẻ này?", + "remove_assets_title": "Xoá mục?", + "remove_custom_date_range": "Bỏ chọn khoảng ngày tùy chỉnh", + "remove_from_album": "Xoá khỏi album", + "remove_from_favorites": "Xoá khỏi Mục yêu thích", + "remove_from_shared_link": "Xoá khỏi liên kết chia sẻ", + "remove_offline_files": "Loại bỏ tập tin ngoại tuyến", + "remove_user": "Xoá người dùng", + "removed_api_key": "Khóa API đã xóa: {name}", + "removed_from_archive": "Đã xoá khỏi Kho lưu trữ", + "removed_from_favorites": "Đã xoá khỏi Mục yêu thích", + "removed_from_favorites_count": "{count, plural, other {Đã xoá #}} khỏi Mục yêu thích", "rename": "Đổi tên", "repair": "Sửa chữa", - "repair_no_results_message": "Các tệp không được theo dõi và bị mất sẽ xuất hiện ở đây", - "replace_with_upload": "Thay thế bằng tải lên", + "repair_no_results_message": "Các tập tin không được theo dõi và bị mất sẽ xuất hiện ở đây", + "replace_with_upload": "Thay thế bằng tập tin tải lên", "repository": "Kho lưu trữ", "require_password": "Yêu cầu mật khẩu", - "require_user_to_change_password_on_first_login": "Yêu cầu người dùng thay đổi mật khẩu khi lần đầu đăng nhập", + "require_user_to_change_password_on_first_login": "Yêu cầu người dùng thay đổi mật khẩu ở lần đầu đăng nhập", "reset": "Đặt lại", "reset_password": "Đặt lại mật khẩu", - "reset_people_visibility": "Đặt lại khả năng hiển thị người", + "reset_people_visibility": "Đặt lại trạng thái hiển thị của mọi người", "reset_settings_to_default": "", "reset_to_default": "Đặt lại về mặc định", "resolve_duplicates": "Xử lý các bản trùng lặp", @@ -1040,20 +1043,20 @@ "restore": "Khôi phục", "restore_all": "Khôi phục tất cả", "restore_user": "Khôi phục người dùng", - "restored_asset": "tệp tin đã được khôi phục", + "restored_asset": "Ảnh đã được khôi phục", "resume": "Tiếp tục", "retry_upload": "Thử tải lên lại", "review_duplicates": "Xem xét các mục trùng lặp", "role": "Vai trò", - "role_editor": "Biên tập viên", + "role_editor": "Người chỉnh sửa", "role_viewer": "Người xem", "save": "Lưu", - "saved_api_key": "API Key đã lưu", + "saved_api_key": "Khoá API đã lưu", "saved_profile": "Hồ sơ đã lưu", "saved_settings": "Cài đặt đã lưu", "say_something": "Nói điều gì đó", "scan_all_libraries": "Quét tất cả thư viện", - "scan_all_library_files": "Quét lại tất cả tập tin thư viện", + "scan_all_library_files": "Quét lại tất cả các tập tin thư viện", "scan_new_library_files": "Quét các tập tin thư viện mới", "scan_settings": "Cài đặt quét", "scanning_for_album": "Đang quét album...", @@ -1061,27 +1064,27 @@ "search_albums": "Tìm kiếm album", "search_by_context": "Tìm kiếm theo ngữ cảnh", "search_by_filename": "Tìm kiếm theo tên hoặc phần mở rộng tập tin", - "search_by_filename_example": "ví dụ: IMG_1234.JPG hoặc PNG", + "search_by_filename_example": "Ví dụ: IMG_1234.JPG hoặc PNG", "search_camera_make": "Tìm kiếm thương hiệu máy ảnh...", - "search_camera_model": "Tìm kiếm mẫu máy ảnh...", + "search_camera_model": "Tìm kiếm dòng máy ảnh...", "search_city": "Tìm kiếm thành phố...", "search_country": "Tìm kiếm quốc gia...", - "search_for_existing_person": "Tìm kiếm người đã tồn tại", + "search_for_existing_person": "Tìm kiếm người hiện có", "search_no_people": "Không có người", "search_no_people_named": "Không có người tên \"{name}\"", "search_people": "Tìm kiếm người", "search_places": "Tìm kiếm địa điểm", - "search_state": "Tìm kiếm tiểu bang...", + "search_state": "Tìm kiếm tỉnh...", "search_timezone": "Tìm kiếm múi giờ...", "search_type": "Loại tìm kiếm", "search_your_photos": "Tìm kiếm ảnh của bạn", - "searching_locales": "Đang tìm kiếm địa phương...", + "searching_locales": "Đang tìm kiếm khu vực...", "second": "Giây", - "see_all_people": "Xem tất cả người", - "select_album_cover": "Chọn bìa album", + "see_all_people": "Xem tất cả mọi người", + "select_album_cover": "Chọn ảnh bìa album", "select_all": "Chọn tất cả", "select_all_duplicates": "Chọn tất cả các bản trùng lặp", - "select_avatar_color": "Chọn màu đại diện", + "select_avatar_color": "Chọn màu ảnh đại diện", "select_face": "Chọn khuôn mặt", "select_featured_photo": "Chọn ảnh nổi bật", "select_from_computer": "Chọn từ máy tính", @@ -1091,7 +1094,7 @@ "select_photos": "Chọn ảnh", "select_trash_all": "Chọn xoá tất cả", "selected": "Đã chọn", - "selected_count": "{count, plural, other {# đã chọn}}", + "selected_count": "{count, plural, other {Đã chọn # mục}}", "send_message": "Gửi tin nhắn", "send_welcome_email": "Gửi email chào mừng", "server": "", @@ -1100,27 +1103,29 @@ "server_stats": "Thống kê máy chủ", "server_version": "Phiên bản máy chủ", "set": "Đặt", - "set_as_album_cover": "Đặt làm bìa album", + "set_as_album_cover": "Đặt làm ảnh bìa album", "set_as_profile_picture": "Đặt làm ảnh đại diện", "set_date_of_birth": "Đặt ngày sinh", "set_profile_picture": "Đặt ảnh đại diện", "set_slideshow_to_fullscreen": "Đặt trình chiếu ở chế độ toàn màn hình", "settings": "Cài đặt", - "settings_saved": "Cài đặt đã lưu", + "settings_saved": "Đã lưu cài đặt", "share": "Chia sẻ", - "shared": "Đã chia sẻ", - "shared_by": "Chia sẻ bởi", - "shared_by_user": "Chia sẻ bởi {user}", - "shared_by_you": "Chia sẻ bởi bạn", + "shared": "Đã được chia sẻ", + "shared_by": "Được chia sẻ bởi", + "shared_by_user": "Được chia sẻ bởi {user}", + "shared_by_you": "Được chia sẻ bởi bạn", "shared_from_partner": "Ảnh từ {partner}", - "shared_links": "Liên kết đã chia sẻ", + "shared_link_options": "Tùy chọn liên kết chia sẻ", + "shared_links": "Liên kết chia sẻ", "shared_photos_and_videos_count": "{assetCount, plural, other {# ảnh & video đã chia sẻ.}}", - "shared_with_partner": "Chia sẻ với {partner}", + "shared_with_partner": "Được chia sẻ với {partner}", "sharing": "Chia sẻ", "sharing_enter_password": "Vui lòng nhập mật khẩu để xem trang này.", - "sharing_sidebar_description": "Hiển thị liên kết đến Chia sẻ trên thanh bên", - "shift_to_permanent_delete": "nhấn ⇧ để xóa vĩnh viễn tệp tin", + "sharing_sidebar_description": "Hiển thị mục Chia sẻ trong thanh bên", + "shift_to_permanent_delete": "nhấn ⇧ để xóa vĩnh viễn ảnh", "show_album_options": "Hiển thị tùy chọn album", + "show_albums": "Hiển thị album", "show_all_people": "Hiển thị tất cả mọi người", "show_and_hide_people": "Hiển thị & ẩn người", "show_file_location": "Hiển thị vị trí tập tin", @@ -1152,17 +1157,19 @@ "sort_recent": "Ảnh gần đây nhất", "sort_title": "Tiêu đề", "source": "Nguồn", - "stack": "Xếp nhóm", - "stack_selected_photos": "Xếp nhóm các ảnh đã chọn", - "stacked_assets_count": "Xếp nhóm {count, plural, one {# tệp tin} other {# tệp tin}}", + "stack": "Nhóm ảnh", + "stack_duplicates": "Nhóm mục trùng lặp", + "stack_select_one_photo": "Chọn một ảnh chính cho nhóm ảnh", + "stack_selected_photos": "Nhóm các ảnh đã chọn", + "stacked_assets_count": "Đã nhóm {count, plural, one {# mục} other {# mục}}", "stacktrace": "Thông tin chi tiết lỗi", "start": "Bắt đầu", "start_date": "Ngày bắt đầu", - "state": "Tiểu bang", + "state": "Tỉnh", "status": "Trạng thái", "stop_motion_photo": "Dừng ảnh chuyển động", "stop_photo_sharing": "Dừng chia sẻ ảnh của bạn?", - "stop_photo_sharing_description": "{partner} sẽ không còn khả năng truy cập ảnh của bạn.", + "stop_photo_sharing_description": "{partner} sẽ không thể truy cập được ảnh của bạn.", "stop_sharing_photos_with_user": "Dừng chia sẻ ảnh của bạn với người dùng này", "storage": "Bộ nhớ", "storage_label": "Nhãn lưu trữ", @@ -1170,13 +1177,13 @@ "submit": "Gửi", "suggestions": "Gợi ý", "sunrise_on_the_beach": "Bình minh trên bãi biển", - "swap_merge_direction": "Hoán đổi hướng gộp", - "sync": "Đồng bộ hóa", + "swap_merge_direction": "Đổi hướng hợp nhất", + "sync": "Đồng bộ", "template": "Mẫu", "theme": "Giao diện", - "theme_selection": "Giao diện", + "theme_selection": "Giao diện tổng thể", "theme_selection_description": "Tự động đặt giao diện sáng hoặc tối dựa trên tùy chọn hệ thống của trình duyệt của bạn", - "they_will_be_merged_together": "Chúng sẽ được gộp lại với nhau", + "they_will_be_merged_together": "Chúng sẽ được hợp nhất với nhau", "time_based_memories": "Kỷ niệm dựa trên thời gian", "timezone": "Múi giờ", "to_archive": "Lưu trữ", @@ -1187,13 +1194,13 @@ "toggle_settings": "Chuyển đổi cài đặt", "toggle_theme": "Chuyển đổi giao diện", "toggle_visibility": "", - "total_usage": "Tổng sử dụng", + "total_usage": "Tổng dung lượng đã sử dụng", "trash": "Thùng rác", - "trash_all": "Vứt tất cả", - "trash_count": "Thùng rác {count, number}", - "trash_delete_asset": "Vứt bỏ/Xóa tệp tin", - "trash_no_results_message": "Ảnh và video đã bị vứt vào thùng rác sẽ xuất hiện ở đây.", - "trashed_items_will_be_permanently_deleted_after": "Các mục đã bị vứt vào thùng rác sẽ bị xóa vĩnh viễn sau {days, plural, one {# ngày} other {# ngày}}.", + "trash_all": "Xoá hết", + "trash_count": "Xoá {count, number} mục", + "trash_delete_asset": "Chuyển vào thùng rác/Xóa vĩnh viễn", + "trash_no_results_message": "Ảnh và video đã bị xoá sẽ hiển thị ở đây.", + "trashed_items_will_be_permanently_deleted_after": "Các mục đã xóa sẽ bị xóa vĩnh viễn sau {days, plural, one {# ngày} other {# ngày}}.", "type": "Loại", "unarchive": "Huỷ lưu trữ", "unarchived": "", @@ -1212,14 +1219,14 @@ "unselect_all": "Bỏ chọn tất cả", "unselect_all_duplicates": "Bỏ chọn tất cả các bản trùng lặp", "unstack": "Huỷ xếp nhóm", - "unstacked_assets_count": "Huỷ xếp nhóm {count, plural, one {# tập tin} other {# tập tin}}", + "unstacked_assets_count": "Đã huỷ xếp nhóm {count, plural, one {# mục} other {# mục}}", "untracked_files": "Các tập tin không được theo dõi", "untracked_files_decription": "Các tập tin này không được ứng dụng theo dõi. Chúng có thể là kết quả của quá trình di chuyển thất bại, tải lên bị gián đoạn hoặc bị bỏ lại do lỗi", "up_next": "Tiếp theo", "updated_password": "Đã cập nhật mật khẩu", "upload": "Tải lên", "upload_concurrency": "Tải lên đồng thời", - "upload_errors": "Tải lên đã hoàn tất với {count, plural, one {# lỗi} other {# lỗi}}, làm mới trang để xem các tập tin mới tải lên.", + "upload_errors": "Tải lên đã hoàn tất với {count, plural, one {# lỗi} other {# lỗi}}, làm mới trang để xem các ảnh mới tải lên.", "upload_progress": "Còn lại {remaining, number} - Đã xử lý {processed, number}/{total, number}", "upload_skipped_duplicates": "Đã bỏ qua {count, plural, one {# mục trùng lặp} other {# mục trùng lặp}}", "upload_status_duplicates": "Mục trùng lặp", @@ -1228,7 +1235,7 @@ "upload_success": "Tải lên thành công, làm mới trang để xem các tập tin mới tải lên.", "url": "URL", "usage": "Sử dụng", - "use_custom_date_range": "Sử dụng khoảng thời gian tùy chỉnh thay vì", + "use_custom_date_range": "Sử dụng khoảng thời gian tuỳ chỉnh", "user": "Người dùng", "user_id": "ID người dùng", "user_liked": "{user} đã thích {type, select, photo {ảnh này} video {video này} asset {tập tin này} other {nó}}", @@ -1256,9 +1263,9 @@ "view_links": "Xem các liên kết", "view_next_asset": "Xem ảnh tiếp theo", "view_previous_asset": "Xem ảnh trước đó", - "view_stack": "Xem xếp nhóm", + "view_stack": "Xem nhóm ảnh", "viewer": "", - "visibility_changed": "Đã thay đổi tình trạng hiển thị cho {count, plural, one {# người} other {# người}}", + "visibility_changed": "Đã thay đổi trạng thái hiển thị cho {count, plural, one {# người} other {# người}}", "waiting": "Đang chờ", "warning": "Cảnh báo", "week": "Tuần", diff --git a/web/src/lib/i18n/zh_Hant.json b/web/src/lib/i18n/zh_Hant.json index 85e0a7344b..f0787bd5b3 100644 --- a/web/src/lib/i18n/zh_Hant.json +++ b/web/src/lib/i18n/zh_Hant.json @@ -20,17 +20,17 @@ "add_partner": "新增同伴", "add_path": "新增路徑", "add_photos": "加入照片", - "add_to": "新增至⋯", + "add_to": "新增至…", "add_to_album": "加入相簿", "add_to_shared_album": "加入共享相簿", "added_to_archive": "已加入封存", "added_to_favorites": "新增至收藏", - "added_to_favorites_count": "已新增 {count} 個項目至收藏", + "added_to_favorites_count": "已新增 {count, number} 個項目至收藏", "admin": { "add_exclusion_pattern_description": "新增排除規則。支援使用「*」、「 **」、「?」來匹配字串。如果要排除所有名稱為「Raw」的檔案或目錄,請使用「**/Raw/**」。如果要排除所有「.tif」結尾的檔案,請使用「**/*.tif」。如果要排除某個絕對路徑,請使用「/path/to/ignore/**」。", "authentication_settings": "驗證設定", "authentication_settings_description": "管理密碼、OAuth 與其他驗證設定", - "authentication_settings_disable_all": "確定要停用所有登入方式嗎?這樣會完全無法登入喔!", + "authentication_settings_disable_all": "確定要停用所有登入方式嗎?這樣會完全無法登入。", "authentication_settings_reenable": "如需重新啟用,請使用 伺服器指令。", "background_task_job": "背景任務", "check_all": "全選", @@ -44,7 +44,7 @@ "crontab_guru": "", "disable_login": "停用登入", "disabled": "已禁用", - "duplicate_detection_job_description": "運行機器學習以檢測相似圖像。此功能仰賴智能搜索", + "duplicate_detection_job_description": "運行機器學習以檢測相似圖像。此功能仰賴智慧搜尋", "exclusion_pattern_description": "排除規則讓您在掃描資料庫時忽略特定文件和文件夾。用於當您有不想導入的文件(例如 RAW 文件)或文件夾。", "external_library_created_at": "外部圖庫(於 {date} 建立)", "external_library_management": "外部圖庫管理", @@ -56,67 +56,75 @@ "forcing_refresh_library_files": "強制重新整理所有圖庫檔案", "image_format_description": "WebP 能產生相對於 JPEG 更小的檔案,但編碼速度較慢。", "image_prefer_embedded_preview": "偏好嵌入的預覽", - "image_prefer_embedded_preview_setting_description": "", + "image_prefer_embedded_preview_setting_description": "優先使用 RAW 的嵌入預覧作影像處理。可以提升某些影像的顏色精確度,但嵌入預覧的影像品質依相機而異,且可能壓縮較多。", "image_prefer_wide_gamut": "偏好廣色域", "image_prefer_wide_gamut_setting_description": "使用 Display P3 來製作縮圖。這可以更好地保留廣色域圖片的鮮豔度,但在舊版瀏覽器或舊設備上,圖片可能會顯示不同。sRGB 圖片會維持 sRGB 以避免顏色變化。", "image_preview_format": "預覽格式", "image_preview_resolution": "預覽解析度", - "image_preview_resolution_description": "", + "image_preview_resolution_description": "檢視單張照片和機器學習時用。高解析度可以保留更多細節,但會增加編碼時間、增加檔案大小、降低應用軟體的流暢度。", "image_quality": "品質", "image_quality_description": "圖片品質從1到100,數值越高代表品質越好但檔案也越大,此選項影響預覽和縮圖圖片。", "image_settings": "圖片設定", "image_settings_description": "管理生成圖片的品質和解析度", "image_thumbnail_format": "縮圖格式", "image_thumbnail_resolution": "縮圖解析度", - "image_thumbnail_resolution_description": "", + "image_thumbnail_resolution_description": "檢視多張照片時用(時間軸、相冊等⋯)。高解析度可以保留更多細節,但會增加編碼時間、增加檔案大小、降低應用軟體的流暢度。", "job_concurrency": "{job}並行", "job_not_concurrency_safe": "這個任務並行並不安全。", "job_settings": "任務設定", "job_settings_description": "管理任務並行", "job_status": "任務狀態", + "jobs_delayed": "{jobCount, plural, other {# 項任務延遲}}", + "jobs_failed": "{jobCount, plural, other {# 項}}任務失敗", "library_created": "已建立圖庫:{library}", - "library_cron_expression": "", - "library_cron_expression_presets": "", + "library_cron_expression": "Cron 表達式", + "library_cron_expression_description": "以 cron 格式設定掃描時段。詳細資訊請參考 Crontab Guru", + "library_cron_expression_presets": "現成的 Cron 表達式", "library_deleted": "圖庫已刪除", - "library_scanning": "", + "library_import_path_description": "選取要載入的資料夾。以掃描資料夾(含子資料夾)內的影像和影片。", + "library_scanning": "定期掃描", "library_scanning_description": "定期圖庫掃描設定", - "library_scanning_enable_description": "", + "library_scanning_enable_description": "啟用圖庫定期掃描", "library_settings": "外部圖庫", "library_settings_description": "管理外部圖庫設定", - "library_tasks_description": "", + "library_tasks_description": "執行圖庫任務", "library_watching_enable_description": "監控外部圖庫的檔案變化", "library_watching_settings": "圖庫監控(實驗中)", "library_watching_settings_description": "自動監控檔案的變化", - "logging_enable_description": "", - "logging_level_description": "", - "logging_settings": "", - "machine_learning_clip_model": "", - "machine_learning_duplicate_detection": "", + "logging_enable_description": "啟用記錄檔", + "logging_level_description": "啟用時的記錄層級。", + "logging_settings": "記錄檔", + "machine_learning_clip_model": "CLIP 模型", + "machine_learning_clip_model_description": "CLIP 模型 名稱列表。更換模型後須對所有影像重新執行「智慧搜尋」。", + "machine_learning_duplicate_detection": "重複檢測", "machine_learning_duplicate_detection_enabled": "啟用重複檢測", - "machine_learning_duplicate_detection_enabled_description": "", - "machine_learning_duplicate_detection_setting_description": "", - "machine_learning_enabled_description": "", + "machine_learning_duplicate_detection_enabled_description": "即使停用,完全一樣的素材仍會被忽略。", + "machine_learning_duplicate_detection_setting_description": "用 CLIP 向量比對潛在重複", + "machine_learning_enabled": "啟用機器學習", + "machine_learning_enabled_description": "若停用,則無視下方的設定,所有機器學習的功能都將停用。", "machine_learning_facial_recognition": "臉部辨識", - "machine_learning_facial_recognition_description": "", - "machine_learning_facial_recognition_model": "", - "machine_learning_facial_recognition_model_description": "", - "machine_learning_facial_recognition_setting_description": "", - "machine_learning_max_detection_distance": "", - "machine_learning_max_detection_distance_description": "", - "machine_learning_max_recognition_distance": "", - "machine_learning_max_recognition_distance_description": "", - "machine_learning_min_detection_score": "", - "machine_learning_min_detection_score_description": "", - "machine_learning_min_recognized_faces": "", - "machine_learning_min_recognized_faces_description": "", - "machine_learning_settings": "", - "machine_learning_settings_description": "", + "machine_learning_facial_recognition_description": "針測、分辨、規類影像中的人臉", + "machine_learning_facial_recognition_model": "人臉辨識模型", + "machine_learning_facial_recognition_model_description": "模型順序由大至小排列。大的模型較慢且使用較多記憶體,但成效較嘉。更換模型後須對所有影像重新執行「人臉辨識」。", + "machine_learning_facial_recognition_setting": "啟用人臉辨識", + "machine_learning_facial_recognition_setting_description": "若停用,影像將不會產生人臉特徵編碼,從而「探索」頁面不會有「人物」功能。", + "machine_learning_max_detection_distance": "針測距離上限", + "machine_learning_max_detection_distance_description": "若兩張影像間的距離小於此將被判斷為相同,範圍為 0.001-0.1。數值越高能偵測到越多重複,但也更有可能誤判。", + "machine_learning_max_recognition_distance": "分辨距離上限", + "machine_learning_max_recognition_distance_description": "若兩張人臉間的距離小於此將被判斷為相同人物,範圍為 0-2。數值降低能減少兩人被混在一起的可能性,數值提升能減少同一人被當作不同臉的可能性。由於合並比拆分容易,建議將數值調小。", + "machine_learning_min_detection_score": "最低檢測分數", + "machine_learning_min_detection_score_description": "最低信任分辨率,從0到1。低值會偵測更多的面孔,但可能導致誤報。", + "machine_learning_min_recognized_faces": "最少認出的臉", + "machine_learning_min_recognized_faces_description": "要創建一個人的最低認可面數。 增加此項數目使面部識別更為準確,但以增加可能不把面孔識別於任何人的機會為代價.", + "machine_learning_settings": "機器學習設定", + "machine_learning_settings_description": "管理機器學習的功能和設定", "machine_learning_smart_search": "智慧搜尋", - "machine_learning_smart_search_description": "", - "machine_learning_smart_search_enabled_description": "", + "machine_learning_smart_search_description": "使用 CLIP 嵌入進行語義圖像搜尋", + "machine_learning_smart_search_enabled": "啟用智慧搜尋", + "machine_learning_smart_search_enabled_description": "如果停用,圖片將不會被編碼以進行智能搜尋。", "machine_learning_url_description": "機器學習伺服器的網址", "manage_concurrency": "管理並行", - "manage_log_settings": "", + "manage_log_settings": "管理日誌設定", "map_dark_style": "深色模式", "map_enable_description": "啟用地圖功能", "map_gps_settings": "地圖與 GPS 設定", @@ -131,41 +139,48 @@ "map_style_description": "地圖主題(style.json)的網址", "metadata_extraction_job": "擷取元資料", "metadata_extraction_job_description": "擷取每個檔案的 GPS、解析度等元資料資訊", - "migration_job_description": "", + "migration_job": "遷移", + "migration_job_description": "將照片和人臉的縮圖遷移到最新的文件夾結構", + "no_paths_added": "未添加路徑", + "no_pattern_added": "未添加pattern", + "note_apply_storage_label_previous_assets": "注意:若要將存儲標籤應用於先前上傳的圖片,請運行", + "note_cannot_be_changed_later": "註:這將無法更改!", "note_unlimited_quota": "註:輸入 0 表示不限制配額", - "notification_email_from_address": "", - "notification_email_from_address_description": "", - "notification_email_host_description": "", - "notification_email_ignore_certificate_errors": "", - "notification_email_ignore_certificate_errors_description": "", + "notification_email_from_address": "發出電郵", + "notification_email_from_address_description": "寄出人電郵,例如:\"Immich 相片伺服器 \"", + "notification_email_host_description": "電郵伺服器主機位址 (e.g. smtp.immich.app)", + "notification_email_ignore_certificate_errors": "忽略憑證錯誤", + "notification_email_ignore_certificate_errors_description": "忽略 TLS 憑證驗證錯誤(不建議)", "notification_email_password_description": "以電子郵件伺服器驗證身份時的密碼", - "notification_email_port_description": "", + "notification_email_port_description": "電郵伺服器端口(例如 25、465 或 587)", "notification_email_sent_test_email_button": "傳送測試電子郵件並儲存", - "notification_email_setting_description": "", + "notification_email_setting_description": "發送電子郵件通知的設置", "notification_email_test_email": "傳送測試電子郵件", - "notification_email_test_email_failed": "", - "notification_email_test_email_sent": "", + "notification_email_test_email_failed": "無法發送測試電子郵件,請檢查您的設置值", + "notification_email_test_email_sent": "測試電子郵件已發送至 {email}。請檢查您的收件箱。", "notification_email_username_description": "以電子郵件伺服器驗證身份時的使用者名稱", "notification_enable_email_notifications": "啟用電子郵件通知", "notification_settings": "通知設定", - "notification_settings_description": "", - "oauth_auto_launch": "", + "notification_settings_description": "管理通知設置,包括電子郵件通知", + "oauth_auto_launch": "自動啟動", "oauth_auto_launch_description": "導覽至登入頁面後自動進行 OAuth 登入流程", "oauth_auto_register": "自動註冊", - "oauth_auto_register_description": "", - "oauth_button_text": "", - "oauth_client_id": "", - "oauth_client_secret": "", + "oauth_auto_register_description": "使用 OAuth 登錄後自動註冊新用戶", + "oauth_button_text": "按鈕文字", + "oauth_client_id": "用戶端識別碼", + "oauth_client_secret": "用戶端密碼", "oauth_enable_description": "用 OAuth 登入", "oauth_issuer_url": "簽發者網址", - "oauth_mobile_redirect_uri": "", - "oauth_mobile_redirect_uri_override": "", - "oauth_mobile_redirect_uri_override_description": "", - "oauth_scope": "", - "oauth_settings": "", + "oauth_mobile_redirect_uri": "移動端重定向 URI", + "oauth_mobile_redirect_uri_override": "移動端重定向 URI 覆蓋", + "oauth_mobile_redirect_uri_override_description": "當 'app.immich:/' 是無效的重定向 URI 時啟用。", + "oauth_profile_signing_algorithm": "用戶檔簽名算法", + "oauth_profile_signing_algorithm_description": "用於簽署用戶檔的算法。", + "oauth_scope": "範圍", + "oauth_settings": "OAuth", "oauth_settings_description": "管理 OAuth 登入設定", "oauth_settings_more_details": "欲瞭解此功能,請參閱文件。", - "oauth_signing_algorithm": "", + "oauth_signing_algorithm": "簽名算法", "oauth_storage_label_claim": "儲存標記宣告", "oauth_storage_label_claim_description": "自動將使用者的儲存標記定爲此宣告之值。", "oauth_storage_quota_claim": "儲存配額宣告", @@ -177,14 +192,20 @@ "password_enable_description": "用電子郵件和密碼登入", "password_settings": "密碼登入", "password_settings_description": "管理密碼登入設定", + "paths_validated_successfully": "所有路徑驗證成功", "quota_size_gib": "配額(GiB)", "refreshing_all_libraries": "正在重新整理所有圖庫", + "registration": "管理員註冊", "registration_description": "由於您是本系統的首位使用者,因此將您指派爲負責管理本系統的管理者,其他使用者須由您協助建立帳號。", "removing_offline_files": "移除離線檔案中", "repair_all": "全部糾正", "repair_matched_items": "有 {count, plural, other {# 個項目相符}}", "repaired_items": "已糾正 {count, plural, other {# 個項目}}", "require_password_change_on_login": "要求使用者在首次登入時更改密碼", + "reset_settings_to_default": "重置設置為默認值", + "reset_settings_to_recent_saved": "重置設置為最近保存的設置", + "scanning_library_for_changed_files": "正在掃描資料庫以檢查文件變更", + "scanning_library_for_new_files": "正在掃描資料庫以檢查新文件", "send_welcome_email": "傳送歡迎電子郵件", "server_external_domain_settings": "外部網域", "server_external_domain_settings_description": "公開分享鏈結的網域(包含「http(s)://」)", @@ -192,107 +213,133 @@ "server_settings_description": "管理伺服器設定", "server_welcome_message": "歡迎訊息", "server_welcome_message_description": "在登入頁面顯示的訊息。", - "sidecar_job_description": "", + "sidecar_job": "側接元資料", + "sidecar_job_description": "從檔案系統探索或同步側接(Sidecar)元資料", "slideshow_duration_description": "每張圖片放映的秒數", - "smart_search_job_description": "", - "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", - "storage_template_hash_verification_enabled_description": "", - "storage_template_migration_job": "", - "storage_template_settings": "", - "storage_template_settings_description": "", + "smart_search_job_description": "對檔案運行機器學習以用於智能搜尋", + "storage_template_date_time_description": "檔案的創建時戳會用於判斷時間資訊", + "storage_template_date_time_sample": "時間樣式 {date}", + "storage_template_enable_description": "啟用存儲模板引擎", + "storage_template_hash_verification_enabled": "散列函数驗證已啟用", + "storage_template_hash_verification_enabled_description": "啟用散列函数驗證,除非您知道自己正在做的事,否則請勿禁用此功能", + "storage_template_migration": "存儲模板遷移", + "storage_template_migration_description": "將當前的 {template} 應用於先前上傳的檔案", + "storage_template_migration_info": "模板更改僅適用於新檔案。若要追溯應用模板至先前上傳的檔案,請運行 {job}。", + "storage_template_migration_job": "存儲模板遷移任務", + "storage_template_more_details": "欲了解更多有關此功能的詳細信息,請參閱 存儲模板 及其 影響", + "storage_template_onboarding_description": "啟用此功能後,將根據用戶自定義的模板自動組織文件。由於穩定性問題,此功能已默認關閉。欲了解更多信息,請參閱 文檔。", + "storage_template_path_length": "大致路徑長度限制:{length, number}/{limit, number}", + "storage_template_settings": "存儲模板", + "storage_template_settings_description": "管理上傳檔案的文件夾結構和文件名", + "storage_template_user_label": "{label} 是用戶的存儲標籤", "system_settings": "系統設定", "theme_custom_css_settings": "自訂 CSS", - "theme_custom_css_settings_description": "", + "theme_custom_css_settings_description": "層疊樣式表(CSS)允許自定義 Immich 的設計。", "theme_settings": "主題設定", - "theme_settings_description": "", + "theme_settings_description": "管理 Immich 網頁界面的自定義設置", "these_files_matched_by_checksum": "這些檔案的核對和(Checksum)是相符的", - "thumbnail_generation_job_description": "", + "thumbnail_generation_job": "生成縮圖", + "thumbnail_generation_job_description": "為每個資產生成大、小和模糊的縮圖,並為每個人生成縮圖", "transcode_policy_description": "", - "transcoding_acceleration_api": "", - "transcoding_acceleration_api_description": "", - "transcoding_acceleration_nvenc": "", + "transcoding_acceleration_api": "加速 API", + "transcoding_acceleration_api_description": "該 API 將用您的設備加速轉碼。設置是“盡力而為”:如果失敗,它將退回到軟件轉碼。VP9 轉碼是否可行取決於您的硬件。", + "transcoding_acceleration_nvenc": "NVENC(需要 NVIDIA GPU)", "transcoding_acceleration_qsv": "快速同步(需要第七代或高於第七代的 Intel CPU)", - "transcoding_acceleration_rkmpp": "", - "transcoding_acceleration_vaapi": "", - "transcoding_accepted_audio_codecs": "", - "transcoding_accepted_audio_codecs_description": "", - "transcoding_accepted_video_codecs": "", - "transcoding_accepted_video_codecs_description": "", + "transcoding_acceleration_rkmpp": "RKMPP(僅適用於 Rockchip SoC)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "接受的音頻編解碼器", + "transcoding_accepted_audio_codecs_description": "選擇不需要轉碼的音頻編解碼器。僅用於某些轉碼策略。", + "transcoding_accepted_containers": "接受的容器格式", + "transcoding_accepted_containers_description": "選擇不需要重新封裝為 MP4 的容器格式。僅用於某些轉碼策略。", + "transcoding_accepted_video_codecs": "接受的視頻編碼器", + "transcoding_accepted_video_codecs_description": "選擇不需要轉碼的視頻編解碼器。僅用於某些轉碼策略。", "transcoding_advanced_options_description": "大多數使用者不需要更改的選項", - "transcoding_audio_codec": "", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", - "transcoding_hardware_acceleration": "", - "transcoding_hardware_acceleration_description": "", - "transcoding_hardware_decoding": "", - "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", - "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", - "transcoding_tone_mapping_npl": "", - "transcoding_tone_mapping_npl_description": "", - "transcoding_transcode_policy": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", - "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", + "transcoding_audio_codec": "音頻編解碼器", + "transcoding_audio_codec_description": "Opus 是音質最高的選擇,但會與舊設備或軟件有較低的兼容性。", + "transcoding_bitrate_description": "比特率高於最大比特率或格式不被接受的視頻", + "transcoding_codecs_learn_more": "欲了解此處使用的術語,請參閱 FFmpeg 文檔中的 H.264 編解碼器HEVC 編解碼器VP9 編解碼器。", + "transcoding_constant_quality_mode": "恆定質量模式", + "transcoding_constant_quality_mode_description": "ICQ 比 CQP 更好,但某些硬件加速設備不支持此模式。設置此選項時,會在使用基於質量的編碼時偏好指定的模式。由於 NVENC 不支持 ICQ,此選項對其無效。", + "transcoding_constant_rate_factor": "恆定速率因子(-crf)", + "transcoding_constant_rate_factor_description": "視頻質量級別。典型值為 H.264 的 23、HEVC 的 28、VP9 的 31 和 AV1 的 35。數值越低,質量越高,但會產生較大的文件。", + "transcoding_disabled_description": "不要轉碼任何視頻,可能會導致某些客戶端無法播放", + "transcoding_hardware_acceleration": "硬體加速", + "transcoding_hardware_acceleration_description": "實驗性功能;速度更快,但在相同比特率下質量較低", + "transcoding_hardware_decoding": "硬體解碼", + "transcoding_hardware_decoding_setting_description": "僅適用於 NVENC、QSV 和 RKMPP。啟用端到端加速,而不僅僅是加速編碼。可能並非所有視頻都適用。", + "transcoding_hevc_codec": "HEVC 編解碼器", + "transcoding_max_b_frames": "最大 B 幀數", + "transcoding_max_b_frames_description": "更高的值可以提高壓縮效率,但會降低編碼速度。在舊設備上可能不兼容硬件加速。0 表示禁用 B 幀,而 -1 則會自動設置此值。", + "transcoding_max_bitrate": "最大位元速率", + "transcoding_max_bitrate_description": "設置最大比特率可以使文件大小更具可預測性,但會稍微降低質量。在 720p 分辨率下,典型值為 VP9 或 HEVC 的 2600k,或 H.264 的 4500k。設置為 0 則禁用此功能。", + "transcoding_max_keyframe_interval": "最大關鍵幀間隔", + "transcoding_max_keyframe_interval_description": "設置關鍵幀之間的最大幀距。較低的值會降低壓縮效率,但可以改善尋找時間,並可能改善快速運動場景中的質量。0 會自動設置此值。", + "transcoding_optimal_description": "分辨率高於目標或格式不被接受的視頻", + "transcoding_preferred_hardware_device": "首選硬件設備", + "transcoding_preferred_hardware_device_description": "僅適用於 VAAPI 和 QSV。設置用於硬件轉碼的 DRI 節點。", + "transcoding_preset_preset": "預設值(-preset)", + "transcoding_preset_preset_description": "壓縮速度。在針對特定位元速率時,較慢的預設值會減少檔案大小並提高品質。VP9 會忽略高於「faster」的速度。", + "transcoding_reference_frames": "參考幀數", + "transcoding_reference_frames_description": "壓縮給定幀時參考的幀數。較高的值可以提高壓縮效率,但會降低編碼速度。0 會自動設置此值。", + "transcoding_required_description": "僅限於格式不被接受的視頻", + "transcoding_settings": "影片轉碼設定", + "transcoding_settings_description": "管理影片的解析度和編碼資訊", + "transcoding_target_resolution": "目標解析度", + "transcoding_target_resolution_description": "較高的解析度可以保留更多細節,但編碼所需時間更長,文件大小也會增加,並可能降低應用程序的響應速度。", + "transcoding_temporal_aq": "時間自適應量化(Temporal AQ)", + "transcoding_temporal_aq_description": "僅適用於 NVENC。提高高細節、低運動場景的質量。可能與舊設備不兼容。", + "transcoding_threads": "線程數量", + "transcoding_threads_description": "較高的值會加快編碼速度,但會減少伺服器在運行過程中處理其他任務的空間。此值不應超過 CPU 核心數。設置為 0 可以最大化利用率。", + "transcoding_tone_mapping": "色調映射", + "transcoding_tone_mapping_description": "在將 HDR 視頻轉換為 SDR 時,嘗試保留其外觀。每種算法在顏色、細節和亮度方面都有不同的權衡。Hable 保留細節,Mobius 保留顏色,Reinhard 保留亮度。", + "transcoding_tone_mapping_npl": "色調映射 NPL", + "transcoding_tone_mapping_npl_description": "顏色將調整為在此亮度顯示器上看起來正常。反直觀地,較低的值會增加視頻的亮度,反之亦然,因為它會補償顯示器的亮度。0 會自動設置此值。", + "transcoding_transcode_policy": "轉碼策略", + "transcoding_transcode_policy_description": "視頻何時應進行轉碼的策略。HDR 視頻將始終進行轉碼(除非禁用轉碼)。", + "transcoding_two_pass_encoding": "雙通道編碼", + "transcoding_two_pass_encoding_setting_description": "使用雙通道編碼以產生更高質量的編碼視頻。當啟用最大比特率時(對 H.264 和 HEVC 有效),此模式使用基於最大比特率的比特率範圍,並忽略 CRF。對於 VP9,如果禁用最大比特率,可以使用 CRF。", + "transcoding_video_codec": "視頻編解碼器", + "transcoding_video_codec_description": "VP9 具有高效能和網頁兼容性,但轉碼時間較長。HEVC 性能相似,但網頁兼容性較低。H.264 兼容性廣泛且轉碼速度快,但生成的文件較大。AV1 是最有效的編解碼器,但在舊設備上支持度不足。", + "trash_enabled_description": "啟用垃圾箱功能", + "trash_number_of_days": "日數", + "trash_number_of_days_description": "永久刪除之前,檔案於垃圾箱中保留的日數", + "trash_settings": "垃圾箱設置", + "trash_settings_description": "管理垃圾箱設置", "untracked_files": "未被追蹤的檔案", "untracked_files_description": "這些檔案不會被追蹤。它們可能是移動失誤、上傳中斷或遇到漏洞而遺留的產物", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", + "user_delete_delay": "{user} 的帳戶和資產將安排在 {delay, plural, one {# 天} other {# 天}} 後進行永久刪除。", + "user_delete_delay_settings": "刪除延遲", + "user_delete_delay_settings_description": "移除後永久刪除用戶帳戶和資產的天數。用戶刪除任務會在午夜運行,以檢查是否有準備好刪除的用戶。對此設置的更改將在下一次執行時進行評估。", + "user_delete_immediately": "{user} 的帳戶和資產將被立即排隊進行永久刪除。", + "user_delete_immediately_checkbox": "將用戶和資產排隊進行立即刪除", "user_management": "使用者管理", "user_password_has_been_reset": "使用者密碼已重設:", "user_password_reset_description": "請提供使用者臨時密碼,並告知下次登入時需要更改密碼。", + "user_restore_description": "{user} 的帳戶將被恢復。", + "user_restore_scheduled_removal": "恢復用戶 - 預定於 {date, date, long} 移除", "user_settings": "使用者設定", "user_settings_description": "管理使用者設定", "user_successfully_removed": "已成功移除 {email}(使用者)。", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", - "video_conversion_job_description": "" + "version_check_enabled_description": "啟用定期向 GitHub 發送請求以檢查新版本", + "version_check_settings": "版本檢查", + "version_check_settings_description": "啟用/禁用新版本通知", + "video_conversion_job": "轉碼視頻", + "video_conversion_job_description": "轉碼視頻以提高瀏覽器和設備的兼容性" }, - "admin_email": "", + "admin_email": "管理員電子郵件", "admin_password": "管理者密碼", "administration": "管理", "advanced": "進階", + "age_months": "年齡 {months, plural, one {# 個月} other {# 個月}}", + "age_year_months": "年齡 1 年,{months, plural, one {# 個月} other {# 個月}}", + "age_years": "{years, plural, other {年齡 #}}", "album_added": "已新增相簿", "album_added_notification_setting_description": "當我被加入共享相簿時,用電子郵件通知我", "album_cover_updated": "已更新相簿封面", - "album_delete_confirmation": "確定要刪除「{album}」(相簿)嗎?\n如果已分享此相簿,其他使用者就無法再存取嘍!", + "album_delete_confirmation": "確定要刪除「{album}」(相簿)嗎?\n如果已分享此相簿,其他使用者就無法再存取。", "album_info_updated": "已更新相簿資訊", + "album_leave": "離開相簿?", + "album_leave_confirmation": "您確定要離開 {album} 嗎?", "album_name": "相簿名稱", "album_options": "相簿選項", "album_remove_user": "移除使用者?", @@ -300,129 +347,172 @@ "album_share_no_users": "看來您與所有使用者共享了這本相簿,或沒有其他使用者可供分享。", "album_updated": "已更新相簿", "album_updated_setting_description": "當共享相簿有新檔案時,用電子郵件通知我", + "album_user_left": "已離開 {album}", "album_user_removed": "已移除 {user}", "album_with_link_access": "讓知道鏈結的任何人都可以看到此相簿中的照片及人物。", "albums": "相簿", - "albums_count": "{count} 本相簿", + "albums_count": "{count, plural, one {{count, number} 本相簿} other {{count, number} 本相簿}}", "all": "全部", "all_albums": "所有相簿", - "all_people": "", - "allow_dark_mode": "", - "allow_edits": "", + "all_people": "所有人", + "all_videos": "所有視頻", + "allow_dark_mode": "允許黑暗模式", + "allow_edits": "允許編輯", "allow_public_user_to_download": "開放給使用者下載", "allow_public_user_to_upload": "開放讓使用者上傳", - "api_key": "", + "api_key": "API 金鑰", + "api_key_description": "此值僅顯示一次。請確保在關閉窗口之前複製它。", "api_key_empty": "您的 API 金鑰名稱不能爲空", - "api_keys": "", - "app_settings": "", - "appears_in": "", + "api_keys": "API 金鑰", + "app_settings": "應用設置", + "appears_in": "出現在", "archive": "封存", "archive_or_unarchive_photo": "封存或取消封存照片", "archive_size": "封存量", "archive_size_description": "設定要下載的封存量(單位:GiB)", "archived": "", - "archived_count": "已封存 {count} 個項目", + "archived_count": "{count, plural, other {已封存 # 個項目}}", + "are_these_the_same_person": "這也是同一個人嗎?", + "are_you_sure_to_do_this": "您確定要這麼做嗎?", "asset_added_to_album": "已加入相簿", - "asset_adding_to_album": "加入相簿中⋯⋯", + "asset_adding_to_album": "加入相簿中…", + "asset_description_updated": "檔案描述已更新", "asset_filename_is_offline": "檔案 {filename} 離線了", - "asset_offline": "", + "asset_has_unassigned_faces": "檔案有未分配的面孔", + "asset_hashing": "Hashing中...", + "asset_offline": "檔案離線", + "asset_offline_description": "此檔案己離線。Immich 無法訪問其文件位置。請確保資產可用,然後重新掃描資料庫。", + "asset_skipped": "跳過", + "asset_uploaded": "已上傳", + "asset_uploading": "上傳中...", "assets": "檔案", + "assets_added_count": "已添加 {count, plural, one {# 個資產} other {# 個資產}}", "assets_added_to_album_count": "已將 {count, plural, other {# 個檔案}}加入相簿", "assets_added_to_name_count": "已將 {count, plural, other {# 個檔案}}加入{hasName, select, true {{name}} other {新相簿}}", - "assets_were_part_of_album_count": "檔案已在相簿中", - "authorized_devices": "", + "assets_count": "{count, plural, one {# 個檔案} other {# 個檔案}}", + "assets_moved_to_trash_count": "已將 {count, plural, one {# 個檔案} other {# 個檔案}} 移到垃圾箱", + "assets_permanently_deleted_count": "已永久刪除 {count, plural, one {# 個檔案} other {# 個檔案}}", + "assets_removed_count": "已移除 {count, plural, one {# 個檔案} other {# 個檔案}}", + "assets_restore_confirmation": "您確定要恢復所有垃圾箱中的檔案嗎?此操作無法撤銷!", + "assets_restored_count": "已恢復 {count, plural, one {# 個檔案} other {# 個檔案}}", + "assets_trashed_count": "{count, plural, one {# 個檔案} other {# 個檔案}} 已放入垃圾箱", + "assets_were_part_of_album_count": "{count, plural, one {檔案已} other {檔案已}} 是相冊的一部分", + "authorized_devices": "授權裝置", "back": "后退", - "backward": "", - "blurred_background": "", + "back_close_deselect": "返回、關閉或取消選擇", + "backward": "倒轉", + "birthdate_saved": "已成功保存出生日期", + "birthdate_set_description": "出生日期會用於計算此人在照片拍攝時的年齡。", + "blurred_background": "模糊背景", + "build": "建置編號", + "build_image": "建置映像", + "bulk_delete_duplicates_confirmation": "您確定要批量刪除 {count, plural, one {# 個重複檔案} other {# 個重複檔案}} 嗎?這將保留每組中的最大檔案,並永久刪除所有其他重複項。此操作無法撤銷!", + "bulk_keep_duplicates_confirmation": "您確定要保留 {count, plural, one {# 個重複檔案} other {# 個重複檔案}} 嗎?這將解決所有重複組而不刪除任何內容。", + "bulk_trash_duplicates_confirmation": "您確定要批量將 {count, plural, one {# 個重複檔案} other {# 個重複檔案}} 移到垃圾箱嗎?這將保留每組中最大的檔案,並將所有其他重複項放入垃圾箱。", + "buy": "購買 Immich", "camera": "相機", "camera_brand": "相機品牌", "camera_model": "相機型號", "cancel": "取消", - "cancel_search": "", - "cannot_merge_people": "", - "cannot_update_the_description": "", + "cancel_search": "取消搜尋", + "cannot_merge_people": "無法合併人物", + "cannot_undo_this_action": "您無法撤銷此操作!", + "cannot_update_the_description": "無法更新描述", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", "cant_search_places": "", - "change_date": "", + "change_date": "更改日期", "change_expiration_time": "更改有效期限", - "change_location": "", + "change_location": "更改位置", "change_name": "改名", "change_name_successfully": "改名成功", "change_password": "更改密碼", "change_password_description": "這是您第一次登入系統,或您被要求更改密碼。請在下面輸入新密碼。", "change_your_password": "更改您的密碼", - "changed_visibility_successfully": "", + "changed_visibility_successfully": "已成功更改可見性", + "check_all": "全選", "check_logs": "檢查日誌", + "choose_matching_people_to_merge": "選擇要合併的匹配人物", "city": "城市", "clear": "清空", "clear_all": "全部清除", + "clear_all_recent_searches": "清除所有最近的搜尋", "clear_message": "清除訊息", - "clear_value": "", - "close": "", - "collapse_all": "", + "clear_value": "清除值", + "close": "關閉", + "collapse": "折疊", + "collapse_all": "全部折疊", "color_theme": "色彩主題", - "comment_options": "", - "comments_are_disabled": "", + "comment_deleted": "評論已刪除", + "comment_options": "評論選項", + "comments_and_likes": "評論與讚好", + "comments_are_disabled": "評論已禁用", "confirm": "确定", "confirm_admin_password": "確認管理者密碼", + "confirm_delete_shared_link": "您確定要刪除這個共享鏈接嗎?", "confirm_password": "確認密碼", - "contain": "", + "contain": "包含", "context": "情境", - "continue": "", + "continue": "繼續", "copied_image_to_clipboard": "圖片已複製到剪貼簿。", "copied_to_clipboard": "已複製到剪貼簿!", "copy_error": "複製錯誤", - "copy_file_path": "", + "copy_file_path": "複製文件路徑", "copy_image": "複製圖片", "copy_link": "複製鏈結", "copy_link_to_clipboard": "將鏈結複製到剪貼簿", "copy_password": "複製密碼", "copy_to_clipboard": "複製到剪貼簿", "country": "國家", - "cover": "", - "covers": "", + "cover": "封面", + "covers": "封面", "create": "创建", "create_album": "建立相簿", - "create_library": "", + "create_library": "創建圖庫", "create_link": "建立鏈結", "create_link_to_share": "建立分享鏈結", - "create_new_person": "", + "create_link_to_share_description": "允許任何擁有鏈接的人查看所選的照片", + "create_new_person": "創建新人物", + "create_new_person_hint": "將選定的檔案分配給新人物", "create_new_user": "建立新使用者", "create_user": "建立使用者", "created": "建立於", - "current_device": "", - "custom_locale": "", - "custom_locale_description": "", - "dark": "", - "date_after": "", + "current_device": "此裝置", + "custom_locale": "自定義區域設定", + "custom_locale_description": "根據語言和地區格式化日期和數字", + "dark": "深色", + "date_after": "日期之後", "date_and_time": "日期和时间", - "date_before": "", + "date_before": "日期之前", + "date_of_birth_saved": "出生日期已成功保存", "date_range": "日期範圍", - "day": "", + "day": "日", "deduplicate_all": "刪除所有重複項目", - "default_locale": "", - "default_locale_description": "", + "default_locale": "默認區域設定", + "default_locale_description": "根據您的瀏覽器區域設定格式化日期和數字", "delete": "删除", "delete_album": "刪除相簿", - "delete_key": "", - "delete_library": "", + "delete_api_key_prompt": "您確定要刪除這個 API Key嗎?", + "delete_duplicates_confirmation": "您確定要永久刪除這些重複項嗎?", + "delete_key": "刪除密鑰", + "delete_library": "刪除圖庫", "delete_link": "刪除鏈結", "delete_shared_link": "刪除分享鏈結", "delete_user": "刪除使用者", "deleted_shared_link": "已刪除分享鏈結", "description": "描述", "details": "詳情", - "direction": "", - "disallow_edits": "", + "direction": "方向", + "disabled": "禁用", + "disallow_edits": "不允許編輯", "discover": "探索", - "dismiss_all_errors": "", - "dismiss_error": "", - "display_options": "", - "display_order": "", + "dismiss_all_errors": "忽略所有錯誤", + "dismiss_error": "忽略錯誤", + "display_options": "顯示選項", + "display_order": "顯示順序", "display_original_photos": "顯示原始照片", - "display_original_photos_setting_description": "", + "display_original_photos_setting_description": "當網頁兼容原始照片時,偏好查看照片時顯示原始檔案而非縮略圖。這可能會導致照片顯示速度變慢。", "do_not_show_again": "不再顯示此訊息", "done": "完成", "download": "下載", @@ -430,8 +520,10 @@ "download_settings_description": "管理與檔案下載相關的設定", "downloading": "下載中", "downloading_asset_filename": "正在下載 {filename}", + "drop_files_to_upload": "將文件拖放到任何位置以上傳", "duplicates": "重複項目", - "duration": "", + "duplicates_description": "通過指示每一組重複的檔案(如果有)來解決問題", + "duration": "時長", "durations": { "days": "", "hours": "", @@ -439,110 +531,161 @@ "months": "", "years": "" }, + "edit": "編輯", "edit_album": "編輯相簿", "edit_avatar": "編輯形象", - "edit_date": "", - "edit_date_and_time": "", - "edit_exclusion_pattern": "", - "edit_faces": "", - "edit_import_path": "", - "edit_import_paths": "", - "edit_key": "", + "edit_date": "編輯日期", + "edit_date_and_time": "編輯日期與時間", + "edit_exclusion_pattern": "編輯排除模式", + "edit_faces": "編輯人面", + "edit_import_path": "編輯匯入路徑", + "edit_import_paths": "編輯匯入路徑", + "edit_key": "編輯密鑰", "edit_link": "編輯鏈結", "edit_location": "编辑位置信息", "edit_name": "編輯名稱", - "edit_people": "", - "edit_title": "", + "edit_people": "編輯人物", + "edit_title": "編輯標題", "edit_user": "編輯使用者", - "edited": "", + "edited": "己編輯", "editor": "", "email": "電子郵件", "empty": "", "empty_album": "", "empty_trash": "清空回收站", - "enable": "", - "enabled": "", + "empty_trash_confirmation": "您確定要清空垃圾桶嗎?這將永久刪除 Immich 中所有垃圾桶中的檔案。\n您不能撤銷這個操作!", + "enable": "啟用", + "enabled": "己啟用", "end_date": "結束日期", "error": "錯誤", "error_loading_image": "載入圖片時出錯", "error_title": "錯誤 - 出問題了", "errors": { + "cannot_navigate_next_asset": "無法瀏覽下一個檔案", + "cannot_navigate_previous_asset": "無法瀏覽上一個檔案", + "cant_apply_changes": "無法套用更改", + "cant_change_activity": "無法{enabled, select, true {禁用} other {啟用}}活動", + "cant_change_asset_favorite": "無法更改檔案的收藏狀態", + "cant_change_metadata_assets_count": "無法更改 {count, plural, other {# 個檔案}}的元資料", + "cant_get_faces": "無法獲取面孔", + "cant_get_number_of_comments": "無法獲取評論數量", + "cant_search_people": "無法搜尋人", + "cant_search_places": "無法搜尋地點", + "cleared_jobs": "已清除以下工作的任務: {job}", "error_adding_assets_to_album": "將檔案加入相簿時出錯", "error_adding_users_to_album": "將使用者加入相簿時出錯", "error_deleting_shared_user": "刪除共享使用者時出錯", "error_downloading": "下載 {filename} 時出錯", + "error_hiding_buy_button": "隱藏購買按鈕時出錯", "error_removing_assets_from_album": "從相簿中移除檔案時出錯了,請到控制臺瞭解詳情", + "error_selecting_all_assets": "選擇所有檔案時出錯", + "exclusion_pattern_already_exists": "此排除模式已存在。", + "failed_job_command": "命令 {command} 執行失敗,作業:{job}", "failed_to_create_album": "相簿建立失敗", "failed_to_create_shared_link": "建立分享鏈結失敗", "failed_to_edit_shared_link": "編輯分享鏈結失敗", + "failed_to_get_people": "無法獲取人物", "failed_to_load_asset": "檔案載入失敗", "failed_to_load_assets": "檔案載入失敗", + "failed_to_load_people": "無法載入人物", + "failed_to_remove_product_key": "無法移除產品密鑰", + "failed_to_stack_assets": "無法堆疊檔案", + "failed_to_unstack_assets": "無法解除堆疊資產", + "import_path_already_exists": "此匯入路徑已存在。", "incorrect_email_or_password": "電子郵件或密碼有誤", + "paths_validation_failed": "{paths, plural, one {# 個路徑} other {# 個路徑}} 驗證失敗", + "profile_picture_transparent_pixels": "個人頭像不能有透明像素。請放大並/或移動圖像。", "quota_higher_than_disk_size": "您定的配額高於磁碟容量", "repair_unable_to_check_items": "無法檢查 {count, select, other { 個項目}}", "unable_to_add_album_users": "無法將使用者加入相簿", "unable_to_add_assets_to_shared_link": "無法將檔案加上分享鏈結", - "unable_to_add_comment": "", - "unable_to_add_partners": "", + "unable_to_add_comment": "無法添加評論", + "unable_to_add_exclusion_pattern": "無法添加排除模式", + "unable_to_add_import_path": "無法添加匯入路徑", + "unable_to_add_partners": "無法添加夥伴", "unable_to_add_remove_archive": "無法{archived, select, true {從封存中移除檔案} other {將檔案加入封存}}", + "unable_to_add_remove_favorites": "無法 {favorite, select, true {將檔案添加至} other {從中移除檔案}} 收藏夾", "unable_to_archive_unarchive": "無法{archived, select, true {封存} other {取消封存}}", "unable_to_change_album_user_role": "無法更改相簿使用者的角色", - "unable_to_change_date": "", - "unable_to_change_location": "", + "unable_to_change_date": "無法更改日期", + "unable_to_change_favorite": "無法更改檔案的收藏狀態", + "unable_to_change_location": "無法更改位置", "unable_to_change_password": "無法更改密碼", + "unable_to_change_visibility": "無法更改 {count, plural, one {# 位人士} other {# 位人士}} 的可見性", "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_complete_oauth_login": "無法完成 OAuth 登入", + "unable_to_connect": "無法連接", + "unable_to_connect_to_server": "無法連接到伺服器", "unable_to_copy_to_clipboard": "無法複製到剪貼板,請確保您以 https 存取該頁面", - "unable_to_create_admin_account": "", - "unable_to_create_library": "", + "unable_to_create_admin_account": "無法建立管理員帳戶", + "unable_to_create_api_key": "無法建立新的 API 金鑰", + "unable_to_create_library": "無法建立資料庫", "unable_to_create_user": "無法建立使用者", "unable_to_delete_album": "無法刪除相簿", - "unable_to_delete_asset": "", + "unable_to_delete_asset": "無法刪除檔案", + "unable_to_delete_assets": "刪除檔案時發生錯誤", + "unable_to_delete_exclusion_pattern": "無法刪除排除模式", + "unable_to_delete_import_path": "無法刪除匯入路徑", "unable_to_delete_shared_link": "無法刪除分享鏈結", "unable_to_delete_user": "無法刪除使用者", "unable_to_download_files": "無法下載檔案", - "unable_to_empty_trash": "", + "unable_to_edit_exclusion_pattern": "無法編輯排除模式", + "unable_to_edit_import_path": "無法編輯匯入路徑", + "unable_to_empty_trash": "無法清空垃圾桶", "unable_to_enter_fullscreen": "無法進入全螢幕", "unable_to_exit_fullscreen": "無法退出全螢幕", + "unable_to_get_comments_number": "無法獲取評論數量", "unable_to_get_shared_link": "取得分享鏈結失敗", - "unable_to_hide_person": "", + "unable_to_hide_person": "無法隱藏人物", + "unable_to_link_oauth_account": "無法連結 OAuth 帳戶", "unable_to_load_album": "無法載入相簿", - "unable_to_load_asset_activity": "", + "unable_to_load_asset_activity": "無法載入檔案活動", "unable_to_load_items": "無法載入項目", - "unable_to_load_liked_status": "", + "unable_to_load_liked_status": "無法載入讚好狀態", + "unable_to_log_out_all_devices": "無法登出所有裝置", + "unable_to_log_out_device": "無法登出裝置", "unable_to_login_with_oauth": "無法使用 OAuth 登入", - "unable_to_play_video": "", + "unable_to_play_video": "無法播放影片", + "unable_to_reassign_assets_existing_person": "無法將檔案重新指派給 {name, select, null {現有的人員} other {{name}}}", + "unable_to_reassign_assets_new_person": "無法將檔案重新指派給新的人員", "unable_to_refresh_user": "無法重新整理使用者", "unable_to_remove_album_users": "無法從相簿中移除使用者", + "unable_to_remove_api_key": "無法移除 API 金鑰", "unable_to_remove_assets_from_shared_link": "無法從分享鏈結中刪除檔案", "unable_to_remove_comment": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_library": "無法移除資料庫", + "unable_to_remove_offline_files": "無法移除離線檔案", + "unable_to_remove_partner": "無法移除夥伴", + "unable_to_remove_reaction": "無法移除反應", "unable_to_remove_user": "", "unable_to_repair_items": "無法糾正項目", "unable_to_reset_password": "無法重設密碼", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", + "unable_to_resolve_duplicate": "無法解決重複項", + "unable_to_restore_assets": "無法恢復檔案", + "unable_to_restore_trash": "無法恢復垃圾桶內容", + "unable_to_restore_user": "無法恢復使用者", "unable_to_save_album": "無法儲存相簿", + "unable_to_save_api_key": "無法儲存 API 金鑰", + "unable_to_save_date_of_birth": "無法儲存出生日期", "unable_to_save_name": "無法儲存名稱", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", - "unable_to_submit_job": "", - "unable_to_trash_asset": "", + "unable_to_save_profile": "無法儲存個人資料", + "unable_to_save_settings": "無法儲存設定", + "unable_to_scan_libraries": "無法掃描資料庫", + "unable_to_scan_library": "無法掃描資料庫", + "unable_to_set_feature_photo": "無法設置特色照片", + "unable_to_set_profile_picture": "無法設置個人頭像", + "unable_to_submit_job": "無法提交作業", + "unable_to_trash_asset": "無法將檔案移至垃圾桶", "unable_to_unlink_account": "無法對帳號取消連接", "unable_to_update_album_cover": "無法更新相簿封面", "unable_to_update_album_info": "無法更新相簿資訊", - "unable_to_update_library": "", - "unable_to_update_location": "", - "unable_to_update_settings": "", - "unable_to_update_user": "無法更新使用者" + "unable_to_update_library": "無法更新資料庫", + "unable_to_update_location": "無法更新位置", + "unable_to_update_settings": "無法更新設定", + "unable_to_update_timeline_display_status": "無法更新時間軸顯示狀態", + "unable_to_update_user": "無法更新使用者", + "unable_to_upload_file": "無法上傳檔案" }, "every_day_at_onepm": "", "every_night_at_midnight": "", @@ -550,134 +693,163 @@ "every_six_hours": "", "exif": "Exif", "exit_slideshow": "退出幻燈片", - "expand_all": "", + "expand_all": "展開全部", "expire_after": "有效時間", "expired": "已過期", "expires_date": "有效期限:{date}", "explore": "探索", "export": "匯出", "export_as_json": "匯出 JSON", - "extension": "", + "extension": "副檔名", "external": "外部", "external_libraries": "外部圖庫", + "face_unassigned": "未指派", "failed_to_get_people": "", "favorite": "收藏", - "favorite_or_unfavorite_photo": "", + "favorite_or_unfavorite_photo": "收藏或取消收藏照片", "favorites": "收藏", "feature": "", - "feature_photo_updated": "", + "feature_photo_updated": "特色照片已更新", "featurecollection": "", "file_name": "檔名", "file_name_or_extension": "檔名或副檔名", "filename": "檔案名稱", "filetype": "檔案類型", - "filter_people": "", + "filter_people": "篩選人物", "find_them_fast": "搜尋名稱,快速找人", "fix_incorrect_match": "修復不相符的", - "force_re-scan_library_files": "", - "forward": "", - "general": "", + "force_re-scan_library_files": "強制重新掃描所有資料庫檔案", + "forward": "順序", + "general": "一般", "get_help": "線上求助", - "getting_started": "", - "go_back": "", - "go_to_search": "", - "go_to_share_page": "", + "getting_started": "開始使用", + "go_back": "返回", + "go_to_search": "前往搜尋", + "go_to_share_page": "前往分享頁面", "group_albums_by": "相簿分組方式", + "group_no": "無分組", + "group_owner": "按擁有者分組", + "group_year": "按年份分組", "has_quota": "配額", "hi_user": "嗨!{name}({email})", + "hide_all_people": "隱藏所有人物", "hide_gallery": "隱藏畫廊", + "hide_named_person": "隱藏 {name}", "hide_password": "隱藏密碼", - "hide_person": "", - "host": "", - "hour": "", + "hide_person": "隱藏人物", + "hide_unnamed_people": "隱藏未命名人物", + "host": "主機", + "hour": "時", "image": "圖片", + "image_alt_text_date": "{isVideo, select, true {影片} other {圖片}}拍攝於 {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {影片} other {圖片}} 與 {person1} 一同於 {date} 拍攝", + "image_alt_text_date_2_people": "{isVideo, select, true {影片} other {圖片}} 與 {person1} 和 {person2} 一同於 {date} 拍攝", + "image_alt_text_date_3_people": "{isVideo, select, true {影片} other {圖片}} 與 {person1}、{person2} 和 {person3} 一同於 {date} 拍攝", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {影片} other {圖片}} 與 {person1}、{person2} 和其他 {additionalCount, number} 人於 {date} 拍攝", + "image_alt_text_date_place": "{isVideo, select, true {影片} other {圖片}} 於 {city}、{country},{date} 拍攝", + "image_alt_text_date_place_1_person": "{isVideo, select, true {影片} other {圖片}} 於 {city}、{country},與 {person1} 一同在 {date} 拍攝", + "image_alt_text_date_place_2_people": "{isVideo, select, true {影片} other {圖片}} 在 {city}、{country},與 {person1} 和 {person2} 一同於 {date} 拍攝", + "image_alt_text_date_place_3_people": "{isVideo, select, true {影片} other {圖片}} 在 {city}、{country},與 {person1}、{person2} 和 {person3} 一同於 {date} 拍攝", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {影片} other {圖片}} 在 {city}、{country},與 {person1}、{person2} 和其他 {additionalCount, number} 人於 {date} 拍攝", "img": "", - "immich_logo": "", + "immich_logo": "Immich 標誌", + "immich_web_interface": "Immich 網頁介面", "import_from_json": "匯入 JSON", - "import_path": "", + "import_path": "匯入路徑", "in_albums": "在 {count, plural, other {# 本相簿}}中", "in_archive": "已封存", "include_archived": "包含已封存", "include_shared_albums": "包含共享相簿", - "include_shared_partner_assets": "", - "individual_share": "", + "include_shared_partner_assets": "包括共享夥伴檔案", + "individual_share": "個別分享", "info": "資訊", "interval": { - "day_at_onepm": "", - "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" + "day_at_onepm": "每天下午 1 點", + "hours": "每 {hours, plural, one {小時} other {{hours, number} 小時}}", + "night_at_midnight": "每晚午夜", + "night_at_twoam": "每晚凌晨 2 點" }, - "invite_people": "", + "invite_people": "邀請人員", "invite_to_album": "邀請至相簿", "items_count": "{count, plural, other {# 個項目}}", "job_settings_description": "", - "jobs": "", + "jobs": "工作", "keep": "保留", "keep_all": "全部保留", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", + "keyboard_shortcuts": "鍵盤快捷鍵", + "language": "語言", + "language_setting_description": "選擇您的首選語言", + "last_seen": "最後上線", "latest_version": "最新版本", + "latitude": "緯度", "leave": "離開", "let_others_respond": "允许他人回复", - "level": "", + "level": "等級", "library": "圖庫", - "library_options": "", - "light": "", + "library_options": "資料庫選項", + "light": "淺色", + "like_deleted": "已刪除的收藏", "link_options": "鏈結選項", "link_to_oauth": "連接 OAuth", "linked_oauth_account": "已連接 OAuth 帳號", - "list": "", - "loading": "", - "loading_search_results_failed": "", - "log_out": "注销", - "log_out_all_devices": "", + "list": "列表", + "loading": "載入中", + "loading_search_results_failed": "載入搜尋結果失敗", + "log_out": "登出", + "log_out_all_devices": "登出所有裝置", + "logged_out_all_devices": "已登出所有裝置", + "logged_out_device": "已登出裝置", "login": "登入", "login_has_been_disabled": "已停用登入功能。", - "look": "", + "logout_all_device_confirmation": "您確定要登出所有裝置嗎?", + "logout_this_device_confirmation": "您確定要登出這個裝置嗎?", + "longitude": "經度", + "look": "樣貌", "loop_videos": "重播影片", "loop_videos_description": "啟用後,影片結束會自動重播。", "make": "製造商", "manage_shared_links": "管理分享鏈結", - "manage_sharing_with_partners": "", - "manage_the_app_settings": "", - "manage_your_account": "", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", + "manage_sharing_with_partners": "管理與夥伴的分享", + "manage_the_app_settings": "管理應用程式設定", + "manage_your_account": "管理您的帳戶", + "manage_your_api_keys": "管理您的 API 金鑰", + "manage_your_devices": "管理已登入的裝置", + "manage_your_oauth_connection": "管理您的 OAuth 連接", "map": "地圖", - "map_marker_with_image": "", + "map_marker_for_images": "在 {city}、{country} 拍攝圖像的地圖標記", + "map_marker_with_image": "帶有圖像的地圖標記", "map_settings": "地圖設定", "matches": "相符", "media_type": "媒體類型", "memories": "回憶", - "memories_setting_description": "", + "memories_setting_description": "管理您在回憶中顯示的內容", "memory": "回憶", "memory_lane_title": "回憶長廊{title}", "menu": "選單", "merge": "合併", - "merge_people": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", + "merge_people": "合併人物", + "merge_people_limit": "您一次最多只能合併 5 張臉部", + "merge_people_prompt": "您要合併這些人物嗎?此操作無法撤銷。", + "merge_people_successfully": "成功合併人物", + "merged_people_count": "合併了 {count, plural, one {# 位人士} other {# 位人士}}", + "minimize": "最小化", + "minute": "分", "missing": "遺失的", "model": "型號", "month": "月", "more": "更多", - "moved_to_trash": "", + "moved_to_trash": "已移至垃圾桶", "my_albums": "我的相簿", "name": "名稱", "name_or_nickname": "名稱或暱稱", "never": "永遠", "new_album": "新相簿", - "new_api_key": "", + "new_api_key": "新的 API 金鑰", "new_password": "新密碼", - "new_person": "", + "new_person": "新的人物", "new_user_created": "已建立新使用者", - "new_version_available": "新版本發布嘍!", - "newest_first": "", + "new_version_available": "新版本已發布", + "newest_first": "最新優先", "next": "下一張", "next_memory": "下一張回憶", "no": "否", @@ -689,7 +861,7 @@ "no_duplicates_found": "沒發現重複項目。", "no_exif_info_available": "沒有可用的 Exif 資訊", "no_explore_results_message": "上傳更多照片以利探索。", - "no_favorites_message": "", + "no_favorites_message": "將最喜愛的項目添加至收藏夾,以便快速找到您的最佳照片和影片", "no_libraries_message": "建立外部圖庫來查看您的照片和影片", "no_name": "無名", "no_places": "沒有地點", @@ -697,79 +869,92 @@ "no_results_description": "試試同義詞或更通用的關鍵字吧", "no_shared_albums_message": "建立相簿分享照片和影片", "not_in_any_album": "不在任何相簿中", + "note_apply_storage_label_to_previously_uploaded assets": "注意:要將存儲標籤應用於先前上傳的檔案,請運行", "note_unlimited_quota": "註:輸入 0 表示不限制配額", - "notes": "", + "notes": "提示", "notification_toggle_setting_description": "啟用電子郵件通知", "notifications": "通知", "notifications_setting_description": "管理通知", - "oauth": "", - "offline": "", + "oauth": "OAuth", + "offline": "離線", "offline_paths": "失效路徑", "offline_paths_description": "這些可能是手動刪除非外部圖庫的檔案時所遺留的。", "ok": "確定", - "oldest_first": "", - "online": "", - "only_favorites": "", + "oldest_first": "由舊至新", + "onboarding": "入門指南", + "onboarding_theme_description": "選擇顏色主題。您可以稍後在設定中更改此選項。", + "onboarding_welcome_description": "讓我們為您的伺服器架構一些常見的設置。", + "onboarding_welcome_user": "歡迎,{user}", + "online": "在線", + "only_favorites": "僅顯示己收藏", "only_refreshes_modified_files": "只重新整理修改過的檔案", + "open_in_map_view": "開啟地圖檢視", "open_in_openstreetmap": "用 OpenStreetMap 開啟", - "open_the_search_filters": "", + "open_the_search_filters": "打開搜尋過濾器", "options": "選項", "or": "或", "organize_your_library": "整理您的圖庫", - "other": "", - "other_devices": "", - "other_variables": "", + "original": "原圖", + "other": "其他", + "other_devices": "其它裝置", + "other_variables": "其他變數", "owned": "我的", "owner": "所有者", "partner": "同伴", "partner_can_access": "{partner} 可以存取", "partner_can_access_assets": "除了已封存和已刪除之外,您所有的照片和影片", - "partner_sharing": "", - "partners": "", + "partner_can_access_location": "您照片拍攝的位置", + "partner_sharing": "夥伴分享", + "partners": "夥伴", "password": "密碼", "password_does_not_match": "密碼不相符", "password_required": "需要密碼", "password_reset_success": "密碼重設成功", "past_durations": { - "days": "", - "hours": "", - "years": "" + "days": "過去 {days, plural, one {一天} other {# 天}}", + "hours": "過去 {hours, plural, one {一小時} other {# 小時}}", + "years": "過去 {years, plural, one {一年} other {# 年}}" }, - "path": "", - "pattern": "", + "path": "路徑", + "pattern": "模式", "pause": "暫停", "pause_memories": "暫停回憶", "paused": "已暫停", - "pending": "", + "pending": "待處理", "people": "人物", - "people_sidebar_description": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", + "people_edits_count": "編輯了 {count, plural, one {# 位人士} other {# 位人士}}", + "people_sidebar_description": "在側邊欄顯示「人物」的連結", + "permanent_deletion_warning": "永久刪除警告", + "permanent_deletion_warning_setting_description": "在永久刪除檔案時顯示警告", "permanently_delete": "永久刪除", - "permanently_delete_assets_prompt": "確定要永久刪除 {count, plural, other {這 # 個檔案?}}這樣{count, plural, one {他} other {他們}}也會從自己所在的相簿中消失喔!", - "permanently_deleted_asset": "", + "permanently_delete_assets_count": "永久刪除 {count, plural, one {檔案} other {檔案}}", + "permanently_delete_assets_prompt": "確定要永久刪除 {count, plural, other {這 # 個檔案?}}這樣{count, plural, one {它} other {它們}}也會從自己所在的相簿中消失。", + "permanently_deleted_asset": "永久刪除的檔案", + "permanently_deleted_assets_count": "永久刪除的 {count, plural, one {# 個檔案} other {# 個檔案}}", + "person": "人物", "person_hidden": "{name}{hidden, select, true {(隱藏)} other {}}", "photo_shared_all_users": "看來您與所有使用者分享了照片,或沒有其他使用者可供分享。", "photos": "照片", "photos_and_videos": "照片及影片", "photos_count": "{count, plural, other {{count, number} 張照片}}", "photos_from_previous_years": "往年的照片", - "pick_a_location": "", + "pick_a_location": "選擇位置", "place": "地點", "places": "地點", - "play": "", + "play": "播放", "play_memories": "播放回憶", - "play_motion_photo": "", + "play_motion_photo": "播放動態相片", "play_or_pause_video": "播放或暫停影片", "point": "", - "port": "", + "port": "埠口", "preset": "預設", "preview": "預覽", "previous": "上一張", "previous_memory": "上一張回憶", - "previous_or_next_photo": "", - "primary": "", - "profile_picture_set": "", + "previous_or_next_photo": "上一張或下一張照片", + "primary": "首要", + "profile_image_of_user": "{user} 的個人資料圖片", + "profile_picture_set": "已設定個人資料圖片。", "public_album": "公開相簿", "public_share": "公開分享", "purchase_account_info": "擁護者", @@ -777,16 +962,45 @@ "purchase_activated_time": "於 {date, date} 啟用", "purchase_activated_title": "金鑰成功啟用了", "purchase_button_activate": "啟用", + "purchase_button_buy": "購買", + "purchase_button_buy_immich": "購買 Immich", + "purchase_button_never_show_again": "不再顯示", + "purchase_button_reminder": "30天後提醒我", + "purchase_button_remove_key": "移除金鑰", + "purchase_button_select": "選擇", "purchase_failed_activation": "啟用失敗!請檢查您的電子郵件以取得正確的產品金鑰!", + "purchase_individual_description_1": "針對個人", + "purchase_individual_description_2": "支持者狀態", + "purchase_individual_title": "個人", "purchase_input_suggestion": "有產品金鑰嗎?請在下面輸入金鑰", + "purchase_license_subtitle": "購買 Immich 以支持軟件發展", + "purchase_lifetime_description": "終身購買", + "purchase_option_title": "購買選項", + "purchase_panel_info_1": "開發 Immich 可不是件容易的事,花了我們不少功夫。好在有一群全職工程師在背後默默努力,爲的就是把它做到最好。我們的目標很簡單:讓開源軟體和正當的商業模式能成爲開發者的長期飯碗,同時打造出重視隱私的生態系統,讓大家有個不被剝削的雲端服務新選擇。", + "purchase_panel_info_2": "由於我們於不設付費牆,這筆購買不會為你提供 Immich 任何額外功能。我們依賴像你這樣的用戶來支持 Immich 持續開發。", + "purchase_panel_title": "支持這個項目", + "purchase_per_server": "每台伺服器", + "purchase_per_user": "每位使用者", + "purchase_remove_product_key": "移除產品密鑰", + "purchase_remove_product_key_prompt": "您確定要移除產品密鑰嗎?", + "purchase_remove_server_product_key": "移除伺服器產品密鑰", + "purchase_remove_server_product_key_prompt": "您確定要移除伺服器產品密鑰嗎?", + "purchase_server_description_1": "適用於整個伺服器", + "purchase_server_description_2": "支持者狀態", "purchase_server_title": "伺服器", "purchase_settings_server_activated": "伺服器產品金鑰是由管理者管理的", "range": "", + "rating": "評星", + "rating_description": "在資訊面板中顯示 Exif 評等", "raw": "", - "reaction_options": "", + "reaction_options": "反應選項", "read_changelog": "閱覽變更日誌", - "recent": "", - "recent_searches": "", + "reassign": "重新指派", + "reassigned_assets_to_existing_person": "已將 {count, plural, one {# 個檔案} other {# 個檔案}} 重新分配給 {name, select, null {現有的人} other {{name}}}", + "reassigned_assets_to_new_person": "已將 {count, plural, one {# 個檔案} other {# 個檔案}} 重新分配給一位新的使用者", + "reassing_hint": "將選定的檔案分配給己存在的人物", + "recent": "最近", + "recent_searches": "最近搜尋項目", "refresh": "重新整理", "refresh_encoded_videos": "重新整理已編碼的影片", "refresh_metadata": "重新整理元資料", @@ -795,70 +1009,89 @@ "refreshes_every_file": "重新整理所有檔案", "refreshing_encoded_video": "正在重新整理已編碼的影片", "refreshing_metadata": "正在重新整理元資料", - "remove": "", + "regenerating_thumbnails": "重新產生縮圖中", + "remove": "移除", "remove_assets_album_confirmation": "確定要從相簿中移除 {count, plural, other {# 個檔案}}嗎?", "remove_assets_shared_link_confirmation": "確定要從此分享鏈結中移除{count, plural, other {# 個檔案}}嗎?", + "remove_assets_title": "移除檔案?", + "remove_custom_date_range": "移除自訂日期範圍", "remove_from_album": "從相簿中移除", - "remove_from_favorites": "", + "remove_from_favorites": "從收藏中移除", "remove_from_shared_link": "從分享鏈結中移除", - "remove_offline_files": "", + "remove_offline_files": "移除離線檔案", + "remove_user": "移除用戶", "removed_api_key": "已移除 API 金鑰:{name}", "removed_from_archive": "從封存中移除", + "removed_from_favorites": "已從收藏中移除", + "removed_from_favorites_count": "已從收藏中移除 {count, plural, one {#} other {#}}", "rename": "改名", "repair": "糾正", "repair_no_results_message": "未被追蹤及遺失的檔案會顯示在這裏", - "replace_with_upload": "", + "replace_with_upload": "用上傳的檔案取代", + "repository": "儲存庫", "require_password": "需要密碼", "require_user_to_change_password_on_first_login": "要求使用者在首次登入時更改密碼", "reset": "重設", "reset_password": "重設密碼", - "reset_people_visibility": "", + "reset_people_visibility": "重置人物可見性", "reset_settings_to_default": "", "reset_to_default": "設爲預設", + "resolve_duplicates": "解決重複項", + "resolved_all_duplicates": "已解決所有重複項目", "restore": "恢复", - "restore_user": "", + "restore_all": "恢復全部", + "restore_user": "恢復使用者", + "restored_asset": "已恢復檔案", "resume": "繼續", - "retry_upload": "", + "retry_upload": "重試上傳", "review_duplicates": "查核重複項目", "role": "角色", "role_editor": "編輯者", "role_viewer": "檢視者", "save": "保存", - "saved_profile": "", - "saved_settings": "", + "saved_api_key": "已儲存的 API 密鑰", + "saved_profile": "已儲存個人資料", + "saved_settings": "已儲存設定", "say_something": "说些什么", - "scan_all_libraries": "", - "scan_all_library_files": "", - "scan_new_library_files": "", - "scan_settings": "", - "scanning_for_album": "掃描相簿中⋯⋯", + "scan_all_libraries": "掃描所有圖庫", + "scan_all_library_files": "重新掃描所有圖庫文件", + "scan_new_library_files": "掃描新圖庫", + "scan_settings": "掃描設定", + "scanning_for_album": "掃描相簿中……", "search": "搜尋", "search_albums": "搜尋相簿", "search_by_context": "以情境搜尋", "search_by_filename": "以檔名或副檔名搜尋", "search_by_filename_example": "如 IMG_1234.JPG 或 PNG", - "search_camera_make": "搜尋相機製造商⋯", - "search_camera_model": "搜尋相機型號⋯", - "search_city": "搜尋城市⋯", - "search_country": "搜尋國家⋯", - "search_for_existing_person": "", - "search_people": "", + "search_camera_make": "搜尋相機製造商…", + "search_camera_model": "搜尋相機型號…", + "search_city": "搜尋城市…", + "search_country": "搜尋國家…", + "search_for_existing_person": "搜尋現有的人物", + "search_no_people": "沒有人找到", + "search_no_people_named": "沒有名為 \"{name}\" 的人", + "search_people": "搜尋人物", "search_places": "搜尋地點", - "search_state": "搜尋地區⋯", - "search_timezone": "", + "search_state": "搜尋地區…", + "search_timezone": "搜尋時區...", "search_type": "搜尋類型", "search_your_photos": "搜尋照片", - "searching_locales": "", - "second": "", + "searching_locales": "搜尋地區...", + "second": "秒", + "see_all_people": "查看所有人物", "select_album_cover": "選擇相簿封面", - "select_all": "", + "select_all": "選擇全部", + "select_all_duplicates": "選擇所有重複項", "select_avatar_color": "選擇形象顏色", - "select_face": "", - "select_featured_photo": "", - "select_library_owner": "", - "select_new_face": "", + "select_face": "選擇臉孔", + "select_featured_photo": "選擇特色照片", + "select_from_computer": "從電腦中選取", + "select_keep_all": "全部保留", + "select_library_owner": "選擇圖庫擁有者", + "select_new_face": "選擇新臉孔", "select_photos": "選相片", - "selected": "", + "select_trash_all": "全部刪除", + "selected": "已選擇", "selected_count": "{count, plural, other {選了 # 項}}", "send_message": "傳訊息", "send_welcome_email": "傳送歡迎電子郵件", @@ -867,90 +1100,111 @@ "server_online": "伺服器在線", "server_stats": "伺服器統計", "server_version": "目前版本", - "set": "", + "set": "設置", "set_as_album_cover": "設爲相簿封面", - "set_as_profile_picture": "", - "set_date_of_birth": "", - "set_profile_picture": "", + "set_as_profile_picture": "設為個人資料圖片", + "set_date_of_birth": "設置出生日期", + "set_profile_picture": "設置個人資料圖片", "set_slideshow_to_fullscreen": "以全螢幕放映幻燈片", "settings": "設定", - "settings_saved": "", + "settings_saved": "設定已儲存", "share": "分享", "shared": "共享", - "shared_by": "", - "shared_by_you": "", + "shared_by": "共享自", + "shared_by_user": "由 {user} 分享", + "shared_by_you": "由你分享", + "shared_from_partner": "來自 {partner} 的照片", "shared_links": "分享鏈結", "shared_photos_and_videos_count": "{assetCount, plural, other {已分享 # 張照片及影片。}}", "shared_with_partner": "與 {partner} 共享", "sharing": "共享", "sharing_enter_password": "要查看此頁面請輸入密碼。", - "sharing_sidebar_description": "", + "sharing_sidebar_description": "在側邊欄顯示共享連結", "shift_to_permanent_delete": "按 ⇧ 永久刪除檔案", "show_album_options": "顯示相簿選項", - "show_file_location": "", + "show_albums": "顯示相簿", + "show_all_people": "顯示所有人物", + "show_and_hide_people": "顯示與隱藏人物", + "show_file_location": "顯示文件位置", "show_gallery": "顯示畫廊", - "show_hidden_people": "", - "show_in_timeline": "", - "show_in_timeline_setting_description": "", - "show_keyboard_shortcuts": "", + "show_hidden_people": "顯示隱藏的人物", + "show_in_timeline": "在時間軸上顯示", + "show_in_timeline_setting_description": "在時間軸上顯示來自此用戶的照片和影片", + "show_keyboard_shortcuts": "顯示鍵盤快捷鍵", "show_metadata": "顯示元資料", "show_or_hide_info": "顯示或隱藏資訊", "show_password": "顯示密碼", - "show_person_options": "", - "show_progress_bar": "", + "show_person_options": "顯示人物選項", + "show_progress_bar": "顯示進度條", "show_search_options": "顯示搜尋選項", - "shuffle": "", + "show_supporter_badge": "支持者徽章", + "show_supporter_badge_description": "顯示支持者徽章", + "shuffle": "隨機排序", "sign_out": "登出", - "sign_up": "", + "sign_up": "註冊", "size": "用量", - "skip_to_content": "", + "skip_to_content": "跳至內容", "slideshow": "幻燈片", "slideshow_settings": "幻燈片設定", "sort_albums_by": "相簿排序方式", "sort_created": "建立日期", "sort_items": "項目數量", "sort_modified": "日期已修改", + "sort_oldest": "最舊的照片", + "sort_recent": "最新的照片", + "sort_title": "標題", + "source": "來源", "stack": "堆叠", - "stack_selected_photos": "", + "stack_duplicates": "堆疊重複項目", + "stack_select_one_photo": "爲堆疊選一張主要照片", + "stack_selected_photos": "堆疊選定的照片", + "stacked_assets_count": "已堆疊 {count, plural, one {# 個檔案} other {# 個檔案}}", "stacktrace": "堆疊追蹤", "start": "開始", "start_date": "開始日期", "state": "地區", - "status": "", - "stop_motion_photo": "", + "status": "狀態", + "stop_motion_photo": "停格照片", "stop_photo_sharing": "要停止分享您的照片嗎?", + "stop_photo_sharing_description": "{partner} 將無法再訪問你的照片。", + "stop_sharing_photos_with_user": "停止與此用戶共享你的照片", "storage": "儲存空間", "storage_label": "儲存標記", "storage_usage": "用了 {used} / 共 {available}", - "submit": "", + "submit": "提交", "suggestions": "建議", "sunrise_on_the_beach": "日出的海灘", - "swap_merge_direction": "", + "swap_merge_direction": "交換合併方向", "sync": "同步", - "template": "", + "template": "模板", "theme": "主題", - "theme_selection": "", - "theme_selection_description": "", + "theme_selection": "主題選項", + "theme_selection_description": "根據你的瀏覽器系統偏好自動設置主題為淺色或深色", + "they_will_be_merged_together": "它們將會被合併在一起", "time_based_memories": "依時間回憶", "timezone": "時區", "to_archive": "封存", "to_change_password": "更改密碼", + "to_favorite": "收藏", "to_login": "登入", + "to_trash": "垃圾桶", "toggle_settings": "切換設定", "toggle_theme": "切換主題", "toggle_visibility": "", "total_usage": "總用量", "trash": "垃圾桶", "trash_all": "全丟進垃圾桶", - "trash_no_results_message": "", + "trash_count": "刪除 {count, number} 檔案", + "trash_delete_asset": "刪除檔案/放入垃圾桶", + "trash_no_results_message": "垃圾桶中的照片和影片將顯示在這裡。", "trashed_items_will_be_permanently_deleted_after": "垃圾桶中的項目會在 {days, plural, other {# 天}}後永久刪除。", "type": "類型", "unarchive": "取消封存", "unarchived": "", - "unarchived_count": "已取消封存 {count} 個項目", + "unarchived_count": "{count, plural, other {已取消封存 # 個項目}}", "unfavorite": "取消收藏", - "unhide_person": "", - "unknown": "", + "unhide_person": "取消隱藏人物", + "unknown": "未知", "unknown_album": "", "unknown_year": "不知年份", "unlimited": "不限制", @@ -958,46 +1212,63 @@ "unlinked_oauth_account": "已解除連接 OAuth 帳號", "unnamed_album": "未命名相簿", "unnamed_share": "未命名分享", - "unselect_all": "", + "unsaved_change": "未儲存的更改", + "unselect_all": "取消全選", + "unselect_all_duplicates": "取消選擇所有重複項", "unstack": "取消堆叠", + "unstacked_assets_count": "已取消堆疊 {count, plural, one {# 個檔案} other {# 個檔案}}", "untracked_files": "未被追蹤的檔案", "untracked_files_decription": "這些檔案不會被追蹤。它們可能是移動失誤、上傳中斷或遇到漏洞而遺留的產物", - "up_next": "", + "up_next": "下一個", "updated_password": "已更新密碼", "upload": "上傳", "upload_concurrency": "上傳並行", "upload_errors": "上傳完成,但有 {count, plural, other {# 處出錯}},要查看新上傳的檔案請重新整理頁面。", + "upload_progress": "剩餘 {remaining, number} - 已處理 {processed, number}/{total, number}", + "upload_skipped_duplicates": "跳過 {count, plural, one {# 個重複檔案} other {# 個重複檔案}}", "upload_status_duplicates": "重複項目", + "upload_status_errors": "錯誤", + "upload_status_uploaded": "己上載", "upload_success": "上傳成功,要查看新上傳的檔案請重新整理頁面。", "url": "網址", "usage": "用量", + "use_custom_date_range": "改用自訂日期範圍", "user": "使用者", "user_id": "使用者 ID", + "user_liked": "{user} 喜歡了 {type, select, photo {這張照片} video {這段影片} asset {這個檔案} other {它}}", + "user_purchase_settings": "購買", + "user_purchase_settings_description": "管理你的購買", "user_role_set": "設 {user} 爲{role}", "user_usage_detail": "使用者用量詳情", "username": "使用者名稱", "users": "使用者", "utilities": "工具", - "validate": "", - "variables": "", - "version": "", + "validate": "驗證", + "variables": "變數", + "version": "版本", "version_announcement_closing": "敬祝順心,Alex", "version_announcement_message": "嗨~本應用程式可以更新了,爲防止配置出錯,請花點時間閱讀發行說明,並確保 docker-compose.yml.env 設置是最新的,特別是使用 WatchTower 等自動更新工具時。", "video": "影片", + "video_hover_setting": "在鼠標懸停時播放影片縮圖", "video_hover_setting_description": "當滑鼠停在項目上時播放影片縮圖。即使停用,將滑鼠停在播放圖示上也可以播放。", "videos": "影片", "videos_count": "{count, plural, other {# 部影片}}", + "view": "查看", "view_album": "查看相簿", "view_all": "瀏覽全部", "view_all_users": "查看所有使用者", "view_links": "檢視鏈結", - "view_next_asset": "", - "view_previous_asset": "", + "view_next_asset": "查看下一項", + "view_previous_asset": "查看上一項", + "view_stack": "查看堆疊", "viewer": "", + "visibility_changed": "{count, plural, one {# 人} other {# 人}} 的可見性已更改", "waiting": "待處理", - "week": "", - "welcome_to_immich": "", - "year": "", + "warning": "警告", + "week": "周", + "welcome": "歡迎", + "welcome_to_immich": "歡迎使用 Immich", + "year": "年", "years_ago": "{years, plural, other {# 年}}前", "yes": "是", "you_dont_have_any_shared_links": "您沒有分享鏈結", diff --git a/web/src/lib/i18n/zh_SIMPLIFIED.json b/web/src/lib/i18n/zh_SIMPLIFIED.json index e3368552e7..fd3fd5815c 100644 --- a/web/src/lib/i18n/zh_SIMPLIFIED.json +++ b/web/src/lib/i18n/zh_SIMPLIFIED.json @@ -918,6 +918,7 @@ "online": "在线", "only_favorites": "仅显示已收藏", "only_refreshes_modified_files": "仅刷新修改的文件", + "open_in_map_view": "在地图视图中打开", "open_in_openstreetmap": "在OpenStreetMap中打开", "open_the_search_filters": "打开搜索过滤器", "options": "选项", @@ -1021,6 +1022,8 @@ "purchase_server_title": "服务器", "purchase_settings_server_activated": "服务器产品密钥正在由管理员管理", "range": "范围", + "rating": "星级", + "rating_description": "在信息面板中展示EXIF星级", "raw": "Raw", "reaction_options": "反应选项", "read_changelog": "阅读更新日志", @@ -1151,6 +1154,7 @@ "sharing_sidebar_description": "在侧边栏中显示共享链接", "shift_to_permanent_delete": "按住⇧永久删除项目", "show_album_options": "显示相册选项", + "show_albums": "显示相册", "show_all_people": "显示所有人物", "show_and_hide_people": "显示和隐藏人物", "show_file_location": "显示文件位置", @@ -1183,6 +1187,8 @@ "sort_title": "标题", "source": "源", "stack": "堆叠", + "stack_duplicates": "堆叠重复项目", + "stack_select_one_photo": "为堆叠选择一张展示图", "stack_selected_photos": "堆叠选定的照片", "stacked_assets_count": "已归档{count, plural, one {#个项目} other {#个项目}}", "stacktrace": "堆栈跟踪", From 9d09b95618b9223c5df58474e17674a879b82ea6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 21:41:37 +0000 Subject: [PATCH 085/723] chore(deps): update machine-learning (#11739) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/Dockerfile | 4 +- machine-learning/export/Dockerfile | 2 +- machine-learning/poetry.lock | 327 ++++++++++++++--------------- 3 files changed, 161 insertions(+), 172 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index c47bba8985..3ab8875a4d 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:d0131ce0ff4bdb5e9eae6bc86ebde891c207d5cac1f3f582b5de0f903cc68384 AS builder-cpu +FROM python:3.11-bookworm@sha256:add76c758e402c3acf53b8251da50d8ae67989a81ca96ff4331e296773df853d AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:a90e299af8a9cd6b59c4aaed2b024c78561476978244a1ab89742a4a5ac8c974 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:1c0c54195c7c7b46e61a2f3b906e9b55a8165f20388a0eeb4af4c6f8579988ac AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index c467b1d5f6..94082ae957 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:954e438daab0ad0835430ea84acb27dd47d1ea35a7120c3c9dd9d1a5578f4b13 AS builder +FROM mambaorg/micromamba:bookworm-slim@sha256:e37ec9f3f7dea01ef9958d3d924d46077911f7e29c4faed40cd6b37a9ac239fc AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index a44933cb52..11b0530dca 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -75,33 +75,33 @@ trio = ["trio (>=0.23)"] [[package]] name = "black" -version = "24.4.2" +version = "24.8.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, - {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, - {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, - {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, - {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, - {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, - {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, - {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, - {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, - {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, - {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, - {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, - {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, - {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, - {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, - {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, - {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, - {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, - {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, - {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, - {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, - {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, + {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, + {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, + {file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"}, + {file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"}, + {file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"}, + {file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"}, + {file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"}, + {file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"}, + {file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"}, + {file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"}, + {file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"}, + {file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"}, + {file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd"}, + {file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2"}, + {file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e"}, + {file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920"}, + {file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"}, + {file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"}, + {file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"}, + {file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"}, + {file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"}, + {file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"}, ] [package.dependencies] @@ -691,13 +691,13 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi-slim" -version = "0.111.1" +version = "0.112.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_slim-0.111.1-py3-none-any.whl", hash = "sha256:ac29948dcbf84cc78d68ed2c4df4e695ac265cf53c339e5794008476e9befbbb"}, - {file = "fastapi_slim-0.111.1.tar.gz", hash = "sha256:f799a60658f56c49fe3842eb534730fabe1168731c0b407b98a042c8d57be39d"}, + {file = "fastapi_slim-0.112.0-py3-none-any.whl", hash = "sha256:7663edfbb5036d641aa45b4f5dad341cf78d98885216e78743a8cdd39a38883e"}, + {file = "fastapi_slim-0.112.0.tar.gz", hash = "sha256:2420f700b7dc2d1a6d02c7230f7aa2ae9fa0320d8d481094062ff717659c0843"}, ] [package.dependencies] @@ -706,8 +706,8 @@ starlette = ">=0.37.2,<0.38.0" typing-extensions = ">=4.8.0" [package.extras] -all = ["email_validator (>=2.0.0)", "fastapi-cli (>=0.0.2)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email_validator (>=2.0.0)", "fastapi-cli (>=0.0.2)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] +all = ["email_validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email_validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "filelock" @@ -889,13 +889,13 @@ tqdm = ["tqdm"] [[package]] name = "ftfy" -version = "6.2.0" +version = "6.2.3" description = "Fixes mojibake and other problems with Unicode, after the fact" optional = false -python-versions = ">=3.8,<4" +python-versions = "<4,>=3.8.1" files = [ - {file = "ftfy-6.2.0-py3-none-any.whl", hash = "sha256:f94a2c34b76e07475720e3096f5ca80911d152406fbde66fdb45c4d0c9150026"}, - {file = "ftfy-6.2.0.tar.gz", hash = "sha256:5e42143c7025ef97944ca2619d6b61b0619fc6654f98771d39e862c1424c75c0"}, + {file = "ftfy-6.2.3-py3-none-any.whl", hash = "sha256:f15761b023f3061a66207d33f0c0149ad40a8319fd16da91796363e2c049fdf8"}, + {file = "ftfy-6.2.3.tar.gz", hash = "sha256:79b505988f29d577a58a9069afe75553a02a46e42de6091c0660cdc67812badc"}, ] [package.dependencies] @@ -1541,13 +1541,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "locust" -version = "2.31.1" +version = "2.31.2" description = "Developer-friendly load testing framework" optional = false python-versions = ">=3.9" files = [ - {file = "locust-2.31.1-py3-none-any.whl", hash = "sha256:20756509939004e95c622ac3042886edab38b736f00534cc03ce2774064e7f71"}, - {file = "locust-2.31.1.tar.gz", hash = "sha256:d26b7333cdef80645f3978d8ff9aabab4d53e41ed82cc8490212aa68e8498fdd"}, + {file = "locust-2.31.2-py3-none-any.whl", hash = "sha256:9bcb8b777d9844ac9498d6eebe17a0afa21712419c42da27b1d1cac5895cd182"}, + {file = "locust-2.31.2.tar.gz", hash = "sha256:a31f8e1d24535494eb809bd8dfd545ada9514df4581b69bdc2ecf3e109b7a1dd"}, ] [package.dependencies] @@ -1562,8 +1562,8 @@ psutil = ">=5.9.1" pywin32 = {version = "*", markers = "sys_platform == \"win32\""} pyzmq = ">=25.0.0" requests = [ - {version = ">=2.32.2", markers = "python_full_version > \"3.11.0\""}, {version = ">=2.26.0", markers = "python_full_version <= \"3.11.0\""}, + {version = ">=2.32.2", markers = "python_full_version > \"3.11.0\""}, ] tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.11\""} @@ -2085,10 +2085,10 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] [[package]] @@ -2393,8 +2393,8 @@ files = [ annotated-types = ">=0.4.0" pydantic-core = "2.20.1" typing-extensions = [ - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""}, + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, ] [package.extras] @@ -2904,29 +2904,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.5.6" +version = "0.5.7" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.5.6-py3-none-linux_armv6l.whl", hash = "sha256:a0ef5930799a05522985b9cec8290b185952f3fcd86c1772c3bdbd732667fdcd"}, - {file = "ruff-0.5.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b652dc14f6ef5d1552821e006f747802cc32d98d5509349e168f6bf0ee9f8f42"}, - {file = "ruff-0.5.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:80521b88d26a45e871f31e4b88938fd87db7011bb961d8afd2664982dfc3641a"}, - {file = "ruff-0.5.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9bc8f328a9f1309ae80e4d392836e7dbc77303b38ed4a7112699e63d3b066ab"}, - {file = "ruff-0.5.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d394940f61f7720ad371ddedf14722ee1d6250fd8d020f5ea5a86e7be217daf"}, - {file = "ruff-0.5.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111a99cdb02f69ddb2571e2756e017a1496c2c3a2aeefe7b988ddab38b416d36"}, - {file = "ruff-0.5.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e395daba77a79f6dc0d07311f94cc0560375ca20c06f354c7c99af3bf4560c5d"}, - {file = "ruff-0.5.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c476acb43c3c51e3c614a2e878ee1589655fa02dab19fe2db0423a06d6a5b1b6"}, - {file = "ruff-0.5.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2ff8003f5252fd68425fd53d27c1f08b201d7ed714bb31a55c9ac1d4c13e2eb"}, - {file = "ruff-0.5.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c94e084ba3eaa80c2172918c2ca2eb2230c3f15925f4ed8b6297260c6ef179ad"}, - {file = "ruff-0.5.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1f77c1c3aa0669fb230b06fb24ffa3e879391a3ba3f15e3d633a752da5a3e670"}, - {file = "ruff-0.5.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f908148c93c02873210a52cad75a6eda856b2cbb72250370ce3afef6fb99b1ed"}, - {file = "ruff-0.5.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:563a7ae61ad284187d3071d9041c08019975693ff655438d8d4be26e492760bd"}, - {file = "ruff-0.5.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:94fe60869bfbf0521e04fd62b74cbca21cbc5beb67cbb75ab33fe8c174f54414"}, - {file = "ruff-0.5.6-py3-none-win32.whl", hash = "sha256:e6a584c1de6f8591c2570e171cc7ce482bb983d49c70ddf014393cd39e9dfaed"}, - {file = "ruff-0.5.6-py3-none-win_amd64.whl", hash = "sha256:d7fe7dccb1a89dc66785d7aa0ac283b2269712d8ed19c63af908fdccca5ccc1a"}, - {file = "ruff-0.5.6-py3-none-win_arm64.whl", hash = "sha256:57c6c0dd997b31b536bff49b9eee5ed3194d60605a4427f735eeb1f9c1b8d264"}, - {file = "ruff-0.5.6.tar.gz", hash = "sha256:07c9e3c2a8e1fe377dd460371c3462671a728c981c3205a5217291422209f642"}, + {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"}, + {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"}, + {file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"}, + {file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"}, + {file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"}, + {file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"}, + {file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"}, ] [[package]] @@ -3164,111 +3164,111 @@ all = ["defusedxml", "fsspec", "imagecodecs (>=2023.8.12)", "lxml", "matplotlib" [[package]] name = "tokenizers" -version = "0.19.1" +version = "0.20.0" description = "" optional = false python-versions = ">=3.7" files = [ - {file = "tokenizers-0.19.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:952078130b3d101e05ecfc7fc3640282d74ed26bcf691400f872563fca15ac97"}, - {file = "tokenizers-0.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82c8b8063de6c0468f08e82c4e198763e7b97aabfe573fd4cf7b33930ca4df77"}, - {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f03727225feaf340ceeb7e00604825addef622d551cbd46b7b775ac834c1e1c4"}, - {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:453e4422efdfc9c6b6bf2eae00d5e323f263fff62b29a8c9cd526c5003f3f642"}, - {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02e81bf089ebf0e7f4df34fa0207519f07e66d8491d963618252f2e0729e0b46"}, - {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b07c538ba956843833fee1190cf769c60dc62e1cf934ed50d77d5502194d63b1"}, - {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e28cab1582e0eec38b1f38c1c1fb2e56bce5dc180acb1724574fc5f47da2a4fe"}, - {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b01afb7193d47439f091cd8f070a1ced347ad0f9144952a30a41836902fe09e"}, - {file = "tokenizers-0.19.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7fb297edec6c6841ab2e4e8f357209519188e4a59b557ea4fafcf4691d1b4c98"}, - {file = "tokenizers-0.19.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2e8a3dd055e515df7054378dc9d6fa8c8c34e1f32777fb9a01fea81496b3f9d3"}, - {file = "tokenizers-0.19.1-cp310-none-win32.whl", hash = "sha256:7ff898780a155ea053f5d934925f3902be2ed1f4d916461e1a93019cc7250837"}, - {file = "tokenizers-0.19.1-cp310-none-win_amd64.whl", hash = "sha256:bea6f9947e9419c2fda21ae6c32871e3d398cba549b93f4a65a2d369662d9403"}, - {file = "tokenizers-0.19.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5c88d1481f1882c2e53e6bb06491e474e420d9ac7bdff172610c4f9ad3898059"}, - {file = "tokenizers-0.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddf672ed719b4ed82b51499100f5417d7d9f6fb05a65e232249268f35de5ed14"}, - {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:dadc509cc8a9fe460bd274c0e16ac4184d0958117cf026e0ea8b32b438171594"}, - {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfedf31824ca4915b511b03441784ff640378191918264268e6923da48104acc"}, - {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac11016d0a04aa6487b1513a3a36e7bee7eec0e5d30057c9c0408067345c48d2"}, - {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76951121890fea8330d3a0df9a954b3f2a37e3ec20e5b0530e9a0044ca2e11fe"}, - {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b342d2ce8fc8d00f376af068e3274e2e8649562e3bc6ae4a67784ded6b99428d"}, - {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d16ff18907f4909dca9b076b9c2d899114dd6abceeb074eca0c93e2353f943aa"}, - {file = "tokenizers-0.19.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:706a37cc5332f85f26efbe2bdc9ef8a9b372b77e4645331a405073e4b3a8c1c6"}, - {file = "tokenizers-0.19.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:16baac68651701364b0289979ecec728546133e8e8fe38f66fe48ad07996b88b"}, - {file = "tokenizers-0.19.1-cp311-none-win32.whl", hash = "sha256:9ed240c56b4403e22b9584ee37d87b8bfa14865134e3e1c3fb4b2c42fafd3256"}, - {file = "tokenizers-0.19.1-cp311-none-win_amd64.whl", hash = "sha256:ad57d59341710b94a7d9dbea13f5c1e7d76fd8d9bcd944a7a6ab0b0da6e0cc66"}, - {file = "tokenizers-0.19.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:621d670e1b1c281a1c9698ed89451395d318802ff88d1fc1accff0867a06f153"}, - {file = "tokenizers-0.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d924204a3dbe50b75630bd16f821ebda6a5f729928df30f582fb5aade90c818a"}, - {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4f3fefdc0446b1a1e6d81cd4c07088ac015665d2e812f6dbba4a06267d1a2c95"}, - {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9620b78e0b2d52ef07b0d428323fb34e8ea1219c5eac98c2596311f20f1f9266"}, - {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04ce49e82d100594715ac1b2ce87d1a36e61891a91de774755f743babcd0dd52"}, - {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5c2ff13d157afe413bf7e25789879dd463e5a4abfb529a2d8f8473d8042e28f"}, - {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3174c76efd9d08f836bfccaca7cfec3f4d1c0a4cf3acbc7236ad577cc423c840"}, - {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c9d5b6c0e7a1e979bec10ff960fae925e947aab95619a6fdb4c1d8ff3708ce3"}, - {file = "tokenizers-0.19.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a179856d1caee06577220ebcfa332af046d576fb73454b8f4d4b0ba8324423ea"}, - {file = "tokenizers-0.19.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:952b80dac1a6492170f8c2429bd11fcaa14377e097d12a1dbe0ef2fb2241e16c"}, - {file = "tokenizers-0.19.1-cp312-none-win32.whl", hash = "sha256:01d62812454c188306755c94755465505836fd616f75067abcae529c35edeb57"}, - {file = "tokenizers-0.19.1-cp312-none-win_amd64.whl", hash = "sha256:b70bfbe3a82d3e3fb2a5e9b22a39f8d1740c96c68b6ace0086b39074f08ab89a"}, - {file = "tokenizers-0.19.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:bb9dfe7dae85bc6119d705a76dc068c062b8b575abe3595e3c6276480e67e3f1"}, - {file = "tokenizers-0.19.1-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:1f0360cbea28ea99944ac089c00de7b2e3e1c58f479fb8613b6d8d511ce98267"}, - {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:71e3ec71f0e78780851fef28c2a9babe20270404c921b756d7c532d280349214"}, - {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b82931fa619dbad979c0ee8e54dd5278acc418209cc897e42fac041f5366d626"}, - {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8ff5b90eabdcdaa19af697885f70fe0b714ce16709cf43d4952f1f85299e73a"}, - {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e742d76ad84acbdb1a8e4694f915fe59ff6edc381c97d6dfdd054954e3478ad4"}, - {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8c5d59d7b59885eab559d5bc082b2985555a54cda04dda4c65528d90ad252ad"}, - {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b2da5c32ed869bebd990c9420df49813709e953674c0722ff471a116d97b22d"}, - {file = "tokenizers-0.19.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:638e43936cc8b2cbb9f9d8dde0fe5e7e30766a3318d2342999ae27f68fdc9bd6"}, - {file = "tokenizers-0.19.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:78e769eb3b2c79687d9cb0f89ef77223e8e279b75c0a968e637ca7043a84463f"}, - {file = "tokenizers-0.19.1-cp37-none-win32.whl", hash = "sha256:72791f9bb1ca78e3ae525d4782e85272c63faaef9940d92142aa3eb79f3407a3"}, - {file = "tokenizers-0.19.1-cp37-none-win_amd64.whl", hash = "sha256:f3bbb7a0c5fcb692950b041ae11067ac54826204318922da754f908d95619fbc"}, - {file = "tokenizers-0.19.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:07f9295349bbbcedae8cefdbcfa7f686aa420be8aca5d4f7d1ae6016c128c0c5"}, - {file = "tokenizers-0.19.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10a707cc6c4b6b183ec5dbfc5c34f3064e18cf62b4a938cb41699e33a99e03c1"}, - {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6309271f57b397aa0aff0cbbe632ca9d70430839ca3178bf0f06f825924eca22"}, - {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ad23d37d68cf00d54af184586d79b84075ada495e7c5c0f601f051b162112dc"}, - {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:427c4f0f3df9109314d4f75b8d1f65d9477033e67ffaec4bca53293d3aca286d"}, - {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e83a31c9cf181a0a3ef0abad2b5f6b43399faf5da7e696196ddd110d332519ee"}, - {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c27b99889bd58b7e301468c0838c5ed75e60c66df0d4db80c08f43462f82e0d3"}, - {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bac0b0eb952412b0b196ca7a40e7dce4ed6f6926489313414010f2e6b9ec2adf"}, - {file = "tokenizers-0.19.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8a6298bde623725ca31c9035a04bf2ef63208d266acd2bed8c2cb7d2b7d53ce6"}, - {file = "tokenizers-0.19.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:08a44864e42fa6d7d76d7be4bec62c9982f6f6248b4aa42f7302aa01e0abfd26"}, - {file = "tokenizers-0.19.1-cp38-none-win32.whl", hash = "sha256:1de5bc8652252d9357a666e609cb1453d4f8e160eb1fb2830ee369dd658e8975"}, - {file = "tokenizers-0.19.1-cp38-none-win_amd64.whl", hash = "sha256:0bcce02bf1ad9882345b34d5bd25ed4949a480cf0e656bbd468f4d8986f7a3f1"}, - {file = "tokenizers-0.19.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:0b9394bd204842a2a1fd37fe29935353742be4a3460b6ccbaefa93f58a8df43d"}, - {file = "tokenizers-0.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4692ab92f91b87769d950ca14dbb61f8a9ef36a62f94bad6c82cc84a51f76f6a"}, - {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6258c2ef6f06259f70a682491c78561d492e885adeaf9f64f5389f78aa49a051"}, - {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85cf76561fbd01e0d9ea2d1cbe711a65400092bc52b5242b16cfd22e51f0c58"}, - {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:670b802d4d82bbbb832ddb0d41df7015b3e549714c0e77f9bed3e74d42400fbe"}, - {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:85aa3ab4b03d5e99fdd31660872249df5e855334b6c333e0bc13032ff4469c4a"}, - {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbf001afbbed111a79ca47d75941e9e5361297a87d186cbfc11ed45e30b5daba"}, - {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4c89aa46c269e4e70c4d4f9d6bc644fcc39bb409cb2a81227923404dd6f5227"}, - {file = "tokenizers-0.19.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:39c1ec76ea1027438fafe16ecb0fb84795e62e9d643444c1090179e63808c69d"}, - {file = "tokenizers-0.19.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c2a0d47a89b48d7daa241e004e71fb5a50533718897a4cd6235cb846d511a478"}, - {file = "tokenizers-0.19.1-cp39-none-win32.whl", hash = "sha256:61b7fe8886f2e104d4caf9218b157b106207e0f2a4905c9c7ac98890688aabeb"}, - {file = "tokenizers-0.19.1-cp39-none-win_amd64.whl", hash = "sha256:f97660f6c43efd3e0bfd3f2e3e5615bf215680bad6ee3d469df6454b8c6e8256"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3b11853f17b54c2fe47742c56d8a33bf49ce31caf531e87ac0d7d13d327c9334"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d26194ef6c13302f446d39972aaa36a1dda6450bc8949f5eb4c27f51191375bd"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e8d1ed93beda54bbd6131a2cb363a576eac746d5c26ba5b7556bc6f964425594"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca407133536f19bdec44b3da117ef0d12e43f6d4b56ac4c765f37eca501c7bda"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce05fde79d2bc2e46ac08aacbc142bead21614d937aac950be88dc79f9db9022"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:35583cd46d16f07c054efd18b5d46af4a2f070a2dd0a47914e66f3ff5efb2b1e"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:43350270bfc16b06ad3f6f07eab21f089adb835544417afda0f83256a8bf8b75"}, - {file = "tokenizers-0.19.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b4399b59d1af5645bcee2072a463318114c39b8547437a7c2d6a186a1b5a0e2d"}, - {file = "tokenizers-0.19.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6852c5b2a853b8b0ddc5993cd4f33bfffdca4fcc5d52f89dd4b8eada99379285"}, - {file = "tokenizers-0.19.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd266ae85c3d39df2f7e7d0e07f6c41a55e9a3123bb11f854412952deacd828"}, - {file = "tokenizers-0.19.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecb2651956eea2aa0a2d099434134b1b68f1c31f9a5084d6d53f08ed43d45ff2"}, - {file = "tokenizers-0.19.1-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:b279ab506ec4445166ac476fb4d3cc383accde1ea152998509a94d82547c8e2a"}, - {file = "tokenizers-0.19.1-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:89183e55fb86e61d848ff83753f64cded119f5d6e1f553d14ffee3700d0a4a49"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2edbc75744235eea94d595a8b70fe279dd42f3296f76d5a86dde1d46e35f574"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:0e64bfde9a723274e9a71630c3e9494ed7b4c0f76a1faacf7fe294cd26f7ae7c"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0b5ca92bfa717759c052e345770792d02d1f43b06f9e790ca0a1db62838816f3"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f8a20266e695ec9d7a946a019c1d5ca4eddb6613d4f466888eee04f16eedb85"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63c38f45d8f2a2ec0f3a20073cccb335b9f99f73b3c69483cd52ebc75369d8a1"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dd26e3afe8a7b61422df3176e06664503d3f5973b94f45d5c45987e1cb711876"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:eddd5783a4a6309ce23432353cdb36220e25cbb779bfa9122320666508b44b88"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:56ae39d4036b753994476a1b935584071093b55c7a72e3b8288e68c313ca26e7"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f9939ca7e58c2758c01b40324a59c034ce0cebad18e0d4563a9b1beab3018243"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6c330c0eb815d212893c67a032e9dc1b38a803eccb32f3e8172c19cc69fbb439"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec11802450a2487cdf0e634b750a04cbdc1c4d066b97d94ce7dd2cb51ebb325b"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b718f316b596f36e1dae097a7d5b91fc5b85e90bf08b01ff139bd8953b25af"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ed69af290c2b65169f0ba9034d1dc39a5db9459b32f1dd8b5f3f32a3fcf06eab"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f8a9c828277133af13f3859d1b6bf1c3cb6e9e1637df0e45312e6b7c2e622b1f"}, - {file = "tokenizers-0.19.1.tar.gz", hash = "sha256:ee59e6680ed0fdbe6b724cf38bd70400a0c1dd623b07ac729087270caeac88e3"}, + {file = "tokenizers-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:6cff5c5e37c41bc5faa519d6f3df0679e4b37da54ea1f42121719c5e2b4905c0"}, + {file = "tokenizers-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:62a56bf75c27443432456f4ca5ca055befa95e25be8a28141cc495cac8ae4d6d"}, + {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68cc7de6a63f09c4a86909c2597b995aa66e19df852a23aea894929c74369929"}, + {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:053c37ecee482cc958fdee53af3c6534286a86f5d35aac476f7c246830e53ae5"}, + {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3d7074aaabc151a6363fa03db5493fc95b423b2a1874456783989e96d541c7b6"}, + {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a11435780f2acd89e8fefe5e81cecf01776f6edb9b3ac95bcb76baee76b30b90"}, + {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9a81cd2712973b007d84268d45fc3f6f90a79c31dfe7f1925e6732f8d2959987"}, + {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7dfd796ab9d909f76fb93080e1c7c8309f196ecb316eb130718cd5e34231c69"}, + {file = "tokenizers-0.20.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8029ad2aa8cb00605c9374566034c1cc1b15130713e0eb5afcef6cface8255c9"}, + {file = "tokenizers-0.20.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ca4d54260ebe97d59dfa9a30baa20d0c4dd9137d99a8801700055c561145c24e"}, + {file = "tokenizers-0.20.0-cp310-none-win32.whl", hash = "sha256:95ee16b57cec11b86a7940174ec5197d506439b0f415ab3859f254b1dffe9df0"}, + {file = "tokenizers-0.20.0-cp310-none-win_amd64.whl", hash = "sha256:0a61a11e93eeadbf02aea082ffc75241c4198e0608bbbac4f65a9026851dcf37"}, + {file = "tokenizers-0.20.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6636b798b3c4d6c9b1af1a918bd07c867808e5a21c64324e95318a237e6366c3"}, + {file = "tokenizers-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ec603e42eaf499ffd58b9258162add948717cf21372458132f14e13a6bc7172"}, + {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cce124264903a8ea6f8f48e1cc7669e5ef638c18bd4ab0a88769d5f92debdf7f"}, + {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07bbeba0231cf8de07aa6b9e33e9779ff103d47042eeeb859a8c432e3292fb98"}, + {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06c0ca8397b35d38b83a44a9c6929790c1692957d88541df061cb34d82ebbf08"}, + {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca6557ac3b83d912dfbb1f70ab56bd4b0594043916688e906ede09f42e192401"}, + {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a5ad94c9e80ac6098328bee2e3264dbced4c6faa34429994d473f795ec58ef4"}, + {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b5c7f906ee6bec30a9dc20268a8b80f3b9584de1c9f051671cb057dc6ce28f6"}, + {file = "tokenizers-0.20.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:31e087e9ee1b8f075b002bfee257e858dc695f955b43903e1bb4aa9f170e37fe"}, + {file = "tokenizers-0.20.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c3124fb6f3346cb3d8d775375d3b429bf4dcfc24f739822702009d20a4297990"}, + {file = "tokenizers-0.20.0-cp311-none-win32.whl", hash = "sha256:a4bb8b40ba9eefa621fdcabf04a74aa6038ae3be0c614c6458bd91a4697a452f"}, + {file = "tokenizers-0.20.0-cp311-none-win_amd64.whl", hash = "sha256:2b709d371f1fe60a28ef0c5c67815952d455ca7f34dbe7197eaaed3cc54b658e"}, + {file = "tokenizers-0.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:15c81a17d0d66f4987c6ca16f4bea7ec253b8c7ed1bb00fdc5d038b1bb56e714"}, + {file = "tokenizers-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a531cdf1fb6dc41c984c785a3b299cb0586de0b35683842a3afbb1e5207f910"}, + {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06caabeb4587f8404e0cd9d40f458e9cba3e815c8155a38e579a74ff3e2a4301"}, + {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8768f964f23f5b9f50546c0369c75ab3262de926983888bbe8b98be05392a79c"}, + {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:626403860152c816f97b649fd279bd622c3d417678c93b4b1a8909b6380b69a8"}, + {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c1b88fa9e5ff062326f4bf82681da5a96fca7104d921a6bd7b1e6fcf224af26"}, + {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d7e559436a07dc547f22ce1101f26d8b2fad387e28ec8e7e1e3b11695d681d8"}, + {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e48afb75e50449848964e4a67b0da01261dd3aa8df8daecf10db8fd7f5b076eb"}, + {file = "tokenizers-0.20.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:baf5d0e1ff44710a95eefc196dd87666ffc609fd447c5e5b68272a7c3d342a1d"}, + {file = "tokenizers-0.20.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e5e56df0e8ed23ba60ae3848c3f069a0710c4b197218fe4f89e27eba38510768"}, + {file = "tokenizers-0.20.0-cp312-none-win32.whl", hash = "sha256:ec53e5ecc142a82432f9c6c677dbbe5a2bfee92b8abf409a9ecb0d425ee0ce75"}, + {file = "tokenizers-0.20.0-cp312-none-win_amd64.whl", hash = "sha256:f18661ece72e39c0dfaa174d6223248a15b457dbd4b0fc07809b8e6d3ca1a234"}, + {file = "tokenizers-0.20.0-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:f7065b1084d8d1a03dc89d9aad69bcbc8415d4bc123c367063eb32958cd85054"}, + {file = "tokenizers-0.20.0-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:e5d4069e4714e3f7ba0a4d3d44f9d84a432cd4e4aa85c3d7dd1f51440f12e4a1"}, + {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:799b808529e54b7e1a36350bda2aeb470e8390e484d3e98c10395cee61d4e3c6"}, + {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f9baa027cc8a281ad5f7725a93c204d7a46986f88edbe8ef7357f40a23fb9c7"}, + {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:010ec7f3f7a96adc4c2a34a3ada41fa14b4b936b5628b4ff7b33791258646c6b"}, + {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98d88f06155335b14fd78e32ee28ca5b2eb30fced4614e06eb14ae5f7fba24ed"}, + {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e13eb000ef540c2280758d1b9cfa5fe424b0424ae4458f440e6340a4f18b2638"}, + {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fab3cf066ff426f7e6d70435dc28a9ff01b2747be83810e397cba106f39430b0"}, + {file = "tokenizers-0.20.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:39fa3761b30a89368f322e5daf4130dce8495b79ad831f370449cdacfb0c0d37"}, + {file = "tokenizers-0.20.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c8da0fba4d179ddf2607821575998df3c294aa59aa8df5a6646dc64bc7352bce"}, + {file = "tokenizers-0.20.0-cp37-none-win32.whl", hash = "sha256:fada996d6da8cf213f6e3c91c12297ad4f6cdf7a85c2fadcd05ec32fa6846fcd"}, + {file = "tokenizers-0.20.0-cp37-none-win_amd64.whl", hash = "sha256:7d29aad702279e0760c265fcae832e89349078e3418dd329732d4503259fd6bd"}, + {file = "tokenizers-0.20.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:099c68207f3ef0227ecb6f80ab98ea74de559f7b124adc7b17778af0250ee90a"}, + {file = "tokenizers-0.20.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:68012d8a8cddb2eab3880870d7e2086cb359c7f7a2b03f5795044f5abff4e850"}, + {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9253bdd209c6aee168deca7d0e780581bf303e0058f268f9bb06859379de19b6"}, + {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f868600ddbcb0545905ed075eb7218a0756bf6c09dae7528ea2f8436ebd2c93"}, + {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a9643d9c8c5f99b6aba43fd10034f77cc6c22c31f496d2f0ee183047d948fa0"}, + {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c375c6a889aeab44734028bc65cc070acf93ccb0f9368be42b67a98e1063d3f6"}, + {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e359f852328e254f070bbd09a19a568421d23388f04aad9f2fb7da7704c7228d"}, + {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d98b01a309d4387f3b1c1dd68a8b8136af50376cf146c1b7e8d8ead217a5be4b"}, + {file = "tokenizers-0.20.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:459f7537119554c2899067dec1ac74a00d02beef6558f4ee2e99513bf6d568af"}, + {file = "tokenizers-0.20.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:392b87ec89452628c045c9f2a88bc2a827f4c79e7d84bc3b72752b74c2581f70"}, + {file = "tokenizers-0.20.0-cp38-none-win32.whl", hash = "sha256:55a393f893d2ed4dd95a1553c2e42d4d4086878266f437b03590d3f81984c4fe"}, + {file = "tokenizers-0.20.0-cp38-none-win_amd64.whl", hash = "sha256:30ffe33c5c2f2aab8e9a3340d0110dd9f7ace7eec7362e20a697802306bd8068"}, + {file = "tokenizers-0.20.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:aa2d4a6fed2a7e3f860c7fc9d48764bb30f2649d83915d66150d6340e06742b8"}, + {file = "tokenizers-0.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b5ef0f814084a897e9071fc4a868595f018c5c92889197bdc4bf19018769b148"}, + {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc1e1b791e8c3bf4c4f265f180dadaff1c957bf27129e16fdd5e5d43c2d3762c"}, + {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b69e55e481459c07885263743a0d3c18d52db19bae8226a19bcca4aaa213fff"}, + {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806b4d82e27a2512bc23057b2986bc8b85824914286975b84d8105ff40d03d9"}, + {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9859e9ef13adf5a473ccab39d31bff9c550606ae3c784bf772b40f615742a24f"}, + {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef703efedf4c20488a8eb17637b55973745b27997ff87bad88ed499b397d1144"}, + {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6eec0061bab94b1841ab87d10831fdf1b48ebaed60e6d66d66dbe1d873f92bf5"}, + {file = "tokenizers-0.20.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:980f3d0d7e73f845b69087f29a63c11c7eb924c4ad6b358da60f3db4cf24bdb4"}, + {file = "tokenizers-0.20.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7c157550a2f3851b29d7fdc9dc059fcf81ff0c0fc49a1e5173a89d533ed043fa"}, + {file = "tokenizers-0.20.0-cp39-none-win32.whl", hash = "sha256:8a3d2f4d08608ec4f9895ec25b4b36a97f05812543190a5f2c3cd19e8f041e5a"}, + {file = "tokenizers-0.20.0-cp39-none-win_amd64.whl", hash = "sha256:d90188d12afd0c75e537f9a1d92f9c7375650188ee4f48fdc76f9e38afbd2251"}, + {file = "tokenizers-0.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d68e15f1815357b059ec266062340c343ea7f98f7f330602df81ffa3474b6122"}, + {file = "tokenizers-0.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:23f9ecec637b9bc80da5f703808d29ed5329e56b5aa8d791d1088014f48afadc"}, + {file = "tokenizers-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f830b318ee599e3d0665b3e325f85bc75ee2d2ca6285f52e439dc22b64691580"}, + {file = "tokenizers-0.20.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3dc750def789cb1de1b5a37657919545e1d9ffa667658b3fa9cb7862407a1b8"}, + {file = "tokenizers-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e26e6c755ae884c2ea6135cd215bdd0fccafe4ee62405014b8c3cd19954e3ab9"}, + {file = "tokenizers-0.20.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a1158c7174f427182e08baa2a8ded2940f2b4a3e94969a85cc9cfd16004cbcea"}, + {file = "tokenizers-0.20.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:6324826287a3fc198898d3dcf758fe4a8479e42d6039f4c59e2cedd3cf92f64e"}, + {file = "tokenizers-0.20.0-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7d8653149405bb0c16feaf9cfee327fdb6aaef9dc2998349fec686f35e81c4e2"}, + {file = "tokenizers-0.20.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8a2dc1e402a155e97309287ca085c80eb1b7fab8ae91527d3b729181639fa51"}, + {file = "tokenizers-0.20.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07bef67b20aa6e5f7868c42c7c5eae4d24f856274a464ae62e47a0f2cccec3da"}, + {file = "tokenizers-0.20.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da06e397182ff53789c506c7833220c192952c57e1581a53f503d8d953e2d67e"}, + {file = "tokenizers-0.20.0-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:302f7e11a14814028b7fc88c45a41f1bbe9b5b35fd76d6869558d1d1809baa43"}, + {file = "tokenizers-0.20.0-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:055ec46e807b875589dfbe3d9259f9a6ee43394fb553b03b3d1e9541662dbf25"}, + {file = "tokenizers-0.20.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e3144b8acebfa6ae062e8f45f7ed52e4b50fb6c62f93afc8871b525ab9fdcab3"}, + {file = "tokenizers-0.20.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:b52aa3fd14b2a07588c00a19f66511cff5cca8f7266ca3edcdd17f3512ad159f"}, + {file = "tokenizers-0.20.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b8cf52779ffc5d4d63a0170fbeb512372bad0dd014ce92bbb9149756c831124"}, + {file = "tokenizers-0.20.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:983a45dd11a876124378dae71d6d9761822199b68a4c73f32873d8cdaf326a5b"}, + {file = "tokenizers-0.20.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df6b819c9a19831ebec581e71a7686a54ab45d90faf3842269a10c11d746de0c"}, + {file = "tokenizers-0.20.0-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e738cfd80795fcafcef89c5731c84b05638a4ab3f412f97d5ed7765466576eb1"}, + {file = "tokenizers-0.20.0-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:c8842c7be2fadb9c9edcee233b1b7fe7ade406c99b0973f07439985c1c1d0683"}, + {file = "tokenizers-0.20.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e47a82355511c373a4a430c4909dc1e518e00031207b1fec536c49127388886b"}, + {file = "tokenizers-0.20.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:9afbf359004551179a5db19424180c81276682773cff2c5d002f6eaaffe17230"}, + {file = "tokenizers-0.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a07eaa8799a92e6af6f472c21a75bf71575de2af3c0284120b7a09297c0de2f3"}, + {file = "tokenizers-0.20.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0994b2e5fc53a301071806bc4303e4bc3bdc3f490e92a21338146a36746b0872"}, + {file = "tokenizers-0.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b6466e0355b603d10e3cc3d282d350b646341b601e50969464a54939f9848d0"}, + {file = "tokenizers-0.20.0-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:1e86594c2a433cb1ea09cfbe596454448c566e57ee8905bd557e489d93e89986"}, + {file = "tokenizers-0.20.0-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3e14cdef1efa96ecead6ea64a891828432c3ebba128bdc0596e3059fea104ef3"}, + {file = "tokenizers-0.20.0.tar.gz", hash = "sha256:39d7acc43f564c274085cafcd1dae9d36f332456de1a31970296a6b8da4eac8d"}, ] [package.dependencies] @@ -3310,17 +3310,6 @@ notebook = ["ipywidgets (>=6)"] slack = ["slack-sdk"] telegram = ["requests"] -[[package]] -name = "typing-extensions" -version = "4.9.0" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, - {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, -] - [[package]] name = "typing-extensions" version = "4.12.2" From f331a974ed7d421a6fd2eda4065310da2b235531 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 23:06:46 -0400 Subject: [PATCH 086/723] chore(deps): update dependency @types/picomatch to v3.0.1 (#11755) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index f8226d377e..a521f3211c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -6107,9 +6107,9 @@ } }, "node_modules/@types/picomatch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-3.0.0.tgz", - "integrity": "sha512-iX/Qwk9vU17N/5Q7QrV46wzciloTdCqTRt6z8A7uFFADM2+Sy5oQh9ldZhAiTXH+l0sM/EkXatEhJIs8FUyOBQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", "dev": true }, "node_modules/@types/prismjs": { @@ -20390,9 +20390,9 @@ } }, "@types/picomatch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-3.0.0.tgz", - "integrity": "sha512-iX/Qwk9vU17N/5Q7QrV46wzciloTdCqTRt6z8A7uFFADM2+Sy5oQh9ldZhAiTXH+l0sM/EkXatEhJIs8FUyOBQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", "dev": true }, "@types/prismjs": { From e934e368b3c86a9675647563f9ce145875bb29c1 Mon Sep 17 00:00:00 2001 From: waclaw66 Date: Wed, 14 Aug 2024 15:21:59 +0200 Subject: [PATCH 087/723] fix(mobile): trash translations (#11761) trash translations --- mobile/assets/i18n/en-US.json | 26 ++++++++++++------- mobile/lib/pages/library/trash.page.dart | 13 ++++------ .../widgets/asset_grid/multiselect_grid.dart | 20 +++++++------- .../widgets/asset_viewer/gallery_app_bar.dart | 3 ++- 4 files changed, 34 insertions(+), 28 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 47ab78b095..ebcf7999f4 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -54,7 +54,14 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", + "asset_restored_successfully": "Asset restored successfully", "asset_viewer_settings_title": "Asset Viewer", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", @@ -448,15 +455,18 @@ "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", "setting_notifications_total_progress_title": "Show background backup total progress", "setting_pages_app_bar_settings": "Settings", - "settings_require_restart": "Please restart Immich to apply this setting", "setting_video_viewer_looping_subtitle": "Enable to automatically loop a video in the detail viewer.", "setting_video_viewer_looping_title": "Looping", "setting_video_viewer_title": "Videos", + "settings_require_restart": "Please restart Immich to apply this setting", "share_add": "Add", "share_add_photos": "Add photos", "share_add_title": "Add a title", "share_assets_selected": "{} selected", "share_create_album": "Create album", + "share_dialog_preparing": "Preparing...", + "share_done": "Done", + "share_invite": "Invite to album", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", "shared_album_activity_remove_content": "Do you want to delete this activity?", @@ -468,7 +478,6 @@ "shared_album_section_people_action_remove_user": "Remove user from album", "shared_album_section_people_owner_label": "Owner", "shared_album_section_people_title": "PEOPLE", - "share_dialog_preparing": "Preparing...", "shared_link_app_bar_title": "Shared Links", "shared_link_clipboard_copied_massage": "Copied to clipboard", "shared_link_clipboard_text": "Link: {}\nPassword: {}", @@ -514,27 +523,25 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Manage Shared links", "shared_link_public_album": "Public album", - "share_done": "Done", - "share_invite": "Invite to album", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", "sharing_page_empty_list": "EMPTY LIST", "sharing_silver_appbar_create_shared_album": "New shared album", - "sharing_silver_appbar_shared_links": "Shared links", "sharing_silver_appbar_share_partner": "Share with partner", + "sharing_silver_appbar_shared_links": "Shared links", "tab_controller_nav_library": "Library", "tab_controller_nav_photos": "Photos", "tab_controller_nav_search": "Search", "tab_controller_nav_sharing": "Sharing", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Dark mode", "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", "theme_setting_image_viewer_quality_title": "Image viewer quality", - "theme_setting_primary_color_title": "Primary color", "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", - "theme_setting_colorful_interface_title": "Colorful interface", - "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_primary_color_title": "Primary color", "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatic (Follow system setting)", "theme_setting_theme_subtitle": "Choose the app's theme setting", @@ -542,6 +549,7 @@ "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", "trash_page_empty_trash_btn": "Empty trash", @@ -567,4 +575,4 @@ "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" -} +} \ No newline at end of file diff --git a/mobile/lib/pages/library/trash.page.dart b/mobile/lib/pages/library/trash.page.dart index 3bba2f2dfe..61c87e19a1 100644 --- a/mobile/lib/pages/library/trash.page.dart +++ b/mobile/lib/pages/library/trash.page.dart @@ -44,7 +44,7 @@ class TrashPage extends HookConsumerWidget { if (context.mounted) { ImmichToast.show( context: context, - msg: 'Emptied trash', + msg: 'trash_emptied'.tr(), gravity: ToastGravity.BOTTOM, ); } @@ -71,13 +71,11 @@ class TrashPage extends HookConsumerWidget { .removeAssets(selection.value); if (isRemoved) { - final assetOrAssets = - selection.value.length > 1 ? 'assets' : 'asset'; if (context.mounted) { ImmichToast.show( context: context, - msg: - '${selection.value.length} $assetOrAssets deleted permanently', + msg: 'assets_deleted_permanently' + .tr(args: ["${selection.value.length}"]), gravity: ToastGravity.BOTTOM, ); } @@ -114,12 +112,11 @@ class TrashPage extends HookConsumerWidget { .read(trashProvider.notifier) .restoreAssets(selection.value); - final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset'; if (result && context.mounted) { ImmichToast.show( context: context, - msg: - '${selection.value.length} $assetOrAssets restored successfully', + msg: 'assets_restored_successfully' + .tr(args: ["${selection.value.length}"]), gravity: ToastGravity.BOTTOM, ); } diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index 23ee771627..e50a9a5ece 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -190,11 +190,12 @@ class MultiselectGrid extends HookConsumerWidget { .deleteAssets(toDelete, force: force); if (isDeleted) { - final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset'; - final trashOrRemoved = force ? 'deleted permanently' : 'trashed'; ImmichToast.show( context: context, - msg: '${selection.value.length} $assetOrAssets $trashOrRemoved', + msg: force + ? 'assets_deleted_permanently' + .tr(args: ["${selection.value.length}"]) + : 'assets_trashed'.tr(args: ["${selection.value.length}"]), gravity: ToastGravity.BOTTOM, ); selectionEnabledHook.value = false; @@ -213,11 +214,10 @@ class MultiselectGrid extends HookConsumerWidget { .read(assetProvider.notifier) .deleteLocalOnlyAssets(localIds, onlyBackedUp: onlyBackedUp); if (isDeleted) { - final assetOrAssets = localIds.length > 1 ? 'assets' : 'asset'; ImmichToast.show( context: context, - msg: - '${localIds.length} $assetOrAssets removed permanently from your device', + msg: 'assets_removed_permanently_from_device' + .tr(args: ["${localIds.length}"]), gravity: ToastGravity.BOTTOM, ); selectionEnabledHook.value = false; @@ -239,12 +239,12 @@ class MultiselectGrid extends HookConsumerWidget { .read(assetProvider.notifier) .deleteRemoteOnlyAssets(toDelete, force: force); if (isDeleted) { - final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset'; - final trashOrRemoved = force ? 'deleted permanently' : 'trashed'; ImmichToast.show( context: context, - msg: - '${toDelete.length} $assetOrAssets $trashOrRemoved from the Immich server', + msg: force + ? 'assets_deleted_permanently_from_server' + .tr(args: ["${toDelete.length}"]) + : 'assets_trashed_from_server'.tr(args: ["${toDelete.length}"]), gravity: ToastGravity.BOTTOM, ); } diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index 9bd6ff1102..fde0d2e82d 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -1,4 +1,5 @@ import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -56,7 +57,7 @@ class GalleryAppBar extends ConsumerWidget { if (result && context.mounted) { ImmichToast.show( context: context, - msg: 'asset restored successfully', + msg: 'asset_restored_successfully'.tr(), gravity: ToastGravity.BOTTOM, ); } From 593f036c0d464c130fe3182cd4c099aa5afe334e Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 14 Aug 2024 08:52:44 -0500 Subject: [PATCH 088/723] fix(web): fallback aperture info when there is no locale set (#11770) * fix(web): fallback aperture info when there is no locale set * pr feedback --- web/src/lib/components/asset-viewer/detail-panel.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 2dd5ff1a4d..4ff2084b9a 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -384,7 +384,7 @@

{asset.exifInfo.make || ''} {asset.exifInfo.model || ''}

{#if asset.exifInfo?.fNumber} -

{$locale ? `ƒ/${asset.exifInfo.fNumber.toLocaleString($locale)}` : ''}

+

ƒ/{asset.exifInfo.fNumber.toLocaleString($locale)}

{/if} {#if asset.exifInfo.exposureTime} From 7f7fec2cea259214912062a8ee84d486c30b4302 Mon Sep 17 00:00:00 2001 From: ilyaChuk <86570508+ilyaChuk@users.noreply.github.com> Date: Wed, 14 Aug 2024 17:54:50 +0300 Subject: [PATCH 089/723] feat(web): image editor - panel and cropping (#11074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * cropping, panel * fix presets * types * prettier * fix lint * fix aspect ratio, performance optimization * improved tool selection, removed placeholder * fix the mouse's exit from canvas * fix error * the "save" button and change tracking * lint, format * the mini functionality of the save button * fix aspect ratio * hide editor button on mobiles * strict equality Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> * Use the dollar sign syntax for stores inside components * unobtrusive grid lines, circles at the corners * more correct image load, handleError * more strict equality * fix styles. unused and tailwind Co-Authored-By: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> * dont store isShowEditor * if showEditor - hide navbar & shortcuts * crop-canvas decomposition (danger) I could have accidentally broken something.. but I checked the work and it seems ok. * fix lint * fix ts * callback function as props * correctly disabling shortcuts * convenient canvas borders • you can use the mouse to go beyond the boundaries and freely change the crop. • the circles on the corners of the canvas are not cut off. * -the editor button for video files, -save button * hide editor btn if panoramic || gif || live * corners instead of circles (preview), fix lint&format * confirm close editor without save * vertical aspect ratios * recovery after merge. editor's closing shortcut * fix format * move from canvas to html elements * fix changes detections * rotation * hide detail panel if showing editor * fix aspect ratios near min size * fix crop area when changing image size when rotate * fix of fix * better layout - grouping https://github.com/user-attachments/assets/48f15172-9666-4588-acb6-3cb5eda873a8 * hide the button * fix i18n, format * hide button * hide button v2 --------- Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Co-authored-by: Alex Tran --- .../asset-viewer/asset-viewer-nav-bar.svelte | 19 + .../asset-viewer/asset-viewer.svelte | 45 +- .../editor/crop-tool/crop-area.svelte | 200 +++++++ .../editor/crop-tool/crop-preset.svelte | 40 ++ .../editor/crop-tool/crop-settings.ts | 159 ++++++ .../editor/crop-tool/crop-store.ts | 27 + .../editor/crop-tool/crop-tool.svelte | 151 +++++ .../asset-viewer/editor/crop-tool/drawing.ts | 40 ++ .../editor/crop-tool/image-loading.ts | 117 ++++ .../editor/crop-tool/mouse-handlers.ts | 536 ++++++++++++++++++ .../asset-viewer/editor/editor-panel.svelte | 76 +++ web/src/lib/i18n/en.json | 7 + web/src/lib/i18n/ru.json | 6 + web/src/lib/stores/asset-editor.store.ts | 73 +++ 14 files changed, 1491 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte create mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte create mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts create mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts create mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte create mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts create mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts create mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts create mode 100644 web/src/lib/components/asset-viewer/editor/editor-panel.svelte create mode 100644 web/src/lib/stores/asset-editor.store.ts diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 85eff91ff4..a5534f79d8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -47,12 +47,22 @@ export let onRunJob: (name: AssetJobName) => void; export let onPlaySlideshow: () => void; export let onShowDetail: () => void; + // export let showEditorHandler: () => void; export let onClose: () => void; const sharedLink = getSharedLink(); $: isOwner = $user && asset.ownerId === $user?.id; $: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; + // $: showEditorButton = + // isOwner && + // asset.type === AssetTypeEnum.Image && + // !( + // asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || + // (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) + // ) && + // !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && + // !asset.livePhotoVideoId;
{/if} + {#if isOwner} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 2148ff7dda..0c8481805a 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -45,7 +45,9 @@ import PhotoViewer from './photo-viewer.svelte'; import SlideshowBar from './slideshow-bar.svelte'; import VideoViewer from './video-wrapper-viewer.svelte'; - + import EditorPanel from './editor/editor-panel.svelte'; + import CropArea from './editor/crop-tool/crop-area.svelte'; + import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; export let preloadAssets: AssetResponseDto[] = []; @@ -80,6 +82,7 @@ let shuffleSlideshowUnsubscribe: () => void; let previewStackedAsset: AssetResponseDto | undefined; let isShowActivity = false; + let isShowEditor = false; let isLiked: ActivityResponseDto | null = null; let numberOfComments: number; let fullscreenElement: Element; @@ -272,6 +275,12 @@ await navigate({ targetRoute: 'current', assetId: null }); }; + const closeEditor = () => { + closeEditorCofirm(() => { + isShowEditor = false; + }); + }; + const navigateAssetRandom = async () => { if (!assetStore) { return; @@ -315,6 +324,13 @@ dispatch(order); }; + // const showEditorHandler = () => { + // if (isShowActivity) { + // isShowActivity = false; + // } + // isShowEditor = !isShowEditor; + // }; + const handleRunJob = async (name: AssetJobName) => { try { await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } }); @@ -383,6 +399,12 @@ onAction?.(action); }; + + let selectedEditType: string = ''; + + function handleUpdateSelectedEditType(type: string) { + selectedEditType = type; + } @@ -393,7 +415,7 @@ use:focusTrap > - {#if $slideshowState === SlideshowState.None} + {#if $slideshowState === SlideshowState.None && !isShowEditor}
{/if} - {#if $slideshowState === SlideshowState.None && showNavigation} + {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
navigateAsset('previous')} />
@@ -487,6 +509,8 @@ .toLowerCase() .endsWith('.insp'))} + {:else if isShowEditor && selectedEditType === 'crop'} + {:else} {/if} @@ -516,13 +540,13 @@ {/if}
- {#if $slideshowState === SlideshowState.None && showNavigation} + {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
navigateAsset('next')} />
{/if} - {#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail} + {#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
{/if} + {#if isShowEditor} +
+ +
+ {/if} + {#if stackedAssets.length > 0 && withStacked}
+ import { onMount, afterUpdate, onDestroy, tick } from 'svelte'; + import { t } from 'svelte-i18n'; + import { getAssetOriginalUrl } from '$lib/utils'; + import { handleError } from '$lib/utils/handle-error'; + import { getAltText } from '$lib/utils/thumbnail-util'; + + import { imgElement, cropAreaEl, resetCropStore, overlayEl, isResizingOrDragging, cropFrame } from './crop-store'; + import { draw } from './drawing'; + import { onImageLoad, resizeCanvas } from './image-loading'; + import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers'; + import { recalculateCrop, animateCropChange } from './crop-settings'; + import { + changedOriention, + cropAspectRatio, + cropSettings, + resetGlobalCropStore, + rotateDegrees, + } from '$lib/stores/asset-editor.store'; + + export let asset; + let img: HTMLImageElement; + + $: imgElement.set(img); + + cropAspectRatio.subscribe((value) => { + if (!img || !$cropAreaEl) { + return; + } + const newCrop = recalculateCrop($cropSettings, $cropAreaEl, value, true); + if (newCrop) { + animateCropChange($cropSettings, newCrop, () => draw($cropSettings)); + } + }); + + onMount(async () => { + resetGlobalCropStore(); + img = new Image(); + await tick(); + + img.src = getAssetOriginalUrl({ id: asset.id, checksum: asset.checksum }); + + img.addEventListener('load', () => onImageLoad(true)); + img.addEventListener('error', (error) => { + handleError(error, $t('error_loading_image')); + }); + + window.addEventListener('mousemove', handleMouseMove); + }); + + onDestroy(() => { + window.removeEventListener('mousemove', handleMouseMove); + resetCropStore(); + resetGlobalCropStore(); + }); + + afterUpdate(() => { + resizeCanvas(); + }); + + +
+ +
+ + diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte new file mode 100644 index 0000000000..667191274f --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte @@ -0,0 +1,40 @@ + + +
  • + +
  • diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts new file mode 100644 index 0000000000..a0390d2d4d --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts @@ -0,0 +1,159 @@ +import type { CropAspectRatio, CropSettings } from '$lib/stores/asset-editor.store'; +import { get } from 'svelte/store'; +import { cropAreaEl } from './crop-store'; +import { checkEdits } from './mouse-handlers'; + +export function recalculateCrop( + crop: CropSettings, + canvas: HTMLElement, + aspectRatio: CropAspectRatio, + returnNewCrop = false, +): CropSettings | null { + const canvasW = canvas.clientWidth; + const canvasH = canvas.clientHeight; + + let newWidth = crop.width; + let newHeight = crop.height; + + const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, aspectRatio); + + if (w > canvasW) { + newWidth = canvasW; + newHeight = canvasW / (w / h); + } else if (h > canvasH) { + newHeight = canvasH; + newWidth = canvasH * (w / h); + } else { + newWidth = w; + newHeight = h; + } + + const newX = Math.max(0, Math.min(crop.x, canvasW - newWidth)); + const newY = Math.max(0, Math.min(crop.y, canvasH - newHeight)); + + const newCrop = { + width: newWidth, + height: newHeight, + x: newX, + y: newY, + }; + + if (returnNewCrop) { + setTimeout(() => { + checkEdits(); + }, 1); + return newCrop; + } else { + crop.width = newWidth; + crop.height = newHeight; + crop.x = newX; + crop.y = newY; + return null; + } +} + +export function animateCropChange(crop: CropSettings, newCrop: CropSettings, draw: () => void, duration = 100) { + const cropArea = get(cropAreaEl); + if (!cropArea) { + return; + } + + const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement; + if (!cropFrame) { + return; + } + + const startTime = performance.now(); + const initialCrop = { ...crop }; + + const animate = (currentTime: number) => { + const elapsedTime = currentTime - startTime; + const progress = Math.min(elapsedTime / duration, 1); + + crop.x = initialCrop.x + (newCrop.x - initialCrop.x) * progress; + crop.y = initialCrop.y + (newCrop.y - initialCrop.y) * progress; + crop.width = initialCrop.width + (newCrop.width - initialCrop.width) * progress; + crop.height = initialCrop.height + (newCrop.height - initialCrop.height) * progress; + + draw(); + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + + requestAnimationFrame(animate); +} + +export function keepAspectRatio(newWidth: number, newHeight: number, aspectRatio: CropAspectRatio) { + const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); + + if (widthRatio && heightRatio) { + const calculatedWidth = (newHeight * widthRatio) / heightRatio; + return { newWidth: calculatedWidth, newHeight }; + } + + return { newWidth, newHeight }; +} + +export function adjustDimensions( + newWidth: number, + newHeight: number, + aspectRatio: CropAspectRatio, + xLimit: number, + yLimit: number, + minSize: number, +) { + let w = newWidth; + let h = newHeight; + + let aspectMultiplier: number; + + if (aspectRatio === 'free') { + aspectMultiplier = newWidth / newHeight; + } else { + const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); + aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight; + } + + if (aspectRatio !== 'free') { + h = w / aspectMultiplier; + } + + if (w > xLimit) { + w = xLimit; + if (aspectRatio !== 'free') { + h = w / aspectMultiplier; + } + } + if (h > yLimit) { + h = yLimit; + if (aspectRatio !== 'free') { + w = h * aspectMultiplier; + } + } + + if (w < minSize) { + w = minSize; + if (aspectRatio !== 'free') { + h = w / aspectMultiplier; + } + } + if (h < minSize) { + h = minSize; + if (aspectRatio !== 'free') { + w = h * aspectMultiplier; + } + } + + if (aspectRatio !== 'free' && w / h !== aspectMultiplier) { + if (w < minSize) { + h = w / aspectMultiplier; + } + if (h < minSize) { + w = h * aspectMultiplier; + } + } + + return { newWidth: w, newHeight: h }; +} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts new file mode 100644 index 0000000000..8e27d41f21 --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts @@ -0,0 +1,27 @@ +import { writable } from 'svelte/store'; + +export const darkenLevel = writable(0.65); +export const isResizingOrDragging = writable(false); +export const animationFrame = writable | null>(null); +export const canvasCursor = writable('default'); +export const dragOffset = writable({ x: 0, y: 0 }); +export const resizeSide = writable(''); +export const imgElement = writable(null); +export const cropAreaEl = writable(null); +export const isDragging = writable(false); + +export const overlayEl = writable(null); +export const cropFrame = writable(null); + +export function resetCropStore() { + darkenLevel.set(0.65); + isResizingOrDragging.set(false); + animationFrame.set(null); + canvasCursor.set('default'); + dragOffset.set({ x: 0, y: 0 }); + resizeSide.set(''); + imgElement.set(null); + cropAreaEl.set(null); + isDragging.set(false); + overlayEl.set(null); +} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte new file mode 100644 index 0000000000..dba3be5d67 --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte @@ -0,0 +1,151 @@ + + +
    +
    +

    {$t('editor_crop_tool_h2_aspect_ratios').toUpperCase()}

    +
    + {#each sizesRows as sizesRow} +
      + {#each sizesRow as size (size.name)} + + {/each} +
    + {/each} +
    +

    {$t('editor_crop_tool_h2_rotation').toUpperCase()}

    +
    +
      +
    • rotate(false)} icon={mdiRotateLeft} />
    • +
    • rotate(true)} icon={mdiRotateRight} />
    • +
    +
    diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts new file mode 100644 index 0000000000..85e7f4b1c4 --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts @@ -0,0 +1,40 @@ +import type { CropSettings } from '$lib/stores/asset-editor.store'; +import { get } from 'svelte/store'; +import { cropFrame, overlayEl } from './crop-store'; + +export function draw(crop: CropSettings) { + const mCropFrame = get(cropFrame); + + if (!mCropFrame) { + return; + } + + mCropFrame.style.left = `${crop.x}px`; + mCropFrame.style.top = `${crop.y}px`; + mCropFrame.style.width = `${crop.width}px`; + mCropFrame.style.height = `${crop.height}px`; + + drawOverlay(crop); +} + +export function drawOverlay(crop: CropSettings) { + const overlay = get(overlayEl); + if (!overlay) { + return; + } + + overlay.style.clipPath = ` + polygon( + 0% 0%, + 0% 100%, + 100% 100%, + 100% 0%, + 0% 0%, + ${crop.x}px ${crop.y}px, + ${crop.x + crop.width}px ${crop.y}px, + ${crop.x + crop.width}px ${crop.y + crop.height}px, + ${crop.x}px ${crop.y + crop.height}px, + ${crop.x}px ${crop.y}px + ) + `; +} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts new file mode 100644 index 0000000000..bce90efd9e --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts @@ -0,0 +1,117 @@ +import { cropImageScale, cropImageSize, cropSettings, type CropSettings } from '$lib/stores/asset-editor.store'; +import { get } from 'svelte/store'; +import { cropAreaEl, cropFrame, imgElement } from './crop-store'; +import { draw } from './drawing'; + +export function onImageLoad(resetSize: boolean = false) { + const img = get(imgElement); + const cropArea = get(cropAreaEl); + + if (!cropArea || !img) { + return; + } + + const containerWidth = cropArea.clientWidth ?? 0; + const containerHeight = cropArea.clientHeight ?? 0; + + const scale = calculateScale(img, containerWidth, containerHeight); + + cropImageSize.set([img.width, img.height]); + + if (resetSize) { + cropSettings.update((crop) => { + crop.x = 0; + crop.y = 0; + crop.width = img.width * scale; + crop.height = img.height * scale; + return crop; + }); + } else { + const cropFrameEl = get(cropFrame); + cropFrameEl?.classList.add('transition'); + cropSettings.update((crop) => normalizeCropArea(crop, img, scale)); + cropFrameEl?.classList.add('transition'); + cropFrameEl?.addEventListener('transitionend', () => { + cropFrameEl?.classList.remove('transition'); + }); + } + cropImageScale.set(scale); + + img.style.width = `${img.width * scale}px`; + img.style.height = `${img.height * scale}px`; + + draw(get(cropSettings)); +} + +export function calculateScale(img: HTMLImageElement, containerWidth: number, containerHeight: number): number { + const imageAspectRatio = img.width / img.height; + let scale: number; + + if (imageAspectRatio > 1) { + scale = containerWidth / img.width; + if (img.height * scale > containerHeight) { + scale = containerHeight / img.height; + } + } else { + scale = containerHeight / img.height; + if (img.width * scale > containerWidth) { + scale = containerWidth / img.width; + } + } + + return scale; +} + +export function normalizeCropArea(crop: CropSettings, img: HTMLImageElement, scale: number) { + const prevScale = get(cropImageScale); + const scaleRatio = scale / prevScale; + + crop.x *= scaleRatio; + crop.y *= scaleRatio; + crop.width *= scaleRatio; + crop.height *= scaleRatio; + + crop.width = Math.min(crop.width, img.width * scale); + crop.height = Math.min(crop.height, img.height * scale); + crop.x = Math.max(0, Math.min(crop.x, img.width * scale - crop.width)); + crop.y = Math.max(0, Math.min(crop.y, img.height * scale - crop.height)); + + return crop; +} + +export function resizeCanvas() { + const img = get(imgElement); + const cropArea = get(cropAreaEl); + + if (!cropArea || !img) { + return; + } + + const containerWidth = cropArea?.clientWidth ?? 0; + const containerHeight = cropArea?.clientHeight ?? 0; + const imageAspectRatio = img.width / img.height; + + let scale; + if (imageAspectRatio > 1) { + scale = containerWidth / img.width; + if (img.height * scale > containerHeight) { + scale = containerHeight / img.height; + } + } else { + scale = containerHeight / img.height; + if (img.width * scale > containerWidth) { + scale = containerWidth / img.width; + } + } + + img.style.width = `${img.width * scale}px`; + img.style.height = `${img.height * scale}px`; + + const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement; + if (cropFrame) { + cropFrame.style.width = `${img.width * scale}px`; + cropFrame.style.height = `${img.height * scale}px`; + } + + draw(get(cropSettings)); +} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts new file mode 100644 index 0000000000..656fd09294 --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts @@ -0,0 +1,536 @@ +import { + cropAspectRatio, + cropImageScale, + cropImageSize, + cropSettings, + cropSettingsChanged, + normaizedRorateDegrees, + rotateDegrees, + showCancelConfirmDialog, + type CropSettings, +} from '$lib/stores/asset-editor.store'; +import { get } from 'svelte/store'; +import { adjustDimensions, keepAspectRatio } from './crop-settings'; +import { + canvasCursor, + cropAreaEl, + dragOffset, + isDragging, + isResizingOrDragging, + overlayEl, + resizeSide, +} from './crop-store'; +import { draw } from './drawing'; + +export function handleMouseDown(e: MouseEvent) { + const canvas = get(cropAreaEl); + if (!canvas) { + return; + } + + const crop = get(cropSettings); + const { mouseX, mouseY } = getMousePosition(e); + + const { + onLeftBoundary, + onRightBoundary, + onTopBoundary, + onBottomBoundary, + onTopLeftCorner, + onTopRightCorner, + onBottomLeftCorner, + onBottomRightCorner, + } = isOnCropBoundary(mouseX, mouseY, crop); + + if ( + onTopLeftCorner || + onTopRightCorner || + onBottomLeftCorner || + onBottomRightCorner || + onLeftBoundary || + onRightBoundary || + onTopBoundary || + onBottomBoundary + ) { + setResizeSide(mouseX, mouseY); + } else if (isInCropArea(mouseX, mouseY, crop)) { + startDragging(mouseX, mouseY); + } + + document.body.style.userSelect = 'none'; + window.addEventListener('mouseup', handleMouseUp); +} + +export function handleMouseMove(e: MouseEvent) { + const canvas = get(cropAreaEl); + if (!canvas) { + return; + } + + const resizeSideValue = get(resizeSide); + const { mouseX, mouseY } = getMousePosition(e); + + if (get(isDragging)) { + moveCrop(mouseX, mouseY); + } else if (resizeSideValue) { + resizeCrop(mouseX, mouseY); + } else { + updateCursor(mouseX, mouseY); + } +} + +export function handleMouseUp() { + window.removeEventListener('mouseup', handleMouseUp); + document.body.style.userSelect = ''; + stopInteraction(); +} + +function getMousePosition(e: MouseEvent) { + let offsetX = e.clientX; + let offsetY = e.clientY; + const clienRect = getBoundingClientRectCached(get(cropAreaEl)); + const rotateDeg = get(normaizedRorateDegrees); + + if (rotateDeg == 90) { + offsetX = e.clientY - (clienRect?.top ?? 0); + offsetY = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); + } else if (rotateDeg == 180) { + offsetX = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); + offsetY = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); + } else if (rotateDeg == 270) { + offsetX = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); + offsetY = e.clientX - (clienRect?.left ?? 0); + } else if (rotateDeg == 0) { + offsetX -= clienRect?.left ?? 0; + offsetY -= clienRect?.top ?? 0; + } + return { mouseX: offsetX, mouseY: offsetY }; +} + +type BoundingClientRect = ReturnType; +let getBoundingClientRectCache: { data: BoundingClientRect | null; time: number } = { + data: null, + time: 0, +}; +rotateDegrees.subscribe(() => { + getBoundingClientRectCache.time = 0; +}); +function getBoundingClientRectCached(el: HTMLElement | null) { + if (Date.now() - getBoundingClientRectCache.time > 5000 || getBoundingClientRectCache.data === null) { + getBoundingClientRectCache = { + time: Date.now(), + data: el?.getBoundingClientRect() ?? null, + }; + } + return getBoundingClientRectCache.data; +} + +function isOnCropBoundary(mouseX: number, mouseY: number, crop: CropSettings) { + const { x, y, width, height } = crop; + const sensitivity = 10; + const cornerSensitivity = 15; + + const outOfBound = mouseX > get(cropImageSize)[0] || mouseY > get(cropImageSize)[1] || mouseX < 0 || mouseY < 0; + if (outOfBound) { + return { + onLeftBoundary: false, + onRightBoundary: false, + onTopBoundary: false, + onBottomBoundary: false, + onTopLeftCorner: false, + onTopRightCorner: false, + onBottomLeftCorner: false, + onBottomRightCorner: false, + }; + } + + const onLeftBoundary = mouseX >= x - sensitivity && mouseX <= x + sensitivity && mouseY >= y && mouseY <= y + height; + const onRightBoundary = + mouseX >= x + width - sensitivity && mouseX <= x + width + sensitivity && mouseY >= y && mouseY <= y + height; + const onTopBoundary = mouseY >= y - sensitivity && mouseY <= y + sensitivity && mouseX >= x && mouseX <= x + width; + const onBottomBoundary = + mouseY >= y + height - sensitivity && mouseY <= y + height + sensitivity && mouseX >= x && mouseX <= x + width; + + const onTopLeftCorner = + mouseX >= x - cornerSensitivity && + mouseX <= x + cornerSensitivity && + mouseY >= y - cornerSensitivity && + mouseY <= y + cornerSensitivity; + const onTopRightCorner = + mouseX >= x + width - cornerSensitivity && + mouseX <= x + width + cornerSensitivity && + mouseY >= y - cornerSensitivity && + mouseY <= y + cornerSensitivity; + const onBottomLeftCorner = + mouseX >= x - cornerSensitivity && + mouseX <= x + cornerSensitivity && + mouseY >= y + height - cornerSensitivity && + mouseY <= y + height + cornerSensitivity; + const onBottomRightCorner = + mouseX >= x + width - cornerSensitivity && + mouseX <= x + width + cornerSensitivity && + mouseY >= y + height - cornerSensitivity && + mouseY <= y + height + cornerSensitivity; + + return { + onLeftBoundary, + onRightBoundary, + onTopBoundary, + onBottomBoundary, + onTopLeftCorner, + onTopRightCorner, + onBottomLeftCorner, + onBottomRightCorner, + }; +} + +function isInCropArea(mouseX: number, mouseY: number, crop: CropSettings) { + const { x, y, width, height } = crop; + return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; +} + +function setResizeSide(mouseX: number, mouseY: number) { + const crop = get(cropSettings); + const { + onLeftBoundary, + onRightBoundary, + onTopBoundary, + onBottomBoundary, + onTopLeftCorner, + onTopRightCorner, + onBottomLeftCorner, + onBottomRightCorner, + } = isOnCropBoundary(mouseX, mouseY, crop); + + if (onTopLeftCorner) { + resizeSide.set('top-left'); + } else if (onTopRightCorner) { + resizeSide.set('top-right'); + } else if (onBottomLeftCorner) { + resizeSide.set('bottom-left'); + } else if (onBottomRightCorner) { + resizeSide.set('bottom-right'); + } else if (onLeftBoundary) { + resizeSide.set('left'); + } else if (onRightBoundary) { + resizeSide.set('right'); + } else if (onTopBoundary) { + resizeSide.set('top'); + } else if (onBottomBoundary) { + resizeSide.set('bottom'); + } +} + +function startDragging(mouseX: number, mouseY: number) { + isDragging.set(true); + const crop = get(cropSettings); + isResizingOrDragging.set(true); + dragOffset.set({ x: mouseX - crop.x, y: mouseY - crop.y }); + fadeOverlay(false); +} + +function moveCrop(mouseX: number, mouseY: number) { + const cropArea = get(cropAreaEl); + if (!cropArea) { + return; + } + + const crop = get(cropSettings); + const { x, y } = get(dragOffset); + + let newX = mouseX - x; + let newY = mouseY - y; + + newX = Math.max(0, Math.min(cropArea.clientWidth - crop.width, newX)); + newY = Math.max(0, Math.min(cropArea.clientHeight - crop.height, newY)); + + cropSettings.update((crop) => { + crop.x = newX; + crop.y = newY; + return crop; + }); + + draw(crop); +} + +function resizeCrop(mouseX: number, mouseY: number) { + const canvas = get(cropAreaEl); + const crop = get(cropSettings); + const resizeSideValue = get(resizeSide); + if (!canvas || !resizeSideValue) { + return; + } + fadeOverlay(false); + + const { x, y, width, height } = crop; + const minSize = 50; + let newWidth = width; + let newHeight = height; + switch (resizeSideValue) { + case 'left': { + newWidth = width + x - mouseX; + newHeight = height; + if (newWidth >= minSize && mouseX >= 0) { + const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio)); + cropSettings.update((crop) => { + crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth)); + crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight)); + crop.x = Math.max(0, x + width - crop.width); + return crop; + }); + } + break; + } + case 'right': { + newWidth = mouseX - x; + newHeight = height; + if (newWidth >= minSize && mouseX <= canvas.clientWidth) { + const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio)); + cropSettings.update((crop) => { + crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth - x)); + crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight)); + return crop; + }); + } + break; + } + case 'top': { + newHeight = height + y - mouseY; + newWidth = width; + if (newHeight >= minSize && mouseY >= 0) { + const { newWidth: w, newHeight: h } = adjustDimensions( + newWidth, + newHeight, + get(cropAspectRatio), + canvas.clientWidth, + canvas.clientHeight, + minSize, + ); + cropSettings.update((crop) => { + crop.y = Math.max(0, y + height - h); + crop.width = w; + crop.height = h; + return crop; + }); + } + break; + } + case 'bottom': { + newHeight = mouseY - y; + newWidth = width; + if (newHeight >= minSize && mouseY <= canvas.clientHeight) { + const { newWidth: w, newHeight: h } = adjustDimensions( + newWidth, + newHeight, + get(cropAspectRatio), + canvas.clientWidth, + canvas.clientHeight - y, + minSize, + ); + cropSettings.update((crop) => { + crop.width = w; + crop.height = h; + return crop; + }); + } + break; + } + case 'top-left': { + newWidth = width + x - Math.max(mouseX, 0); + newHeight = height + y - Math.max(mouseY, 0); + const { newWidth: w, newHeight: h } = adjustDimensions( + newWidth, + newHeight, + get(cropAspectRatio), + canvas.clientWidth, + canvas.clientHeight, + minSize, + ); + cropSettings.update((crop) => { + crop.width = w; + crop.height = h; + crop.x = Math.max(0, x + width - crop.width); + crop.y = Math.max(0, y + height - crop.height); + return crop; + }); + break; + } + case 'top-right': { + newWidth = Math.max(mouseX, 0) - x; + newHeight = height + y - Math.max(mouseY, 0); + const { newWidth: w, newHeight: h } = adjustDimensions( + newWidth, + newHeight, + get(cropAspectRatio), + canvas.clientWidth - x, + y + height, + minSize, + ); + cropSettings.update((crop) => { + crop.width = w; + crop.height = h; + crop.y = Math.max(0, y + height - crop.height); + return crop; + }); + break; + } + case 'bottom-left': { + newWidth = width + x - Math.max(mouseX, 0); + newHeight = Math.max(mouseY, 0) - y; + const { newWidth: w, newHeight: h } = adjustDimensions( + newWidth, + newHeight, + get(cropAspectRatio), + canvas.clientWidth, + canvas.clientHeight - y, + minSize, + ); + cropSettings.update((crop) => { + crop.width = w; + crop.height = h; + crop.x = Math.max(0, x + width - crop.width); + return crop; + }); + break; + } + case 'bottom-right': { + newWidth = Math.max(mouseX, 0) - x; + newHeight = Math.max(mouseY, 0) - y; + const { newWidth: w, newHeight: h } = adjustDimensions( + newWidth, + newHeight, + get(cropAspectRatio), + canvas.clientWidth - x, + canvas.clientHeight - y, + minSize, + ); + cropSettings.update((crop) => { + crop.width = w; + crop.height = h; + return crop; + }); + break; + } + } + + cropSettings.update((crop) => { + crop.x = Math.max(0, Math.min(crop.x, canvas.clientWidth - crop.width)); + crop.y = Math.max(0, Math.min(crop.y, canvas.clientHeight - crop.height)); + return crop; + }); + + draw(crop); +} + +function updateCursor(mouseX: number, mouseY: number) { + const canvas = get(cropAreaEl); + if (!canvas) { + return; + } + + const crop = get(cropSettings); + const rotateDeg = get(normaizedRorateDegrees); + + let { + onLeftBoundary, + onRightBoundary, + onTopBoundary, + onBottomBoundary, + onTopLeftCorner, + onTopRightCorner, + onBottomLeftCorner, + onBottomRightCorner, + } = isOnCropBoundary(mouseX, mouseY, crop); + + if (rotateDeg == 90) { + [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ + onLeftBoundary, + onTopBoundary, + onRightBoundary, + onBottomBoundary, + ]; + + [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ + onBottomLeftCorner, + onTopLeftCorner, + onTopRightCorner, + onBottomRightCorner, + ]; + } else if (rotateDeg == 180) { + [onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary]; + [onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary]; + + [onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner]; + [onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner]; + } else if (rotateDeg == 270) { + [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ + onRightBoundary, + onBottomBoundary, + onLeftBoundary, + onTopBoundary, + ]; + + [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ + onTopRightCorner, + onBottomRightCorner, + onBottomLeftCorner, + onTopLeftCorner, + ]; + } + if (onTopLeftCorner || onBottomRightCorner) { + setCursor('nwse-resize'); + } else if (onTopRightCorner || onBottomLeftCorner) { + setCursor('nesw-resize'); + } else if (onLeftBoundary || onRightBoundary) { + setCursor('ew-resize'); + } else if (onTopBoundary || onBottomBoundary) { + setCursor('ns-resize'); + } else if (isInCropArea(mouseX, mouseY, crop)) { + setCursor('move'); + } else { + setCursor('default'); + } + + function setCursor(cursorName: string) { + if (get(canvasCursor) != cursorName && canvas && !get(showCancelConfirmDialog)) { + canvasCursor.set(cursorName); + document.body.style.cursor = cursorName; + canvas.style.cursor = cursorName; + } + } +} + +function stopInteraction() { + isResizingOrDragging.set(false); + isDragging.set(false); + resizeSide.set(''); + fadeOverlay(true); // Darken the background + + setTimeout(() => { + checkEdits(); + }, 1); +} + +export function checkEdits() { + const cropImageSizeParams = get(cropSettings); + const originalImgSize = get(cropImageSize).map((el) => el * get(cropImageScale)); + const changed = + Math.abs(originalImgSize[0] - cropImageSizeParams.width) > 2 || + Math.abs(originalImgSize[1] - cropImageSizeParams.height) > 2; + cropSettingsChanged.set(changed); +} + +function fadeOverlay(toDark: boolean) { + const overlay = get(overlayEl); + const cropFrame = document.querySelector('.crop-frame'); + + if (toDark) { + overlay?.classList.remove('light'); + cropFrame?.classList.remove('resizing'); + } else { + overlay?.classList.add('light'); + cropFrame?.classList.add('resizing'); + } + + isResizingOrDragging.set(!toDark); +} diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte new file mode 100644 index 0000000000..1adef32735 --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -0,0 +1,76 @@ + + + + +
    +
    + +

    {$t('editor')}

    +
    +
    +
      + {#each editTypes as etype (etype.name)} +
    • + selectType(etype.name)} + /> +
    • + {/each} +
    +
    +
    + +
    +
    + +{#if $showCancelConfirmDialog} + { + $showCancelConfirmDialog = false; + }} + onConfirm={() => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog())} + /> +{/if} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index f424e60a66..5b2d9d393a 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -359,6 +359,7 @@ "allow_edits": "Allow edits", "allow_public_user_to_download": "Allow public user to download", "allow_public_user_to_upload": "Allow public user to upload", + "anti_clockwise": "Anti-clockwise", "api_key": "API Key", "api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.", "api_key_empty": "Your API Key name shouldn't be empty", @@ -434,6 +435,7 @@ "clear_all_recent_searches": "Clear all recent searches", "clear_message": "Clear message", "clear_value": "Clear value", + "clockwise": "Сlockwise", "close": "Close", "collapse": "Collapse", "collapse_all": "Collapse all", @@ -535,6 +537,11 @@ "edit_title": "Edit Title", "edit_user": "Edit user", "edited": "Edited", + "editor": "Editor", + "editor_close_without_save_prompt": "The changes will not be saved", + "editor_close_without_save_title": "Close editor?", + "editor_crop_tool_h2_aspect_ratios": "Aspect ratios", + "editor_crop_tool_h2_rotation": "Rotation", "email": "Email", "empty_trash": "Empty trash", "empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 6a31d297af..1a55ab009d 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -360,6 +360,7 @@ "allow_edits": "Разрешить редактирование", "allow_public_user_to_download": "Разрешить скачивание публичным пользователям", "allow_public_user_to_upload": "Разрешить публичным пользователям загружать файлы", + "anti_clockwise": "Против часовой", "api_key": "API Ключ", "api_key_description": "Это значение будет показано только один раз. Пожалуйста, убедитесь, что скопировали его перед закрытием окна.", "api_key_empty": "Ваш API ключ не должен быть пустым", @@ -441,6 +442,7 @@ "clear_all_recent_searches": "Очистить все недавние результаты поиска", "clear_message": "Очистить сообщение", "clear_value": "Очистить значение", + "clockwise": "По часовой", "close": "Закрыть", "collapse": "Свернуть", "collapse_all": "Свернуть всё", @@ -550,6 +552,10 @@ "edit_user": "Редактировать пользователя", "edited": "Отредактировано", "editor": "Редактор", + "editor_close_without_save_prompt": "Изменения не будут сохранены", + "editor_close_without_save_title": "Закрыть редактор?", + "editor_crop_tool_h2_aspect_ratios": "Соотношения сторон", + "editor_crop_tool_h2_rotation": "Вращение", "email": "Электронная почта", "empty": "", "empty_album": "Пустой альбом", diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts new file mode 100644 index 0000000000..4d2f8977ee --- /dev/null +++ b/web/src/lib/stores/asset-editor.store.ts @@ -0,0 +1,73 @@ +import CropTool from '$lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte'; +import { mdiCropRotate } from '@mdi/js'; +import { derived, get, writable } from 'svelte/store'; + +//---------crop +export const cropSettings = writable({ x: 0, y: 0, width: 100, height: 100 }); +export const cropImageSize = writable([1000, 1000]); +export const cropImageScale = writable(1); +export const cropAspectRatio = writable('free'); +export const cropSettingsChanged = writable(false); +//---------rotate +export const rotateDegrees = writable(0); +export const normaizedRorateDegrees = derived(rotateDegrees, (v) => { + const newAngle = v % 360; + return newAngle < 0 ? newAngle + 360 : newAngle; +}); +export const changedOriention = derived(normaizedRorateDegrees, () => get(normaizedRorateDegrees) % 180 > 0); +//-----other +export const showCancelConfirmDialog = writable(false); + +export const editTypes = [ + { + name: 'crop', + icon: mdiCropRotate, + component: CropTool, + changesFlag: cropSettingsChanged, + }, +]; + +export function closeEditorCofirm(closeCallback: CallableFunction) { + if (get(hasChanges)) { + showCancelConfirmDialog.set(closeCallback); + } else { + closeCallback(); + } +} + +export const hasChanges = derived( + editTypes.map((t) => t.changesFlag), + ($flags) => { + return $flags.some(Boolean); + }, +); + +export function resetGlobalCropStore() { + cropSettings.set({ x: 0, y: 0, width: 100, height: 100 }); + cropImageSize.set([1000, 1000]); + cropImageScale.set(1); + cropAspectRatio.set('free'); + cropSettingsChanged.set(false); + showCancelConfirmDialog.set(false); + rotateDegrees.set(0); +} + +export type CropAspectRatio = + | '1:1' + | '16:9' + | '4:3' + | '3:2' + | '7:5' + | '9:16' + | '3:4' + | '2:3' + | '5:7' + | 'free' + | 'reset'; + +export type CropSettings = { + x: number; + y: number; + width: number; + height: number; +}; From fb962f49ea3815c4189edb8417278765e136b644 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 14 Aug 2024 10:20:12 -0500 Subject: [PATCH 090/723] fix(ml): pydantic dep causes starting up issue (#11773) * fix(ml): pydantic dep causes starting up issue * revert import --- machine-learning/app/config.py | 2 +- machine-learning/app/main.py | 2 +- machine-learning/app/schemas.py | 2 +- machine-learning/poetry.lock | 175 +++++++++----------------------- machine-learning/pyproject.toml | 2 +- 5 files changed, 54 insertions(+), 129 deletions(-) diff --git a/machine-learning/app/config.py b/machine-learning/app/config.py index 5dec031529..af2d0aa4b9 100644 --- a/machine-learning/app/config.py +++ b/machine-learning/app/config.py @@ -6,7 +6,7 @@ from pathlib import Path from socket import socket from gunicorn.arbiter import Arbiter -from pydantic.v1 import BaseModel, BaseSettings +from pydantic import BaseModel, BaseSettings from rich.console import Console from rich.logging import RichHandler from uvicorn import Server diff --git a/machine-learning/app/main.py b/machine-learning/app/main.py index 52b9a66c05..000119937e 100644 --- a/machine-learning/app/main.py +++ b/machine-learning/app/main.py @@ -15,7 +15,7 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException from fastapi.responses import ORJSONResponse from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile from PIL.Image import Image -from pydantic.v1 import ValidationError +from pydantic import ValidationError from starlette.formparsers import MultiPartParser from app.models import get_model_deps diff --git a/machine-learning/app/schemas.py b/machine-learning/app/schemas.py index e8a36ef44d..f051db12c3 100644 --- a/machine-learning/app/schemas.py +++ b/machine-learning/app/schemas.py @@ -3,7 +3,7 @@ from typing import Any, Literal, Protocol, TypedDict, TypeGuard, TypeVar import numpy as np import numpy.typing as npt -from pydantic.v1 import BaseModel +from pydantic import BaseModel class StrEnum(str, Enum): diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 11b0530dca..9d19b671d1 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -40,17 +40,6 @@ develop = ["imgaug (>=0.4.0)", "pytest"] imgaug = ["imgaug (>=0.4.0)"] tests = ["pytest"] -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - [[package]] name = "anyio" version = "4.2.0" @@ -2380,126 +2369,62 @@ files = [ [[package]] name = "pydantic" -version = "2.8.2" -description = "Data validation using Python type hints" +version = "1.10.17" +description = "Data validation and settings management using python type hints" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, - {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, + {file = "pydantic-1.10.17-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fa51175313cc30097660b10eec8ca55ed08bfa07acbfe02f7a42f6c242e9a4b"}, + {file = "pydantic-1.10.17-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7e8988bb16988890c985bd2093df9dd731bfb9d5e0860db054c23034fab8f7a"}, + {file = "pydantic-1.10.17-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:371dcf1831f87c9e217e2b6a0c66842879a14873114ebb9d0861ab22e3b5bb1e"}, + {file = "pydantic-1.10.17-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4866a1579c0c3ca2c40575398a24d805d4db6cb353ee74df75ddeee3c657f9a7"}, + {file = "pydantic-1.10.17-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:543da3c6914795b37785703ffc74ba4d660418620cc273490d42c53949eeeca6"}, + {file = "pydantic-1.10.17-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7623b59876f49e61c2e283551cc3647616d2fbdc0b4d36d3d638aae8547ea681"}, + {file = "pydantic-1.10.17-cp310-cp310-win_amd64.whl", hash = "sha256:409b2b36d7d7d19cd8310b97a4ce6b1755ef8bd45b9a2ec5ec2b124db0a0d8f3"}, + {file = "pydantic-1.10.17-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fa43f362b46741df8f201bf3e7dff3569fa92069bcc7b4a740dea3602e27ab7a"}, + {file = "pydantic-1.10.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2a72d2a5ff86a3075ed81ca031eac86923d44bc5d42e719d585a8eb547bf0c9b"}, + {file = "pydantic-1.10.17-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4ad32aed3bf5eea5ca5decc3d1bbc3d0ec5d4fbcd72a03cdad849458decbc63"}, + {file = "pydantic-1.10.17-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb4e741782e236ee7dc1fb11ad94dc56aabaf02d21df0e79e0c21fe07c95741"}, + {file = "pydantic-1.10.17-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d2f89a719411cb234105735a520b7c077158a81e0fe1cb05a79c01fc5eb59d3c"}, + {file = "pydantic-1.10.17-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db3b48d9283d80a314f7a682f7acae8422386de659fffaba454b77a083c3937d"}, + {file = "pydantic-1.10.17-cp311-cp311-win_amd64.whl", hash = "sha256:9c803a5113cfab7bbb912f75faa4fc1e4acff43e452c82560349fff64f852e1b"}, + {file = "pydantic-1.10.17-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:820ae12a390c9cbb26bb44913c87fa2ff431a029a785642c1ff11fed0a095fcb"}, + {file = "pydantic-1.10.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1e51d1af306641b7d1574d6d3307eaa10a4991542ca324f0feb134fee259815"}, + {file = "pydantic-1.10.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e53fb834aae96e7b0dadd6e92c66e7dd9cdf08965340ed04c16813102a47fab"}, + {file = "pydantic-1.10.17-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e2495309b1266e81d259a570dd199916ff34f7f51f1b549a0d37a6d9b17b4dc"}, + {file = "pydantic-1.10.17-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:098ad8de840c92ea586bf8efd9e2e90c6339d33ab5c1cfbb85be66e4ecf8213f"}, + {file = "pydantic-1.10.17-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:525bbef620dac93c430d5d6bdbc91bdb5521698d434adf4434a7ef6ffd5c4b7f"}, + {file = "pydantic-1.10.17-cp312-cp312-win_amd64.whl", hash = "sha256:6654028d1144df451e1da69a670083c27117d493f16cf83da81e1e50edce72ad"}, + {file = "pydantic-1.10.17-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c87cedb4680d1614f1d59d13fea353faf3afd41ba5c906a266f3f2e8c245d655"}, + {file = "pydantic-1.10.17-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11289fa895bcbc8f18704efa1d8020bb9a86314da435348f59745473eb042e6b"}, + {file = "pydantic-1.10.17-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94833612d6fd18b57c359a127cbfd932d9150c1b72fea7c86ab58c2a77edd7c7"}, + {file = "pydantic-1.10.17-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d4ecb515fa7cb0e46e163ecd9d52f9147ba57bc3633dca0e586cdb7a232db9e3"}, + {file = "pydantic-1.10.17-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7017971ffa7fd7808146880aa41b266e06c1e6e12261768a28b8b41ba55c8076"}, + {file = "pydantic-1.10.17-cp37-cp37m-win_amd64.whl", hash = "sha256:e840e6b2026920fc3f250ea8ebfdedf6ea7a25b77bf04c6576178e681942ae0f"}, + {file = "pydantic-1.10.17-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bfbb18b616abc4df70591b8c1ff1b3eabd234ddcddb86b7cac82657ab9017e33"}, + {file = "pydantic-1.10.17-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ebb249096d873593e014535ab07145498957091aa6ae92759a32d40cb9998e2e"}, + {file = "pydantic-1.10.17-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8c209af63ccd7b22fba94b9024e8b7fd07feffee0001efae50dd99316b27768"}, + {file = "pydantic-1.10.17-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b40c9e13a0b61583e5599e7950490c700297b4a375b55b2b592774332798b7"}, + {file = "pydantic-1.10.17-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c31d281c7485223caf6474fc2b7cf21456289dbaa31401844069b77160cab9c7"}, + {file = "pydantic-1.10.17-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ae5184e99a060a5c80010a2d53c99aee76a3b0ad683d493e5f0620b5d86eeb75"}, + {file = "pydantic-1.10.17-cp38-cp38-win_amd64.whl", hash = "sha256:ad1e33dc6b9787a6f0f3fd132859aa75626528b49cc1f9e429cdacb2608ad5f0"}, + {file = "pydantic-1.10.17-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e17c0ee7192e54a10943f245dc79e36d9fe282418ea05b886e1c666063a7b54"}, + {file = "pydantic-1.10.17-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cafb9c938f61d1b182dfc7d44a7021326547b7b9cf695db5b68ec7b590214773"}, + {file = "pydantic-1.10.17-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95ef534e3c22e5abbdbdd6f66b6ea9dac3ca3e34c5c632894f8625d13d084cbe"}, + {file = "pydantic-1.10.17-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d96b8799ae3d782df7ec9615cb59fc32c32e1ed6afa1b231b0595f6516e8ab"}, + {file = "pydantic-1.10.17-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ab2f976336808fd5d539fdc26eb51f9aafc1f4b638e212ef6b6f05e753c8011d"}, + {file = "pydantic-1.10.17-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8ad363330557beac73159acfbeed220d5f1bfcd6b930302a987a375e02f74fd"}, + {file = "pydantic-1.10.17-cp39-cp39-win_amd64.whl", hash = "sha256:48db882e48575ce4b39659558b2f9f37c25b8d348e37a2b4e32971dd5a7d6227"}, + {file = "pydantic-1.10.17-py3-none-any.whl", hash = "sha256:e41b5b973e5c64f674b3b4720286ded184dcc26a691dd55f34391c62c6934688"}, + {file = "pydantic-1.10.17.tar.gz", hash = "sha256:f434160fb14b353caf634149baaf847206406471ba70e64657c1e8330277a991"}, ] [package.dependencies] -annotated-types = ">=0.4.0" -pydantic-core = "2.20.1" -typing-extensions = [ - {version = ">=4.6.1", markers = "python_version < \"3.13\""}, - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, -] +typing-extensions = ">=4.2.0" [package.extras] -email = ["email-validator (>=2.0.0)"] - -[[package]] -name = "pydantic-core" -version = "2.20.1" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, - {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, - {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, - {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, - {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, - {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, - {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, - {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, - {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, - {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, - {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, - {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, - {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, - {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, - {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, - {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, - {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, - {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, - {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, - {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, - {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, - {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, - {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, - {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, - {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, - {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, - {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, - {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, - {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, - {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, - {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, - {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, - {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, - {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, - {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, - {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, - {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] [[package]] name = "pygments" @@ -3677,4 +3602,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "187485f19267f2d0a01e38fc0c1f8911c07a29aee11080179a96a127abb9c11b" +content-hash = "b2b053886ca1dd3a3305c63caf155b1976dfc4066f72f5d1ecfc42099db34aab" diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index e9a9708f15..37001ba2eb 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -13,7 +13,7 @@ opencv-python-headless = ">=4.7.0.72,<5.0" pillow = ">=9.5.0,<11.0" fastapi-slim = ">=0.95.2,<1.0" uvicorn = {extras = ["standard"], version = ">=0.22.0,<1.0"} -pydantic = "^2.8.2" +pydantic = "^1.10.8" aiocache = ">=0.12.1,<1.0" rich = ">=13.4.2" ftfy = ">=6.1.1" From 8014b0f86deae138ee91eb23ae0c6a8859dac87e Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 14 Aug 2024 10:29:49 -0500 Subject: [PATCH 091/723] chore(mobile): Translations update (#11771) chore(mobile): translation update --- mobile/assets/i18n/ar-JO.json | 13 ++++++ mobile/assets/i18n/cs-CZ.json | 13 ++++++ mobile/assets/i18n/da-DK.json | 13 ++++++ mobile/assets/i18n/de-DE.json | 77 +++++++++++++++++++------------- mobile/assets/i18n/el-GR.json | 13 ++++++ mobile/assets/i18n/en-US.json | 14 +++--- mobile/assets/i18n/es-ES.json | 13 ++++++ mobile/assets/i18n/es-MX.json | 13 ++++++ mobile/assets/i18n/es-PE.json | 13 ++++++ mobile/assets/i18n/es-US.json | 13 ++++++ mobile/assets/i18n/fi-FI.json | 13 ++++++ mobile/assets/i18n/fr-CA.json | 13 ++++++ mobile/assets/i18n/fr-FR.json | 13 ++++++ mobile/assets/i18n/he-IL.json | 75 ++++++++++++++++++------------- mobile/assets/i18n/hi-IN.json | 13 ++++++ mobile/assets/i18n/hu-HU.json | 13 ++++++ mobile/assets/i18n/it-IT.json | 13 ++++++ mobile/assets/i18n/ja-JP.json | 13 ++++++ mobile/assets/i18n/ko-KR.json | 37 ++++++++++----- mobile/assets/i18n/lt-LT.json | 13 ++++++ mobile/assets/i18n/lv-LV.json | 13 ++++++ mobile/assets/i18n/mn.json | 13 ++++++ mobile/assets/i18n/nb-NO.json | 71 +++++++++++++++++------------ mobile/assets/i18n/nl-NL.json | 13 ++++++ mobile/assets/i18n/pl-PL.json | 13 ++++++ mobile/assets/i18n/pt-PT.json | 13 ++++++ mobile/assets/i18n/ro-RO.json | 13 ++++++ mobile/assets/i18n/ru-RU.json | 71 +++++++++++++++++------------ mobile/assets/i18n/sk-SK.json | 13 ++++++ mobile/assets/i18n/sl-SI.json | 13 ++++++ mobile/assets/i18n/sr-Cyrl.json | 13 ++++++ mobile/assets/i18n/sr-Latn.json | 13 ++++++ mobile/assets/i18n/sv-FI.json | 13 ++++++ mobile/assets/i18n/sv-SE.json | 15 ++++++- mobile/assets/i18n/th-TH.json | 13 ++++++ mobile/assets/i18n/uk-UA.json | 71 +++++++++++++++++------------ mobile/assets/i18n/vi-VN.json | 79 +++++++++++++++++++-------------- mobile/assets/i18n/zh-CN.json | 13 ++++++ mobile/assets/i18n/zh-Hans.json | 13 ++++++ mobile/assets/i18n/zh-TW.json | 13 ++++++ 40 files changed, 710 insertions(+), 203 deletions(-) diff --git a/mobile/assets/i18n/ar-JO.json b/mobile/assets/i18n/ar-JO.json index e6a8a47d85..1ba97ee507 100644 --- a/mobile/assets/i18n/ar-JO.json +++ b/mobile/assets/i18n/ar-JO.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "تصميم", "asset_list_settings_subtitle": "إعدادات تخطيط شبكة الصور", "asset_list_settings_title": "شبكة الصور", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "عارض الأصول", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "انقر للتضمين، وانقر نقرًا مزدوجًا للاستثناء", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "مشاركة", "theme_setting_asset_list_storage_indicator_title": "عرض مؤشر التخزين على بلاط الأصول", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "الوضع المظلم", "theme_setting_image_viewer_quality_subtitle": "اضبط جودة عارض الصورة التفصيلية", "theme_setting_image_viewer_quality_title": "جودة عارض الصورة", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "تلقائي (اتبع إعداد النظام)", "theme_setting_theme_subtitle": "اختر إعدادات مظهر التطبيق", "theme_setting_theme_title": "مظهر", "theme_setting_three_stage_loading_subtitle": "قد يزيد التحميل من ثلاث مراحل من أداء التحميل ولكنه يسبب تحميل شبكة أعلى بكثير", "theme_setting_three_stage_loading_title": "تمكين تحميل ثلاث مراحل", "translated_text_options": "خيارات", + "trash_emptied": "Emptied trash", "trash_page_delete": "مسح", "trash_page_delete_all": "حذف الكل", "trash_page_empty_trash_btn": "افرغ سله المهملات ", diff --git a/mobile/assets/i18n/cs-CZ.json b/mobile/assets/i18n/cs-CZ.json index e62691a446..86ca2e032b 100644 --- a/mobile/assets/i18n/cs-CZ.json +++ b/mobile/assets/i18n/cs-CZ.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Rozložení", "asset_list_settings_subtitle": "Nastavení rozložení mřížky fotografií", "asset_list_settings_title": "Fotografická mřížka", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Prohlížeč", "backup_album_selection_page_albums_device": "Alba v zařízení ({})", "backup_album_selection_page_albums_tap": "Klepnutím na položku ji zahrnete, dvojím klepnutím ji vyloučíte", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Sdílení", "theme_setting_asset_list_storage_indicator_title": "Zobrazit indikátor úložiště na dlaždicích položek", "theme_setting_asset_list_tiles_per_row_title": "Počet položek na řádek ({})", + "theme_setting_colorful_interface_subtitle": "Použít hlavní barvu na povrchy pozadí.", + "theme_setting_colorful_interface_title": "Barevné rozhraní", "theme_setting_dark_mode_switch": "Tmavé téma", "theme_setting_image_viewer_quality_subtitle": "Přizpůsobení kvality detailů prohlížeče obrázků", "theme_setting_image_viewer_quality_title": "Kvalita prohlížeče obrázků", + "theme_setting_primary_color_subtitle": "Zvolte barvu pro hlavní akce a zvýraznění.", + "theme_setting_primary_color_title": "Hlavní barva", + "theme_setting_system_primary_color_title": "Použití systémové barvy", "theme_setting_system_theme_switch": "Automaticky (podle systemového nastavení)", "theme_setting_theme_subtitle": "Vyberte nastavení tématu aplikace", "theme_setting_theme_title": "Téma", "theme_setting_three_stage_loading_subtitle": "Třístupňové načítání může zvýšit výkonnost načítání, ale vede k výrazně vyššímu zatížení sítě.", "theme_setting_three_stage_loading_title": "Povolení třístupňového načítání", "translated_text_options": "Možnosti", + "trash_emptied": "Emptied trash", "trash_page_delete": "Smazat", "trash_page_delete_all": "Smazat všechny", "trash_page_empty_trash_btn": "Vysypat koš", diff --git a/mobile/assets/i18n/da-DK.json b/mobile/assets/i18n/da-DK.json index db3a7f89ba..8d05d74fb0 100644 --- a/mobile/assets/i18n/da-DK.json +++ b/mobile/assets/i18n/da-DK.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Indstillinger for billedgitterlayout", "asset_list_settings_title": "Billedgitter", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Billedviser", "backup_album_selection_page_albums_device": "Albummer på enhed ({})", "backup_album_selection_page_albums_tap": "Tryk en gang for at inkludere, tryk to gange for at ekskludere", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Deling", "theme_setting_asset_list_storage_indicator_title": "Vis opbevaringsindikator på filer", "theme_setting_asset_list_tiles_per_row_title": "Antal elementer per række ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Mørk tilstand", "theme_setting_image_viewer_quality_subtitle": "Juster kvaliteten i billedfremviseren", "theme_setting_image_viewer_quality_title": "Billedfremviserkvalitet", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatisk (Følg systemindstillinger)", "theme_setting_theme_subtitle": "Vælg appens temaindstilling", "theme_setting_theme_title": "Tema", "theme_setting_three_stage_loading_subtitle": "Tre-trins indlæsning kan øge ydeevnen, men kan ligeledes føre til højere netværksbelastning", "theme_setting_three_stage_loading_title": "Slå tre-trins indlæsning til", "translated_text_options": "Handlinger", + "trash_emptied": "Emptied trash", "trash_page_delete": "Slet", "trash_page_delete_all": "Slet alt", "trash_page_empty_trash_btn": "Tøm papirkurv", diff --git a/mobile/assets/i18n/de-DE.json b/mobile/assets/i18n/de-DE.json index 15aa784db1..d7d93d719c 100644 --- a/mobile/assets/i18n/de-DE.json +++ b/mobile/assets/i18n/de-DE.json @@ -3,15 +3,15 @@ "action_common_cancel": "Abbrechen", "action_common_clear": "Leeren", "action_common_confirm": "Bestätigen", - "action_common_save": "Save", - "action_common_select": "Select", + "action_common_save": "Speichern", + "action_common_select": "Auswählen ", "action_common_update": "Aktualisieren", "add_to_album_bottom_sheet_added": "Zu {album} hinzugefügt", "add_to_album_bottom_sheet_already_exists": "Bereits in {album}", "advanced_settings_log_level_title": "Log-Level: {}", "advanced_settings_prefer_remote_subtitle": "Manche Endgeräte laden Vorschaubilder von lokalen Bilder sehr langsam. Durch diese Einstellung werden diese stattdessen direkt vom Server geladen.", "advanced_settings_prefer_remote_title": "Server-Bilder bevorzugen", - "advanced_settings_proxy_headers_subtitle": "Definiere Proxy-Header, die Immich bei jeder Netzwerkanfrage mitschicken soll", + "advanced_settings_proxy_headers_subtitle": "Definiere einen Proxy-Header, den Immich bei jeder Netzwerkanfrage mitschicken soll", "advanced_settings_proxy_headers_title": "Proxy-Headers", "advanced_settings_self_signed_ssl_subtitle": "Verifizierung von SSL-Zertifikaten vom Server überspringen. Notwendig bei selbstsignierten Zertifikaten.", "advanced_settings_self_signed_ssl_title": "Selbstsignierte SSL-Zertifikate erlauben", @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Einstellungen für das Fotogitter-Layout", "asset_list_settings_title": "Fotogitter", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Fotoanzeige", "backup_album_selection_page_albums_device": "Alben auf dem Gerät ({})", "backup_album_selection_page_albums_tap": "Einmalig das Album antippen um es zu sichern, doppelt antippen um es nicht mehr zu sichern.", @@ -62,7 +69,7 @@ "backup_album_selection_page_selection_info": "Information", "backup_album_selection_page_total_assets": "Elemente", "backup_all": "Alle", - "backup_background_service_backup_failed_message": "Fehler beim Sichern von Elementen. Probiere erneut...", + "backup_background_service_backup_failed_message": "Es trat ein Fehler bei der Sicherung auf. Erneuter Versuch...", "backup_background_service_connection_failed_message": "Es konnte keine Verbindung zum Server hergestellt werden. Erneuter Versuch...", "backup_background_service_current_upload_notification": "Lädt {} hoch", "backup_background_service_default_notification": "Suche nach neuen Elementen…", @@ -144,20 +151,20 @@ "change_password_form_password_mismatch": "Passwörter stimmen nicht überein", "change_password_form_reenter_new_password": "Passwort erneut eingeben", "client_cert_dialog_msg_confirm": "OK", - "client_cert_enter_password": "Enter Password", - "client_cert_import": "Import", - "client_cert_import_success_msg": "Client certificate is imported", - "client_cert_invalid_msg": "Invalid certificate file or wrong password", - "client_cert_remove": "Remove", - "client_cert_remove_msg": "Client certificate is removed", - "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", - "client_cert_title": "SSL Client Certificate", + "client_cert_enter_password": "Passwort eingeben", + "client_cert_import": "Importieren", + "client_cert_import_success_msg": "Client Zertifikat wurde importiert", + "client_cert_invalid_msg": "Ungültige Zertifikatsdatei oder falsches Passwort", + "client_cert_remove": "Entfernen", + "client_cert_remove_msg": "Client Zertifikat wurde entfernt", + "client_cert_subtitle": "Unterstützt nur das PKCS12 (.p12, .pfx) Format. Zertifikatsimporte oder -entfernungen sind nur vor dem Login möglich.", + "client_cert_title": "SSL-Client-Zertifikat ", "common_add_to_album": "Zu Album hinzufügen", "common_change_password": "Passwort ändern", "common_create_new_album": "Neues Album erstellen", "common_server_error": "Bitte überprüfe Deine Netzwerkverbindung und stelle sicher, dass die App und Server Versionen kompatibel sind.", "common_shared": "Geteilt", - "contextual_search": "Sunrise on the beach", + "contextual_search": "Sonnenaufgang am Strand", "control_bottom_app_bar_add_to_album": "Zu Album hinzufügen", "control_bottom_app_bar_album_info": "{} Elemente", "control_bottom_app_bar_album_info_shared": "{} Elemente · Geteilt", @@ -166,7 +173,7 @@ "control_bottom_app_bar_delete": "Löschen", "control_bottom_app_bar_delete_from_immich": "Aus Immich löschen", "control_bottom_app_bar_delete_from_local": "Vom Gerät löschen", - "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_edit": "Bearbeiten", "control_bottom_app_bar_edit_location": "Ort bearbeiten", "control_bottom_app_bar_edit_time": "Datum und Uhrzeit bearbeiten", "control_bottom_app_bar_favorite": "Favorit", @@ -216,7 +223,7 @@ "experimental_settings_title": "Experimentell", "favorites_page_no_favorites": "Keine favorisierten Inhalte gefunden", "favorites_page_title": "Favoriten", - "filename_search": "File name or extension", + "filename_search": "Dateiname oder Dateityp", "haptic_feedback_switch": "Haptisches Feedback aktivieren", "haptic_feedback_title": "Haptisches Feedback", "header_settings_add_header_tip": "Header hinzufügen", @@ -224,7 +231,7 @@ "header_settings_header_name_input": "Header-Name", "header_settings_header_value_input": "Header-Wert", "header_settings_page_title": "Proxy-Headers", - "headers_settings_tile_subtitle": "Definiere Proxy-Header, die die Anwendung bei jeder Netzwerkanfrage mitschicken soll", + "headers_settings_tile_subtitle": "Definiere einen Proxy-Header, den die Anwendung bei jeder Netzwerkanfrage mitschicken soll", "headers_settings_tile_title": "Benutzerdefinierte Proxy-Header", "home_page_add_to_album_conflicts": "{added} Elemente zu {album} hinzugefügt. {failed} Elemente sind bereits vorhanden.", "home_page_add_to_album_err_local": "Es können lokale Elemente noch nicht zu Alben hinzugefügt werden, überspringen...", @@ -244,8 +251,8 @@ "image_viewer_page_state_provider_download_started": "Download gestartet", "image_viewer_page_state_provider_download_success": "Erfolgreich heruntergeladen", "image_viewer_page_state_provider_share_error": "Fehler beim Teilen", - "invalid_date": "Invalid date", - "invalid_date_format": "Invalid date format", + "invalid_date": "Ungültiges Datum ", + "invalid_date_format": "Ungültiges Datumsformat", "library_page_albums": "Alben", "library_page_archive": "Archiv", "library_page_device_albums": "Alben auf dem Gerät", @@ -327,7 +334,7 @@ "multiselect_grid_edit_date_time_err_read_only": "Das Datum und die Uhrzeit von schreibgeschützten Inhalten kann nicht verändert werden, überspringen...", "multiselect_grid_edit_gps_err_read_only": "Der Aufnahmeort von schreibgeschützten Inhalten kann nicht verändert werden, überspringen...", "no_assets_to_show": "Keine Vorschau vorhanden", - "no_name": "No name", + "no_name": "Kein Name", "notification_permission_dialog_cancel": "Abbrechen", "notification_permission_dialog_content": "Um Benachrichtigungen zu aktivieren, navigiere zu Einstellungen und klicke \"Erlauben\"", "notification_permission_dialog_settings": "Einstellungen", @@ -371,30 +378,30 @@ "scaffold_body_error_occurred": "Ein Fehler ist aufgetreten", "search_bar_hint": "Durchsuche deine Fotos", "search_filter_apply": "Filter anwenden", - "search_filter_camera": "Camera", + "search_filter_camera": "Kamera", "search_filter_camera_make": "Marke", "search_filter_camera_model": "Modell", - "search_filter_camera_title": "Select camera type", - "search_filter_date": "Date", - "search_filter_date_interval": "{start} to {end}", - "search_filter_date_title": "Select a date range", + "search_filter_camera_title": "Kameratyp auswählen ", + "search_filter_date": "Datum", + "search_filter_date_interval": "{start} bis {end}", + "search_filter_date_title": "Wähle einen Zeitraum", "search_filter_display_option_archive": "Archiv", "search_filter_display_option_favorite": "Favorit", "search_filter_display_option_not_in_album": "Nicht im Album", - "search_filter_display_options": "Display Options", - "search_filter_display_options_title": "Display options", - "search_filter_location": "Location", + "search_filter_display_options": "Anzeigeeinstellungen", + "search_filter_display_options_title": "Anzeigeeinstellungen ", + "search_filter_location": "Ort", "search_filter_location_city": "Stadt", "search_filter_location_country": "Land", "search_filter_location_state": "Bundesland", - "search_filter_location_title": "Select location", - "search_filter_media_type": "Media Type", + "search_filter_location_title": "Ort auswählen ", + "search_filter_media_type": "Medientyp", "search_filter_media_type_all": "Alle", "search_filter_media_type_image": "Bild", - "search_filter_media_type_title": "Select media type", + "search_filter_media_type_title": "Medientyp auswählen ", "search_filter_media_type_video": "Video", - "search_filter_people": "People", - "search_filter_people_title": "Select people", + "search_filter_people": "Personen", + "search_filter_people_title": "Personen auswählen ", "search_page_categories": "Kategorien", "search_page_favorites": "Favoriten", "search_page_motion_photos": "Live-Fotos", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Teilen", "theme_setting_asset_list_storage_indicator_title": "Zeige Sicherungsstatus auf Vorschaubild", "theme_setting_asset_list_tiles_per_row_title": "Anzahl der Elemente pro Reihe ({})", + "theme_setting_colorful_interface_subtitle": "Primärfarbe auf Hintergrundflächen verwenden", + "theme_setting_colorful_interface_title": "Bunte Oberfläche ", "theme_setting_dark_mode_switch": "Dunkler Modus", "theme_setting_image_viewer_quality_subtitle": "Einstellen der Qualität des Detailbildbetrachters", "theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters", + "theme_setting_primary_color_subtitle": "Wähle eine Farbe für primäre Aktionen und Akzente", + "theme_setting_primary_color_title": "Primärfarbe", + "theme_setting_system_primary_color_title": "Systemfarbe verwenden", "theme_setting_system_theme_switch": "Automatisch (Systemeinstellung)", "theme_setting_theme_subtitle": "Wählen Sie die Themeneinstellung der App", "theme_setting_theme_title": "Theme", "theme_setting_three_stage_loading_subtitle": "Das dreistufige Ladeverfahren kann die Performance beim Laden verbessern, erhöht allerdings den Datenverbrauch deutlich", "theme_setting_three_stage_loading_title": "Dreistufiges Laden aktivieren", "translated_text_options": "Optionen", + "trash_emptied": "Emptied trash", "trash_page_delete": "Löschen", "trash_page_delete_all": "Alle löschen", "trash_page_empty_trash_btn": "Papierkorb leeren", diff --git a/mobile/assets/i18n/el-GR.json b/mobile/assets/i18n/el-GR.json index 28bf17c981..07a23680aa 100644 --- a/mobile/assets/i18n/el-GR.json +++ b/mobile/assets/i18n/el-GR.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Ρυθμίσεις διάταξης πλέγματος φωτογραφιών", "asset_list_settings_title": "Πλέγμα φωτογραφιών", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Άλμπουμ στη συσκευή ({})", "backup_album_selection_page_albums_tap": "Πάτημα για συμπερίληψη, διπλό πάτημα για εξαίρεση", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Sharing", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Dark mode", "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatic (Follow system setting)", "theme_setting_theme_subtitle": "Choose the app's theme setting", "theme_setting_theme_title": "Theme", "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", "trash_page_empty_trash_btn": "Empty trash", diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index ebcf7999f4..9ef2a3e599 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -55,13 +55,13 @@ "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", "asset_restored_successfully": "Asset restored successfully", - "asset_viewer_settings_title": "Asset Viewer", "assets_deleted_permanently": "{} asset(s) deleted permanently", "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", @@ -208,7 +208,7 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", - "edit_date_time_dialog_date_time": "Edit date and time", + "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_location_dialog_title": "Location", "exif_bottom_sheet_description": "Add Description...", @@ -455,18 +455,15 @@ "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", "setting_notifications_total_progress_title": "Show background backup total progress", "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", "setting_video_viewer_looping_subtitle": "Enable to automatically loop a video in the detail viewer.", "setting_video_viewer_looping_title": "Looping", "setting_video_viewer_title": "Videos", - "settings_require_restart": "Please restart Immich to apply this setting", "share_add": "Add", "share_add_photos": "Add photos", "share_add_title": "Add a title", "share_assets_selected": "{} selected", "share_create_album": "Create album", - "share_dialog_preparing": "Preparing...", - "share_done": "Done", - "share_invite": "Invite to album", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", "shared_album_activity_remove_content": "Do you want to delete this activity?", @@ -478,6 +475,7 @@ "shared_album_section_people_action_remove_user": "Remove user from album", "shared_album_section_people_owner_label": "Owner", "shared_album_section_people_title": "PEOPLE", + "share_dialog_preparing": "Preparing...", "shared_link_app_bar_title": "Shared Links", "shared_link_clipboard_copied_massage": "Copied to clipboard", "shared_link_clipboard_text": "Link: {}\nPassword: {}", @@ -523,12 +521,14 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Manage Shared links", "shared_link_public_album": "Public album", + "share_done": "Done", + "share_invite": "Invite to album", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", "sharing_page_empty_list": "EMPTY LIST", "sharing_silver_appbar_create_shared_album": "New shared album", - "sharing_silver_appbar_share_partner": "Share with partner", "sharing_silver_appbar_shared_links": "Shared links", + "sharing_silver_appbar_share_partner": "Share with partner", "tab_controller_nav_library": "Library", "tab_controller_nav_photos": "Photos", "tab_controller_nav_search": "Search", diff --git a/mobile/assets/i18n/es-ES.json b/mobile/assets/i18n/es-ES.json index dbd61dec26..c582a645ee 100644 --- a/mobile/assets/i18n/es-ES.json +++ b/mobile/assets/i18n/es-ES.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Disposición", "asset_list_settings_subtitle": "Configuraciones del diseño de la cuadrícula de fotos", "asset_list_settings_title": "Cuadrícula de fotos", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Visor de Archivos", "backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({})", "backup_album_selection_page_albums_tap": "Toque para incluir, doble toque para excluir", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Compartiendo", "theme_setting_asset_list_storage_indicator_title": "Mostrar indicador de almacenamiento en las miniaturas de los archivos", "theme_setting_asset_list_tiles_per_row_title": "Número de elementos por fila ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Modo oscuro", "theme_setting_image_viewer_quality_subtitle": "Ajustar la calidad del visor de detalles de imágenes", "theme_setting_image_viewer_quality_title": "Calidad del visor de imágenes", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automático (seguir ajuste del sistema)", "theme_setting_theme_subtitle": "Elige la configuración del tema de la aplicación", "theme_setting_theme_title": "Tema", "theme_setting_three_stage_loading_subtitle": "La carga en tres etapas puede aumentar el rendimiento de carga pero provoca un consumo de red significativamente mayor", "theme_setting_three_stage_loading_title": "Activar carga en tres etapas", "translated_text_options": "Opciones", + "trash_emptied": "Emptied trash", "trash_page_delete": "Eliminar", "trash_page_delete_all": "Eliminar todos", "trash_page_empty_trash_btn": "Vaciar papelera", diff --git a/mobile/assets/i18n/es-MX.json b/mobile/assets/i18n/es-MX.json index 1eec06a6ad..279897fe81 100644 --- a/mobile/assets/i18n/es-MX.json +++ b/mobile/assets/i18n/es-MX.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Configuraciones del diseño de la cuadrícula de fotos", "asset_list_settings_title": "Cuadrícula de fotos", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({})", "backup_album_selection_page_albums_tap": "Pulsar para incluir, pulsar dos veces para excluir", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Compartiendo", "theme_setting_asset_list_storage_indicator_title": "Mostrar indicador de almacenamiento en las miniaturas de los archivos", "theme_setting_asset_list_tiles_per_row_title": "Número de elementos por fila ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Modo oscuro", "theme_setting_image_viewer_quality_subtitle": "Ajustar la calidad del visor de detalles de imágenes", "theme_setting_image_viewer_quality_title": "Calidad del visor de imágenes", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automático (seguir ajuste del sistema)", "theme_setting_theme_subtitle": "Elige la configuración del tema de la aplicación", "theme_setting_theme_title": "Tema", "theme_setting_three_stage_loading_subtitle": "La carga en tres etapas puede aumentar el rendimiento de carga pero provoca un consumo de red significativamente mayor", "theme_setting_three_stage_loading_title": "Activar carga en tres etapas", "translated_text_options": "Opciones", + "trash_emptied": "Emptied trash", "trash_page_delete": "Eliminar", "trash_page_delete_all": "Eliminar todos", "trash_page_empty_trash_btn": "Vaciar papelera", diff --git a/mobile/assets/i18n/es-PE.json b/mobile/assets/i18n/es-PE.json index 008cc6e6fc..e9ad4a9220 100644 --- a/mobile/assets/i18n/es-PE.json +++ b/mobile/assets/i18n/es-PE.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Configuraciones del diseño de la cuadrícula de fotos", "asset_list_settings_title": "Cuadrícula de fotos", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({})", "backup_album_selection_page_albums_tap": "Pulsar para incluir, pulsar dos veces para excluir", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Compartiendo", "theme_setting_asset_list_storage_indicator_title": "Mostrar indicador de almacenamiento en las miniaturas de los archivos", "theme_setting_asset_list_tiles_per_row_title": "Número de elementos por fila ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Modo oscuro", "theme_setting_image_viewer_quality_subtitle": "Ajustar la calidad del visor de detalles de imágenes", "theme_setting_image_viewer_quality_title": "Calidad del visor de imágenes", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automático (seguir ajuste del sistema)", "theme_setting_theme_subtitle": "Elige la configuración del tema de la aplicación", "theme_setting_theme_title": "Tema", "theme_setting_three_stage_loading_subtitle": "La carga en tres etapas puede aumentar el rendimiento de carga pero provoca un consumo de red significativamente mayor", "theme_setting_three_stage_loading_title": "Activar carga en tres etapas", "translated_text_options": "Opciones", + "trash_emptied": "Emptied trash", "trash_page_delete": "Eliminar", "trash_page_delete_all": "Eliminar todos", "trash_page_empty_trash_btn": "Vaciar papelera", diff --git a/mobile/assets/i18n/es-US.json b/mobile/assets/i18n/es-US.json index f192ea2d9c..9a17fba787 100644 --- a/mobile/assets/i18n/es-US.json +++ b/mobile/assets/i18n/es-US.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Configuraciones del diseño de la cuadrícula de fotos", "asset_list_settings_title": "Cuadrícula de fotos", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({})", "backup_album_selection_page_albums_tap": "Pulsar para incluir, pulsar dos veces para excluir", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Compartidos", "theme_setting_asset_list_storage_indicator_title": "Mostrar indicador de almacenamiento en las miniaturas de los recursos", "theme_setting_asset_list_tiles_per_row_title": "Número de recursos por fila ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Modo oscuro", "theme_setting_image_viewer_quality_subtitle": "Ajustar la calidad del visor de detalles de imágenes", "theme_setting_image_viewer_quality_title": "Calidad del visor de imágenes", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automático (seguir ajuste del sistema)", "theme_setting_theme_subtitle": "Elige la configuración del tema de la aplicación", "theme_setting_theme_title": "Tema", "theme_setting_three_stage_loading_subtitle": "La carga en tres etapas puede aumentar el rendimiento de carga pero provoca un consumo de red significativamente mayor", "theme_setting_three_stage_loading_title": "Activar carga en tres etapas", "translated_text_options": "Opciones", + "trash_emptied": "Emptied trash", "trash_page_delete": "Eliminar", "trash_page_delete_all": "Eliminar todos", "trash_page_empty_trash_btn": "Vaciar papelera", diff --git a/mobile/assets/i18n/fi-FI.json b/mobile/assets/i18n/fi-FI.json index f13b160216..b669d0709b 100644 --- a/mobile/assets/i18n/fi-FI.json +++ b/mobile/assets/i18n/fi-FI.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Asettelu", "asset_list_settings_subtitle": "Kuvaruudukon asettelu", "asset_list_settings_title": "Kuvaruudukko", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Katselin", "backup_album_selection_page_albums_device": "Laitteen albumit ({})", "backup_album_selection_page_albums_tap": "Napauta sisällyttääksesi, kaksoisnapauta jättääksesi pois", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Jakaminen", "theme_setting_asset_list_storage_indicator_title": "Näytä tallennustilan ilmaisin kohteiden kuvakkeissa", "theme_setting_asset_list_tiles_per_row_title": "Kohteiden määrä rivillä ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Tumma teema", "theme_setting_image_viewer_quality_subtitle": "Säädä kuvien katselun laatua", "theme_setting_image_viewer_quality_title": "Kuvien katseluohjelman laatu", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automaattinen (seuraa järjestelmän asetusta)", "theme_setting_theme_subtitle": "Valitse sovelluksen teema-asetukset", "theme_setting_theme_title": "Teema", "theme_setting_three_stage_loading_subtitle": "Kolmivaiheinen lataaminen saattaa parantaa latauksen suorituskykyä, mutta lisää kaistankäyttöä huomattavasti.", "theme_setting_three_stage_loading_title": "Ota kolmivaiheinen lataus käyttöön", "translated_text_options": "Vaihtoehdot", + "trash_emptied": "Emptied trash", "trash_page_delete": "Poista", "trash_page_delete_all": "Poista kaikki", "trash_page_empty_trash_btn": "Tyhjennä roskakori", diff --git a/mobile/assets/i18n/fr-CA.json b/mobile/assets/i18n/fr-CA.json index 55092e16f5..7193513a35 100644 --- a/mobile/assets/i18n/fr-CA.json +++ b/mobile/assets/i18n/fr-CA.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Paramètres de disposition de la grille de photos", "asset_list_settings_title": "Grille de photos", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Albums sur l'appareil ({})", "backup_album_selection_page_albums_tap": "Tapez pour inclure, tapez deux fois pour exclure", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Partage", "theme_setting_asset_list_storage_indicator_title": "Afficher l'indicateur de stockage sur les tuiles des éléments", "theme_setting_asset_list_tiles_per_row_title": "Nombre d'éléments par ligne ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Mode sombre", "theme_setting_image_viewer_quality_subtitle": "Ajustez la qualité de la visionneuse d'images détaillées", "theme_setting_image_viewer_quality_title": "Qualité de la visualisation des images", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatique (suivre les paramètres du système)", "theme_setting_theme_subtitle": "Choisissez le thème de l'application", "theme_setting_theme_title": "Thème", "theme_setting_three_stage_loading_subtitle": "Le chargement en trois étapes peut améliorer les performances de chargement, mais entraîne une augmentation significative de la charge du réseau.", "theme_setting_three_stage_loading_title": "Activer le chargement en trois étapes", "translated_text_options": "Options", + "trash_emptied": "Emptied trash", "trash_page_delete": "Supprimer", "trash_page_delete_all": "Tout supprimer", "trash_page_empty_trash_btn": "Vider la corbeille", diff --git a/mobile/assets/i18n/fr-FR.json b/mobile/assets/i18n/fr-FR.json index 9cee91e622..f36e17e88e 100644 --- a/mobile/assets/i18n/fr-FR.json +++ b/mobile/assets/i18n/fr-FR.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Disposition", "asset_list_settings_subtitle": "Paramètres de disposition de la grille de photos", "asset_list_settings_title": "Grille de photos", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Visualisateur d'éléments", "backup_album_selection_page_albums_device": "Albums sur l'appareil ({})", "backup_album_selection_page_albums_tap": "Tapez pour inclure, tapez deux fois pour exclure", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Partage", "theme_setting_asset_list_storage_indicator_title": "Afficher l'indicateur de stockage sur les tuiles des éléments", "theme_setting_asset_list_tiles_per_row_title": "Nombre d'éléments par ligne ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Mode sombre", "theme_setting_image_viewer_quality_subtitle": "Ajustez la qualité de la visionneuse d'images détaillées", "theme_setting_image_viewer_quality_title": "Qualité de la visualisation des images", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatique (suivre les paramètres du système)", "theme_setting_theme_subtitle": "Choisissez le thème de l'application", "theme_setting_theme_title": "Thème", "theme_setting_three_stage_loading_subtitle": "Le chargement en trois étapes peut améliorer les performances de chargement, mais entraîne une augmentation significative de la charge du réseau.", "theme_setting_three_stage_loading_title": "Activer le chargement en trois étapes", "translated_text_options": "Options", + "trash_emptied": "Emptied trash", "trash_page_delete": "Supprimer", "trash_page_delete_all": "Tout supprimer", "trash_page_empty_trash_btn": "Vider la corbeille", diff --git a/mobile/assets/i18n/he-IL.json b/mobile/assets/i18n/he-IL.json index 8e16ed2936..c41701dff8 100644 --- a/mobile/assets/i18n/he-IL.json +++ b/mobile/assets/i18n/he-IL.json @@ -3,8 +3,8 @@ "action_common_cancel": "ביטול", "action_common_clear": "נקה", "action_common_confirm": "אישור", - "action_common_save": "Save", - "action_common_select": "Select", + "action_common_save": "שמור", + "action_common_select": "בחר", "action_common_update": "עדכון", "add_to_album_bottom_sheet_added": "נוסף ל {album}", "add_to_album_bottom_sheet_already_exists": "כבר ב {album}", @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "פריסה", "asset_list_settings_subtitle": "הגדרות תבנית רשת תמונות", "asset_list_settings_title": "רשת תמונות", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "מציג הנכסים", "backup_album_selection_page_albums_device": "אלבומים במכשיר ({})", "backup_album_selection_page_albums_tap": "הקש כדי לכלול, הקש פעמיים כדי להחריג", @@ -143,21 +150,21 @@ "change_password_form_new_password": "סיסמה חדשה", "change_password_form_password_mismatch": "סיסמאות לא תואמות", "change_password_form_reenter_new_password": "הכנס שוב סיסמה חדשה", - "client_cert_dialog_msg_confirm": "OK", - "client_cert_enter_password": "Enter Password", - "client_cert_import": "Import", - "client_cert_import_success_msg": "Client certificate is imported", - "client_cert_invalid_msg": "Invalid certificate file or wrong password", - "client_cert_remove": "Remove", - "client_cert_remove_msg": "Client certificate is removed", - "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", - "client_cert_title": "SSL Client Certificate", + "client_cert_dialog_msg_confirm": "בסדר", + "client_cert_enter_password": "הזן סיסמה", + "client_cert_import": "ייבוא", + "client_cert_import_success_msg": "תעודת לקוח מיובאת", + "client_cert_invalid_msg": "קובץ תעודה לא תקין או סיסמה שגויה", + "client_cert_remove": "הסרה", + "client_cert_remove_msg": "תעודת לקוח הוסרה", + "client_cert_subtitle": "תומך בפורמט PKCS12 (.p12, .pfx) בלבד. ייבוא/הסרה של תעודה זמינה רק לפני התחברות", + "client_cert_title": "תעודת לקוח SSL", "common_add_to_album": "הוסף לאלבום", "common_change_password": "שנה סיסמה", "common_create_new_album": "צור אלבום חדש", "common_server_error": "נא לבדוק את חיבור הרשת שלך, תוודא/י שהשרת נגיש ושגרסאות אפליקציה/שרת תואמות", "common_shared": "משותף", - "contextual_search": "Sunrise on the beach", + "contextual_search": "Sunrise on the beach (מומלץ לחפש באנגלית לתוצאות טובות יותר)", "control_bottom_app_bar_add_to_album": "הוסף לאלבום", "control_bottom_app_bar_album_info": "{} פריטים", "control_bottom_app_bar_album_info_shared": "{} פריטים · משותפים", @@ -166,7 +173,7 @@ "control_bottom_app_bar_delete": "מחק", "control_bottom_app_bar_delete_from_immich": "מחק מהשרת", "control_bottom_app_bar_delete_from_local": "מחק מהמכשיר", - "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_edit": "עריכה", "control_bottom_app_bar_edit_location": "ערוך מיקום", "control_bottom_app_bar_edit_time": "ערוך תאריך & זמן", "control_bottom_app_bar_favorite": "הוסף למועדפים", @@ -216,7 +223,7 @@ "experimental_settings_title": "נסיוני", "favorites_page_no_favorites": "לא נמצאו נכסים מועדפים", "favorites_page_title": "מועדפים", - "filename_search": "File name or extension", + "filename_search": "שם קובץ או סיומת", "haptic_feedback_switch": "אפשר משוב ברטט", "haptic_feedback_title": "משוב ברטט", "header_settings_add_header_tip": "הוסף כותרת", @@ -244,8 +251,8 @@ "image_viewer_page_state_provider_download_started": "ההורדה החלה", "image_viewer_page_state_provider_download_success": "הצלחת הורדה", "image_viewer_page_state_provider_share_error": "שיתוף שגיאה", - "invalid_date": "Invalid date", - "invalid_date_format": "Invalid date format", + "invalid_date": "תאריך לא תקין", + "invalid_date_format": "פורמט תאריך לא תקין", "library_page_albums": "אלבומים", "library_page_archive": "ארכיון", "library_page_device_albums": "אלבומים במכשיר", @@ -327,7 +334,7 @@ "multiselect_grid_edit_date_time_err_read_only": "לא ניתן לערוך תאריך של נכס(ים) לקריאה בלבד, מדלג", "multiselect_grid_edit_gps_err_read_only": "לא ניתן לערוך מיקום של נכס(ים) לקריאה בלבד, מדלג", "no_assets_to_show": "אין נכסים להציג", - "no_name": "No name", + "no_name": "ללא שם", "notification_permission_dialog_cancel": "ביטול", "notification_permission_dialog_content": "כדי לאפשר התראות, לך להגדרות ובחר התר", "notification_permission_dialog_settings": "הגדרות", @@ -371,30 +378,30 @@ "scaffold_body_error_occurred": "אירעה שגיאה", "search_bar_hint": "חפש/י בתמונות שלך", "search_filter_apply": "החל סינון", - "search_filter_camera": "Camera", + "search_filter_camera": "מצלמה", "search_filter_camera_make": "תוצרת", "search_filter_camera_model": "דגם", - "search_filter_camera_title": "Select camera type", - "search_filter_date": "Date", - "search_filter_date_interval": "{start} to {end}", - "search_filter_date_title": "Select a date range", + "search_filter_camera_title": "בחר סוג מצלמה", + "search_filter_date": "תאריך", + "search_filter_date_interval": "{start} עד {end}", + "search_filter_date_title": "בחר טווח תאריכים", "search_filter_display_option_archive": "ארכיון", "search_filter_display_option_favorite": "מועדף", "search_filter_display_option_not_in_album": "לא באלבום", - "search_filter_display_options": "Display Options", - "search_filter_display_options_title": "Display options", - "search_filter_location": "Location", + "search_filter_display_options": "אפשרויות תצוגה", + "search_filter_display_options_title": "אפשרויות תצוגה", + "search_filter_location": "מיקום", "search_filter_location_city": "עיר", "search_filter_location_country": "ארץ", "search_filter_location_state": "מדינה", - "search_filter_location_title": "Select location", - "search_filter_media_type": "Media Type", + "search_filter_location_title": "בחר מיקום", + "search_filter_media_type": "סוג מדיה", "search_filter_media_type_all": "הכל", "search_filter_media_type_image": "תמונה", - "search_filter_media_type_title": "Select media type", + "search_filter_media_type_title": "בחר סוג מדיה", "search_filter_media_type_video": "סרטון", - "search_filter_people": "People", - "search_filter_people_title": "Select people", + "search_filter_people": "אנשים", + "search_filter_people_title": "בחר אנשים", "search_page_categories": "קטגוריות", "search_page_favorites": "מועדפים", "search_page_motion_photos": "תמונות עם תנועה", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "שיתוף", "theme_setting_asset_list_storage_indicator_title": "הראה מחוון אחסון על אריחי נכסים", "theme_setting_asset_list_tiles_per_row_title": "מספר נכסים בכל שורה ({})", + "theme_setting_colorful_interface_subtitle": "החל את הצבע העיקרי למשטחי רקע", + "theme_setting_colorful_interface_title": "ממשק צבעוני", "theme_setting_dark_mode_switch": "מצב כהה", "theme_setting_image_viewer_quality_subtitle": "התאם את האיכות של מציג פרטי התמונות", "theme_setting_image_viewer_quality_title": "איכות מציג תמונות", + "theme_setting_primary_color_subtitle": "בחר צבע לפעולות עיקריות והדגשות", + "theme_setting_primary_color_title": "צבע עיקרי", + "theme_setting_system_primary_color_title": "השתמש בצבע המערכת", "theme_setting_system_theme_switch": "אוטומטי (עקוב אחרי הגדרת מערכת)", - "theme_setting_theme_subtitle": "בחר/י את הגדרת ערכת הנושא של היישום", + "theme_setting_theme_subtitle": "בחר את הגדרת ערכת הנושא של היישום", "theme_setting_theme_title": "ערכת נושא", "theme_setting_three_stage_loading_subtitle": "טעינה בשלושה שלבים עשויה לשפר את ביצועי הטעינה אבל גורמת באופן משמעותי לעומס רשת גבוה יותר", "theme_setting_three_stage_loading_title": "אפשר טעינה בשלושה שלבים", "translated_text_options": "אפשרויות", + "trash_emptied": "Emptied trash", "trash_page_delete": "מחק", "trash_page_delete_all": "מחק הכל", "trash_page_empty_trash_btn": "רוקן אשפה", diff --git a/mobile/assets/i18n/hi-IN.json b/mobile/assets/i18n/hi-IN.json index 85246a2b21..5c8e63c720 100644 --- a/mobile/assets/i18n/hi-IN.json +++ b/mobile/assets/i18n/hi-IN.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Sharing", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Dark mode", "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatic (Follow system setting)", "theme_setting_theme_subtitle": "Choose the app's theme setting", "theme_setting_theme_title": "Theme", "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", "trash_page_empty_trash_btn": "कूड़ेदान खाली करें", diff --git a/mobile/assets/i18n/hu-HU.json b/mobile/assets/i18n/hu-HU.json index 9d00eabd99..ee5f102d27 100644 --- a/mobile/assets/i18n/hu-HU.json +++ b/mobile/assets/i18n/hu-HU.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Elrendezés", "asset_list_settings_subtitle": "Fotórács elrendezése", "asset_list_settings_title": "Fotórács", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Elem Megjelenítő", "backup_album_selection_page_albums_device": "Ezen az eszközön lévő albumok ({})", "backup_album_selection_page_albums_tap": "Koppincs a hozzáadáshoz, duplán koppincs az eltávolításhoz", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Megosztás", "theme_setting_asset_list_storage_indicator_title": "Tárhely ikon mutatása az elemeken", "theme_setting_asset_list_tiles_per_row_title": "Elemek száma soronként ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Sötét mód", "theme_setting_image_viewer_quality_subtitle": "Részletes képmegjelenítő minőségének beállítása", "theme_setting_image_viewer_quality_title": "Képmegjelenítő minősége", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatikus (követi a rendszer témáját)", "theme_setting_theme_subtitle": "Alkalmazás témájának választása", "theme_setting_theme_title": "Téma", "theme_setting_three_stage_loading_subtitle": "A háromlépcsős betöltés javíthatja a betöltési teljesítményt, de jelentősen növeli a hálózati forgalmat", "theme_setting_three_stage_loading_title": "Háromlépcsős betöltés engedélyezése", "translated_text_options": "Beállítások", + "trash_emptied": "Emptied trash", "trash_page_delete": "Töröl", "trash_page_delete_all": "Mindet Töröl", "trash_page_empty_trash_btn": "Lomtár Ürítése", diff --git a/mobile/assets/i18n/it-IT.json b/mobile/assets/i18n/it-IT.json index b28ff678fe..da111fbb7e 100644 --- a/mobile/assets/i18n/it-IT.json +++ b/mobile/assets/i18n/it-IT.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Impostazion del layout della griglia delle foto", "asset_list_settings_title": "Griglia foto", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Visualizzazione risorse", "backup_album_selection_page_albums_device": "Album sul dispositivo ({})", "backup_album_selection_page_albums_tap": "Tap per includere, doppio tap per escludere.", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Condivisione", "theme_setting_asset_list_storage_indicator_title": "Mostra indicatore dello storage nei titoli dei contenuti", "theme_setting_asset_list_tiles_per_row_title": "Numero di contenuti per riga ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Dark mode", "theme_setting_image_viewer_quality_subtitle": "Cambia la qualità del dettaglio dell'immagine", "theme_setting_image_viewer_quality_title": "Qualità immagine", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatico (Segue le impostazioni di sistema)", "theme_setting_theme_subtitle": "Scegli un'impostazione per il tema dell'app", "theme_setting_theme_title": "Tema", "theme_setting_three_stage_loading_subtitle": "Il caricamento a tre stage aumenterà le performance di caricamento ma anche il consumo di banda", "theme_setting_three_stage_loading_title": "Abilita il caricamento a tre stage", "translated_text_options": "Opzioni", + "trash_emptied": "Emptied trash", "trash_page_delete": "Elimina", "trash_page_delete_all": "Elimina tutti", "trash_page_empty_trash_btn": "Svuota cestino", diff --git a/mobile/assets/i18n/ja-JP.json b/mobile/assets/i18n/ja-JP.json index a275157b60..64ef567c30 100644 --- a/mobile/assets/i18n/ja-JP.json +++ b/mobile/assets/i18n/ja-JP.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "レイアウト", "asset_list_settings_subtitle": "グリッドに関する設定", "asset_list_settings_title": "グリッド", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "アセットビューアー", "backup_album_selection_page_albums_device": "端末上のアルバム数: {} ", "backup_album_selection_page_albums_tap": "タップで選択、ダブルタップで除外", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "共有", "theme_setting_asset_list_storage_indicator_title": "ストレージに関する情報を表示", "theme_setting_asset_list_tiles_per_row_title": "一列ごとの枚数: {}", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "ダークモード", "theme_setting_image_viewer_quality_subtitle": "画像ビューの画質の設定", "theme_setting_image_viewer_quality_title": "画像ビュー", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "自動 (デバイスの設定を反映)", "theme_setting_theme_subtitle": "テーマ設定", "theme_setting_theme_title": "テーマ", "theme_setting_three_stage_loading_subtitle": "三段階読み込みを有効にすると、パフォーマンスが改善する可能性がありますが、ネットワーク負荷が著しく増加します。", "theme_setting_three_stage_loading_title": "三段階読み込みをオンにする", "translated_text_options": "オプション", + "trash_emptied": "Emptied trash", "trash_page_delete": "削除", "trash_page_delete_all": "すべて削除", "trash_page_empty_trash_btn": "コミ箱を空にする", diff --git a/mobile/assets/i18n/ko-KR.json b/mobile/assets/i18n/ko-KR.json index ddb0ec63a1..6a2dcfb64b 100644 --- a/mobile/assets/i18n/ko-KR.json +++ b/mobile/assets/i18n/ko-KR.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "레이아웃", "asset_list_settings_subtitle": "사진 배열 레이아웃 설정", "asset_list_settings_title": "사진 배열", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "보기 옵션", "backup_album_selection_page_albums_device": "기기의 앨범 ({})", "backup_album_selection_page_albums_tap": "포함하려면 한 번 누르고 제외하려면 두 번 누르세요.", @@ -101,7 +108,7 @@ "backup_controller_page_remainder": "남은 항목", "backup_controller_page_remainder_sub": "백업할 사진 및 동영상", "backup_controller_page_select": "선택", - "backup_controller_page_server_storage": "서버 스토리지", + "backup_controller_page_server_storage": "저장 공간", "backup_controller_page_start_backup": "백업 시작", "backup_controller_page_status_off": "자동 백업이 비활성화되었습니다.", "backup_controller_page_status_on": "자동 백업이 활성화되었습니다.", @@ -147,7 +154,7 @@ "client_cert_enter_password": "비밀번호 입력", "client_cert_import": "가져오기", "client_cert_import_success_msg": "클라이언트 인증서를 가져왔습니다.", - "client_cert_invalid_msg": "올바르지 않은 인증서이거나 비밀번호가 일치하지 않습니다.", + "client_cert_invalid_msg": "유효하지 않은 인증서이거나 비밀번호가 일치하지 않습니다.", "client_cert_remove": "제거", "client_cert_remove_msg": "클라이언트 인증서가 제거되었습니다.", "client_cert_subtitle": "인증서 가져오기/제거는 로그인 전에만 가능합니다. PKCS12 (.p12, .pfx) 형식을 지원합니다.", @@ -241,11 +248,11 @@ "home_page_share_err_local": "기기의 항목은 링크로 공유할 수 없습니다. 건너뜁니다.", "home_page_upload_err_limit": "한 번에 최대 30개의 항목만 업로드할 수 있습니다.", "image_viewer_page_state_provider_download_error": "다운로드 오류", - "image_viewer_page_state_provider_download_started": "다운로드 시작됨", + "image_viewer_page_state_provider_download_started": "다운로드가 시작되었습니다.", "image_viewer_page_state_provider_download_success": "다운로드 완료", "image_viewer_page_state_provider_share_error": "공유 오류", - "invalid_date": "올바르지 않은 날짜입니다.", - "invalid_date_format": "올바르지 않은 날짜 형식입니다.", + "invalid_date": "잘못된 날짜입니다.", + "invalid_date_format": "잘못된 날짜 형식입니다.", "library_page_albums": "앨범", "library_page_archive": "보관함", "library_page_device_albums": "기기의 앨범", @@ -360,10 +367,10 @@ "profile_drawer_client_out_of_date_major": "모바일 앱이 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요.", "profile_drawer_client_out_of_date_minor": "모바일 앱이 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요.", "profile_drawer_client_server_up_to_date": "모바일 앱과 서버가 최신 버전입니다.", - "profile_drawer_documentation": "공식 문서", + "profile_drawer_documentation": "문서", "profile_drawer_github": "Github", - "profile_drawer_server_out_of_date_major": "서버가 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요.", - "profile_drawer_server_out_of_date_minor": "서버가 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요.", + "profile_drawer_server_out_of_date_major": "서버 버전이 최신이 아닙니다. 최신 버전으로 업데이트하세요.", + "profile_drawer_server_out_of_date_minor": "서버 버전이 최신이 아닙니다. 최신 버전으로 업데이트하세요.", "profile_drawer_settings": "설정", "profile_drawer_sign_out": "로그아웃", "profile_drawer_trash": "휴지통", @@ -375,9 +382,9 @@ "search_filter_camera_make": "제조사", "search_filter_camera_model": "모델명", "search_filter_camera_title": "카메라 종류 선택", - "search_filter_date": "날짜\n", + "search_filter_date": "날짜", "search_filter_date_interval": "{start}에서 {end} 까지", - "search_filter_date_title": "날짜 범위 선택\n", + "search_filter_date_title": "날짜 범위 선택", "search_filter_display_option_archive": "보관함", "search_filter_display_option_favorite": "즐겨찾기", "search_filter_display_option_not_in_album": "앨범에 없음", @@ -455,7 +462,7 @@ "share_add": "추가", "share_add_photos": "사진 추가", "share_add_title": "앨범 제목 입력", - "share_assets_selected": "{}개 선택됨", + "share_assets_selected": "{}개 항목 선택됨", "share_create_album": "앨범 생성", "shared_album_activities_input_disable": "댓글이 비활성화되었습니다", "shared_album_activities_input_hint": "댓글을 입력하세요", @@ -526,17 +533,23 @@ "tab_controller_nav_photos": "사진", "tab_controller_nav_search": "검색", "tab_controller_nav_sharing": "공유", - "theme_setting_asset_list_storage_indicator_title": "항목에 스토리지 상태 표시", + "theme_setting_asset_list_storage_indicator_title": "항목에 스토리지 동기화 여부 표시", "theme_setting_asset_list_tiles_per_row_title": "한 줄에 표시할 항목 수 ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "다크 모드", "theme_setting_image_viewer_quality_subtitle": "상세 보기 이미지 품질 조정", "theme_setting_image_viewer_quality_title": "이미지 보기 품질", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "자동 (시스템 설정)", "theme_setting_theme_subtitle": "앱 테마 선택", "theme_setting_theme_title": "테마", "theme_setting_three_stage_loading_subtitle": "이 기능은 앱의 로드 성능을 향상시킬 수 있지만 더 많은 데이터를 사용합니다.", "theme_setting_three_stage_loading_title": "3단계 로드 활성화", "translated_text_options": "옵션", + "trash_emptied": "Emptied trash", "trash_page_delete": "삭제", "trash_page_delete_all": "모두 삭제", "trash_page_empty_trash_btn": "휴지통 비우기", diff --git a/mobile/assets/i18n/lt-LT.json b/mobile/assets/i18n/lt-LT.json index ad3103b002..9ef2a3e599 100644 --- a/mobile/assets/i18n/lt-LT.json +++ b/mobile/assets/i18n/lt-LT.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Sharing", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Dark mode", "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatic (Follow system setting)", "theme_setting_theme_subtitle": "Choose the app's theme setting", "theme_setting_theme_title": "Theme", "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", "trash_page_empty_trash_btn": "Empty trash", diff --git a/mobile/assets/i18n/lv-LV.json b/mobile/assets/i18n/lv-LV.json index eaea6cc34b..1c1f186718 100644 --- a/mobile/assets/i18n/lv-LV.json +++ b/mobile/assets/i18n/lv-LV.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Izvietojums", "asset_list_settings_subtitle": "Fotorežģa izkārtojuma iestatījumi", "asset_list_settings_title": "Fotorežģis", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Aktīvu Skatītājs", "backup_album_selection_page_albums_device": "Albumi ierīcē ({})", "backup_album_selection_page_albums_tap": "Pieskarieties, lai iekļautu, veiciet dubultskārienu, lai izslēgtu", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Kopīgošana", "theme_setting_asset_list_storage_indicator_title": "Rādīt krātuves indikatoru uz aktīvu elementiem", "theme_setting_asset_list_tiles_per_row_title": "Aktīvu skaits rindā ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Tumšais režīms", "theme_setting_image_viewer_quality_subtitle": "Attēlu skatītāja detaļu kvalitātes pielāgošana", "theme_setting_image_viewer_quality_title": "Attēlu skatītāja kvalitāte", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automātisks (sekot sistēmas iestatījumiem)", "theme_setting_theme_subtitle": "Izvēlieties programmas dizaina iestatījumu", "theme_setting_theme_title": "Dizains", "theme_setting_three_stage_loading_subtitle": "Trīspakāpju ielāde var palielināt ielādēšanas veiktspēju, bet izraisa ievērojami lielāku tīkla noslodzi", "theme_setting_three_stage_loading_title": "Iespējot trīspakāpju ielādi", "translated_text_options": "Iestatījumi", + "trash_emptied": "Emptied trash", "trash_page_delete": "Dzēst", "trash_page_delete_all": "Dzēst Visu", "trash_page_empty_trash_btn": "Iztukšot atkritni", diff --git a/mobile/assets/i18n/mn.json b/mobile/assets/i18n/mn.json index d58427c4b6..d00e5cfc34 100644 --- a/mobile/assets/i18n/mn.json +++ b/mobile/assets/i18n/mn.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Sharing", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Dark mode", "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatic (Follow system setting)", "theme_setting_theme_subtitle": "Choose the app's theme setting", "theme_setting_theme_title": "Theme", "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", "trash_page_empty_trash_btn": "Empty trash", diff --git a/mobile/assets/i18n/nb-NO.json b/mobile/assets/i18n/nb-NO.json index 528701675f..fbb8444bee 100644 --- a/mobile/assets/i18n/nb-NO.json +++ b/mobile/assets/i18n/nb-NO.json @@ -3,8 +3,8 @@ "action_common_cancel": "Avbryt", "action_common_clear": "Tøm", "action_common_confirm": "Bekreft", - "action_common_save": "Save", - "action_common_select": "Select", + "action_common_save": "Lagre", + "action_common_select": "Velg", "action_common_update": "Oppdater", "add_to_album_bottom_sheet_added": "Lagt til i {album}", "add_to_album_bottom_sheet_already_exists": "Allerede i {album}", @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Fordeling", "asset_list_settings_subtitle": "Innstillinger for layout av fotorutenett", "asset_list_settings_title": "Fotorutenett", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Objektviser", "backup_album_selection_page_albums_device": "Album på enhet ({})", "backup_album_selection_page_albums_tap": "Trykk for å inkludere, dobbelttrykk for å ekskludere", @@ -144,20 +151,20 @@ "change_password_form_password_mismatch": "Passordene stemmer ikke", "change_password_form_reenter_new_password": "Skriv nytt passord igjen", "client_cert_dialog_msg_confirm": "OK", - "client_cert_enter_password": "Enter Password", - "client_cert_import": "Import", - "client_cert_import_success_msg": "Client certificate is imported", - "client_cert_invalid_msg": "Invalid certificate file or wrong password", - "client_cert_remove": "Remove", - "client_cert_remove_msg": "Client certificate is removed", - "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", - "client_cert_title": "SSL Client Certificate", + "client_cert_enter_password": "Skriv inn passord", + "client_cert_import": "Importer", + "client_cert_import_success_msg": "Klient sertifikat er importert", + "client_cert_invalid_msg": "Ugyldig sertifikat eller feil passord", + "client_cert_remove": "Fjern", + "client_cert_remove_msg": "Klient sertifikat er fjernet", + "client_cert_subtitle": "Støtter kun PKCS12 (.p12, .pfx) formater. Importering/Fjerning av sertifikater er kun mulig før innlogging.", + "client_cert_title": "SSL Klient sertifikat", "common_add_to_album": "Legg til i album", "common_change_password": "Endre passord", "common_create_new_album": "Lag nytt album", "common_server_error": "Sjekk nettverkstilkoblingen din, forsikre deg om at serveren er mulig å nå, og at app-/server-versjonene er kompatible.", "common_shared": "Delt", - "contextual_search": "Sunrise on the beach", + "contextual_search": "Soloppgang ved stranden", "control_bottom_app_bar_add_to_album": "Legg til i album", "control_bottom_app_bar_album_info": "{} objekter", "control_bottom_app_bar_album_info_shared": "{} objekter · Delt", @@ -166,7 +173,7 @@ "control_bottom_app_bar_delete": "Slett", "control_bottom_app_bar_delete_from_immich": "Slett fra Immich", "control_bottom_app_bar_delete_from_local": "Slett fra enhet", - "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_edit": "Endre", "control_bottom_app_bar_edit_location": "Endre lokasjon", "control_bottom_app_bar_edit_time": "Endre Dato og tid", "control_bottom_app_bar_favorite": "Favoritt", @@ -216,7 +223,7 @@ "experimental_settings_title": "Eksperimentelt", "favorites_page_no_favorites": "Ingen favorittobjekter funnet", "favorites_page_title": "Favoritter", - "filename_search": "File name or extension", + "filename_search": "Filnavn eller filtype", "haptic_feedback_switch": "Aktivert haptisk tilbakemelding", "haptic_feedback_title": "Haptisk tilbakemelding", "header_settings_add_header_tip": "Legg til header", @@ -244,8 +251,8 @@ "image_viewer_page_state_provider_download_started": "Nedlasting startet", "image_viewer_page_state_provider_download_success": "Nedlasting vellykket", "image_viewer_page_state_provider_share_error": "Delingsfeil", - "invalid_date": "Invalid date", - "invalid_date_format": "Invalid date format", + "invalid_date": "Ugyldig dato", + "invalid_date_format": "Ugyldig datoformat", "library_page_albums": "Albumer", "library_page_archive": "Arkiv", "library_page_device_albums": "Albumer på enheten", @@ -327,7 +334,7 @@ "multiselect_grid_edit_date_time_err_read_only": "Kan ikke endre dato på objekt(er) med kun lese-rettigheter, hopper over", "multiselect_grid_edit_gps_err_read_only": "Kan ikke endre lokasjon på objekt(er) med kun lese-rettigheter, hopper over", "no_assets_to_show": "Ingen objekter å vise", - "no_name": "No name", + "no_name": "Ingen navn", "notification_permission_dialog_cancel": "Avbryt", "notification_permission_dialog_content": "For å aktivere notifikasjoner, gå til Innstillinger og velg tillat.", "notification_permission_dialog_settings": "Innstillinger", @@ -371,30 +378,30 @@ "scaffold_body_error_occurred": "Feil oppstått", "search_bar_hint": "Søk i dine bilder", "search_filter_apply": "Aktiver filter", - "search_filter_camera": "Camera", + "search_filter_camera": "Kamera", "search_filter_camera_make": "Merke", "search_filter_camera_model": "Modell", - "search_filter_camera_title": "Select camera type", - "search_filter_date": "Date", - "search_filter_date_interval": "{start} to {end}", - "search_filter_date_title": "Select a date range", + "search_filter_camera_title": "Velg kameratype", + "search_filter_date": "Dato", + "search_filter_date_interval": "{start} til {end}", + "search_filter_date_title": "Velg ett datoområde", "search_filter_display_option_archive": "Arkiver", "search_filter_display_option_favorite": "Favoritt", "search_filter_display_option_not_in_album": "Ikke i album", - "search_filter_display_options": "Display Options", - "search_filter_display_options_title": "Display options", - "search_filter_location": "Location", + "search_filter_display_options": "Visningsvalg", + "search_filter_display_options_title": "Visningsvalg", + "search_filter_location": "Lokasjon", "search_filter_location_city": "By", "search_filter_location_country": "Land", "search_filter_location_state": "Fylke", - "search_filter_location_title": "Select location", - "search_filter_media_type": "Media Type", + "search_filter_location_title": "Velg lokasjon", + "search_filter_media_type": "Medietype", "search_filter_media_type_all": "Alle", "search_filter_media_type_image": "Bilde", - "search_filter_media_type_title": "Select media type", + "search_filter_media_type_title": "Velg medietype", "search_filter_media_type_video": "Video", - "search_filter_people": "People", - "search_filter_people_title": "Select people", + "search_filter_people": "Mennesker", + "search_filter_people_title": "Velg mennesker", "search_page_categories": "Kategorier", "search_page_favorites": "Favoritter", "search_page_motion_photos": "Bevegelige bilder", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Deling", "theme_setting_asset_list_storage_indicator_title": "Vis lagringsindiaktor på objekter i fotorutenettet", "theme_setting_asset_list_tiles_per_row_title": "Antall objekter per rad ({})", + "theme_setting_colorful_interface_subtitle": "Angi primærfarge til bakgrunner", + "theme_setting_colorful_interface_title": "Fargefullt grensesnitt", "theme_setting_dark_mode_switch": "Mørk modus", "theme_setting_image_viewer_quality_subtitle": "Juster kvaliteten på bilder i detaljvisning", "theme_setting_image_viewer_quality_title": "Kvalitet på bildevisning", + "theme_setting_primary_color_subtitle": "Velg en farge for primærhendelser og etterfølgende.", + "theme_setting_primary_color_title": "Primærfarge", + "theme_setting_system_primary_color_title": "Bruk systemfarge", "theme_setting_system_theme_switch": "Automatisk (følg systeminnstillinger)", "theme_setting_theme_subtitle": "Velg app-ens temainnstilling", "theme_setting_theme_title": "Tema", "theme_setting_three_stage_loading_subtitle": "Tre-trinns innlasting kan øke lasteytelsen, men forårsaker betydelig høyere nettverksbelastning", "theme_setting_three_stage_loading_title": "Aktiver tre-trinns innlasting", "translated_text_options": "Valg", + "trash_emptied": "Emptied trash", "trash_page_delete": "Slett", "trash_page_delete_all": "Slett alt", "trash_page_empty_trash_btn": "Tøm søppelbøtte", diff --git a/mobile/assets/i18n/nl-NL.json b/mobile/assets/i18n/nl-NL.json index 52575c05d2..80b1e2f697 100644 --- a/mobile/assets/i18n/nl-NL.json +++ b/mobile/assets/i18n/nl-NL.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Fotorasterlayoutinstellingen", "asset_list_settings_title": "Fotoraster", + "asset_restored_successfully": "Asset succesvol hersteld", + "assets_deleted_permanently": "{} asset(s) permanent verwijderd", + "assets_deleted_permanently_from_server": "{} asset(s) permanent verwijderd van de Immich server", + "assets_removed_permanently_from_device": "{} asset(s) permanent verwijderd van je apparaat", + "assets_restored_successfully": "{} asset(s) succesvol hersteld", + "assets_trashed": "{} asset(s) naar de prullenbak verplaatst", + "assets_trashed_from_server": "{} asset(s) naar de prullenbak verplaatst op de Immich server", "asset_viewer_settings_title": "Foto weergave", "backup_album_selection_page_albums_device": "Albums op apparaat ({})", "backup_album_selection_page_albums_tap": "Tik om in te voegen, dubbel tik om uit te sluiten", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Delen", "theme_setting_asset_list_storage_indicator_title": "Toon opslag indicator bij de asset tegels", "theme_setting_asset_list_tiles_per_row_title": "Aantal assets per rij ({})", + "theme_setting_colorful_interface_subtitle": "Pas primaire kleuren toe op achtergronden.", + "theme_setting_colorful_interface_title": "Kleurrijke interface", "theme_setting_dark_mode_switch": "Donkere modus", "theme_setting_image_viewer_quality_subtitle": "De kwaliteit van de gedetailleerde-fotoweergave aanpassen", "theme_setting_image_viewer_quality_title": "Fotoweergavekwaliteit", + "theme_setting_primary_color_subtitle": "Kies een kleur voor primaire acties en accenten.", + "theme_setting_primary_color_title": "Primaire kleur", + "theme_setting_system_primary_color_title": "Gebruik systeemkleur", "theme_setting_system_theme_switch": "Automatisch (systeeminstelling volgen)", "theme_setting_theme_subtitle": "De thema-instelling van de app kiezen", "theme_setting_theme_title": "Thema", "theme_setting_three_stage_loading_subtitle": "Laden in drie fasen kan de laadprestaties verbeteren, maar veroorzaakt een aanzienlijk hogere netwerkbelasting", "theme_setting_three_stage_loading_title": "Laden in drie fasen inschakelen", "translated_text_options": "Opties", + "trash_emptied": "Prullenbak geleegd", "trash_page_delete": "Verwijderen", "trash_page_delete_all": "Verwijder alle", "trash_page_empty_trash_btn": "Leeg prullenbak", diff --git a/mobile/assets/i18n/pl-PL.json b/mobile/assets/i18n/pl-PL.json index 3397b2160f..f0fbf42742 100644 --- a/mobile/assets/i18n/pl-PL.json +++ b/mobile/assets/i18n/pl-PL.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Układ", "asset_list_settings_subtitle": "Ustawienia układu siatki zdjęć", "asset_list_settings_title": "Siatka Zdjęć", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Przeglądarka zasobów", "backup_album_selection_page_albums_device": "Albumy na urządzeniu ({})", "backup_album_selection_page_albums_tap": "Stuknij, aby włączyć, stuknij dwukrotnie, aby wykluczyć", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Udostępnianie", "theme_setting_asset_list_storage_indicator_title": "Pokaż wskaźnik przechowywania na kafelkach zasobów", "theme_setting_asset_list_tiles_per_row_title": "Liczba zasobów w wierszu ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Ciemny Motyw", "theme_setting_image_viewer_quality_subtitle": "Dostosuj jakość podglądu szczegółowości", "theme_setting_image_viewer_quality_title": "Jakość przeglądania obrazów", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatyczny (Postępuj zgodnie z ustawieniami systemu)", "theme_setting_theme_subtitle": "Wybierz ustawienia motywu aplikacji", "theme_setting_theme_title": "Motyw", "theme_setting_three_stage_loading_subtitle": "Trójstopniowe ładowanie może zwiększyć wydajność ładowania, ale powoduje znacznie większe obciążenie sieci", "theme_setting_three_stage_loading_title": "Włączenie trójstopniowego ładowania", "translated_text_options": "Opcje", + "trash_emptied": "Emptied trash", "trash_page_delete": "Usuń", "trash_page_delete_all": "Usuń wszystko", "trash_page_empty_trash_btn": "Opróżnij kosz", diff --git a/mobile/assets/i18n/pt-PT.json b/mobile/assets/i18n/pt-PT.json index 60c033fe4d..e13ce30e4e 100644 --- a/mobile/assets/i18n/pt-PT.json +++ b/mobile/assets/i18n/pt-PT.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Disposição", "asset_list_settings_subtitle": "Configurações de layout da grelha de fotos", "asset_list_settings_title": "Grelha de fotos", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Visualizador de recursos", "backup_album_selection_page_albums_device": "Álbuns no dispositivo ({})", "backup_album_selection_page_albums_tap": "Toque para incluir, duplo toque para exluir", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Partilhar", "theme_setting_asset_list_storage_indicator_title": "Mostrar indicador de armazenamento em blocos de ativos", "theme_setting_asset_list_tiles_per_row_title": "Número de itens por linha ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Modo escuro", "theme_setting_image_viewer_quality_subtitle": "Ajuste a qualidade do visualizador de imagens detalhadas", "theme_setting_image_viewer_quality_title": "Qualidade do visualizador de imagens", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automático (Siga a configuração do sistema)", "theme_setting_theme_subtitle": "Escolha a configuração do tema do aplicativo", "theme_setting_theme_title": "Tema", "theme_setting_three_stage_loading_subtitle": "O carregamento em três estágios pode aumentar o desempenho do carregamento, mas causa uma carga de rede significativamente maior", "theme_setting_three_stage_loading_title": "Habilitar carregamento em três estágios", "translated_text_options": "Opções", + "trash_emptied": "Emptied trash", "trash_page_delete": "Apagar", "trash_page_delete_all": "Apagar tudo", "trash_page_empty_trash_btn": "Esvaziar lixo", diff --git a/mobile/assets/i18n/ro-RO.json b/mobile/assets/i18n/ro-RO.json index 0ac15772ba..14efd65979 100644 --- a/mobile/assets/i18n/ro-RO.json +++ b/mobile/assets/i18n/ro-RO.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Setări format grilă fotografii", "asset_list_settings_title": "Grilă fotografii", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Albume în dispozitiv ({})", "backup_album_selection_page_albums_tap": "Apasă odata pentru a include, de două ori pentru a exclude", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Distribuire", "theme_setting_asset_list_storage_indicator_title": "Arată indicator stocare", "theme_setting_asset_list_tiles_per_row_title": "Număr de resurse pe rând ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Mod întunecat", "theme_setting_image_viewer_quality_subtitle": "Ajustează calitatea detaliilor vizualizatorului de imagine", "theme_setting_image_viewer_quality_title": "Calitate vizualizator de imagine", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automat (La fel ca setarea sistemului)", "theme_setting_theme_subtitle": "Alege tema aplicației", "theme_setting_theme_title": "Temă", "theme_setting_three_stage_loading_subtitle": "Încărcarea în trei etape are putea crește performanța încărcării dar generează un volum semnificativ mai mare de trafic pe rețea", "theme_setting_three_stage_loading_title": "Pornește încărcarea în 3 etape", "translated_text_options": "Opțiuni", + "trash_emptied": "Emptied trash", "trash_page_delete": "Șterge", "trash_page_delete_all": "Șterge tot", "trash_page_empty_trash_btn": "Golește coș", diff --git a/mobile/assets/i18n/ru-RU.json b/mobile/assets/i18n/ru-RU.json index 4edba8a65a..dce60ddbf9 100644 --- a/mobile/assets/i18n/ru-RU.json +++ b/mobile/assets/i18n/ru-RU.json @@ -3,8 +3,8 @@ "action_common_cancel": "Отмена", "action_common_clear": "Очистить", "action_common_confirm": "Подтвердить", - "action_common_save": "Save", - "action_common_select": "Select", + "action_common_save": "Сохранить", + "action_common_select": "Выбрать", "action_common_update": "Обновить", "add_to_album_bottom_sheet_added": "Добавлено в {album}", "add_to_album_bottom_sheet_already_exists": "Уже в {album}", @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Разметка", "asset_list_settings_subtitle": "Настройка макета сетки фотографий", "asset_list_settings_title": "Сетка фотографий", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Просмотрщик изображений", "backup_album_selection_page_albums_device": "Альбомов на устройстве ({})", "backup_album_selection_page_albums_tap": "Нажмите, чтобы включить,\nнажмите дважды, чтобы исключить", @@ -144,20 +151,20 @@ "change_password_form_password_mismatch": "Пароли не совпадают", "change_password_form_reenter_new_password": "Повторно введите новый пароль", "client_cert_dialog_msg_confirm": "OK", - "client_cert_enter_password": "Enter Password", - "client_cert_import": "Import", - "client_cert_import_success_msg": "Client certificate is imported", - "client_cert_invalid_msg": "Invalid certificate file or wrong password", - "client_cert_remove": "Remove", - "client_cert_remove_msg": "Client certificate is removed", - "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", - "client_cert_title": "SSL Client Certificate", + "client_cert_enter_password": "Введите пароль", + "client_cert_import": "Импорт", + "client_cert_import_success_msg": "Клиентский сертификат импортирован", + "client_cert_invalid_msg": "Неверный файл сертификата или неверный пароль", + "client_cert_remove": "Удалить", + "client_cert_remove_msg": "Клиентский сертификат удален", + "client_cert_subtitle": "Поддерживается только формат PKCS12 (.p12, .pfx). Импорт/удаление сертификата доступно только перед входом в систему.", + "client_cert_title": "Клиентский SSL-сертификат ", "common_add_to_album": "Добавить в альбом", "common_change_password": "Изменить пароль", "common_create_new_album": "Создать новый альбом", "common_server_error": "Пожалуйста, проверьте подключение к сети и убедитесь, что ваш сервер доступен, а версии приложения и сервера — совместимы.", "common_shared": "Общие", - "contextual_search": "Sunrise on the beach", + "contextual_search": "Восход солнца на пляже", "control_bottom_app_bar_add_to_album": "Добавить в альбом", "control_bottom_app_bar_album_info": "{} файлов", "control_bottom_app_bar_album_info_shared": "{} файлов · Общий", @@ -166,7 +173,7 @@ "control_bottom_app_bar_delete": "Удалить", "control_bottom_app_bar_delete_from_immich": "Удалить из Immich\n", "control_bottom_app_bar_delete_from_local": "Удалить с устройства", - "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_edit": "Редактировать", "control_bottom_app_bar_edit_location": "Редактировать местоположение", "control_bottom_app_bar_edit_time": "Редактировать дату и время", "control_bottom_app_bar_favorite": "В избранное", @@ -216,7 +223,7 @@ "experimental_settings_title": "Экспериментальные функции", "favorites_page_no_favorites": "В избранном сейчас пусто", "favorites_page_title": "Избранное", - "filename_search": "File name or extension", + "filename_search": "Имя или расширение файла", "haptic_feedback_switch": "Включить тактильную отдачу", "haptic_feedback_title": "Тактильная отдача", "header_settings_add_header_tip": "Добавить заголовок", @@ -244,8 +251,8 @@ "image_viewer_page_state_provider_download_started": "Загрузка началась", "image_viewer_page_state_provider_download_success": "Успешно загружено", "image_viewer_page_state_provider_share_error": "Ошибка общего доступа", - "invalid_date": "Invalid date", - "invalid_date_format": "Invalid date format", + "invalid_date": "Неверная дата", + "invalid_date_format": "Неверный формат даты", "library_page_albums": "Альбомы", "library_page_archive": "Архив", "library_page_device_albums": "Альбомы на устройстве", @@ -327,7 +334,7 @@ "multiselect_grid_edit_date_time_err_read_only": "Невозможно редактировать дату объектов только для чтения, пропуск...", "multiselect_grid_edit_gps_err_read_only": "Невозможно редактировать местоположение объектов только для чтения, пропуск...", "no_assets_to_show": "Объекты отсутствуют", - "no_name": "No name", + "no_name": "Без имени", "notification_permission_dialog_cancel": "Отмена", "notification_permission_dialog_content": "Чтобы включить уведомления, перейдите в «Настройки» и выберите «Разрешить».", "notification_permission_dialog_settings": "Настройки", @@ -371,30 +378,30 @@ "scaffold_body_error_occurred": "Возникла ошибка", "search_bar_hint": "Поиск фотографий", "search_filter_apply": "Применить фильтр", - "search_filter_camera": "Camera", + "search_filter_camera": "Камера", "search_filter_camera_make": "Производитель", "search_filter_camera_model": "Модель", - "search_filter_camera_title": "Select camera type", - "search_filter_date": "Date", - "search_filter_date_interval": "{start} to {end}", - "search_filter_date_title": "Select a date range", + "search_filter_camera_title": "Выберите тип камеры", + "search_filter_date": "Дата", + "search_filter_date_interval": "{start} до {end}", + "search_filter_date_title": "Выберите диапазон дат", "search_filter_display_option_archive": "Архив", "search_filter_display_option_favorite": "Избранное", "search_filter_display_option_not_in_album": "Не в альбоме", - "search_filter_display_options": "Display Options", - "search_filter_display_options_title": "Display options", - "search_filter_location": "Location", + "search_filter_display_options": "Параметри відображення", + "search_filter_display_options_title": "Параметри відображення", + "search_filter_location": "Местоположение", "search_filter_location_city": "Город", "search_filter_location_country": "Страна", "search_filter_location_state": "Регион", - "search_filter_location_title": "Select location", - "search_filter_media_type": "Media Type", + "search_filter_location_title": "Выберите местонахождение", + "search_filter_media_type": "Тип носителя", "search_filter_media_type_all": "Все", "search_filter_media_type_image": "Изображения", - "search_filter_media_type_title": "Select media type", + "search_filter_media_type_title": "Выберите тип носителя", "search_filter_media_type_video": "Видео", - "search_filter_people": "People", - "search_filter_people_title": "Select people", + "search_filter_people": "Люди", + "search_filter_people_title": "Выберите людей", "search_page_categories": "Категории", "search_page_favorites": "Избранное", "search_page_motion_photos": "Динамические фото", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Общие", "theme_setting_asset_list_storage_indicator_title": "Показать индикатор хранилища на плитках объектов", "theme_setting_asset_list_tiles_per_row_title": "Количество объектов в строке ({})", + "theme_setting_colorful_interface_subtitle": "Применить основной цвет на поверхность фона.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Тёмная тема", "theme_setting_image_viewer_quality_subtitle": "Настройка качества просмотра полноэкранных изображения", "theme_setting_image_viewer_quality_title": "Качество просмотра изображений", + "theme_setting_primary_color_subtitle": "Выберите цвет для основных действий и акцентов.", + "theme_setting_primary_color_title": "Основной цвет", + "theme_setting_system_primary_color_title": "Использовать системный цвет", "theme_setting_system_theme_switch": "Автоматически (как в системе)", "theme_setting_theme_subtitle": "Настройка темы приложения", "theme_setting_theme_title": "Тема", "theme_setting_three_stage_loading_subtitle": "Трехэтапная загрузка может повысить производительность загрузки, но вызывает значительно более высокую нагрузку на сеть", "theme_setting_three_stage_loading_title": "Включить трехэтапную загрузку", "translated_text_options": "Опции", + "trash_emptied": "Emptied trash", "trash_page_delete": "Удалить", "trash_page_delete_all": "Удалить все", "trash_page_empty_trash_btn": "Очистить корзину", diff --git a/mobile/assets/i18n/sk-SK.json b/mobile/assets/i18n/sk-SK.json index 0a7c3d93e4..152c1719f9 100644 --- a/mobile/assets/i18n/sk-SK.json +++ b/mobile/assets/i18n/sk-SK.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Rozvrhnutie", "asset_list_settings_subtitle": "Nastavenia rozloženia mriežky fotografií", "asset_list_settings_title": "Fotografická mriežka", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Zobrazovač položiek", "backup_album_selection_page_albums_device": "Albumy v zariadení ({})", "backup_album_selection_page_albums_tap": "Ťuknutím na položku ju zahrniete, dvojitým ťuknutím ju vylúčite", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Zdieľanie", "theme_setting_asset_list_storage_indicator_title": "Zobraziť indikátor úložiska na dlaždiciach položiek", "theme_setting_asset_list_tiles_per_row_title": "Počet položiek na riadok ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Tmavá téma", "theme_setting_image_viewer_quality_subtitle": "Prispôsobenie kvality prehliadača detailov", "theme_setting_image_viewer_quality_title": "Kvalita prehliadača obrázkov", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automaticky (podľa systemového nastavenia)", "theme_setting_theme_subtitle": "Vyberte nastavenia témy aplikácie", "theme_setting_theme_title": "Téma", "theme_setting_three_stage_loading_subtitle": "Trojstupňové načítanie môže zvýšiť výkonnosť načítania, ale vedie k výrazne vyššiemu zaťaženiu siete.", "theme_setting_three_stage_loading_title": "Povolenie trojstupňového načítavania", "translated_text_options": "Nastavenia", + "trash_emptied": "Emptied trash", "trash_page_delete": "Vymazať", "trash_page_delete_all": "Vymazať všetky", "trash_page_empty_trash_btn": "Vyprázdniť kôš", diff --git a/mobile/assets/i18n/sl-SI.json b/mobile/assets/i18n/sl-SI.json index 6b6e3e39bb..e84a723172 100644 --- a/mobile/assets/i18n/sl-SI.json +++ b/mobile/assets/i18n/sl-SI.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Postavitev", "asset_list_settings_subtitle": "Nastavitve postavitve mreže fotografij", "asset_list_settings_title": "Mreža fotografij", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Pregledovalnik sredstev", "backup_album_selection_page_albums_device": "Albumi v napravi ({})", "backup_album_selection_page_albums_tap": "Tapnite za vključitev, dvakrat tapnite za izključitev", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Deljeno", "theme_setting_asset_list_storage_indicator_title": "Pokaži indikator shrambe na ploščicah sredstev", "theme_setting_asset_list_tiles_per_row_title": "Število sredstev na vrstico ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Temni način", "theme_setting_image_viewer_quality_subtitle": "Prilagodite kakovost podrobnega pregledovalnika slik", "theme_setting_image_viewer_quality_title": "Kakovost pregledovalnika slik", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Samodejno (Sledi nastavitvi sistema)", "theme_setting_theme_subtitle": "Izberi nastavitev teme aplikacije", "theme_setting_theme_title": "Tema", "theme_setting_three_stage_loading_subtitle": "Tristopenjsko nalaganje lahko poveča zmogljivost nalaganja, vendar povzroči znatno večjo obremenitev omrežja", "theme_setting_three_stage_loading_title": "Omogoči tristopenjsko nalaganje", "translated_text_options": "Možnosti", + "trash_emptied": "Emptied trash", "trash_page_delete": "Izbriši", "trash_page_delete_all": "Izbriši vse", "trash_page_empty_trash_btn": "Izprazni smeti", diff --git a/mobile/assets/i18n/sr-Cyrl.json b/mobile/assets/i18n/sr-Cyrl.json index ad3103b002..9ef2a3e599 100644 --- a/mobile/assets/i18n/sr-Cyrl.json +++ b/mobile/assets/i18n/sr-Cyrl.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Sharing", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Dark mode", "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatic (Follow system setting)", "theme_setting_theme_subtitle": "Choose the app's theme setting", "theme_setting_theme_title": "Theme", "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", "trash_page_empty_trash_btn": "Empty trash", diff --git a/mobile/assets/i18n/sr-Latn.json b/mobile/assets/i18n/sr-Latn.json index 3abd75807c..e4740cb8d0 100644 --- a/mobile/assets/i18n/sr-Latn.json +++ b/mobile/assets/i18n/sr-Latn.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Opcije za mrežni prikaz fotografija", "asset_list_settings_title": "Mrežni prikaz fotografija", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Albuma na uređaju ({})", "backup_album_selection_page_albums_tap": "Dodirni da uključiš, dodirni dvaput da isključiš", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Deljenje", "theme_setting_asset_list_storage_indicator_title": "Prikaži indikator prostora na zapisima", "theme_setting_asset_list_tiles_per_row_title": "Broj zapisa po redu ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Tamni Mod", "theme_setting_image_viewer_quality_subtitle": "Prilagodite kvalitet prikaza za detaljno pregledavanje slike", "theme_setting_image_viewer_quality_title": "Kvalitet pregledača slika", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatski (Prati opcije sistema)", "theme_setting_theme_subtitle": "Odaberi temu sistema", "theme_setting_theme_title": "Teme", "theme_setting_three_stage_loading_subtitle": "Trostepeno učitavanje možda ubrza učitavanje, po cenu potrošnje podataka", "theme_setting_three_stage_loading_title": "Aktiviraj trostepeno učitavanje", "translated_text_options": "Options", + "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", "trash_page_empty_trash_btn": "Empty trash", diff --git a/mobile/assets/i18n/sv-FI.json b/mobile/assets/i18n/sv-FI.json index ad3103b002..9ef2a3e599 100644 --- a/mobile/assets/i18n/sv-FI.json +++ b/mobile/assets/i18n/sv-FI.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Sharing", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Dark mode", "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatic (Follow system setting)", "theme_setting_theme_subtitle": "Choose the app's theme setting", "theme_setting_theme_title": "Theme", "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", "trash_page_empty_trash_btn": "Empty trash", diff --git a/mobile/assets/i18n/sv-SE.json b/mobile/assets/i18n/sv-SE.json index 9efce70e15..3e8fd85931 100644 --- a/mobile/assets/i18n/sv-SE.json +++ b/mobile/assets/i18n/sv-SE.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Layoutinställningar för bildrutnät", "asset_list_settings_title": "Bildrutnät", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Objektvisare", "backup_album_selection_page_albums_device": "Album på enhet ({})", "backup_album_selection_page_albums_tap": "Tryck en gång för att inkludera, tryck två gånger för att exkludera", @@ -166,7 +173,7 @@ "control_bottom_app_bar_delete": "Radera", "control_bottom_app_bar_delete_from_immich": "Ta bort från Immich", "control_bottom_app_bar_delete_from_local": "Ta bort från enhet", - "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_edit": "Redigera", "control_bottom_app_bar_edit_location": "Redigera plats", "control_bottom_app_bar_edit_time": "Redigera Datum & Tid", "control_bottom_app_bar_favorite": "Favorit", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Delning", "theme_setting_asset_list_storage_indicator_title": "Visa lagringsindikator på filer", "theme_setting_asset_list_tiles_per_row_title": "Antal bilder och videor per rad ({})", + "theme_setting_colorful_interface_subtitle": "Applicera primärfärgen på bakgrundsytor.", + "theme_setting_colorful_interface_title": "Färgglatt gränssnitt", "theme_setting_dark_mode_switch": "Mörkt läge", "theme_setting_image_viewer_quality_subtitle": "Justera kvaliteten i bildvisaren", "theme_setting_image_viewer_quality_title": "Bildvisarens kvalitet", + "theme_setting_primary_color_subtitle": "Välj en färg för primära åtgärder och accenter.", + "theme_setting_primary_color_title": "Primärfärg", + "theme_setting_system_primary_color_title": "Använd systemfärg", "theme_setting_system_theme_switch": "Automatisk (Följ systeminställningar)", "theme_setting_theme_subtitle": "Välj inställning för appens tema", "theme_setting_theme_title": "Tema", "theme_setting_three_stage_loading_subtitle": "Trestegsladdning kan öka prestandan, men kan också leda till signifikant högre nätverksbelastning", "theme_setting_three_stage_loading_title": "Aktivera trestegsladdning", "translated_text_options": "Val", + "trash_emptied": "Emptied trash", "trash_page_delete": "Ta Bort", "trash_page_delete_all": "Ta Bort Alla", "trash_page_empty_trash_btn": "Töm papperskorg", diff --git a/mobile/assets/i18n/th-TH.json b/mobile/assets/i18n/th-TH.json index c6bddcb7e9..46c9ee7549 100644 --- a/mobile/assets/i18n/th-TH.json +++ b/mobile/assets/i18n/th-TH.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "การจัดวาง", "asset_list_settings_subtitle": "ตั้งค่าการจัดวางตารางรูปภาพ", "asset_list_settings_title": "ตารางรูปภาพ", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "ตัวดูทรัพยากร", "backup_album_selection_page_albums_device": "อัลบั้มบนเครื่อง ({})", "backup_album_selection_page_albums_tap": "กดเพื่อรวม กดสองครั้งเพื่อยกเว้น", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "แชร์", "theme_setting_asset_list_storage_indicator_title": "แสดงตัวพื้นที่จัดเก็บบนตารางทรัพยากร", "theme_setting_asset_list_tiles_per_row_title": "จำนวนทรัพยากรต่อแถว ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "โหทดมืด", "theme_setting_image_viewer_quality_subtitle": "ปรับคุณภาพขอตัวดูรูปภาพละเอียด", "theme_setting_image_viewer_quality_title": "คุณภาพตังดูรูปภาพ", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "อัตโนมัติ (การตั้งค่าระบบ)", "theme_setting_theme_subtitle": "เลือกธีมของแอพ", "theme_setting_theme_title": "ธีม", "theme_setting_three_stage_loading_subtitle": "การโหลดแบบสามขั้นตอนอาจเพิ่มประสิทธิภาพในการโหลดแต่จะทำให้โหลดเครื่อข่ายเพิ่มขึ้นมาก", "theme_setting_three_stage_loading_title": "เปิดการโหลดสามขั้นตอน", "translated_text_options": "ตัวเลือก", + "trash_emptied": "Emptied trash", "trash_page_delete": "ลบ", "trash_page_delete_all": "ลบทั้งหมด", "trash_page_empty_trash_btn": "ทิ้งจากถังขยะ", diff --git a/mobile/assets/i18n/uk-UA.json b/mobile/assets/i18n/uk-UA.json index 8eba72a862..f81175ff72 100644 --- a/mobile/assets/i18n/uk-UA.json +++ b/mobile/assets/i18n/uk-UA.json @@ -3,8 +3,8 @@ "action_common_cancel": "Скасувати", "action_common_clear": "Очистити", "action_common_confirm": "Підтвердити", - "action_common_save": "Save", - "action_common_select": "Select", + "action_common_save": "Зберегти", + "action_common_select": "Вибрати", "action_common_update": "Оновити", "add_to_album_bottom_sheet_added": "Додати до {album}", "add_to_album_bottom_sheet_already_exists": "Вже є в {album}", @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Розмітка", "asset_list_settings_subtitle": "Налаштування компонування знімків", "asset_list_settings_title": "Фото-сітка", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Переглядач зображень", "backup_album_selection_page_albums_device": "Альбоми на пристрої ({})", "backup_album_selection_page_albums_tap": "Торкніться, щоб включити,\nторкніться двічі, щоб виключити", @@ -144,20 +151,20 @@ "change_password_form_password_mismatch": "Паролі не співпадають", "change_password_form_reenter_new_password": "Повторіть новий пароль", "client_cert_dialog_msg_confirm": "OK", - "client_cert_enter_password": "Enter Password", - "client_cert_import": "Import", - "client_cert_import_success_msg": "Client certificate is imported", - "client_cert_invalid_msg": "Invalid certificate file or wrong password", - "client_cert_remove": "Remove", - "client_cert_remove_msg": "Client certificate is removed", - "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", - "client_cert_title": "SSL Client Certificate", + "client_cert_enter_password": "Введіть пароль", + "client_cert_import": "Імпорт", + "client_cert_import_success_msg": "Клієнтський сертифікат імпортовано", + "client_cert_invalid_msg": "Недійсний файл сертифіката або неправильний пароль", + "client_cert_remove": "Видалити", + "client_cert_remove_msg": "Клієнтський сертифікат видалено", + "client_cert_subtitle": "Підтримується лише формат PKCS12 (.p12, .pfx). Імпорт/видалення сертифіката доступне лише перед входом у систему.", + "client_cert_title": "Клієнтський SSL-сертифікат", "common_add_to_album": "Додати у альбом", "common_change_password": "Змінити пароль", "common_create_new_album": "Створити новий альбом", "common_server_error": "Будь ласка, перевірте з'єднання, переконайтеся, що сервер доступний і версія програми/сервера сумісна.", "common_shared": "Спільні", - "contextual_search": "Sunrise on the beach", + "contextual_search": "Схід сонця на пляжі", "control_bottom_app_bar_add_to_album": "Додати у альбом", "control_bottom_app_bar_album_info": "{} елементи", "control_bottom_app_bar_album_info_shared": "{} елементи · Спільні", @@ -166,7 +173,7 @@ "control_bottom_app_bar_delete": "Видалити", "control_bottom_app_bar_delete_from_immich": "Видалити з Immich", "control_bottom_app_bar_delete_from_local": "Видалити з пристрою", - "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_edit": "Редагувати", "control_bottom_app_bar_edit_location": "Редагувати місцезнаходження", "control_bottom_app_bar_edit_time": "Редагувати дату та час", "control_bottom_app_bar_favorite": "До улюблених", @@ -216,7 +223,7 @@ "experimental_settings_title": "Експериментальні", "favorites_page_no_favorites": "Немає улюблених елементів", "favorites_page_title": "Улюблені", - "filename_search": "File name or extension", + "filename_search": "Ім'я або розширення файлу", "haptic_feedback_switch": "Увімкнути тактильну віддачу", "haptic_feedback_title": "Тактильна віддача", "header_settings_add_header_tip": "Додати заголовок", @@ -244,8 +251,8 @@ "image_viewer_page_state_provider_download_started": "Завантаження почалося", "image_viewer_page_state_provider_download_success": "Усіпшно завантажено", "image_viewer_page_state_provider_share_error": "Помилка спільного доступу", - "invalid_date": "Invalid date", - "invalid_date_format": "Invalid date format", + "invalid_date": "Недійсна дата", + "invalid_date_format": "Недійсний формат дати", "library_page_albums": "Альбоми", "library_page_archive": "Архів", "library_page_device_albums": "Альбоми на пристрої", @@ -327,7 +334,7 @@ "multiselect_grid_edit_date_time_err_read_only": "Неможливо редагувати дату елементів лише для читання, пропущено", "multiselect_grid_edit_gps_err_read_only": "Неможливо редагувати місцезнаходження елементів лише для читання, пропущено", "no_assets_to_show": "Елементи відсутні", - "no_name": "No name", + "no_name": "Без імені", "notification_permission_dialog_cancel": "Скасувати", "notification_permission_dialog_content": "Щоб увімкнути сповіщення, перейдіть до Налаштувань і надайте дозвіл.", "notification_permission_dialog_settings": "Налаштування", @@ -371,30 +378,30 @@ "scaffold_body_error_occurred": "Виникла помилка", "search_bar_hint": "Шукати ваші знімки", "search_filter_apply": "Застосувати фільтр", - "search_filter_camera": "Camera", + "search_filter_camera": "Камера", "search_filter_camera_make": "Виробник", "search_filter_camera_model": "Модель", - "search_filter_camera_title": "Select camera type", - "search_filter_date": "Date", - "search_filter_date_interval": "{start} to {end}", - "search_filter_date_title": "Select a date range", + "search_filter_camera_title": "Виберіть тип камери", + "search_filter_date": "Дата", + "search_filter_date_interval": "{start} до {end}", + "search_filter_date_title": "Виберіть діапазон дат", "search_filter_display_option_archive": "Архів", "search_filter_display_option_favorite": "Улюблені", "search_filter_display_option_not_in_album": "Не в альбомі", - "search_filter_display_options": "Display Options", - "search_filter_display_options_title": "Display options", - "search_filter_location": "Location", + "search_filter_display_options": "Параметри відображення", + "search_filter_display_options_title": "Параметри відображення", + "search_filter_location": "Місцезнаходження", "search_filter_location_city": "Місто", "search_filter_location_country": "Країна", "search_filter_location_state": "Регіон", - "search_filter_location_title": "Select location", - "search_filter_media_type": "Media Type", + "search_filter_location_title": "Виберіть місцезнаходження", + "search_filter_media_type": "Тип носія", "search_filter_media_type_all": "Усі", "search_filter_media_type_image": "Зображення", - "search_filter_media_type_title": "Select media type", + "search_filter_media_type_title": "Виберіть тип носія", "search_filter_media_type_video": "Відео", - "search_filter_people": "People", - "search_filter_people_title": "Select people", + "search_filter_people": "Люди", + "search_filter_people_title": "Виберіть людей", "search_page_categories": "Категорії", "search_page_favorites": "Улюблені", "search_page_motion_photos": "Рухомі знімки", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Спільні", "theme_setting_asset_list_storage_indicator_title": "Показувати піктограму сховища на плитках елементів", "theme_setting_asset_list_tiles_per_row_title": "Кількість елементів у рядку ({})", + "theme_setting_colorful_interface_subtitle": "Застосувати основний колір на поверхню фону.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Темна тема", "theme_setting_image_viewer_quality_subtitle": "Налаштування якості перегляду повноекранних зображень", "theme_setting_image_viewer_quality_title": "Якість перегляду зображень", + "theme_setting_primary_color_subtitle": "Виберіть колір для основних дій і акцентів.", + "theme_setting_primary_color_title": "Основний колір", + "theme_setting_system_primary_color_title": "Використовувати колір системи", "theme_setting_system_theme_switch": "Автоматично (як у системі)", "theme_setting_theme_subtitle": "Налаштування теми додатка", "theme_setting_theme_title": "Тема", "theme_setting_three_stage_loading_subtitle": "Триетапне завантаження може підвищити продуктивність завантаження, але спричинить значно більше навантаження на мережу", "theme_setting_three_stage_loading_title": "Увімкнути триетапне завантаження", "translated_text_options": "Налаштування", + "trash_emptied": "Emptied trash", "trash_page_delete": "Видалити", "trash_page_delete_all": "Видалити усі", "trash_page_empty_trash_btn": "Очистити кошик", diff --git a/mobile/assets/i18n/vi-VN.json b/mobile/assets/i18n/vi-VN.json index 4a6c59280a..64dc7a82be 100644 --- a/mobile/assets/i18n/vi-VN.json +++ b/mobile/assets/i18n/vi-VN.json @@ -3,8 +3,8 @@ "action_common_cancel": "Từ chối", "action_common_clear": "Xoá", "action_common_confirm": "Xác nhận", - "action_common_save": "Save", - "action_common_select": "Select", + "action_common_save": "Lưu", + "action_common_select": "Chọn", "action_common_update": "Cập nhật", "add_to_album_bottom_sheet_added": "Thêm vào {album}", "add_to_album_bottom_sheet_already_exists": "Đã có sẵn trong {album}", @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Bố cục", "asset_list_settings_subtitle": "Cài đặt bố cục lưới ảnh", "asset_list_settings_title": "Lưới ảnh", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Trình xem ảnh", "backup_album_selection_page_albums_device": "Album trên thiết bị ({})", "backup_album_selection_page_albums_tap": "Nhấn để chọn, nhấn đúp để bỏ qua", @@ -143,21 +150,21 @@ "change_password_form_new_password": "Mật khẩu mới", "change_password_form_password_mismatch": "Mật khẩu không giống nhau", "change_password_form_reenter_new_password": "Nhập lại mật khẩu mới", - "client_cert_dialog_msg_confirm": "OK", - "client_cert_enter_password": "Enter Password", - "client_cert_import": "Import", - "client_cert_import_success_msg": "Client certificate is imported", - "client_cert_invalid_msg": "Invalid certificate file or wrong password", - "client_cert_remove": "Remove", - "client_cert_remove_msg": "Client certificate is removed", + "client_cert_dialog_msg_confirm": "Đồng ý", + "client_cert_enter_password": "Nhập mật khẩu", + "client_cert_import": "Nhập", + "client_cert_import_success_msg": "Chứng chỉ khách đã được nhập", + "client_cert_invalid_msg": "Tập tin chứng chỉ không hợp lệ hoặc sai mật khẩu", + "client_cert_remove": "Xoá", + "client_cert_remove_msg": "Chứng chỉ khách đã bị xoá", "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", - "client_cert_title": "SSL Client Certificate", + "client_cert_title": "Chứng chỉ khách SSL", "common_add_to_album": "Thêm vào album", "common_change_password": "Thay đổi mật khẩu", "common_create_new_album": "Tạo album mới", "common_server_error": "Vui lòng kiểm tra kết nối mạng của bạn, đảm bảo máy chủ có thể truy cập được và các phiên bản ứng dụng/máy chủ phải tương thích với nhau", "common_shared": "Chia sẻ", - "contextual_search": "Sunrise on the beach", + "contextual_search": "Bình mình trên bãi biển", "control_bottom_app_bar_add_to_album": "Thêm vào album", "control_bottom_app_bar_album_info": "{} mục", "control_bottom_app_bar_album_info_shared": "{} mục chia sẻ", @@ -166,13 +173,13 @@ "control_bottom_app_bar_delete": "Xoá", "control_bottom_app_bar_delete_from_immich": "Xóa khỏi Immich", "control_bottom_app_bar_delete_from_local": "Xóa khỏi thiết bị\n", - "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_edit": "Sửa", "control_bottom_app_bar_edit_location": "Chỉnh sửa vị trí", "control_bottom_app_bar_edit_time": "Chỉnh sửa Ngày và Giờ", "control_bottom_app_bar_favorite": "Yêu thích", "control_bottom_app_bar_share": "Chia sẻ", "control_bottom_app_bar_share_to": "Chia sẻ với", - "control_bottom_app_bar_stack": "Xếp nhóm", + "control_bottom_app_bar_stack": "Nhóm ảnh", "control_bottom_app_bar_trash_from_immich": "Chuyển tới thùng rác", "control_bottom_app_bar_unarchive": "Huỷ lưu trữ", "control_bottom_app_bar_unfavorite": "Bỏ yêu thích", @@ -216,8 +223,8 @@ "experimental_settings_title": "Chưa hoàn thiện", "favorites_page_no_favorites": "Không tìm thấy ảnh yêu thích", "favorites_page_title": "Ảnh yêu thích", - "filename_search": "File name or extension", - "haptic_feedback_switch": "Bật haptic feedback\n", + "filename_search": "Tên hoặc phần mở rộng tập tin", + "haptic_feedback_switch": "Bật phản hồi haptic\n", "haptic_feedback_title": "Haptic Feedback\n", "header_settings_add_header_tip": "Thêm Header", "header_settings_field_validator_msg": "Trường này không được để trống", @@ -244,8 +251,8 @@ "image_viewer_page_state_provider_download_started": "Đã bắt đầu tải xuống", "image_viewer_page_state_provider_download_success": "Tải xuống thành công", "image_viewer_page_state_provider_share_error": "Chia sẻ không thành công", - "invalid_date": "Invalid date", - "invalid_date_format": "Invalid date format", + "invalid_date": "Ngày không hợp lệ", + "invalid_date_format": "Định dạng ngày không hợp lệ", "library_page_albums": "Album", "library_page_archive": "Kho lưu trữ", "library_page_device_albums": "Album trên thiết bị", @@ -327,7 +334,7 @@ "multiselect_grid_edit_date_time_err_read_only": "Không thể chỉnh sửa ngày của ảnh chỉ có quyền đọc, bỏ qua", "multiselect_grid_edit_gps_err_read_only": "Không thể chỉnh sửa vị trí của ảnh chỉ có quyền đọc, bỏ qua", "no_assets_to_show": "Không có mục nào để hiển thị", - "no_name": "No name", + "no_name": "Không có tên", "notification_permission_dialog_cancel": "Từ chối", "notification_permission_dialog_content": "Để bật thông báo, chuyển tới Cài đặt và chọn cho phép", "notification_permission_dialog_settings": "Cài đặt", @@ -371,30 +378,30 @@ "scaffold_body_error_occurred": "Xảy ra lỗi", "search_bar_hint": "Tìm kiếm ảnh của bạn", "search_filter_apply": "Áp dụng bộ lọc", - "search_filter_camera": "Camera", - "search_filter_camera_make": "Chụp bởi", - "search_filter_camera_model": "Model", - "search_filter_camera_title": "Select camera type", - "search_filter_date": "Date", - "search_filter_date_interval": "{start} to {end}", - "search_filter_date_title": "Select a date range", + "search_filter_camera": "Máy ảnh", + "search_filter_camera_make": "Thương hiệu", + "search_filter_camera_model": "Dòng máy ảnh", + "search_filter_camera_title": "Chọn loại máy ảnh", + "search_filter_date": "Ngày", + "search_filter_date_interval": "{start} đến {end}", + "search_filter_date_title": "Chọn khoảng ngày", "search_filter_display_option_archive": "Kho lưu trữ", "search_filter_display_option_favorite": "Yêu thích", "search_filter_display_option_not_in_album": "Không nằm trong album", - "search_filter_display_options": "Display Options", - "search_filter_display_options_title": "Display options", - "search_filter_location": "Location", + "search_filter_display_options": "Tuỳ chọn hiển thị", + "search_filter_display_options_title": "Tuỳ chọn hiển thị", + "search_filter_location": "Vị trí", "search_filter_location_city": "Thành phố", "search_filter_location_country": "Quốc gia", "search_filter_location_state": "Tỉnh", - "search_filter_location_title": "Select location", - "search_filter_media_type": "Media Type", + "search_filter_location_title": "Chọn vị trí", + "search_filter_media_type": "Loại phương tiện", "search_filter_media_type_all": "Tất cả", "search_filter_media_type_image": "Ảnh", - "search_filter_media_type_title": "Select media type", + "search_filter_media_type_title": "Chọn loại phương tiện", "search_filter_media_type_video": "Video", - "search_filter_people": "People", - "search_filter_people_title": "Select people", + "search_filter_people": "Mọi người", + "search_filter_people_title": "Chọn người", "search_page_categories": "Danh mục", "search_page_favorites": "Ảnh yêu thích", "search_page_motion_photos": "Ảnh động", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Chia sẻ", "theme_setting_asset_list_storage_indicator_title": "Hiện thị trạng thái sao lưu ảnh trên hình thu nhỏ ", "theme_setting_asset_list_tiles_per_row_title": "Số lượng ảnh trên một dòng ({})", + "theme_setting_colorful_interface_subtitle": "Áp dụng màu chủ đạo cho nền ứng dụng", + "theme_setting_colorful_interface_title": "Giao diện màu sắc", "theme_setting_dark_mode_switch": "Chế độ tối", "theme_setting_image_viewer_quality_subtitle": "Điều chỉnh chất lượng của trình xem ảnh", "theme_setting_image_viewer_quality_title": "Chất lượng trình xem ảnh", + "theme_setting_primary_color_subtitle": "Chọn màu cho các hành động chính và điểm nhấn.", + "theme_setting_primary_color_title": "Màu chủ đạo", + "theme_setting_system_primary_color_title": "Dùng màu hệ thống", "theme_setting_system_theme_switch": "Tự động (Theo cài đặt hệ thống)", "theme_setting_theme_subtitle": "Chọn cài đặt giao diện ứng dụng", "theme_setting_theme_title": "Giao diện", "theme_setting_three_stage_loading_subtitle": "Tải ba giai doạn có thể tăng hiệu năng tải ảnh nhưng sẽ tốn dữ liệu mạng đáng kể.", "theme_setting_three_stage_loading_title": "Bật tải ba giai đoạn", "translated_text_options": "Tuỳ chỉnh", + "trash_emptied": "Emptied trash", "trash_page_delete": "Xoá", "trash_page_delete_all": "Xoá tất cả", "trash_page_empty_trash_btn": "Dọn sạch thùng rác", diff --git a/mobile/assets/i18n/zh-CN.json b/mobile/assets/i18n/zh-CN.json index 737bf9dc0c..1f0f225b48 100644 --- a/mobile/assets/i18n/zh-CN.json +++ b/mobile/assets/i18n/zh-CN.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "布局", "asset_list_settings_subtitle": "照片网格布局设置", "asset_list_settings_title": "照片网格", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "资源查看器", "backup_album_selection_page_albums_device": "设备上的相册({})", "backup_album_selection_page_albums_tap": "单击选中,双击取消", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "共享", "theme_setting_asset_list_storage_indicator_title": "在项目标题上显示存储占用", "theme_setting_asset_list_tiles_per_row_title": "每行展示 {} 项", + "theme_setting_colorful_interface_subtitle": "应用主色调到背景", + "theme_setting_colorful_interface_title": "彩色界面", "theme_setting_dark_mode_switch": "深色模式", "theme_setting_image_viewer_quality_subtitle": "调整查看大图时的图像质量", "theme_setting_image_viewer_quality_title": "图像质量", + "theme_setting_primary_color_subtitle": "选择颜色作为主色调", + "theme_setting_primary_color_title": "主色调", + "theme_setting_system_primary_color_title": "使用系统颜色", "theme_setting_system_theme_switch": "自动(跟随系统设置)", "theme_setting_theme_subtitle": "选择应用主题", "theme_setting_theme_title": "主题", "theme_setting_three_stage_loading_subtitle": "三段式加载可能会提升加载性能,但可能会导致更高的网络负载", "theme_setting_three_stage_loading_title": "启用三段式加载", "translated_text_options": "选项", + "trash_emptied": "Emptied trash", "trash_page_delete": "删除", "trash_page_delete_all": "删除全部", "trash_page_empty_trash_btn": "清空回收站", diff --git a/mobile/assets/i18n/zh-Hans.json b/mobile/assets/i18n/zh-Hans.json index 8899810193..8420fe53d6 100644 --- a/mobile/assets/i18n/zh-Hans.json +++ b/mobile/assets/i18n/zh-Hans.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "布局", "asset_list_settings_subtitle": "照片网格布局设置", "asset_list_settings_title": "照片网格", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "资源查看器", "backup_album_selection_page_albums_device": "设备上的相册({})", "backup_album_selection_page_albums_tap": "单击选中,双击取消", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "共享", "theme_setting_asset_list_storage_indicator_title": "在项目标题上显示存储占用", "theme_setting_asset_list_tiles_per_row_title": "每行展示 {} 项", + "theme_setting_colorful_interface_subtitle": "应用主色调到背景", + "theme_setting_colorful_interface_title": "彩色界面", "theme_setting_dark_mode_switch": "深色模式", "theme_setting_image_viewer_quality_subtitle": "调整查看大图时的图像质量", "theme_setting_image_viewer_quality_title": "图像质量", + "theme_setting_primary_color_subtitle": "选择颜色作为主色调", + "theme_setting_primary_color_title": "主色调", + "theme_setting_system_primary_color_title": "使用系统颜色", "theme_setting_system_theme_switch": "自动(跟随系统设置)", "theme_setting_theme_subtitle": "选择应用主题", "theme_setting_theme_title": "主题", "theme_setting_three_stage_loading_subtitle": "三段式加载可能会提升加载性能,但可能会导致更高的网络负载", "theme_setting_three_stage_loading_title": "启用三段式加载", "translated_text_options": "选项", + "trash_emptied": "Emptied trash", "trash_page_delete": "删除", "trash_page_delete_all": "删除全部", "trash_page_empty_trash_btn": "清空回收站", diff --git a/mobile/assets/i18n/zh-TW.json b/mobile/assets/i18n/zh-TW.json index ad3103b002..9ef2a3e599 100644 --- a/mobile/assets/i18n/zh-TW.json +++ b/mobile/assets/i18n/zh-TW.json @@ -54,6 +54,13 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", @@ -528,15 +535,21 @@ "tab_controller_nav_sharing": "Sharing", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", "theme_setting_dark_mode_switch": "Dark mode", "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", "theme_setting_system_theme_switch": "Automatic (Follow system setting)", "theme_setting_theme_subtitle": "Choose the app's theme setting", "theme_setting_theme_title": "Theme", "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", "trash_page_empty_trash_btn": "Empty trash", From 228a7710e6f995d0f4786d1c5ea619acb2132dee Mon Sep 17 00:00:00 2001 From: Alex The Bot Date: Wed, 14 Aug 2024 15:51:18 +0000 Subject: [PATCH 092/723] Version v1.112.0 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 10 +++++----- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 32 insertions(+), 28 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 9a9bd1c88c..29c16cf81d 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.13", + "version": "2.2.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.13", + "version": "2.2.14", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.111.0", + "version": "1.112.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 31a50f9f79..f45ac6ef73 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.13", + "version": "2.2.14", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index dfd3e47a6b..d7882951ec 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.112.0", + "url": "https://v1.112.0.archive.immich.app" + }, { "label": "v1.111.0", "url": "https://v1.111.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index eed3ee6de8..2892e7bf18 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.111.0", + "version": "1.112.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.111.0", + "version": "1.112.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.13", + "version": "2.2.14", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -81,7 +81,7 @@ "prettier-plugin-organize-imports": "^4.0.0", "typescript": "^5.3.3", "vite": "^5.0.12", - "vite-tsconfig-paths": "^4.3.2", + "vite-tsconfig-paths": "^5.0.0", "vitest": "^2.0.5", "vitest-fetch-mock": "^0.3.0", "yaml": "^2.3.1" @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.111.0", + "version": "1.112.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 364dcc9682..838236d795 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.111.0", + "version": "1.112.0", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 37001ba2eb..f04520cd4f 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.111.0" +version = "1.112.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 8adc7beeda..47ec22c9c0 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 152, - "android.injected.version.name" => "1.111.0", + "android.injected.version.code" => 153, + "android.injected.version.name" => "1.112.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 2bfa4c9f1f..1599fb3199 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.111.0" + version_number: "1.112.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 97f6a9d6c8..247fe9e8c4 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.111.0 +- API version: 1.112.0 - Generator version: 7.5.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6c853054ea..dd7823b81a 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.111.0+152 +version: 1.112.0+153 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 91e32d1e05..750af46883 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7102,7 +7102,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.111.0", + "version": "1.112.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 4ddd61093b..9cb3b5aee1 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.111.0", + "version": "1.112.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.111.0", + "version": "1.112.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index e699e94be1..866c0d9cc5 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.111.0", + "version": "1.112.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9d97f4bcce..68f37100ca 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.111.0 + * 1.112.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index a521f3211c..2f7fa57849 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.111.0", + "version": "1.112.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.111.0", + "version": "1.112.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 35f22cd2b4..39487469d9 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.111.0", + "version": "1.112.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index c718fd1150..9a68422fb6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.111.0", + "version": "1.112.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.111.0", + "version": "1.112.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -73,7 +73,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.111.0", + "version": "1.112.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 48f07127c9..bcf000fcfe 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.111.0", + "version": "1.112.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From da6f269008b38bd49a195069f1a0e93429e00b5d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 14 Aug 2024 14:42:33 -0400 Subject: [PATCH 093/723] refactor: asset e2e performance (#11779) --- e2e/src/api/specs/asset.e2e-spec.ts | 35 +++++++++++++++++++---------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index b9282ff811..4ee035ee95 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -993,7 +993,7 @@ describe('/asset', () => { expect(body).toEqual(errorDto.badRequest()); }); - it.each([ + const tests = [ { input: 'formats/avif/8bit-sRGB.avif', expected: { @@ -1209,21 +1209,32 @@ describe('/asset', () => { }, }, }, - ])(`should upload and generate a thumbnail for $input`, async ({ input, expected }) => { - const filepath = join(testAssetDir, input); - const { id, status } = await utils.createAsset(admin.accessToken, { - assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, - }); + ]; - expect(status).toBe(AssetMediaStatus.Created); + it(`should upload and generate a thumbnail for different file types`, async () => { + // upload in parallel + const assets = await Promise.all( + tests.map(async ({ input }) => { + const filepath = join(testAssetDir, input); + return utils.createAsset(admin.accessToken, { + assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, + }); + }), + ); - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: id }); + for (const { id, status } of assets) { + expect(status).toBe(AssetMediaStatus.Created); + await utils.waitForWebsocketEvent({ event: 'assetUpload', id }); + } - const asset = await utils.getAssetInfo(admin.accessToken, id); + for (const [i, { id }] of assets.entries()) { + const { expected } = tests[i]; + const asset = await utils.getAssetInfo(admin.accessToken, id); - expect(asset.exifInfo).toBeDefined(); - expect(asset.exifInfo).toMatchObject(expected.exifInfo); - expect(asset).toMatchObject(expected); + expect(asset.exifInfo).toBeDefined(); + expect(asset.exifInfo).toMatchObject(expected.exifInfo); + expect(asset).toMatchObject(expected); + } }); it('should handle a duplicate', async () => { From 9e21f254cddd6a48f9e2ef44abbed025264dc863 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 14 Aug 2024 13:50:35 -0500 Subject: [PATCH 094/723] chore(mobile): post release task (#11776) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 6f15687916..1a3b115c8a 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -401,7 +401,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 167; + CURRENT_PROJECT_VERSION = 168; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 167; + CURRENT_PROJECT_VERSION = 168; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -571,7 +571,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 167; + CURRENT_PROJECT_VERSION = 168; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 5dba46ea35..0727cd4603 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.111.0 + 1.112.0 CFBundleSignature ???? CFBundleVersion - 167 + 168 FLTEnableImpeller ITSAppUsesNonExemptEncryption From 7d888106eda61322c7cfb80d568c31eda45dc277 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 14 Aug 2024 14:52:19 -0500 Subject: [PATCH 095/723] fix(mobile): load original (#11786) * fix(mobile): load original * revert change to format --- mobile/lib/pages/common/gallery_viewer.page.dart | 7 +------ .../image/immich_local_image_provider.dart | 15 +++++++++++++-- .../image/immich_remote_image_provider.dart | 2 +- mobile/lib/utils/image_url_builder.dart | 8 ++------ 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 93fd5afceb..8c2c70d93c 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -55,8 +55,6 @@ class GalleryViewerPage extends HookConsumerWidget { final settings = ref.watch(appSettingsServiceProvider); final loadAsset = renderList.loadAsset; final totalAssets = useState(renderList.totalAssets); - final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue); - final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue); final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue); final isZoomed = useState(false); final isPlayingVideo = useState(false); @@ -97,10 +95,6 @@ class GalleryViewerPage extends HookConsumerWidget { useEffect( () { - isLoadPreview.value = - settings.getSetting(AppSettingsEnum.loadPreview); - isLoadOriginal.value = - settings.getSetting(AppSettingsEnum.loadOriginal); shouldLoopVideo.value = settings.getSetting(AppSettingsEnum.loopVideo); return null; @@ -324,6 +318,7 @@ class GalleryViewerPage extends HookConsumerWidget { builder: (context, index) { final a = index == currentIndex.value ? asset : loadAsset(index); + final ImageProvider provider = ImmichImage.imageProvider(asset: a); diff --git a/mobile/lib/providers/image/immich_local_image_provider.dart b/mobile/lib/providers/image/immich_local_image_provider.dart index cf9cf86090..dc1b8a9845 100644 --- a/mobile/lib/providers/image/immich_local_image_provider.dart +++ b/mobile/lib/providers/image/immich_local_image_provider.dart @@ -7,6 +7,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:photo_manager/photo_manager.dart'; /// The local image provider for an asset @@ -17,6 +19,12 @@ class ImmichLocalImageProvider extends ImageProvider { required this.asset, }) : assert(asset.local != null, 'Only usable when asset.local is set'); + /// Whether to show the original file or load a compressed version + bool get _useOriginal => Store.get( + AppSettingsEnum.loadOriginal.storeKey, + AppSettingsEnum.loadOriginal.defaultValue, + ); + /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// that describes the precise image to load. @override @@ -62,8 +70,11 @@ class ImmichLocalImageProvider extends ImageProvider { if (asset.isImage) { /// Using 2K thumbnail for local iOS image to avoid double swiping issue if (Platform.isIOS) { - final largeImageBytes = await asset.local - ?.thumbnailDataWithSize(const ThumbnailSize(3840, 2160)); + final largeImageBytes = _useOriginal + ? await asset.local?.originBytes + : await asset.local + ?.thumbnailDataWithSize(const ThumbnailSize(3840, 2160)); + if (largeImageBytes == null) { throw StateError( "Loading thumb for local photo ${asset.fileName} failed", diff --git a/mobile/lib/providers/image/immich_remote_image_provider.dart b/mobile/lib/providers/image/immich_remote_image_provider.dart index 2756ed1dc9..9e1d8aa120 100644 --- a/mobile/lib/providers/image/immich_remote_image_provider.dart +++ b/mobile/lib/providers/image/immich_remote_image_provider.dart @@ -101,7 +101,7 @@ class ImmichRemoteImageProvider // Load the final remote image if (_useOriginal) { // Load the original image - final url = getImageUrlFromId(key.assetId); + final url = getOriginalUrlForRemoteId(key.assetId); final codec = await ImageLoader.loadImageFromCache( url, cache: cache, diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index b6c7f2ba8b..e7a1b9e39e 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -55,12 +55,8 @@ String getAlbumThumbNailCacheKey( ); } -String getImageUrl(final Asset asset) { - return getImageUrlFromId(asset.remoteId!); -} - -String getImageUrlFromId(final String id) { - return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=preview'; +String getOriginalUrlForRemoteId(final String id) { + return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original'; } String getImageCacheKey(final Asset asset) { From fcec5f867c061669b52a22db11b0118197bea7f2 Mon Sep 17 00:00:00 2001 From: Thariq Shanavas Date: Wed, 14 Aug 2024 16:01:27 -0600 Subject: [PATCH 096/723] chore(docs): Encode db dump in UTF-8 for windows (#11787) * Encode db dump in UTF-8 for windows * Update backup-and-restore.md --- docs/docs/administration/backup-and-restore.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index 5c6ae47e43..3d226dd061 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -45,7 +45,7 @@ docker compose up -d # Start remainder of Immich apps ```powershell title='Backup' -docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres > "\path\to\backup\dump.sql" +docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | Set-Content -Encoding utf8 "C:\path\to\backup\dump.sql" ``` ```powershell title='Restore' From 44c26c20b65bf0795ee21c97db24a99dca8872bf Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 14 Aug 2024 18:06:11 -0400 Subject: [PATCH 097/723] chore: update submodule (#11789) --- .gitmodules | 2 +- e2e/test-assets | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 8c4cc4e205..d417dc5ba8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "mobile/.isar"] path = mobile/.isar url = https://github.com/isar/isar -[submodule "server/test/assets"] +[submodule "e2e/test-assets"] path = e2e/test-assets url = https://github.com/immich-app/test-assets diff --git a/e2e/test-assets b/e2e/test-assets index 39f25a96f1..4e9731d3fc 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 39f25a96f13f743c96cdb7c6d93b031fcb61b83c +Subproject commit 4e9731d3fc270fe25901f72a6b6f57277cdb8a30 From a38dd53afd03a5649d3f3ba3578b879608966c4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Aug 2024 18:23:43 -0400 Subject: [PATCH 098/723] chore(deps): bump docker/build-push-action from 6.6.1 to 6.7.0 (#11768) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.6.1 to 6.7.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6.6.1...v6.7.0) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli.yml | 2 +- .github/workflows/docker.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index cbeb2e5509..1ec17b381d 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -88,7 +88,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@v6.6.1 + uses: docker/build-push-action@v6.7.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5632336d32..2da49a7310 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -115,7 +115,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.6.1 + uses: docker/build-push-action@v6.7.0 with: context: ${{ matrix.context }} file: ${{ matrix.file }} From 7d5f07d1c76055c3c3def7e6b1a05d033f950719 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 14 Aug 2024 18:55:52 -0500 Subject: [PATCH 099/723] fix(mobile): android always prompts permission when accessing backup page (#11790) Android always prompt permission --- mobile/lib/providers/gallery_permission.provider.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mobile/lib/providers/gallery_permission.provider.dart b/mobile/lib/providers/gallery_permission.provider.dart index 7554a6a6bf..8077ca99fe 100644 --- a/mobile/lib/providers/gallery_permission.provider.dart +++ b/mobile/lib/providers/gallery_permission.provider.dart @@ -36,7 +36,8 @@ class GalleryPermissionNotifier extends StateNotifier { // Return the joint result of those two permissions final PermissionStatus status; - if (photos.isGranted && videos.isGranted) { + if ((photos.isGranted && videos.isGranted) || + (photos.isLimited && videos.isLimited)) { status = PermissionStatus.granted; } else if (photos.isDenied || videos.isDenied) { status = PermissionStatus.denied; @@ -79,7 +80,8 @@ class GalleryPermissionNotifier extends StateNotifier { // Return the joint result of those two permissions final PermissionStatus status; - if (photos.isGranted && videos.isGranted) { + if ((photos.isGranted && videos.isGranted) || + (photos.isLimited && videos.isLimited)) { status = PermissionStatus.granted; } else if (photos.isDenied || videos.isDenied) { status = PermissionStatus.denied; From f7bfde6a3286d4b454c2f05ccf354914f8eccac6 Mon Sep 17 00:00:00 2001 From: Alex The Bot Date: Thu, 15 Aug 2024 00:00:22 +0000 Subject: [PATCH 100/723] Version v1.112.1 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 29c16cf81d..cdba2036c4 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.14", + "version": "2.2.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.14", + "version": "2.2.15", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.112.0", + "version": "1.112.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index f45ac6ef73..c3f2f708e2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.14", + "version": "2.2.15", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index d7882951ec..c2bce22893 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.112.1", + "url": "https://v1.112.1.archive.immich.app" + }, { "label": "v1.112.0", "url": "https://v1.112.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 2892e7bf18..855cd34bba 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.112.0", + "version": "1.112.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.112.0", + "version": "1.112.1", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.14", + "version": "2.2.15", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.112.0", + "version": "1.112.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 838236d795..bf393e071a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.112.0", + "version": "1.112.1", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index f04520cd4f..05ac4618cd 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.112.0" +version = "1.112.1" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 47ec22c9c0..3905d6d555 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 153, - "android.injected.version.name" => "1.112.0", + "android.injected.version.code" => 154, + "android.injected.version.name" => "1.112.1", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 1599fb3199..c7d078ceea 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.112.0" + version_number: "1.112.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 247fe9e8c4..e747db37b0 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.112.0 +- API version: 1.112.1 - Generator version: 7.5.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index dd7823b81a..2551acce48 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.112.0+153 +version: 1.112.1+154 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 750af46883..f2693f1913 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7102,7 +7102,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.112.0", + "version": "1.112.1", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 9cb3b5aee1..53ef27fd29 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.112.0", + "version": "1.112.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.112.0", + "version": "1.112.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 866c0d9cc5..bbf7c962a0 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.112.0", + "version": "1.112.1", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 68f37100ca..d270f09e50 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.112.0 + * 1.112.1 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 2f7fa57849..05d5fcac25 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.112.0", + "version": "1.112.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.112.0", + "version": "1.112.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 39487469d9..97ca1ac69a 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.112.0", + "version": "1.112.1", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 9a68422fb6..fee3148631 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.112.0", + "version": "1.112.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.112.0", + "version": "1.112.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -73,7 +73,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.112.0", + "version": "1.112.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index bcf000fcfe..7d7751b67f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.112.0", + "version": "1.112.1", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From fa6427747681b99dc1e558ae833f9bf90fbaf3f7 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 15 Aug 2024 10:36:29 +0200 Subject: [PATCH 101/723] fix(web): focus trap inside portal (#11797) * fix(web): focus trap inside portal * fix tests --- web/src/lib/actions/__test__/focus-trap.spec.ts | 9 +++++---- web/src/lib/actions/focus-trap.ts | 5 ++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/web/src/lib/actions/__test__/focus-trap.spec.ts b/web/src/lib/actions/__test__/focus-trap.spec.ts index be3a97db3f..6ce5ad6d5b 100644 --- a/web/src/lib/actions/__test__/focus-trap.spec.ts +++ b/web/src/lib/actions/__test__/focus-trap.spec.ts @@ -6,19 +6,22 @@ import { tick } from 'svelte'; describe('focusTrap action', () => { const user = userEvent.setup(); - it('sets focus to the first focusable element', () => { + it('sets focus to the first focusable element', async () => { render(FocusTrapTest, { show: true }); + await tick(); expect(document.activeElement).toEqual(screen.getByTestId('one')); }); it('supports backward focus wrapping', async () => { render(FocusTrapTest, { show: true }); + await tick(); await user.keyboard('{Shift>}{Tab}{/Shift}'); expect(document.activeElement).toEqual(screen.getByTestId('three')); }); it('supports forward focus wrapping', async () => { render(FocusTrapTest, { show: true }); + await tick(); screen.getByTestId('three').focus(); await user.keyboard('{Tab}'); expect(document.activeElement).toEqual(screen.getByTestId('one')); @@ -28,9 +31,7 @@ describe('focusTrap action', () => { render(FocusTrapTest, { show: false }); const openButton = screen.getByText('Open'); - openButton.focus(); - openButton.click(); - await tick(); + await user.click(openButton); expect(document.activeElement).toEqual(screen.getByTestId('one')); screen.getByText('Close').click(); diff --git a/web/src/lib/actions/focus-trap.ts b/web/src/lib/actions/focus-trap.ts index c854199600..7483e76099 100644 --- a/web/src/lib/actions/focus-trap.ts +++ b/web/src/lib/actions/focus-trap.ts @@ -1,4 +1,5 @@ import { shortcuts } from '$lib/actions/shortcut'; +import { tick } from 'svelte'; const selectors = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; @@ -7,7 +8,9 @@ export function focusTrap(container: HTMLElement) { const triggerElement = document.activeElement; const focusableElement = container.querySelector(selectors); - focusableElement?.focus(); + + // Use tick() to ensure focus trap works correctly inside + void tick().then(() => focusableElement?.focus()); const getFocusableElements = (): [HTMLElement | null, HTMLElement | null] => { const focusableElements = container.querySelectorAll(selectors); From b288241a5c5fdc85d2cd238ae32e99295283335a Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 Aug 2024 06:57:01 -0400 Subject: [PATCH 102/723] refactor(server): enums (#11809) --- server/src/cores/access.core.ts | 45 +------ server/src/cores/system-config.core.ts | 2 +- server/src/dtos/album.dto.ts | 4 +- server/src/dtos/asset-response.dto.ts | 3 +- server/src/dtos/asset.dto.ts | 2 +- server/src/dtos/audit.dto.ts | 2 +- server/src/dtos/memory.dto.ts | 3 +- server/src/dtos/search.dto.ts | 3 +- server/src/dtos/shared-link.dto.ts | 3 +- server/src/dtos/time-bucket.dto.ts | 2 +- server/src/dtos/user-preferences.dto.ts | 3 +- server/src/dtos/user.dto.ts | 5 +- server/src/entities/album-user.entity.ts | 6 +- server/src/entities/album.entity.ts | 7 +- server/src/entities/asset.entity.ts | 8 +- server/src/entities/audit.entity.ts | 12 +- server/src/entities/memory.entity.ts | 6 +- server/src/entities/shared-link.entity.ts | 11 +- server/src/entities/system-metadata.entity.ts | 10 +- server/src/entities/user-metadata.entity.ts | 19 +-- server/src/entities/user.entity.ts | 7 +- server/src/enum.ts | 118 ++++++++++++++++++ server/src/interfaces/access.interface.ts | 2 +- server/src/interfaces/asset.interface.ts | 4 +- server/src/interfaces/audit.interface.ts | 2 +- server/src/interfaces/search.interface.ts | 3 +- server/src/repositories/access.repository.ts | 2 +- server/src/repositories/asset.repository.ts | 4 +- server/src/repositories/map.repository.ts | 2 +- server/src/repositories/search.repository.ts | 3 +- server/src/services/activity.service.ts | 3 +- server/src/services/album.service.spec.ts | 2 +- server/src/services/album.service.ts | 3 +- .../src/services/asset-media.service.spec.ts | 3 +- server/src/services/asset-media.service.ts | 5 +- server/src/services/asset.service.spec.ts | 3 +- server/src/services/asset.service.ts | 3 +- server/src/services/audit.service.spec.ts | 2 +- server/src/services/audit.service.ts | 4 +- server/src/services/download.service.ts | 3 +- server/src/services/job.service.ts | 2 +- server/src/services/library.service.spec.ts | 2 +- server/src/services/library.service.ts | 2 +- server/src/services/media.service.spec.ts | 2 +- server/src/services/media.service.ts | 3 +- server/src/services/memory.service.spec.ts | 2 +- server/src/services/memory.service.ts | 3 +- server/src/services/metadata.service.spec.ts | 2 +- server/src/services/metadata.service.ts | 3 +- .../src/services/notification.service.spec.ts | 2 +- server/src/services/partner.service.ts | 3 +- server/src/services/person.service.spec.ts | 2 +- server/src/services/person.service.ts | 6 +- server/src/services/search.service.ts | 2 +- server/src/services/server.service.spec.ts | 2 +- server/src/services/server.service.ts | 2 +- server/src/services/session.service.ts | 3 +- .../src/services/shared-link.service.spec.ts | 2 +- server/src/services/shared-link.service.ts | 5 +- .../src/services/storage-template.service.ts | 3 +- server/src/services/sync.service.ts | 4 +- .../services/system-config.service.spec.ts | 2 +- .../services/system-metadata.service.spec.ts | 2 +- .../src/services/system-metadata.service.ts | 2 +- server/src/services/timeline.service.ts | 3 +- server/src/services/trash.service.ts | 3 +- .../src/services/user-admin.service.spec.ts | 2 +- server/src/services/user-admin.service.ts | 3 +- server/src/services/user.service.spec.ts | 2 +- server/src/services/user.service.ts | 3 +- server/src/services/version.service.spec.ts | 2 +- server/src/services/version.service.ts | 3 +- server/src/subscribers/audit.subscriber.ts | 3 +- server/src/utils/asset.util.ts | 3 +- server/src/utils/mime-types.ts | 2 +- server/src/utils/preferences.ts | 3 +- server/test/fixtures/album.stub.ts | 4 +- server/test/fixtures/asset.stub.ts | 3 +- server/test/fixtures/audit.stub.ts | 3 +- server/test/fixtures/memory.stub.ts | 3 +- server/test/fixtures/shared-link.stub.ts | 5 +- server/test/fixtures/user.stub.ts | 2 +- 82 files changed, 242 insertions(+), 207 deletions(-) create mode 100644 server/src/enum.ts diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts index e857e9b5cc..aba13e5acf 100644 --- a/server/src/cores/access.core.ts +++ b/server/src/cores/access.core.ts @@ -1,53 +1,10 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AlbumUserRole } from 'src/entities/album-user.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { AlbumUserRole, Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { setDifference, setIsEqual, setUnion } from 'src/utils/set'; -export enum Permission { - ACTIVITY_CREATE = 'activity.create', - ACTIVITY_DELETE = 'activity.delete', - - // ASSET_CREATE = 'asset.create', - ASSET_READ = 'asset.read', - ASSET_UPDATE = 'asset.update', - ASSET_DELETE = 'asset.delete', - ASSET_RESTORE = 'asset.restore', - ASSET_SHARE = 'asset.share', - ASSET_VIEW = 'asset.view', - ASSET_DOWNLOAD = 'asset.download', - ASSET_UPLOAD = 'asset.upload', - - // ALBUM_CREATE = 'album.create', - ALBUM_READ = 'album.read', - ALBUM_UPDATE = 'album.update', - ALBUM_DELETE = 'album.delete', - ALBUM_ADD_ASSET = 'album.addAsset', - ALBUM_REMOVE_ASSET = 'album.removeAsset', - ALBUM_SHARE = 'album.share', - ALBUM_DOWNLOAD = 'album.download', - - AUTH_DEVICE_DELETE = 'authDevice.delete', - - ARCHIVE_READ = 'archive.read', - - TIMELINE_READ = 'timeline.read', - TIMELINE_DOWNLOAD = 'timeline.download', - - MEMORY_READ = 'memory.read', - MEMORY_WRITE = 'memory.write', - MEMORY_DELETE = 'memory.delete', - - PERSON_READ = 'person.read', - PERSON_WRITE = 'person.write', - PERSON_MERGE = 'person.merge', - PERSON_CREATE = 'person.create', - PERSON_REASSIGN = 'person.reassign', - - PARTNER_UPDATE = 'partner.update', -} - let instance: AccessCore | null; export class AccessCore { diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index 10fdb45637..7c1434004a 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -7,7 +7,7 @@ import * as _ from 'lodash'; import { Subject } from 'rxjs'; import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { SystemMetadataKey } from 'src/enum'; import { DatabaseLock } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 21eb649e11..8f5c996cae 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -5,8 +5,8 @@ import _ from 'lodash'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; -import { AlbumUserRole } from 'src/entities/album-user.entity'; -import { AlbumEntity, AssetOrder } from 'src/entities/album.entity'; +import { AlbumEntity } from 'src/entities/album.entity'; +import { AlbumUserRole, AssetOrder } from 'src/enum'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class AlbumInfoDto { diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 03fa2f8b3d..4238fd3490 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -11,8 +11,9 @@ import { import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; +import { AssetType } from 'src/enum'; import { mimeTypes } from 'src/utils/mime-types'; export class SanitizedAssetResponseDto { diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 8b438992d3..9bc007543a 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -14,7 +14,7 @@ import { ValidateIf, } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetType } from 'src/entities/asset.entity'; +import { AssetType } from 'src/enum'; import { AssetStats } from 'src/interfaces/asset.interface'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; diff --git a/server/src/dtos/audit.dto.ts b/server/src/dtos/audit.dto.ts index e83efca768..dcace5a551 100644 --- a/server/src/dtos/audit.dto.ts +++ b/server/src/dtos/audit.dto.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator'; -import { EntityType } from 'src/entities/audit.entity'; import { AssetPathType, PathType, PersonPathType, UserPathType } from 'src/entities/move.entity'; +import { EntityType } from 'src/enum'; import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType }); diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index c9db4b04e0..5d2e13a9ad 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -2,7 +2,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; -import { MemoryEntity, MemoryType } from 'src/entities/memory.entity'; +import { MemoryEntity } from 'src/entities/memory.entity'; +import { MemoryType } from 'src/enum'; import { ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; class MemoryBaseDto { diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index b81321b873..9e36cfee80 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -4,9 +4,8 @@ import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { PropertyLifecycle } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { AssetOrder } from 'src/entities/album.entity'; -import { AssetType } from 'src/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; +import { AssetOrder, AssetType } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; class BaseSearchDto { diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index 9a90901d27..b97791db58 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -3,7 +3,8 @@ import { IsEnum, IsString } from 'class-validator'; import _ from 'lodash'; import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; -import { SharedLinkEntity, SharedLinkType } from 'src/entities/shared-link.entity'; +import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { SharedLinkType } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; export class SharedLinkCreateDto { diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index a551260136..8803f24fc4 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { AssetOrder } from 'src/entities/album.entity'; +import { AssetOrder } from 'src/enum'; import { TimeBucketSize } from 'src/interfaces/asset.interface'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 3305e1cce1..c3b2c051af 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsDateString, IsEnum, IsInt, IsPositive, ValidateNested } from 'class-validator'; -import { UserAvatarColor, UserPreferences } from 'src/entities/user-metadata.entity'; +import { UserPreferences } from 'src/entities/user-metadata.entity'; +import { UserAvatarColor } from 'src/enum'; import { Optional, ValidateBoolean } from 'src/validation'; class AvatarUpdate { diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 54020a7397..f7cd70ee74 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -1,8 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator'; -import { UserAvatarColor, UserMetadataEntity, UserMetadataKey } from 'src/entities/user-metadata.entity'; -import { UserEntity, UserStatus } from 'src/entities/user.entity'; +import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; +import { UserEntity } from 'src/entities/user.entity'; +import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { getPreferences } from 'src/utils/preferences'; import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; diff --git a/server/src/entities/album-user.entity.ts b/server/src/entities/album-user.entity.ts index 66ed58c4f1..e75b3cd43e 100644 --- a/server/src/entities/album-user.entity.ts +++ b/server/src/entities/album-user.entity.ts @@ -1,12 +1,8 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { AlbumUserRole } from 'src/enum'; import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; -export enum AlbumUserRole { - EDITOR = 'editor', - VIEWER = 'viewer', -} - @Entity('albums_shared_users_users') // Pre-existing indices from original album <--> user ManyToMany mapping @Index('IDX_427c350ad49bd3935a50baab73', ['album']) diff --git a/server/src/entities/album.entity.ts b/server/src/entities/album.entity.ts index 39d5b72bf2..e5d2c98814 100644 --- a/server/src/entities/album.entity.ts +++ b/server/src/entities/album.entity.ts @@ -2,6 +2,7 @@ import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { AssetOrder } from 'src/enum'; import { Column, CreateDateColumn, @@ -15,12 +16,6 @@ import { UpdateDateColumn, } from 'typeorm'; -// ran into issues when importing the enum from `asset.dto.ts` -export enum AssetOrder { - ASC = 'asc', - DESC = 'desc', -} - @Entity('albums') export class AlbumEntity { @PrimaryGeneratedColumn('uuid') diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index ca486fb471..f4ea5eafdd 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -9,6 +9,7 @@ import { SmartSearchEntity } from 'src/entities/smart-search.entity'; import { StackEntity } from 'src/entities/stack.entity'; import { TagEntity } from 'src/entities/tag.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { AssetType } from 'src/enum'; import { Column, CreateDateColumn, @@ -175,10 +176,3 @@ export class AssetEntity { @Column({ type: 'uuid', nullable: true }) duplicateId!: string | null; } - -export enum AssetType { - IMAGE = 'IMAGE', - VIDEO = 'VIDEO', - AUDIO = 'AUDIO', - OTHER = 'OTHER', -} diff --git a/server/src/entities/audit.entity.ts b/server/src/entities/audit.entity.ts index be5e14891c..7f51e17585 100644 --- a/server/src/entities/audit.entity.ts +++ b/server/src/entities/audit.entity.ts @@ -1,16 +1,6 @@ +import { DatabaseAction, EntityType } from 'src/enum'; import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -export enum DatabaseAction { - CREATE = 'CREATE', - UPDATE = 'UPDATE', - DELETE = 'DELETE', -} - -export enum EntityType { - ASSET = 'ASSET', - ALBUM = 'ALBUM', -} - @Entity('audit') @Index('IDX_ownerId_createdAt', ['ownerId', 'createdAt']) export class AuditEntity { diff --git a/server/src/entities/memory.entity.ts b/server/src/entities/memory.entity.ts index d7dcff4b80..c8121dd32e 100644 --- a/server/src/entities/memory.entity.ts +++ b/server/src/entities/memory.entity.ts @@ -1,5 +1,6 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { MemoryType } from 'src/enum'; import { Column, CreateDateColumn, @@ -12,11 +13,6 @@ import { UpdateDateColumn, } from 'typeorm'; -export enum MemoryType { - /** pictures taken on this day X years ago */ - ON_THIS_DAY = 'on_this_day', -} - export type OnThisDayData = { year: number }; export interface MemoryData { diff --git a/server/src/entities/shared-link.entity.ts b/server/src/entities/shared-link.entity.ts index f328192f7f..1fed44b301 100644 --- a/server/src/entities/shared-link.entity.ts +++ b/server/src/entities/shared-link.entity.ts @@ -1,6 +1,7 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { SharedLinkType } from 'src/enum'; import { Column, CreateDateColumn, @@ -62,13 +63,3 @@ export class SharedLinkEntity { @Column({ type: 'varchar', nullable: true }) albumId!: string | null; } - -export enum SharedLinkType { - ALBUM = 'ALBUM', - - /** - * Individual asset - * or group of assets that are not in an album - */ - INDIVIDUAL = 'INDIVIDUAL', -} diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index 72aca4c72b..ae01c47b84 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -1,4 +1,5 @@ import { SystemConfig } from 'src/config'; +import { SystemMetadataKey } from 'src/enum'; import { Column, DeepPartial, Entity, PrimaryColumn } from 'typeorm'; @Entity('system_metadata') @@ -10,15 +11,6 @@ export class SystemMetadataEntity> { diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index 73eb9e04aa..2dcb570935 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -1,4 +1,5 @@ import { UserEntity } from 'src/entities/user.entity'; +import { UserAvatarColor, UserMetadataKey } from 'src/enum'; import { HumanReadableSize } from 'src/utils/bytes'; import { Column, DeepPartial, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; @@ -17,19 +18,6 @@ export class UserMetadataEntity value!: UserMetadata[T]; } -export enum UserAvatarColor { - PRIMARY = 'primary', - PINK = 'pink', - RED = 'red', - YELLOW = 'yellow', - BLUE = 'blue', - GREEN = 'green', - PURPLE = 'purple', - ORANGE = 'orange', - GRAY = 'gray', - AMBER = 'amber', -} - export interface UserPreferences { rating: { enabled: boolean; @@ -85,11 +73,6 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences }; }; -export enum UserMetadataKey { - PREFERENCES = 'preferences', - LICENSE = 'license', -} - export interface UserMetadata extends Record> { [UserMetadataKey.PREFERENCES]: DeepPartial; [UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date }; diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index 6878292ab0..9cacad315b 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -1,6 +1,7 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { TagEntity } from 'src/entities/tag.entity'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; +import { UserStatus } from 'src/enum'; import { Column, CreateDateColumn, @@ -11,12 +12,6 @@ import { UpdateDateColumn, } from 'typeorm'; -export enum UserStatus { - ACTIVE = 'active', - REMOVING = 'removing', - DELETED = 'deleted', -} - @Entity('users') export class UserEntity { @PrimaryGeneratedColumn('uuid') diff --git a/server/src/enum.ts b/server/src/enum.ts new file mode 100644 index 0000000000..04f59e5a98 --- /dev/null +++ b/server/src/enum.ts @@ -0,0 +1,118 @@ +export enum AssetType { + IMAGE = 'IMAGE', + VIDEO = 'VIDEO', + AUDIO = 'AUDIO', + OTHER = 'OTHER', +} + +export enum AlbumUserRole { + EDITOR = 'editor', + VIEWER = 'viewer', +} + +export enum AssetOrder { + ASC = 'asc', + DESC = 'desc', +} + +export enum DatabaseAction { + CREATE = 'CREATE', + UPDATE = 'UPDATE', + DELETE = 'DELETE', +} + +export enum EntityType { + ASSET = 'ASSET', + ALBUM = 'ALBUM', +} + +export enum MemoryType { + /** pictures taken on this day X years ago */ + ON_THIS_DAY = 'on_this_day', +} + +export enum Permission { + ACTIVITY_CREATE = 'activity.create', + ACTIVITY_DELETE = 'activity.delete', + + // ASSET_CREATE = 'asset.create', + ASSET_READ = 'asset.read', + ASSET_UPDATE = 'asset.update', + ASSET_DELETE = 'asset.delete', + ASSET_RESTORE = 'asset.restore', + ASSET_SHARE = 'asset.share', + ASSET_VIEW = 'asset.view', + ASSET_DOWNLOAD = 'asset.download', + ASSET_UPLOAD = 'asset.upload', + + // ALBUM_CREATE = 'album.create', + ALBUM_READ = 'album.read', + ALBUM_UPDATE = 'album.update', + ALBUM_DELETE = 'album.delete', + ALBUM_ADD_ASSET = 'album.addAsset', + ALBUM_REMOVE_ASSET = 'album.removeAsset', + ALBUM_SHARE = 'album.share', + ALBUM_DOWNLOAD = 'album.download', + + AUTH_DEVICE_DELETE = 'authDevice.delete', + + ARCHIVE_READ = 'archive.read', + + TIMELINE_READ = 'timeline.read', + TIMELINE_DOWNLOAD = 'timeline.download', + + MEMORY_READ = 'memory.read', + MEMORY_WRITE = 'memory.write', + MEMORY_DELETE = 'memory.delete', + + PERSON_READ = 'person.read', + PERSON_WRITE = 'person.write', + PERSON_MERGE = 'person.merge', + PERSON_CREATE = 'person.create', + PERSON_REASSIGN = 'person.reassign', + + PARTNER_UPDATE = 'partner.update', +} + +export enum SharedLinkType { + ALBUM = 'ALBUM', + + /** + * Individual asset + * or group of assets that are not in an album + */ + INDIVIDUAL = 'INDIVIDUAL', +} + +export enum SystemMetadataKey { + REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', + FACIAL_RECOGNITION_STATE = 'facial-recognition-state', + ADMIN_ONBOARDING = 'admin-onboarding', + SYSTEM_CONFIG = 'system-config', + VERSION_CHECK_STATE = 'version-check-state', + LICENSE = 'license', +} + +export enum UserMetadataKey { + PREFERENCES = 'preferences', + LICENSE = 'license', +} + +export enum UserAvatarColor { + PRIMARY = 'primary', + PINK = 'pink', + RED = 'red', + YELLOW = 'yellow', + BLUE = 'blue', + GREEN = 'green', + PURPLE = 'purple', + ORANGE = 'orange', + GRAY = 'gray', + AMBER = 'amber', +} + +export enum UserStatus { + ACTIVE = 'active', + REMOVING = 'removing', + DELETED = 'deleted', +} diff --git a/server/src/interfaces/access.interface.ts b/server/src/interfaces/access.interface.ts index 6b408c263e..cf5ebbd005 100644 --- a/server/src/interfaces/access.interface.ts +++ b/server/src/interfaces/access.interface.ts @@ -1,4 +1,4 @@ -import { AlbumUserRole } from 'src/entities/album-user.entity'; +import { AlbumUserRole } from 'src/enum'; export const IAccessRepository = 'IAccessRepository'; diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 37115a6e3a..aca45f3dc7 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -1,7 +1,7 @@ -import { AssetOrder } from 'src/entities/album.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; +import { AssetOrder, AssetType } from 'src/enum'; import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindOptionsOrder, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; diff --git a/server/src/interfaces/audit.interface.ts b/server/src/interfaces/audit.interface.ts index b023d00d56..0b9f19d8db 100644 --- a/server/src/interfaces/audit.interface.ts +++ b/server/src/interfaces/audit.interface.ts @@ -1,4 +1,4 @@ -import { DatabaseAction, EntityType } from 'src/entities/audit.entity'; +import { DatabaseAction, EntityType } from 'src/enum'; export const IAuditRepository = 'IAuditRepository'; diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index d77cd62cd1..0226e3663c 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -1,6 +1,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; +import { AssetType } from 'src/enum'; import { Paginated } from 'src/utils/pagination'; export const ISearchRepository = 'ISearchRepository'; diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 9dd294cc21..438424ab78 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { ActivityEntity } from 'src/entities/activity.entity'; -import { AlbumUserRole } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -12,6 +11,7 @@ import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { AlbumUserRole } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { Instrumentation } from 'src/utils/instrumentation'; import { Brackets, In, Repository } from 'typeorm'; diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index cc9fac4652..1029b8d8da 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; -import { AssetOrder } from 'src/entities/album.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; +import { AssetOrder, AssetType } from 'src/enum'; import { AssetBuilderOptions, AssetCreate, diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 80b3fd7854..555f1042bb 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -8,7 +8,7 @@ import { citiesFile, resourcePaths } from 'src/constants'; import { AssetEntity } from 'src/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { NaturalEarthCountriesEntity } from 'src/entities/natural-earth-countries.entity'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { SystemMetadataKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { GeoPoint, diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 9abe62a12d..40f87ddf24 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -3,10 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm'; import { getVectorExtension } from 'src/database.config'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity'; +import { AssetType } from 'src/enum'; import { DatabaseExtension } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { diff --git a/server/src/services/activity.service.ts b/server/src/services/activity.service.ts index 7589fb8ccc..c1b2e1b4d0 100644 --- a/server/src/services/activity.service.ts +++ b/server/src/services/activity.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { ActivityCreateDto, ActivityDto, @@ -13,6 +13,7 @@ import { } from 'src/dtos/activity.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { ActivityEntity } from 'src/entities/activity.entity'; +import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IActivityRepository } from 'src/interfaces/activity.interface'; diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 56ea787be9..41f8930733 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import _ from 'lodash'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { AlbumUserRole } from 'src/entities/album-user.entity'; +import { AlbumUserRole } from 'src/enum'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 9cd750e6b1..f8108ad065 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { AddUsersDto, AlbumCountResponseDto, @@ -17,6 +17,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; +import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 3990b4c3de..978f98cf10 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -2,7 +2,8 @@ import { BadRequestException, NotFoundException, UnauthorizedException } from '@ import { Stats } from 'node:fs'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto'; -import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; +import { AssetType } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 8895e1c369..b8a43b34ec 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common'; import { extname } from 'node:path'; import sanitize from 'sanitize-filename'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { AssetBulkUploadCheckResponseDto, @@ -27,7 +27,8 @@ import { UploadFieldName, } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; +import { AssetType, Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 3385427c29..95a80ab4da 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -1,7 +1,8 @@ import { BadRequestException } from '@nestjs/common'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { AssetType } from 'src/enum'; import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index a34349498b..bbbc2bb407 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject } from '@nestjs/common'; import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetResponseDto, @@ -22,6 +22,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { AssetEntity } from 'src/entities/asset.entity'; +import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; diff --git a/server/src/services/audit.service.spec.ts b/server/src/services/audit.service.spec.ts index 8557677f92..ef685f4a87 100644 --- a/server/src/services/audit.service.spec.ts +++ b/server/src/services/audit.service.spec.ts @@ -1,4 +1,4 @@ -import { DatabaseAction, EntityType } from 'src/entities/audit.entity'; +import { DatabaseAction, EntityType } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index bfff09c0bc..225bd11061 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -2,7 +2,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { resolve } from 'node:path'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { AuditDeletesDto, @@ -13,8 +13,8 @@ import { PathEntityType, } from 'src/dtos/audit.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DatabaseAction } from 'src/entities/audit.entity'; import { AssetPathType, PersonPathType, UserPathType } from 'src/entities/move.entity'; +import { DatabaseAction, Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 11e4de83d9..157142d906 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -1,10 +1,11 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { parse } from 'node:path'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; import { AssetEntity } from 'src/entities/asset.entity'; +import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index f232c4ac77..aa84ef4f40 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -3,7 +3,7 @@ import { snakeCase } from 'lodash'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto'; -import { AssetType } from 'src/entities/asset.entity'; +import { AssetType } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 4aad2d3d58..7f81fd44aa 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -3,8 +3,8 @@ import { Stats } from 'node:fs'; import { SystemConfig } from 'src/config'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { mapLibrary } from 'src/dtos/library.dto'; -import { AssetType } from 'src/entities/asset.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { AssetType } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 6cded14775..f0d7fe8cd4 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -17,8 +17,8 @@ import { ValidateLibraryResponseDto, mapLibrary, } from 'src/dtos/library.dto'; -import { AssetType } from 'src/entities/asset.entity'; import { LibraryEntity } from 'src/entities/library.entity'; +import { AssetType } from 'src/enum'; import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 7bb201f78f..d9d5948cea 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -8,8 +8,8 @@ import { TranscodePolicy, VideoCodec, } from 'src/config'; -import { AssetType } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; +import { AssetType } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 9d5b4ed858..5264da9fe9 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -13,8 +13,9 @@ import { import { GeneratedImageType, StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/entities/move.entity'; +import { AssetType } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index cee3113f00..ba184daa80 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { MemoryType } from 'src/entities/memory.entity'; +import { MemoryType } from 'src/enum'; import { IMemoryRepository } from 'src/interfaces/memory.interface'; import { MemoryService } from 'src/services/memory.service'; import { authStub } from 'test/fixtures/auth.stub'; diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 0164dd0b96..02fdacc355 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -1,9 +1,10 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; import { AssetEntity } from 'src/entities/asset.entity'; +import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IMemoryRepository } from 'src/interfaces/memory.interface'; import { addAssets, removeAssets } from 'src/utils/asset.util'; diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 3adae86377..522e1320fd 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -2,8 +2,8 @@ import { BinaryField } from 'exiftool-vendored'; import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; -import { AssetType } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; +import { AssetType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 7e940744e7..041b35c02c 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -8,8 +8,9 @@ import path from 'node:path'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; +import { AssetType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 293cc11657..f10c79c579 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -1,6 +1,6 @@ import { defaults, SystemConfig } from 'src/config'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; -import { UserMetadataKey } from 'src/entities/user-metadata.entity'; +import { UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index d26149dceb..c20d43db5d 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -1,9 +1,10 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto'; import { mapUser } from 'src/dtos/user.dto'; import { PartnerEntity } from 'src/entities/partner.entity'; +import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IPartnerRepository, PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface'; diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 8a2e88b276..70e043cc7f 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -3,7 +3,7 @@ import { Colorspace } from 'src/config'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { SystemMetadataKey } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 261c771b0d..8ffae5bf05 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ImageFormat } from 'src/config'; import { FACE_THUMBNAIL_SIZE } from 'src/constants'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; @@ -23,10 +23,10 @@ import { mapPerson, } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { PersonPathType } from 'src/entities/move.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { AssetType, Permission, SystemMetadataKey } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 1d746a03d8..6af42ac1f3 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -14,8 +14,8 @@ import { SmartSearchDto, mapPlaces, } from 'src/dtos/search.dto'; -import { AssetOrder } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; +import { AssetOrder } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 6c7ef03627..799ec2c5a3 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -1,4 +1,4 @@ -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { SystemMetadataKey } from 'src/enum'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 22196c4e26..67e19eda78 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -14,7 +14,7 @@ import { ServerStorageResponseDto, UsageByUserDto, } from 'src/dtos/server.dto'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { SystemMetadataKey } from 'src/enum'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { OnEvents } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index f72bf194c1..01cf3a5c09 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -1,8 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; +import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index f0b42b0153..0fd47b612e 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -2,7 +2,7 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from ' import _ from 'lodash'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { SharedLinkType } from 'src/entities/shared-link.entity'; +import { SharedLinkType } from 'src/enum'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 773e42ce8c..4b6768e028 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; @@ -14,7 +14,8 @@ import { mapSharedLinkWithoutMetadata, } from 'src/dtos/shared-link.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { SharedLinkEntity, SharedLinkType } from 'src/entities/shared-link.entity'; +import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { Permission, SharedLinkType } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index e067252553..599f5e10a5 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -15,8 +15,9 @@ import { } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/entities/move.entity'; +import { AssetType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 1a7a74d699..6af43d6ebc 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,11 +1,11 @@ import { Inject } from '@nestjs/common'; import { DateTime } from 'luxon'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto'; -import { DatabaseAction, EntityType } from 'src/entities/audit.entity'; +import { DatabaseAction, EntityType, Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index a3b0011d0c..bb0e706d61 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -13,7 +13,7 @@ import { VideoContainer, defaults, } from 'src/config'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { SystemMetadataKey } from 'src/enum'; import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; diff --git a/server/src/services/system-metadata.service.spec.ts b/server/src/services/system-metadata.service.spec.ts index 9d11c1c72a..5799ee859d 100644 --- a/server/src/services/system-metadata.service.spec.ts +++ b/server/src/services/system-metadata.service.spec.ts @@ -1,4 +1,4 @@ -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { SystemMetadataKey } from 'src/enum'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SystemMetadataService } from 'src/services/system-metadata.service'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; diff --git a/server/src/services/system-metadata.service.ts b/server/src/services/system-metadata.service.ts index e8fddfc13c..c2c9a4fdfc 100644 --- a/server/src/services/system-metadata.service.ts +++ b/server/src/services/system-metadata.service.ts @@ -4,7 +4,7 @@ import { AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto, } from 'src/dtos/system-metadata.dto'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { SystemMetadataKey } from 'src/enum'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @Injectable() diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index b82a16f139..44f1136da1 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,8 +1,9 @@ import { BadRequestException, Inject } from '@nestjs/common'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; +import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index 0c64332941..7e2582fd24 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -1,8 +1,9 @@ import { Inject } from '@nestjs/common'; import { DateTime } from 'luxon'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index 2479b9826d..8e80aa4dc1 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mapUserAdmin } from 'src/dtos/user.dto'; -import { UserStatus } from 'src/entities/user.entity'; +import { UserStatus } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index ba829947dc..76ae3dd23a 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -11,8 +11,7 @@ import { UserAdminUpdateDto, mapUserAdmin, } from 'src/dtos/user.dto'; -import { UserMetadataKey } from 'src/entities/user-metadata.entity'; -import { UserStatus } from 'src/entities/user.entity'; +import { UserMetadataKey, UserStatus } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 007b56b212..0ac0ea6dbc 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { UserMetadataKey } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 03aee5c00b..92404a6958 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -9,8 +9,9 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; -import { UserMetadataEntity, UserMetadataKey } from 'src/entities/user-metadata.entity'; +import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 74489e04ea..02dfe7588f 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -1,6 +1,6 @@ import { DateTime } from 'luxon'; import { serverVersion } from 'src/constants'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { SystemMetadataKey } from 'src/enum'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 8408e53bfe..42e2b50ab5 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -5,7 +5,8 @@ import { isDev, serverVersion } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnServerEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; -import { SystemMetadataKey, VersionCheckMetadata } from 'src/entities/system-metadata.entity'; +import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; +import { SystemMetadataKey } from 'src/enum'; import { ClientEvent, IEventRepository, OnEvents, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; diff --git a/server/src/subscribers/audit.subscriber.ts b/server/src/subscribers/audit.subscriber.ts index 3d65507aec..8c2ad3e18d 100644 --- a/server/src/subscribers/audit.subscriber.ts +++ b/server/src/subscribers/audit.subscriber.ts @@ -1,6 +1,7 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AuditEntity, DatabaseAction, EntityType } from 'src/entities/audit.entity'; +import { AuditEntity } from 'src/entities/audit.entity'; +import { DatabaseAction, EntityType } from 'src/enum'; import { EntitySubscriberInterface, EventSubscriber, RemoveEvent } from 'typeorm'; @EventSubscriber() diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 76a8dc06b0..aa77a0b144 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,6 +1,7 @@ -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 495efc9ebc..6b59d2cd41 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -1,5 +1,5 @@ import { extname } from 'node:path'; -import { AssetType } from 'src/entities/asset.entity'; +import { AssetType } from 'src/enum'; const raw: Record = { '.3fr': ['image/3fr', 'image/x-hasselblad-3fr'], diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index f3561fa7b6..beaeb472ec 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -1,7 +1,8 @@ import _ from 'lodash'; import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; -import { UserMetadataKey, UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; +import { UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { UserMetadataKey } from 'src/enum'; import { getKeysDeep } from 'src/utils/misc'; import { DeepPartial } from 'typeorm'; diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index 4105b01978..c2c59a8007 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -1,5 +1,5 @@ -import { AlbumUserRole } from 'src/entities/album-user.entity'; -import { AlbumEntity, AssetOrder } from 'src/entities/album.entity'; +import { AlbumEntity } from 'src/entities/album.entity'; +import { AlbumUserRole, AssetOrder } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index aa141a9964..23df5e4f56 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -1,6 +1,7 @@ -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { StackEntity } from 'src/entities/stack.entity'; +import { AssetType } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { libraryStub } from 'test/fixtures/library.stub'; diff --git a/server/test/fixtures/audit.stub.ts b/server/test/fixtures/audit.stub.ts index bca1d33491..3e79a60819 100644 --- a/server/test/fixtures/audit.stub.ts +++ b/server/test/fixtures/audit.stub.ts @@ -1,4 +1,5 @@ -import { AuditEntity, DatabaseAction, EntityType } from 'src/entities/audit.entity'; +import { AuditEntity } from 'src/entities/audit.entity'; +import { DatabaseAction, EntityType } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; export const auditStub = { diff --git a/server/test/fixtures/memory.stub.ts b/server/test/fixtures/memory.stub.ts index bb84a8f1df..50872d8ac1 100644 --- a/server/test/fixtures/memory.stub.ts +++ b/server/test/fixtures/memory.stub.ts @@ -1,4 +1,5 @@ -import { MemoryEntity, MemoryType } from 'src/entities/memory.entity'; +import { MemoryEntity } from 'src/entities/memory.entity'; +import { MemoryType } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 1120e15e94..1635f8d24f 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -3,10 +3,9 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { ExifResponseDto } from 'src/dtos/exif.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; import { mapUser } from 'src/dtos/user.dto'; -import { AssetOrder } from 'src/entities/album.entity'; -import { AssetType } from 'src/entities/asset.entity'; -import { SharedLinkEntity, SharedLinkType } from 'src/entities/shared-link.entity'; +import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { AssetOrder, AssetType, SharedLinkType } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index cb82dfe26c..6f3a819eef 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -1,5 +1,5 @@ -import { UserAvatarColor, UserMetadataKey } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { UserAvatarColor, UserMetadataKey } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; export const userDto = { From a4506758aa6bfa1e9f80e500e4a77516584e45ae Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 Aug 2024 09:14:23 -0400 Subject: [PATCH 103/723] refactor: auth service (#11811) --- e2e/src/api/specs/api-key.e2e-spec.ts | 206 +++++++++++++++++++ open-api/immich-openapi-specs.json | 2 +- server/src/controllers/api-key.controller.ts | 3 +- server/src/dtos/auth.dto.ts | 2 + server/src/middleware/auth.guard.ts | 18 +- server/src/repositories/event.repository.ts | 6 +- server/src/services/auth.service.spec.ts | 124 ++++++++--- server/src/services/auth.service.ts | 37 +++- 8 files changed, 352 insertions(+), 46 deletions(-) create mode 100644 e2e/src/api/specs/api-key.e2e-spec.ts diff --git a/e2e/src/api/specs/api-key.e2e-spec.ts b/e2e/src/api/specs/api-key.e2e-spec.ts new file mode 100644 index 0000000000..32d18f612d --- /dev/null +++ b/e2e/src/api/specs/api-key.e2e-spec.ts @@ -0,0 +1,206 @@ +import { LoginResponseDto, createApiKey } from '@immich/sdk'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const create = (accessToken: string) => + createApiKey({ apiKeyCreateDto: { name: 'api key' } }, { headers: asBearerAuth(accessToken) }); + +describe('/api-keys', () => { + let admin: LoginResponseDto; + let user: LoginResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + + admin = await utils.adminSetup(); + user = await utils.userSetup(admin.accessToken, createUserDto.user1); + }); + + beforeEach(async () => { + await utils.resetDatabase(['api_keys']); + }); + + describe('POST /api-keys', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/api-keys').send({ name: 'API Key' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should create an api key', async () => { + const { status, body } = await request(app) + .post('/api-keys') + .send({ name: 'API Key' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual({ + apiKey: { + id: expect.any(String), + name: 'API Key', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + secret: expect.any(String), + }); + expect(status).toEqual(201); + }); + }); + + describe('GET /api-keys', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/api-keys'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should start off empty', async () => { + const { status, body } = await request(app).get('/api-keys').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([]); + expect(status).toEqual(200); + }); + + it('should return a list of api keys', async () => { + const [{ apiKey: apiKey1 }, { apiKey: apiKey2 }, { apiKey: apiKey3 }] = await Promise.all([ + create(admin.accessToken), + create(admin.accessToken), + create(admin.accessToken), + ]); + const { status, body } = await request(app).get('/api-keys').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toHaveLength(3); + expect(body).toEqual(expect.arrayContaining([apiKey1, apiKey2, apiKey3])); + expect(status).toEqual(200); + }); + }); + + describe('GET /api-keys/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get(`/api-keys/${uuidDto.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const { apiKey } = await create(user.accessToken); + const { status, body } = await request(app) + .get(`/api-keys/${apiKey.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('API Key not found')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .get(`/api-keys/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should get api key details', async () => { + const { apiKey } = await create(user.accessToken); + const { status, body } = await request(app) + .get(`/api-keys/${apiKey.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + name: 'api key', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + }); + + describe('PUT /api-keys/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/api-keys/${uuidDto.notFound}`).send({ name: 'new name' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const { apiKey } = await create(user.accessToken); + const { status, body } = await request(app) + .put(`/api-keys/${apiKey.id}`) + .send({ name: 'new name' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('API Key not found')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .put(`/api-keys/${uuidDto.invalid}`) + .send({ name: 'new name' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should update api key details', async () => { + const { apiKey } = await create(user.accessToken); + const { status, body } = await request(app) + .put(`/api-keys/${apiKey.id}`) + .send({ name: 'new name' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + name: 'new name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + }); + + describe('DELETE /api-keys/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).delete(`/api-keys/${uuidDto.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const { apiKey } = await create(user.accessToken); + const { status, body } = await request(app) + .delete(`/api-keys/${apiKey.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('API Key not found')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .delete(`/api-keys/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should delete an api key', async () => { + const { apiKey } = await create(user.accessToken); + const { status } = await request(app) + .delete(`/api-keys/${apiKey.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + }); + }); + + describe('authentication', () => { + it('should work as a header', async () => { + const { secret } = await create(admin.accessToken); + const { status, body } = await request(app).get('/api-keys').set('x-api-key', secret); + expect(body).toHaveLength(1); + expect(status).toBe(200); + }); + + it('should work as a query param', async () => { + const { secret } = await create(admin.accessToken); + const { status, body } = await request(app).get(`/api-keys?apiKey=${secret}`); + expect(body).toHaveLength(1); + expect(status).toBe(200); + }); + }); +}); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f2693f1913..aa0d9fa2bb 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1185,7 +1185,7 @@ } ], "responses": { - "200": { + "204": { "description": "" } }, diff --git a/server/src/controllers/api-key.controller.ts b/server/src/controllers/api-key.controller.ts index 54144e78d5..feba7cccbb 100644 --- a/server/src/controllers/api-key.controller.ts +++ b/server/src/controllers/api-key.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -40,6 +40,7 @@ export class APIKeyController { } @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) @Authenticated() deleteApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 6488901fb6..f2d5bd2324 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -25,6 +25,8 @@ export enum ImmichHeader { export enum ImmichQuery { SHARED_LINK_KEY = 'key', + API_KEY = 'apiKey', + SESSION_KEY = 'sessionKey', } export type CookieResponse = { diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index bac25d80ed..c4aa928dbd 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -89,20 +89,14 @@ export class AuthGuard implements CanActivate { return true; } + const { admin: adminRoute, sharedLink: sharedLinkRoute } = { sharedLink: false, admin: false, ...options }; const request = context.switchToHttp().getRequest(); - const authDto = await this.authService.validate(request.headers, request.query as Record); - if (authDto.sharedLink && !(options as SharedLinkRoute).sharedLink) { - this.logger.warn(`Denied access to non-shared route: ${request.path}`); - return false; - } - - if (!authDto.user.isAdmin && (options as AdminRoute).admin) { - this.logger.warn(`Denied access to admin only route: ${request.path}`); - return false; - } - - request.user = authDto; + request.user = await this.authService.authenticate({ + headers: request.headers, + queryParams: request.query as Record, + metadata: { adminRoute, sharedLinkRoute, uri: request.path }, + }); return true; } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index aecc9d7239..0bb973b293 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -59,7 +59,11 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect async handleConnection(client: Socket) { try { this.logger.log(`Websocket Connect: ${client.id}`); - const auth = await this.authService.validate(client.request.headers, {}); + const auth = await this.authService.authenticate({ + headers: client.request.headers, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' }, + }); await client.join(auth.user.id); this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id }); } catch (error: Error | any) { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 7aa03e6bdd..ed73c5aa00 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -1,7 +1,5 @@ -import { BadRequestException, UnauthorizedException } from '@nestjs/common'; -import { IncomingHttpHeaders } from 'node:http'; +import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { Issuer, generators } from 'openid-client'; -import { Socket } from 'socket.io'; import { AuthType } from 'src/constants'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; @@ -252,15 +250,26 @@ describe('AuthService', () => { }); describe('validate - socket connections', () => { - it('should throw token is not provided', async () => { - await expect(sut.validate({}, {})).rejects.toBeInstanceOf(UnauthorizedException); + it('should throw when token is not provided', async () => { + await expect( + sut.authenticate({ + headers: {}, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('should validate using authorization header', async () => { userMock.get.mockResolvedValue(userStub.user1); sessionMock.getByToken.mockResolvedValue(sessionStub.valid); - const client = { request: { headers: { authorization: 'Bearer auth_token' } } }; - await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual({ + await expect( + sut.authenticate({ + headers: { authorization: 'Bearer auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).resolves.toEqual({ user: userStub.user1, session: sessionStub.valid, }); @@ -270,28 +279,48 @@ describe('AuthService', () => { describe('validate - shared key', () => { it('should not accept a non-existent key', async () => { shareMock.getByKey.mockResolvedValue(null); - const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' }; - await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); + await expect( + sut.authenticate({ + headers: { 'x-immich-share-key': 'key' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('should not accept an expired key', async () => { shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired); - const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' }; - await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); + await expect( + sut.authenticate({ + headers: { 'x-immich-share-key': 'key' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('should not accept a key without a user', async () => { shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired); userMock.get.mockResolvedValue(null); - const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' }; - await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); + await expect( + sut.authenticate({ + headers: { 'x-immich-share-key': 'key' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('should accept a base64url key', async () => { shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid); userMock.get.mockResolvedValue(userStub.admin); - const headers: IncomingHttpHeaders = { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') }; - await expect(sut.validate(headers, {})).resolves.toEqual({ + await expect( + sut.authenticate({ + headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, + }), + ).resolves.toEqual({ user: userStub.admin, sharedLink: sharedLinkStub.valid, }); @@ -301,8 +330,13 @@ describe('AuthService', () => { it('should accept a hex key', async () => { shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid); userMock.get.mockResolvedValue(userStub.admin); - const headers: IncomingHttpHeaders = { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') }; - await expect(sut.validate(headers, {})).resolves.toEqual({ + await expect( + sut.authenticate({ + headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, + }), + ).resolves.toEqual({ user: userStub.admin, sharedLink: sharedLinkStub.valid, }); @@ -313,24 +347,50 @@ describe('AuthService', () => { describe('validate - user token', () => { it('should throw if no token is found', async () => { sessionMock.getByToken.mockResolvedValue(null); - const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' }; - await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); + await expect( + sut.authenticate({ + headers: { 'x-immich-user-token': 'auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('should return an auth dto', async () => { sessionMock.getByToken.mockResolvedValue(sessionStub.valid); - const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; - await expect(sut.validate(headers, {})).resolves.toEqual({ + await expect( + sut.authenticate({ + headers: { cookie: 'immich_access_token=auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).resolves.toEqual({ user: userStub.user1, session: sessionStub.valid, }); }); + it('should throw if admin route and not an admin', async () => { + sessionMock.getByToken.mockResolvedValue(sessionStub.valid); + await expect( + sut.authenticate({ + headers: { cookie: 'immich_access_token=auth_token' }, + queryParams: {}, + metadata: { adminRoute: true, sharedLinkRoute: false, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + it('should update when access time exceeds an hour', async () => { sessionMock.getByToken.mockResolvedValue(sessionStub.inactive); sessionMock.update.mockResolvedValue(sessionStub.valid); - const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; - await expect(sut.validate(headers, {})).resolves.toBeDefined(); + await expect( + sut.authenticate({ + headers: { cookie: 'immich_access_token=auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).resolves.toBeDefined(); expect(sessionMock.update.mock.calls[0][0]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); }); }); @@ -338,15 +398,25 @@ describe('AuthService', () => { describe('validate - api key', () => { it('should throw an error if no api key is found', async () => { keyMock.getKey.mockResolvedValue(null); - const headers: IncomingHttpHeaders = { 'x-api-key': 'auth_token' }; - await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); + await expect( + sut.authenticate({ + headers: { 'x-api-key': 'auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); it('should return an auth dto', async () => { keyMock.getKey.mockResolvedValue(keyStub.admin); - const headers: IncomingHttpHeaders = { 'x-api-key': 'auth_token' }; - await expect(sut.validate(headers, {})).resolves.toEqual({ user: userStub.admin, apiKey: keyStub.admin }); + await expect( + sut.authenticate({ + headers: { 'x-api-key': 'auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).resolves.toEqual({ user: userStub.admin, apiKey: keyStub.admin }); expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index c151c10a66..0ba44601b9 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Inject, Injectable, InternalServerErrorException, @@ -19,6 +20,7 @@ import { ChangePasswordDto, ImmichCookie, ImmichHeader, + ImmichQuery, LoginCredentialDto, LogoutResponseDto, OAuthAuthorizeResponseDto, @@ -53,6 +55,16 @@ interface ClaimOptions { isValid: (value: unknown) => boolean; } +export type ValidateRequest = { + headers: IncomingHttpHeaders; + queryParams: Record; + metadata: { + sharedLinkRoute: boolean; + adminRoute: boolean; + uri: string; + }; +}; + @Injectable() export class AuthService { private configCore: SystemConfigCore; @@ -143,14 +155,31 @@ export class AuthService { return mapUserAdmin(admin); } - async validate(headers: IncomingHttpHeaders, params: Record): Promise { - const shareKey = (headers[ImmichHeader.SHARED_LINK_KEY] || params.key) as string; + async authenticate({ headers, queryParams, metadata }: ValidateRequest): Promise { + const authDto = await this.validate({ headers, queryParams }); + const { adminRoute, sharedLinkRoute, uri } = metadata; + + if (!authDto.user.isAdmin && adminRoute) { + this.logger.warn(`Denied access to admin only route: ${uri}`); + throw new ForbiddenException('Forbidden'); + } + + if (authDto.sharedLink && !sharedLinkRoute) { + this.logger.warn(`Denied access to non-shared route: ${uri}`); + throw new ForbiddenException('Forbidden'); + } + + return authDto; + } + + private async validate({ headers, queryParams }: Omit): Promise { + const shareKey = (headers[ImmichHeader.SHARED_LINK_KEY] || queryParams[ImmichQuery.SHARED_LINK_KEY]) as string; const session = (headers[ImmichHeader.USER_TOKEN] || headers[ImmichHeader.SESSION_TOKEN] || - params.sessionKey || + queryParams[ImmichQuery.SESSION_KEY] || this.getBearerToken(headers) || this.getCookieToken(headers)) as string; - const apiKey = (headers[ImmichHeader.API_KEY] || params.apiKey) as string; + const apiKey = (headers[ImmichHeader.API_KEY] || queryParams[ImmichQuery.API_KEY]) as string; if (shareKey) { return this.validateSharedLink(shareKey); From 49610de4b3b153676888b7fd6066232c0b086089 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 15 Aug 2024 11:36:43 -0500 Subject: [PATCH 104/723] chore(mobile): update target SDK version (#11719) * chore(mobile): update target SDK version * background service * remove print statements * remove extra line * format kotlin * Correct permission --- mobile/android/app/build.gradle | 2 +- .../android/app/src/main/AndroidManifest.xml | 49 +- .../example/mobile/BackgroundServicePlugin.kt | 238 +++---- .../kotlin/com/example/mobile/BackupWorker.kt | 619 +++++++++--------- .../example/mobile/ContentObserverWorker.kt | 288 ++++---- 5 files changed, 626 insertions(+), 570 deletions(-) diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index a26d055cba..52750232cc 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -46,7 +46,7 @@ android { defaultConfig { applicationId "app.alextran.immich" minSdkVersion 26 - targetSdkVersion 33 + targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 1bac79daf5..9222b38de0 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -51,23 +80,13 @@ + tools:node="remove" /> - - - - - - - - - - - - + + + @@ -79,4 +98,4 @@ - \ No newline at end of file + diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt index 6541ad5755..8520413cff 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt @@ -1,115 +1,123 @@ -package app.alextran.immich - -import android.content.Context -import android.util.Log -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import java.security.MessageDigest -import java.io.File -import java.io.FileInputStream -import kotlinx.coroutines.* - -/** - * Android plugin for Dart `BackgroundService` - * - * Receives messages/method calls from the foreground Dart side to manage - * the background service, e.g. start (enqueue), stop (cancel) - */ -class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { - - private var methodChannel: MethodChannel? = null - private var context: Context? = null - private val sha1: MessageDigest = MessageDigest.getInstance("SHA-1") - - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) - } - - private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) { - context = ctx - methodChannel = MethodChannel(messenger, "immich/foregroundChannel") - methodChannel?.setMethodCallHandler(this) - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - onDetachedFromEngine() - } - - private fun onDetachedFromEngine() { - methodChannel?.setMethodCallHandler(null) - methodChannel = null - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - val ctx = context!! - when (call.method) { - "enable" -> { - val args = call.arguments>()!! - ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) - .edit() - .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true) - .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long) - .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String) - .apply() - ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean) - result.success(true) - } - "configure" -> { - val args = call.arguments>()!! - val requireUnmeteredNetwork = args.get(0) as Boolean - val requireCharging = args.get(1) as Boolean - val triggerUpdateDelay = (args.get(2) as Number).toLong() - val triggerMaxDelay = (args.get(3) as Number).toLong() - ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging, triggerUpdateDelay, triggerMaxDelay) - result.success(true) - } - "disable" -> { - ContentObserverWorker.disable(ctx) - BackupWorker.stopWork(ctx) - result.success(true) - } - "isEnabled" -> { - result.success(ContentObserverWorker.isEnabled(ctx)) - } - "isIgnoringBatteryOptimizations" -> { - result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx)) - } - "digestFiles" -> { - val args = call.arguments>()!! - GlobalScope.launch(Dispatchers.IO) { - val buf = ByteArray(BUFSIZE) - val digest: MessageDigest = MessageDigest.getInstance("SHA-1") - val hashes = arrayOfNulls(args.size) - for (i in args.indices) { - val path = args[i] - var len = 0 - try { - val file = FileInputStream(path) - try { - while (true) { - len = file.read(buf) - if (len != BUFSIZE) break - digest.update(buf) - } - } finally { - file.close() - } - digest.update(buf, 0, len) - hashes[i] = digest.digest() - } catch (e: Exception) { - // skip this file - Log.w(TAG, "Failed to hash file ${args[i]}: $e") - } - } - result.success(hashes.asList()) - } - } - else -> result.notImplemented() - } - } -} - -private const val TAG = "BackgroundServicePlugin" -private const val BUFSIZE = 2*1024*1024; +package app.alextran.immich + +import android.content.Context +import android.util.Log +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.security.MessageDigest +import java.io.FileInputStream +import kotlinx.coroutines.* + +/** + * Android plugin for Dart `BackgroundService` + * + * Receives messages/method calls from the foreground Dart side to manage + * the background service, e.g. start (enqueue), stop (cancel) + */ +class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + + private var methodChannel: MethodChannel? = null + private var context: Context? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) + } + + private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) { + context = ctx + methodChannel = MethodChannel(messenger, "immich/foregroundChannel") + methodChannel?.setMethodCallHandler(this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + onDetachedFromEngine() + } + + private fun onDetachedFromEngine() { + methodChannel?.setMethodCallHandler(null) + methodChannel = null + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + val ctx = context!! + when (call.method) { + "enable" -> { + val args = call.arguments>()!! + ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true) + .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args[0] as Long) + .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args[1] as String) + .apply() + ContentObserverWorker.enable(ctx, immediate = args[2] as Boolean) + result.success(true) + } + + "configure" -> { + val args = call.arguments>()!! + val requireUnmeteredNetwork = args[0] as Boolean + val requireCharging = args[1] as Boolean + val triggerUpdateDelay = (args[2] as Number).toLong() + val triggerMaxDelay = (args[3] as Number).toLong() + ContentObserverWorker.configureWork( + ctx, + requireUnmeteredNetwork, + requireCharging, + triggerUpdateDelay, + triggerMaxDelay + ) + result.success(true) + } + + "disable" -> { + ContentObserverWorker.disable(ctx) + BackupWorker.stopWork(ctx) + result.success(true) + } + + "isEnabled" -> { + result.success(ContentObserverWorker.isEnabled(ctx)) + } + + "isIgnoringBatteryOptimizations" -> { + result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx)) + } + + "digestFiles" -> { + val args = call.arguments>()!! + GlobalScope.launch(Dispatchers.IO) { + val buf = ByteArray(BUFFER_SIZE) + val digest: MessageDigest = MessageDigest.getInstance("SHA-1") + val hashes = arrayOfNulls(args.size) + for (i in args.indices) { + val path = args[i] + var len = 0 + try { + val file = FileInputStream(path) + file.use { assetFile -> + while (true) { + len = assetFile.read(buf) + if (len != BUFFER_SIZE) break + digest.update(buf) + } + } + digest.update(buf, 0, len) + hashes[i] = digest.digest() + } catch (e: Exception) { + // skip this file + Log.w(TAG, "Failed to hash file ${args[i]}: $e") + } + } + result.success(hashes.asList()) + } + } + + else -> result.notImplemented() + } + } +} + +private const val TAG = "BackgroundServicePlugin" +private const val BUFFER_SIZE = 2 * 1024 * 1024; diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt index b6b78c2cba..052a4e4c1f 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt @@ -4,6 +4,7 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE import android.os.Build import android.os.Handler import android.os.Looper @@ -40,323 +41,351 @@ import java.util.concurrent.TimeUnit * Called by Android WorkManager when all constraints for the work are met, * i.e. battery is not low and optionally Wifi and charging are active. */ -class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler { +class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), + MethodChannel.MethodCallHandler { - private val resolvableFuture = ResolvableFuture.create() - private var engine: FlutterEngine? = null - private lateinit var backgroundChannel: MethodChannel - private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext) - private var timeBackupStarted: Long = 0L - private var notificationBuilder: NotificationCompat.Builder? = null - private var notificationDetailBuilder: NotificationCompat.Builder? = null - private var fgFuture: ListenableFuture? = null + private val resolvableFuture = ResolvableFuture.create() + private var engine: FlutterEngine? = null + private lateinit var backgroundChannel: MethodChannel + private val notificationManager = + ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext) + private var timeBackupStarted: Long = 0L + private var notificationBuilder: NotificationCompat.Builder? = null + private var notificationDetailBuilder: NotificationCompat.Builder? = null + private var fgFuture: ListenableFuture? = null - override fun startWork(): ListenableFuture { + override fun startWork(): ListenableFuture { - Log.d(TAG, "startWork") + Log.d(TAG, "startWork") - val ctx = applicationContext + val ctx = applicationContext - if (!flutterLoader.initialized()) { - flutterLoader.startInitialization(ctx) + if (!flutterLoader.initialized()) { + flutterLoader.startInitialization(ctx) + } + + // Create a Notification channel + createChannel() + + Log.d(TAG, "isIgnoringBatteryOptimizations $isIgnoringBatteryOptimizations") + if (isIgnoringBatteryOptimizations) { + // normal background services can only up to 10 minutes + // foreground services are allowed to run indefinitely + // requires battery optimizations to be disabled (either manually by the user + // or by the system learning that immich is important to the user) + val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!! + showInfo(getInfoBuilder(title, indeterminate = true).build()) + } + + engine = FlutterEngine(ctx) + + flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { + runDart() + } + + return resolvableFuture + } + + /** + * Starts the Dart runtime/engine and calls `_nativeEntry` function in + * `background.service.dart` to run the actual backup logic. + */ + private fun runDart() { + val callbackDispatcherHandle = applicationContext.getSharedPreferences( + SHARED_PREF_NAME, Context.MODE_PRIVATE + ).getLong(SHARED_PREF_CALLBACK_KEY, 0L) + val callbackInformation = + FlutterCallbackInformation.lookupCallbackInformation(callbackDispatcherHandle) + val appBundlePath = flutterLoader.findAppBundlePath() + + engine?.let { engine -> + backgroundChannel = MethodChannel(engine.dartExecutor, "immich/backgroundChannel") + backgroundChannel.setMethodCallHandler(this@BackupWorker) + engine.dartExecutor.executeDartCallback( + DartExecutor.DartCallback( + applicationContext.assets, + appBundlePath, + callbackInformation + ) + ) + } + } + + override fun onStopped() { + Log.d(TAG, "onStopped") + // called when the system has to stop this worker because constraints are + // no longer met or the system needs resources for more important tasks + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + backgroundChannel.invokeMethod("systemStop", null) + } + waitOnSetForegroundAsync() + // cannot await/get(block) on resolvableFuture as its already cancelled (would throw CancellationException) + // instead, wait for 5 seconds until forcefully stopping backup work + Handler(Looper.getMainLooper()).postDelayed({ + stopEngine(null) + }, 5000) + } + + private fun waitOnSetForegroundAsync() { + val fgFuture = this.fgFuture + if (fgFuture != null && !fgFuture.isCancelled && !fgFuture.isDone) { + try { + fgFuture.get(500, TimeUnit.MILLISECONDS) + } catch (e: Exception) { + // ignored, there is nothing to be done + } + } + } + + private fun stopEngine(result: Result?) { + clearBackgroundNotification() + engine?.destroy() + engine = null + if (result != null) { + Log.d(TAG, "stopEngine result=${result}") + resolvableFuture.set(result) + } + waitOnSetForegroundAsync() + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) { + when (call.method) { + "initialized" -> { + timeBackupStarted = SystemClock.uptimeMillis() + backgroundChannel.invokeMethod( + "onAssetsChanged", + null, + object : MethodChannel.Result { + override fun notImplemented() { + stopEngine(Result.failure()) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + stopEngine(Result.failure()) + } + + override fun success(receivedResult: Any?) { + val success = receivedResult as Boolean + stopEngine(if (success) Result.success() else Result.retry()) + } + } + ) + } + + "updateNotification" -> { + val args = call.arguments>()!! + val title = args[0] as String? + val content = args[1] as String? + val progress = args[2] as Int + val max = args[3] as Int + val indeterminate = args[4] as Boolean + val isDetail = args[5] as Boolean + val onlyIfFG = args[6] as Boolean + if (!onlyIfFG || isIgnoringBatteryOptimizations) { + showInfo( + getInfoBuilder(title, content, isDetail, progress, max, indeterminate).build(), + isDetail + ) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Create a Notification channel if necessary - createChannel() - } - if (isIgnoringBatteryOptimizations) { - // normal background services can only up to 10 minutes - // foreground services are allowed to run indefinitely - // requires battery optimizations to be disabled (either manually by the user - // or by the system learning that immich is important to the user) - val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!! - showInfo(getInfoBuilder(title, indeterminate=true).build()) - } - engine = FlutterEngine(ctx) + } - flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { - runDart() - } + "showError" -> { + val args = call.arguments>()!! + val title = args[0] as String + val content = args[1] as String? + val individualTag = args[2] as String? + showError(title, content, individualTag) + } - return resolvableFuture + "clearErrorNotifications" -> clearErrorNotifications() + "hasContentChanged" -> { + val lastChange = applicationContext + .getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .getLong(SHARED_PREF_LAST_CHANGE, timeBackupStarted) + val hasContentChanged = lastChange > timeBackupStarted; + timeBackupStarted = SystemClock.uptimeMillis() + r.success(hasContentChanged) + } + + else -> r.notImplemented() + } + } + + private fun showError(title: String, content: String?, individualTag: String?) { + val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID) + .setContentTitle(title) + .setTicker(title) + .setContentText(content) + .setSmallIcon(R.mipmap.ic_launcher) + .build() + notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification) + } + + private fun clearErrorNotifications() { + notificationManager.cancel(NOTIFICATION_ERROR_ID) + } + + private fun clearBackgroundNotification() { + notificationManager.cancel(NOTIFICATION_ID) + notificationManager.cancel(NOTIFICATION_DETAIL_ID) + } + + private fun showInfo(notification: Notification, isDetail: Boolean = false) { + val id = if (isDetail) NOTIFICATION_DETAIL_ID else NOTIFICATION_ID + + if (isIgnoringBatteryOptimizations && !isDetail) { + fgFuture = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + setForegroundAsync(ForegroundInfo(id, notification, FOREGROUND_SERVICE_TYPE_SHORT_SERVICE)) + } else { + setForegroundAsync(ForegroundInfo(id, notification)) + } + } else { + notificationManager.notify(id, notification) + } + } + + private fun getInfoBuilder( + title: String? = null, + content: String? = null, + isDetail: Boolean = false, + progress: Int = 0, + max: Int = 0, + indeterminate: Boolean = false, + ): NotificationCompat.Builder { + var builder = if (isDetail) notificationDetailBuilder else notificationBuilder + if (builder == null) { + builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setOnlyAlertOnce(true) + .setOngoing(true) + if (isDetail) { + notificationDetailBuilder = builder + } else { + notificationBuilder = builder + } + } + if (title != null) { + builder.setTicker(title).setContentTitle(title) + } + if (content != null) { + builder.setContentText(content) + } + return builder.setProgress(max, progress, indeterminate) + } + + private fun createChannel() { + val foreground = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_CHANNEL_ID, + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(foreground) + val error = NotificationChannel( + NOTIFICATION_CHANNEL_ERROR_ID, + NOTIFICATION_CHANNEL_ERROR_ID, + NotificationManager.IMPORTANCE_HIGH + ) + notificationManager.createNotificationChannel(error) + } + + companion object { + const val SHARED_PREF_NAME = "immichBackgroundService" + const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" + const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle" + const val SHARED_PREF_LAST_CHANGE = "lastChange" + + private const val TASK_NAME_BACKUP = "immich/BackupWorker" + private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService" + private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError" + private const val NOTIFICATION_DEFAULT_TITLE = "Immich" + private const val NOTIFICATION_ID = 1 + private const val NOTIFICATION_ERROR_ID = 2 + private const val NOTIFICATION_DETAIL_ID = 3 + private const val ONE_MINUTE = 60000L + + /** + * Enqueues the BackupWorker to run once the constraints are met + */ + fun enqueueBackupWorker( + context: Context, + requireWifi: Boolean = false, + requireCharging: Boolean = false, + delayMilliseconds: Long = 0L + ) { + val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds) + WorkManager.getInstance(context) + .enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest) + Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued") } /** - * Starts the Dart runtime/engine and calls `_nativeEntry` function in - * `background.service.dart` to run the actual backup logic. + * Updates the constraints of an already enqueued BackupWorker */ - private fun runDart() { - val callbackDispatcherHandle = applicationContext.getSharedPreferences( - SHARED_PREF_NAME, Context.MODE_PRIVATE).getLong(SHARED_PREF_CALLBACK_KEY, 0L) - val callbackInformation = FlutterCallbackInformation.lookupCallbackInformation(callbackDispatcherHandle) - val appBundlePath = flutterLoader.findAppBundlePath() - - engine?.let { engine -> - backgroundChannel = MethodChannel(engine.dartExecutor, "immich/backgroundChannel") - backgroundChannel.setMethodCallHandler(this@BackupWorker) - engine.dartExecutor.executeDartCallback( - DartExecutor.DartCallback( - applicationContext.assets, - appBundlePath, - callbackInformation - ) - ) - } - } - - override fun onStopped() { - Log.d(TAG, "onStopped") - // called when the system has to stop this worker because constraints are - // no longer met or the system needs resources for more important tasks - Handler(Looper.getMainLooper()).postAtFrontOfQueue { - backgroundChannel.invokeMethod("systemStop", null) - } - waitOnSetForegroundAsync() - // cannot await/get(block) on resolvableFuture as its already cancelled (would throw CancellationException) - // instead, wait for 5 seconds until forcefully stopping backup work - Handler(Looper.getMainLooper()).postDelayed({ - stopEngine(null) - }, 5000) - } - - private fun waitOnSetForegroundAsync() { - val fgFuture = this.fgFuture - if (fgFuture != null && !fgFuture.isCancelled() && !fgFuture.isDone()) { - try { - fgFuture.get(500, TimeUnit.MILLISECONDS) - } - catch (e: Exception) { - // ignored, there is nothing to be done + fun updateBackupWorker( + context: Context, + requireWifi: Boolean = false, + requireCharging: Boolean = false + ) { + try { + val wm = WorkManager.getInstance(context) + val workInfoFuture = wm.getWorkInfosForUniqueWork(TASK_NAME_BACKUP) + val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS) + if (workInfoList != null) { + for (workInfo in workInfoList) { + if (workInfo.state == WorkInfo.State.ENQUEUED) { + val workRequest = buildWorkRequest(requireWifi, requireCharging) + wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest) + Log.d(TAG, "updateBackupWorker updated BackupWorker constraints") + return } + } } + Log.d(TAG, "updateBackupWorker: BackupWorker not enqueued") + } catch (e: Exception) { + Log.d(TAG, "updateBackupWorker failed: $e") + } } - private fun stopEngine(result: Result?) { - clearBackgroundNotification() - engine?.destroy() - engine = null - if (result != null) { - Log.d(TAG, "stopEngine result=${result}") - resolvableFuture.set(result) - } - waitOnSetForegroundAsync() + /** + * Stops the currently running worker (if any) and removes it from the work queue + */ + fun stopWork(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP) + Log.d(TAG, "stopWork: BackupWorker cancelled") } - override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) { - when (call.method) { - "initialized" -> { - timeBackupStarted = SystemClock.uptimeMillis() - backgroundChannel.invokeMethod( - "onAssetsChanged", - null, - object : MethodChannel.Result { - override fun notImplemented() { - stopEngine(Result.failure()) - } - - override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { - stopEngine(Result.failure()) - } - - override fun success(receivedResult: Any?) { - val success = receivedResult as Boolean - stopEngine(if(success) Result.success() else Result.retry()) - } - } - ) - } - "updateNotification" -> { - val args = call.arguments>()!! - val title = args.get(0) as String? - val content = args.get(1) as String? - val progress = args.get(2) as Int - val max = args.get(3) as Int - val indeterminate = args.get(4) as Boolean - val isDetail = args.get(5) as Boolean - val onlyIfFG = args.get(6) as Boolean - if (!onlyIfFG || isIgnoringBatteryOptimizations) { - showInfo(getInfoBuilder(title, content, isDetail, progress, max, indeterminate).build(), isDetail) - } - } - "showError" -> { - val args = call.arguments>()!! - val title = args.get(0) as String - val content = args.get(1) as String? - val individualTag = args.get(2) as String? - showError(title, content, individualTag) - } - "clearErrorNotifications" -> clearErrorNotifications() - "hasContentChanged" -> { - val lastChange = applicationContext - .getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - .getLong(SHARED_PREF_LAST_CHANGE, timeBackupStarted) - val hasContentChanged = lastChange > timeBackupStarted; - timeBackupStarted = SystemClock.uptimeMillis() - r.success(hasContentChanged) - } - else -> r.notImplemented() - } + /** + * Returns `true` if the app is ignoring battery optimizations + */ + fun isIgnoringBatteryOptimizations(ctx: Context): Boolean { + val powerManager = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager + return powerManager.isIgnoringBatteryOptimizations(ctx.packageName) } - private fun showError(title: String, content: String?, individualTag: String?) { - val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID) - .setContentTitle(title) - .setTicker(title) - .setContentText(content) - .setSmallIcon(R.mipmap.ic_launcher) - .build() - notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification) + private fun buildWorkRequest( + requireWifi: Boolean = false, + requireCharging: Boolean = false, + delayMilliseconds: Long = 0L + ): OneTimeWorkRequest { + val constraints = Constraints.Builder() + .setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .setRequiresCharging(requireCharging) + .build(); + + val work = OneTimeWorkRequest.Builder(BackupWorker::class.java) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS) + .setInitialDelay(delayMilliseconds, TimeUnit.MILLISECONDS) + .build() + return work } - private fun clearErrorNotifications() { - notificationManager.cancel(NOTIFICATION_ERROR_ID) - } - - private fun clearBackgroundNotification() { - notificationManager.cancel(NOTIFICATION_ID) - notificationManager.cancel(NOTIFICATION_DETAIL_ID) - } - - private fun showInfo(notification: Notification, isDetail: Boolean = false) { - val id = if(isDetail) NOTIFICATION_DETAIL_ID else NOTIFICATION_ID - if (isIgnoringBatteryOptimizations && !isDetail) { - fgFuture = setForegroundAsync(ForegroundInfo(id, notification)) - } else { - notificationManager.notify(id, notification) - } - } - - private fun getInfoBuilder( - title: String? = null, - content: String? = null, - isDetail: Boolean = false, - progress: Int = 0, - max: Int = 0, - indeterminate: Boolean = false, - ): NotificationCompat.Builder { - var builder = if(isDetail) notificationDetailBuilder else notificationBuilder - if (builder == null) { - builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.mipmap.ic_launcher) - .setOnlyAlertOnce(true) - .setOngoing(true) - if (isDetail) { - notificationDetailBuilder = builder - } else { - notificationBuilder = builder - } - } - if (title != null) { - builder.setTicker(title).setContentTitle(title) - } - if (content != null) { - builder.setContentText(content) - } - return builder.setProgress(max, progress, indeterminate) - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createChannel() { - val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW) - notificationManager.createNotificationChannel(foreground) - val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH) - notificationManager.createNotificationChannel(error) - } - - companion object { - const val SHARED_PREF_NAME = "immichBackgroundService" - const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" - const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle" - const val SHARED_PREF_LAST_CHANGE = "lastChange" - - private const val TASK_NAME_BACKUP = "immich/BackupWorker" - private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService" - private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError" - private const val NOTIFICATION_DEFAULT_TITLE = "Immich" - private const val NOTIFICATION_ID = 1 - private const val NOTIFICATION_ERROR_ID = 2 - private const val NOTIFICATION_DETAIL_ID = 3 - private const val ONE_MINUTE = 60000L - - /** - * Enqueues the BackupWorker to run once the constraints are met - */ - fun enqueueBackupWorker(context: Context, - requireWifi: Boolean = false, - requireCharging: Boolean = false, - delayMilliseconds: Long = 0L) { - val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds) - WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest) - Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued") - } - - /** - * Updates the constraints of an already enqueued BackupWorker - */ - fun updateBackupWorker(context: Context, - requireWifi: Boolean = false, - requireCharging: Boolean = false) { - try { - val wm = WorkManager.getInstance(context) - val workInfoFuture = wm.getWorkInfosForUniqueWork(TASK_NAME_BACKUP) - val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS) - if (workInfoList != null) { - for (workInfo in workInfoList) { - if (workInfo.state == WorkInfo.State.ENQUEUED) { - val workRequest = buildWorkRequest(requireWifi, requireCharging) - wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest) - Log.d(TAG, "updateBackupWorker updated BackupWorker constraints") - return - } - } - } - Log.d(TAG, "updateBackupWorker: BackupWorker not enqueued") - } catch (e: Exception) { - Log.d(TAG, "updateBackupWorker failed: ${e}") - } - } - - /** - * Stops the currently running worker (if any) and removes it from the work queue - */ - fun stopWork(context: Context) { - WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP) - Log.d(TAG, "stopWork: BackupWorker cancelled") - } - - /** - * Returns `true` if the app is ignoring battery optimizations - */ - fun isIgnoringBatteryOptimizations(ctx: Context): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val pwrm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager - val name = ctx.packageName - return pwrm.isIgnoringBatteryOptimizations(name) - } - return true - } - - private fun buildWorkRequest(requireWifi: Boolean = false, - requireCharging: Boolean = false, - delayMilliseconds: Long = 0L): OneTimeWorkRequest { - val constraints = Constraints.Builder() - .setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED) - .setRequiresBatteryNotLow(true) - .setRequiresCharging(requireCharging) - .build(); - - val work = OneTimeWorkRequest.Builder(BackupWorker::class.java) - .setConstraints(constraints) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS) - .setInitialDelay(delayMilliseconds, TimeUnit.MILLISECONDS) - .build() - return work - } - - private val flutterLoader = FlutterLoader() - } + private val flutterLoader = FlutterLoader() + } } private const val TAG = "BackupWorker" diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt index 59ca6d5638..9cb2ec7779 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt @@ -1,144 +1,144 @@ -package app.alextran.immich - -import android.content.Context -import android.os.SystemClock -import android.provider.MediaStore -import android.util.Log -import androidx.work.Constraints -import androidx.work.Worker -import androidx.work.WorkerParameters -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequest -import androidx.work.WorkManager -import androidx.work.Operation -import java.util.concurrent.TimeUnit - -/** - * Worker executed by Android WorkManager observing content changes (new photos/videos) - * - * Immediately enqueues the BackupWorker when running. - * As this work is not triggered periodically, but on content change, the - * worker enqueues itself again after each run. - */ -class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) { - - override fun doWork(): Result { - if (!isEnabled(applicationContext)) { - return Result.failure() - } - if (getTriggeredContentUris().size > 0) { - startBackupWorker(applicationContext, delayMilliseconds = 0) - } - enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE) - return Result.success() - } - - companion object { - const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled" - const val SHARED_PREF_REQUIRE_WIFI = "requireWifi" - const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging" - const val SHARED_PREF_TRIGGER_UPDATE_DELAY = "triggerUpdateDelay" - const val SHARED_PREF_TRIGGER_MAX_DELAY = "triggerMaxDelay" - - private const val TASK_NAME_OBSERVER = "immich/ContentObserver" - - /** - * Enqueues the `ContentObserverWorker`. - * - * @param context Android Context - */ - fun enable(context: Context, immediate: Boolean = false) { - enqueueObserverWorker(context, ExistingWorkPolicy.KEEP) - Log.d(TAG, "enabled ContentObserverWorker") - if (immediate) { - startBackupWorker(context, delayMilliseconds = 5000) - } - } - - /** - * Configures the `BackupWorker` to run when all constraints are met. - * - * @param context Android Context - * @param requireWifi if true, task only runs if connected to wifi - * @param requireCharging if true, task only runs if device is charging - */ - fun configureWork(context: Context, - requireWifi: Boolean = false, - requireCharging: Boolean = false, - triggerUpdateDelay: Long = 5000, - triggerMaxDelay: Long = 50000) { - context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) - .edit() - .putBoolean(SHARED_PREF_SERVICE_ENABLED, true) - .putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi) - .putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging) - .putLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, triggerUpdateDelay) - .putLong(SHARED_PREF_TRIGGER_MAX_DELAY, triggerMaxDelay) - .apply() - BackupWorker.updateBackupWorker(context, requireWifi, requireCharging) - } - - /** - * Stops the currently running worker (if any) and removes it from the work queue - */ - fun disable(context: Context) { - context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) - .edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply() - WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_OBSERVER) - Log.d(TAG, "disabled ContentObserverWorker") - } - - /** - * Return true if the user has enabled the background backup service - */ - fun isEnabled(ctx: Context): Boolean { - return ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) - .getBoolean(SHARED_PREF_SERVICE_ENABLED, false) - } - - /** - * Enqueue and replace the worker without the content trigger but with a short delay - */ - fun workManagerAppClearedWorkaround(context: Context) { - val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java) - .setInitialDelay(500, TimeUnit.MILLISECONDS) - .build() - WorkManager - .getInstance(context) - .enqueueUniqueWork(TASK_NAME_OBSERVER, ExistingWorkPolicy.REPLACE, work) - .getResult() - .get() - Log.d(TAG, "workManagerAppClearedWorkaround") - } - - private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) { - val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) - val constraints = Constraints.Builder() - .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) - .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) - .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) - .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) - .setTriggerContentUpdateDelay(sp.getLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, 5000), TimeUnit.MILLISECONDS) - .setTriggerContentMaxDelay(sp.getLong(SHARED_PREF_TRIGGER_MAX_DELAY, 50000), TimeUnit.MILLISECONDS) - .build() - - val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java) - .setConstraints(constraints) - .build() - WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work) - } - - fun startBackupWorker(context: Context, delayMilliseconds: Long) { - val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) - if (!sp.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)) - return - val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true) - val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false) - BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds) - sp.edit().putLong(BackupWorker.SHARED_PREF_LAST_CHANGE, SystemClock.uptimeMillis()).apply() - } - - } -} - -private const val TAG = "ContentObserverWorker" \ No newline at end of file +package app.alextran.immich + +import android.content.Context +import android.os.SystemClock +import android.provider.MediaStore +import android.util.Log +import androidx.work.Constraints +import androidx.work.Worker +import androidx.work.WorkerParameters +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.Operation +import java.util.concurrent.TimeUnit + +/** + * Worker executed by Android WorkManager observing content changes (new photos/videos) + * + * Immediately enqueues the BackupWorker when running. + * As this work is not triggered periodically, but on content change, the + * worker enqueues itself again after each run. + */ +class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) { + + override fun doWork(): Result { + if (!isEnabled(applicationContext)) { + return Result.failure() + } + if (triggeredContentUris.size > 0) { + startBackupWorker(applicationContext, delayMilliseconds = 0) + } + enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE) + return Result.success() + } + + companion object { + const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled" + private const val SHARED_PREF_REQUIRE_WIFI = "requireWifi" + private const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging" + private const val SHARED_PREF_TRIGGER_UPDATE_DELAY = "triggerUpdateDelay" + private const val SHARED_PREF_TRIGGER_MAX_DELAY = "triggerMaxDelay" + + private const val TASK_NAME_OBSERVER = "immich/ContentObserver" + + /** + * Enqueues the `ContentObserverWorker`. + * + * @param context Android Context + */ + fun enable(context: Context, immediate: Boolean = false) { + enqueueObserverWorker(context, ExistingWorkPolicy.KEEP) + Log.d(TAG, "enabled ContentObserverWorker") + if (immediate) { + startBackupWorker(context, delayMilliseconds = 5000) + } + } + + /** + * Configures the `BackupWorker` to run when all constraints are met. + * + * @param context Android Context + * @param requireWifi if true, task only runs if connected to wifi + * @param requireCharging if true, task only runs if device is charging + */ + fun configureWork(context: Context, + requireWifi: Boolean = false, + requireCharging: Boolean = false, + triggerUpdateDelay: Long = 5000, + triggerMaxDelay: Long = 50000) { + context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(SHARED_PREF_SERVICE_ENABLED, true) + .putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi) + .putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging) + .putLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, triggerUpdateDelay) + .putLong(SHARED_PREF_TRIGGER_MAX_DELAY, triggerMaxDelay) + .apply() + BackupWorker.updateBackupWorker(context, requireWifi, requireCharging) + } + + /** + * Stops the currently running worker (if any) and removes it from the work queue + */ + fun disable(context: Context) { + context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply() + WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_OBSERVER) + Log.d(TAG, "disabled ContentObserverWorker") + } + + /** + * Return true if the user has enabled the background backup service + */ + fun isEnabled(ctx: Context): Boolean { + return ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + .getBoolean(SHARED_PREF_SERVICE_ENABLED, false) + } + + /** + * Enqueue and replace the worker without the content trigger but with a short delay + */ + fun workManagerAppClearedWorkaround(context: Context) { + val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java) + .setInitialDelay(500, TimeUnit.MILLISECONDS) + .build() + WorkManager + .getInstance(context) + .enqueueUniqueWork(TASK_NAME_OBSERVER, ExistingWorkPolicy.REPLACE, work) + .result + .get() + Log.d(TAG, "workManagerAppClearedWorkaround") + } + + private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) { + val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + val constraints = Constraints.Builder() + .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) + .setTriggerContentUpdateDelay(sp.getLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, 5000), TimeUnit.MILLISECONDS) + .setTriggerContentMaxDelay(sp.getLong(SHARED_PREF_TRIGGER_MAX_DELAY, 50000), TimeUnit.MILLISECONDS) + .build() + + val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java) + .setConstraints(constraints) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work) + } + + fun startBackupWorker(context: Context, delayMilliseconds: Long) { + val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + if (!sp.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)) + return + val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true) + val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false) + BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds) + sp.edit().putLong(BackupWorker.SHARED_PREF_LAST_CHANGE, SystemClock.uptimeMillis()).apply() + } + + } +} + +private const val TAG = "ContentObserverWorker" From 3ab74380360e88eba09e67588a621ea24a12f2fc Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 15 Aug 2024 12:38:02 -0500 Subject: [PATCH 105/723] chore(mobile): post release task (#11791) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 1a3b115c8a..46ebb2a14a 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -401,7 +401,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 168; + CURRENT_PROJECT_VERSION = 169; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 168; + CURRENT_PROJECT_VERSION = 169; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -571,7 +571,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 168; + CURRENT_PROJECT_VERSION = 169; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 0727cd4603..c7a5991212 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.112.0 + 1.112.1 CFBundleSignature ???? CFBundleVersion - 168 + 169 FLTEnableImpeller ITSAppUsesNonExemptEncryption From f40a4fc1c8d8f6d46059608a7615014e09da3309 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Thu, 15 Aug 2024 19:27:18 +0100 Subject: [PATCH 106/723] fix(ml): broken openvino builds (#11818) * fix: install opencl from github releases directly to pin versions * chore: remove configuration-apt script --- machine-learning/Dockerfile | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 3ab8875a4d..c06b4900e6 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -38,11 +38,17 @@ FROM python:3.11-slim-bookworm@sha256:1c0c54195c7c7b46e61a2f3b906e9b55a8165f2038 FROM prod-cpu AS prod-openvino -COPY scripts/configure-apt.sh ./ -RUN ./configure-apt.sh && \ - apt-get update && \ - apt-get install -t unstable --no-install-recommends -yqq intel-opencl-icd && \ - rm configure-apt.sh +RUN apt-get update && \ + apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-core_1.0.17193.4_amd64.deb && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-opencl_1.0.17193.4_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd-dbgsym_24.26.30049.6_amd64.ddeb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd_24.26.30049.6_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/libigdgmm12_22.3.20_amd64.deb && \ + dpkg -i *.deb && \ + rm *.deb && \ + apt-get remove wget -yqq && \ + rm -rf /var/lib/apt/lists/* FROM nvidia/cuda:12.3.2-cudnn9-runtime-ubuntu22.04@sha256:fa44193567d1908f7ca1f3abf8623ce9c63bc8cba7bcfdb32702eb04d326f7a8 AS prod-cuda From e51b581f6e9510ebbd1c5b8967d9d75478c95f23 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 15 Aug 2024 14:10:13 -0500 Subject: [PATCH 107/723] fix(mobile): correct native package naming convention (#11826) --- .../alextran/immich}/AppGlideModule.kt | 12 +++---- .../immich}/BackgroundServicePlugin.kt | 0 .../alextran/immich}/BackupWorker.kt | 0 .../alextran/immich}/ContentObserverWorker.kt | 0 .../alextran/immich}/ImmichApp.kt | 36 +++++++++---------- .../alextran/immich}/MainActivity.kt | 30 ++++++++-------- 6 files changed, 39 insertions(+), 39 deletions(-) rename mobile/android/app/src/main/kotlin/{com/example/mobile => app/alextran/immich}/AppGlideModule.kt (96%) rename mobile/android/app/src/main/kotlin/{com/example/mobile => app/alextran/immich}/BackgroundServicePlugin.kt (100%) rename mobile/android/app/src/main/kotlin/{com/example/mobile => app/alextran/immich}/BackupWorker.kt (100%) rename mobile/android/app/src/main/kotlin/{com/example/mobile => app/alextran/immich}/ContentObserverWorker.kt (100%) rename mobile/android/app/src/main/kotlin/{com/example/mobile => app/alextran/immich}/ImmichApp.kt (97%) rename mobile/android/app/src/main/kotlin/{com/example/mobile => app/alextran/immich}/MainActivity.kt (96%) diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/AppGlideModule.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/AppGlideModule.kt similarity index 96% rename from mobile/android/app/src/main/kotlin/com/example/mobile/AppGlideModule.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/AppGlideModule.kt index da43d1c268..f969b9576f 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/AppGlideModule.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/AppGlideModule.kt @@ -1,7 +1,7 @@ -package app.alextran.immich - -import com.bumptech.glide.annotation.GlideModule -import com.bumptech.glide.module.AppGlideModule - -@GlideModule +package app.alextran.immich + +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.module.AppGlideModule + +@GlideModule class AppGlideModule : AppGlideModule() \ No newline at end of file diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt similarity index 100% rename from mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt similarity index 100% rename from mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/ContentObserverWorker.kt similarity index 100% rename from mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/ContentObserverWorker.kt diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/ImmichApp.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt similarity index 97% rename from mobile/android/app/src/main/kotlin/com/example/mobile/ImmichApp.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt index 86b82d2be9..ff806870f9 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/ImmichApp.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt @@ -1,19 +1,19 @@ -package app.alextran.immich - -import android.app.Application -import androidx.work.Configuration -import androidx.work.WorkManager - -class ImmichApp : Application() { - override fun onCreate() { - super.onCreate() - val config = Configuration.Builder().build() - WorkManager.initialize(this, config) - // always start BackupWorker after WorkManager init; this fixes the following bug: - // After the process is killed (by user or system), the first trigger (taking a new picture) is lost. - // Thus, the BackupWorker is not started. If the system kills the process after each initialization - // (because of low memory etc.), the backup is never performed. - // As a workaround, we also run a backup check when initializing the application - ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0) - } +package app.alextran.immich + +import android.app.Application +import androidx.work.Configuration +import androidx.work.WorkManager + +class ImmichApp : Application() { + override fun onCreate() { + super.onCreate() + val config = Configuration.Builder().build() + WorkManager.initialize(this, config) + // always start BackupWorker after WorkManager init; this fixes the following bug: + // After the process is killed (by user or system), the first trigger (taking a new picture) is lost. + // Thus, the BackupWorker is not started. If the system kills the process after each initialization + // (because of low memory etc.), the backup is never performed. + // As a workaround, we also run a backup check when initializing the application + ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0) + } } \ No newline at end of file diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt similarity index 96% rename from mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 5df36cb18f..4ffb490c77 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -1,15 +1,15 @@ -package app.alextran.immich - -import io.flutter.embedding.android.FlutterActivity -import io.flutter.embedding.engine.FlutterEngine -import android.os.Bundle -import android.content.Intent - -class MainActivity : FlutterActivity() { - - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - flutterEngine.plugins.add(BackgroundServicePlugin()) - } - -} +package app.alextran.immich + +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import android.os.Bundle +import android.content.Intent + +class MainActivity : FlutterActivity() { + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + flutterEngine.plugins.add(BackgroundServicePlugin()) + } + +} From 00023e387f78550a31c73b985653ba935fddc8e9 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 15 Aug 2024 14:12:56 -0500 Subject: [PATCH 108/723] feat(mobile): enable Impeller rendering engine on Android (#11831) --- mobile/android/app/src/main/AndroidManifest.xml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 9222b38de0..edb41510f0 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -24,7 +24,7 @@ + android:largeHeap="true" android:enableOnBackInvokedCallback="true"> + android:value="true" /> - - - - @@ -98,4 +94,4 @@ - + \ No newline at end of file From ed6971222c3d8ec7c16d6b91072d3ec2c5e84dda Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 15 Aug 2024 14:53:37 -0500 Subject: [PATCH 109/723] chore(mobile): Flutter 3.24 (#11633) * chore(mobile): Flutter 3.24 * fix lint * fix rendering issues that lead to log get filled with error messages * linting * merge main * fix isar prod build Android * fix mismatch icon offset --- mobile/.fvmrc | 4 +- mobile/.vscode/settings.json | 2 +- mobile/android/build.gradle | 15 ++++- mobile/ios/Podfile.lock | 2 +- mobile/ios/Runner/AppDelegate.swift | 2 +- mobile/lib/main.dart | 2 +- .../lib/pages/common/gallery_viewer.page.dart | 2 +- .../pages/common/headers_settings.page.dart | 2 +- .../lib/pages/common/tab_controller.page.dart | 2 +- .../lib/pages/common/video_viewer.page.dart | 2 +- .../draggable_scrollbar_custom.dart | 59 ++++++++++--------- .../asset_grid/immich_asset_grid_view.dart | 6 +- mobile/lib/widgets/common/immich_app_bar.dart | 4 +- mobile/pubspec.lock | 44 +++++++------- mobile/pubspec.yaml | 4 +- 15 files changed, 86 insertions(+), 66 deletions(-) diff --git a/mobile/.fvmrc b/mobile/.fvmrc index cf7449069c..971587f297 100644 --- a/mobile/.fvmrc +++ b/mobile/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.22.3" -} \ No newline at end of file + "flutter": "3.24.0" +} diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json index c959187bb5..aa43dab3fb 100644 --- a/mobile/.vscode/settings.json +++ b/mobile/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.22.3", + "dart.flutterSdkPath": ".fvm/versions/3.24.0", "search.exclude": { "**/.fvm": true }, diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle index 9b5e515a68..87cc79281d 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -10,6 +10,18 @@ allprojects { rootProject.buildDir = '../build' subprojects { + // fix for verifyReleaseResources + // ============ + afterEvaluate { project -> + if (project.plugins.hasPlugin("com.android.application") || + project.plugins.hasPlugin("com.android.library")) { + project.android { + compileSdkVersion 34 + buildToolsVersion "34.0.0" + } + } + } + // ============ project.buildDir = "${rootProject.buildDir}/${project.name}" } @@ -23,4 +35,5 @@ tasks.register("clean", Delete) { tasks.named('wrapper') { distributionType = Wrapper.DistributionType.ALL -} \ No newline at end of file +} + diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index e3603eef42..3b361c4e19 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -202,7 +202,7 @@ SPEC CHECKSUMS: fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c geolocator_apple: 6cbaf322953988e009e5ecb481f07efece75c450 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 - integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9 diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index a73b6417c6..05cb061ca5 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -6,7 +6,7 @@ import path_provider_ios import photo_manager import permission_handler_apple -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 916c1ad3d3..dc1df746cb 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -39,7 +39,6 @@ import 'package:path_provider/path_provider.dart'; void main() async { ImmichWidgetsBinding(); - final db = await loadDb(); await initApp(); await migrateDatabaseIfNeeded(db); @@ -73,6 +72,7 @@ Future initApp() async { var log = Logger("ImmichErrorLogger"); FlutterError.onError = (details) { + debugPrint("FlutterError - Catch all: $details"); FlutterError.presentError(details); log.severe( 'FlutterError - Catch all', diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 8c2c70d93c..cc62620dfb 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -264,7 +264,7 @@ class GalleryViewerPage extends HookConsumerWidget { return PopScope( // Change immersive mode back to normal "edgeToEdge" mode - onPopInvoked: (_) => + onPopInvokedWithResult: (didPop, _) => SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge), child: Scaffold( backgroundColor: Colors.black, diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index e2a816bce1..7f6ee3e4e2 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -74,7 +74,7 @@ class HeaderSettingsPage extends HookConsumerWidget { ], ), body: PopScope( - onPopInvoked: (_) => saveHeaders(headers.value), + onPopInvokedWithResult: (didPop, _) => saveHeaders(headers.value), child: ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0), itemCount: list.length, diff --git a/mobile/lib/pages/common/tab_controller.page.dart b/mobile/lib/pages/common/tab_controller.page.dart index a48e9e92be..b619e003d2 100644 --- a/mobile/lib/pages/common/tab_controller.page.dart +++ b/mobile/lib/pages/common/tab_controller.page.dart @@ -177,7 +177,7 @@ class TabControllerPage extends HookConsumerWidget { final tabsRouter = AutoTabsRouter.of(context); return PopScope( canPop: tabsRouter.activeIndex == 0, - onPopInvoked: (didPop) => + onPopInvokedWithResult: (didPop, _) => !didPop ? tabsRouter.setActiveIndex(0) : null, child: LayoutBuilder( builder: (context, constraints) { diff --git a/mobile/lib/pages/common/video_viewer.page.dart b/mobile/lib/pages/common/video_viewer.page.dart index 527411ec89..573f7277f2 100644 --- a/mobile/lib/pages/common/video_viewer.page.dart +++ b/mobile/lib/pages/common/video_viewer.page.dart @@ -123,7 +123,7 @@ class VideoViewerPage extends HookConsumerWidget { final size = MediaQuery.sizeOf(context); return PopScope( - onPopInvoked: (pop) { + onPopInvokedWithResult: (didPop, _) { ref.read(videoPlaybackValueProvider.notifier).value = VideoPlaybackValue.uninitialized(); }, diff --git a/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart b/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart index 94a01a57c5..4490da7aed 100644 --- a/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart +++ b/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart @@ -59,6 +59,8 @@ class DraggableScrollbar extends StatefulWidget { final Function(bool scrolling) scrollStateListener; + final double viewPortHeight; + DraggableScrollbar.semicircle({ super.key, Key? scrollThumbKey, @@ -67,6 +69,7 @@ class DraggableScrollbar extends StatefulWidget { required this.controller, required this.itemPositionsListener, required this.scrollStateListener, + required this.viewPortHeight, this.heightScrollThumb = 48.0, this.backgroundColor = Colors.white, this.padding, @@ -251,7 +254,7 @@ class DraggableScrollbarState extends State } double get barMaxScrollExtent => - (context.size?.height ?? 0) - + widget.viewPortHeight - widget.heightScrollThumb - (widget.heightOffset ?? 0); @@ -316,37 +319,39 @@ class DraggableScrollbarState extends State } setState(() { - int firstItemIndex = - widget.itemPositionsListener.itemPositions.value.first.index; + try { + int firstItemIndex = + widget.itemPositionsListener.itemPositions.value.first.index; - if (notification is ScrollUpdateNotification) { - _barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent; + if (notification is ScrollUpdateNotification) { + _barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent; - if (_barOffset < barMinScrollExtent) { - _barOffset = barMinScrollExtent; - } - if (_barOffset > barMaxScrollExtent) { - _barOffset = barMaxScrollExtent; - } - } - - if (notification is ScrollUpdateNotification || - notification is OverscrollNotification) { - if (_thumbAnimationController.status != AnimationStatus.forward) { - _thumbAnimationController.forward(); + if (_barOffset < barMinScrollExtent) { + _barOffset = barMinScrollExtent; + } + if (_barOffset > barMaxScrollExtent) { + _barOffset = barMaxScrollExtent; + } } - if (itemPos < maxItemCount) { - _currentItem = itemPos; - } + if (notification is ScrollUpdateNotification || + notification is OverscrollNotification) { + if (_thumbAnimationController.status != AnimationStatus.forward) { + _thumbAnimationController.forward(); + } - _fadeoutTimer?.cancel(); - _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () { - _thumbAnimationController.reverse(); - _labelAnimationController.reverse(); - _fadeoutTimer = null; - }); - } + if (itemPos < maxItemCount) { + _currentItem = itemPos; + } + + _fadeoutTimer?.cancel(); + _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () { + _thumbAnimationController.reverse(); + _labelAnimationController.reverse(); + _fadeoutTimer = null; + }); + } + } catch (_) {} }); } diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index ea65031a0c..8ae74ba120 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -262,8 +262,9 @@ class ImmichAssetGridViewState extends ConsumerState { shrinkWrap: widget.shrinkWrap, ); - final child = useDragScrolling + final child = (useDragScrolling && ModalRoute.of(context) != null) ? DraggableScrollbar.semicircle( + viewPortHeight: context.height, scrollStateListener: dragScrolling, itemPositionsListener: _itemPositionsListener, controller: _itemScrollController, @@ -281,6 +282,7 @@ class ImmichAssetGridViewState extends ConsumerState { child: listWidget, ) : listWidget; + return widget.onRefresh == null ? child : appBarOffset() @@ -528,7 +530,7 @@ class ImmichAssetGridViewState extends ConsumerState { Widget build(BuildContext context) { return PopScope( canPop: !(widget.selectionActive && _selectedAssets.isNotEmpty), - onPopInvoked: (didPop) => !didPop ? _deselectAll() : null, + onPopInvokedWithResult: (didPop, _) => !didPop ? _deselectAll() : null, child: Stack( children: [ AssetDragRegion( diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index 455a19fcdb..8e2465fc9c 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -58,7 +58,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { isLabelVisible: serverInfoState.isVersionMismatch || ((user?.isAdmin ?? false) && serverInfoState.isNewReleaseAvailable), - offset: const Offset(2, 2), + offset: const Offset(-2, -12), child: user == null ? const Icon( Icons.face_outlined, @@ -132,7 +132,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { backgroundColor: Colors.transparent, alignment: Alignment.bottomRight, isLabelVisible: indicatorIcon != null, - offset: const Offset(2, 2), + offset: const Offset(-2, -12), child: Icon( Icons.backup_rounded, size: widgetSize, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5c62b95227..14b487ce4d 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -5,23 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "5aaf60d96c4cd00fe7f21594b5ad6a1b699c80a27420f8a837f4d68473ef09e3" + sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 url: "https://pub.dev" source: hosted - version: "68.0.0" + version: "72.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.1.0" + version: "0.3.2" analyzer: dependency: "direct overridden" description: name: analyzer - sha256: "21f1d3720fd1c70316399d5e2bccaebb415c434592d778cce8acb967b8578808" + sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.7.0" analyzer_plugin: dependency: "direct overridden" description: @@ -540,10 +540,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "55b9b229307a10974b26296ff29f2e132256ba4bd74266939118eaefa941cb00" + sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3 url: "https://pub.dev" source: hosted - version: "16.3.3" + version: "17.2.1+2" flutter_local_notifications_linux: dependency: transitive description: @@ -901,18 +901,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -941,10 +941,10 @@ packages: dependency: transitive description: name: macros - sha256: "12e8a9842b5a7390de7a781ec63d793527582398d16ea26c60fed58833c9ae79" + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" url: "https://pub.dev" source: hosted - version: "0.1.0-main.0" + version: "0.1.2-main.4" maplibre_gl: dependency: "direct main" description: @@ -981,10 +981,10 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: "direct overridden" description: @@ -1212,10 +1212,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" plugin_platform_interface: dependency: transitive description: @@ -1537,10 +1537,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" thumbhash: dependency: "direct main" description: @@ -1737,10 +1737,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.4" wakelock_plus: dependency: "direct main" description: @@ -1847,4 +1847,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.22.3" + flutter: ">=3.24.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2551acce48..51a31a24e3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -6,7 +6,7 @@ version: 1.112.1+154 environment: sdk: '>=3.3.0 <4.0.0' - flutter: 3.22.3 + flutter: 3.24.0 dependencies: flutter: @@ -50,7 +50,7 @@ dependencies: device_info_plus: ^9.1.1 connectivity_plus: ^5.0.2 wakelock_plus: ^1.1.4 - flutter_local_notifications: ^16.3.2 + flutter_local_notifications: ^17.2.1+2 timezone: ^0.9.2 octo_image: ^2.0.0 thumbhash: 0.1.0+1 From 32c05ea9507d477c494d1c191539c49e65b3dc25 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 Aug 2024 16:06:16 -0400 Subject: [PATCH 110/723] feat(server): do not automatically download android motion videos (#11774) feat(server): do not automatically download embedded android motion videos --- e2e/docker-compose.yml | 2 -- e2e/src/api/specs/user.e2e-spec.ts | 26 ++++++++++++++++ .../openapi/lib/model/download_response.dart | 14 +++++++-- mobile/openapi/lib/model/download_update.dart | 23 ++++++++++++-- open-api/immich-openapi-specs.json | 10 +++++- open-api/typescript-sdk/src/fetch-client.ts | 2 ++ server/src/dtos/user-preferences.dto.ts | 7 ++++- server/src/entities/user-metadata.entity.ts | 2 ++ server/src/services/download.service.spec.ts | 26 ++++++++++++++++ server/src/services/download.service.ts | 16 ++++++++-- .../download-settings.svelte | 31 +++++++++++++------ web/src/lib/i18n/en.json | 4 ++- web/src/lib/utils/asset-utils.ts | 16 +++++++--- 13 files changed, 151 insertions(+), 28 deletions(-) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 436613d4a8..b45ea4137f 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - name: immich-e2e services: diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 15fe3de3be..1964dc6793 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -236,6 +236,32 @@ describe('/users', () => { const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } }); }); + + it('should require a boolean for download include embedded videos', async () => { + const { status, body } = await request(app) + .put(`/users/me/preferences`) + .send({ download: { includeEmbeddedVideos: 1_234_567.89 } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['download.includeEmbeddedVideos must be a boolean value'])); + }); + + it('should update download include embedded videos', async () => { + const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(before).toMatchObject({ download: { includeEmbeddedVideos: false } }); + + const { status, body } = await request(app) + .put(`/users/me/preferences`) + .send({ download: { includeEmbeddedVideos: true } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ download: { includeEmbeddedVideos: true } }); + + const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } }); + }); }); describe('GET /users/:id', () => { diff --git a/mobile/openapi/lib/model/download_response.dart b/mobile/openapi/lib/model/download_response.dart index 8973e17ebe..25c5159a8b 100644 --- a/mobile/openapi/lib/model/download_response.dart +++ b/mobile/openapi/lib/model/download_response.dart @@ -14,25 +14,31 @@ class DownloadResponse { /// Returns a new [DownloadResponse] instance. DownloadResponse({ required this.archiveSize, + this.includeEmbeddedVideos = false, }); int archiveSize; + bool includeEmbeddedVideos; + @override bool operator ==(Object other) => identical(this, other) || other is DownloadResponse && - other.archiveSize == archiveSize; + other.archiveSize == archiveSize && + other.includeEmbeddedVideos == includeEmbeddedVideos; @override int get hashCode => // ignore: unnecessary_parenthesis - (archiveSize.hashCode); + (archiveSize.hashCode) + + (includeEmbeddedVideos.hashCode); @override - String toString() => 'DownloadResponse[archiveSize=$archiveSize]'; + String toString() => 'DownloadResponse[archiveSize=$archiveSize, includeEmbeddedVideos=$includeEmbeddedVideos]'; Map toJson() { final json = {}; json[r'archiveSize'] = this.archiveSize; + json[r'includeEmbeddedVideos'] = this.includeEmbeddedVideos; return json; } @@ -45,6 +51,7 @@ class DownloadResponse { return DownloadResponse( archiveSize: mapValueOfType(json, r'archiveSize')!, + includeEmbeddedVideos: mapValueOfType(json, r'includeEmbeddedVideos')!, ); } return null; @@ -93,6 +100,7 @@ class DownloadResponse { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'archiveSize', + 'includeEmbeddedVideos', }; } diff --git a/mobile/openapi/lib/model/download_update.dart b/mobile/openapi/lib/model/download_update.dart index 1629706415..2c3839a687 100644 --- a/mobile/openapi/lib/model/download_update.dart +++ b/mobile/openapi/lib/model/download_update.dart @@ -14,6 +14,7 @@ class DownloadUpdate { /// Returns a new [DownloadUpdate] instance. DownloadUpdate({ this.archiveSize, + this.includeEmbeddedVideos, }); /// Minimum value: 1 @@ -25,17 +26,27 @@ class DownloadUpdate { /// int? archiveSize; + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? includeEmbeddedVideos; + @override bool operator ==(Object other) => identical(this, other) || other is DownloadUpdate && - other.archiveSize == archiveSize; + other.archiveSize == archiveSize && + other.includeEmbeddedVideos == includeEmbeddedVideos; @override int get hashCode => // ignore: unnecessary_parenthesis - (archiveSize == null ? 0 : archiveSize!.hashCode); + (archiveSize == null ? 0 : archiveSize!.hashCode) + + (includeEmbeddedVideos == null ? 0 : includeEmbeddedVideos!.hashCode); @override - String toString() => 'DownloadUpdate[archiveSize=$archiveSize]'; + String toString() => 'DownloadUpdate[archiveSize=$archiveSize, includeEmbeddedVideos=$includeEmbeddedVideos]'; Map toJson() { final json = {}; @@ -44,6 +55,11 @@ class DownloadUpdate { } else { // json[r'archiveSize'] = null; } + if (this.includeEmbeddedVideos != null) { + json[r'includeEmbeddedVideos'] = this.includeEmbeddedVideos; + } else { + // json[r'includeEmbeddedVideos'] = null; + } return json; } @@ -56,6 +72,7 @@ class DownloadUpdate { return DownloadUpdate( archiveSize: mapValueOfType(json, r'archiveSize'), + includeEmbeddedVideos: mapValueOfType(json, r'includeEmbeddedVideos'), ); } return null; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index aa0d9fa2bb..63d22aa4f9 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8497,10 +8497,15 @@ "properties": { "archiveSize": { "type": "integer" + }, + "includeEmbeddedVideos": { + "default": false, + "type": "boolean" } }, "required": [ - "archiveSize" + "archiveSize", + "includeEmbeddedVideos" ], "type": "object" }, @@ -8527,6 +8532,9 @@ "archiveSize": { "minimum": 1, "type": "integer" + }, + "includeEmbeddedVideos": { + "type": "boolean" } }, "type": "object" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d270f09e50..077e802b8c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -86,6 +86,7 @@ export type AvatarResponse = { }; export type DownloadResponse = { archiveSize: number; + includeEmbeddedVideos: boolean; }; export type EmailNotificationsResponse = { albumInvite: boolean; @@ -115,6 +116,7 @@ export type AvatarUpdate = { }; export type DownloadUpdate = { archiveSize?: number; + includeEmbeddedVideos?: boolean; }; export type EmailNotificationsUpdate = { albumInvite?: boolean; diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index c3b2c051af..7ccf6cd78b 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -33,12 +33,15 @@ class EmailNotificationsUpdate { albumUpdate?: boolean; } -class DownloadUpdate { +class DownloadUpdate implements Partial { @Optional() @IsInt() @IsPositive() @ApiProperty({ type: 'integer' }) archiveSize?: number; + + @ValidateBoolean({ optional: true }) + includeEmbeddedVideos?: boolean; } class PurchaseUpdate { @@ -104,6 +107,8 @@ class EmailNotificationsResponse { class DownloadResponse { @ApiProperty({ type: 'integer' }) archiveSize!: number; + + includeEmbeddedVideos: boolean = false; } class PurchaseResponse { diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index 2dcb570935..eadcdeec57 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -35,6 +35,7 @@ export interface UserPreferences { }; download: { archiveSize: number; + includeEmbeddedVideos: boolean; }; purchase: { showSupportBadge: boolean; @@ -65,6 +66,7 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences }, download: { archiveSize: HumanReadableSize.GiB * 4, + includeEmbeddedVideos: false, }, purchase: { showSupportBadge: true, diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 2d3c11a6f1..14fa7bab48 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -226,5 +226,31 @@ describe(DownloadService.name, () => { ], }); }); + + it('should skip the video portion of an android live photo by default', async () => { + const assetIds = [assetStub.livePhotoStillAsset.id]; + const assets = [ + assetStub.livePhotoStillAsset, + { ...assetStub.livePhotoMotionAsset, originalPath: 'upload/encoded-video/uuid-MP.mp4' }, + ]; + + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); + assetMock.getByIds.mockImplementation( + (ids) => + Promise.resolve( + ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), + ) as Promise, + ); + + await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ + totalSize: 25_000, + archives: [ + { + assetIds: [assetStub.livePhotoStillAsset.id], + size: 25_000, + }, + ], + }); + }); }); }); diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 157142d906..1ff9e51576 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { parse } from 'node:path'; import { AccessCore } from 'src/cores/access.core'; +import { StorageCore } from 'src/cores/storage.core'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; @@ -12,6 +13,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ImmichReadStream, IStorageRepository } from 'src/interfaces/storage.interface'; import { HumanReadableSize } from 'src/utils/bytes'; import { usePagination } from 'src/utils/pagination'; +import { getPreferences } from 'src/utils/preferences'; @Injectable() export class DownloadService { @@ -32,12 +34,22 @@ export class DownloadService { const archives: DownloadArchiveInfo[] = []; let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; + const preferences = getPreferences(auth.user); + const assetPagination = await this.getDownloadAssets(auth, dto); for await (const assets of assetPagination) { // motion part of live photos - const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id); + const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id); if (motionIds.length > 0) { - assets.push(...(await this.assetRepository.getByIds(motionIds, { exifInfo: true }))); + const motionAssets = await this.assetRepository.getByIds(motionIds, { exifInfo: true }); + for (const motionAsset of motionAssets) { + if ( + !StorageCore.isAndroidMotionPath(motionAsset.originalPath) || + preferences.download.includeEmbeddedVideos + ) { + assets.push(motionAsset); + } + } } for (const asset of assets) { diff --git a/web/src/lib/components/user-settings-page/download-settings.svelte b/web/src/lib/components/user-settings-page/download-settings.svelte index f103f348fc..f5b94ebee8 100644 --- a/web/src/lib/components/user-settings-page/download-settings.svelte +++ b/web/src/lib/components/user-settings-page/download-settings.svelte @@ -14,13 +14,21 @@ SettingInputFieldType, } from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; let archiveSize = convertFromBytes($preferences?.download?.archiveSize || 4, ByteUnit.GiB); + let includeEmbeddedVideos = $preferences?.download?.includeEmbeddedVideos || false; const handleSave = async () => { try { - const dto = { download: { archiveSize: Math.floor(convertToBytes(archiveSize, ByteUnit.GiB)) } }; - const newPreferences = await updateMyPreferences({ userPreferencesUpdateDto: dto }); + const newPreferences = await updateMyPreferences({ + userPreferencesUpdateDto: { + download: { + archiveSize: Math.floor(convertToBytes(archiveSize, ByteUnit.GiB)), + includeEmbeddedVideos, + }, + }, + }); $preferences = newPreferences; notificationController.show({ message: $t('saved_settings'), type: NotificationType.Info }); @@ -34,14 +42,17 @@
    -
    - -
    + +
    diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 5b2d9d393a..2b97cb6e24 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -368,7 +368,7 @@ "appears_in": "Appears in", "archive": "Archive", "archive_or_unarchive_photo": "Archive or unarchive photo", - "archive_size": "Archive Size", + "archive_size": "Archive size", "archive_size_description": "Configure the archive size for downloads (in GiB)", "archived_count": "{count, plural, other {Archived #}}", "are_these_the_same_person": "Are these the same person?", @@ -512,6 +512,8 @@ "do_not_show_again": "Do not show this message again", "done": "Done", "download": "Download", + "download_include_embedded_motion_videos": "Embedded videos", + "download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file", "download_settings": "Download", "download_settings_description": "Manage settings related to asset download", "downloading": "Downloading", diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index a23c369009..74a695770e 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -172,13 +172,19 @@ export const downloadFile = async (asset: AssetResponseDto) => { }, ]; + const isAndroidMotionVideo = (asset: AssetResponseDto) => { + return asset.originalPath.includes('encoded-video'); + }; + if (asset.livePhotoVideoId) { const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: getKey() }); - assets.push({ - filename: motionAsset.originalFileName, - id: asset.livePhotoVideoId, - size: motionAsset.exifInfo?.fileSizeInByte || 0, - }); + if (!isAndroidMotionVideo(motionAsset) || get(preferences).download.includeEmbeddedVideos) { + assets.push({ + filename: motionAsset.originalFileName, + id: asset.livePhotoVideoId, + size: motionAsset.exifInfo?.fileSizeInByte || 0, + }); + } } for (const { filename, id, size } of assets) { From 433c7ab01d78ab184ecda14bb6d517b26f9fa7ec Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 Aug 2024 16:12:41 -0400 Subject: [PATCH 111/723] refactor: server emit events (#11780) --- server/src/app.module.ts | 22 +++++++-- server/src/decorators.ts | 9 ++-- server/src/interfaces/event.interface.ts | 42 ++++++----------- server/src/middleware/auth.guard.ts | 2 +- server/src/repositories/event.repository.ts | 18 +++++--- server/src/services/album.service.spec.ts | 6 +-- server/src/services/album.service.ts | 4 +- server/src/services/database.service.spec.ts | 34 +++++++------- server/src/services/database.service.ts | 9 ++-- server/src/services/library.service.spec.ts | 26 +++++------ server/src/services/library.service.ts | 13 ++++-- server/src/services/metadata.service.spec.ts | 6 +-- server/src/services/metadata.service.ts | 14 ++++-- server/src/services/microservices.service.ts | 8 ++-- .../src/services/notification.service.spec.ts | 16 +++---- server/src/services/notification.service.ts | 23 +++++----- server/src/services/server.service.ts | 7 +-- .../src/services/smart-info.service.spec.ts | 26 +++++------ server/src/services/smart-info.service.ts | 14 ++++-- .../services/storage-template.service.spec.ts | 6 +-- .../src/services/storage-template.service.ts | 8 ++-- server/src/services/storage.service.spec.ts | 4 +- server/src/services/storage.service.ts | 7 +-- server/src/services/system-config.service.ts | 23 ++++------ server/src/services/user-admin.service.ts | 2 +- server/src/services/version.service.ts | 9 ++-- server/src/utils/events.ts | 46 ++++++++++++++----- 27 files changed, 222 insertions(+), 182 deletions(-) diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 541f7dc659..1a8a05fd4d 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -5,6 +5,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@ne import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; +import _ from 'lodash'; import { ClsModule } from 'nestjs-cls'; import { OpenTelemetryModule } from 'nestjs-otel'; import { commands } from 'src/commands'; @@ -13,6 +14,7 @@ import { controllers } from 'src/controllers'; import { databaseConfig } from 'src/database.config'; import { entities } from 'src/entities'; import { IEventRepository } from 'src/interfaces/event.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthGuard } from 'src/middleware/auth.guard'; import { ErrorInterceptor } from 'src/middleware/error.interceptor'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; @@ -54,15 +56,25 @@ export class ApiModule implements OnModuleInit, OnModuleDestroy { constructor( private moduleRef: ModuleRef, @Inject(IEventRepository) private eventRepository: IEventRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, ) {} async onModuleInit() { - setupEventHandlers(this.moduleRef); - await this.eventRepository.emit('onBootstrapEvent', 'api'); + const items = setupEventHandlers(this.moduleRef); + + await this.eventRepository.emit('onBootstrap', 'api'); + + this.logger.setContext('EventLoader'); + const eventMap = _.groupBy(items, 'event'); + for (const [event, handlers] of Object.entries(eventMap)) { + for (const { priority, label } of handlers) { + this.logger.verbose(`Added ${event} {${label}${priority ? '' : ', ' + priority}} event`); + } + } } async onModuleDestroy() { - await this.eventRepository.emit('onShutdownEvent'); + await this.eventRepository.emit('onShutdown'); } } @@ -78,11 +90,11 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy { async onModuleInit() { setupEventHandlers(this.moduleRef); - await this.eventRepository.emit('onBootstrapEvent', 'microservices'); + await this.eventRepository.emit('onBootstrap', 'microservices'); } async onModuleDestroy() { - await this.eventRepository.emit('onShutdownEvent'); + await this.eventRepository.emit('onShutdown'); } } diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 1c632e549a..2316e114e8 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -4,7 +4,7 @@ import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces'; import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; -import { ServerEvent } from 'src/interfaces/event.interface'; +import { EmitEvent, ServerEvent } from 'src/interfaces/event.interface'; import { Metadata } from 'src/middleware/auth.guard'; import { setUnion } from 'src/utils/set'; @@ -136,11 +136,12 @@ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GEN export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) => OnEvent(event, { suppressErrors: false, ...options }); -export type HandlerOptions = { +export type EmitConfig = { + event: EmitEvent; /** lower value has higher priority, defaults to 0 */ - priority: number; + priority?: number; }; -export const EventHandlerOptions = (options: HandlerOptions) => SetMetadata(Metadata.EVENT_HANDLER_OPTIONS, options); +export const OnEmit = (config: EmitConfig) => SetMetadata(Metadata.ON_EMIT_CONFIG, config); type LifecycleRelease = 'NEXT_RELEASE' | string; type LifecycleMetadata = { diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 828531fdf3..613a6423a4 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -4,41 +4,27 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d export const IEventRepository = 'IEventRepository'; -export type SystemConfigUpdateEvent = { newConfig: SystemConfig; oldConfig: SystemConfig }; -export type AlbumUpdateEvent = { - id: string; - /** user id */ - updatedBy: string; -}; -export type AlbumInviteEvent = { id: string; userId: string }; -export type UserSignupEvent = { notify: boolean; id: string; tempPassword?: string }; - -type MaybePromise = Promise | T; -type Handler = (data: T) => MaybePromise; - -const noop = () => {}; -const dummyHandlers = { +type EmitEventMap = { // app events - onBootstrapEvent: noop as Handler<'api' | 'microservices'>, - onShutdownEvent: noop as () => MaybePromise, + onBootstrap: ['api' | 'microservices']; + onShutdown: []; // config events - onConfigUpdateEvent: noop as Handler, - onConfigValidateEvent: noop as Handler, + onConfigUpdate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + onConfigValidate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; // album events - onAlbumUpdateEvent: noop as Handler, - onAlbumInviteEvent: noop as Handler, + onAlbumUpdate: [{ id: string; updatedBy: string }]; + onAlbumInvite: [{ id: string; userId: string }]; // user events - onUserSignupEvent: noop as Handler, + onUserSignup: [{ notify: boolean; id: string; tempPassword?: string }]; }; -export type EventHandlers = typeof dummyHandlers; -export type EmitEvent = keyof EventHandlers; -export type EmitEventHandler = (...args: Parameters) => MaybePromise; -export const events = Object.keys(dummyHandlers) as EmitEvent[]; -export type OnEvents = Partial; +export type EmitEvent = keyof EmitEventMap; +export type EmitHandler = (...args: ArgsOf) => Promise | void; +export type ArgOf = EmitEventMap[T][0]; +export type ArgsOf = EmitEventMap[T]; export enum ClientEvent { UPLOAD_SUCCESS = 'on_upload_success', @@ -81,8 +67,8 @@ export interface ServerEventMap { } export interface IEventRepository { - on(event: T, handler: EmitEventHandler): void; - emit(event: T, ...args: Parameters>): Promise; + on(event: T, handler: EmitHandler): void; + emit(event: T, ...args: ArgsOf): Promise; /** * Send to connected clients for a specific user diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index c4aa928dbd..beab484950 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -20,7 +20,7 @@ export enum Metadata { ADMIN_ROUTE = 'admin_route', SHARED_ROUTE = 'shared_route', API_KEY_SECURITY = 'api_key', - EVENT_HANDLER_OPTIONS = 'event_handler_options', + ON_EMIT_CONFIG = 'on_emit_config', } type AdminRoute = { admin?: true }; diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 0bb973b293..668eac48d9 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -9,9 +9,10 @@ import { } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { + ArgsOf, ClientEventMap, EmitEvent, - EmitEventHandler, + EmitHandler, IEventRepository, ServerEvent, ServerEventMap, @@ -20,6 +21,8 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService } from 'src/services/auth.service'; import { Instrumentation } from 'src/utils/instrumentation'; +type EmitHandlers = Partial<{ [T in EmitEvent]: EmitHandler[] }>; + @Instrumentation() @WebSocketGateway({ cors: true, @@ -28,7 +31,7 @@ import { Instrumentation } from 'src/utils/instrumentation'; }) @Injectable() export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, IEventRepository { - private emitHandlers: Partial[]>> = {}; + private emitHandlers: EmitHandlers = {}; @WebSocketServer() private server?: Server; @@ -78,12 +81,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect await client.leave(client.nsp.name); } - on(event: T, handler: EmitEventHandler): void { - const handlers: EmitEventHandler[] = this.emitHandlers[event] || []; - this.emitHandlers[event] = [...handlers, handler]; + on(event: T, handler: EmitHandler): void { + if (!this.emitHandlers[event]) { + this.emitHandlers[event] = []; + } + + this.emitHandlers[event].push(handler); } - async emit(event: T, ...args: Parameters>): Promise { + async emit(event: T, ...args: ArgsOf): Promise { const handlers = this.emitHandlers[event] || []; for (const handler of handlers) { await handler(...args); diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 41f8930733..6db39328df 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -380,7 +380,7 @@ describe(AlbumService.name, () => { userId: authStub.user2.user.id, albumId: albumStub.sharedWithAdmin.id, }); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInviteEvent', { + expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInvite', { id: albumStub.sharedWithAdmin.id, userId: userStub.user2.id, }); @@ -568,7 +568,7 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdateEvent', { + expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdate', { id: 'album-123', updatedBy: authStub.admin.user.id, }); @@ -612,7 +612,7 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdateEvent', { + expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdate', { id: 'album-123', updatedBy: authStub.user1.user.id, }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index f8108ad065..71594d20b6 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -187,7 +187,7 @@ export class AlbumService { albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId, }); - await this.eventRepository.emit('onAlbumUpdateEvent', { id, updatedBy: auth.user.id }); + await this.eventRepository.emit('onAlbumUpdate', { id, updatedBy: auth.user.id }); } return results; @@ -235,7 +235,7 @@ export class AlbumService { } await this.albumUserRepository.create({ userId: userId, albumId: id, role }); - await this.eventRepository.emit('onAlbumInviteEvent', { id, userId }); + await this.eventRepository.emit('onAlbumInvite', { id, userId }); } return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets); diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index a21b1d7d67..c63428560e 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -45,7 +45,7 @@ describe(DatabaseService.name, () => { it('should throw an error if PostgreSQL version is below minimum supported version', async () => { databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0'); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); + await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); }); @@ -65,7 +65,7 @@ describe(DatabaseService.name, () => { availableVersion: minVersionInRange, }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); expect(databaseMock.createExtension).toHaveBeenCalledWith(extension); @@ -79,7 +79,7 @@ describe(DatabaseService.name, () => { databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); const message = `The ${extensionName} extension is not available in this Postgres instance. If using a container image, ensure the image has the extension installed.`; - await expect(sut.onBootstrapEvent()).rejects.toThrow(message); + await expect(sut.onBootstrap()).rejects.toThrow(message); expect(databaseMock.createExtension).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled(); @@ -91,7 +91,7 @@ describe(DatabaseService.name, () => { availableVersion: versionBelowRange, }); - await expect(sut.onBootstrapEvent()).rejects.toThrow( + await expect(sut.onBootstrap()).rejects.toThrow( `The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`, ); @@ -101,7 +101,7 @@ describe(DatabaseService.name, () => { it(`should throw an error if ${extension} extension version is a nightly`, async () => { databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); - await expect(sut.onBootstrapEvent()).rejects.toThrow( + await expect(sut.onBootstrap()).rejects.toThrow( `The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`, ); @@ -117,7 +117,7 @@ describe(DatabaseService.name, () => { }); databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); @@ -132,7 +132,7 @@ describe(DatabaseService.name, () => { installedVersion: minVersionInRange, }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); @@ -145,7 +145,7 @@ describe(DatabaseService.name, () => { installedVersion: null, }); - await expect(sut.onBootstrapEvent()).rejects.toThrow(); + await expect(sut.onBootstrap()).rejects.toThrow(); expect(databaseMock.createExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); @@ -159,7 +159,7 @@ describe(DatabaseService.name, () => { installedVersion: minVersionInRange, }); - await expect(sut.onBootstrapEvent()).rejects.toThrow(); + await expect(sut.onBootstrap()).rejects.toThrow(); expect(databaseMock.createExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); @@ -173,7 +173,7 @@ describe(DatabaseService.name, () => { installedVersion: updateInRange, }); - await expect(sut.onBootstrapEvent()).rejects.toThrow( + await expect(sut.onBootstrap()).rejects.toThrow( `The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`, ); @@ -189,7 +189,7 @@ describe(DatabaseService.name, () => { }); databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to update extension'); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension'); expect(loggerMock.warn.mock.calls[0][0]).toContain( `The ${extensionName} extension can be updated to ${updateInRange}.`, @@ -206,7 +206,7 @@ describe(DatabaseService.name, () => { }); databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(loggerMock.warn).toHaveBeenCalledTimes(1); expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName); @@ -218,7 +218,7 @@ describe(DatabaseService.name, () => { it(`should reindex ${extension} indices if needed`, async () => { databaseMock.shouldReindex.mockResolvedValue(true); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); expect(databaseMock.reindex).toHaveBeenCalledTimes(2); @@ -229,7 +229,7 @@ describe(DatabaseService.name, () => { it(`should not reindex ${extension} indices if not needed`, async () => { databaseMock.shouldReindex.mockResolvedValue(false); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); expect(databaseMock.reindex).toHaveBeenCalledTimes(0); @@ -240,7 +240,7 @@ describe(DatabaseService.name, () => { it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { process.env.DB_SKIP_MIGRATIONS = 'true'; - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); @@ -255,7 +255,7 @@ describe(DatabaseService.name, () => { databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to create extension'); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); expect(loggerMock.fatal).toHaveBeenCalledTimes(1); expect(loggerMock.fatal.mock.calls[0][0]).toContain( @@ -274,7 +274,7 @@ describe(DatabaseService.name, () => { databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to create extension'); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); expect(loggerMock.fatal).toHaveBeenCalledTimes(1); expect(loggerMock.fatal.mock.calls[0][0]).toContain( diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index a2f43c58ba..b6d61c578d 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import semver from 'semver'; import { getVectorExtension } from 'src/database.config'; -import { EventHandlerOptions } from 'src/decorators'; +import { OnEmit } from 'src/decorators'; import { DatabaseExtension, DatabaseLock, @@ -10,7 +10,6 @@ import { VectorExtension, VectorIndex, } from 'src/interfaces/database.interface'; -import { OnEvents } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; type CreateFailedArgs = { name: string; extension: string; otherName: string }; @@ -61,7 +60,7 @@ const messages = { }; @Injectable() -export class DatabaseService implements OnEvents { +export class DatabaseService { constructor( @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @@ -69,8 +68,8 @@ export class DatabaseService implements OnEvents { this.logger.setContext(DatabaseService.name); } - @EventHandlerOptions({ priority: -200 }) - async onBootstrapEvent() { + @OnEmit({ event: 'onBootstrap', priority: -200 }) + async onBootstrap() { const version = await this.databaseRepository.getPostgresVersion(); const current = semver.coerce(version); const postgresRange = this.databaseRepository.getPostgresVersionRange(); diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 7f81fd44aa..8a74ec9189 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -73,7 +73,7 @@ describe(LibraryService.name, () => { it('should init cron job and subscribe to config changes', async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryScan); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); expect(systemMock.get).toHaveBeenCalled(); expect(jobMock.addCronJob).toHaveBeenCalled(); @@ -105,7 +105,7 @@ describe(LibraryService.name, () => { ), ); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); expect(storageMock.watch.mock.calls).toEqual( expect.arrayContaining([ @@ -118,7 +118,7 @@ describe(LibraryService.name, () => { it('should not initialize watcher when watching is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); expect(storageMock.watch).not.toHaveBeenCalled(); }); @@ -127,7 +127,7 @@ describe(LibraryService.name, () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); databaseMock.tryLock.mockResolvedValue(false); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); expect(storageMock.watch).not.toHaveBeenCalled(); }); @@ -136,7 +136,7 @@ describe(LibraryService.name, () => { describe('onConfigValidateEvent', () => { it('should allow a valid cron expression', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { library: { scan: { cronExpression: '0 0 * * *' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -145,7 +145,7 @@ describe(LibraryService.name, () => { it('should fail for an invalid cron expression', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { library: { scan: { cronExpression: 'foo' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -730,7 +730,7 @@ describe(LibraryService.name, () => { const mockClose = vitest.fn(); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); await sut.delete(libraryStub.externalLibraryWithImportPaths1.id); expect(mockClose).toHaveBeenCalled(); @@ -861,7 +861,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([]); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); await sut.create({ ownerId: authStub.admin.user.id, importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, @@ -917,7 +917,7 @@ describe(LibraryService.name, () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.getAll.mockResolvedValue([]); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); }); it('should update library', async () => { @@ -933,7 +933,7 @@ describe(LibraryService.name, () => { beforeEach(async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); }); it('should not watch library', async () => { @@ -949,7 +949,7 @@ describe(LibraryService.name, () => { beforeEach(async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.getAll.mockResolvedValue([]); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); }); it('should watch library', async () => { @@ -1107,8 +1107,8 @@ describe(LibraryService.name, () => { const mockClose = vitest.fn(); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); - await sut.onBootstrapEvent(); - await sut.onShutdownEvent(); + await sut.onBootstrap(); + await sut.onShutdown(); expect(mockClose).toHaveBeenCalledTimes(2); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index f0d7fe8cd4..1bee2d32c3 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -6,6 +6,7 @@ import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { CreateLibraryDto, LibraryResponseDto, @@ -22,7 +23,7 @@ import { AssetType } from 'src/enum'; import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IBaseJob, IEntityJob, @@ -45,7 +46,7 @@ import { validateCronExpression } from 'src/validation'; const LIBRARY_SCAN_BATCH_SIZE = 5000; @Injectable() -export class LibraryService implements OnEvents { +export class LibraryService { private configCore: SystemConfigCore; private watchLibraries = false; private watchLock = false; @@ -65,7 +66,8 @@ export class LibraryService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async onBootstrapEvent() { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap() { const config = await this.configCore.getConfig({ withCache: false }); const { watch, scan } = config.library; @@ -102,7 +104,7 @@ export class LibraryService implements OnEvents { }); } - onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) { + onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { const { scan } = newConfig.library; if (!validateCronExpression(scan.cronExpression)) { throw new Error(`Invalid cron expression ${scan.cronExpression}`); @@ -187,7 +189,8 @@ export class LibraryService implements OnEvents { } } - async onShutdownEvent() { + @OnEmit({ event: 'onShutdown' }) + async onShutdown() { await this.unwatchAll(); } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 522e1320fd..05f6f9f658 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -95,7 +95,7 @@ describe(MetadataService.name, () => { }); afterEach(async () => { - await sut.onShutdownEvent(); + await sut.onShutdown(); }); it('should be defined', () => { @@ -104,7 +104,7 @@ describe(MetadataService.name, () => { describe('onBootstrapEvent', () => { it('should pause and resume queue during init', async () => { - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(jobMock.pause).toHaveBeenCalledTimes(1); expect(mapMock.init).toHaveBeenCalledTimes(1); @@ -114,7 +114,7 @@ describe(MetadataService.name, () => { it('should return if reverse geocoding is disabled', async () => { systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: false } }); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(jobMock.pause).not.toHaveBeenCalled(); expect(mapMock.init).not.toHaveBeenCalled(); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 041b35c02c..f1d367fb7b 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -8,6 +8,7 @@ import path from 'node:path'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { AssetType } from 'src/enum'; @@ -15,7 +16,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { ClientEvent, IEventRepository, OnEvents } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IBaseJob, IEntityJob, @@ -86,7 +87,7 @@ const validate = (value: T): NonNullable | null => { }; @Injectable() -export class MetadataService implements OnEvents { +export class MetadataService { private storageCore: StorageCore; private configCore: SystemConfigCore; @@ -120,7 +121,8 @@ export class MetadataService implements OnEvents { ); } - async onBootstrapEvent(app: 'api' | 'microservices') { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(app: ArgOf<'onBootstrap'>) { if (app !== 'microservices') { return; } @@ -128,7 +130,8 @@ export class MetadataService implements OnEvents { await this.init(config); } - async onConfigUpdateEvent({ newConfig }: { newConfig: SystemConfig }) { + @OnEmit({ event: 'onConfigUpdate' }) + async onConfigUpdate({ newConfig }: ArgOf<'onConfigUpdate'>) { await this.init(newConfig); } @@ -150,7 +153,8 @@ export class MetadataService implements OnEvents { } } - async onShutdownEvent() { + @OnEmit({ event: 'onShutdown' }) + async onShutdown() { await this.repository.teardown(); } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index fe1f4edc07..46ca4118d1 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { OnEvents } from 'src/interfaces/event.interface'; +import { OnEmit } from 'src/decorators'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; @@ -19,7 +20,7 @@ import { VersionService } from 'src/services/version.service'; import { otelShutdown } from 'src/utils/instrumentation'; @Injectable() -export class MicroservicesService implements OnEvents { +export class MicroservicesService { constructor( private auditService: AuditService, private assetService: AssetService, @@ -38,7 +39,8 @@ export class MicroservicesService implements OnEvents { private versionService: VersionService, ) {} - async onBootstrapEvent(app: 'api' | 'microservices') { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(app: ArgOf<'onBootstrap'>) { if (app !== 'microservices') { return; } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index f10c79c579..74d2a12127 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -90,7 +90,7 @@ describe(NotificationService.name, () => { const newConfig = configs.smtpEnabled; notificationMock.verifySmtp.mockResolvedValue(true); - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); @@ -99,7 +99,7 @@ describe(NotificationService.name, () => { const newConfig = configs.smtpTransport; notificationMock.verifySmtp.mockResolvedValue(true); - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); @@ -107,7 +107,7 @@ describe(NotificationService.name, () => { const oldConfig = { ...configs.smtpEnabled }; const newConfig = { ...configs.smtpEnabled }; - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); }); @@ -115,19 +115,19 @@ describe(NotificationService.name, () => { const oldConfig = { ...configs.smtpEnabled }; const newConfig = { ...configs.smtpDisabled }; - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); }); }); describe('onUserSignupEvent', () => { it('skips when notify is false', async () => { - await sut.onUserSignupEvent({ id: '', notify: false }); + await sut.onUserSignup({ id: '', notify: false }); expect(jobMock.queue).not.toHaveBeenCalled(); }); it('should queue notify signup event if notify is true', async () => { - await sut.onUserSignupEvent({ id: '', notify: true }); + await sut.onUserSignup({ id: '', notify: true }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_SIGNUP, data: { id: '', tempPassword: undefined }, @@ -137,7 +137,7 @@ describe(NotificationService.name, () => { describe('onAlbumUpdateEvent', () => { it('should queue notify album update event', async () => { - await sut.onAlbumUpdateEvent({ id: '', updatedBy: '42' }); + await sut.onAlbumUpdate({ id: '', updatedBy: '42' }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id: '', senderId: '42' }, @@ -147,7 +147,7 @@ describe(NotificationService.name, () => { describe('onAlbumInviteEvent', () => { it('should queue notify album invite event', async () => { - await sut.onAlbumInviteEvent({ id: '', userId: '42' }); + await sut.onAlbumInvite({ id: '', userId: '42' }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id: '', recipientId: '42' }, diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index c5f9a4f9f7..80abc4ca98 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -2,17 +2,12 @@ import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; import { isEqual } from 'lodash'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { - AlbumInviteEvent, - AlbumUpdateEvent, - OnEvents, - SystemConfigUpdateEvent, - UserSignupEvent, -} from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IEmailJob, IJobRepository, @@ -30,7 +25,7 @@ import { getFilenameExtension } from 'src/utils/file'; import { getPreferences } from 'src/utils/preferences'; @Injectable() -export class NotificationService implements OnEvents { +export class NotificationService { private configCore: SystemConfigCore; constructor( @@ -46,7 +41,8 @@ export class NotificationService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } - async onConfigValidateEvent({ oldConfig, newConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigValidate', priority: -100 }) + async onConfigValidate({ oldConfig, newConfig }: ArgOf<'onConfigValidate'>) { try { if ( newConfig.notifications.smtp.enabled && @@ -60,17 +56,20 @@ export class NotificationService implements OnEvents { } } - async onUserSignupEvent({ notify, id, tempPassword }: UserSignupEvent) { + @OnEmit({ event: 'onUserSignup' }) + async onUserSignup({ notify, id, tempPassword }: ArgOf<'onUserSignup'>) { if (notify) { await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } }); } } - async onAlbumUpdateEvent({ id, updatedBy }: AlbumUpdateEvent) { + @OnEmit({ event: 'onAlbumUpdate' }) + async onAlbumUpdate({ id, updatedBy }: ArgOf<'onAlbumUpdate'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } }); } - async onAlbumInviteEvent({ id, userId }: AlbumInviteEvent) { + @OnEmit({ event: 'onAlbumInvite' }) + async onAlbumInvite({ id, userId }: ArgOf<'onAlbumInvite'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 67e19eda78..faf4d98164 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -3,6 +3,7 @@ import { getBuildMetadata, getServerLicensePublicKey } from 'src/config'; import { serverVersion } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, @@ -16,7 +17,6 @@ import { } from 'src/dtos/server.dto'; import { SystemMetadataKey } from 'src/enum'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { OnEvents } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -27,7 +27,7 @@ import { mimeTypes } from 'src/utils/mime-types'; import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() -export class ServerService implements OnEvents { +export class ServerService { private configCore: SystemConfigCore; constructor( @@ -42,7 +42,8 @@ export class ServerService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async onBootstrapEvent(): Promise { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index f18dc91ff1..278e06d287 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -49,7 +49,7 @@ describe(SmartInfoService.name, () => { describe('onConfigValidateEvent', () => { it('should allow a valid model', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-B-16__openai' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -58,7 +58,7 @@ describe(SmartInfoService.name, () => { it('should allow including organization', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { machineLearning: { clip: { modelName: 'immich-app/ViT-B-16__openai' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -67,7 +67,7 @@ describe(SmartInfoService.name, () => { it('should fail for an unsupported model', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { machineLearning: { clip: { modelName: 'test-model' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -77,7 +77,7 @@ describe(SmartInfoService.name, () => { describe('onBootstrapEvent', () => { it('should return if not microservices', async () => { - await sut.onBootstrapEvent('api'); + await sut.onBootstrap('api'); expect(systemMock.get).not.toHaveBeenCalled(); expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); @@ -92,7 +92,7 @@ describe(SmartInfoService.name, () => { it('should return if machine learning is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); @@ -107,7 +107,7 @@ describe(SmartInfoService.name, () => { it('should return if model and DB dimension size are equal', async () => { searchMock.getDimensionSize.mockResolvedValue(512); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); @@ -123,7 +123,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(768); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); @@ -138,7 +138,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(768); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); @@ -154,7 +154,7 @@ describe(SmartInfoService.name, () => { it('should return if machine learning is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig, oldConfig: systemConfigStub.machineLearningDisabled as SystemConfig, }); @@ -172,7 +172,7 @@ describe(SmartInfoService.name, () => { it('should return if model and DB dimension size are equal', async () => { searchMock.getDimensionSize.mockResolvedValue(512); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-B-16__openai', enabled: true }, enabled: true }, } as SystemConfig, @@ -194,7 +194,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(512); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-L-14-quickgelu__dfn2b', enabled: true }, enabled: true }, } as SystemConfig, @@ -215,7 +215,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(512); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true }, } as SystemConfig, @@ -237,7 +237,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(512); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true }, } as SystemConfig, diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 1957f3885c..883f320abf 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,9 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IBaseJob, IEntityJob, @@ -21,7 +22,7 @@ import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class SmartInfoService implements OnEvents { +export class SmartInfoService { private configCore: SystemConfigCore; constructor( @@ -37,7 +38,8 @@ export class SmartInfoService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async onBootstrapEvent(app: 'api' | 'microservices') { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(app: ArgOf<'onBootstrap'>) { if (app !== 'microservices') { return; } @@ -46,7 +48,8 @@ export class SmartInfoService implements OnEvents { await this.init(config); } - onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigValidate' }) + onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { try { getCLIPModelInfo(newConfig.machineLearning.clip.modelName); } catch { @@ -56,7 +59,8 @@ export class SmartInfoService implements OnEvents { } } - async onConfigUpdateEvent({ oldConfig, newConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigUpdate' }) + async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'onConfigUpdate'>) { await this.init(newConfig, oldConfig); } diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 7a9b9952e0..c1e0410a3d 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -76,10 +76,10 @@ describe(StorageTemplateService.name, () => { SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults); }); - describe('onConfigValidateEvent', () => { + describe('onConfigValidate', () => { it('should allow valid templates', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { storageTemplate: { template: @@ -93,7 +93,7 @@ describe(StorageTemplateService.name, () => { it('should fail for an invalid template', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { storageTemplate: { template: '{{foo}}', diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 599f5e10a5..0ee5bdd3b5 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -15,6 +15,7 @@ import { } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/entities/move.entity'; import { AssetType } from 'src/enum'; @@ -22,7 +23,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; @@ -46,7 +47,7 @@ interface RenderMetadata { } @Injectable() -export class StorageTemplateService implements OnEvents { +export class StorageTemplateService { private configCore: SystemConfigCore; private storageCore: StorageCore; private _template: { @@ -88,7 +89,8 @@ export class StorageTemplateService implements OnEvents { ); } - onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigValidate' }) + onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { try { const { compiled } = this.compile(newConfig.storageTemplate.template); this.render(compiled, { diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index 5ce6d92d26..d9b4c8eefb 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -20,9 +20,9 @@ describe(StorageService.name, () => { expect(sut).toBeDefined(); }); - describe('onBootstrapEvent', () => { + describe('onBootstrap', () => { it('should create the library folder on initialization', () => { - sut.onBootstrapEvent(); + sut.onBootstrap(); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); }); }); diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 8222d7c46d..1535d53d95 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,12 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { OnEvents } from 'src/interfaces/event.interface'; +import { OnEmit } from 'src/decorators'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @Injectable() -export class StorageService implements OnEvents { +export class StorageService { constructor( @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @@ -14,7 +14,8 @@ export class StorageService implements OnEvents { this.logger.setContext(StorageService.name); } - onBootstrapEvent() { + @OnEmit({ event: 'onBootstrap' }) + onBootstrap() { const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); this.storageRepository.mkdirSync(libraryBase); } diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 5aa800a224..b4e6f903b1 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -13,20 +13,14 @@ import { supportedYearTokens, } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { EventHandlerOptions, OnServerEvent } from 'src/decorators'; +import { OnEmit, OnServerEvent } from 'src/decorators'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; -import { - ClientEvent, - IEventRepository, - OnEvents, - ServerEvent, - SystemConfigUpdateEvent, -} from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @Injectable() -export class SystemConfigService implements OnEvents { +export class SystemConfigService { private core: SystemConfigCore; constructor( @@ -39,8 +33,8 @@ export class SystemConfigService implements OnEvents { this.core.config$.subscribe((config) => this.setLogLevel(config)); } - @EventHandlerOptions({ priority: -100 }) - async onBootstrapEvent() { + @OnEmit({ event: 'onBootstrap', priority: -100 }) + async onBootstrap() { const config = await this.core.getConfig({ withCache: false }); this.core.config$.next(config); } @@ -54,7 +48,8 @@ export class SystemConfigService implements OnEvents { return mapConfig(defaults); } - onConfigValidateEvent({ newConfig, oldConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigValidate' }) + onConfigValidate({ newConfig, oldConfig }: ArgOf<'onConfigValidate'>) { if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); } @@ -68,7 +63,7 @@ export class SystemConfigService implements OnEvents { const oldConfig = await this.core.getConfig({ withCache: false }); try { - await this.eventRepository.emit('onConfigValidateEvent', { newConfig: dto, oldConfig }); + await this.eventRepository.emit('onConfigValidate', { newConfig: dto, oldConfig }); } catch (error) { this.logger.warn(`Unable to save system config due to a validation error: ${error}`); throw new BadRequestException(error instanceof Error ? error.message : error); @@ -79,7 +74,7 @@ export class SystemConfigService implements OnEvents { // TODO probably move web socket emits to a separate service this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null); - await this.eventRepository.emit('onConfigUpdateEvent', { newConfig, oldConfig }); + await this.eventRepository.emit('onConfigUpdate', { newConfig, oldConfig }); return mapConfig(newConfig); } diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 76ae3dd23a..95eeed0475 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -45,7 +45,7 @@ export class UserAdminService { const { notify, ...rest } = dto; const user = await this.userCore.createUser(rest); - await this.eventRepository.emit('onUserSignupEvent', { + await this.eventRepository.emit('onUserSignup', { notify: !!notify, id: user.id, tempPassword: user.shouldChangePassword ? rest.password : undefined, diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 42e2b50ab5..2f04a51014 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -3,11 +3,11 @@ import { DateTime } from 'luxon'; import semver, { SemVer } from 'semver'; import { isDev, serverVersion } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnServerEvent } from 'src/decorators'; +import { OnEmit, OnServerEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { SystemMetadataKey } from 'src/enum'; -import { ClientEvent, IEventRepository, OnEvents, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; +import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; @@ -23,7 +23,7 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re }; @Injectable() -export class VersionService implements OnEvents { +export class VersionService { private configCore: SystemConfigCore; constructor( @@ -37,7 +37,8 @@ export class VersionService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async onBootstrapEvent(): Promise { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(): Promise { await this.handleVersionCheck(); } diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts index 1bee4c6558..2dd7e7fd5d 100644 --- a/server/src/utils/events.ts +++ b/server/src/utils/events.ts @@ -1,33 +1,57 @@ import { ModuleRef, Reflector } from '@nestjs/core'; import _ from 'lodash'; -import { HandlerOptions } from 'src/decorators'; -import { EmitEvent, EmitEventHandler, IEventRepository, OnEvents, events } from 'src/interfaces/event.interface'; +import { EmitConfig } from 'src/decorators'; +import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface'; import { Metadata } from 'src/middleware/auth.guard'; import { services } from 'src/services'; +type Item = { + event: T; + handler: EmitHandler; + priority: number; + label: string; +}; + export const setupEventHandlers = (moduleRef: ModuleRef) => { const reflector = moduleRef.get(Reflector, { strict: false }); const repository = moduleRef.get(IEventRepository); - const handlers: Array<{ event: EmitEvent; handler: EmitEventHandler; priority: number }> = []; + const items: Item[] = []; // discovery for (const Service of services) { - const instance = moduleRef.get(Service); - for (const event of events) { - const handler = instance[event] as EmitEventHandler; + const instance = moduleRef.get(Service); + const ctx = Object.getPrototypeOf(instance); + for (const property of Object.getOwnPropertyNames(ctx)) { + const descriptor = Object.getOwnPropertyDescriptor(ctx, property); + if (!descriptor || descriptor.get || descriptor.set) { + continue; + } + + const handler = instance[property]; if (typeof handler !== 'function') { continue; } - const options = reflector.get(Metadata.EVENT_HANDLER_OPTIONS, handler); - const priority = options?.priority || 0; + const options = reflector.get(Metadata.ON_EMIT_CONFIG, handler); + if (!options) { + continue; + } - handlers.push({ event, handler: handler.bind(instance), priority }); + items.push({ + event: options.event, + priority: options.priority || 0, + handler: handler.bind(instance), + label: `${Service.name}.${handler.name}`, + }); } } + const handlers = _.orderBy(items, ['priority'], ['asc']); + // register by priority - for (const { event, handler } of _.orderBy(handlers, ['priority'], ['asc'])) { - repository.on(event, handler); + for (const { event, handler } of handlers) { + repository.on(event as EmitEvent, handler); } + + return handlers; }; From c582a841bac0dbcc2845645e8c8124eda9f72da4 Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Thu, 15 Aug 2024 22:48:21 +0200 Subject: [PATCH 112/723] fix(docs): read-only affects XMP writing (#11823) * mention issue: read-only library vs XMP sidecars * mention issue: read-only library vs XMP sidecars chore: rename motionphotos to kebab-case and add new assets (#5) --- docs/docs/features/libraries.md | 3 ++- docs/docs/guides/external-library.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index ffccb1286a..94cbff6ebe 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -112,7 +112,8 @@ The `immich-server` container will need access to the gallery. Modify your docke ``` :::tip -The `ro` flag at the end only gives read-only access to the volumes. This will disallow the images from being deleted in the web UI. +The `ro` flag at the end only gives read-only access to the volumes. +This will disallow the images from being deleted in the web UI, or adding metadata to the library ([XMP sidecars](/docs/features/xmp-sidecars)). ::: :::info diff --git a/docs/docs/guides/external-library.md b/docs/docs/guides/external-library.md index 07d1047ea0..b44949818c 100644 --- a/docs/docs/guides/external-library.md +++ b/docs/docs/guides/external-library.md @@ -7,7 +7,7 @@ in a directory on the same machine. # Mount the directory into the containers. Edit `docker-compose.yml` to add one or more new mount points in the section `immich-server:` under `volumes:`. -If you want Immich to be able to delete the images in the external library, remove `:ro` from the end of the mount point. +If you want Immich to be able to delete the images in the external library or add metadata ([XMP sidecars](/docs/features/xmp-sidecars)), remove `:ro` from the end of the mount point. ```diff immich-server: From 1c754b60dc2aa5340be734d54a678705c9b65132 Mon Sep 17 00:00:00 2001 From: Saschl <19493808+Saschl@users.noreply.github.com> Date: Fri, 16 Aug 2024 15:08:21 +0200 Subject: [PATCH 113/723] chore(mobile): only enable wakelock when backup is running (#11849) chore: only enable wakelock when backup is running --- mobile/lib/pages/backup/backup_controller.page.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/lib/pages/backup/backup_controller.page.dart b/mobile/lib/pages/backup/backup_controller.page.dart index 7b86f3225c..bb9d462e50 100644 --- a/mobile/lib/pages/backup/backup_controller.page.dart +++ b/mobile/lib/pages/backup/backup_controller.page.dart @@ -51,8 +51,8 @@ class BackupControllerPage extends HookConsumerWidget { } void stopScreenDarkenTimer() { - isScreenDarkened.value = false; darkenScreenTimer.value?.cancel(); + isScreenDarkened.value = false; SystemChrome.setEnabledSystemUIMode( SystemUiMode.manual, overlays: [ @@ -75,8 +75,6 @@ class BackupControllerPage extends HookConsumerWidget { .watch(websocketProvider.notifier) .stopListenToEvent('on_upload_success'); - WakelockPlus.enable(); - return () { WakelockPlus.disable(); darkenScreenTimer.value?.cancel(); @@ -102,8 +100,10 @@ class BackupControllerPage extends HookConsumerWidget { () { if (backupState.backupProgress == BackUpProgressEnum.inProgress) { startScreenDarkenTimer(); + WakelockPlus.enable(); } else { stopScreenDarkenTimer(); + WakelockPlus.disable(); } return null; From a372b56d44980f88418c4d398493ec5872df693b Mon Sep 17 00:00:00 2001 From: waclaw66 Date: Fri, 16 Aug 2024 15:19:05 +0200 Subject: [PATCH 114/723] fix(mobile): download translation (#11838) fix: download translation --- mobile/assets/i18n/en-US.json | 13 +++++++------ .../widgets/asset_viewer/bottom_gallery_bar.dart | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 9ef2a3e599..f9dd86513d 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -55,13 +55,13 @@ "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", "asset_restored_successfully": "Asset restored successfully", + "asset_viewer_settings_title": "Asset Viewer", "assets_deleted_permanently": "{} asset(s) deleted permanently", "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", - "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", @@ -173,6 +173,7 @@ "control_bottom_app_bar_delete": "Delete", "control_bottom_app_bar_delete_from_immich": "Delete from Immich", "control_bottom_app_bar_delete_from_local": "Delete from device", + "control_bottom_app_bar_download": "Download", "control_bottom_app_bar_edit": "Edit", "control_bottom_app_bar_edit_location": "Edit Location", "control_bottom_app_bar_edit_time": "Edit Date & Time", @@ -455,15 +456,18 @@ "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", "setting_notifications_total_progress_title": "Show background backup total progress", "setting_pages_app_bar_settings": "Settings", - "settings_require_restart": "Please restart Immich to apply this setting", "setting_video_viewer_looping_subtitle": "Enable to automatically loop a video in the detail viewer.", "setting_video_viewer_looping_title": "Looping", "setting_video_viewer_title": "Videos", + "settings_require_restart": "Please restart Immich to apply this setting", "share_add": "Add", "share_add_photos": "Add photos", "share_add_title": "Add a title", "share_assets_selected": "{} selected", "share_create_album": "Create album", + "share_dialog_preparing": "Preparing...", + "share_done": "Done", + "share_invite": "Invite to album", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", "shared_album_activity_remove_content": "Do you want to delete this activity?", @@ -475,7 +479,6 @@ "shared_album_section_people_action_remove_user": "Remove user from album", "shared_album_section_people_owner_label": "Owner", "shared_album_section_people_title": "PEOPLE", - "share_dialog_preparing": "Preparing...", "shared_link_app_bar_title": "Shared Links", "shared_link_clipboard_copied_massage": "Copied to clipboard", "shared_link_clipboard_text": "Link: {}\nPassword: {}", @@ -521,14 +524,12 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Manage Shared links", "shared_link_public_album": "Public album", - "share_done": "Done", - "share_invite": "Invite to album", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", "sharing_page_empty_list": "EMPTY LIST", "sharing_silver_appbar_create_shared_album": "New shared album", - "sharing_silver_appbar_shared_links": "Shared links", "sharing_silver_appbar_share_partner": "Share with partner", + "sharing_silver_appbar_shared_links": "Shared links", "tab_controller_nav_library": "Library", "tab_controller_nav_photos": "Photos", "tab_controller_nav_search": "Search", diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index d78b10270e..fb70ac309e 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -366,8 +366,8 @@ class BottomGalleryBar extends ConsumerWidget { { BottomNavigationBarItem( icon: const Icon(Icons.download_outlined), - label: 'download'.tr(), - tooltip: 'download'.tr(), + label: 'control_bottom_app_bar_download'.tr(), + tooltip: 'control_bottom_app_bar_download'.tr(), ): (_) => handleDownload(), }, if (isInAlbum) From f230b3aa426931d2d6d3c38c02aecbe353c13127 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 16 Aug 2024 09:48:43 -0400 Subject: [PATCH 115/723] feat(server): granular permissions for api keys (#11824) feat(server): api auth permissions --- e2e/src/api/specs/api-key.e2e-spec.ts | 82 ++++- e2e/src/cli/specs/login.e2e-spec.ts | 5 +- e2e/src/responses.ts | 6 + e2e/src/utils.ts | 7 +- mobile/lib/services/backup.service.dart | 4 +- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + mobile/openapi/lib/api_helper.dart | 3 + .../openapi/lib/model/api_key_create_dto.dart | 14 +- .../lib/model/api_key_response_dto.dart | 10 +- mobile/openapi/lib/model/permission.dart | 292 ++++++++++++++++++ open-api/immich-openapi-specs.json | 92 ++++++ open-api/typescript-sdk/src/fetch-client.ts | 75 +++++ server/src/controllers/activity.controller.ts | 9 +- server/src/controllers/album.controller.ts | 13 +- server/src/controllers/api-key.controller.ts | 11 +- server/src/controllers/face.controller.ts | 5 +- server/src/controllers/library.controller.ts | 13 +- server/src/controllers/memory.controller.ts | 11 +- server/src/controllers/partner.controller.ts | 9 +- server/src/controllers/person.controller.ts | 19 +- .../src/controllers/shared-link.controller.ts | 11 +- .../controllers/system-config.controller.ts | 9 +- .../controllers/system-metadata.controller.ts | 7 +- server/src/controllers/tag.controller.ts | 11 +- .../src/controllers/user-admin.controller.ts | 17 +- server/src/cores/access.core.ts | 4 +- server/src/dtos/api-key.dto.ts | 11 +- server/src/entities/api-key.entity.ts | 4 + server/src/enum.ts | 62 +++- server/src/middleware/auth.guard.ts | 11 +- .../1723719333525-AddApiKeyPermissions.ts | 14 + server/src/queries/api.key.repository.sql | 3 + server/src/repositories/api-key.repository.ts | 1 + server/src/services/api-key.service.spec.ts | 7 +- server/src/services/api-key.service.ts | 12 +- server/src/services/auth.service.ts | 9 +- server/src/services/memory.service.ts | 4 +- server/src/services/person.service.ts | 8 +- server/src/utils/access.ts | 15 + .../lib/components/forms/api-key-form.svelte | 20 +- .../user-api-key-list.svelte | 28 +- 43 files changed, 817 insertions(+), 135 deletions(-) create mode 100644 mobile/openapi/lib/model/permission.dart create mode 100644 server/src/migrations/1723719333525-AddApiKeyPermissions.ts create mode 100644 server/src/utils/access.ts diff --git a/e2e/src/api/specs/api-key.e2e-spec.ts b/e2e/src/api/specs/api-key.e2e-spec.ts index 32d18f612d..1748276625 100644 --- a/e2e/src/api/specs/api-key.e2e-spec.ts +++ b/e2e/src/api/specs/api-key.e2e-spec.ts @@ -1,12 +1,12 @@ -import { LoginResponseDto, createApiKey } from '@immich/sdk'; +import { LoginResponseDto, Permission, createApiKey } from '@immich/sdk'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; -const create = (accessToken: string) => - createApiKey({ apiKeyCreateDto: { name: 'api key' } }, { headers: asBearerAuth(accessToken) }); +const create = (accessToken: string, permissions: Permission[]) => + createApiKey({ apiKeyCreateDto: { name: 'api key', permissions } }, { headers: asBearerAuth(accessToken) }); describe('/api-keys', () => { let admin: LoginResponseDto; @@ -30,15 +30,65 @@ describe('/api-keys', () => { expect(body).toEqual(errorDto.unauthorized); }); + it('should not work without permission', async () => { + const { secret } = await create(user.accessToken, [Permission.ApiKeyRead]); + const { status, body } = await request(app).post('/api-keys').set('x-api-key', secret).send({ name: 'API Key' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('apiKey.create')); + }); + + it('should work with apiKey.create', async () => { + const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate, Permission.ApiKeyRead]); + const { status, body } = await request(app) + .post('/api-keys') + .set('x-api-key', secret) + .send({ + name: 'API Key', + permissions: [Permission.ApiKeyRead], + }); + expect(body).toEqual({ + secret: expect.any(String), + apiKey: { + id: expect.any(String), + name: 'API Key', + permissions: [Permission.ApiKeyRead], + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + }); + expect(status).toBe(201); + }); + + it('should not create an api key with all permissions', async () => { + const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate]); + const { status, body } = await request(app) + .post('/api-keys') + .set('x-api-key', secret) + .send({ name: 'API Key', permissions: [Permission.All] }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Cannot grant permissions you do not have')); + }); + + it('should not create an api key with more permissions', async () => { + const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate]); + const { status, body } = await request(app) + .post('/api-keys') + .set('x-api-key', secret) + .send({ name: 'API Key', permissions: [Permission.ApiKeyRead] }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Cannot grant permissions you do not have')); + }); + it('should create an api key', async () => { const { status, body } = await request(app) .post('/api-keys') - .send({ name: 'API Key' }) + .send({ name: 'API Key', permissions: [Permission.All] }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toEqual({ apiKey: { id: expect.any(String), name: 'API Key', + permissions: [Permission.All], createdAt: expect.any(String), updatedAt: expect.any(String), }, @@ -63,9 +113,9 @@ describe('/api-keys', () => { it('should return a list of api keys', async () => { const [{ apiKey: apiKey1 }, { apiKey: apiKey2 }, { apiKey: apiKey3 }] = await Promise.all([ - create(admin.accessToken), - create(admin.accessToken), - create(admin.accessToken), + create(admin.accessToken, [Permission.All]), + create(admin.accessToken, [Permission.All]), + create(admin.accessToken, [Permission.All]), ]); const { status, body } = await request(app).get('/api-keys').set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toHaveLength(3); @@ -82,7 +132,7 @@ describe('/api-keys', () => { }); it('should require authorization', async () => { - const { apiKey } = await create(user.accessToken); + const { apiKey } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) .get(`/api-keys/${apiKey.id}`) .set('Authorization', `Bearer ${admin.accessToken}`); @@ -99,7 +149,7 @@ describe('/api-keys', () => { }); it('should get api key details', async () => { - const { apiKey } = await create(user.accessToken); + const { apiKey } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) .get(`/api-keys/${apiKey.id}`) .set('Authorization', `Bearer ${user.accessToken}`); @@ -107,6 +157,7 @@ describe('/api-keys', () => { expect(body).toEqual({ id: expect.any(String), name: 'api key', + permissions: [Permission.All], createdAt: expect.any(String), updatedAt: expect.any(String), }); @@ -121,7 +172,7 @@ describe('/api-keys', () => { }); it('should require authorization', async () => { - const { apiKey } = await create(user.accessToken); + const { apiKey } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) .put(`/api-keys/${apiKey.id}`) .send({ name: 'new name' }) @@ -140,7 +191,7 @@ describe('/api-keys', () => { }); it('should update api key details', async () => { - const { apiKey } = await create(user.accessToken); + const { apiKey } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) .put(`/api-keys/${apiKey.id}`) .send({ name: 'new name' }) @@ -149,6 +200,7 @@ describe('/api-keys', () => { expect(body).toEqual({ id: expect.any(String), name: 'new name', + permissions: [Permission.All], createdAt: expect.any(String), updatedAt: expect.any(String), }); @@ -163,7 +215,7 @@ describe('/api-keys', () => { }); it('should require authorization', async () => { - const { apiKey } = await create(user.accessToken); + const { apiKey } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) .delete(`/api-keys/${apiKey.id}`) .set('Authorization', `Bearer ${admin.accessToken}`); @@ -180,7 +232,7 @@ describe('/api-keys', () => { }); it('should delete an api key', async () => { - const { apiKey } = await create(user.accessToken); + const { apiKey } = await create(user.accessToken, [Permission.All]); const { status } = await request(app) .delete(`/api-keys/${apiKey.id}`) .set('Authorization', `Bearer ${user.accessToken}`); @@ -190,14 +242,14 @@ describe('/api-keys', () => { describe('authentication', () => { it('should work as a header', async () => { - const { secret } = await create(admin.accessToken); + const { secret } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app).get('/api-keys').set('x-api-key', secret); expect(body).toHaveLength(1); expect(status).toBe(200); }); it('should work as a query param', async () => { - const { secret } = await create(admin.accessToken); + const { secret } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app).get(`/api-keys?apiKey=${secret}`); expect(body).toHaveLength(1); expect(status).toBe(200); diff --git a/e2e/src/cli/specs/login.e2e-spec.ts b/e2e/src/cli/specs/login.e2e-spec.ts index 0fb48188a2..fc3e817595 100644 --- a/e2e/src/cli/specs/login.e2e-spec.ts +++ b/e2e/src/cli/specs/login.e2e-spec.ts @@ -1,3 +1,4 @@ +import { Permission } from '@immich/sdk'; import { stat } from 'node:fs/promises'; import { app, immichCli, utils } from 'src/utils'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -29,7 +30,7 @@ describe(`immich login`, () => { it('should login and save auth.yml with 600', async () => { const admin = await utils.adminSetup(); - const key = await utils.createApiKey(admin.accessToken); + const key = await utils.createApiKey(admin.accessToken, [Permission.All]); const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]); expect(stdout.split('\n')).toEqual([ 'Logging in to http://127.0.0.1:2283/api', @@ -46,7 +47,7 @@ describe(`immich login`, () => { it('should login without /api in the url', async () => { const admin = await utils.adminSetup(); - const key = await utils.createApiKey(admin.accessToken); + const key = await utils.createApiKey(admin.accessToken, [Permission.All]); const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]); expect(stdout.split('\n')).toEqual([ 'Logging in to http://127.0.0.1:2283', diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 80e4f76f4f..6ca2225180 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -13,6 +13,12 @@ export const errorDto = { message: expect.any(String), correlationId: expect.any(String), }, + missingPermission: (permission: string) => ({ + error: 'Forbidden', + statusCode: 403, + message: `Missing required permission: ${permission}`, + correlationId: expect.any(String), + }), wrongPassword: { error: 'Bad Request', statusCode: 400, diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 9e397d03ed..30e2497b51 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -7,6 +7,7 @@ import { CreateAlbumDto, CreateLibraryDto, MetadataSearchDto, + Permission, PersonCreateDto, SharedLinkCreateDto, UserAdminCreateDto, @@ -279,8 +280,8 @@ export const utils = { }); }, - createApiKey: (accessToken: string) => { - return createApiKey({ apiKeyCreateDto: { name: 'e2e' } }, { headers: asBearerAuth(accessToken) }); + createApiKey: (accessToken: string, permissions: Permission[]) => { + return createApiKey({ apiKeyCreateDto: { name: 'e2e', permissions } }, { headers: asBearerAuth(accessToken) }); }, createAlbum: (accessToken: string, dto: CreateAlbumDto) => @@ -492,7 +493,7 @@ export const utils = { }, cliLogin: async (accessToken: string) => { - const key = await utils.createApiKey(accessToken); + const key = await utils.createApiKey(accessToken, [Permission.All]); await immichCli(['login', app, `${key.secret}`]); return key.secret; }, diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index a42c587435..64d683dc2a 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -20,7 +20,7 @@ import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:path/path.dart' as p; -import 'package:permission_handler/permission_handler.dart'; +import 'package:permission_handler/permission_handler.dart' as pm; import 'package:photo_manager/photo_manager.dart'; final backupServiceProvider = Provider( @@ -213,7 +213,7 @@ class BackupService { _appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets); if (Platform.isAndroid && - !(await Permission.accessMediaLocation.status).isGranted) { + !(await pm.Permission.accessMediaLocation.status).isGranted) { // double check that permission is granted here, to guard against // uploading corrupt assets without EXIF information _log.warning("Media location permission is not granted. " diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e747db37b0..657dad9d5b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -363,6 +363,7 @@ Class | Method | HTTP request | Description - [PeopleResponseDto](doc//PeopleResponseDto.md) - [PeopleUpdateDto](doc//PeopleUpdateDto.md) - [PeopleUpdateItem](doc//PeopleUpdateItem.md) + - [Permission](doc//Permission.md) - [PersonCreateDto](doc//PersonCreateDto.md) - [PersonResponseDto](doc//PersonResponseDto.md) - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index bbe680731e..4d33f1018c 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -175,6 +175,7 @@ part 'model/path_type.dart'; part 'model/people_response_dto.dart'; part 'model/people_update_dto.dart'; part 'model/people_update_item.dart'; +part 'model/permission.dart'; part 'model/person_create_dto.dart'; part 'model/person_response_dto.dart'; part 'model/person_statistics_response_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 01c646d393..b5b79be8b1 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -407,6 +407,8 @@ class ApiClient { return PeopleUpdateDto.fromJson(value); case 'PeopleUpdateItem': return PeopleUpdateItem.fromJson(value); + case 'Permission': + return PermissionTypeTransformer().decode(value); case 'PersonCreateDto': return PersonCreateDto.fromJson(value); case 'PersonResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 04fcaa3463..7f46e145b1 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -112,6 +112,9 @@ String parameterToString(dynamic value) { if (value is PathType) { return PathTypeTypeTransformer().encode(value).toString(); } + if (value is Permission) { + return PermissionTypeTransformer().encode(value).toString(); + } if (value is ReactionLevel) { return ReactionLevelTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/api_key_create_dto.dart b/mobile/openapi/lib/model/api_key_create_dto.dart index f6ff8e5f97..433855c4cf 100644 --- a/mobile/openapi/lib/model/api_key_create_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_dto.dart @@ -14,6 +14,7 @@ class APIKeyCreateDto { /// Returns a new [APIKeyCreateDto] instance. APIKeyCreateDto({ this.name, + this.permissions = const [], }); /// @@ -24,17 +25,21 @@ class APIKeyCreateDto { /// String? name; + List permissions; + @override bool operator ==(Object other) => identical(this, other) || other is APIKeyCreateDto && - other.name == name; + other.name == name && + _deepEquality.equals(other.permissions, permissions); @override int get hashCode => // ignore: unnecessary_parenthesis - (name == null ? 0 : name!.hashCode); + (name == null ? 0 : name!.hashCode) + + (permissions.hashCode); @override - String toString() => 'APIKeyCreateDto[name=$name]'; + String toString() => 'APIKeyCreateDto[name=$name, permissions=$permissions]'; Map toJson() { final json = {}; @@ -43,6 +48,7 @@ class APIKeyCreateDto { } else { // json[r'name'] = null; } + json[r'permissions'] = this.permissions; return json; } @@ -55,6 +61,7 @@ class APIKeyCreateDto { return APIKeyCreateDto( name: mapValueOfType(json, r'name'), + permissions: Permission.listFromJson(json[r'permissions']), ); } return null; @@ -102,6 +109,7 @@ class APIKeyCreateDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'permissions', }; } diff --git a/mobile/openapi/lib/model/api_key_response_dto.dart b/mobile/openapi/lib/model/api_key_response_dto.dart index 764d5ec973..b6ca86c050 100644 --- a/mobile/openapi/lib/model/api_key_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_response_dto.dart @@ -16,6 +16,7 @@ class APIKeyResponseDto { required this.createdAt, required this.id, required this.name, + this.permissions = const [], required this.updatedAt, }); @@ -25,6 +26,8 @@ class APIKeyResponseDto { String name; + List permissions; + DateTime updatedAt; @override @@ -32,6 +35,7 @@ class APIKeyResponseDto { other.createdAt == createdAt && other.id == id && other.name == name && + _deepEquality.equals(other.permissions, permissions) && other.updatedAt == updatedAt; @override @@ -40,16 +44,18 @@ class APIKeyResponseDto { (createdAt.hashCode) + (id.hashCode) + (name.hashCode) + + (permissions.hashCode) + (updatedAt.hashCode); @override - String toString() => 'APIKeyResponseDto[createdAt=$createdAt, id=$id, name=$name, updatedAt=$updatedAt]'; + String toString() => 'APIKeyResponseDto[createdAt=$createdAt, id=$id, name=$name, permissions=$permissions, updatedAt=$updatedAt]'; Map toJson() { final json = {}; json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); json[r'id'] = this.id; json[r'name'] = this.name; + json[r'permissions'] = this.permissions; json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); return json; } @@ -65,6 +71,7 @@ class APIKeyResponseDto { createdAt: mapDateTime(json, r'createdAt', r'')!, id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, + permissions: Permission.listFromJson(json[r'permissions']), updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); } @@ -116,6 +123,7 @@ class APIKeyResponseDto { 'createdAt', 'id', 'name', + 'permissions', 'updatedAt', }; } diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart new file mode 100644 index 0000000000..30dc89a47c --- /dev/null +++ b/mobile/openapi/lib/model/permission.dart @@ -0,0 +1,292 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class Permission { + /// Instantiate a new enum with the provided [value]. + const Permission._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const all = Permission._(r'all'); + static const activityPeriodCreate = Permission._(r'activity.create'); + static const activityPeriodRead = Permission._(r'activity.read'); + static const activityPeriodUpdate = Permission._(r'activity.update'); + static const activityPeriodDelete = Permission._(r'activity.delete'); + static const activityPeriodStatistics = Permission._(r'activity.statistics'); + static const apiKeyPeriodCreate = Permission._(r'apiKey.create'); + static const apiKeyPeriodRead = Permission._(r'apiKey.read'); + static const apiKeyPeriodUpdate = Permission._(r'apiKey.update'); + static const apiKeyPeriodDelete = Permission._(r'apiKey.delete'); + static const assetPeriodRead = Permission._(r'asset.read'); + static const assetPeriodUpdate = Permission._(r'asset.update'); + static const assetPeriodDelete = Permission._(r'asset.delete'); + static const assetPeriodRestore = Permission._(r'asset.restore'); + static const assetPeriodShare = Permission._(r'asset.share'); + static const assetPeriodView = Permission._(r'asset.view'); + static const assetPeriodDownload = Permission._(r'asset.download'); + static const assetPeriodUpload = Permission._(r'asset.upload'); + static const albumPeriodCreate = Permission._(r'album.create'); + static const albumPeriodRead = Permission._(r'album.read'); + static const albumPeriodUpdate = Permission._(r'album.update'); + static const albumPeriodDelete = Permission._(r'album.delete'); + static const albumPeriodStatistics = Permission._(r'album.statistics'); + static const albumPeriodAddAsset = Permission._(r'album.addAsset'); + static const albumPeriodRemoveAsset = Permission._(r'album.removeAsset'); + static const albumPeriodShare = Permission._(r'album.share'); + static const albumPeriodDownload = Permission._(r'album.download'); + static const authDevicePeriodDelete = Permission._(r'authDevice.delete'); + static const archivePeriodRead = Permission._(r'archive.read'); + static const facePeriodCreate = Permission._(r'face.create'); + static const facePeriodRead = Permission._(r'face.read'); + static const facePeriodUpdate = Permission._(r'face.update'); + static const facePeriodDelete = Permission._(r'face.delete'); + static const libraryPeriodCreate = Permission._(r'library.create'); + static const libraryPeriodRead = Permission._(r'library.read'); + static const libraryPeriodUpdate = Permission._(r'library.update'); + static const libraryPeriodDelete = Permission._(r'library.delete'); + static const libraryPeriodStatistics = Permission._(r'library.statistics'); + static const timelinePeriodRead = Permission._(r'timeline.read'); + static const timelinePeriodDownload = Permission._(r'timeline.download'); + static const memoryPeriodCreate = Permission._(r'memory.create'); + static const memoryPeriodRead = Permission._(r'memory.read'); + static const memoryPeriodUpdate = Permission._(r'memory.update'); + static const memoryPeriodDelete = Permission._(r'memory.delete'); + static const partnerPeriodCreate = Permission._(r'partner.create'); + static const partnerPeriodRead = Permission._(r'partner.read'); + static const partnerPeriodUpdate = Permission._(r'partner.update'); + static const partnerPeriodDelete = Permission._(r'partner.delete'); + static const personPeriodCreate = Permission._(r'person.create'); + static const personPeriodRead = Permission._(r'person.read'); + static const personPeriodUpdate = Permission._(r'person.update'); + static const personPeriodDelete = Permission._(r'person.delete'); + static const personPeriodStatistics = Permission._(r'person.statistics'); + static const personPeriodMerge = Permission._(r'person.merge'); + static const personPeriodReassign = Permission._(r'person.reassign'); + static const sharedLinkPeriodCreate = Permission._(r'sharedLink.create'); + static const sharedLinkPeriodRead = Permission._(r'sharedLink.read'); + static const sharedLinkPeriodUpdate = Permission._(r'sharedLink.update'); + static const sharedLinkPeriodDelete = Permission._(r'sharedLink.delete'); + static const systemConfigPeriodRead = Permission._(r'systemConfig.read'); + static const systemConfigPeriodUpdate = Permission._(r'systemConfig.update'); + static const systemMetadataPeriodRead = Permission._(r'systemMetadata.read'); + static const systemMetadataPeriodUpdate = Permission._(r'systemMetadata.update'); + static const tagPeriodCreate = Permission._(r'tag.create'); + static const tagPeriodRead = Permission._(r'tag.read'); + static const tagPeriodUpdate = Permission._(r'tag.update'); + static const tagPeriodDelete = Permission._(r'tag.delete'); + static const adminPeriodUserPeriodCreate = Permission._(r'admin.user.create'); + static const adminPeriodUserPeriodRead = Permission._(r'admin.user.read'); + static const adminPeriodUserPeriodUpdate = Permission._(r'admin.user.update'); + static const adminPeriodUserPeriodDelete = Permission._(r'admin.user.delete'); + + /// List of all possible values in this [enum][Permission]. + static const values = [ + all, + activityPeriodCreate, + activityPeriodRead, + activityPeriodUpdate, + activityPeriodDelete, + activityPeriodStatistics, + apiKeyPeriodCreate, + apiKeyPeriodRead, + apiKeyPeriodUpdate, + apiKeyPeriodDelete, + assetPeriodRead, + assetPeriodUpdate, + assetPeriodDelete, + assetPeriodRestore, + assetPeriodShare, + assetPeriodView, + assetPeriodDownload, + assetPeriodUpload, + albumPeriodCreate, + albumPeriodRead, + albumPeriodUpdate, + albumPeriodDelete, + albumPeriodStatistics, + albumPeriodAddAsset, + albumPeriodRemoveAsset, + albumPeriodShare, + albumPeriodDownload, + authDevicePeriodDelete, + archivePeriodRead, + facePeriodCreate, + facePeriodRead, + facePeriodUpdate, + facePeriodDelete, + libraryPeriodCreate, + libraryPeriodRead, + libraryPeriodUpdate, + libraryPeriodDelete, + libraryPeriodStatistics, + timelinePeriodRead, + timelinePeriodDownload, + memoryPeriodCreate, + memoryPeriodRead, + memoryPeriodUpdate, + memoryPeriodDelete, + partnerPeriodCreate, + partnerPeriodRead, + partnerPeriodUpdate, + partnerPeriodDelete, + personPeriodCreate, + personPeriodRead, + personPeriodUpdate, + personPeriodDelete, + personPeriodStatistics, + personPeriodMerge, + personPeriodReassign, + sharedLinkPeriodCreate, + sharedLinkPeriodRead, + sharedLinkPeriodUpdate, + sharedLinkPeriodDelete, + systemConfigPeriodRead, + systemConfigPeriodUpdate, + systemMetadataPeriodRead, + systemMetadataPeriodUpdate, + tagPeriodCreate, + tagPeriodRead, + tagPeriodUpdate, + tagPeriodDelete, + adminPeriodUserPeriodCreate, + adminPeriodUserPeriodRead, + adminPeriodUserPeriodUpdate, + adminPeriodUserPeriodDelete, + ]; + + static Permission? fromJson(dynamic value) => PermissionTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = Permission.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [Permission] to String, +/// and [decode] dynamic data back to [Permission]. +class PermissionTypeTransformer { + factory PermissionTypeTransformer() => _instance ??= const PermissionTypeTransformer._(); + + const PermissionTypeTransformer._(); + + String encode(Permission data) => data.value; + + /// Decodes a [dynamic value][data] to a Permission. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + Permission? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'all': return Permission.all; + case r'activity.create': return Permission.activityPeriodCreate; + case r'activity.read': return Permission.activityPeriodRead; + case r'activity.update': return Permission.activityPeriodUpdate; + case r'activity.delete': return Permission.activityPeriodDelete; + case r'activity.statistics': return Permission.activityPeriodStatistics; + case r'apiKey.create': return Permission.apiKeyPeriodCreate; + case r'apiKey.read': return Permission.apiKeyPeriodRead; + case r'apiKey.update': return Permission.apiKeyPeriodUpdate; + case r'apiKey.delete': return Permission.apiKeyPeriodDelete; + case r'asset.read': return Permission.assetPeriodRead; + case r'asset.update': return Permission.assetPeriodUpdate; + case r'asset.delete': return Permission.assetPeriodDelete; + case r'asset.restore': return Permission.assetPeriodRestore; + case r'asset.share': return Permission.assetPeriodShare; + case r'asset.view': return Permission.assetPeriodView; + case r'asset.download': return Permission.assetPeriodDownload; + case r'asset.upload': return Permission.assetPeriodUpload; + case r'album.create': return Permission.albumPeriodCreate; + case r'album.read': return Permission.albumPeriodRead; + case r'album.update': return Permission.albumPeriodUpdate; + case r'album.delete': return Permission.albumPeriodDelete; + case r'album.statistics': return Permission.albumPeriodStatistics; + case r'album.addAsset': return Permission.albumPeriodAddAsset; + case r'album.removeAsset': return Permission.albumPeriodRemoveAsset; + case r'album.share': return Permission.albumPeriodShare; + case r'album.download': return Permission.albumPeriodDownload; + case r'authDevice.delete': return Permission.authDevicePeriodDelete; + case r'archive.read': return Permission.archivePeriodRead; + case r'face.create': return Permission.facePeriodCreate; + case r'face.read': return Permission.facePeriodRead; + case r'face.update': return Permission.facePeriodUpdate; + case r'face.delete': return Permission.facePeriodDelete; + case r'library.create': return Permission.libraryPeriodCreate; + case r'library.read': return Permission.libraryPeriodRead; + case r'library.update': return Permission.libraryPeriodUpdate; + case r'library.delete': return Permission.libraryPeriodDelete; + case r'library.statistics': return Permission.libraryPeriodStatistics; + case r'timeline.read': return Permission.timelinePeriodRead; + case r'timeline.download': return Permission.timelinePeriodDownload; + case r'memory.create': return Permission.memoryPeriodCreate; + case r'memory.read': return Permission.memoryPeriodRead; + case r'memory.update': return Permission.memoryPeriodUpdate; + case r'memory.delete': return Permission.memoryPeriodDelete; + case r'partner.create': return Permission.partnerPeriodCreate; + case r'partner.read': return Permission.partnerPeriodRead; + case r'partner.update': return Permission.partnerPeriodUpdate; + case r'partner.delete': return Permission.partnerPeriodDelete; + case r'person.create': return Permission.personPeriodCreate; + case r'person.read': return Permission.personPeriodRead; + case r'person.update': return Permission.personPeriodUpdate; + case r'person.delete': return Permission.personPeriodDelete; + case r'person.statistics': return Permission.personPeriodStatistics; + case r'person.merge': return Permission.personPeriodMerge; + case r'person.reassign': return Permission.personPeriodReassign; + case r'sharedLink.create': return Permission.sharedLinkPeriodCreate; + case r'sharedLink.read': return Permission.sharedLinkPeriodRead; + case r'sharedLink.update': return Permission.sharedLinkPeriodUpdate; + case r'sharedLink.delete': return Permission.sharedLinkPeriodDelete; + case r'systemConfig.read': return Permission.systemConfigPeriodRead; + case r'systemConfig.update': return Permission.systemConfigPeriodUpdate; + case r'systemMetadata.read': return Permission.systemMetadataPeriodRead; + case r'systemMetadata.update': return Permission.systemMetadataPeriodUpdate; + case r'tag.create': return Permission.tagPeriodCreate; + case r'tag.read': return Permission.tagPeriodRead; + case r'tag.update': return Permission.tagPeriodUpdate; + case r'tag.delete': return Permission.tagPeriodDelete; + case r'admin.user.create': return Permission.adminPeriodUserPeriodCreate; + case r'admin.user.read': return Permission.adminPeriodUserPeriodRead; + case r'admin.user.update': return Permission.adminPeriodUserPeriodUpdate; + case r'admin.user.delete': return Permission.adminPeriodUserPeriodDelete; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [PermissionTypeTransformer] instance. + static PermissionTypeTransformer? _instance; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 63d22aa4f9..0d0793c263 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7135,8 +7135,17 @@ "properties": { "name": { "type": "string" + }, + "permissions": { + "items": { + "$ref": "#/components/schemas/Permission" + }, + "type": "array" } }, + "required": [ + "permissions" + ], "type": "object" }, "APIKeyCreateResponseDto": { @@ -7166,6 +7175,12 @@ "name": { "type": "string" }, + "permissions": { + "items": { + "$ref": "#/components/schemas/Permission" + }, + "type": "array" + }, "updatedAt": { "format": "date-time", "type": "string" @@ -7175,6 +7190,7 @@ "createdAt", "id", "name", + "permissions", "updatedAt" ], "type": "object" @@ -9729,6 +9745,82 @@ ], "type": "object" }, + "Permission": { + "enum": [ + "all", + "activity.create", + "activity.read", + "activity.update", + "activity.delete", + "activity.statistics", + "apiKey.create", + "apiKey.read", + "apiKey.update", + "apiKey.delete", + "asset.read", + "asset.update", + "asset.delete", + "asset.restore", + "asset.share", + "asset.view", + "asset.download", + "asset.upload", + "album.create", + "album.read", + "album.update", + "album.delete", + "album.statistics", + "album.addAsset", + "album.removeAsset", + "album.share", + "album.download", + "authDevice.delete", + "archive.read", + "face.create", + "face.read", + "face.update", + "face.delete", + "library.create", + "library.read", + "library.update", + "library.delete", + "library.statistics", + "timeline.read", + "timeline.download", + "memory.create", + "memory.read", + "memory.update", + "memory.delete", + "partner.create", + "partner.read", + "partner.update", + "partner.delete", + "person.create", + "person.read", + "person.update", + "person.delete", + "person.statistics", + "person.merge", + "person.reassign", + "sharedLink.create", + "sharedLink.read", + "sharedLink.update", + "sharedLink.delete", + "systemConfig.read", + "systemConfig.update", + "systemMetadata.read", + "systemMetadata.update", + "tag.create", + "tag.read", + "tag.update", + "tag.delete", + "admin.user.create", + "admin.user.read", + "admin.user.update", + "admin.user.delete" + ], + "type": "string" + }, "PersonCreateDto": { "properties": { "birthDate": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 077e802b8c..89e0360368 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -299,10 +299,12 @@ export type ApiKeyResponseDto = { createdAt: string; id: string; name: string; + permissions: Permission[]; updatedAt: string; }; export type ApiKeyCreateDto = { name?: string; + permissions: Permission[]; }; export type ApiKeyCreateResponseDto = { apiKey: ApiKeyResponseDto; @@ -3125,6 +3127,79 @@ export enum Error { NotFound = "not_found", Unknown = "unknown" } +export enum Permission { + All = "all", + ActivityCreate = "activity.create", + ActivityRead = "activity.read", + ActivityUpdate = "activity.update", + ActivityDelete = "activity.delete", + ActivityStatistics = "activity.statistics", + ApiKeyCreate = "apiKey.create", + ApiKeyRead = "apiKey.read", + ApiKeyUpdate = "apiKey.update", + ApiKeyDelete = "apiKey.delete", + AssetRead = "asset.read", + AssetUpdate = "asset.update", + AssetDelete = "asset.delete", + AssetRestore = "asset.restore", + AssetShare = "asset.share", + AssetView = "asset.view", + AssetDownload = "asset.download", + AssetUpload = "asset.upload", + AlbumCreate = "album.create", + AlbumRead = "album.read", + AlbumUpdate = "album.update", + AlbumDelete = "album.delete", + AlbumStatistics = "album.statistics", + AlbumAddAsset = "album.addAsset", + AlbumRemoveAsset = "album.removeAsset", + AlbumShare = "album.share", + AlbumDownload = "album.download", + AuthDeviceDelete = "authDevice.delete", + ArchiveRead = "archive.read", + FaceCreate = "face.create", + FaceRead = "face.read", + FaceUpdate = "face.update", + FaceDelete = "face.delete", + LibraryCreate = "library.create", + LibraryRead = "library.read", + LibraryUpdate = "library.update", + LibraryDelete = "library.delete", + LibraryStatistics = "library.statistics", + TimelineRead = "timeline.read", + TimelineDownload = "timeline.download", + MemoryCreate = "memory.create", + MemoryRead = "memory.read", + MemoryUpdate = "memory.update", + MemoryDelete = "memory.delete", + PartnerCreate = "partner.create", + PartnerRead = "partner.read", + PartnerUpdate = "partner.update", + PartnerDelete = "partner.delete", + PersonCreate = "person.create", + PersonRead = "person.read", + PersonUpdate = "person.update", + PersonDelete = "person.delete", + PersonStatistics = "person.statistics", + PersonMerge = "person.merge", + PersonReassign = "person.reassign", + SharedLinkCreate = "sharedLink.create", + SharedLinkRead = "sharedLink.read", + SharedLinkUpdate = "sharedLink.update", + SharedLinkDelete = "sharedLink.delete", + SystemConfigRead = "systemConfig.read", + SystemConfigUpdate = "systemConfig.update", + SystemMetadataRead = "systemMetadata.read", + SystemMetadataUpdate = "systemMetadata.update", + TagCreate = "tag.create", + TagRead = "tag.read", + TagUpdate = "tag.update", + TagDelete = "tag.delete", + AdminUserCreate = "admin.user.create", + AdminUserRead = "admin.user.read", + AdminUserUpdate = "admin.user.update", + AdminUserDelete = "admin.user.delete" +} export enum AssetMediaStatus { Created = "created", Replaced = "replaced", diff --git a/server/src/controllers/activity.controller.ts b/server/src/controllers/activity.controller.ts index 76b58a56ce..9b06f82f3a 100644 --- a/server/src/controllers/activity.controller.ts +++ b/server/src/controllers/activity.controller.ts @@ -9,6 +9,7 @@ import { ActivityStatisticsResponseDto, } from 'src/dtos/activity.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { ActivityService } from 'src/services/activity.service'; import { UUIDParamDto } from 'src/validation'; @@ -19,19 +20,19 @@ export class ActivityController { constructor(private service: ActivityService) {} @Get() - @Authenticated() + @Authenticated({ permission: Permission.ACTIVITY_READ }) getActivities(@Auth() auth: AuthDto, @Query() dto: ActivitySearchDto): Promise { return this.service.getAll(auth, dto); } @Get('statistics') - @Authenticated() + @Authenticated({ permission: Permission.ACTIVITY_STATISTICS }) getActivityStatistics(@Auth() auth: AuthDto, @Query() dto: ActivityDto): Promise { return this.service.getStatistics(auth, dto); } @Post() - @Authenticated() + @Authenticated({ permission: Permission.ACTIVITY_CREATE }) async createActivity( @Auth() auth: AuthDto, @Body() dto: ActivityCreateDto, @@ -46,7 +47,7 @@ export class ActivityController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() + @Authenticated({ permission: Permission.ACTIVITY_DELETE }) deleteActivity(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index 1455aeec4b..06f2066c29 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -12,6 +12,7 @@ import { } from 'src/dtos/album.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { AlbumService } from 'src/services/album.service'; import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation'; @@ -22,24 +23,24 @@ export class AlbumController { constructor(private service: AlbumService) {} @Get('count') - @Authenticated() + @Authenticated({ permission: Permission.ALBUM_STATISTICS }) getAlbumCount(@Auth() auth: AuthDto): Promise { return this.service.getCount(auth); } @Get() - @Authenticated() + @Authenticated({ permission: Permission.ALBUM_READ }) getAllAlbums(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise { return this.service.getAll(auth, query); } @Post() - @Authenticated() + @Authenticated({ permission: Permission.ALBUM_CREATE }) createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto): Promise { return this.service.create(auth, dto); } - @Authenticated({ sharedLink: true }) + @Authenticated({ permission: Permission.ALBUM_READ, sharedLink: true }) @Get(':id') getAlbumInfo( @Auth() auth: AuthDto, @@ -50,7 +51,7 @@ export class AlbumController { } @Patch(':id') - @Authenticated() + @Authenticated({ permission: Permission.ALBUM_UPDATE }) updateAlbumInfo( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -60,7 +61,7 @@ export class AlbumController { } @Delete(':id') - @Authenticated() + @Authenticated({ permission: Permission.ALBUM_DELETE }) deleteAlbum(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { return this.service.delete(auth, id); } diff --git a/server/src/controllers/api-key.controller.ts b/server/src/controllers/api-key.controller.ts index feba7cccbb..4691ce05ef 100644 --- a/server/src/controllers/api-key.controller.ts +++ b/server/src/controllers/api-key.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } import { ApiTags } from '@nestjs/swagger'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { APIKeyService } from 'src/services/api-key.service'; import { UUIDParamDto } from 'src/validation'; @@ -12,25 +13,25 @@ export class APIKeyController { constructor(private service: APIKeyService) {} @Post() - @Authenticated() + @Authenticated({ permission: Permission.API_KEY_CREATE }) createApiKey(@Auth() auth: AuthDto, @Body() dto: APIKeyCreateDto): Promise { return this.service.create(auth, dto); } @Get() - @Authenticated() + @Authenticated({ permission: Permission.API_KEY_READ }) getApiKeys(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @Get(':id') - @Authenticated() + @Authenticated({ permission: Permission.API_KEY_READ }) getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getById(auth, id); } @Put(':id') - @Authenticated() + @Authenticated({ permission: Permission.API_KEY_UPDATE }) updateApiKey( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -41,7 +42,7 @@ export class APIKeyController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() + @Authenticated({ permission: Permission.API_KEY_DELETE }) deleteApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/face.controller.ts b/server/src/controllers/face.controller.ts index e3330e9563..7d93bfd34d 100644 --- a/server/src/controllers/face.controller.ts +++ b/server/src/controllers/face.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/dtos/person.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { PersonService } from 'src/services/person.service'; import { UUIDParamDto } from 'src/validation'; @@ -12,13 +13,13 @@ export class FaceController { constructor(private service: PersonService) {} @Get() - @Authenticated() + @Authenticated({ permission: Permission.FACE_READ }) getFaces(@Auth() auth: AuthDto, @Query() dto: FaceDto): Promise { return this.service.getFacesById(auth, dto); } @Put(':id') - @Authenticated() + @Authenticated({ permission: Permission.FACE_UPDATE }) reassignFacesById( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, diff --git a/server/src/controllers/library.controller.ts b/server/src/controllers/library.controller.ts index fd7a88b074..18ba43c0a6 100644 --- a/server/src/controllers/library.controller.ts +++ b/server/src/controllers/library.controller.ts @@ -9,6 +9,7 @@ import { ValidateLibraryDto, ValidateLibraryResponseDto, } from 'src/dtos/library.dto'; +import { Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { LibraryService } from 'src/services/library.service'; import { UUIDParamDto } from 'src/validation'; @@ -19,25 +20,25 @@ export class LibraryController { constructor(private service: LibraryService) {} @Get() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.LIBRARY_READ, admin: true }) getAllLibraries(): Promise { return this.service.getAll(); } @Post() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.LIBRARY_CREATE, admin: true }) createLibrary(@Body() dto: CreateLibraryDto): Promise { return this.service.create(dto); } @Put(':id') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true }) updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateLibraryDto): Promise { return this.service.update(id, dto); } @Get(':id') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.LIBRARY_READ, admin: true }) getLibrary(@Param() { id }: UUIDParamDto): Promise { return this.service.get(id); } @@ -52,13 +53,13 @@ export class LibraryController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true }) deleteLibrary(@Param() { id }: UUIDParamDto): Promise { return this.service.delete(id); } @Get(':id/statistics') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true }) getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise { return this.service.getStatistics(id); } diff --git a/server/src/controllers/memory.controller.ts b/server/src/controllers/memory.controller.ts index 9c5c22de43..710ca9f2f8 100644 --- a/server/src/controllers/memory.controller.ts +++ b/server/src/controllers/memory.controller.ts @@ -3,6 +3,7 @@ import { ApiTags } from '@nestjs/swagger'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto } from 'src/dtos/memory.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MemoryService } from 'src/services/memory.service'; import { UUIDParamDto } from 'src/validation'; @@ -13,25 +14,25 @@ export class MemoryController { constructor(private service: MemoryService) {} @Get() - @Authenticated() + @Authenticated({ permission: Permission.MEMORY_READ }) searchMemories(@Auth() auth: AuthDto): Promise { return this.service.search(auth); } @Post() - @Authenticated() + @Authenticated({ permission: Permission.MEMORY_CREATE }) createMemory(@Auth() auth: AuthDto, @Body() dto: MemoryCreateDto): Promise { return this.service.create(auth, dto); } @Get(':id') - @Authenticated() + @Authenticated({ permission: Permission.MEMORY_READ }) getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') - @Authenticated() + @Authenticated({ permission: Permission.MEMORY_UPDATE }) updateMemory( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -42,7 +43,7 @@ export class MemoryController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() + @Authenticated({ permission: Permission.MEMORY_DELETE }) deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } diff --git a/server/src/controllers/partner.controller.ts b/server/src/controllers/partner.controller.ts index 208d571464..0662243d61 100644 --- a/server/src/controllers/partner.controller.ts +++ b/server/src/controllers/partner.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/ import { ApiQuery, ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto'; +import { Permission } from 'src/enum'; import { PartnerDirection } from 'src/interfaces/partner.interface'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { PartnerService } from 'src/services/partner.service'; @@ -14,20 +15,20 @@ export class PartnerController { @Get() @ApiQuery({ name: 'direction', type: 'string', enum: PartnerDirection, required: true }) - @Authenticated() + @Authenticated({ permission: Permission.PARTNER_READ }) // TODO: remove 'direction' and convert to full query dto getPartners(@Auth() auth: AuthDto, @Query() dto: PartnerSearchDto): Promise { return this.service.search(auth, dto); } @Post(':id') - @Authenticated() + @Authenticated({ permission: Permission.PARTNER_CREATE }) createPartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.create(auth, id); } @Put(':id') - @Authenticated() + @Authenticated({ permission: Permission.PARTNER_UPDATE }) updatePartner( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -37,7 +38,7 @@ export class PartnerController { } @Delete(':id') - @Authenticated() + @Authenticated({ permission: Permission.PARTNER_DELETE }) removePartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index 082d5ca46c..5462305d9f 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -16,6 +16,7 @@ import { PersonStatisticsResponseDto, PersonUpdateDto, } from 'src/dtos/person.dto'; +import { Permission } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { PersonService } from 'src/services/person.service'; @@ -31,31 +32,31 @@ export class PersonController { ) {} @Get() - @Authenticated() + @Authenticated({ permission: Permission.PERSON_READ }) getAllPeople(@Auth() auth: AuthDto, @Query() withHidden: PersonSearchDto): Promise { return this.service.getAll(auth, withHidden); } @Post() - @Authenticated() + @Authenticated({ permission: Permission.PERSON_CREATE }) createPerson(@Auth() auth: AuthDto, @Body() dto: PersonCreateDto): Promise { return this.service.create(auth, dto); } @Put() - @Authenticated() + @Authenticated({ permission: Permission.PERSON_UPDATE }) updatePeople(@Auth() auth: AuthDto, @Body() dto: PeopleUpdateDto): Promise { return this.service.updateAll(auth, dto); } @Get(':id') - @Authenticated() + @Authenticated({ permission: Permission.PERSON_READ }) getPerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getById(auth, id); } @Put(':id') - @Authenticated() + @Authenticated({ permission: Permission.PERSON_UPDATE }) updatePerson( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -65,14 +66,14 @@ export class PersonController { } @Get(':id/statistics') - @Authenticated() + @Authenticated({ permission: Permission.PERSON_STATISTICS }) getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getStatistics(auth, id); } @Get(':id/thumbnail') @FileResponse() - @Authenticated() + @Authenticated({ permission: Permission.PERSON_READ }) async getPersonThumbnail( @Res() res: Response, @Next() next: NextFunction, @@ -90,7 +91,7 @@ export class PersonController { } @Put(':id/reassign') - @Authenticated() + @Authenticated({ permission: Permission.PERSON_REASSIGN }) reassignFaces( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -100,7 +101,7 @@ export class PersonController { } @Post(':id/merge') - @Authenticated() + @Authenticated({ permission: Permission.PERSON_MERGE }) mergePerson( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index ffd6e0c969..065e578ec5 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -10,6 +10,7 @@ import { SharedLinkPasswordDto, SharedLinkResponseDto, } from 'src/dtos/shared-link.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { LoginDetails } from 'src/services/auth.service'; import { SharedLinkService } from 'src/services/shared-link.service'; @@ -22,7 +23,7 @@ export class SharedLinkController { constructor(private service: SharedLinkService) {} @Get() - @Authenticated() + @Authenticated({ permission: Permission.SHARED_LINK_READ }) getAllSharedLinks(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @@ -48,19 +49,19 @@ export class SharedLinkController { } @Get(':id') - @Authenticated() + @Authenticated({ permission: Permission.SHARED_LINK_READ }) getSharedLinkById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Post() - @Authenticated() + @Authenticated({ permission: Permission.SHARED_LINK_CREATE }) createSharedLink(@Auth() auth: AuthDto, @Body() dto: SharedLinkCreateDto) { return this.service.create(auth, dto); } @Patch(':id') - @Authenticated() + @Authenticated({ permission: Permission.SHARED_LINK_UPDATE }) updateSharedLink( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -70,7 +71,7 @@ export class SharedLinkController { } @Delete(':id') - @Authenticated() + @Authenticated({ permission: Permission.SHARED_LINK_DELETE }) removeSharedLink(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } diff --git a/server/src/controllers/system-config.controller.ts b/server/src/controllers/system-config.controller.ts index e88f3dcb39..804c19500f 100644 --- a/server/src/controllers/system-config.controller.ts +++ b/server/src/controllers/system-config.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; +import { Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { SystemConfigService } from 'src/services/system-config.service'; @@ -10,25 +11,25 @@ export class SystemConfigController { constructor(private service: SystemConfigService) {} @Get() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true }) getConfig(): Promise { return this.service.getConfig(); } @Get('defaults') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true }) getConfigDefaults(): SystemConfigDto { return this.service.getDefaults(); } @Put() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_CONFIG_UPDATE, admin: true }) updateConfig(@Body() dto: SystemConfigDto): Promise { return this.service.updateConfig(dto); } @Get('storage-template-options') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true }) getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { return this.service.getStorageTemplateOptions(); } diff --git a/server/src/controllers/system-metadata.controller.ts b/server/src/controllers/system-metadata.controller.ts index 90e9f5b6a8..bca5c65d8e 100644 --- a/server/src/controllers/system-metadata.controller.ts +++ b/server/src/controllers/system-metadata.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto } from 'src/dtos/system-metadata.dto'; +import { Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { SystemMetadataService } from 'src/services/system-metadata.service'; @@ -10,20 +11,20 @@ export class SystemMetadataController { constructor(private service: SystemMetadataService) {} @Get('admin-onboarding') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_METADATA_READ, admin: true }) getAdminOnboarding(): Promise { return this.service.getAdminOnboarding(); } @Post('admin-onboarding') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_METADATA_UPDATE, admin: true }) updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise { return this.service.updateAdminOnboarding(dto); } @Get('reverse-geocoding-state') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_METADATA_READ, admin: true }) getReverseGeocodingState(): Promise { return this.service.getReverseGeocodingState(); } diff --git a/server/src/controllers/tag.controller.ts b/server/src/controllers/tag.controller.ts index 71d826fcc5..8b646400cc 100644 --- a/server/src/controllers/tag.controller.ts +++ b/server/src/controllers/tag.controller.ts @@ -5,6 +5,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { CreateTagDto, TagResponseDto, UpdateTagDto } from 'src/dtos/tag.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TagService } from 'src/services/tag.service'; import { UUIDParamDto } from 'src/validation'; @@ -15,31 +16,31 @@ export class TagController { constructor(private service: TagService) {} @Post() - @Authenticated() + @Authenticated({ permission: Permission.TAG_CREATE }) createTag(@Auth() auth: AuthDto, @Body() dto: CreateTagDto): Promise { return this.service.create(auth, dto); } @Get() - @Authenticated() + @Authenticated({ permission: Permission.TAG_READ }) getAllTags(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @Get(':id') - @Authenticated() + @Authenticated({ permission: Permission.TAG_READ }) getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getById(auth, id); } @Patch(':id') - @Authenticated() + @Authenticated({ permission: Permission.TAG_UPDATE }) updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateTagDto): Promise { return this.service.update(auth, id, dto); } @Delete(':id') - @Authenticated() + @Authenticated({ permission: Permission.TAG_DELETE }) deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index a4f3b3198c..d44115be2f 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -9,6 +9,7 @@ import { UserAdminSearchDto, UserAdminUpdateDto, } from 'src/dtos/user.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { UserAdminService } from 'src/services/user-admin.service'; import { UUIDParamDto } from 'src/validation'; @@ -19,25 +20,25 @@ export class UserAdminController { constructor(private service: UserAdminService) {} @Get() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) searchUsersAdmin(@Auth() auth: AuthDto, @Query() dto: UserAdminSearchDto): Promise { return this.service.search(auth, dto); } @Post() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.ADMIN_USER_CREATE, admin: true }) createUserAdmin(@Body() createUserDto: UserAdminCreateDto): Promise { return this.service.create(createUserDto); } @Get(':id') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) getUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.ADMIN_USER_UPDATE, admin: true }) updateUserAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -47,7 +48,7 @@ export class UserAdminController { } @Delete(':id') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.ADMIN_USER_DELETE, admin: true }) deleteUserAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -57,13 +58,13 @@ export class UserAdminController { } @Get(':id/preferences') - @Authenticated() + @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getPreferences(auth, id); } @Put(':id/preferences') - @Authenticated() + @Authenticated({ permission: Permission.ADMIN_USER_UPDATE, admin: true }) updateUserPreferencesAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -73,7 +74,7 @@ export class UserAdminController { } @Post(':id/restore') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.ADMIN_USER_DELETE, admin: true }) restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.restore(auth, id); } diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts index aba13e5acf..b8ba88b59d 100644 --- a/server/src/cores/access.core.ts +++ b/server/src/cores/access.core.ts @@ -256,7 +256,7 @@ export class AccessCore { return this.repository.memory.checkOwnerAccess(auth.user.id, ids); } - case Permission.MEMORY_WRITE: { + case Permission.MEMORY_UPDATE: { return this.repository.memory.checkOwnerAccess(auth.user.id, ids); } @@ -272,7 +272,7 @@ export class AccessCore { return await this.repository.person.checkOwnerAccess(auth.user.id, ids); } - case Permission.PERSON_WRITE: { + case Permission.PERSON_UPDATE: { return await this.repository.person.checkOwnerAccess(auth.user.id, ids); } diff --git a/server/src/dtos/api-key.dto.ts b/server/src/dtos/api-key.dto.ts index 1f4f855216..7e81ce8c60 100644 --- a/server/src/dtos/api-key.dto.ts +++ b/server/src/dtos/api-key.dto.ts @@ -1,10 +1,17 @@ -import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { Permission } from 'src/enum'; import { Optional } from 'src/validation'; export class APIKeyCreateDto { @IsString() @IsNotEmpty() @Optional() name?: string; + + @IsEnum(Permission, { each: true }) + @ApiProperty({ enum: Permission, enumName: 'Permission', isArray: true }) + @ArrayMinSize(1) + permissions!: Permission[]; } export class APIKeyUpdateDto { @@ -23,4 +30,6 @@ export class APIKeyResponseDto { name!: string; createdAt!: Date; updatedAt!: Date; + @ApiProperty({ enum: Permission, enumName: 'Permission', isArray: true }) + permissions!: Permission[]; } diff --git a/server/src/entities/api-key.entity.ts b/server/src/entities/api-key.entity.ts index 18aaa83041..998ee4f8ef 100644 --- a/server/src/entities/api-key.entity.ts +++ b/server/src/entities/api-key.entity.ts @@ -1,4 +1,5 @@ import { UserEntity } from 'src/entities/user.entity'; +import { Permission } from 'src/enum'; import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; @Entity('api_keys') @@ -18,6 +19,9 @@ export class APIKeyEntity { @Column() userId!: string; + @Column({ array: true, type: 'varchar' }) + permissions!: Permission[]; + @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; diff --git a/server/src/enum.ts b/server/src/enum.ts index 04f59e5a98..da4b2d76fc 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -32,8 +32,18 @@ export enum MemoryType { } export enum Permission { + ALL = 'all', + ACTIVITY_CREATE = 'activity.create', + ACTIVITY_READ = 'activity.read', + ACTIVITY_UPDATE = 'activity.update', ACTIVITY_DELETE = 'activity.delete', + ACTIVITY_STATISTICS = 'activity.statistics', + + API_KEY_CREATE = 'apiKey.create', + API_KEY_READ = 'apiKey.read', + API_KEY_UPDATE = 'apiKey.update', + API_KEY_DELETE = 'apiKey.delete', // ASSET_CREATE = 'asset.create', ASSET_READ = 'asset.read', @@ -45,10 +55,12 @@ export enum Permission { ASSET_DOWNLOAD = 'asset.download', ASSET_UPLOAD = 'asset.upload', - // ALBUM_CREATE = 'album.create', + ALBUM_CREATE = 'album.create', ALBUM_READ = 'album.read', ALBUM_UPDATE = 'album.update', ALBUM_DELETE = 'album.delete', + ALBUM_STATISTICS = 'album.statistics', + ALBUM_ADD_ASSET = 'album.addAsset', ALBUM_REMOVE_ASSET = 'album.removeAsset', ALBUM_SHARE = 'album.share', @@ -58,20 +70,58 @@ export enum Permission { ARCHIVE_READ = 'archive.read', + FACE_CREATE = 'face.create', + FACE_READ = 'face.read', + FACE_UPDATE = 'face.update', + FACE_DELETE = 'face.delete', + + LIBRARY_CREATE = 'library.create', + LIBRARY_READ = 'library.read', + LIBRARY_UPDATE = 'library.update', + LIBRARY_DELETE = 'library.delete', + LIBRARY_STATISTICS = 'library.statistics', + TIMELINE_READ = 'timeline.read', TIMELINE_DOWNLOAD = 'timeline.download', + MEMORY_CREATE = 'memory.create', MEMORY_READ = 'memory.read', - MEMORY_WRITE = 'memory.write', + MEMORY_UPDATE = 'memory.update', MEMORY_DELETE = 'memory.delete', - PERSON_READ = 'person.read', - PERSON_WRITE = 'person.write', - PERSON_MERGE = 'person.merge', + PARTNER_CREATE = 'partner.create', + PARTNER_READ = 'partner.read', + PARTNER_UPDATE = 'partner.update', + PARTNER_DELETE = 'partner.delete', + PERSON_CREATE = 'person.create', + PERSON_READ = 'person.read', + PERSON_UPDATE = 'person.update', + PERSON_DELETE = 'person.delete', + PERSON_STATISTICS = 'person.statistics', + PERSON_MERGE = 'person.merge', PERSON_REASSIGN = 'person.reassign', - PARTNER_UPDATE = 'partner.update', + SHARED_LINK_CREATE = 'sharedLink.create', + SHARED_LINK_READ = 'sharedLink.read', + SHARED_LINK_UPDATE = 'sharedLink.update', + SHARED_LINK_DELETE = 'sharedLink.delete', + + SYSTEM_CONFIG_READ = 'systemConfig.read', + SYSTEM_CONFIG_UPDATE = 'systemConfig.update', + + SYSTEM_METADATA_READ = 'systemMetadata.read', + SYSTEM_METADATA_UPDATE = 'systemMetadata.update', + + TAG_CREATE = 'tag.create', + TAG_READ = 'tag.read', + TAG_UPDATE = 'tag.update', + TAG_DELETE = 'tag.delete', + + ADMIN_USER_CREATE = 'admin.user.create', + ADMIN_USER_READ = 'admin.user.read', + ADMIN_USER_UPDATE = 'admin.user.update', + ADMIN_USER_DELETE = 'admin.user.delete', } export enum SharedLinkType { diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index beab484950..d6138f2d3a 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -11,6 +11,7 @@ import { Reflector } from '@nestjs/core'; import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger'; import { Request } from 'express'; import { AuthDto, ImmichQuery } from 'src/dtos/auth.dto'; +import { Permission } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { UAParser } from 'ua-parser-js'; @@ -25,7 +26,7 @@ export enum Metadata { type AdminRoute = { admin?: true }; type SharedLinkRoute = { sharedLink?: true }; -type AuthenticatedOptions = AdminRoute | SharedLinkRoute; +type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute); export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator => { const decorators: MethodDecorator[] = [ @@ -89,13 +90,17 @@ export class AuthGuard implements CanActivate { return true; } - const { admin: adminRoute, sharedLink: sharedLinkRoute } = { sharedLink: false, admin: false, ...options }; + const { + admin: adminRoute, + sharedLink: sharedLinkRoute, + permission, + } = { sharedLink: false, admin: false, ...options }; const request = context.switchToHttp().getRequest(); request.user = await this.authService.authenticate({ headers: request.headers, queryParams: request.query as Record, - metadata: { adminRoute, sharedLinkRoute, uri: request.path }, + metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path }, }); return true; diff --git a/server/src/migrations/1723719333525-AddApiKeyPermissions.ts b/server/src/migrations/1723719333525-AddApiKeyPermissions.ts new file mode 100644 index 0000000000..d585d98bcb --- /dev/null +++ b/server/src/migrations/1723719333525-AddApiKeyPermissions.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddApiKeyPermissions1723719333525 implements MigrationInterface { + name = 'AddApiKeyPermissions1723719333525'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "api_keys" ADD "permissions" character varying array NOT NULL DEFAULT '{all}'`); + await queryRunner.query(`ALTER TABLE "api_keys" ALTER COLUMN "permissions" DROP DEFAULT`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "api_keys" DROP COLUMN "permissions"`); + } +} diff --git a/server/src/queries/api.key.repository.sql b/server/src/queries/api.key.repository.sql index ba54a6e67c..e5f389ac4d 100644 --- a/server/src/queries/api.key.repository.sql +++ b/server/src/queries/api.key.repository.sql @@ -9,6 +9,7 @@ FROM "APIKeyEntity"."id" AS "APIKeyEntity_id", "APIKeyEntity"."key" AS "APIKeyEntity_key", "APIKeyEntity"."userId" AS "APIKeyEntity_userId", + "APIKeyEntity"."permissions" AS "APIKeyEntity_permissions", "APIKeyEntity__APIKeyEntity_user"."id" AS "APIKeyEntity__APIKeyEntity_user_id", "APIKeyEntity__APIKeyEntity_user"."name" AS "APIKeyEntity__APIKeyEntity_user_name", "APIKeyEntity__APIKeyEntity_user"."isAdmin" AS "APIKeyEntity__APIKeyEntity_user_isAdmin", @@ -46,6 +47,7 @@ SELECT "APIKeyEntity"."id" AS "APIKeyEntity_id", "APIKeyEntity"."name" AS "APIKeyEntity_name", "APIKeyEntity"."userId" AS "APIKeyEntity_userId", + "APIKeyEntity"."permissions" AS "APIKeyEntity_permissions", "APIKeyEntity"."createdAt" AS "APIKeyEntity_createdAt", "APIKeyEntity"."updatedAt" AS "APIKeyEntity_updatedAt" FROM @@ -63,6 +65,7 @@ SELECT "APIKeyEntity"."id" AS "APIKeyEntity_id", "APIKeyEntity"."name" AS "APIKeyEntity_name", "APIKeyEntity"."userId" AS "APIKeyEntity_userId", + "APIKeyEntity"."permissions" AS "APIKeyEntity_permissions", "APIKeyEntity"."createdAt" AS "APIKeyEntity_createdAt", "APIKeyEntity"."updatedAt" AS "APIKeyEntity_updatedAt" FROM diff --git a/server/src/repositories/api-key.repository.ts b/server/src/repositories/api-key.repository.ts index c5cdb80551..5178039177 100644 --- a/server/src/repositories/api-key.repository.ts +++ b/server/src/repositories/api-key.repository.ts @@ -31,6 +31,7 @@ export class ApiKeyRepository implements IKeyRepository { id: true, key: true, userId: true, + permissions: true, }, where: { key: hashedToken }, relations: { diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 2b5efc674f..4d13eead57 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -1,4 +1,5 @@ import { BadRequestException } from '@nestjs/common'; +import { Permission } from 'src/enum'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { APIKeyService } from 'src/services/api-key.service'; @@ -22,10 +23,11 @@ describe(APIKeyService.name, () => { describe('create', () => { it('should create a new key', async () => { keyMock.create.mockResolvedValue(keyStub.admin); - await sut.create(authStub.admin, { name: 'Test Key' }); + await sut.create(authStub.admin, { name: 'Test Key', permissions: [Permission.ALL] }); expect(keyMock.create).toHaveBeenCalledWith({ key: 'cmFuZG9tLWJ5dGVz (hashed)', name: 'Test Key', + permissions: [Permission.ALL], userId: authStub.admin.user.id, }); expect(cryptoMock.newPassword).toHaveBeenCalled(); @@ -35,11 +37,12 @@ describe(APIKeyService.name, () => { it('should not require a name', async () => { keyMock.create.mockResolvedValue(keyStub.admin); - await sut.create(authStub.admin, {}); + await sut.create(authStub.admin, { permissions: [Permission.ALL] }); expect(keyMock.create).toHaveBeenCalledWith({ key: 'cmFuZG9tLWJ5dGVz (hashed)', name: 'API Key', + permissions: [Permission.ALL], userId: authStub.admin.user.id, }); expect(cryptoMock.newPassword).toHaveBeenCalled(); diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 24a57d3651..7dd1ed5c26 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -1,9 +1,10 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto } from 'src/dtos/api-key.dto'; +import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { APIKeyEntity } from 'src/entities/api-key.entity'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { isGranted } from 'src/utils/access'; @Injectable() export class APIKeyService { @@ -14,16 +15,22 @@ export class APIKeyService { async create(auth: AuthDto, dto: APIKeyCreateDto): Promise { const secret = this.crypto.newPassword(32); + + if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) { + throw new BadRequestException('Cannot grant permissions you do not have'); + } + const entity = await this.repository.create({ key: this.crypto.hashSha256(secret), name: dto.name || 'API Key', userId: auth.user.id, + permissions: dto.permissions, }); return { secret, apiKey: this.map(entity) }; } - async update(auth: AuthDto, id: string, dto: APIKeyCreateDto): Promise { + async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise { const exists = await this.repository.getById(auth.user.id, id); if (!exists) { throw new BadRequestException('API Key not found'); @@ -62,6 +69,7 @@ export class APIKeyService { name: entity.name, createdAt: entity.createdAt, updatedAt: entity.updatedAt, + permissions: entity.permissions, }; } } diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 0ba44601b9..18b4268292 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -31,6 +31,7 @@ import { } from 'src/dtos/auth.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserEntity } from 'src/entities/user.entity'; +import { Permission } from 'src/enum'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; @@ -38,6 +39,7 @@ import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; export interface LoginDetails { @@ -61,6 +63,7 @@ export type ValidateRequest = { metadata: { sharedLinkRoute: boolean; adminRoute: boolean; + permission?: Permission; uri: string; }; }; @@ -157,7 +160,7 @@ export class AuthService { async authenticate({ headers, queryParams, metadata }: ValidateRequest): Promise { const authDto = await this.validate({ headers, queryParams }); - const { adminRoute, sharedLinkRoute, uri } = metadata; + const { adminRoute, sharedLinkRoute, permission, uri } = metadata; if (!authDto.user.isAdmin && adminRoute) { this.logger.warn(`Denied access to admin only route: ${uri}`); @@ -169,6 +172,10 @@ export class AuthService { throw new ForbiddenException('Forbidden'); } + if (authDto.apiKey && permission && !isGranted({ requested: [permission], current: authDto.apiKey.permissions })) { + throw new ForbiddenException(`Missing required permission: ${permission}`); + } + return authDto; } diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 02fdacc355..c8c44d04b3 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -50,7 +50,7 @@ export class MemoryService { } async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id); + await this.access.requirePermission(auth, Permission.MEMORY_UPDATE, id); const memory = await this.repository.update({ id, @@ -82,7 +82,7 @@ export class MemoryService { } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id); + await this.access.requirePermission(auth, Permission.MEMORY_UPDATE, id); const repos = { accessRepository: this.accessRepository, repository: this.repository }; const results = await removeAssets(auth, repos, { diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 8ffae5bf05..6d536f4bf8 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -113,7 +113,7 @@ export class PersonService { } async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise { - await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId); + await this.access.requirePermission(auth, Permission.PERSON_UPDATE, personId); const person = await this.findOrFail(personId); const result: PersonResponseDto[] = []; const changeFeaturePhoto: string[] = []; @@ -142,7 +142,7 @@ export class PersonService { } async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise { - await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId); + await this.access.requirePermission(auth, Permission.PERSON_UPDATE, personId); await this.access.requirePermission(auth, Permission.PERSON_CREATE, dto.id); const face = await this.repository.getFaceById(dto.id); @@ -226,7 +226,7 @@ export class PersonService { } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { - await this.access.requirePermission(auth, Permission.PERSON_WRITE, id); + await this.access.requirePermission(auth, Permission.PERSON_UPDATE, id); const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto; // TODO: set by faceId directly @@ -581,7 +581,7 @@ export class PersonService { throw new BadRequestException('Cannot merge a person into themselves'); } - await this.access.requirePermission(auth, Permission.PERSON_WRITE, id); + await this.access.requirePermission(auth, Permission.PERSON_UPDATE, id); let primaryPerson = await this.findOrFail(id); const primaryName = primaryPerson.name || primaryPerson.id; diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts new file mode 100644 index 0000000000..cd24087d9b --- /dev/null +++ b/server/src/utils/access.ts @@ -0,0 +1,15 @@ +import { Permission } from 'src/enum'; +import { setIsSuperset } from 'src/utils/set'; + +export type GrantedRequest = { + requested: Permission[]; + current: Permission[]; +}; + +export const isGranted = ({ requested, current }: GrantedRequest) => { + if (current.includes(Permission.ALL)) { + return true; + } + + return setIsSuperset(new Set(current), new Set(requested)); +}; diff --git a/web/src/lib/components/forms/api-key-form.svelte b/web/src/lib/components/forms/api-key-form.svelte index 55ec258b40..5b1341db44 100644 --- a/web/src/lib/components/forms/api-key-form.svelte +++ b/web/src/lib/components/forms/api-key-form.svelte @@ -1,25 +1,21 @@ - + onCancel()}>
    @@ -37,7 +33,7 @@
    - +
    diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte index 1cc89ad30d..13ec440082 100644 --- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte +++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte @@ -1,6 +1,13 @@
    - +
    - +
    diff --git a/web/src/lib/components/shared-components/number-range-input.svelte b/web/src/lib/components/shared-components/number-range-input.svelte index e4c780a708..2e7dca8781 100644 --- a/web/src/lib/components/shared-components/number-range-input.svelte +++ b/web/src/lib/components/shared-components/number-range-input.svelte @@ -1,5 +1,6 @@ From c9f1304bce74587fea7bd56b917bec8a7baf10af Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 18 Aug 2024 14:12:10 +0200 Subject: [PATCH 120/723] fix(web): show camera make in search options after searching (#11884) --- .../search-bar/search-camera-section.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte index 3e7f03e9c2..3610b11a74 100644 --- a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte @@ -27,11 +27,12 @@ model, includeNull: true, }); + + makes = results.map((result) => result ?? ''); + if (filters.make && !makes.includes(filters.make)) { filters.make = undefined; } - - makes = results.map((result) => result ?? ''); } async function updateModels(make?: string) { From bd42e05152aba5b05185e727ed56b1f83fc60cc8 Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Sun, 18 Aug 2024 07:13:41 -0500 Subject: [PATCH 121/723] fix(web): correctly populate the camera model search dropdown (#11883) --- .../shared-components/search-bar/search-camera-section.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte index 3610b11a74..839c17ecce 100644 --- a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte @@ -42,7 +42,7 @@ includeNull: true, }); - const models = results.map((result) => result ?? ''); + models = results.map((result) => result ?? ''); if (filters.model && !models.includes(filters.model)) { filters.model = undefined; From 5ab92f346a17e6eb68bdd73011bb7c12d311a2e2 Mon Sep 17 00:00:00 2001 From: simkli Date: Sun, 18 Aug 2024 16:38:21 +0200 Subject: [PATCH 122/723] feat(web): drag and drop or paste directories for upload (#11879) feat(web): support for directories drag and drop Allows directories to be drag and dropped or pasted for upload. --- .../drag-and-drop-upload-overlay.svelte | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte index 466b3d083e..935c63500d 100644 --- a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte +++ b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte @@ -26,12 +26,67 @@ const onDrop = async (e: DragEvent) => { dragStartTarget = null; - await handleFiles(e.dataTransfer?.files); + await handleDataTransfer(e.dataTransfer); }; - const onPaste = ({ clipboardData }: ClipboardEvent) => handleFiles(clipboardData?.files); + const onPaste = ({ clipboardData }: ClipboardEvent) => handleDataTransfer(clipboardData); - const handleFiles = async (files?: FileList) => { + const handleDataTransfer = async (dataTransfer?: DataTransfer | null) => { + if (!dataTransfer) { + return; + } + + if (!browserSupportsDirectoryUpload()) { + return handleFiles(dataTransfer.files); + } + + const transferEntries = Array.from(dataTransfer.items) + .map((i: DataTransferItem) => i.webkitGetAsEntry()) + .filter((i) => i !== null); + const files = await getAllFilesFromTransferEntries(transferEntries); + return handleFiles(files); + }; + + const browserSupportsDirectoryUpload = () => typeof DataTransferItem.prototype.webkitGetAsEntry === 'function'; + + const getAllFilesFromTransferEntries = async (transferEntries: FileSystemEntry[]): Promise => { + const allFiles: File[] = []; + let entriesToCheckForSubDirectories = [...transferEntries]; + while (entriesToCheckForSubDirectories.length > 0) { + const currentEntry = entriesToCheckForSubDirectories.pop(); + + if (isFileSystemDirectoryEntry(currentEntry)) { + entriesToCheckForSubDirectories = entriesToCheckForSubDirectories.concat( + await getContentsFromFileSystemDirectoryEntry(currentEntry), + ); + } else if (isFileSystemFileEntry(currentEntry)) { + allFiles.push(await getFileFromFileSystemEntry(currentEntry)); + } + } + + return allFiles; + }; + + const isFileSystemDirectoryEntry = (entry?: FileSystemEntry): entry is FileSystemDirectoryEntry => + !!entry && entry.isDirectory; + const isFileSystemFileEntry = (entry?: FileSystemEntry): entry is FileSystemFileEntry => !!entry && entry.isFile; + + const getFileFromFileSystemEntry = async (fileSystemFileEntry: FileSystemFileEntry): Promise => { + return new Promise((resolve, reject) => { + fileSystemFileEntry.file(resolve, reject); + }); + }; + + const getContentsFromFileSystemDirectoryEntry = async ( + fileSystemDirectoryEntry: FileSystemDirectoryEntry, + ): Promise => { + return new Promise((resolve, reject) => { + const reader = fileSystemDirectoryEntry.createReader(); + reader.readEntries(resolve, reject); + }); + }; + + const handleFiles = async (files?: FileList | File[]) => { if (!files) { return; } From 036676d50152779f3d0b11232039f8ed8cdba809 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sun, 18 Aug 2024 11:05:10 -0400 Subject: [PATCH 123/723] fix(ml): tokenization for webli models (#11881) --- machine-learning/app/models/clip/textual.py | 13 +++++++-- machine-learning/app/models/transforms.py | 9 +++++++ machine-learning/app/test_main.py | 29 ++++++++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/machine-learning/app/models/clip/textual.py b/machine-learning/app/models/clip/textual.py index 7a25c2f4ad..32c28ea2bb 100644 --- a/machine-learning/app/models/clip/textual.py +++ b/machine-learning/app/models/clip/textual.py @@ -10,6 +10,7 @@ from tokenizers import Encoding, Tokenizer from app.config import log from app.models.base import InferenceModel +from app.models.transforms import clean_text from app.schemas import ModelSession, ModelTask, ModelType @@ -25,6 +26,8 @@ class BaseCLIPTextualEncoder(InferenceModel): session = super()._load() log.debug(f"Loading tokenizer for CLIP model '{self.model_name}'") self.tokenizer = self._load_tokenizer() + tokenizer_kwargs: dict[str, Any] | None = self.text_cfg.get("tokenizer_kwargs") + self.canonicalize = tokenizer_kwargs is not None and tokenizer_kwargs.get("clean") == "canonicalize" log.debug(f"Loaded tokenizer for CLIP model '{self.model_name}'") return session @@ -56,6 +59,11 @@ class BaseCLIPTextualEncoder(InferenceModel): log.debug(f"Loaded model config for CLIP model '{self.model_name}'") return model_cfg + @property + def text_cfg(self) -> dict[str, Any]: + text_cfg: dict[str, Any] = self.model_cfg["text_cfg"] + return text_cfg + @cached_property def tokenizer_file(self) -> dict[str, Any]: log.debug(f"Loading tokenizer file for CLIP model '{self.model_name}'") @@ -73,8 +81,7 @@ class BaseCLIPTextualEncoder(InferenceModel): class OpenClipTextualEncoder(BaseCLIPTextualEncoder): def _load_tokenizer(self) -> Tokenizer: - text_cfg: dict[str, Any] = self.model_cfg["text_cfg"] - context_length: int = text_cfg.get("context_length", 77) + context_length: int = self.text_cfg.get("context_length", 77) pad_token: str = self.tokenizer_cfg["pad_token"] tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix()) @@ -86,12 +93,14 @@ class OpenClipTextualEncoder(BaseCLIPTextualEncoder): return tokenizer def tokenize(self, text: str) -> dict[str, NDArray[np.int32]]: + text = clean_text(text, canonicalize=self.canonicalize) tokens: Encoding = self.tokenizer.encode(text) return {"text": np.array([tokens.ids], dtype=np.int32)} class MClipTextualEncoder(OpenClipTextualEncoder): def tokenize(self, text: str) -> dict[str, NDArray[np.int32]]: + text = clean_text(text, canonicalize=self.canonicalize) tokens: Encoding = self.tokenizer.encode(text) return { "input_ids": np.array([tokens.ids], dtype=np.int32), diff --git a/machine-learning/app/models/transforms.py b/machine-learning/app/models/transforms.py index cae9b6b1ab..bb03103d4b 100644 --- a/machine-learning/app/models/transforms.py +++ b/machine-learning/app/models/transforms.py @@ -1,3 +1,4 @@ +import string from io import BytesIO from typing import IO @@ -7,6 +8,7 @@ from numpy.typing import NDArray from PIL import Image _PIL_RESAMPLING_METHODS = {resampling.name.lower(): resampling for resampling in Image.Resampling} +_PUNCTUATION_TRANS = str.maketrans("", "", string.punctuation) def resize_pil(img: Image.Image, size: int) -> Image.Image: @@ -60,3 +62,10 @@ def decode_cv2(image_bytes: NDArray[np.uint8] | bytes | Image.Image) -> NDArray[ if isinstance(image_bytes, Image.Image): return pil_to_cv2(image_bytes) return image_bytes + + +def clean_text(text: str, canonicalize: bool = False) -> str: + text = " ".join(text.split()) + if canonicalize: + text = text.translate(_PUNCTUATION_TRANS).lower() + return text diff --git a/machine-learning/app/test_main.py b/machine-learning/app/test_main.py index fb3542e7e4..17fdb5b1fa 100644 --- a/machine-learning/app/test_main.py +++ b/machine-learning/app/test_main.py @@ -379,13 +379,40 @@ class TestCLIP: clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache") clip_encoder._load() - tokens = clip_encoder.tokenize("test search query") + tokens = clip_encoder.tokenize("test search query") assert "text" in tokens assert isinstance(tokens["text"], np.ndarray) assert tokens["text"].shape == (1, 77) assert tokens["text"].dtype == np.int32 assert np.allclose(tokens["text"], np.array([mock_ids], dtype=np.int32), atol=0) + mock_tokenizer.encode.assert_called_once_with("test search query") + + def test_openclip_tokenizer_canonicalizes_text( + self, + mocker: MockerFixture, + clip_model_cfg: dict[str, Any], + clip_tokenizer_cfg: Callable[[Path], dict[str, Any]], + ) -> None: + clip_model_cfg["text_cfg"]["tokenizer_kwargs"] = {"clean": "canonicalize"} + mocker.patch.object(OpenClipTextualEncoder, "download") + mocker.patch.object(OpenClipTextualEncoder, "model_cfg", clip_model_cfg) + mocker.patch.object(OpenClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg) + mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value + mock_tokenizer = mocker.patch("app.models.clip.textual.Tokenizer.from_file", autospec=True).return_value + mock_ids = [randint(0, 50000) for _ in range(77)] + mock_tokenizer.encode.return_value = SimpleNamespace(ids=mock_ids) + + clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache") + clip_encoder._load() + tokens = clip_encoder.tokenize("Test Search Query!") + + assert "text" in tokens + assert isinstance(tokens["text"], np.ndarray) + assert tokens["text"].shape == (1, 77) + assert tokens["text"].dtype == np.int32 + assert np.allclose(tokens["text"], np.array([mock_ids], dtype=np.int32), atol=0) + mock_tokenizer.encode.assert_called_once_with("test search query") def test_mclip_tokenizer( self, From fa7f1e656ff7286309fd20125031f626f7e119d4 Mon Sep 17 00:00:00 2001 From: "immich-tofu[bot]" <171590969+immich-tofu[bot]@users.noreply.github.com> Date: Sun, 18 Aug 2024 21:46:08 +0000 Subject: [PATCH 124/723] chore: modify .github/FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..472954447f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ["https://buy.immich.app"] From bc31b7c06c0245708ff27b2b278edbfa0a526328 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 18 Aug 2024 21:27:19 -0500 Subject: [PATCH 125/723] feat(mobile): memories lane with the new CarouselView (#11892) * feat(mobile): memories lane with the new CarouselView * tuning * tuning --- mobile/lib/widgets/memories/memory_lane.dart | 157 ++++++++++--------- 1 file changed, 85 insertions(+), 72 deletions(-) diff --git a/mobile/lib/widgets/memories/memory_lane.dart b/mobile/lib/widgets/memories/memory_lane.dart index 4d4fa8c4e0..41e9cc628e 100644 --- a/mobile/lib/widgets/memories/memory_lane.dart +++ b/mobile/lib/widgets/memories/memory_lane.dart @@ -1,6 +1,8 @@ import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -9,6 +11,7 @@ import 'package:immich_mobile/widgets/common/immich_image.dart'; class MemoryLane extends HookConsumerWidget { const MemoryLane({super.key}); + @override Widget build(BuildContext context, WidgetRef ref) { final memoryLaneFutureProvider = ref.watch(memoryFutureProvider); @@ -16,82 +19,35 @@ class MemoryLane extends HookConsumerWidget { final memoryLane = memoryLaneFutureProvider .whenData( (memories) => memories != null - ? SizedBox( - height: 200, - child: ListView.builder( - scrollDirection: Axis.horizontal, - shrinkWrap: true, - itemCount: memories.length, - padding: const EdgeInsets.only( - right: 8.0, - bottom: 8, - top: 10, - left: 10, + ? ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 200, + ), + child: CarouselView( + itemExtent: 145.0, + shrinkExtent: 1.0, + elevation: 2, + backgroundColor: Colors.black, + overlayColor: WidgetStateProperty.all( + Colors.white.withOpacity(0.1), ), - itemBuilder: (context, index) { - final memory = memories[index]; - - return GestureDetector( - onTap: () { - ref - .read(hapticFeedbackProvider.notifier) - .heavyImpact(); - context.pushRoute( - MemoryRoute( - memories: memories, - memoryIndex: index, - ), - ); - }, - child: Stack( - children: [ - Card( - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(13.0), - ), - clipBehavior: Clip.hardEdge, - child: ColorFiltered( - colorFilter: ColorFilter.mode( - Colors.black.withOpacity(0.2), - BlendMode.darken, - ), - child: Hero( - tag: 'memory-${memory.assets[0].id}', - child: ImmichImage( - memory.assets[0], - fit: BoxFit.cover, - width: 130, - height: 200, - placeholder: const ThumbnailPlaceholder( - width: 130, - height: 200, - ), - ), - ), - ), - ), - Positioned( - bottom: 16, - left: 16, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 114, - ), - child: Text( - memory.title, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: Colors.white, - fontSize: 15, - ), - ), - ), - ), - ], + onTap: (memoryIndex) { + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + context.pushRoute( + MemoryRoute( + memories: memories, + memoryIndex: memoryIndex, ), ); }, + children: memories + .mapIndexed( + (index, memory) => MemoryCard( + index: index, + memory: memory, + ), + ) + .toList(), ), ) : const SizedBox(), @@ -101,3 +57,60 @@ class MemoryLane extends HookConsumerWidget { return memoryLane ?? const SizedBox(); } } + +class MemoryCard extends ConsumerWidget { + const MemoryCard({ + super.key, + required this.index, + required this.memory, + }); + + final int index; + final Memory memory; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Center( + child: Stack( + children: [ + ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.black.withOpacity(0.2), + BlendMode.darken, + ), + child: Hero( + tag: 'memory-${memory.assets[0].id}', + child: ImmichImage( + memory.assets[0], + fit: BoxFit.cover, + width: 205, + height: 200, + placeholder: const ThumbnailPlaceholder( + width: 105, + height: 200, + ), + ), + ), + ), + Positioned( + bottom: 16, + left: 16, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 114, + ), + child: Text( + memory.title, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.white, + fontSize: 15, + ), + ), + ), + ), + ], + ), + ); + } +} From ca52cbace1f8df0591132406d6c694d3c78ce3a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carles=20Alb=C3=A0s=20Boix?= <43018489+carlesalbasboix@users.noreply.github.com> Date: Mon, 19 Aug 2024 19:07:18 +0200 Subject: [PATCH 126/723] feat(web): Left hand navigation with A/D (#11907) --- .../asset-viewer/actions/next-asset-action.svelte | 9 +++++++-- .../asset-viewer/actions/previous-asset-action.svelte | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/asset-viewer/actions/next-asset-action.svelte b/web/src/lib/components/asset-viewer/actions/next-asset-action.svelte index a4ee322996..cc074f3b6c 100644 --- a/web/src/lib/components/asset-viewer/actions/next-asset-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/next-asset-action.svelte @@ -1,5 +1,5 @@ - + diff --git a/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte b/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte index ef836b618c..9f8c638e12 100644 --- a/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte @@ -1,5 +1,5 @@ - + From 8338657eaa3c965f4f260723cd59fffad9f3b73b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 19 Aug 2024 13:37:15 -0400 Subject: [PATCH 127/723] refactor(server): stacks (#11453) * refactor: stacks * mobile: get it built * chore: feedback * fix: sync and duplicates * mobile: remove old stack reference * chore: add primary asset id * revert change to asset entity * mobile: refactor mobile api * mobile: sync stack info after creating stack * mobile: update timeline after deleting stack * server: update asset updatedAt when stack is deleted * mobile: simplify action * mobile: rename to match dto property * fix: web test --------- Co-authored-by: Alex --- e2e/src/api/specs/asset.e2e-spec.ts | 157 +------ e2e/src/api/specs/stack.e2e-spec.ts | 211 ++++++++++ e2e/src/api/specs/user-admin.e2e-spec.ts | 6 +- mobile/assets/i18n/en-US.json | 4 +- mobile/lib/entities/asset.entity.dart | 56 +-- mobile/lib/entities/asset.entity.g.dart | 346 ++++++++++++---- .../lib/pages/common/gallery_viewer.page.dart | 2 +- mobile/lib/providers/asset.provider.dart | 4 +- .../asset_viewer/asset_stack.provider.dart | 2 +- mobile/lib/services/api.service.dart | 2 + mobile/lib/services/asset_stack.service.dart | 72 ---- mobile/lib/services/stack.service.dart | 79 ++++ .../widgets/asset_grid/multiselect_grid.dart | 10 +- .../widgets/asset_grid/thumbnail_image.dart | 8 +- .../asset_viewer/bottom_gallery_bar.dart | 88 +--- mobile/openapi/README.md | 12 +- mobile/openapi/lib/api.dart | 6 +- mobile/openapi/lib/api/assets_api.dart | 39 -- mobile/openapi/lib/api/stacks_api.dart | 298 +++++++++++++ mobile/openapi/lib/api_client.dart | 10 +- .../lib/model/asset_bulk_update_dto.dart | 40 +- .../openapi/lib/model/asset_response_dto.dart | 35 +- .../lib/model/asset_stack_response_dto.dart | 114 +++++ mobile/openapi/lib/model/permission.dart | 12 + .../openapi/lib/model/stack_create_dto.dart | 101 +++++ .../openapi/lib/model/stack_response_dto.dart | 114 +++++ .../openapi/lib/model/stack_update_dto.dart | 107 +++++ .../lib/model/update_stack_parent_dto.dart | 106 ----- mobile/test/fixtures/asset.stub.dart | 2 - .../extensions/asset_extensions_test.dart | 1 - .../home/asset_grid_data_structure_test.dart | 1 - .../modules/shared/sync_service_test.dart | 1 - open-api/immich-openapi-specs.json | 390 ++++++++++++++---- open-api/typescript-sdk/src/fetch-client.ts | 104 ++++- server/src/controllers/asset.controller.ts | 8 - server/src/controllers/index.ts | 2 + server/src/controllers/stack.controller.ts | 57 +++ server/src/cores/access.core.ts | 12 + server/src/dtos/asset-response.dto.ts | 34 +- server/src/dtos/asset.dto.ts | 6 - server/src/dtos/stack.dto.ts | 41 +- server/src/enum.ts | 5 + server/src/interfaces/access.interface.ts | 4 + server/src/interfaces/stack.interface.ts | 9 +- server/src/queries/access.repository.sql | 11 + server/src/repositories/access.repository.ts | 29 +- server/src/repositories/stack.repository.ts | 131 +++++- server/src/services/asset.service.spec.ts | 190 +-------- server/src/services/asset.service.ts | 92 +---- server/src/services/duplicate.service.ts | 2 +- server/src/services/index.ts | 2 + server/src/services/stack.service.ts | 84 ++++ server/test/fixtures/shared-link.stub.ts | 1 - .../repositories/access.repository.mock.ts | 15 +- .../repositories/stack.repository.mock.ts | 2 + .../actions/unstack-action.svelte | 8 +- .../asset-viewer/asset-viewer-nav-bar.svelte | 15 +- .../asset-viewer/asset-viewer.svelte | 50 +-- .../assets/thumbnail/thumbnail.svelte | 4 +- .../photos-page/actions/stack-action.svelte | 5 +- .../duplicates/duplicate-asset.svelte | 7 +- web/src/lib/utils/asset-utils.ts | 106 ++--- web/src/test-data/factories/asset-factory.ts | 1 - 63 files changed, 2321 insertions(+), 1152 deletions(-) create mode 100644 e2e/src/api/specs/stack.e2e-spec.ts delete mode 100644 mobile/lib/services/asset_stack.service.dart create mode 100644 mobile/lib/services/stack.service.dart create mode 100644 mobile/openapi/lib/api/stacks_api.dart create mode 100644 mobile/openapi/lib/model/asset_stack_response_dto.dart create mode 100644 mobile/openapi/lib/model/stack_create_dto.dart create mode 100644 mobile/openapi/lib/model/stack_response_dto.dart create mode 100644 mobile/openapi/lib/model/stack_update_dto.dart delete mode 100644 mobile/openapi/lib/model/update_stack_parent_dto.dart create mode 100644 server/src/controllers/stack.controller.ts create mode 100644 server/src/services/stack.service.ts diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 4ee035ee95..5bd52b437e 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -7,7 +7,6 @@ import { SharedLinkType, getAssetInfo, getMyUser, - updateAssets, } from '@immich/sdk'; import { exiftool } from 'exiftool-vendored'; import { DateTime } from 'luxon'; @@ -67,11 +66,9 @@ describe('/asset', () => { let timeBucketUser: LoginResponseDto; let quotaUser: LoginResponseDto; let statsUser: LoginResponseDto; - let stackUser: LoginResponseDto; let user1Assets: AssetMediaResponseDto[]; let user2Assets: AssetMediaResponseDto[]; - let stackAssets: AssetMediaResponseDto[]; let locationAsset: AssetMediaResponseDto; let ratingAsset: AssetMediaResponseDto; @@ -79,14 +76,13 @@ describe('/asset', () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - [websocket, user1, user2, statsUser, quotaUser, timeBucketUser, stackUser] = await Promise.all([ + [websocket, user1, user2, statsUser, quotaUser, timeBucketUser] = await Promise.all([ utils.connectWebsocket(admin.accessToken), utils.userSetup(admin.accessToken, createUserDto.create('1')), utils.userSetup(admin.accessToken, createUserDto.create('2')), utils.userSetup(admin.accessToken, createUserDto.create('stats')), utils.userSetup(admin.accessToken, createUserDto.userQuota), utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')), - utils.userSetup(admin.accessToken, createUserDto.create('stack')), ]); await utils.createPartner(user1.accessToken, user2.userId); @@ -149,20 +145,6 @@ describe('/asset', () => { }), ]); - // stacks - stackAssets = await Promise.all([ - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - ]); - - await updateAssets( - { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, - { headers: asBearerAuth(stackUser.accessToken) }, - ); - const person1 = await utils.createPerson(user1.accessToken, { name: 'Test Person', }); @@ -826,145 +808,8 @@ describe('/asset', () => { expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); - - it('should require a valid parent id', async () => { - const { status, body } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID'])); - }); - - it('should require access to the parent', async () => { - const { status, body } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should add stack children', async () => { - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })])); - }); - - it('should remove stack children', async () => { - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ removeParent: true, ids: [stackAssets[1].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: stackAssets[2].id }), - expect.objectContaining({ id: stackAssets[3].id }), - ]), - ); - }); - - it('should remove all stack children', async () => { - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).toBeUndefined(); - }); - - it('should merge stack children', async () => { - // create stack after previous test removed stack children - await updateAssets( - { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, - { headers: asBearerAuth(stackUser.accessToken) }, - ); - - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: stackAssets[0].id }), - expect.objectContaining({ id: stackAssets[1].id }), - expect.objectContaining({ id: stackAssets[2].id }), - ]), - ); - }); }); - describe('PUT /assets/stack/parent', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put('/assets/stack/parent'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should require a valid id', async () => { - const { status, body } = await request(app) - .put('/assets/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); - }); - - it('should require access', async () => { - const { status, body } = await request(app) - .put('/assets/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should make old parent child of new parent', async () => { - const { status } = await request(app) - .put('/assets/stack/parent') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); - - expect(status).toBe(200); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - - // new parent - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: stackAssets[1].id }), - expect.objectContaining({ id: stackAssets[2].id }), - expect.objectContaining({ id: stackAssets[3].id }), - ]), - ); - }); - }); describe('POST /assets', () => { beforeAll(setupTests, 30_000); diff --git a/e2e/src/api/specs/stack.e2e-spec.ts b/e2e/src/api/specs/stack.e2e-spec.ts new file mode 100644 index 0000000000..bf34369ee3 --- /dev/null +++ b/e2e/src/api/specs/stack.e2e-spec.ts @@ -0,0 +1,211 @@ +import { AssetMediaResponseDto, LoginResponseDto, searchStacks } from '@immich/sdk'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('/stacks', () => { + let admin: LoginResponseDto; + let user1: LoginResponseDto; + let user2: LoginResponseDto; + let asset: AssetMediaResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + + admin = await utils.adminSetup(); + + [user1, user2] = await Promise.all([ + utils.userSetup(admin.accessToken, createUserDto.user1), + utils.userSetup(admin.accessToken, createUserDto.user2), + ]); + + asset = await utils.createAsset(user1.accessToken); + }); + + describe('POST /stacks', () => { + it('should require authentication', async () => { + const { status, body } = await request(app) + .post('/stacks') + .send({ assetIds: [asset.id] }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require at least two assets', async () => { + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [uuidDto.invalid, uuidDto.invalid] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + + it('should require access', async () => { + const user2Asset = await utils.createAsset(user2.accessToken); + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset.id, user2Asset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should create a stack', async () => { + const [asset1, asset2] = await Promise.all([ + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + ]); + + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset1.id, asset2.id] }); + + expect(status).toBe(201); + expect(body).toEqual({ + id: expect.any(String), + primaryAssetId: asset1.id, + assets: [expect.objectContaining({ id: asset1.id }), expect.objectContaining({ id: asset2.id })], + }); + }); + + it('should merge an existing stack', async () => { + const [asset1, asset2, asset3] = await Promise.all([ + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + ]); + + const response1 = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset1.id, asset2.id] }); + + expect(response1.status).toBe(201); + + const stacksBefore = await searchStacks({}, { headers: asBearerAuth(user1.accessToken) }); + + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset1.id, asset3.id] }); + + expect(status).toBe(201); + expect(body).toEqual({ + id: expect.any(String), + primaryAssetId: asset1.id, + assets: expect.arrayContaining([ + expect.objectContaining({ id: asset1.id }), + expect.objectContaining({ id: asset2.id }), + expect.objectContaining({ id: asset3.id }), + ]), + }); + + const stacksAfter = await searchStacks({}, { headers: asBearerAuth(user1.accessToken) }); + expect(stacksAfter.length).toBe(stacksBefore.length); + }); + + // it('should require a valid parent id', async () => { + // const { status, body } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${user1.accessToken}`) + // .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID'])); + // }); + }); + + // it('should require access to the parent', async () => { + // const { status, body } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${user1.accessToken}`) + // .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorDto.noPermission); + // }); + + // it('should add stack children', async () => { + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).not.toBeUndefined(); + // expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })])); + // }); + + // it('should remove stack children', async () => { + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ removeParent: true, ids: [stackAssets[1].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).not.toBeUndefined(); + // expect(asset.stack).toEqual( + // expect.arrayContaining([ + // expect.objectContaining({ id: stackAssets[2].id }), + // expect.objectContaining({ id: stackAssets[3].id }), + // ]), + // ); + // }); + + // it('should remove all stack children', async () => { + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).toBeUndefined(); + // }); + + // it('should merge stack children', async () => { + // // create stack after previous test removed stack children + // await updateAssets( + // { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, + // { headers: asBearerAuth(stackUser.accessToken) }, + // ); + + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).not.toBeUndefined(); + // expect(asset.stack).toEqual( + // expect.arrayContaining([ + // expect.objectContaining({ id: stackAssets[0].id }), + // expect.objectContaining({ id: stackAssets[1].id }), + // expect.objectContaining({ id: stackAssets[2].id }), + // ]), + // ); + // }); +}); diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index b7147f52cc..8a417387e7 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -1,11 +1,11 @@ import { LoginResponseDto, + createStack, deleteUserAdmin, getMyUser, getUserAdmin, getUserPreferencesAdmin, login, - updateAssets, } from '@immich/sdk'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; @@ -321,8 +321,8 @@ describe('/admin/users', () => { utils.createAsset(user.accessToken), ]); - await updateAssets( - { assetBulkUpdateDto: { stackParentId: asset1.id, ids: [asset2.id] } }, + await createStack( + { stackCreateDto: { assetIds: [asset1.id, asset2.id] } }, { headers: asBearerAuth(user.accessToken) }, ); diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index f9dd86513d..decb0a72e1 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -573,7 +573,5 @@ "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", - "viewer_remove_from_stack": "Remove from Stack", - "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" -} \ No newline at end of file +} diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 3f8c1fa74c..97e10b3d20 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -33,11 +33,13 @@ class Asset { isArchived = remote.isArchived, isTrashed = remote.isTrashed, isOffline = remote.isOffline, - // workaround to nullify stackParentId for the parent asset until we refactor the mobile app + // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app // stack handling to properly handle it - stackParentId = - remote.stackParentId == remote.id ? null : remote.stackParentId, - stackCount = remote.stackCount, + stackPrimaryAssetId = remote.stack?.primaryAssetId == remote.id + ? null + : remote.stack?.primaryAssetId, + stackCount = remote.stack?.assetCount ?? 0, + stackId = remote.stack?.id, thumbhash = remote.thumbhash; Asset.local(AssetEntity local, List hash) @@ -86,7 +88,8 @@ class Asset { this.isFavorite = false, this.isArchived = false, this.isTrashed = false, - this.stackParentId, + this.stackId, + this.stackPrimaryAssetId, this.stackCount = 0, this.isOffline = false, this.thumbhash, @@ -163,12 +166,11 @@ class Asset { @ignore ExifInfo? exifInfo; - String? stackParentId; + String? stackId; - @ignore - int get stackChildrenCount => stackCount ?? 0; + String? stackPrimaryAssetId; - int? stackCount; + int stackCount; /// Aspect ratio of the asset @ignore @@ -231,7 +233,8 @@ class Asset { isArchived == other.isArchived && isTrashed == other.isTrashed && stackCount == other.stackCount && - stackParentId == other.stackParentId; + stackPrimaryAssetId == other.stackPrimaryAssetId && + stackId == other.stackId; } @override @@ -256,7 +259,8 @@ class Asset { isArchived.hashCode ^ isTrashed.hashCode ^ stackCount.hashCode ^ - stackParentId.hashCode; + stackPrimaryAssetId.hashCode ^ + stackId.hashCode; /// Returns `true` if this [Asset] can updated with values from parameter [a] bool canUpdate(Asset a) { @@ -269,7 +273,6 @@ class Asset { width == null && a.width != null || height == null && a.height != null || livePhotoVideoId == null && a.livePhotoVideoId != null || - stackParentId == null && a.stackParentId != null || isFavorite != a.isFavorite || isArchived != a.isArchived || isTrashed != a.isTrashed || @@ -278,10 +281,9 @@ class Asset { a.exifInfo?.longitude != exifInfo?.longitude || // no local stack count or different count from remote a.thumbhash != thumbhash || - ((stackCount == null && a.stackCount != null) || - (stackCount != null && - a.stackCount != null && - stackCount != a.stackCount)); + stackId != a.stackId || + stackCount != a.stackCount || + stackPrimaryAssetId == null && a.stackPrimaryAssetId != null; } /// Returns a new [Asset] with values from this and merged & updated with [a] @@ -311,9 +313,11 @@ class Asset { id: id, remoteId: remoteId, livePhotoVideoId: livePhotoVideoId, - // workaround to nullify stackParentId for the parent asset until we refactor the mobile app + // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app // stack handling to properly handle it - stackParentId: stackParentId == remoteId ? null : stackParentId, + stackId: stackId, + stackPrimaryAssetId: + stackPrimaryAssetId == remoteId ? null : stackPrimaryAssetId, stackCount: stackCount, isFavorite: isFavorite, isArchived: isArchived, @@ -330,9 +334,12 @@ class Asset { width: a.width, height: a.height, livePhotoVideoId: a.livePhotoVideoId, - // workaround to nullify stackParentId for the parent asset until we refactor the mobile app + // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app // stack handling to properly handle it - stackParentId: a.stackParentId == a.remoteId ? null : a.stackParentId, + stackId: a.stackId, + stackPrimaryAssetId: a.stackPrimaryAssetId == a.remoteId + ? null + : a.stackPrimaryAssetId, stackCount: a.stackCount, // isFavorite + isArchived are not set by device-only assets isFavorite: a.isFavorite, @@ -374,7 +381,8 @@ class Asset { bool? isTrashed, bool? isOffline, ExifInfo? exifInfo, - String? stackParentId, + String? stackId, + String? stackPrimaryAssetId, int? stackCount, String? thumbhash, }) => @@ -398,7 +406,8 @@ class Asset { isTrashed: isTrashed ?? this.isTrashed, isOffline: isOffline ?? this.isOffline, exifInfo: exifInfo ?? this.exifInfo, - stackParentId: stackParentId ?? this.stackParentId, + stackId: stackId ?? this.stackId, + stackPrimaryAssetId: stackPrimaryAssetId ?? this.stackPrimaryAssetId, stackCount: stackCount ?? this.stackCount, thumbhash: thumbhash ?? this.thumbhash, ); @@ -445,8 +454,9 @@ class Asset { "checksum": "$checksum", "ownerId": $ownerId, "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}", + "stackId": "${stackId ?? "N/A"}", + "stackPrimaryAssetId": "${stackPrimaryAssetId ?? "N/A"}", "stackCount": "$stackCount", - "stackParentId": "${stackParentId ?? "N/A"}", "fileCreatedAt": "$fileCreatedAt", "fileModifiedAt": "$fileModifiedAt", "updatedAt": "$updatedAt", diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart index 099e15eef1..23bf236046 100644 --- a/mobile/lib/entities/asset.entity.g.dart +++ b/mobile/lib/entities/asset.entity.g.dart @@ -92,29 +92,34 @@ const AssetSchema = CollectionSchema( name: r'stackCount', type: IsarType.long, ), - r'stackParentId': PropertySchema( + r'stackId': PropertySchema( id: 15, - name: r'stackParentId', + name: r'stackId', + type: IsarType.string, + ), + r'stackPrimaryAssetId': PropertySchema( + id: 16, + name: r'stackPrimaryAssetId', type: IsarType.string, ), r'thumbhash': PropertySchema( - id: 16, + id: 17, name: r'thumbhash', type: IsarType.string, ), r'type': PropertySchema( - id: 17, + id: 18, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 18, + id: 19, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 19, + id: 20, name: r'width', type: IsarType.int, ) @@ -205,7 +210,13 @@ int _assetEstimateSize( } } { - final value = object.stackParentId; + final value = object.stackId; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.stackPrimaryAssetId; if (value != null) { bytesCount += 3 + value.length * 3; } @@ -240,11 +251,12 @@ void _assetSerialize( writer.writeLong(offsets[12], object.ownerId); writer.writeString(offsets[13], object.remoteId); writer.writeLong(offsets[14], object.stackCount); - writer.writeString(offsets[15], object.stackParentId); - writer.writeString(offsets[16], object.thumbhash); - writer.writeByte(offsets[17], object.type.index); - writer.writeDateTime(offsets[18], object.updatedAt); - writer.writeInt(offsets[19], object.width); + writer.writeString(offsets[15], object.stackId); + writer.writeString(offsets[16], object.stackPrimaryAssetId); + writer.writeString(offsets[17], object.thumbhash); + writer.writeByte(offsets[18], object.type.index); + writer.writeDateTime(offsets[19], object.updatedAt); + writer.writeInt(offsets[20], object.width); } Asset _assetDeserialize( @@ -269,13 +281,14 @@ Asset _assetDeserialize( localId: reader.readStringOrNull(offsets[11]), ownerId: reader.readLong(offsets[12]), remoteId: reader.readStringOrNull(offsets[13]), - stackCount: reader.readLongOrNull(offsets[14]), - stackParentId: reader.readStringOrNull(offsets[15]), - thumbhash: reader.readStringOrNull(offsets[16]), - type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ?? + stackCount: reader.readLongOrNull(offsets[14]) ?? 0, + stackId: reader.readStringOrNull(offsets[15]), + stackPrimaryAssetId: reader.readStringOrNull(offsets[16]), + thumbhash: reader.readStringOrNull(offsets[17]), + type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[18]), - width: reader.readIntOrNull(offsets[19]), + updatedAt: reader.readDateTime(offsets[19]), + width: reader.readIntOrNull(offsets[20]), ); return object; } @@ -316,17 +329,19 @@ P _assetDeserializeProp

    ( case 13: return (reader.readStringOrNull(offset)) as P; case 14: - return (reader.readLongOrNull(offset)) as P; + return (reader.readLongOrNull(offset) ?? 0) as P; case 15: return (reader.readStringOrNull(offset)) as P; case 16: return (reader.readStringOrNull(offset)) as P; case 17: + return (reader.readStringOrNull(offset)) as P; + case 18: return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? AssetType.other) as P; - case 18: - return (reader.readDateTime(offset)) as P; case 19: + return (reader.readDateTime(offset)) as P; + case 20: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -1859,24 +1874,8 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder stackCountIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'stackCount', - )); - }); - } - - QueryBuilder stackCountIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'stackCount', - )); - }); - } - QueryBuilder stackCountEqualTo( - int? value) { + int value) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.equalTo( property: r'stackCount', @@ -1886,7 +1885,7 @@ extension AssetQueryFilter on QueryBuilder { } QueryBuilder stackCountGreaterThan( - int? value, { + int value, { bool include = false, }) { return QueryBuilder.apply(this, (query) { @@ -1899,7 +1898,7 @@ extension AssetQueryFilter on QueryBuilder { } QueryBuilder stackCountLessThan( - int? value, { + int value, { bool include = false, }) { return QueryBuilder.apply(this, (query) { @@ -1912,8 +1911,8 @@ extension AssetQueryFilter on QueryBuilder { } QueryBuilder stackCountBetween( - int? lower, - int? upper, { + int lower, + int upper, { bool includeLower = true, bool includeUpper = true, }) { @@ -1928,36 +1927,36 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder stackParentIdIsNull() { + QueryBuilder stackIdIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( - property: r'stackParentId', + property: r'stackId', )); }); } - QueryBuilder stackParentIdIsNotNull() { + QueryBuilder stackIdIsNotNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'stackParentId', + property: r'stackId', )); }); } - QueryBuilder stackParentIdEqualTo( + QueryBuilder stackIdEqualTo( String? value, { bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.equalTo( - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdGreaterThan( + QueryBuilder stackIdGreaterThan( String? value, { bool include = false, bool caseSensitive = true, @@ -1965,14 +1964,14 @@ extension AssetQueryFilter on QueryBuilder { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.greaterThan( include: include, - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdLessThan( + QueryBuilder stackIdLessThan( String? value, { bool include = false, bool caseSensitive = true, @@ -1980,14 +1979,14 @@ extension AssetQueryFilter on QueryBuilder { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.lessThan( include: include, - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdBetween( + QueryBuilder stackIdBetween( String? lower, String? upper, { bool includeLower = true, @@ -1996,7 +1995,7 @@ extension AssetQueryFilter on QueryBuilder { }) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.between( - property: r'stackParentId', + property: r'stackId', lower: lower, includeLower: includeLower, upper: upper, @@ -2006,69 +2005,221 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder stackParentIdStartsWith( + QueryBuilder stackIdStartsWith( String value, { bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.startsWith( - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdEndsWith( + QueryBuilder stackIdEndsWith( String value, { bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.endsWith( - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdContains( + QueryBuilder stackIdContains( String value, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.contains( - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdMatches( + QueryBuilder stackIdMatches( String pattern, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.matches( - property: r'stackParentId', + property: r'stackId', wildcard: pattern, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdIsEmpty() { + QueryBuilder stackIdIsEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.equalTo( - property: r'stackParentId', + property: r'stackId', value: '', )); }); } - QueryBuilder stackParentIdIsNotEmpty() { + QueryBuilder stackIdIsNotEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.greaterThan( - property: r'stackParentId', + property: r'stackId', + value: '', + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'stackPrimaryAssetId', + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'stackPrimaryAssetId', + )); + }); + } + + QueryBuilder stackPrimaryAssetIdEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'stackPrimaryAssetId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'stackPrimaryAssetId', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'stackPrimaryAssetId', + value: '', + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'stackPrimaryAssetId', value: '', )); }); @@ -2580,15 +2731,27 @@ extension AssetQuerySortBy on QueryBuilder { }); } - QueryBuilder sortByStackParentId() { + QueryBuilder sortByStackId() { return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'stackParentId', Sort.asc); + return query.addSortBy(r'stackId', Sort.asc); }); } - QueryBuilder sortByStackParentIdDesc() { + QueryBuilder sortByStackIdDesc() { return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'stackParentId', Sort.desc); + return query.addSortBy(r'stackId', Sort.desc); + }); + } + + QueryBuilder sortByStackPrimaryAssetId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackPrimaryAssetId', Sort.asc); + }); + } + + QueryBuilder sortByStackPrimaryAssetIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackPrimaryAssetId', Sort.desc); }); } @@ -2834,15 +2997,27 @@ extension AssetQuerySortThenBy on QueryBuilder { }); } - QueryBuilder thenByStackParentId() { + QueryBuilder thenByStackId() { return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'stackParentId', Sort.asc); + return query.addSortBy(r'stackId', Sort.asc); }); } - QueryBuilder thenByStackParentIdDesc() { + QueryBuilder thenByStackIdDesc() { return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'stackParentId', Sort.desc); + return query.addSortBy(r'stackId', Sort.desc); + }); + } + + QueryBuilder thenByStackPrimaryAssetId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackPrimaryAssetId', Sort.asc); + }); + } + + QueryBuilder thenByStackPrimaryAssetIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackPrimaryAssetId', Sort.desc); }); } @@ -2992,10 +3167,17 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } - QueryBuilder distinctByStackParentId( + QueryBuilder distinctByStackId( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'stackParentId', + return query.addDistinctBy(r'stackId', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByStackPrimaryAssetId( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'stackPrimaryAssetId', caseSensitive: caseSensitive); }); } @@ -3117,15 +3299,21 @@ extension AssetQueryProperty on QueryBuilder { }); } - QueryBuilder stackCountProperty() { + QueryBuilder stackCountProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'stackCount'); }); } - QueryBuilder stackParentIdProperty() { + QueryBuilder stackIdProperty() { return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'stackParentId'); + return query.addPropertyName(r'stackId'); + }); + } + + QueryBuilder stackPrimaryAssetIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'stackPrimaryAssetId'); }); } diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index cc62620dfb..d8ea7cd89b 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -68,7 +68,7 @@ class GalleryViewerPage extends HookConsumerWidget { }); final stackIndex = useState(-1); - final stack = showStack && currentAsset.stackChildrenCount > 0 + final stack = showStack && currentAsset.stackCount > 0 ? ref.watch(assetStackStateProvider(currentAsset)) : []; final stackElements = showStack ? [currentAsset, ...stack] : []; diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index a0a3879db5..3c1a5ecc01 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -360,7 +360,7 @@ QueryBuilder? getRemoteAssetQuery(WidgetRef ref) { .filter() .ownerIdEqualTo(userId) .isTrashedEqualTo(false) - .stackParentIdIsNull() + .stackPrimaryAssetIdIsNull() .sortByFileCreatedAtDesc(); } @@ -374,6 +374,6 @@ QueryBuilder _commonFilterAndSort( .filter() .isArchivedEqualTo(false) .isTrashedEqualTo(false) - .stackParentIdIsNull() + .stackPrimaryAssetIdIsNull() .sortByFileCreatedAtDesc(); } diff --git a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart index 0883ed92db..c3e4414b39 100644 --- a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart @@ -48,7 +48,7 @@ final assetStackProvider = .filter() .isArchivedEqualTo(false) .isTrashedEqualTo(false) - .stackParentIdEqualTo(asset.remoteId) + .stackPrimaryAssetIdEqualTo(asset.remoteId) .sortByFileCreatedAtDesc() .findAll(); }); diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index c128a2c2fc..6ff62d4b3a 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -29,6 +29,7 @@ class ApiService implements Authentication { late ActivitiesApi activitiesApi; late DownloadApi downloadApi; late TrashApi trashApi; + late StacksApi stacksApi; ApiService() { final endpoint = Store.tryGet(StoreKey.serverEndpoint); @@ -61,6 +62,7 @@ class ApiService implements Authentication { activitiesApi = ActivitiesApi(_apiClient); downloadApi = DownloadApi(_apiClient); trashApi = TrashApi(_apiClient); + stacksApi = StacksApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async { diff --git a/mobile/lib/services/asset_stack.service.dart b/mobile/lib/services/asset_stack.service.dart deleted file mode 100644 index 9eff495f37..0000000000 --- a/mobile/lib/services/asset_stack.service.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:openapi/api.dart'; - -class AssetStackService { - AssetStackService(this._api); - - final ApiService _api; - - Future updateStack( - Asset parentAsset, { - List? childrenToAdd, - List? childrenToRemove, - }) async { - // Guard [local asset] - if (parentAsset.remoteId == null) { - return; - } - - try { - if (childrenToAdd != null) { - final toAdd = childrenToAdd - .where((e) => e.isRemote) - .map((e) => e.remoteId!) - .toList(); - - await _api.assetsApi.updateAssets( - AssetBulkUpdateDto(ids: toAdd, stackParentId: parentAsset.remoteId), - ); - } - - if (childrenToRemove != null) { - final toRemove = childrenToRemove - .where((e) => e.isRemote) - .map((e) => e.remoteId!) - .toList(); - await _api.assetsApi.updateAssets( - AssetBulkUpdateDto(ids: toRemove, removeParent: true), - ); - } - } catch (error) { - debugPrint("Error while updating stack children: ${error.toString()}"); - } - } - - Future updateStackParent(Asset oldParent, Asset newParent) async { - // Guard [local asset] - if (oldParent.remoteId == null || newParent.remoteId == null) { - return; - } - - try { - await _api.assetsApi.updateStackParent( - UpdateStackParentDto( - oldParentId: oldParent.remoteId!, - newParentId: newParent.remoteId!, - ), - ); - } catch (error) { - debugPrint("Error while updating stack parent: ${error.toString()}"); - } - } -} - -final assetStackServiceProvider = Provider( - (ref) => AssetStackService( - ref.watch(apiServiceProvider), - ), -); diff --git a/mobile/lib/services/stack.service.dart b/mobile/lib/services/stack.service.dart new file mode 100644 index 0000000000..75074101c2 --- /dev/null +++ b/mobile/lib/services/stack.service.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:isar/isar.dart'; +import 'package:openapi/api.dart'; + +class StackService { + StackService(this._api, this._db); + + final ApiService _api; + final Isar _db; + + Future getStack(String stackId) async { + try { + return _api.stacksApi.getStack(stackId); + } catch (error) { + debugPrint("Error while fetching stack: $error"); + } + return null; + } + + Future createStack(List assetIds) async { + try { + return _api.stacksApi.createStack( + StackCreateDto(assetIds: assetIds), + ); + } catch (error) { + debugPrint("Error while creating stack: $error"); + } + return null; + } + + Future updateStack( + String stackId, + String primaryAssetId, + ) async { + try { + return await _api.stacksApi.updateStack( + stackId, + StackUpdateDto(primaryAssetId: primaryAssetId), + ); + } catch (error) { + debugPrint("Error while updating stack children: $error"); + } + return null; + } + + Future deleteStack(String stackId, List assets) async { + try { + await _api.stacksApi.deleteStack(stackId); + + // Update local database to trigger rerendering + final List removeAssets = []; + for (final asset in assets) { + asset.stackId = null; + asset.stackPrimaryAssetId = null; + asset.stackCount = 0; + + removeAssets.add(asset); + } + + _db.writeTxn(() async { + await _db.assets.putAll(removeAssets); + }); + } catch (error) { + debugPrint("Error while deleting stack: $error"); + } + } +} + +final stackServiceProvider = Provider( + (ref) => StackService( + ref.watch(apiServiceProvider), + ref.watch(dbProvider), + ), +); diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index e50a9a5ece..3263373554 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/services/asset_stack.service.dart'; +import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; @@ -344,11 +344,9 @@ class MultiselectGrid extends HookConsumerWidget { if (!selectionEnabledHook.value || selection.value.length < 2) { return; } - final parent = selection.value.elementAt(0); - selection.value.remove(parent); - await ref.read(assetStackServiceProvider).updateStack( - parent, - childrenToAdd: selection.value.toList(), + + await ref.read(stackServiceProvider).createStack( + selection.value.map((e) => e.remoteId!).toList(), ); } finally { processing.value = false; diff --git a/mobile/lib/widgets/asset_grid/thumbnail_image.dart b/mobile/lib/widgets/asset_grid/thumbnail_image.dart index 2480f44278..8e818f64fb 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_image.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_image.dart @@ -107,16 +107,16 @@ class ThumbnailImage extends ConsumerWidget { right: 8, child: Row( children: [ - if (asset.stackChildrenCount > 1) + if (asset.stackCount > 1) Text( - "${asset.stackChildrenCount}", + "${asset.stackCount}", style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), - if (asset.stackChildrenCount > 1) + if (asset.stackCount > 1) const SizedBox( width: 3, ), @@ -208,7 +208,7 @@ class ThumbnailImage extends ConsumerWidget { ), ), if (!asset.isImage) buildVideoIcon(), - if (asset.stackChildrenCount > 0) buildStackIcon(), + if (asset.stackCount > 0) buildStackIcon(), ], ); } diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index fb70ac309e..7d9e49bd29 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/services/asset_stack.service.dart'; +import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; @@ -49,11 +49,10 @@ class BottomGalleryBar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; - final stack = showStack && asset.stackChildrenCount > 0 + final stackItems = showStack && asset.stackCount > 0 ? ref.watch(assetStackStateProvider(asset)) : []; - final stackElements = showStack ? [asset, ...stack] : []; - bool isParent = stackIndex == -1 || stackIndex == 0; + bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null; final navStack = AutoRouter.of(context).stackData; final isTrashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); @@ -76,7 +75,7 @@ class BottomGalleryBar extends ConsumerWidget { {asset}, force: force, ); - if (isDeleted && isParent) { + if (isDeleted && isStackPrimaryAsset) { // Workaround for asset remaining in the gallery renderList.deleteAsset(asset); @@ -98,7 +97,7 @@ class BottomGalleryBar extends ConsumerWidget { final isDeleted = await onDelete(false); if (isDeleted) { // Can only trash assets stored in server. Local assets are always permanently removed for now - if (context.mounted && asset.isRemote && isParent) { + if (context.mounted && asset.isRemote && isStackPrimaryAsset) { ImmichToast.show( durationInSecond: 1, context: context, @@ -127,6 +126,16 @@ class BottomGalleryBar extends ConsumerWidget { ); } + unStack() async { + if (asset.stackId == null) { + return; + } + + await ref + .read(stackServiceProvider) + .deleteStack(asset.stackId!, [asset, ...stackItems]); + } + void showStackActionItems() { showModalBottomSheet( context: context, @@ -138,74 +147,13 @@ class BottomGalleryBar extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (!isParent) - ListTile( - leading: const Icon( - Icons.bookmark_border_outlined, - size: 24, - ), - onTap: () async { - await ref - .read(assetStackServiceProvider) - .updateStackParent( - asset, - stackElements.elementAt(stackIndex), - ); - ctx.pop(); - context.maybePop(); - }, - title: const Text( - "viewer_stack_use_as_main_asset", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), - ListTile( - leading: const Icon( - Icons.copy_all_outlined, - size: 24, - ), - onTap: () async { - if (isParent) { - await ref - .read(assetStackServiceProvider) - .updateStackParent( - asset, - stackElements - .elementAt(1), // Next asset as parent - ); - // Remove itself from stack - await ref.read(assetStackServiceProvider).updateStack( - stackElements.elementAt(1), - childrenToRemove: [asset], - ); - ctx.pop(); - context.maybePop(); - } else { - await ref.read(assetStackServiceProvider).updateStack( - asset, - childrenToRemove: [ - stackElements.elementAt(stackIndex), - ], - ); - removeAssetFromStack(); - ctx.pop(); - } - }, - title: const Text( - "viewer_remove_from_stack", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), ListTile( leading: const Icon( Icons.filter_none_outlined, size: 18, ), onTap: () async { - await ref.read(assetStackServiceProvider).updateStack( - asset, - childrenToRemove: stack, - ); + await unStack(); ctx.pop(); context.maybePop(); }, @@ -255,7 +203,7 @@ class BottomGalleryBar extends ConsumerWidget { handleArchive() { ref.read(assetProvider.notifier).toggleArchive([asset]); - if (isParent) { + if (isStackPrimaryAsset) { context.maybePop(); return; } @@ -346,7 +294,7 @@ class BottomGalleryBar extends ConsumerWidget { tooltip: 'control_bottom_app_bar_archive'.tr(), ): (_) => handleArchive(), }, - if (isOwner && stack.isNotEmpty) + if (isOwner && asset.stackCount > 0) { BottomNavigationBarItem( icon: const Icon(Icons.burst_mode_outlined), diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 657dad9d5b..f2effe1c20 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -107,7 +107,6 @@ Class | Method | HTTP request | Description *AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | *AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | -*AssetsApi* | [**updateStackParent**](doc//AssetsApi.md#updatestackparent) | **PUT** /assets/stack/parent | *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | *AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes | @@ -205,6 +204,12 @@ Class | Method | HTTP request | Description *SharedLinksApi* | [**removeSharedLink**](doc//SharedLinksApi.md#removesharedlink) | **DELETE** /shared-links/{id} | *SharedLinksApi* | [**removeSharedLinkAssets**](doc//SharedLinksApi.md#removesharedlinkassets) | **DELETE** /shared-links/{id}/assets | *SharedLinksApi* | [**updateSharedLink**](doc//SharedLinksApi.md#updatesharedlink) | **PATCH** /shared-links/{id} | +*StacksApi* | [**createStack**](doc//StacksApi.md#createstack) | **POST** /stacks | +*StacksApi* | [**deleteStack**](doc//StacksApi.md#deletestack) | **DELETE** /stacks/{id} | +*StacksApi* | [**deleteStacks**](doc//StacksApi.md#deletestacks) | **DELETE** /stacks | +*StacksApi* | [**getStack**](doc//StacksApi.md#getstack) | **GET** /stacks/{id} | +*StacksApi* | [**searchStacks**](doc//StacksApi.md#searchstacks) | **GET** /stacks | +*StacksApi* | [**updateStack**](doc//StacksApi.md#updatestack) | **PUT** /stacks/{id} | *SyncApi* | [**getDeltaSync**](doc//SyncApi.md#getdeltasync) | **POST** /sync/delta-sync | *SyncApi* | [**getFullSyncForUser**](doc//SyncApi.md#getfullsyncforuser) | **POST** /sync/full-sync | *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | @@ -289,6 +294,7 @@ Class | Method | HTTP request | Description - [AssetMediaStatus](doc//AssetMediaStatus.md) - [AssetOrder](doc//AssetOrder.md) - [AssetResponseDto](doc//AssetResponseDto.md) + - [AssetStackResponseDto](doc//AssetStackResponseDto.md) - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) - [AssetTypeEnum](doc//AssetTypeEnum.md) - [AudioCodec](doc//AudioCodec.md) @@ -404,6 +410,9 @@ Class | Method | HTTP request | Description - [SignUpDto](doc//SignUpDto.md) - [SmartInfoResponseDto](doc//SmartInfoResponseDto.md) - [SmartSearchDto](doc//SmartSearchDto.md) + - [StackCreateDto](doc//StackCreateDto.md) + - [StackResponseDto](doc//StackResponseDto.md) + - [StackUpdateDto](doc//StackUpdateDto.md) - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigImageDto](doc//SystemConfigImageDto.md) @@ -439,7 +448,6 @@ Class | Method | HTTP request | Description - [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateLibraryDto](doc//UpdateLibraryDto.md) - [UpdatePartnerDto](doc//UpdatePartnerDto.md) - - [UpdateStackParentDto](doc//UpdateStackParentDto.md) - [UpdateTagDto](doc//UpdateTagDto.md) - [UsageByUserDto](doc//UsageByUserDto.md) - [UserAdminCreateDto](doc//UserAdminCreateDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 4d33f1018c..6ee06d5304 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -54,6 +54,7 @@ part 'api/server_api.dart'; part 'api/server_info_api.dart'; part 'api/sessions_api.dart'; part 'api/shared_links_api.dart'; +part 'api/stacks_api.dart'; part 'api/sync_api.dart'; part 'api/system_config_api.dart'; part 'api/system_metadata_api.dart'; @@ -101,6 +102,7 @@ part 'model/asset_media_size.dart'; part 'model/asset_media_status.dart'; part 'model/asset_order.dart'; part 'model/asset_response_dto.dart'; +part 'model/asset_stack_response_dto.dart'; part 'model/asset_stats_response_dto.dart'; part 'model/asset_type_enum.dart'; part 'model/audio_codec.dart'; @@ -216,6 +218,9 @@ part 'model/shared_link_type.dart'; part 'model/sign_up_dto.dart'; part 'model/smart_info_response_dto.dart'; part 'model/smart_search_dto.dart'; +part 'model/stack_create_dto.dart'; +part 'model/stack_response_dto.dart'; +part 'model/stack_update_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_image_dto.dart'; @@ -251,7 +256,6 @@ part 'model/update_album_user_dto.dart'; part 'model/update_asset_dto.dart'; part 'model/update_library_dto.dart'; part 'model/update_partner_dto.dart'; -part 'model/update_stack_parent_dto.dart'; part 'model/update_tag_dto.dart'; part 'model/usage_by_user_dto.dart'; part 'model/user_admin_create_dto.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index d7d386130b..ceba3574cd 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -804,45 +804,6 @@ class AssetsApi { } } - /// Performs an HTTP 'PUT /assets/stack/parent' operation and returns the [Response]. - /// Parameters: - /// - /// * [UpdateStackParentDto] updateStackParentDto (required): - Future updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async { - // ignore: prefer_const_declarations - final path = r'/assets/stack/parent'; - - // ignore: prefer_final_locals - Object? postBody = updateStackParentDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - path, - 'PUT', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [UpdateStackParentDto] updateStackParentDto (required): - Future updateStackParent(UpdateStackParentDto updateStackParentDto,) async { - final response = await updateStackParentWithHttpInfo(updateStackParentDto,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } - /// Performs an HTTP 'POST /assets' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/stacks_api.dart b/mobile/openapi/lib/api/stacks_api.dart new file mode 100644 index 0000000000..aa1d9b3416 --- /dev/null +++ b/mobile/openapi/lib/api/stacks_api.dart @@ -0,0 +1,298 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class StacksApi { + StacksApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'POST /stacks' operation and returns the [Response]. + /// Parameters: + /// + /// * [StackCreateDto] stackCreateDto (required): + Future createStackWithHttpInfo(StackCreateDto stackCreateDto,) async { + // ignore: prefer_const_declarations + final path = r'/stacks'; + + // ignore: prefer_final_locals + Object? postBody = stackCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [StackCreateDto] stackCreateDto (required): + Future createStack(StackCreateDto stackCreateDto,) async { + final response = await createStackWithHttpInfo(stackCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'StackResponseDto',) as StackResponseDto; + + } + return null; + } + + /// Performs an HTTP 'DELETE /stacks/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future deleteStackWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/stacks/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future deleteStack(String id,) async { + final response = await deleteStackWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'DELETE /stacks' operation and returns the [Response]. + /// Parameters: + /// + /// * [BulkIdsDto] bulkIdsDto (required): + Future deleteStacksWithHttpInfo(BulkIdsDto bulkIdsDto,) async { + // ignore: prefer_const_declarations + final path = r'/stacks'; + + // ignore: prefer_final_locals + Object? postBody = bulkIdsDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [BulkIdsDto] bulkIdsDto (required): + Future deleteStacks(BulkIdsDto bulkIdsDto,) async { + final response = await deleteStacksWithHttpInfo(bulkIdsDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'GET /stacks/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future getStackWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/stacks/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future getStack(String id,) async { + final response = await getStackWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'StackResponseDto',) as StackResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /stacks' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] primaryAssetId: + Future searchStacksWithHttpInfo({ String? primaryAssetId, }) async { + // ignore: prefer_const_declarations + final path = r'/stacks'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (primaryAssetId != null) { + queryParams.addAll(_queryParams('', 'primaryAssetId', primaryAssetId)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] primaryAssetId: + Future?> searchStacks({ String? primaryAssetId, }) async { + final response = await searchStacksWithHttpInfo( primaryAssetId: primaryAssetId, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Performs an HTTP 'PUT /stacks/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [StackUpdateDto] stackUpdateDto (required): + Future updateStackWithHttpInfo(String id, StackUpdateDto stackUpdateDto,) async { + // ignore: prefer_const_declarations + final path = r'/stacks/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = stackUpdateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [StackUpdateDto] stackUpdateDto (required): + Future updateStack(String id, StackUpdateDto stackUpdateDto,) async { + final response = await updateStackWithHttpInfo(id, stackUpdateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'StackResponseDto',) as StackResponseDto; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index b5b79be8b1..935324272d 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -259,6 +259,8 @@ class ApiClient { return AssetOrderTypeTransformer().decode(value); case 'AssetResponseDto': return AssetResponseDto.fromJson(value); + case 'AssetStackResponseDto': + return AssetStackResponseDto.fromJson(value); case 'AssetStatsResponseDto': return AssetStatsResponseDto.fromJson(value); case 'AssetTypeEnum': @@ -489,6 +491,12 @@ class ApiClient { return SmartInfoResponseDto.fromJson(value); case 'SmartSearchDto': return SmartSearchDto.fromJson(value); + case 'StackCreateDto': + return StackCreateDto.fromJson(value); + case 'StackResponseDto': + return StackResponseDto.fromJson(value); + case 'StackUpdateDto': + return StackUpdateDto.fromJson(value); case 'SystemConfigDto': return SystemConfigDto.fromJson(value); case 'SystemConfigFFmpegDto': @@ -559,8 +567,6 @@ class ApiClient { return UpdateLibraryDto.fromJson(value); case 'UpdatePartnerDto': return UpdatePartnerDto.fromJson(value); - case 'UpdateStackParentDto': - return UpdateStackParentDto.fromJson(value); case 'UpdateTagDto': return UpdateTagDto.fromJson(value); case 'UsageByUserDto': diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index 452dd2f9a5..c9b21683fb 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -21,8 +21,6 @@ class AssetBulkUpdateDto { this.latitude, this.longitude, this.rating, - this.removeParent, - this.stackParentId, }); /// @@ -79,22 +77,6 @@ class AssetBulkUpdateDto { /// num? rating; - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? removeParent; - - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - String? stackParentId; - @override bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto && other.dateTimeOriginal == dateTimeOriginal && @@ -104,9 +86,7 @@ class AssetBulkUpdateDto { other.isFavorite == isFavorite && other.latitude == latitude && other.longitude == longitude && - other.rating == rating && - other.removeParent == removeParent && - other.stackParentId == stackParentId; + other.rating == rating; @override int get hashCode => @@ -118,12 +98,10 @@ class AssetBulkUpdateDto { (isFavorite == null ? 0 : isFavorite!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) + - (rating == null ? 0 : rating!.hashCode) + - (removeParent == null ? 0 : removeParent!.hashCode) + - (stackParentId == null ? 0 : stackParentId!.hashCode); + (rating == null ? 0 : rating!.hashCode); @override - String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating, removeParent=$removeParent, stackParentId=$stackParentId]'; + String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]'; Map toJson() { final json = {}; @@ -163,16 +141,6 @@ class AssetBulkUpdateDto { } else { // json[r'rating'] = null; } - if (this.removeParent != null) { - json[r'removeParent'] = this.removeParent; - } else { - // json[r'removeParent'] = null; - } - if (this.stackParentId != null) { - json[r'stackParentId'] = this.stackParentId; - } else { - // json[r'stackParentId'] = null; - } return json; } @@ -194,8 +162,6 @@ class AssetBulkUpdateDto { latitude: num.parse('${json[r'latitude']}'), longitude: num.parse('${json[r'longitude']}'), rating: num.parse('${json[r'rating']}'), - removeParent: mapValueOfType(json, r'removeParent'), - stackParentId: mapValueOfType(json, r'stackParentId'), ); } return null; diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 61e33ef4e0..561a42cc85 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -38,9 +38,7 @@ class AssetResponseDto { this.people = const [], required this.resized, this.smartInfo, - this.stack = const [], - required this.stackCount, - this.stackParentId, + this.stack, this.tags = const [], required this.thumbhash, required this.type, @@ -124,11 +122,7 @@ class AssetResponseDto { /// SmartInfoResponseDto? smartInfo; - List stack; - - int? stackCount; - - String? stackParentId; + AssetStackResponseDto? stack; List tags; @@ -167,9 +161,7 @@ class AssetResponseDto { _deepEquality.equals(other.people, people) && other.resized == resized && other.smartInfo == smartInfo && - _deepEquality.equals(other.stack, stack) && - other.stackCount == stackCount && - other.stackParentId == stackParentId && + other.stack == stack && _deepEquality.equals(other.tags, tags) && other.thumbhash == thumbhash && other.type == type && @@ -204,9 +196,7 @@ class AssetResponseDto { (people.hashCode) + (resized.hashCode) + (smartInfo == null ? 0 : smartInfo!.hashCode) + - (stack.hashCode) + - (stackCount == null ? 0 : stackCount!.hashCode) + - (stackParentId == null ? 0 : stackParentId!.hashCode) + + (stack == null ? 0 : stack!.hashCode) + (tags.hashCode) + (thumbhash == null ? 0 : thumbhash!.hashCode) + (type.hashCode) + @@ -214,7 +204,7 @@ class AssetResponseDto { (updatedAt.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]'; + String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -271,16 +261,10 @@ class AssetResponseDto { } else { // json[r'smartInfo'] = null; } + if (this.stack != null) { json[r'stack'] = this.stack; - if (this.stackCount != null) { - json[r'stackCount'] = this.stackCount; } else { - // json[r'stackCount'] = null; - } - if (this.stackParentId != null) { - json[r'stackParentId'] = this.stackParentId; - } else { - // json[r'stackParentId'] = null; + // json[r'stack'] = null; } json[r'tags'] = this.tags; if (this.thumbhash != null) { @@ -327,9 +311,7 @@ class AssetResponseDto { people: PersonWithFacesResponseDto.listFromJson(json[r'people']), resized: mapValueOfType(json, r'resized')!, smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']), - stack: AssetResponseDto.listFromJson(json[r'stack']), - stackCount: mapValueOfType(json, r'stackCount'), - stackParentId: mapValueOfType(json, r'stackParentId'), + stack: AssetStackResponseDto.fromJson(json[r'stack']), tags: TagResponseDto.listFromJson(json[r'tags']), thumbhash: mapValueOfType(json, r'thumbhash'), type: AssetTypeEnum.fromJson(json[r'type'])!, @@ -399,7 +381,6 @@ class AssetResponseDto { 'originalPath', 'ownerId', 'resized', - 'stackCount', 'thumbhash', 'type', 'updatedAt', diff --git a/mobile/openapi/lib/model/asset_stack_response_dto.dart b/mobile/openapi/lib/model/asset_stack_response_dto.dart new file mode 100644 index 0000000000..89d30f7810 --- /dev/null +++ b/mobile/openapi/lib/model/asset_stack_response_dto.dart @@ -0,0 +1,114 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetStackResponseDto { + /// Returns a new [AssetStackResponseDto] instance. + AssetStackResponseDto({ + required this.assetCount, + required this.id, + required this.primaryAssetId, + }); + + int assetCount; + + String id; + + String primaryAssetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetStackResponseDto && + other.assetCount == assetCount && + other.id == id && + other.primaryAssetId == primaryAssetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetCount.hashCode) + + (id.hashCode) + + (primaryAssetId.hashCode); + + @override + String toString() => 'AssetStackResponseDto[assetCount=$assetCount, id=$id, primaryAssetId=$primaryAssetId]'; + + Map toJson() { + final json = {}; + json[r'assetCount'] = this.assetCount; + json[r'id'] = this.id; + json[r'primaryAssetId'] = this.primaryAssetId; + return json; + } + + /// Returns a new [AssetStackResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetStackResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return AssetStackResponseDto( + assetCount: mapValueOfType(json, r'assetCount')!, + id: mapValueOfType(json, r'id')!, + primaryAssetId: mapValueOfType(json, r'primaryAssetId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetStackResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetStackResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetStackResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetStackResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetCount', + 'id', + 'primaryAssetId', + }; +} + diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 30dc89a47c..3a9b61d81c 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -82,6 +82,10 @@ class Permission { static const sharedLinkPeriodRead = Permission._(r'sharedLink.read'); static const sharedLinkPeriodUpdate = Permission._(r'sharedLink.update'); static const sharedLinkPeriodDelete = Permission._(r'sharedLink.delete'); + static const stackPeriodCreate = Permission._(r'stack.create'); + static const stackPeriodRead = Permission._(r'stack.read'); + static const stackPeriodUpdate = Permission._(r'stack.update'); + static const stackPeriodDelete = Permission._(r'stack.delete'); static const systemConfigPeriodRead = Permission._(r'systemConfig.read'); static const systemConfigPeriodUpdate = Permission._(r'systemConfig.update'); static const systemMetadataPeriodRead = Permission._(r'systemMetadata.read'); @@ -156,6 +160,10 @@ class Permission { sharedLinkPeriodRead, sharedLinkPeriodUpdate, sharedLinkPeriodDelete, + stackPeriodCreate, + stackPeriodRead, + stackPeriodUpdate, + stackPeriodDelete, systemConfigPeriodRead, systemConfigPeriodUpdate, systemMetadataPeriodRead, @@ -265,6 +273,10 @@ class PermissionTypeTransformer { case r'sharedLink.read': return Permission.sharedLinkPeriodRead; case r'sharedLink.update': return Permission.sharedLinkPeriodUpdate; case r'sharedLink.delete': return Permission.sharedLinkPeriodDelete; + case r'stack.create': return Permission.stackPeriodCreate; + case r'stack.read': return Permission.stackPeriodRead; + case r'stack.update': return Permission.stackPeriodUpdate; + case r'stack.delete': return Permission.stackPeriodDelete; case r'systemConfig.read': return Permission.systemConfigPeriodRead; case r'systemConfig.update': return Permission.systemConfigPeriodUpdate; case r'systemMetadata.read': return Permission.systemMetadataPeriodRead; diff --git a/mobile/openapi/lib/model/stack_create_dto.dart b/mobile/openapi/lib/model/stack_create_dto.dart new file mode 100644 index 0000000000..9b37bc6e2e --- /dev/null +++ b/mobile/openapi/lib/model/stack_create_dto.dart @@ -0,0 +1,101 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class StackCreateDto { + /// Returns a new [StackCreateDto] instance. + StackCreateDto({ + this.assetIds = const [], + }); + + /// first asset becomes the primary + List assetIds; + + @override + bool operator ==(Object other) => identical(this, other) || other is StackCreateDto && + _deepEquality.equals(other.assetIds, assetIds); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetIds.hashCode); + + @override + String toString() => 'StackCreateDto[assetIds=$assetIds]'; + + Map toJson() { + final json = {}; + json[r'assetIds'] = this.assetIds; + return json; + } + + /// Returns a new [StackCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static StackCreateDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return StackCreateDto( + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = StackCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = StackCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of StackCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = StackCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetIds', + }; +} + diff --git a/mobile/openapi/lib/model/stack_response_dto.dart b/mobile/openapi/lib/model/stack_response_dto.dart new file mode 100644 index 0000000000..3d0aaf91d1 --- /dev/null +++ b/mobile/openapi/lib/model/stack_response_dto.dart @@ -0,0 +1,114 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class StackResponseDto { + /// Returns a new [StackResponseDto] instance. + StackResponseDto({ + this.assets = const [], + required this.id, + required this.primaryAssetId, + }); + + List assets; + + String id; + + String primaryAssetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is StackResponseDto && + _deepEquality.equals(other.assets, assets) && + other.id == id && + other.primaryAssetId == primaryAssetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assets.hashCode) + + (id.hashCode) + + (primaryAssetId.hashCode); + + @override + String toString() => 'StackResponseDto[assets=$assets, id=$id, primaryAssetId=$primaryAssetId]'; + + Map toJson() { + final json = {}; + json[r'assets'] = this.assets; + json[r'id'] = this.id; + json[r'primaryAssetId'] = this.primaryAssetId; + return json; + } + + /// Returns a new [StackResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static StackResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return StackResponseDto( + assets: AssetResponseDto.listFromJson(json[r'assets']), + id: mapValueOfType(json, r'id')!, + primaryAssetId: mapValueOfType(json, r'primaryAssetId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = StackResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = StackResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of StackResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = StackResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assets', + 'id', + 'primaryAssetId', + }; +} + diff --git a/mobile/openapi/lib/model/stack_update_dto.dart b/mobile/openapi/lib/model/stack_update_dto.dart new file mode 100644 index 0000000000..0e97127210 --- /dev/null +++ b/mobile/openapi/lib/model/stack_update_dto.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class StackUpdateDto { + /// Returns a new [StackUpdateDto] instance. + StackUpdateDto({ + this.primaryAssetId, + }); + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? primaryAssetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is StackUpdateDto && + other.primaryAssetId == primaryAssetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (primaryAssetId == null ? 0 : primaryAssetId!.hashCode); + + @override + String toString() => 'StackUpdateDto[primaryAssetId=$primaryAssetId]'; + + Map toJson() { + final json = {}; + if (this.primaryAssetId != null) { + json[r'primaryAssetId'] = this.primaryAssetId; + } else { + // json[r'primaryAssetId'] = null; + } + return json; + } + + /// Returns a new [StackUpdateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static StackUpdateDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return StackUpdateDto( + primaryAssetId: mapValueOfType(json, r'primaryAssetId'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = StackUpdateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = StackUpdateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of StackUpdateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = StackUpdateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/update_stack_parent_dto.dart b/mobile/openapi/lib/model/update_stack_parent_dto.dart deleted file mode 100644 index 4247c2e29f..0000000000 --- a/mobile/openapi/lib/model/update_stack_parent_dto.dart +++ /dev/null @@ -1,106 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class UpdateStackParentDto { - /// Returns a new [UpdateStackParentDto] instance. - UpdateStackParentDto({ - required this.newParentId, - required this.oldParentId, - }); - - String newParentId; - - String oldParentId; - - @override - bool operator ==(Object other) => identical(this, other) || other is UpdateStackParentDto && - other.newParentId == newParentId && - other.oldParentId == oldParentId; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (newParentId.hashCode) + - (oldParentId.hashCode); - - @override - String toString() => 'UpdateStackParentDto[newParentId=$newParentId, oldParentId=$oldParentId]'; - - Map toJson() { - final json = {}; - json[r'newParentId'] = this.newParentId; - json[r'oldParentId'] = this.oldParentId; - return json; - } - - /// Returns a new [UpdateStackParentDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static UpdateStackParentDto? fromJson(dynamic value) { - if (value is Map) { - final json = value.cast(); - - return UpdateStackParentDto( - newParentId: mapValueOfType(json, r'newParentId')!, - oldParentId: mapValueOfType(json, r'oldParentId')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = UpdateStackParentDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = UpdateStackParentDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of UpdateStackParentDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = UpdateStackParentDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'newParentId', - 'oldParentId', - }; -} - diff --git a/mobile/test/fixtures/asset.stub.dart b/mobile/test/fixtures/asset.stub.dart index b173dd2ac5..26108d63b2 100644 --- a/mobile/test/fixtures/asset.stub.dart +++ b/mobile/test/fixtures/asset.stub.dart @@ -17,7 +17,6 @@ final class AssetStub { isFavorite: true, isArchived: false, isTrashed: false, - stackCount: 0, ); static final image2 = Asset( @@ -34,6 +33,5 @@ final class AssetStub { isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, ); } diff --git a/mobile/test/modules/extensions/asset_extensions_test.dart b/mobile/test/modules/extensions/asset_extensions_test.dart index b90879acc7..d2b9b93d62 100644 --- a/mobile/test/modules/extensions/asset_extensions_test.dart +++ b/mobile/test/modules/extensions/asset_extensions_test.dart @@ -34,7 +34,6 @@ Asset makeAsset({ isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, exifInfo: exifInfo, ); } diff --git a/mobile/test/modules/home/asset_grid_data_structure_test.dart b/mobile/test/modules/home/asset_grid_data_structure_test.dart index f12b9b2190..b4ee851969 100644 --- a/mobile/test/modules/home/asset_grid_data_structure_test.dart +++ b/mobile/test/modules/home/asset_grid_data_structure_test.dart @@ -25,7 +25,6 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, ), ); } diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 24f0c443ba..07437289be 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -32,7 +32,6 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0d0793c263..a9b08fc400 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1689,41 +1689,6 @@ ] } }, - "/assets/stack/parent": { - "put": { - "operationId": "updateStackParent", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateStackParentDto" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Assets" - ] - } - }, "/assets/statistics": { "get": { "operationId": "getAssetStatistics", @@ -5655,6 +5620,248 @@ ] } }, + "/stacks": { + "delete": { + "operationId": "deleteStacks", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkIdsDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + }, + "get": { + "operationId": "searchStacks", + "parameters": [ + { + "name": "primaryAssetId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/StackResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + }, + "post": { + "operationId": "createStack", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + } + }, + "/stacks/{id}": { + "delete": { + "operationId": "deleteStack", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + }, + "get": { + "operationId": "getStack", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + }, + "put": { + "operationId": "updateStack", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + } + }, "/sync/delta-sync": { "post": { "operationId": "getDeltaSync", @@ -7570,13 +7777,6 @@ "maximum": 5, "minimum": 0, "type": "number" - }, - "removeParent": { - "type": "boolean" - }, - "stackParentId": { - "format": "uuid", - "type": "string" } }, "required": [ @@ -8117,18 +8317,12 @@ "$ref": "#/components/schemas/SmartInfoResponseDto" }, "stack": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" - }, - "stackCount": { - "nullable": true, - "type": "integer" - }, - "stackParentId": { - "nullable": true, - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/AssetStackResponseDto" + } + ], + "nullable": true }, "tags": { "items": { @@ -8172,13 +8366,31 @@ "originalPath", "ownerId", "resized", - "stackCount", "thumbhash", "type", "updatedAt" ], "type": "object" }, + "AssetStackResponseDto": { + "properties": { + "assetCount": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "primaryAssetId": { + "type": "string" + } + }, + "required": [ + "assetCount", + "id", + "primaryAssetId" + ], + "type": "object" + }, "AssetStatsResponseDto": { "properties": { "images": { @@ -9806,6 +10018,10 @@ "sharedLink.read", "sharedLink.update", "sharedLink.delete", + "stack.create", + "stack.read", + "stack.update", + "stack.delete", "systemConfig.read", "systemConfig.update", "systemMetadata.read", @@ -10882,6 +11098,53 @@ ], "type": "object" }, + "StackCreateDto": { + "properties": { + "assetIds": { + "description": "first asset becomes the primary", + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "assetIds" + ], + "type": "object" + }, + "StackResponseDto": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "primaryAssetId": { + "type": "string" + } + }, + "required": [ + "assets", + "id", + "primaryAssetId" + ], + "type": "object" + }, + "StackUpdateDto": { + "properties": { + "primaryAssetId": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + }, "SystemConfigDto": { "properties": { "ffmpeg": { @@ -11735,23 +11998,6 @@ ], "type": "object" }, - "UpdateStackParentDto": { - "properties": { - "newParentId": { - "format": "uuid", - "type": "string" - }, - "oldParentId": { - "format": "uuid", - "type": "string" - } - }, - "required": [ - "newParentId", - "oldParentId" - ], - "type": "object" - }, "UpdateTagDto": { "properties": { "name": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 89e0360368..8b503821f7 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -192,6 +192,11 @@ export type SmartInfoResponseDto = { objects?: string[] | null; tags?: string[] | null; }; +export type AssetStackResponseDto = { + assetCount: number; + id: string; + primaryAssetId: string; +}; export type TagResponseDto = { id: string; name: string; @@ -226,9 +231,7 @@ export type AssetResponseDto = { people?: PersonWithFacesResponseDto[]; resized: boolean; smartInfo?: SmartInfoResponseDto; - stack?: AssetResponseDto[]; - stackCount: number | null; - stackParentId?: string | null; + stack?: (AssetStackResponseDto) | null; tags?: TagResponseDto[]; thumbhash: string | null; "type": AssetTypeEnum; @@ -344,8 +347,6 @@ export type AssetBulkUpdateDto = { latitude?: number; longitude?: number; rating?: number; - removeParent?: boolean; - stackParentId?: string; }; export type AssetBulkUploadCheckItem = { /** base64 or hex encoded sha1 hash */ @@ -379,10 +380,6 @@ export type MemoryLaneResponseDto = { assets: AssetResponseDto[]; yearsAgo: number; }; -export type UpdateStackParentDto = { - newParentId: string; - oldParentId: string; -}; export type AssetStatsResponseDto = { images: number; total: number; @@ -973,6 +970,18 @@ export type AssetIdsResponseDto = { error?: Error2; success: boolean; }; +export type StackResponseDto = { + assets: AssetResponseDto[]; + id: string; + primaryAssetId: string; +}; +export type StackCreateDto = { + /** first asset becomes the primary */ + assetIds: string[]; +}; +export type StackUpdateDto = { + primaryAssetId?: string; +}; export type AssetDeltaSyncDto = { updatedAfter: string; userIds: string[]; @@ -1632,15 +1641,6 @@ export function getRandom({ count }: { ...opts })); } -export function updateStackParent({ updateStackParentDto }: { - updateStackParentDto: UpdateStackParentDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/assets/stack/parent", oazapfts.json({ - ...opts, - method: "PUT", - body: updateStackParentDto - }))); -} export function getAssetStatistics({ isArchived, isFavorite, isTrashed }: { isArchived?: boolean; isFavorite?: boolean; @@ -2706,6 +2706,70 @@ export function addSharedLinkAssets({ id, key, assetIdsDto }: { body: assetIdsDto }))); } +export function deleteStacks({ bulkIdsDto }: { + bulkIdsDto: BulkIdsDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/stacks", oazapfts.json({ + ...opts, + method: "DELETE", + body: bulkIdsDto + }))); +} +export function searchStacks({ primaryAssetId }: { + primaryAssetId?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: StackResponseDto[]; + }>(`/stacks${QS.query(QS.explode({ + primaryAssetId + }))}`, { + ...opts + })); +} +export function createStack({ stackCreateDto }: { + stackCreateDto: StackCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: StackResponseDto; + }>("/stacks", oazapfts.json({ + ...opts, + method: "POST", + body: stackCreateDto + }))); +} +export function deleteStack({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/stacks/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +export function getStack({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: StackResponseDto; + }>(`/stacks/${encodeURIComponent(id)}`, { + ...opts + })); +} +export function updateStack({ id, stackUpdateDto }: { + id: string; + stackUpdateDto: StackUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: StackResponseDto; + }>(`/stacks/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: stackUpdateDto + }))); +} export function getDeltaSync({ assetDeltaSyncDto }: { assetDeltaSyncDto: AssetDeltaSyncDto; }, opts?: Oazapfts.RequestOpts) { @@ -3187,6 +3251,10 @@ export enum Permission { SharedLinkRead = "sharedLink.read", SharedLinkUpdate = "sharedLink.update", SharedLinkDelete = "sharedLink.delete", + StackCreate = "stack.create", + StackRead = "stack.read", + StackUpdate = "stack.update", + StackDelete = "stack.delete", SystemConfigRead = "systemConfig.read", SystemConfigUpdate = "systemConfig.update", SystemMetadataRead = "systemMetadata.read", diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 8c70bed166..f275aa7242 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -13,7 +13,6 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; -import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Route } from 'src/middleware/file-upload.interceptor'; import { AssetService } from 'src/services/asset.service'; @@ -72,13 +71,6 @@ export class AssetController { return this.service.deleteAll(auth, dto); } - @Put('stack/parent') - @HttpCode(HttpStatus.OK) - @Authenticated() - updateStackParent(@Auth() auth: AuthDto, @Body() dto: UpdateStackParentDto): Promise { - return this.service.updateStackParent(auth, dto); - } - @Get(':id') @Authenticated({ sharedLink: true }) getAssetInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 9675cf6d3b..3a832c1a1b 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -23,6 +23,7 @@ import { ServerInfoController } from 'src/controllers/server-info.controller'; import { ServerController } from 'src/controllers/server.controller'; import { SessionController } from 'src/controllers/session.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller'; +import { StackController } from 'src/controllers/stack.controller'; import { SyncController } from 'src/controllers/sync.controller'; import { SystemConfigController } from 'src/controllers/system-config.controller'; import { SystemMetadataController } from 'src/controllers/system-metadata.controller'; @@ -58,6 +59,7 @@ export const controllers = [ ServerInfoController, SessionController, SharedLinkController, + StackController, SyncController, SystemConfigController, SystemMetadataController, diff --git a/server/src/controllers/stack.controller.ts b/server/src/controllers/stack.controller.ts new file mode 100644 index 0000000000..184fa96b38 --- /dev/null +++ b/server/src/controllers/stack.controller.ts @@ -0,0 +1,57 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto } from 'src/dtos/stack.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { StackService } from 'src/services/stack.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Stacks') +@Controller('stacks') +export class StackController { + constructor(private service: StackService) {} + + @Get() + @Authenticated({ permission: Permission.STACK_READ }) + searchStacks(@Auth() auth: AuthDto, @Query() query: StackSearchDto): Promise { + return this.service.search(auth, query); + } + + @Post() + @Authenticated({ permission: Permission.STACK_CREATE }) + createStack(@Auth() auth: AuthDto, @Body() dto: StackCreateDto): Promise { + return this.service.create(auth, dto); + } + + @Delete() + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated({ permission: Permission.STACK_DELETE }) + deleteStacks(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { + return this.service.deleteAll(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.STACK_READ }) + getStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + @Authenticated({ permission: Permission.STACK_UPDATE }) + updateStack( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: StackUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated({ permission: Permission.STACK_DELETE }) + deleteStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } +} diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts index b8ba88b59d..f0050b3947 100644 --- a/server/src/cores/access.core.ts +++ b/server/src/cores/access.core.ts @@ -292,6 +292,18 @@ export class AccessCore { return await this.repository.partner.checkUpdateAccess(auth.user.id, ids); } + case Permission.STACK_READ: { + return this.repository.stack.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.STACK_UPDATE: { + return this.repository.stack.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.STACK_DELETE: { + return this.repository.stack.checkOwnerAccess(auth.user.id, ids); + } + default: { return new Set(); } diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 4238fd3490..6ed1125253 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -52,13 +52,19 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { unassignedFaces?: AssetFaceWithoutPersonResponseDto[]; /**base64 encoded sha1 hash */ checksum!: string; - stackParentId?: string | null; - stack?: AssetResponseDto[]; - @ApiProperty({ type: 'integer' }) - stackCount!: number | null; + stack?: AssetStackResponseDto | null; duplicateId?: string | null; } +export class AssetStackResponseDto { + id!: string; + + primaryAssetId!: string; + + @ApiProperty({ type: 'integer' }) + assetCount!: number; +} + export type AssetMapOptions = { stripMetadata?: boolean; withStack?: boolean; @@ -83,6 +89,18 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] return result; }; +const mapStack = (entity: AssetEntity) => { + if (!entity.stack) { + return null; + } + + return { + id: entity.stack.id, + primaryAssetId: entity.stack.primaryAssetId, + assetCount: entity.stack.assetCount ?? entity.stack.assets.length, + }; +}; + export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; @@ -129,13 +147,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As people: peopleWithFaces(entity.faces), unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), checksum: entity.checksum.toString('base64'), - stackParentId: withStack ? entity.stack?.primaryAssetId : undefined, - stack: withStack - ? entity.stack?.assets - ?.filter((a) => a.id !== entity.stack?.primaryAssetId) - ?.map((a) => mapAsset(a, { stripMetadata, auth: options.auth })) - : undefined, - stackCount: entity.stack?.assetCount ?? entity.stack?.assets?.length ?? null, + stack: withStack ? mapStack(entity) : undefined, isOffline: entity.isOffline, hasMetadata: true, duplicateId: entity.duplicateId, diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 9bc007543a..5a2fdb5120 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -60,12 +60,6 @@ export class AssetBulkUpdateDto extends UpdateAssetBase { @ValidateUUID({ each: true }) ids!: string[]; - @ValidateUUID({ optional: true }) - stackParentId?: string; - - @ValidateBoolean({ optional: true }) - removeParent?: boolean; - @Optional() duplicateId?: string | null; } diff --git a/server/src/dtos/stack.dto.ts b/server/src/dtos/stack.dto.ts index 3ff04ee5ed..3b867b02fe 100644 --- a/server/src/dtos/stack.dto.ts +++ b/server/src/dtos/stack.dto.ts @@ -1,9 +1,38 @@ +import { ArrayMinSize } from 'class-validator'; +import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { StackEntity } from 'src/entities/stack.entity'; import { ValidateUUID } from 'src/validation'; -export class UpdateStackParentDto { - @ValidateUUID() - oldParentId!: string; - - @ValidateUUID() - newParentId!: string; +export class StackCreateDto { + /** first asset becomes the primary */ + @ValidateUUID({ each: true }) + @ArrayMinSize(2) + assetIds!: string[]; } + +export class StackSearchDto { + primaryAssetId?: string; +} + +export class StackUpdateDto { + @ValidateUUID({ optional: true }) + primaryAssetId?: string; +} + +export class StackResponseDto { + id!: string; + primaryAssetId!: string; + assets!: AssetResponseDto[]; +} + +export const mapStack = (stack: StackEntity, { auth }: { auth?: AuthDto }) => { + const primary = stack.assets.filter((asset) => asset.id === stack.primaryAssetId); + const others = stack.assets.filter((asset) => asset.id !== stack.primaryAssetId); + + return { + id: stack.id, + primaryAssetId: stack.primaryAssetId, + assets: [...primary, ...others].map((asset) => mapAsset(asset, { auth })), + }; +}; diff --git a/server/src/enum.ts b/server/src/enum.ts index da4b2d76fc..4a81d54218 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -107,6 +107,11 @@ export enum Permission { SHARED_LINK_UPDATE = 'sharedLink.update', SHARED_LINK_DELETE = 'sharedLink.delete', + STACK_CREATE = 'stack.create', + STACK_READ = 'stack.read', + STACK_UPDATE = 'stack.update', + STACK_DELETE = 'stack.delete', + SYSTEM_CONFIG_READ = 'systemConfig.read', SYSTEM_CONFIG_UPDATE = 'systemConfig.update', diff --git a/server/src/interfaces/access.interface.ts b/server/src/interfaces/access.interface.ts index cf5ebbd005..2dcf9d6b94 100644 --- a/server/src/interfaces/access.interface.ts +++ b/server/src/interfaces/access.interface.ts @@ -42,4 +42,8 @@ export interface IAccessRepository { partner: { checkUpdateAccess(userId: string, partnerIds: Set): Promise>; }; + + stack: { + checkOwnerAccess(userId: string, stackIds: Set): Promise>; + }; } diff --git a/server/src/interfaces/stack.interface.ts b/server/src/interfaces/stack.interface.ts index 0e6baf0a34..378f63fd95 100644 --- a/server/src/interfaces/stack.interface.ts +++ b/server/src/interfaces/stack.interface.ts @@ -2,9 +2,16 @@ import { StackEntity } from 'src/entities/stack.entity'; export const IStackRepository = 'IStackRepository'; +export interface StackSearch { + ownerId: string; + primaryAssetId?: string; +} + export interface IStackRepository { - create(stack: Partial & { ownerId: string }): Promise; + search(query: StackSearch): Promise; + create(stack: { ownerId: string; assetIds: string[] }): Promise; update(stack: Pick & Partial): Promise; delete(id: string): Promise; + deleteAll(ids: string[]): Promise; getById(id: string): Promise; } diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index ffe4b6413f..48a93f546b 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -248,6 +248,17 @@ WHERE "partner"."sharedById" IN ($1) AND "partner"."sharedWithId" = $2 +-- AccessRepository.stack.checkOwnerAccess +SELECT + "StackEntity"."id" AS "StackEntity_id" +FROM + "asset_stack" "StackEntity" +WHERE + ( + ("StackEntity"."id" IN ($1)) + AND ("StackEntity"."ownerId" = $2) + ) + -- AccessRepository.timeline.checkPartnerAccess SELECT "partner"."sharedById" AS "partner_sharedById", diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 438424ab78..6dd6d47a46 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -11,6 +11,7 @@ import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { StackEntity } from 'src/entities/stack.entity'; import { AlbumUserRole } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { Instrumentation } from 'src/utils/instrumentation'; @@ -20,10 +21,11 @@ type IActivityAccess = IAccessRepository['activity']; type IAlbumAccess = IAccessRepository['album']; type IAssetAccess = IAccessRepository['asset']; type IAuthDeviceAccess = IAccessRepository['authDevice']; -type ITimelineAccess = IAccessRepository['timeline']; type IMemoryAccess = IAccessRepository['memory']; type IPersonAccess = IAccessRepository['person']; type IPartnerAccess = IAccessRepository['partner']; +type IStackAccess = IAccessRepository['stack']; +type ITimelineAccess = IAccessRepository['timeline']; @Instrumentation() @Injectable() @@ -313,6 +315,28 @@ class AuthDeviceAccess implements IAuthDeviceAccess { } } +class StackAccess implements IStackAccess { + constructor(private stackRepository: Repository) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, stackIds: Set): Promise> { + if (stackIds.size === 0) { + return new Set(); + } + + return this.stackRepository + .find({ + select: { id: true }, + where: { + id: In([...stackIds]), + ownerId: userId, + }, + }) + .then((stacks) => new Set(stacks.map((stack) => stack.id))); + } +} + class TimelineAccess implements ITimelineAccess { constructor(private partnerRepository: Repository) {} @@ -428,6 +452,7 @@ export class AccessRepository implements IAccessRepository { memory: IMemoryAccess; person: IPersonAccess; partner: IPartnerAccess; + stack: IStackAccess; timeline: ITimelineAccess; constructor( @@ -441,6 +466,7 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(AssetFaceEntity) assetFaceRepository: Repository, @InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository, @InjectRepository(SessionEntity) sessionRepository: Repository, + @InjectRepository(StackEntity) stackRepository: Repository, ) { this.activity = new ActivityAccess(activityRepository, albumRepository); this.album = new AlbumAccess(albumRepository, sharedLinkRepository); @@ -449,6 +475,7 @@ export class AccessRepository implements IAccessRepository { this.memory = new MemoryAccess(memoryRepository); this.person = new PersonAccess(assetFaceRepository, personRepository); this.partner = new PartnerAccess(partnerRepository); + this.stack = new StackAccess(stackRepository); this.timeline = new TimelineAccess(partnerRepository); } } diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index 46cc14e713..f23a1c9a9c 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -1,21 +1,120 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { AssetEntity } from 'src/entities/asset.entity'; import { StackEntity } from 'src/entities/stack.entity'; -import { IStackRepository } from 'src/interfaces/stack.interface'; +import { IStackRepository, StackSearch } from 'src/interfaces/stack.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Repository } from 'typeorm'; +import { DataSource, In, Repository } from 'typeorm'; @Instrumentation() @Injectable() export class StackRepository implements IStackRepository { - constructor(@InjectRepository(StackEntity) private repository: Repository) {} + constructor( + @InjectDataSource() private dataSource: DataSource, + @InjectRepository(StackEntity) private repository: Repository, + ) {} - create(entity: Partial) { - return this.save(entity); + search(query: StackSearch): Promise { + return this.repository.find({ + where: { + ownerId: query.ownerId, + primaryAssetId: query.primaryAssetId, + }, + relations: { + assets: { + exifInfo: true, + }, + }, + }); + } + + async create(entity: { ownerId: string; assetIds: string[] }): Promise { + return this.dataSource.manager.transaction(async (manager) => { + const stackRepository = manager.getRepository(StackEntity); + + const stacks = await stackRepository.find({ + where: { + ownerId: entity.ownerId, + primaryAssetId: In(entity.assetIds), + }, + select: { + id: true, + assets: { + id: true, + }, + }, + relations: { + assets: { + exifInfo: true, + }, + }, + }); + + const assetIds = new Set(entity.assetIds); + + // children + for (const stack of stacks) { + for (const asset of stack.assets) { + assetIds.add(asset.id); + } + } + + if (stacks.length > 0) { + await stackRepository.delete({ id: In(stacks.map((stack) => stack.id)) }); + } + + const { id } = await stackRepository.save({ + ownerId: entity.ownerId, + primaryAssetId: entity.assetIds[0], + assets: [...assetIds].map((id) => ({ id }) as AssetEntity), + }); + + return stackRepository.findOneOrFail({ + where: { + id, + }, + relations: { + assets: { + exifInfo: true, + }, + }, + }); + }); } async delete(id: string): Promise { + const stack = await this.getById(id); + if (!stack) { + return; + } + + const assetIds = stack.assets.map(({ id }) => id); + await this.repository.delete(id); + + // Update assets updatedAt + await this.dataSource.manager.update(AssetEntity, assetIds, { + updatedAt: new Date(), + }); + } + + async deleteAll(ids: string[]): Promise { + const assetIds = []; + for (const id of ids) { + const stack = await this.getById(id); + if (!stack) { + continue; + } + + assetIds.push(...stack.assets.map(({ id }) => id)); + } + + await this.repository.delete(ids); + + // Update assets updatedAt + await this.dataSource.manager.update(AssetEntity, assetIds, { + updatedAt: new Date(), + }); } update(entity: Partial) { @@ -28,8 +127,14 @@ export class StackRepository implements IStackRepository { id, }, relations: { - primaryAsset: true, - assets: true, + assets: { + exifInfo: true, + }, + }, + order: { + assets: { + fileCreatedAt: 'ASC', + }, }, }); } @@ -41,8 +146,14 @@ export class StackRepository implements IStackRepository { id, }, relations: { - primaryAsset: true, - assets: true, + assets: { + exifInfo: true, + }, + }, + order: { + assets: { + fileCreatedAt: 'ASC', + }, }, }); } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 95a80ab4da..f79b2819ff 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -4,7 +4,7 @@ import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetType } from 'src/enum'; import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; @@ -12,7 +12,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AssetService } from 'src/services/asset.service'; -import { assetStub, stackStub } from 'test/fixtures/asset.stub'; +import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; @@ -253,134 +253,6 @@ describe(AssetService.name, () => { await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); }); - - /// Stack related - - it('should require asset update access for parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - await expect( - sut.updateAll(authStub.user1, { - ids: ['asset-1'], - stackParentId: 'parent', - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should update parent asset updatedAt when children are added', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['parent'])); - mockGetById([{ ...assetStub.image, id: 'parent' }]); - await sut.updateAll(authStub.user1, { - ids: [], - stackParentId: 'parent', - }); - expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { updatedAt: expect.any(Date) }); - }); - - it('should update parent asset when children are removed', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1'])); - assetMock.getByIds.mockResolvedValue([ - { - id: 'child-1', - stackId: 'stack-1', - stack: stackStub('stack-1', [{ id: 'parent' } as AssetEntity, { id: 'child-1' } as AssetEntity]), - } as AssetEntity, - ]); - stackMock.getById.mockResolvedValue(stackStub('stack-1', [{ id: 'parent' } as AssetEntity])); - - await sut.updateAll(authStub.user1, { - ids: ['child-1'], - removeParent: true, - }); - expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['child-1']), { stack: null }); - expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { - updatedAt: expect.any(Date), - }); - expect(stackMock.delete).toHaveBeenCalledWith('stack-1'); - }); - - it('update parentId for new children', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1', 'child-2'])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); - const stack = stackStub('stack-1', [ - { id: 'parent' } as AssetEntity, - { id: 'child-1' } as AssetEntity, - { id: 'child-2' } as AssetEntity, - ]); - assetMock.getById.mockResolvedValue({ - id: 'child-1', - stack, - } as AssetEntity); - - await sut.updateAll(authStub.user1, { - stackParentId: 'parent', - ids: ['child-1', 'child-2'], - }); - - expect(stackMock.update).toHaveBeenCalledWith({ - ...stackStub('stack-1', [ - { id: 'child-1' } as AssetEntity, - { id: 'child-2' } as AssetEntity, - { id: 'parent' } as AssetEntity, - ]), - primaryAsset: undefined, - }); - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2', 'parent'], { updatedAt: expect.any(Date) }); - }); - - it('remove stack for removed children', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1', 'child-2'])); - await sut.updateAll(authStub.user1, { - removeParent: true, - ids: ['child-1', 'child-2'], - }); - - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stack: null }); - }); - - it('merge stacks if new child has children', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); - assetMock.getById.mockResolvedValue({ ...assetStub.image, id: 'parent' }); - assetMock.getByIds.mockResolvedValue([ - { - id: 'child-1', - stackId: 'stack-1', - stack: stackStub('stack-1', [{ id: 'child-1' } as AssetEntity, { id: 'child-2' } as AssetEntity]), - } as AssetEntity, - ]); - stackMock.getById.mockResolvedValue(stackStub('stack-1', [{ id: 'parent' } as AssetEntity])); - - await sut.updateAll(authStub.user1, { - ids: ['child-1'], - stackParentId: 'parent', - }); - - expect(stackMock.delete).toHaveBeenCalledWith('stack-1'); - expect(stackMock.create).toHaveBeenCalledWith({ - assets: [{ id: 'child-1' }, { id: 'parent' }, { id: 'child-1' }, { id: 'child-2' }], - ownerId: 'user-id', - primaryAssetId: 'parent', - }); - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'parent', 'child-1', 'child-2'], { - updatedAt: expect.any(Date), - }); - }); - - it('should send ws asset update event', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); - assetMock.getById.mockResolvedValue(assetStub.image); - - await sut.updateAll(authStub.user1, { - ids: ['asset-1'], - stackParentId: 'parent', - }); - - expect(eventMock.clientSend).toHaveBeenCalledWith(ClientEvent.ASSET_STACK_UPDATE, authStub.user1.user.id, [ - 'asset-1', - 'parent', - ]); - }); }); describe('deleteAll', () => { @@ -530,53 +402,17 @@ describe(AssetService.name, () => { }); }); - describe('updateStackParent', () => { - it('should require asset update access for new parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['old'])); - await expect( - sut.updateStackParent(authStub.user1, { - oldParentId: 'old', - newParentId: 'new', - }), - ).rejects.toBeInstanceOf(BadRequestException); + describe('getUserAssetsByDeviceId', () => { + it('get assets by device id', async () => { + const assets = [assetStub.image, assetStub.image1]; + + assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); + + const deviceId = 'device-id'; + const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); + + expect(result.length).toEqual(2); + expect(result).toEqual(assets.map((asset) => asset.deviceAssetId)); }); - - it('should require asset read access for old parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['new'])); - await expect( - sut.updateStackParent(authStub.user1, { - oldParentId: 'old', - newParentId: 'new', - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('make old parent the child of new parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set([assetStub.image.id])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['new'])); - assetMock.getById.mockResolvedValue({ ...assetStub.image, stackId: 'stack-1' }); - - await sut.updateStackParent(authStub.user1, { - oldParentId: assetStub.image.id, - newParentId: 'new', - }); - - expect(stackMock.update).toBeCalledWith({ id: 'stack-1', primaryAssetId: 'new' }); - expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id, 'new', assetStub.image.id], { - updatedAt: expect.any(Date), - }); - }); - }); - - it('get assets by device id', async () => { - const assets = [assetStub.image, assetStub.image1]; - - assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); - - const deviceId = 'device-id'; - const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); - - expect(result.length).toEqual(2); - expect(result).toEqual(assets.map((asset) => asset.deviceAssetId)); }); }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index bbbc2bb407..94a3ba1603 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -20,7 +20,6 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; -import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; @@ -179,68 +178,14 @@ export class AssetService { } async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise { - const { ids, removeParent, dateTimeOriginal, latitude, longitude, ...options } = dto; + const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto; await this.access.requirePermission(auth, Permission.ASSET_UPDATE, ids); - // TODO: refactor this logic into separate API calls POST /stack, PUT /stack, etc. - const stackIdsToCheckForDelete: string[] = []; - if (removeParent) { - (options as Partial).stack = null; - const assets = await this.assetRepository.getByIds(ids, { stack: true }); - stackIdsToCheckForDelete.push(...new Set(assets.filter((a) => !!a.stackId).map((a) => a.stackId!))); - // This updates the updatedAt column of the parents to indicate that one of its children is removed - // All the unique parent's -> parent is set to null - await this.assetRepository.updateAll( - assets.filter((a) => !!a.stack?.primaryAssetId).map((a) => a.stack!.primaryAssetId!), - { updatedAt: new Date() }, - ); - } else if (options.stackParentId) { - //Creating new stack if parent doesn't have one already. If it does, then we add to the existing stack - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, options.stackParentId); - const primaryAsset = await this.assetRepository.getById(options.stackParentId, { stack: { assets: true } }); - if (!primaryAsset) { - throw new BadRequestException('Asset not found for given stackParentId'); - } - let stack = primaryAsset.stack; - - ids.push(options.stackParentId); - const assets = await this.assetRepository.getByIds(ids, { stack: { assets: true } }); - stackIdsToCheckForDelete.push( - ...new Set(assets.filter((a) => !!a.stackId && stack?.id !== a.stackId).map((a) => a.stackId!)), - ); - const assetsWithChildren = assets.filter((a) => a.stack && a.stack.assets.length > 0); - ids.push(...assetsWithChildren.flatMap((child) => child.stack!.assets.map((gChild) => gChild.id))); - - if (stack) { - await this.stackRepository.update({ - id: stack.id, - primaryAssetId: primaryAsset.id, - assets: ids.map((id) => ({ id }) as AssetEntity), - }); - } else { - stack = await this.stackRepository.create({ - primaryAssetId: primaryAsset.id, - ownerId: primaryAsset.ownerId, - assets: ids.map((id) => ({ id }) as AssetEntity), - }); - } - - // Merge stacks - options.stackParentId = undefined; - (options as Partial).updatedAt = new Date(); - } - for (const id of ids) { await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); } await this.assetRepository.updateAll(ids, options); - const stackIdsToDelete = await Promise.all(stackIdsToCheckForDelete.map((id) => this.stackRepository.getById(id))); - const stacksToDelete = stackIdsToDelete - .flatMap((stack) => (stack ? [stack] : [])) - .filter((stack) => stack.assets.length < 2); - await Promise.all(stacksToDelete.map((as) => this.stackRepository.delete(as.id))); - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, ids); } async handleAssetDeletionCheck(): Promise { @@ -343,41 +288,6 @@ export class AssetService { } } - async updateStackParent(auth: AuthDto, dto: UpdateStackParentDto): Promise { - const { oldParentId, newParentId } = dto; - await this.access.requirePermission(auth, Permission.ASSET_READ, oldParentId); - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, newParentId); - - const childIds: string[] = []; - const oldParent = await this.assetRepository.getById(oldParentId, { - faces: { - person: true, - }, - library: true, - stack: { - assets: true, - }, - }); - if (!oldParent?.stackId) { - throw new Error('Asset not found or not in a stack'); - } - if (oldParent != null) { - // Get all children of old parent - childIds.push(oldParent.id, ...(oldParent.stack?.assets.map((a) => a.id) ?? [])); - } - await this.stackRepository.update({ - id: oldParent.stackId, - primaryAssetId: newParentId, - }); - - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, [ - ...childIds, - newParentId, - oldParentId, - ]); - await this.assetRepository.updateAll([oldParentId, newParentId, ...childIds], { updatedAt: new Date() }); - } - async run(auth: AuthDto, dto: AssetJobsDto) { await this.access.requirePermission(auth, Permission.ASSET_UPDATE, dto.assetIds); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index ae9d101c58..70852a5381 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -39,7 +39,7 @@ export class DuplicateService { async getDuplicates(auth: AuthDto): Promise { const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); - return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth }))); + return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true }))); } async handleQueueSearchDuplicates({ force }: IBaseJob): Promise { diff --git a/server/src/services/index.ts b/server/src/services/index.ts index ab680f15e3..5a2e53927a 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -25,6 +25,7 @@ import { ServerService } from 'src/services/server.service'; import { SessionService } from 'src/services/session.service'; import { SharedLinkService } from 'src/services/shared-link.service'; import { SmartInfoService } from 'src/services/smart-info.service'; +import { StackService } from 'src/services/stack.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; import { SyncService } from 'src/services/sync.service'; @@ -65,6 +66,7 @@ export const services = [ SessionService, SharedLinkService, SmartInfoService, + StackService, StorageService, StorageTemplateService, SyncService, diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts new file mode 100644 index 0000000000..70234dee56 --- /dev/null +++ b/server/src/services/stack.service.ts @@ -0,0 +1,84 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { AccessCore } from 'src/cores/access.core'; +import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto'; +import { Permission } from 'src/enum'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IStackRepository } from 'src/interfaces/stack.interface'; + +@Injectable() +export class StackService { + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IEventRepository) private eventRepository: IEventRepository, + @Inject(IStackRepository) private stackRepository: IStackRepository, + ) { + this.access = AccessCore.create(accessRepository); + } + + async search(auth: AuthDto, dto: StackSearchDto): Promise { + const stacks = await this.stackRepository.search({ + ownerId: auth.user.id, + primaryAssetId: dto.primaryAssetId, + }); + + return stacks.map((stack) => mapStack(stack, { auth })); + } + + async create(auth: AuthDto, dto: StackCreateDto): Promise { + await this.access.requirePermission(auth, Permission.ASSET_UPDATE, dto.assetIds); + + const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds }); + + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + + return mapStack(stack, { auth }); + } + + async get(auth: AuthDto, id: string): Promise { + await this.access.requirePermission(auth, Permission.STACK_READ, id); + const stack = await this.findOrFail(id); + return mapStack(stack, { auth }); + } + + async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise { + await this.access.requirePermission(auth, Permission.STACK_UPDATE, id); + const stack = await this.findOrFail(id); + if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) { + throw new BadRequestException('Primary asset must be in the stack'); + } + + const updatedStack = await this.stackRepository.update({ id, primaryAssetId: dto.primaryAssetId }); + + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + + return mapStack(updatedStack, { auth }); + } + + async delete(auth: AuthDto, id: string): Promise { + await this.access.requirePermission(auth, Permission.STACK_DELETE, id); + await this.stackRepository.delete(id); + + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + } + + async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise { + await this.access.requirePermission(auth, Permission.STACK_DELETE, dto.ids); + await this.stackRepository.deleteAll(dto.ids); + + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + } + + private async findOrFail(id: string) { + const stack = await this.stackRepository.getById(id); + if (!stack) { + throw new Error('Asset stack not found'); + } + + return stack; + } +} diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 1635f8d24f..8a5cc17d4f 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -76,7 +76,6 @@ const assetResponse: AssetResponseDto = { isTrashed: false, libraryId: 'library-id', hasMetadata: true, - stackCount: 0, }; const assetResponseWithoutMetadata = { diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 8d69e35c05..befe9c77a8 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -7,10 +7,11 @@ export interface IAccessRepositoryMock { asset: Mocked; album: Mocked; authDevice: Mocked; - timeline: Mocked; memory: Mocked; person: Mocked; partner: Mocked; + stack: Mocked; + timeline: Mocked; } export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => { @@ -42,10 +43,6 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, - timeline: { - checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), - }, - memory: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, @@ -58,5 +55,13 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => partner: { checkUpdateAccess: vitest.fn().mockResolvedValue(new Set()), }, + + stack: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + + timeline: { + checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, }; }; diff --git a/server/test/repositories/stack.repository.mock.ts b/server/test/repositories/stack.repository.mock.ts index 5567d2e1ac..35d1614de7 100644 --- a/server/test/repositories/stack.repository.mock.ts +++ b/server/test/repositories/stack.repository.mock.ts @@ -3,9 +3,11 @@ import { Mocked, vitest } from 'vitest'; export const newStackRepositoryMock = (): Mocked => { return { + search: vitest.fn(), create: vitest.fn(), update: vitest.fn(), delete: vitest.fn(), getById: vitest.fn(), + deleteAll: vitest.fn(), }; }; diff --git a/web/src/lib/components/asset-viewer/actions/unstack-action.svelte b/web/src/lib/components/asset-viewer/actions/unstack-action.svelte index 40178c472d..bd18e0e8bf 100644 --- a/web/src/lib/components/asset-viewer/actions/unstack-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/unstack-action.svelte @@ -1,17 +1,17 @@

    - +
    {#if isFromExternalLibrary}
    {$t('external')}
    {/if} - {#if stackCount != null && stackCount != 0} + {#if asset.stack?.assetCount}
    -
    {stackCount}
    +
    {asset.stack.assetCount}
    diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 74a695770e..2722745317 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -12,9 +12,12 @@ import { createAlbum } from '$lib/utils/album-utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { addAssetsToAlbum as addAssets, + createStack, + deleteStacks, getAssetInfo, getBaseUrl, getDownloadInfo, + getStack, updateAsset, updateAssets, type AlbumResponseDto, @@ -335,79 +338,60 @@ export const stackAssets = async (assets: AssetResponseDto[], showNotification = return false; } - const parent = assets[0]; - const children = assets.slice(1); - const ids = children.map(({ id }) => id); const $t = get(t); try { - await updateAssets({ - assetBulkUpdateDto: { - ids, - stackParentId: parent.id, - }, - }); + const stack = await createStack({ stackCreateDto: { assetIds: assets.map(({ id }) => id) } }); + if (showNotification) { + notificationController.show({ + message: $t('stacked_assets_count', { values: { count: stack.assets.length } }), + type: NotificationType.Info, + button: { + text: $t('view_stack'), + onClick: () => assetViewingStore.setAssetId(stack.primaryAssetId), + }, + }); + } + + for (const [index, asset] of assets.entries()) { + asset.stack = index === 0 ? { id: stack.id, assetCount: stack.assets.length, primaryAssetId: asset.id } : null; + } + + return assets.slice(1).map((asset) => asset.id); } catch (error) { handleError(error, $t('errors.failed_to_stack_assets')); return false; } - - let grandChildren: AssetResponseDto[] = []; - for (const asset of children) { - asset.stackParentId = parent.id; - if (asset.stack) { - // Add grand-children to new parent - grandChildren = grandChildren.concat(asset.stack); - // Reset children stack info - asset.stackCount = null; - asset.stack = []; - } - } - - parent.stack ??= []; - parent.stack = parent.stack.concat(children, grandChildren); - parent.stackCount = parent.stack.length + 1; - - if (showNotification) { - notificationController.show({ - message: $t('stacked_assets_count', { values: { count: parent.stackCount } }), - type: NotificationType.Info, - button: { - text: $t('view_stack'), - onClick() { - return assetViewingStore.setAssetId(parent.id); - }, - }, - }); - } - - return ids; }; -export const unstackAssets = async (assets: AssetResponseDto[]) => { - const ids = assets.map(({ id }) => id); - const $t = get(t); - try { - await updateAssets({ - assetBulkUpdateDto: { - ids, - removeParent: true, - }, - }); - } catch (error) { - handleError(error, $t('errors.failed_to_unstack_assets')); +export const deleteStack = async (stackIds: string[]) => { + const ids = [...new Set(stackIds)]; + if (ids.length === 0) { return; } - for (const asset of assets) { - asset.stackParentId = null; - asset.stackCount = null; - asset.stack = []; + + const $t = get(t); + + try { + const stacks = await Promise.all(ids.map((id) => getStack({ id }))); + const count = stacks.reduce((sum, stack) => sum + stack.assets.length, 0); + + await deleteStacks({ bulkIdsDto: { ids: [...ids] } }); + + notificationController.show({ + type: NotificationType.Info, + message: $t('unstacked_assets_count', { values: { count } }), + }); + + const assets = stacks.flatMap((stack) => stack.assets); + for (const asset of assets) { + asset.stack = null; + } + + return assets; + } catch (error) { + handleError(error, $t('errors.failed_to_unstack_assets')); } - notificationController.show({ - type: NotificationType.Info, - message: $t('unstacked_assets_count', { values: { count: assets.length } }), - }); - return assets; }; export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => { diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index e76138fe59..5f31b8af44 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -25,5 +25,4 @@ export const assetFactory = Sync.makeFactory({ checksum: Sync.each(() => faker.string.alphanumeric(28)), isOffline: Sync.each(() => faker.datatype.boolean()), hasMetadata: Sync.each(() => faker.datatype.boolean()), - stackCount: null, }); From d9698884bdf328e11d91af17a10adabf78cbd68e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 19 Aug 2024 13:50:00 -0400 Subject: [PATCH 128/723] refactor(server): track thumbnail jobs in job status table (#11908) refactor: track thumbnail jobs in job status table --- .../src/entities/asset-job-status.entity.ts | 6 ++++++ .../1724080823160-AddThumbnailJobStatus.ts | 17 +++++++++++++++ server/src/repositories/asset.repository.ts | 21 +++++++++++-------- server/src/services/media.service.ts | 14 +++++++++++++ 4 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 server/src/migrations/1724080823160-AddThumbnailJobStatus.ts diff --git a/server/src/entities/asset-job-status.entity.ts b/server/src/entities/asset-job-status.entity.ts index 44c0a04696..353055df43 100644 --- a/server/src/entities/asset-job-status.entity.ts +++ b/server/src/entities/asset-job-status.entity.ts @@ -18,4 +18,10 @@ export class AssetJobStatusEntity { @Column({ type: 'timestamptz', nullable: true }) duplicatesDetectedAt!: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + previewAt!: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + thumbnailAt!: Date | null; } diff --git a/server/src/migrations/1724080823160-AddThumbnailJobStatus.ts b/server/src/migrations/1724080823160-AddThumbnailJobStatus.ts new file mode 100644 index 0000000000..a71ddfbcf3 --- /dev/null +++ b/server/src/migrations/1724080823160-AddThumbnailJobStatus.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddThumbnailJobStatus1724080823160 implements MigrationInterface { + name = 'AddThumbnailJobStatus1724080823160'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "asset_job_status" ADD "previewAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "asset_job_status" ADD "thumbnailAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`UPDATE "asset_job_status" SET "previewAt" = NOW() FROM "assets" WHERE "assetId" = "assets"."id" AND "assets"."previewPath" IS NOT NULL`); + await queryRunner.query(`UPDATE "asset_job_status" SET "thumbnailAt" = NOW() FROM "assets" WHERE "assetId" = "assets"."id" AND "assets"."thumbnailPath" IS NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "asset_job_status" DROP COLUMN "thumbnailAt"`); + await queryRunner.query(`ALTER TABLE "asset_job_status" DROP COLUMN "previewAt"`); + } +} diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 1029b8d8da..80b26a67bf 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -391,11 +391,10 @@ export class AssetRepository implements IAssetRepository { switch (property) { case WithoutProperty.THUMBNAIL: { + relations = { jobStatus: true }; where = [ - { previewPath: IsNull(), isVisible: true }, - { previewPath: '', isVisible: true }, - { thumbnailPath: IsNull(), isVisible: true }, - { thumbnailPath: '', isVisible: true }, + { jobStatus: { previewAt: IsNull() }, isVisible: true }, + { jobStatus: { thumbnailAt: IsNull() }, isVisible: true }, { thumbhash: IsNull(), isVisible: true }, ]; break; @@ -429,7 +428,7 @@ export class AssetRepository implements IAssetRepository { }; where = { isVisible: true, - previewPath: Not(IsNull()), + jobStatus: { previewAt: Not(IsNull()) }, smartSearch: { embedding: IsNull(), }, @@ -439,10 +438,10 @@ export class AssetRepository implements IAssetRepository { case WithoutProperty.DUPLICATE: { where = { - previewPath: Not(IsNull()), isVisible: true, smartSearch: true, jobStatus: { + previewAt: Not(IsNull()), duplicatesDetectedAt: IsNull(), }, }; @@ -454,7 +453,9 @@ export class AssetRepository implements IAssetRepository { smartInfo: true, }; where = { - previewPath: Not(IsNull()), + jobStatus: { + previewAt: Not(IsNull()), + }, isVisible: true, smartInfo: { tags: IsNull(), @@ -469,13 +470,13 @@ export class AssetRepository implements IAssetRepository { jobStatus: true, }; where = { - previewPath: Not(IsNull()), isVisible: true, faces: { assetId: IsNull(), personId: IsNull(), }, jobStatus: { + previewAt: Not(IsNull()), facesRecognizedAt: IsNull(), }, }; @@ -487,7 +488,9 @@ export class AssetRepository implements IAssetRepository { faces: true, }; where = { - previewPath: Not(IsNull()), + jobStatus: { + previewAt: Not(IsNull()), + }, isVisible: true, faces: { assetId: Not(IsNull()), diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 5264da9fe9..ff77cbb34e 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -178,11 +178,18 @@ export class MediaService { } const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat); + if (!previewPath) { + return JobStatus.SKIPPED; + } + if (asset.previewPath && asset.previewPath !== previewPath) { this.logger.debug(`Deleting old preview for asset ${asset.id}`); await this.storageRepository.unlink(asset.previewPath); } + await this.assetRepository.update({ id: asset.id, previewPath }); + await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date() }); + return JobStatus.SUCCESS; } @@ -257,11 +264,18 @@ export class MediaService { } const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); + if (!thumbnailPath) { + return JobStatus.SKIPPED; + } + if (asset.thumbnailPath && asset.thumbnailPath !== thumbnailPath) { this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); await this.storageRepository.unlink(asset.thumbnailPath); } + await this.assetRepository.update({ id: asset.id, thumbnailPath }); + await this.assetRepository.upsertJobStatus({ assetId: asset.id, thumbnailAt: new Date() }); + return JobStatus.SUCCESS; } From 2237b7a399c69bd406f3bf862ec1cc17bfd3e28c Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Mon, 19 Aug 2024 20:47:27 +0100 Subject: [PATCH 129/723] chore: validate every PR has a changelog related label (#11909) --- .github/release.yml | 14 +++++++------- .github/workflows/pr-label-validation.yml | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/pr-label-validation.yml diff --git a/.github/release.yml b/.github/release.yml index 4463555deb..1d9764194c 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -2,28 +2,28 @@ changelog: categories: - title: 🚨 Breaking Changes labels: - - breaking-change + - changelog:breaking-change - title: 🔒 Security labels: - - security + - changelog:security - title: 🚀 Features labels: - - feature + - changelog:feature - title: 🌟 Enhancements labels: - - enhancement + - changelog:enhancement - title: 🐛 Bug fixes labels: - - bugfix + - changelog:bugfix - title: 📚 Documentation labels: - - documentation + - changelog:documentation - title: 🌐 Translations labels: - - translation + - changelog:translation diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml new file mode 100644 index 0000000000..510995aa54 --- /dev/null +++ b/.github/workflows/pr-label-validation.yml @@ -0,0 +1,18 @@ +name: PR Label Validation + +on: + pull_request: + types: [opened, labeled, unlabeled, synchronize] + +jobs: + validate-release-label: + runs-on: ubuntu-latest + steps: + - name: Require PR to have a changelog label + uses: mheap/github-action-required-labels@v5 + with: + mode: exactly + count: 1 + use_regex: true + labels: "changelog:.*" + add_comment: true From af3a793fe8b3ccc4752f49d225215e376d2a4f1b Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 19 Aug 2024 15:43:57 -0500 Subject: [PATCH 130/723] fix(server): create shared album from the mobile app does not trigger send email invite (#11911) * fix(server): create shared album from the mobile app does not trigger send email invite * remove unused value --- server/src/services/album.service.spec.ts | 4 ++++ server/src/services/album.service.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 6db39328df..406302ece9 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -205,6 +205,10 @@ describe(AlbumService.name, () => { expect(userMock.get).toHaveBeenCalledWith('user-id', {}); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); + expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInvite', { + id: albumStub.empty.id, + userId: 'user-id', + }); }); it('should require valid userIds', async () => { diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 71594d20b6..06f2a7a0fb 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -138,6 +138,10 @@ export class AlbumService { albumThumbnailAssetId: assets[0]?.id || null, }); + for (const { userId } of albumUsers) { + await this.eventRepository.emit('onAlbumInvite', { id: album.id, userId }); + } + return mapAlbumWithAssets(album); } From 7af6733665fcf637565bbb23068a2225efa237dd Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 19 Aug 2024 20:03:33 -0400 Subject: [PATCH 131/723] refactor(server): move files to separate table (#11861) --- server/src/cores/storage.core.ts | 14 ++- server/src/dtos/asset-response.dto.ts | 5 +- server/src/entities/asset-files.entity.ts | 38 ++++++ server/src/entities/asset.entity.ts | 8 +- server/src/entities/index.ts | 2 + server/src/enum.ts | 5 + server/src/interfaces/asset.interface.ts | 3 +- .../1724101822106-AddAssetFilesTable.ts | 34 ++++++ server/src/queries/asset.repository.sql | 113 ++++++++++++------ server/src/queries/person.repository.sql | 6 - server/src/queries/search.repository.sql | 10 -- server/src/queries/shared.link.repository.sql | 6 - server/src/repositories/asset.repository.ts | 20 +++- .../src/services/asset-media.service.spec.ts | 6 +- server/src/services/asset-media.service.ts | 10 +- server/src/services/asset.service.spec.ts | 4 +- server/src/services/asset.service.ts | 11 +- server/src/services/audit.service.ts | 22 ++-- server/src/services/duplicate.service.ts | 6 +- server/src/services/media.service.spec.ts | 50 ++++---- server/src/services/media.service.ts | 44 ++++--- .../src/services/notification.service.spec.ts | 20 +++- server/src/services/notification.service.ts | 10 +- server/src/services/person.service.spec.ts | 6 +- server/src/services/person.service.ts | 21 ++-- .../src/services/smart-info.service.spec.ts | 2 +- server/src/services/smart-info.service.ts | 8 +- server/src/utils/asset.util.ts | 12 +- server/src/utils/database.ts | 2 +- server/test/fixtures/asset.stub.ts | 111 +++++++++-------- server/test/fixtures/shared-link.stub.ts | 3 +- .../repositories/asset.repository.mock.ts | 1 + 32 files changed, 403 insertions(+), 210 deletions(-) create mode 100644 server/src/entities/asset-files.entity.ts create mode 100644 server/src/migrations/1724101822106-AddAssetFilesTable.ts diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 4f386a51ef..e20a0c658d 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -6,6 +6,7 @@ import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType, PathType, PersonPathType } from 'src/entities/move.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { AssetFileType } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; @@ -13,6 +14,7 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getAssetFiles } from 'src/utils/asset.util'; export enum StorageFolder { ENCODED_VIDEO = 'encoded-video', @@ -130,12 +132,14 @@ export class StorageCore { } async moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType, format: ImageFormat) { - const { id: entityId, previewPath, thumbnailPath } = asset; + const { id: entityId, files } = asset; + const { thumbnailFile, previewFile } = getAssetFiles(files); + const oldFile = pathType === AssetPathType.PREVIEW ? previewFile : thumbnailFile; return this.moveFile({ entityId, pathType, - oldPath: pathType === AssetPathType.PREVIEW ? previewPath : thumbnailPath, - newPath: StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, format), + oldPath: oldFile?.path || null, + newPath: StorageCore.getImagePath(asset, pathType, format), }); } @@ -285,10 +289,10 @@ export class StorageCore { return this.assetRepository.update({ id, originalPath: newPath }); } case AssetPathType.PREVIEW: { - return this.assetRepository.update({ id, previewPath: newPath }); + return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.PREVIEW, path: newPath }); } case AssetPathType.THUMBNAIL: { - return this.assetRepository.update({ id, thumbnailPath: newPath }); + return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.THUMBNAIL, path: newPath }); } case AssetPathType.ENCODED_VIDEO: { return this.assetRepository.update({ id, encodedVideoPath: newPath }); diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 6ed1125253..332f258d49 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -14,6 +14,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { AssetType } from 'src/enum'; +import { getAssetFiles } from 'src/utils/asset.util'; import { mimeTypes } from 'src/utils/mime-types'; export class SanitizedAssetResponseDto { @@ -111,7 +112,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash?.toString('base64') ?? null, localDateTime: entity.localDateTime, - resized: !!entity.previewPath, + resized: !!getAssetFiles(entity.files).previewFile, duration: entity.duration ?? '0:00:00.00000', livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, @@ -130,7 +131,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As originalPath: entity.originalPath, originalFileName: entity.originalFileName, originalMimeType: mimeTypes.lookup(entity.originalFileName), - resized: !!entity.previewPath, + resized: !!getAssetFiles(entity.files).previewFile, thumbhash: entity.thumbhash?.toString('base64') ?? null, fileCreatedAt: entity.fileCreatedAt, fileModifiedAt: entity.fileModifiedAt, diff --git a/server/src/entities/asset-files.entity.ts b/server/src/entities/asset-files.entity.ts new file mode 100644 index 0000000000..a8a6ddfee1 --- /dev/null +++ b/server/src/entities/asset-files.entity.ts @@ -0,0 +1,38 @@ +import { AssetEntity } from 'src/entities/asset.entity'; +import { AssetFileType } from 'src/enum'; +import { + Column, + CreateDateColumn, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; + +@Unique('UQ_assetId_type', ['assetId', 'type']) +@Entity('asset_files') +export class AssetFileEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Index('IDX_asset_files_assetId') + @Column() + assetId!: string; + + @ManyToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + asset?: AssetEntity; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @Column() + type!: AssetFileType; + + @Column() + path!: string; +} diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index f4ea5eafdd..9ebf9364d1 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -1,5 +1,6 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { LibraryEntity } from 'src/entities/library.entity'; @@ -72,11 +73,8 @@ export class AssetEntity { @Column() originalPath!: string; - @Column({ type: 'varchar', nullable: true }) - previewPath!: string | null; - - @Column({ type: 'varchar', nullable: true, default: '' }) - thumbnailPath!: string | null; + @OneToMany(() => AssetFileEntity, (assetFile) => assetFile.asset) + files!: AssetFileEntity[]; @Column({ type: 'bytea', nullable: true }) thumbhash!: Buffer | null; diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 148e264095..0b7ca8c3bd 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -3,6 +3,7 @@ import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { APIKeyEntity } from 'src/entities/api-key.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { AuditEntity } from 'src/entities/audit.entity'; @@ -32,6 +33,7 @@ export const entities = [ APIKeyEntity, AssetEntity, AssetFaceEntity, + AssetFileEntity, AssetJobStatusEntity, AuditEntity, ExifEntity, diff --git a/server/src/enum.ts b/server/src/enum.ts index 4a81d54218..64cb1f118a 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -5,6 +5,11 @@ export enum AssetType { OTHER = 'OTHER', } +export enum AssetFileType { + PREVIEW = 'preview', + THUMBNAIL = 'thumbnail', +} + export enum AlbumUserRole { EDITOR = 'editor', VIEWER = 'viewer', diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index aca45f3dc7..6dd81edaef 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -1,7 +1,7 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetOrder, AssetType } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetType } from 'src/enum'; import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindOptionsOrder, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; @@ -191,4 +191,5 @@ export interface IAssetRepository { getDuplicates(options: AssetBuilderOptions): Promise; getAllForUserFullSync(options: AssetFullSyncOptions): Promise; getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise; + upsertFile(options: { assetId: string; type: AssetFileType; path: string }): Promise; } diff --git a/server/src/migrations/1724101822106-AddAssetFilesTable.ts b/server/src/migrations/1724101822106-AddAssetFilesTable.ts new file mode 100644 index 0000000000..1ed4945749 --- /dev/null +++ b/server/src/migrations/1724101822106-AddAssetFilesTable.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddAssetFilesTable1724101822106 implements MigrationInterface { + name = 'AddAssetFilesTable1724101822106' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "asset_files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "assetId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "type" character varying NOT NULL, "path" character varying NOT NULL, CONSTRAINT "UQ_assetId_type" UNIQUE ("assetId", "type"), CONSTRAINT "PK_c41dc3e9ef5e1c57ca5a08a0004" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_asset_files_assetId" ON "asset_files" ("assetId") `); + await queryRunner.query(`ALTER TABLE "asset_files" ADD CONSTRAINT "FK_e3e103a5f1d8bc8402999286040" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + + // preview path migration + await queryRunner.query(`INSERT INTO "asset_files" ("assetId", "type", "path") SELECT "id", 'preview', "previewPath" FROM "assets" WHERE "previewPath" IS NOT NULL AND "previewPath" != ''`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "previewPath"`); + + // thumbnail path migration + await queryRunner.query(`INSERT INTO "asset_files" ("assetId", "type", "path") SELECT "id", 'thumbnail', "thumbnailPath" FROM "assets" WHERE "thumbnailPath" IS NOT NULL AND "thumbnailPath" != ''`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "thumbnailPath"`); + } + + public async down(queryRunner: QueryRunner): Promise { + // undo preview path migration + await queryRunner.query(`ALTER TABLE "assets" ADD "previewPath" character varying`); + await queryRunner.query(`UPDATE "assets" SET "previewPath" = "asset_files".path FROM "asset_files" WHERE "assets".id = "asset_files".assetId AND "asset_files".type = 'preview'`); + + // undo thumbnail path migration + await queryRunner.query(`ALTER TABLE "assets" ADD "thumbnailPath" character varying DEFAULT ''`); + await queryRunner.query(`UPDATE "assets" SET "thumbnailPath" = "asset_files".path FROM "asset_files" WHERE "assets".id = "asset_files".assetId AND "asset_files".type = 'thumbnail'`); + + await queryRunner.query(`ALTER TABLE "asset_files" DROP CONSTRAINT "FK_e3e103a5f1d8bc8402999286040"`); + await queryRunner.query(`DROP INDEX "public"."IDX_asset_files_assetId"`); + await queryRunner.query(`DROP TABLE "asset_files"`); + } + +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 98fb1d6999..c9bd8083bb 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -9,8 +9,6 @@ SELECT "entity"."deviceId" AS "entity_deviceId", "entity"."type" AS "entity_type", "entity"."originalPath" AS "entity_originalPath", - "entity"."previewPath" AS "entity_previewPath", - "entity"."thumbnailPath" AS "entity_thumbnailPath", "entity"."thumbhash" AS "entity_thumbhash", "entity"."encodedVideoPath" AS "entity_encodedVideoPath", "entity"."createdAt" AS "entity_createdAt", @@ -59,16 +57,22 @@ SELECT "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."rating" AS "exifInfo_rating", - "exifInfo"."fps" AS "exifInfo_fps" + "exifInfo"."fps" AS "exifInfo_fps", + "files"."id" AS "files_id", + "files"."assetId" AS "files_assetId", + "files"."createdAt" AS "files_createdAt", + "files"."updatedAt" AS "files_updatedAt", + "files"."type" AS "files_type", + "files"."path" AS "files_path" FROM "assets" "entity" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id" + LEFT JOIN "asset_files" "files" ON "files"."assetId" = "entity"."id" WHERE ( "entity"."ownerId" IN ($1) AND "entity"."isVisible" = true AND "entity"."isArchived" = false - AND "entity"."previewPath" IS NOT NULL AND EXTRACT( DAY FROM @@ -93,8 +97,6 @@ SELECT "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -129,8 +131,6 @@ SELECT "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -216,8 +216,6 @@ SELECT "bd93d5747511a4dad4923546c51365bf1a803774"."deviceId" AS "bd93d5747511a4dad4923546c51365bf1a803774_deviceId", "bd93d5747511a4dad4923546c51365bf1a803774"."type" AS "bd93d5747511a4dad4923546c51365bf1a803774_type", "bd93d5747511a4dad4923546c51365bf1a803774"."originalPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalPath", - "bd93d5747511a4dad4923546c51365bf1a803774"."previewPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_previewPath", - "bd93d5747511a4dad4923546c51365bf1a803774"."thumbnailPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_thumbnailPath", "bd93d5747511a4dad4923546c51365bf1a803774"."thumbhash" AS "bd93d5747511a4dad4923546c51365bf1a803774_thumbhash", "bd93d5747511a4dad4923546c51365bf1a803774"."encodedVideoPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_encodedVideoPath", "bd93d5747511a4dad4923546c51365bf1a803774"."createdAt" AS "bd93d5747511a4dad4923546c51365bf1a803774_createdAt", @@ -237,7 +235,13 @@ SELECT "bd93d5747511a4dad4923546c51365bf1a803774"."originalFileName" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalFileName", "bd93d5747511a4dad4923546c51365bf1a803774"."sidecarPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_sidecarPath", "bd93d5747511a4dad4923546c51365bf1a803774"."stackId" AS "bd93d5747511a4dad4923546c51365bf1a803774_stackId", - "bd93d5747511a4dad4923546c51365bf1a803774"."duplicateId" AS "bd93d5747511a4dad4923546c51365bf1a803774_duplicateId" + "bd93d5747511a4dad4923546c51365bf1a803774"."duplicateId" AS "bd93d5747511a4dad4923546c51365bf1a803774_duplicateId", + "AssetEntity__AssetEntity_files"."id" AS "AssetEntity__AssetEntity_files_id", + "AssetEntity__AssetEntity_files"."assetId" AS "AssetEntity__AssetEntity_files_assetId", + "AssetEntity__AssetEntity_files"."createdAt" AS "AssetEntity__AssetEntity_files_createdAt", + "AssetEntity__AssetEntity_files"."updatedAt" AS "AssetEntity__AssetEntity_files_updatedAt", + "AssetEntity__AssetEntity_files"."type" AS "AssetEntity__AssetEntity_files_type", + "AssetEntity__AssetEntity_files"."path" AS "AssetEntity__AssetEntity_files_path" FROM "assets" "AssetEntity" LEFT JOIN "exif" "AssetEntity__AssetEntity_exifInfo" ON "AssetEntity__AssetEntity_exifInfo"."assetId" = "AssetEntity"."id" @@ -248,6 +252,7 @@ FROM LEFT JOIN "person" "8258e303a73a72cf6abb13d73fb592dde0d68280" ON "8258e303a73a72cf6abb13d73fb592dde0d68280"."id" = "AssetEntity__AssetEntity_faces"."personId" LEFT JOIN "asset_stack" "AssetEntity__AssetEntity_stack" ON "AssetEntity__AssetEntity_stack"."id" = "AssetEntity"."stackId" LEFT JOIN "assets" "bd93d5747511a4dad4923546c51365bf1a803774" ON "bd93d5747511a4dad4923546c51365bf1a803774"."stackId" = "AssetEntity__AssetEntity_stack"."id" + LEFT JOIN "asset_files" "AssetEntity__AssetEntity_files" ON "AssetEntity__AssetEntity_files"."assetId" = "AssetEntity"."id" WHERE (("AssetEntity"."id" IN ($1))) @@ -298,8 +303,6 @@ FROM "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -397,8 +400,6 @@ SELECT "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -452,8 +453,6 @@ SELECT "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -525,8 +524,6 @@ SELECT "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -581,8 +578,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -603,6 +598,12 @@ SELECT "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", "asset"."duplicateId" AS "asset_duplicateId", + "files"."id" AS "files_id", + "files"."assetId" AS "files_assetId", + "files"."createdAt" AS "files_createdAt", + "files"."updatedAt" AS "files_updatedAt", + "files"."type" AS "files_type", + "files"."path" AS "files_path", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -642,8 +643,6 @@ SELECT "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -666,6 +665,7 @@ SELECT "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" FROM "assets" "asset" + LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" @@ -692,6 +692,7 @@ SELECT )::timestamptz AS "timeBucket" FROM "assets" "asset" + LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" @@ -723,8 +724,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -745,6 +744,12 @@ SELECT "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", "asset"."duplicateId" AS "asset_duplicateId", + "files"."id" AS "files_id", + "files"."assetId" AS "files_assetId", + "files"."createdAt" AS "files_createdAt", + "files"."updatedAt" AS "files_updatedAt", + "files"."type" AS "files_type", + "files"."path" AS "files_path", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -784,8 +789,6 @@ SELECT "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -808,6 +811,7 @@ SELECT "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" FROM "assets" "asset" + LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" @@ -841,8 +845,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -863,6 +865,12 @@ SELECT "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", "asset"."duplicateId" AS "asset_duplicateId", + "files"."id" AS "files_id", + "files"."assetId" AS "files_assetId", + "files"."createdAt" AS "files_createdAt", + "files"."updatedAt" AS "files_updatedAt", + "files"."type" AS "files_type", + "files"."path" AS "files_path", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -902,8 +910,6 @@ SELECT "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -926,6 +932,7 @@ SELECT "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" FROM "assets" "asset" + LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" @@ -957,6 +964,7 @@ SELECT DISTINCT c.city AS "value" FROM "assets" "asset" + LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" INNER JOIN "exif" "e" ON "asset"."id" = e."assetId" INNER JOIN "cities" "c" ON c.city = "e"."city" WHERE @@ -987,6 +995,7 @@ SELECT DISTINCT unnest("si"."tags") AS "value" FROM "assets" "asset" + LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" INNER JOIN "smart_info" "si" ON "asset"."id" = si."assetId" INNER JOIN "random_tags" "t" ON "si"."tags" @> ARRAY[t.tag] WHERE @@ -1009,8 +1018,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -1031,6 +1038,12 @@ SELECT "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", "asset"."duplicateId" AS "asset_duplicateId", + "files"."id" AS "files_id", + "files"."assetId" AS "files_assetId", + "files"."createdAt" AS "files_createdAt", + "files"."updatedAt" AS "files_updatedAt", + "files"."type" AS "files_type", + "files"."path" AS "files_path", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -1065,6 +1078,7 @@ SELECT "stack"."primaryAssetId" AS "stack_primaryAssetId" FROM "assets" "asset" + LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" WHERE @@ -1086,8 +1100,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -1108,6 +1120,12 @@ SELECT "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", "asset"."duplicateId" AS "asset_duplicateId", + "files"."id" AS "files_id", + "files"."assetId" AS "files_assetId", + "files"."createdAt" AS "files_createdAt", + "files"."updatedAt" AS "files_updatedAt", + "files"."type" AS "files_type", + "files"."path" AS "files_path", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -1142,9 +1160,34 @@ SELECT "stack"."primaryAssetId" AS "stack_primaryAssetId" FROM "assets" "asset" + LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" WHERE "asset"."isVisible" = true AND "asset"."ownerId" IN ($1) AND "asset"."updatedAt" > $2 + +-- AssetRepository.upsertFile +INSERT INTO + "asset_files" ( + "id", + "assetId", + "createdAt", + "updatedAt", + "type", + "path" + ) +VALUES + (DEFAULT, $1, DEFAULT, DEFAULT, $2, $3) +ON CONFLICT ("assetId", "type") DO +UPDATE +SET + "assetId" = EXCLUDED."assetId", + "type" = EXCLUDED."type", + "path" = EXCLUDED."path", + "updatedAt" = DEFAULT +RETURNING + "id", + "createdAt", + "updatedAt" diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 9b20b964d8..9c94232d20 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -157,8 +157,6 @@ FROM "AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", "AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", - "AssetFaceEntity__AssetFaceEntity_asset"."previewPath" AS "AssetFaceEntity__AssetFaceEntity_asset_previewPath", - "AssetFaceEntity__AssetFaceEntity_asset"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbnailPath", "AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", "AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath", "AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt", @@ -255,8 +253,6 @@ FROM "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -386,8 +382,6 @@ SELECT "AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", "AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", - "AssetFaceEntity__AssetFaceEntity_asset"."previewPath" AS "AssetFaceEntity__AssetFaceEntity_asset_previewPath", - "AssetFaceEntity__AssetFaceEntity_asset"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbnailPath", "AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", "AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath", "AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt", diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 390aedaf35..e9e94400ad 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -14,8 +14,6 @@ FROM "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -46,8 +44,6 @@ FROM "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -111,8 +107,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -143,8 +137,6 @@ SELECT "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -353,8 +345,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 2880e6896f..10af8d17db 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -28,8 +28,6 @@ FROM "SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId", "SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type", "SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath", - "SharedLinkEntity__SharedLinkEntity_assets"."previewPath" AS "SharedLinkEntity__SharedLinkEntity_assets_previewPath", - "SharedLinkEntity__SharedLinkEntity_assets"."thumbnailPath" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbnailPath", "SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash", "SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath", "SharedLinkEntity__SharedLinkEntity_assets"."createdAt" AS "SharedLinkEntity__SharedLinkEntity_assets_createdAt", @@ -96,8 +94,6 @@ FROM "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deviceId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_deviceId", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."type" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_type", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."originalPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_originalPath", - "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."previewPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_previewPath", - "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."thumbnailPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_thumbnailPath", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."thumbhash" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_thumbhash", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."encodedVideoPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_encodedVideoPath", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."createdAt" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_createdAt", @@ -218,8 +214,6 @@ SELECT "SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId", "SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type", "SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath", - "SharedLinkEntity__SharedLinkEntity_assets"."previewPath" AS "SharedLinkEntity__SharedLinkEntity_assets_previewPath", - "SharedLinkEntity__SharedLinkEntity_assets"."thumbnailPath" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbnailPath", "SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash", "SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath", "SharedLinkEntity__SharedLinkEntity_assets"."createdAt" AS "SharedLinkEntity__SharedLinkEntity_assets_createdAt", diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 80b26a67bf..a74451f9a5 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,11 +1,12 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; -import { AssetOrder, AssetType } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetType } from 'src/enum'; import { AssetBuilderOptions, AssetCreate, @@ -59,6 +60,7 @@ const dateTrunc = (options: TimeBucketOptions) => export class AssetRepository implements IAssetRepository { constructor( @InjectRepository(AssetEntity) private repository: Repository, + @InjectRepository(AssetFileEntity) private fileRepository: Repository, @InjectRepository(ExifEntity) private exifRepository: Repository, @InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository, @InjectRepository(SmartInfoEntity) private smartInfoRepository: Repository, @@ -84,7 +86,6 @@ export class AssetRepository implements IAssetRepository { `entity.ownerId IN (:...ownerIds) AND entity.isVisible = true AND entity.isArchived = false - AND entity.previewPath IS NOT NULL AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`, { @@ -94,6 +95,7 @@ export class AssetRepository implements IAssetRepository { }, ) .leftJoinAndSelect('entity.exifInfo', 'exifInfo') + .leftJoinAndSelect('entity.files', 'files') .orderBy('entity.localDateTime', 'ASC') .getMany(); } @@ -128,6 +130,7 @@ export class AssetRepository implements IAssetRepository { stack: { assets: true, }, + files: true, }, withDeleted: true, }); @@ -214,7 +217,7 @@ export class AssetRepository implements IAssetRepository { } getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { - let builder = this.repository.createQueryBuilder('asset'); + let builder = this.repository.createQueryBuilder('asset').leftJoinAndSelect('asset.files', 'files'); builder = searchAssetBuilder(builder, options); builder.orderBy('asset.createdAt', options.orderDirection ?? 'ASC'); return paginatedBuilder(builder, { @@ -706,7 +709,11 @@ export class AssetRepository implements IAssetRepository { } private getBuilder(options: AssetBuilderOptions) { - const builder = this.repository.createQueryBuilder('asset').where('asset.isVisible = true'); + const builder = this.repository + .createQueryBuilder('asset') + .where('asset.isVisible = true') + .leftJoinAndSelect('asset.files', 'files'); + if (options.assetType !== undefined) { builder.andWhere('asset.type = :assetType', { assetType: options.assetType }); } @@ -812,4 +819,9 @@ export class AssetRepository implements IAssetRepository { .withDeleted(); return builder.getMany(); } + + @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) + async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise { + await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] }); + } } diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 978f98cf10..2f5192d84f 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException, NotFoundException, UnauthorizedException } from '@ import { Stats } from 'node:fs'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; import { AssetType } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; @@ -150,15 +151,14 @@ const assetEntity = Object.freeze({ deviceId: 'device_id_1', type: AssetType.VIDEO, originalPath: 'fake_path/asset_1.jpeg', - previewPath: '', fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), updatedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, isArchived: false, - thumbnailPath: '', encodedVideoPath: '', duration: '0:00:00.000000', + files: [] as AssetFileEntity[], exifInfo: { latitude: 49.533_547, longitude: 10.703_075, @@ -418,7 +418,7 @@ describe(AssetMediaService.name, () => { await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException); - expect(assetMock.getById).toHaveBeenCalledWith('asset-1'); + expect(assetMock.getById).toHaveBeenCalledWith('asset-1', { files: true }); }); it('should download a file', async () => { diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index b8a43b34ec..b66b0607b3 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -36,6 +36,7 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { getAssetFiles } from 'src/utils/asset.util'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; @@ -238,9 +239,10 @@ export class AssetMediaService { const asset = await this.findOrFail(id); const size = dto.size ?? AssetMediaSize.THUMBNAIL; - let filepath = asset.previewPath; - if (size === AssetMediaSize.THUMBNAIL && asset.thumbnailPath) { - filepath = asset.thumbnailPath; + const { thumbnailFile, previewFile } = getAssetFiles(asset.files); + let filepath = previewFile?.path; + if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) { + filepath = thumbnailFile.path; } if (!filepath) { @@ -460,7 +462,7 @@ export class AssetMediaService { } private async findOrFail(id: string): Promise { - const asset = await this.assetRepository.getById(id); + const asset = await this.assetRepository.getById(id, { files: true }); if (!asset) { throw new NotFoundException('Asset not found'); } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index f79b2819ff..3ac7aa1c71 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -299,8 +299,8 @@ describe(AssetService.name, () => { name: JobName.DELETE_FILES, data: { files: [ - assetWithFace.thumbnailPath, - assetWithFace.previewPath, + '/uploads/user-id/webp/path.ext', + '/uploads/user-id/thumbs/path.jpg', assetWithFace.encodedVideoPath, assetWithFace.sidecarPath, assetWithFace.originalPath, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 94a3ba1603..e9aefce910 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -39,7 +39,7 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; -import { getMyPartnerIds } from 'src/utils/asset.util'; +import { getAssetFiles, getMyPartnerIds } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; export class AssetService { @@ -71,9 +71,10 @@ export class AssetService { const userIds = [auth.user.id, ...partnerIds]; const assets = await this.assetRepository.getByDayOfYear(userIds, dto); + const assetsWithThumbnails = assets.filter(({ files }) => !!getAssetFiles(files).thumbnailFile); const groups: Record = {}; const currentYear = new Date().getFullYear(); - for (const asset of assets) { + for (const asset of assetsWithThumbnails) { const yearsAgo = currentYear - asset.localDateTime.getFullYear(); if (!groups[yearsAgo]) { groups[yearsAgo] = []; @@ -126,6 +127,7 @@ export class AssetService { exifInfo: true, }, }, + files: true, }, { faces: { @@ -170,6 +172,7 @@ export class AssetService { faces: { person: true, }, + files: true, }); if (!asset) { throw new BadRequestException('Asset not found'); @@ -223,6 +226,7 @@ export class AssetService { library: true, stack: { assets: true }, exifInfo: true, + files: true, }); if (!asset) { @@ -260,7 +264,8 @@ export class AssetService { } } - const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath]; + const { thumbnailFile, previewFile } = getAssetFiles(asset.files); + const files = [thumbnailFile?.path, previewFile?.path, asset.encodedVideoPath]; if (deleteOnDisk) { files.push(asset.sidecarPath, asset.originalPath); } diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index 225bd11061..734ed9b7c3 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -14,7 +14,7 @@ import { } from 'src/dtos/audit.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetPathType, PersonPathType, UserPathType } from 'src/entities/move.entity'; -import { DatabaseAction, Permission } from 'src/enum'; +import { AssetFileType, DatabaseAction, Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; @@ -24,6 +24,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { getAssetFiles } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; @Injectable() @@ -97,12 +98,12 @@ export class AuditService { } case AssetPathType.PREVIEW: { - await this.assetRepository.update({ id, previewPath: pathValue }); + await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.PREVIEW, path: pathValue }); break; } case AssetPathType.THUMBNAIL: { - await this.assetRepository.update({ id, thumbnailPath: pathValue }); + await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.THUMBNAIL, path: pathValue }); break; } @@ -155,7 +156,7 @@ export class AuditService { } } - const track = (filename: string | null) => { + const track = (filename: string | null | undefined) => { if (!filename) { return; } @@ -175,8 +176,9 @@ export class AuditService { const orphans: FileReportItemDto[] = []; for await (const assets of pagination) { assetCount += assets.length; - for (const { id, originalPath, previewPath, encodedVideoPath, thumbnailPath, isExternal, checksum } of assets) { - for (const file of [originalPath, previewPath, encodedVideoPath, thumbnailPath]) { + for (const { id, files, originalPath, encodedVideoPath, isExternal, checksum } of assets) { + const { previewFile, thumbnailFile } = getAssetFiles(files); + for (const file of [originalPath, previewFile?.path, encodedVideoPath, thumbnailFile?.path]) { track(file); } @@ -192,11 +194,11 @@ export class AuditService { ) { orphans.push({ ...entity, pathType: AssetPathType.ORIGINAL, pathValue: originalPath }); } - if (previewPath && !hasFile(thumbFiles, previewPath)) { - orphans.push({ ...entity, pathType: AssetPathType.PREVIEW, pathValue: previewPath }); + if (previewFile && !hasFile(thumbFiles, previewFile.path)) { + orphans.push({ ...entity, pathType: AssetPathType.PREVIEW, pathValue: previewFile.path }); } - if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) { - orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: thumbnailPath }); + if (thumbnailFile && !hasFile(thumbFiles, thumbnailFile.path)) { + orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: thumbnailFile.path }); } if (encodedVideoPath && !hasFile(videoFiles, encodedVideoPath)) { orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: encodedVideoPath }); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 70852a5381..35a1a7325b 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -17,6 +17,7 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetDuplicateResult, ISearchRepository } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getAssetFiles } from 'src/utils/asset.util'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @@ -69,7 +70,7 @@ export class DuplicateService { return JobStatus.SKIPPED; } - const asset = await this.assetRepository.getById(id, { smartSearch: true }); + const asset = await this.assetRepository.getById(id, { files: true, smartSearch: true }); if (!asset) { this.logger.error(`Asset ${id} not found`); return JobStatus.FAILED; @@ -80,7 +81,8 @@ export class DuplicateService { return JobStatus.SKIPPED; } - if (!asset.previewPath) { + const { previewFile } = getAssetFiles(asset.files); + if (!previewFile) { this.logger.warn(`Asset ${id} is missing preview image`); return JobStatus.FAILED; } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index d9d5948cea..634cd790eb 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -9,7 +9,7 @@ import { VideoCodec, } from 'src/config'; import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetType } from 'src/enum'; +import { AssetFileType, AssetType } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -298,18 +298,20 @@ describe(MediaService.name, () => { colorspace: Colorspace.SRGB, processInvalidImages: false, }); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', previewPath }); + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: previewPath, + }); }); it('should delete previous preview if different path', async () => { - const previousPreviewPath = assetStub.image.previewPath; - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGeneratePreview({ id: assetStub.image.id }); - expect(storageMock.unlink).toHaveBeenCalledWith(previousPreviewPath); + expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); }); it('should generate a P3 thumbnail for a wide gamut image', async () => { @@ -330,9 +332,10 @@ describe(MediaService.name, () => { processInvalidImages: false, }, ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: 'asset-id', - previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', }); }); @@ -357,9 +360,10 @@ describe(MediaService.name, () => { twoPass: false, }, ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: 'asset-id', - previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', }); }); @@ -384,9 +388,10 @@ describe(MediaService.name, () => { twoPass: false, }, ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: 'asset-id', - previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', }); }); @@ -472,19 +477,21 @@ describe(MediaService.name, () => { colorspace: Colorspace.SRGB, processInvalidImages: false, }); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbnailPath }); + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: thumbnailPath, + }); }, ); it('should delete previous thumbnail if different path', async () => { - const previousThumbnailPath = assetStub.image.thumbnailPath; - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - expect(storageMock.unlink).toHaveBeenCalledWith(previousThumbnailPath); + expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); }); }); @@ -504,9 +511,10 @@ describe(MediaService.name, () => { processInvalidImages: false, }, ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: 'asset-id', - thumbnailPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index ff77cbb34e..b48d00a7a8 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -15,7 +15,7 @@ import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/entities/move.entity'; -import { AssetType } from 'src/enum'; +import { AssetFileType, AssetType } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { @@ -34,6 +34,7 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getAssetFiles } from 'src/utils/asset.util'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; @@ -72,7 +73,11 @@ export class MediaService { async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination, { isVisible: true, withDeleted: true, withArchived: true }) + ? this.assetRepository.getAll(pagination, { + isVisible: true, + withDeleted: true, + withArchived: true, + }) : this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL); }); @@ -80,13 +85,17 @@ export class MediaService { const jobs: JobItem[] = []; for (const asset of assets) { - if (!asset.previewPath || force) { + const { previewFile, thumbnailFile } = getAssetFiles(asset.files); + + if (!previewFile || force) { jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id: asset.id } }); continue; } - if (!asset.thumbnailPath) { + + if (!thumbnailFile) { jobs.push({ name: JobName.GENERATE_THUMBNAIL, data: { id: asset.id } }); } + if (!asset.thumbhash) { jobs.push({ name: JobName.GENERATE_THUMBHASH, data: { id: asset.id } }); } @@ -152,7 +161,7 @@ export class MediaService { async handleAssetMigration({ id }: IEntityJob): Promise { const { image } = await this.configCore.getConfig({ withCache: true }); - const [asset] = await this.assetRepository.getByIds([id]); + const [asset] = await this.assetRepository.getByIds([id], { files: true }); if (!asset) { return JobStatus.FAILED; } @@ -182,12 +191,14 @@ export class MediaService { return JobStatus.SKIPPED; } - if (asset.previewPath && asset.previewPath !== previewPath) { + const { previewFile } = getAssetFiles(asset.files); + if (previewFile && previewFile.path !== previewPath) { this.logger.debug(`Deleting old preview for asset ${asset.id}`); - await this.storageRepository.unlink(asset.previewPath); + await this.storageRepository.unlink(previewFile.path); } - await this.assetRepository.update({ id: asset.id, previewPath }); + await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.PREVIEW, path: previewPath }); + await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date() }); return JobStatus.SUCCESS; @@ -253,7 +264,7 @@ export class MediaService { async handleGenerateThumbnail({ id }: IEntityJob): Promise { const [{ image }, [asset]] = await Promise.all([ this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true }), + this.assetRepository.getByIds([id], { exifInfo: true, files: true }), ]); if (!asset) { return JobStatus.FAILED; @@ -268,19 +279,21 @@ export class MediaService { return JobStatus.SKIPPED; } - if (asset.thumbnailPath && asset.thumbnailPath !== thumbnailPath) { + const { thumbnailFile } = getAssetFiles(asset.files); + if (thumbnailFile && thumbnailFile.path !== thumbnailPath) { this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); - await this.storageRepository.unlink(asset.thumbnailPath); + await this.storageRepository.unlink(thumbnailFile.path); } - await this.assetRepository.update({ id: asset.id, thumbnailPath }); + await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: thumbnailPath }); + await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); await this.assetRepository.upsertJobStatus({ assetId: asset.id, thumbnailAt: new Date() }); return JobStatus.SUCCESS; } async handleGenerateThumbhash({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id]); + const [asset] = await this.assetRepository.getByIds([id], { files: true }); if (!asset) { return JobStatus.FAILED; } @@ -289,11 +302,12 @@ export class MediaService { return JobStatus.SKIPPED; } - if (!asset.previewPath) { + const { previewFile } = getAssetFiles(asset.files); + if (!previewFile) { return JobStatus.FAILED; } - const thumbhash = await this.mediaRepository.generateThumbhash(asset.previewPath); + const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path); await this.assetRepository.update({ id: asset.id, thumbhash }); return JobStatus.SUCCESS; diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 74d2a12127..bcce902e91 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -1,6 +1,7 @@ import { defaults, SystemConfig } from 'src/config'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; -import { UserMetadataKey } from 'src/enum'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; +import { AssetFileType, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -333,7 +334,9 @@ describe(NotificationService.name, () => { notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId); + expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + files: true, + }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ @@ -358,10 +361,15 @@ describe(NotificationService.name, () => { }); systemMock.get.mockResolvedValue({ server: {} }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - assetMock.getById.mockResolvedValue({ ...assetStub.image, thumbnailPath: 'path-to-thumb.jpg' }); + assetMock.getById.mockResolvedValue({ + ...assetStub.image, + files: [{ assetId: 'asset-id', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' } as AssetFileEntity], + }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId); + expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + files: true, + }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ @@ -389,7 +397,9 @@ describe(NotificationService.name, () => { assetMock.getById.mockResolvedValue(assetStub.image); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId); + expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + files: true, + }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 80abc4ca98..31701013b7 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -21,6 +21,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailImageAttachment, EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { getAssetFiles } from 'src/utils/asset.util'; import { getFilenameExtension } from 'src/utils/file'; import { getPreferences } from 'src/utils/preferences'; @@ -268,14 +269,15 @@ export class NotificationService { return; } - const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId); - if (!albumThumbnail?.thumbnailPath) { + const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId, { files: true }); + const { thumbnailFile } = getAssetFiles(albumThumbnail?.files); + if (!thumbnailFile) { return; } return { - filename: `album-thumbnail${getFilenameExtension(albumThumbnail.thumbnailPath)}`, - path: albumThumbnail.thumbnailPath, + filename: `album-thumbnail${getFilenameExtension(thumbnailFile.path)}`, + path: thumbnailFile.path, cid: 'album-thumbnail', }; } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 70e043cc7f..f8608243ae 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -716,7 +716,7 @@ describe(PersonService.name, () => { await sut.handleDetectFaces({ id: assetStub.image.id }); expect(machineLearningMock.detectFaces).toHaveBeenCalledWith( 'http://immich-machine-learning:3003', - assetStub.image.previewPath, + '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), ); expect(personMock.createFaces).not.toHaveBeenCalled(); @@ -946,7 +946,7 @@ describe(PersonService.name, () => { await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - expect(assetMock.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true }); + expect(assetMock.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true, files: true }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, @@ -1032,7 +1032,7 @@ describe(PersonService.name, () => { await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.video.previewPath, + '/uploads/user-id/thumbs/path.jpg', 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { format: 'jpeg', diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 6d536f4bf8..3fc34d8b15 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -50,6 +50,7 @@ import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interf import { ISearchRepository } from 'src/interfaces/search.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getAssetFiles } from 'src/utils/asset.util'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { isFacialRecognitionEnabled } from 'src/utils/misc'; @@ -333,9 +334,11 @@ export class PersonService { faces: { person: false, }, + files: true, }; const [asset] = await this.assetRepository.getByIds([id], relations); - if (!asset || !asset.previewPath || asset.faces?.length > 0) { + const { previewFile } = getAssetFiles(asset.files); + if (!asset || !previewFile || asset.faces?.length > 0) { return JobStatus.FAILED; } @@ -349,11 +352,11 @@ export class PersonService { const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces( machineLearning.url, - asset.previewPath, + previewFile.path, machineLearning.facialRecognition, ); - this.logger.debug(`${faces.length} faces detected in ${asset.previewPath}`); + this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`); if (faces.length > 0) { await this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); @@ -549,7 +552,10 @@ export class PersonService { imageHeight: oldHeight, } = face; - const asset = await this.assetRepository.getById(assetId, { exifInfo: true }); + const asset = await this.assetRepository.getById(assetId, { + exifInfo: true, + files: true, + }); if (!asset) { this.logger.error(`Could not generate person thumbnail: asset ${assetId} does not exist`); return JobStatus.FAILED; @@ -646,7 +652,8 @@ export class PersonService { throw new Error(`Asset ${asset.id} dimensions are unknown`); } - if (!asset.previewPath) { + const { previewFile } = getAssetFiles(asset.files); + if (!previewFile) { throw new Error(`Asset ${asset.id} has no preview path`); } @@ -659,8 +666,8 @@ export class PersonService { return { width, height, inputPath: asset.originalPath }; } - const { width, height } = await this.mediaRepository.getImageDimensions(asset.previewPath); - return { width, height, inputPath: asset.previewPath }; + const { width, height } = await this.mediaRepository.getImageDimensions(previewFile.path); + return { width, height, inputPath: previewFile.path }; } private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions { diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 278e06d287..97d22da9b8 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -318,7 +318,7 @@ describe(SmartInfoService.name, () => { expect(machineMock.encodeImage).toHaveBeenCalledWith( 'http://immich-machine-learning:3003', - assetStub.image.previewPath, + '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]); diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 883f320abf..d57b5fb54f 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -18,6 +18,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getAssetFiles } from 'src/utils/asset.util'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @@ -135,7 +136,7 @@ export class SmartInfoService { return JobStatus.SKIPPED; } - const [asset] = await this.assetRepository.getByIds([id]); + const [asset] = await this.assetRepository.getByIds([id], { files: true }); if (!asset) { return JobStatus.FAILED; } @@ -144,13 +145,14 @@ export class SmartInfoService { return JobStatus.SKIPPED; } - if (!asset.previewPath) { + const { previewFile } = getAssetFiles(asset.files); + if (!previewFile) { return JobStatus.FAILED; } const embedding = await this.machineLearning.encodeImage( machineLearning.url, - asset.previewPath, + previewFile.path, machineLearning.clip, ); diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index aa77a0b144..31f708611d 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,7 +1,8 @@ import { AccessCore } from 'src/cores/access.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { Permission } from 'src/enum'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; +import { AssetFileType, Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; @@ -11,6 +12,15 @@ export interface IBulkAsset { removeAssetIds: (id: string, assetIds: string[]) => Promise; } +const getFileByType = (files: AssetFileEntity[] | undefined, type: AssetFileType) => { + return (files || []).find((file) => file.type === type); +}; + +export const getAssetFiles = (files?: AssetFileEntity[]) => ({ + previewFile: getFileByType(files, AssetFileType.PREVIEW), + thumbnailFile: getFileByType(files, AssetFileType.THUMBNAIL), +}); + export const addAssets = async ( auth: AuthDto, repositories: { accessRepository: IAccessRepository; repository: IBulkAsset }, diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 21a40ffcc8..f3232eb78b 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -71,7 +71,7 @@ export function searchAssetBuilder( builder.andWhere(`${builder.alias}.ownerId IN (:...userIds)`, { userIds: options.userIds }); } - const path = _.pick(options, ['encodedVideoPath', 'originalPath', 'previewPath', 'thumbnailPath']); + const path = _.pick(options, ['encodedVideoPath', 'originalPath']); builder.andWhere(_.omitBy(path, _.isUndefined)); if (options.originalFileName) { diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 23df5e4f56..b8c7e06d82 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -1,12 +1,33 @@ +import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { StackEntity } from 'src/entities/stack.entity'; -import { AssetType } from 'src/enum'; +import { AssetFileType, AssetType } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { libraryStub } from 'test/fixtures/library.stub'; import { userStub } from 'test/fixtures/user.stub'; +const previewFile: AssetFileEntity = { + id: 'file-1', + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: '/uploads/user-id/thumbs/path.jpg', + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), +}; + +const thumbnailFile: AssetFileEntity = { + id: 'file-2', + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: '/uploads/user-id/webp/path.ext', + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), +}; + +const files: AssetFileEntity[] = [previewFile, thumbnailFile]; + export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => { return { id: stackId, @@ -29,10 +50,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: 'upload/library/IMG_123.jpg', - previewPath: null, + files: [thumbnailFile], checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -63,10 +83,10 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: 'upload/library/IMG_456.jpg', - previewPath: '/uploads/user-id/thumbs/path.ext', + + files: [previewFile], checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: null, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -101,10 +121,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', + files, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', thumbhash: null, encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -136,10 +155,9 @@ export const assetStub = { ownerId: 'admin-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/admin-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), + files, type: AssetType.IMAGE, - thumbnailPath: '/uploads/admin-id/webp/path.ext', thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -181,10 +199,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', + files, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -221,10 +238,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -261,10 +277,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -301,10 +316,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/data/user1/photo.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('path hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -341,10 +355,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -379,10 +392,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/data/user1/photo.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('path hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -419,10 +431,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -457,10 +468,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', + files, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2015-02-23T05:06:29.716Z'), @@ -496,10 +506,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.VIDEO, - thumbnailPath: null, + files: [previewFile], thumbhash: null, encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -548,8 +557,22 @@ export const assetStub = { isVisible: false, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - previewPath: '/uploads/user-id/thumbs/path.ext', - thumbnailPath: '/uploads/user-id/webp/path.ext', + files: [ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: '/uploads/user-id/thumbs/path.ext', + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: '/uploads/user-id/webp/path.ext', + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + }, + ], exifInfo: { fileSizeInByte: 100_000, timeZone: `America/New_York`, @@ -612,10 +635,9 @@ export const assetStub = { deviceId: 'device-id', checksum: Buffer.from('file hash', 'utf8'), originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', sidecarPath: null, type: AssetType.IMAGE, - thumbnailPath: null, + files: [previewFile], thumbhash: null, encodedVideoPath: null, createdAt: new Date('2023-02-22T05:06:29.716Z'), @@ -653,11 +675,10 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', thumbhash: null, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: null, + files: [previewFile], encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -687,11 +708,10 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', thumbhash: null, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: null, + files: [previewFile], encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -722,11 +742,10 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', thumbhash: null, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: null, + files: [previewFile], encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -758,10 +777,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.VIDEO, - thumbnailPath: null, + files: [previewFile], thumbhash: null, encodedVideoPath: '/encoded/video/path.mp4', createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -794,10 +812,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/data/user1/photo.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -833,10 +850,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/data/user1/photo.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -872,10 +888,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.dng', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -911,10 +926,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -952,10 +966,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 8a5cc17d4f..9ea252b5f7 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -196,7 +196,6 @@ export const sharedLinkStub = { deviceId: 'device_id_1', type: AssetType.VIDEO, originalPath: 'fake_path/jpeg', - previewPath: '', checksum: Buffer.from('file hash', 'utf8'), fileModifiedAt: today, fileCreatedAt: today, @@ -213,7 +212,7 @@ export const sharedLinkStub = { objects: ['a', 'b', 'c'], asset: null as any, }, - thumbnailPath: '', + files: [], thumbhash: null, encodedVideoPath: '', duration: null, diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index f1091c041f..9320639b93 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -42,5 +42,6 @@ export const newAssetRepositoryMock = (): Mocked => { getAllForUserFullSync: vitest.fn(), getChangedDeltaSync: vitest.fn(), getDuplicates: vitest.fn(), + upsertFile: vitest.fn(), }; }; From 1d559431ba2738287ecb37d135cff425ab78f8b2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 Aug 2024 23:35:17 -0400 Subject: [PATCH 132/723] chore(deps): update grafana/grafana docker tag to v11.1.4 (#11912) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index fd4ed4f1c9..2fec915a42 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -91,7 +91,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:11.1.3-ubuntu@sha256:e10453733015f31103cb530425f32c994816b50102886fa885dafea2c50a711c + image: grafana/grafana:11.1.4-ubuntu@sha256:8e74fb7eed4d59fb5595acd0576c21411167f6b6401426ae29f2e8f9f71b68f6 volumes: - grafana-data:/var/lib/grafana From 2fba9f9547a00e90d7ba40f86afbae7973cab0ee Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 20 Aug 2024 00:30:28 -0400 Subject: [PATCH 133/723] chore(deps): update dependency @types/node to ^20.14.15 (#11920) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 21 ++++++++-------- cli/package.json | 2 +- e2e/package-lock.json | 23 ++++++++--------- e2e/package.json | 2 +- open-api/typescript-sdk/package-lock.json | 19 +++++++------- open-api/typescript-sdk/package.json | 2 +- server/package-lock.json | 30 +++++++++++------------ server/package.json | 2 +- 8 files changed, 52 insertions(+), 49 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index cdba2036c4..6044069672 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -24,7 +24,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.14", + "@types/node": "^20.14.15", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -59,7 +59,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.14", + "@types/node": "^20.14.15", "typescript": "^5.3.3" } }, @@ -1269,13 +1269,13 @@ } }, "node_modules/@types/node": { - "version": "20.14.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", - "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", + "version": "20.16.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.1.tgz", + "integrity": "sha512-zJDo7wEadFtSyNz5QITDfRcrhqDvQI1xQNQ0VoizPjM/dVAODqqIUWbJPkvsxmTI0MYRGRikcdjMPhOssnPejQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/normalize-package-data": { @@ -4151,10 +4151,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/update-browserslist-db": { "version": "1.0.13", diff --git a/cli/package.json b/cli/package.json index c3f2f708e2..ddd6730887 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.14", + "@types/node": "^20.14.15", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 855cd34bba..d265768764 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.14.14", + "@types/node": "^20.14.15", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -64,7 +64,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.14", + "@types/node": "^20.14.15", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -99,7 +99,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.14", + "@types/node": "^20.14.15", "typescript": "^5.3.3" } }, @@ -1516,13 +1516,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.14.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", - "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", + "version": "20.16.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.1.tgz", + "integrity": "sha512-zJDo7wEadFtSyNz5QITDfRcrhqDvQI1xQNQ0VoizPjM/dVAODqqIUWbJPkvsxmTI0MYRGRikcdjMPhOssnPejQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/normalize-package-data": { @@ -6339,10 +6339,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", diff --git a/e2e/package.json b/e2e/package.json index bf393e071a..1c19526e83 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.14.14", + "@types/node": "^20.14.15", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 53ef27fd29..89322e1e07 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -12,7 +12,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.14", + "@types/node": "^20.14.15", "typescript": "^5.3.3" } }, @@ -22,13 +22,13 @@ "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" }, "node_modules/@types/node": { - "version": "20.14.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", - "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", + "version": "20.16.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.1.tgz", + "integrity": "sha512-zJDo7wEadFtSyNz5QITDfRcrhqDvQI1xQNQ0VoizPjM/dVAODqqIUWbJPkvsxmTI0MYRGRikcdjMPhOssnPejQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/typescript": { @@ -46,10 +46,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" } } } diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index bbf7c962a0..6f54670789 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.14", + "@types/node": "^20.14.15", "typescript": "^5.3.3" }, "repository": { diff --git a/server/package-lock.json b/server/package-lock.json index 05d5fcac25..b5cabd49e3 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -83,7 +83,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.14.14", + "@types/node": "^20.14.15", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/semver": "^7.5.8", @@ -6014,11 +6014,11 @@ } }, "node_modules/@types/node": { - "version": "20.14.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", - "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", + "version": "20.16.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.1.tgz", + "integrity": "sha512-zJDo7wEadFtSyNz5QITDfRcrhqDvQI1xQNQ0VoizPjM/dVAODqqIUWbJPkvsxmTI0MYRGRikcdjMPhOssnPejQ==", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/nodemailer": { @@ -15959,9 +15959,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "node_modules/universalify": { "version": "2.0.0", @@ -20310,11 +20310,11 @@ } }, "@types/node": { - "version": "20.14.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", - "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", + "version": "20.16.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.1.tgz", + "integrity": "sha512-zJDo7wEadFtSyNz5QITDfRcrhqDvQI1xQNQ0VoizPjM/dVAODqqIUWbJPkvsxmTI0MYRGRikcdjMPhOssnPejQ==", "requires": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "@types/nodemailer": { @@ -27338,9 +27338,9 @@ } }, "undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "universalify": { "version": "2.0.0", diff --git a/server/package.json b/server/package.json index 97ca1ac69a..d918582a58 100644 --- a/server/package.json +++ b/server/package.json @@ -109,7 +109,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.14.14", + "@types/node": "^20.14.15", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/semver": "^7.5.8", From 8d89eba3a949448622dd6894a86c78b3745f055b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 20 Aug 2024 04:39:57 +0000 Subject: [PATCH 134/723] fix(deps): update dependency exiftool-vendored to v28.2.1 (#11934) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- e2e/package-lock.json | 8 ++++---- server/package-lock.json | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/e2e/package-lock.json b/e2e/package-lock.json index d265768764..bc08cb0f92 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -3176,9 +3176,9 @@ } }, "node_modules/exiftool-vendored": { - "version": "28.2.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.0.tgz", - "integrity": "sha512-s2k92EB8LSeYjXv4agtpANeH8y1CsEThYqMm7AF1jP64PyFb40AoD0RGf69j28G6RqXkT5JGl4Xwk9kOy3IkjQ==", + "version": "28.2.1", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.1.tgz", + "integrity": "sha512-D3YsKErr3BbjKeJzUVsv6CVZ+SQNgAJKPFWVLXu0CBtr24FNuE3CZBXWKWysGu0MjzeDCNwQrQI5+bXUFeiYVA==", "dev": true, "license": "MIT", "dependencies": { @@ -3186,7 +3186,7 @@ "@types/luxon": "^3.4.2", "batch-cluster": "^13.0.0", "he": "^1.2.0", - "luxon": "^3.4.4" + "luxon": "^3.5.0" }, "optionalDependencies": { "exiftool-vendored.exe": "12.91.0", diff --git a/server/package-lock.json b/server/package-lock.json index b5cabd49e3..05c1469d1e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9239,15 +9239,15 @@ } }, "node_modules/exiftool-vendored": { - "version": "28.2.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.0.tgz", - "integrity": "sha512-s2k92EB8LSeYjXv4agtpANeH8y1CsEThYqMm7AF1jP64PyFb40AoD0RGf69j28G6RqXkT5JGl4Xwk9kOy3IkjQ==", + "version": "28.2.1", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.1.tgz", + "integrity": "sha512-D3YsKErr3BbjKeJzUVsv6CVZ+SQNgAJKPFWVLXu0CBtr24FNuE3CZBXWKWysGu0MjzeDCNwQrQI5+bXUFeiYVA==", "dependencies": { "@photostructure/tz-lookup": "^10.0.0", "@types/luxon": "^3.4.2", "batch-cluster": "^13.0.0", "he": "^1.2.0", - "luxon": "^3.4.4" + "luxon": "^3.5.0" }, "optionalDependencies": { "exiftool-vendored.exe": "12.91.0", @@ -22698,9 +22698,9 @@ } }, "exiftool-vendored": { - "version": "28.2.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.0.tgz", - "integrity": "sha512-s2k92EB8LSeYjXv4agtpANeH8y1CsEThYqMm7AF1jP64PyFb40AoD0RGf69j28G6RqXkT5JGl4Xwk9kOy3IkjQ==", + "version": "28.2.1", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.1.tgz", + "integrity": "sha512-D3YsKErr3BbjKeJzUVsv6CVZ+SQNgAJKPFWVLXu0CBtr24FNuE3CZBXWKWysGu0MjzeDCNwQrQI5+bXUFeiYVA==", "requires": { "@photostructure/tz-lookup": "^10.0.0", "@types/luxon": "^3.4.2", @@ -22708,7 +22708,7 @@ "exiftool-vendored.exe": "12.91.0", "exiftool-vendored.pl": "12.91.0", "he": "^1.2.0", - "luxon": "^3.4.4" + "luxon": "^3.5.0" } }, "exiftool-vendored.exe": { From b60fa7784688f4c0bb01ba915b40a13fa46f2190 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 20 Aug 2024 05:33:43 -0400 Subject: [PATCH 135/723] fix: update renovate labels (#11931) --- renovate.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/renovate.json b/renovate.json index 6f5424023b..ccfb75b19c 100644 --- a/renovate.json +++ b/renovate.json @@ -79,7 +79,11 @@ "schedule": "on tuesday" } ], - "ignorePaths": ["mobile/openapi/pubspec.yaml", "mobile/ios", "mobile/android"], + "ignorePaths": [ + "mobile/openapi/pubspec.yaml", + "mobile/ios", + "mobile/android" + ], "ignoreDeps": ["http", "intl"], - "labels": ["dependencies"] + "labels": ["dependencies", "changelog:skip"] } From c7801eae7e2b95c62dceeeabf8ad03c11af3efeb Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 20 Aug 2024 07:49:35 -0400 Subject: [PATCH 136/723] fix: random e2e test (#11932) --- e2e/src/api/specs/asset.e2e-spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 5bd52b437e..8444aea2ba 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -363,6 +363,8 @@ describe('/asset', () => { utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); + + await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration'); }); it('should require authentication', async () => { From 8285803c9560077556bf724854945ae82c7caaf9 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 20 Aug 2024 07:49:56 -0400 Subject: [PATCH 137/723] refactor: access core (#11930) --- server/src/cores/access.core.ts | 312 ------------------ server/src/services/activity.service.ts | 18 +- server/src/services/album.service.ts | 36 +- server/src/services/asset-media.service.ts | 29 +- server/src/services/asset.service.ts | 16 +- server/src/services/audit.service.ts | 9 +- server/src/services/download.service.ts | 15 +- server/src/services/memory.service.ts | 30 +- server/src/services/partner.service.ts | 11 +- server/src/services/person.service.ts | 37 ++- server/src/services/session.service.ts | 9 +- server/src/services/shared-link.service.ts | 16 +- server/src/services/stack.service.ts | 20 +- server/src/services/sync.service.ts | 14 +- server/src/services/timeline.service.ts | 16 +- server/src/services/trash.service.ts | 12 +- server/src/utils/access.ts | 273 ++++++++++++++- server/src/utils/asset.util.ts | 31 +- .../repositories/access.repository.mock.ts | 7 +- 19 files changed, 415 insertions(+), 496 deletions(-) delete mode 100644 server/src/cores/access.core.ts diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts deleted file mode 100644 index f0050b3947..0000000000 --- a/server/src/cores/access.core.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { BadRequestException, UnauthorizedException } from '@nestjs/common'; -import { AuthDto } from 'src/dtos/auth.dto'; -import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { AlbumUserRole, Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { setDifference, setIsEqual, setUnion } from 'src/utils/set'; - -let instance: AccessCore | null; - -export class AccessCore { - private constructor(private repository: IAccessRepository) {} - - static create(repository: IAccessRepository) { - if (!instance) { - instance = new AccessCore(repository); - } - - return instance; - } - - static reset() { - instance = null; - } - - requireUploadAccess(auth: AuthDto | null): AuthDto { - if (!auth || (auth.sharedLink && !auth.sharedLink.allowUpload)) { - throw new UnauthorizedException(); - } - return auth; - } - - /** - * Check if user has access to all ids, for the given permission. - * Throws error if user does not have access to any of the ids. - */ - async requirePermission(auth: AuthDto, permission: Permission, ids: string[] | string) { - ids = Array.isArray(ids) ? ids : [ids]; - const allowedIds = await this.checkAccess(auth, permission, ids); - if (!setIsEqual(new Set(ids), allowedIds)) { - throw new BadRequestException(`Not found or no ${permission} access`); - } - } - - /** - * Return ids that user has access to, for the given permission. - * Check is done for each id, and only allowed ids are returned. - * - * @returns Set - */ - async checkAccess(auth: AuthDto, permission: Permission, ids: Set | string[]): Promise> { - const idSet = Array.isArray(ids) ? new Set(ids) : ids; - if (idSet.size === 0) { - return new Set(); - } - - if (auth.sharedLink) { - return this.checkAccessSharedLink(auth.sharedLink, permission, idSet); - } - - return this.checkAccessOther(auth, permission, idSet); - } - - private async checkAccessSharedLink( - sharedLink: SharedLinkEntity, - permission: Permission, - ids: Set, - ): Promise> { - const sharedLinkId = sharedLink.id; - - switch (permission) { - case Permission.ASSET_READ: { - return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids); - } - - case Permission.ASSET_VIEW: { - return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids); - } - - case Permission.ASSET_DOWNLOAD: { - return sharedLink.allowDownload - ? await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids) - : new Set(); - } - - case Permission.ASSET_UPLOAD: { - return sharedLink.allowUpload ? ids : new Set(); - } - - case Permission.ASSET_SHARE: { - // TODO: fix this to not use sharedLink.userId for access control - return await this.repository.asset.checkOwnerAccess(sharedLink.userId, ids); - } - - case Permission.ALBUM_READ: { - return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids); - } - - case Permission.ALBUM_DOWNLOAD: { - return sharedLink.allowDownload - ? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids) - : new Set(); - } - - case Permission.ALBUM_ADD_ASSET: { - return sharedLink.allowUpload - ? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids) - : new Set(); - } - - default: { - return new Set(); - } - } - } - - private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set): Promise> { - switch (permission) { - // uses album id - case Permission.ACTIVITY_CREATE: { - return await this.repository.activity.checkCreateAccess(auth.user.id, ids); - } - - // uses activity id - case Permission.ACTIVITY_DELETE: { - const isOwner = await this.repository.activity.checkOwnerAccess(auth.user.id, ids); - const isAlbumOwner = await this.repository.activity.checkAlbumOwnerAccess( - auth.user.id, - setDifference(ids, isOwner), - ); - return setUnion(isOwner, isAlbumOwner); - } - - case Permission.ASSET_READ: { - const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); - const isPartner = await this.repository.asset.checkPartnerAccess( - auth.user.id, - setDifference(ids, isOwner, isAlbum), - ); - return setUnion(isOwner, isAlbum, isPartner); - } - - case Permission.ASSET_SHARE: { - const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - const isPartner = await this.repository.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); - return setUnion(isOwner, isPartner); - } - - case Permission.ASSET_VIEW: { - const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); - const isPartner = await this.repository.asset.checkPartnerAccess( - auth.user.id, - setDifference(ids, isOwner, isAlbum), - ); - return setUnion(isOwner, isAlbum, isPartner); - } - - case Permission.ASSET_DOWNLOAD: { - const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); - const isPartner = await this.repository.asset.checkPartnerAccess( - auth.user.id, - setDifference(ids, isOwner, isAlbum), - ); - return setUnion(isOwner, isAlbum, isPartner); - } - - case Permission.ASSET_UPDATE: { - return await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ASSET_DELETE: { - return await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ASSET_RESTORE: { - return await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ALBUM_READ: { - const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.VIEWER, - ); - return setUnion(isOwner, isShared); - } - - case Permission.ALBUM_ADD_ASSET: { - const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.EDITOR, - ); - return setUnion(isOwner, isShared); - } - - case Permission.ALBUM_UPDATE: { - return await this.repository.album.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ALBUM_DELETE: { - return await this.repository.album.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ALBUM_SHARE: { - return await this.repository.album.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ALBUM_DOWNLOAD: { - const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.VIEWER, - ); - return setUnion(isOwner, isShared); - } - - case Permission.ALBUM_REMOVE_ASSET: { - const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.EDITOR, - ); - return setUnion(isOwner, isShared); - } - - case Permission.ASSET_UPLOAD: { - return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); - } - - case Permission.ARCHIVE_READ: { - return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); - } - - case Permission.AUTH_DEVICE_DELETE: { - return await this.repository.authDevice.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.TIMELINE_READ: { - const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); - const isPartner = await this.repository.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); - return setUnion(isOwner, isPartner); - } - - case Permission.TIMELINE_DOWNLOAD: { - return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); - } - - case Permission.MEMORY_READ: { - return this.repository.memory.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.MEMORY_UPDATE: { - return this.repository.memory.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.MEMORY_DELETE: { - return this.repository.memory.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.MEMORY_DELETE: { - return this.repository.memory.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_READ: { - return await this.repository.person.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_UPDATE: { - return await this.repository.person.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_MERGE: { - return await this.repository.person.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_CREATE: { - return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_REASSIGN: { - return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids); - } - - case Permission.PARTNER_UPDATE: { - return await this.repository.partner.checkUpdateAccess(auth.user.id, ids); - } - - case Permission.STACK_READ: { - return this.repository.stack.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.STACK_UPDATE: { - return this.repository.stack.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.STACK_DELETE: { - return this.repository.stack.checkOwnerAccess(auth.user.id, ids); - } - - default: { - return new Set(); - } - } - } -} diff --git a/server/src/services/activity.service.ts b/server/src/services/activity.service.ts index c1b2e1b4d0..1e4034de93 100644 --- a/server/src/services/activity.service.ts +++ b/server/src/services/activity.service.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { AccessCore } from 'src/cores/access.core'; import { ActivityCreateDto, ActivityDto, @@ -16,20 +15,17 @@ import { ActivityEntity } from 'src/entities/activity.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IActivityRepository } from 'src/interfaces/activity.interface'; +import { requireAccess } from 'src/utils/access'; @Injectable() export class ActivityService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IActivityRepository) private repository: IActivityRepository, - ) { - this.access = AccessCore.create(accessRepository); - } + ) {} async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_READ, dto.albumId); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); const activities = await this.repository.search({ userId: dto.userId, albumId: dto.albumId, @@ -41,12 +37,12 @@ export class ActivityService { } async getStatistics(auth: AuthDto, dto: ActivityDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_READ, dto.albumId); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); return { comments: await this.repository.getStatistics(dto.assetId, dto.albumId) }; } async create(auth: AuthDto, dto: ActivityCreateDto): Promise> { - await this.access.requirePermission(auth, Permission.ACTIVITY_CREATE, dto.albumId); + await requireAccess(this.access, { auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] }); const common = { userId: auth.user.id, @@ -80,7 +76,7 @@ export class ActivityService { } async delete(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ACTIVITY_DELETE, id); + await requireAccess(this.access, { auth, permission: Permission.ACTIVITY_DELETE, ids: [id] }); await this.repository.delete(id); } } diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 06f2a7a0fb..02dab1a740 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AccessCore } from 'src/cores/access.core'; import { AddUsersDto, AlbumCountResponseDto, @@ -24,21 +23,19 @@ import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfa import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @Injectable() export class AlbumService { - private access: AccessCore; constructor( - @Inject(IAccessRepository) private accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IAlbumUserRepository) private albumUserRepository: IAlbumUserRepository, - ) { - this.access = AccessCore.create(accessRepository); - } + ) {} async getCount(auth: AuthDto): Promise { const [owned, shared, notShared] = await Promise.all([ @@ -102,7 +99,7 @@ export class AlbumService { } async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_READ, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [id] }); await this.albumRepository.updateThumbnails(); const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets; const album = await this.findOrFail(id, { withAssets }); @@ -126,7 +123,11 @@ export class AlbumService { } } - const allowedAssetIdsSet = await this.access.checkAccess(auth, Permission.ASSET_SHARE, new Set(dto.assetIds)); + const allowedAssetIdsSet = await checkAccess(this.access, { + auth, + permission: Permission.ASSET_SHARE, + ids: dto.assetIds || [], + }); const assets = [...allowedAssetIdsSet].map((id) => ({ id }) as AssetEntity); const album = await this.albumRepository.create({ @@ -146,7 +147,7 @@ export class AlbumService { } async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_UPDATE, ids: [id] }); const album = await this.findOrFail(id, { withAssets: true }); @@ -169,17 +170,17 @@ export class AlbumService { } async delete(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_DELETE, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_DELETE, ids: [id] }); await this.albumRepository.delete(id); } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { const album = await this.findOrFail(id, { withAssets: false }); - await this.access.requirePermission(auth, Permission.ALBUM_ADD_ASSET, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] }); const results = await addAssets( auth, - { accessRepository: this.accessRepository, repository: this.albumRepository }, + { access: this.access, bulk: this.albumRepository }, { parentId: id, assetIds: dto.ids }, ); @@ -198,12 +199,12 @@ export class AlbumService { } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_REMOVE_ASSET, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] }); const album = await this.findOrFail(id, { withAssets: false }); const results = await removeAssets( auth, - { accessRepository: this.accessRepository, repository: this.albumRepository }, + { access: this.access, bulk: this.albumRepository }, { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.ALBUM_DELETE }, ); @@ -219,7 +220,7 @@ export class AlbumService { } async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); const album = await this.findOrFail(id, { withAssets: false }); @@ -263,15 +264,14 @@ export class AlbumService { // non-admin can remove themselves if (auth.user.id !== userId) { - await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); } await this.albumUserRepository.delete({ albumId: id, userId }); } async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id); - + await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); } diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index b66b0607b3..9ce2e58d28 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -7,7 +7,6 @@ import { } from '@nestjs/common'; import { extname } from 'node:path'; import sanitize from 'sanitize-filename'; -import { AccessCore } from 'src/cores/access.core'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { AssetBulkUploadCheckResponseDto, @@ -36,6 +35,7 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { requireAccess, requireUploadAccess } from 'src/utils/access'; import { getAssetFiles } from 'src/utils/asset.util'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; @@ -57,10 +57,8 @@ export interface UploadFile { @Injectable() export class AssetMediaService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @@ -69,7 +67,6 @@ export class AssetMediaService { @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(AssetMediaService.name); - this.access = AccessCore.create(accessRepository); } async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise { @@ -86,7 +83,7 @@ export class AssetMediaService { } canUploadFile({ auth, fieldName, file }: UploadRequest): true { - this.access.requireUploadAccess(auth); + requireUploadAccess(auth); const filename = file.originalName; @@ -118,7 +115,7 @@ export class AssetMediaService { } getUploadFilename({ auth, fieldName, file }: UploadRequest): string { - this.access.requireUploadAccess(auth); + requireUploadAccess(auth); const originalExtension = extname(file.originalName); @@ -132,7 +129,7 @@ export class AssetMediaService { } getUploadFolder({ auth, fieldName, file }: UploadRequest): string { - auth = this.access.requireUploadAccess(auth); + auth = requireUploadAccess(auth); let folder = StorageCore.getNestedFolder(StorageFolder.UPLOAD, auth.user.id, file.uuid); if (fieldName === UploadFieldName.PROFILE_DATA) { @@ -151,12 +148,12 @@ export class AssetMediaService { sidecarFile?: UploadFile, ): Promise { try { - await this.access.requirePermission( + await requireAccess(this.access, { auth, - Permission.ASSET_UPLOAD, + permission: Permission.ASSET_UPLOAD, // do not need an id here, but the interface requires it - auth.user.id, - ); + ids: [auth.user.id], + }); this.requireQuota(auth, file.size); @@ -195,7 +192,7 @@ export class AssetMediaService { sidecarFile?: UploadFile, ): Promise { try { - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const asset = (await this.assetRepository.getById(id)) as AssetEntity; this.requireQuota(auth, file.size); @@ -219,7 +216,7 @@ export class AssetMediaService { } async downloadOriginal(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] }); const asset = await this.findOrFail(id); if (!asset) { @@ -234,7 +231,7 @@ export class AssetMediaService { } async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_VIEW, id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_VIEW, ids: [id] }); const asset = await this.findOrFail(id); const size = dto.size ?? AssetMediaSize.THUMBNAIL; @@ -257,7 +254,7 @@ export class AssetMediaService { } async playbackVideo(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ASSET_VIEW, id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_VIEW, ids: [id] }); const asset = await this.findOrFail(id); if (!asset) { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index e9aefce910..bfd3a0c4d2 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Inject } from '@nestjs/common'; import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; -import { AccessCore } from 'src/cores/access.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetResponseDto, @@ -39,15 +38,15 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { requireAccess } from 'src/utils/access'; import { getAssetFiles, getMyPartnerIds } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; export class AssetService { - private access: AccessCore; private configCore: SystemConfigCore; constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @@ -58,7 +57,6 @@ export class AssetService { @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(AssetService.name); - this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } @@ -109,7 +107,7 @@ export class AssetService { } async get(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ASSET_READ, id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [id] }); const asset = await this.assetRepository.getById( id, @@ -158,7 +156,7 @@ export class AssetService { } async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); @@ -182,7 +180,7 @@ export class AssetService { async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise { const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto; - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, ids); + await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids }); for (const id of ids) { await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); @@ -278,7 +276,7 @@ export class AssetService { async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise { const { ids, force } = dto; - await this.access.requirePermission(auth, Permission.ASSET_DELETE, ids); + await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids }); if (force) { await this.jobRepository.queueAll( @@ -294,7 +292,7 @@ export class AssetService { } async run(auth: AuthDto, dto: AssetJobsDto) { - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, dto.assetIds); + await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); const jobs: JobItem[] = []; diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index 734ed9b7c3..72db2b6eb5 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -2,7 +2,6 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { resolve } from 'node:path'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; -import { AccessCore } from 'src/cores/access.core'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { AuditDeletesDto, @@ -24,15 +23,14 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { requireAccess } from 'src/utils/access'; import { getAssetFiles } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; @Injectable() export class AuditService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IPersonRepository) private personRepository: IPersonRepository, @@ -41,7 +39,6 @@ export class AuditService { @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { - this.access = AccessCore.create(accessRepository); this.logger.setContext(AuditService.name); } @@ -52,7 +49,7 @@ export class AuditService { async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise { const userId = dto.userId || auth.user.id; - await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId); + await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [userId] }); const audits = await this.repository.getAfter(dto.after, { userIds: [userId], diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 1ff9e51576..988b859ff8 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { parse } from 'node:path'; -import { AccessCore } from 'src/cores/access.core'; import { StorageCore } from 'src/cores/storage.core'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -11,21 +10,19 @@ import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ImmichReadStream, IStorageRepository } from 'src/interfaces/storage.interface'; +import { requireAccess } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; import { usePagination } from 'src/utils/pagination'; import { getPreferences } from 'src/utils/preferences'; @Injectable() export class DownloadService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { - this.access = AccessCore.create(accessRepository); this.logger.setContext(DownloadService.name); } @@ -76,7 +73,7 @@ export class DownloadService { } async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, dto.assetIds); + await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds }); const zip = this.storageRepository.createZipStream(); const assets = await this.assetRepository.getByIds(dto.assetIds); @@ -119,20 +116,20 @@ export class DownloadService { if (dto.assetIds) { const assetIds = dto.assetIds; - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, assetIds); + await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds }); const assets = await this.assetRepository.getByIds(assetIds, { exifInfo: true }); return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets })); } if (dto.albumId) { const albumId = dto.albumId; - await this.access.requirePermission(auth, Permission.ALBUM_DOWNLOAD, albumId); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] }); return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId)); } if (dto.userId) { const userId = dto.userId; - await this.access.requirePermission(auth, Permission.TIMELINE_DOWNLOAD, userId); + await requireAccess(this.access, { auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] }); return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, userId, { isVisible: true }), ); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index c8c44d04b3..fb1ff49f0b 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AccessCore } from 'src/cores/access.core'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; @@ -7,18 +6,15 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IMemoryRepository } from 'src/interfaces/memory.interface'; +import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @Injectable() export class MemoryService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) private accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IMemoryRepository) private repository: IMemoryRepository, - ) { - this.access = AccessCore.create(accessRepository); - } + ) {} async search(auth: AuthDto) { const memories = await this.repository.search(auth.user.id); @@ -26,7 +22,7 @@ export class MemoryService { } async get(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_READ, id); + await requireAccess(this.access, { auth, permission: Permission.MEMORY_READ, ids: [id] }); const memory = await this.findOrFail(id); return mapMemory(memory); } @@ -35,7 +31,11 @@ export class MemoryService { // TODO validate type/data combination const assetIds = dto.assetIds || []; - const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, assetIds); + const allowedAssetIds = await checkAccess(this.access, { + auth, + permission: Permission.ASSET_SHARE, + ids: assetIds, + }); const memory = await this.repository.create({ ownerId: auth.user.id, type: dto.type, @@ -50,7 +50,7 @@ export class MemoryService { } async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); const memory = await this.repository.update({ id, @@ -63,14 +63,14 @@ export class MemoryService { } async remove(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_DELETE, id); + await requireAccess(this.access, { auth, permission: Permission.MEMORY_DELETE, ids: [id] }); await this.repository.delete(id); } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_READ, id); + await requireAccess(this.access, { auth, permission: Permission.MEMORY_READ, ids: [id] }); - const repos = { accessRepository: this.accessRepository, repository: this.repository }; + const repos = { access: this.access, bulk: this.repository }; const results = await addAssets(auth, repos, { parentId: id, assetIds: dto.ids }); const hasSuccess = results.find(({ success }) => success); @@ -82,9 +82,9 @@ export class MemoryService { } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); - const repos = { accessRepository: this.accessRepository, repository: this.repository }; + const repos = { access: this.access, bulk: this.repository }; const results = await removeAssets(auth, repos, { parentId: id, assetIds: dto.ids, diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index c20d43db5d..4b7cd4c516 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AccessCore } from 'src/cores/access.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto'; import { mapUser } from 'src/dtos/user.dto'; @@ -7,16 +6,14 @@ import { PartnerEntity } from 'src/entities/partner.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IPartnerRepository, PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface'; +import { requireAccess } from 'src/utils/access'; @Injectable() export class PartnerService { - private access: AccessCore; constructor( @Inject(IPartnerRepository) private repository: IPartnerRepository, - @Inject(IAccessRepository) accessRepository: IAccessRepository, - ) { - this.access = AccessCore.create(accessRepository); - } + @Inject(IAccessRepository) private access: IAccessRepository, + ) {} async create(auth: AuthDto, sharedWithId: string): Promise { const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId }; @@ -49,7 +46,7 @@ export class PartnerService { } async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise { - await this.access.requirePermission(auth, Permission.PARTNER_UPDATE, sharedById); + await requireAccess(this.access, { auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] }); const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id }; const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 3fc34d8b15..6f2283b72c 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ImageFormat } from 'src/config'; import { FACE_THUMBNAIL_SIZE } from 'src/constants'; -import { AccessCore } from 'src/cores/access.core'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; @@ -50,6 +49,7 @@ import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interf import { ISearchRepository } from 'src/interfaces/search.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { checkAccess, requireAccess } from 'src/utils/access'; import { getAssetFiles } from 'src/utils/asset.util'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; @@ -59,12 +59,11 @@ import { IsNull } from 'typeorm'; @Injectable() export class PersonService { - private access: AccessCore; private configCore: SystemConfigCore; private storageCore: StorageCore; constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IMachineLearningRepository) private machineLearningRepository: IMachineLearningRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository, @@ -77,7 +76,6 @@ export class PersonService { @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { - this.access = AccessCore.create(accessRepository); this.logger.setContext(PersonService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( @@ -114,7 +112,7 @@ export class PersonService { } async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise { - await this.access.requirePermission(auth, Permission.PERSON_UPDATE, personId); + await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); const person = await this.findOrFail(personId); const result: PersonResponseDto[] = []; const changeFeaturePhoto: string[] = []; @@ -122,7 +120,7 @@ export class PersonService { const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]); for (const face of faces) { - await this.access.requirePermission(auth, Permission.PERSON_CREATE, face.id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_CREATE, ids: [face.id] }); if (person.faceAssetId === null) { changeFeaturePhoto.push(person.id); } @@ -143,9 +141,8 @@ export class PersonService { } async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise { - await this.access.requirePermission(auth, Permission.PERSON_UPDATE, personId); - - await this.access.requirePermission(auth, Permission.PERSON_CREATE, dto.id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); + await requireAccess(this.access, { auth, permission: Permission.PERSON_CREATE, ids: [dto.id] }); const face = await this.repository.getFaceById(dto.id); const person = await this.findOrFail(personId); @@ -161,7 +158,7 @@ export class PersonService { } async getFacesById(auth: AuthDto, dto: FaceDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_READ, dto.id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [dto.id] }); const faces = await this.repository.getFaces(dto.id); return faces.map((asset) => mapFaces(asset, auth)); } @@ -188,17 +185,17 @@ export class PersonService { } async getById(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.PERSON_READ, id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] }); return this.findOrFail(id).then(mapPerson); } async getStatistics(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.PERSON_READ, id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] }); return this.repository.getStatistics(id); } async getThumbnail(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.PERSON_READ, id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] }); const person = await this.repository.getById(id); if (!person || !person.thumbnailPath) { throw new NotFoundException(); @@ -212,7 +209,7 @@ export class PersonService { } async getAssets(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.PERSON_READ, id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] }); const assets = await this.repository.getAssets(id); return assets.map((asset) => mapAsset(asset)); } @@ -227,13 +224,13 @@ export class PersonService { } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { - await this.access.requirePermission(auth, Permission.PERSON_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto; // TODO: set by faceId directly let faceId: string | undefined = undefined; if (assetId) { - await this.access.requirePermission(auth, Permission.ASSET_READ, assetId); + await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [assetId] }); const [face] = await this.repository.getFacesByIds([{ personId: id, assetId }]); if (!face) { throw new BadRequestException('Invalid assetId for feature face'); @@ -587,13 +584,17 @@ export class PersonService { throw new BadRequestException('Cannot merge a person into themselves'); } - await this.access.requirePermission(auth, Permission.PERSON_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); let primaryPerson = await this.findOrFail(id); const primaryName = primaryPerson.name || primaryPerson.id; const results: BulkIdResponseDto[] = []; - const allowedIds = await this.access.checkAccess(auth, Permission.PERSON_MERGE, mergeIds); + const allowedIds = await checkAccess(this.access, { + auth, + permission: Permission.PERSON_MERGE, + ids: mergeIds, + }); for (const mergeId of mergeIds) { const hasAccess = allowedIds.has(mergeId); diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 01cf3a5c09..47abf3c380 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; -import { AccessCore } from 'src/cores/access.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { Permission } from 'src/enum'; @@ -8,18 +7,16 @@ import { IAccessRepository } from 'src/interfaces/access.interface'; import { JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; +import { requireAccess } from 'src/utils/access'; @Injectable() export class SessionService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ISessionRepository) private sessionRepository: ISessionRepository, ) { this.logger.setContext(SessionService.name); - this.access = AccessCore.create(accessRepository); } async handleCleanup() { @@ -47,7 +44,7 @@ export class SessionService { } async delete(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id); + await requireAccess(this.access, { auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] }); await this.sessionRepository.delete(id); } diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 4b6768e028..54c7fdf25b 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; -import { AccessCore } from 'src/cores/access.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; @@ -21,22 +20,21 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { checkAccess, requireAccess } from 'src/utils/access'; import { OpenGraphTags } from 'src/utils/misc'; @Injectable() export class SharedLinkService { - private access: AccessCore; private configCore: SystemConfigCore; constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ISharedLinkRepository) private repository: ISharedLinkRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, ) { this.logger.setContext(SharedLinkService.name); - this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } @@ -69,7 +67,7 @@ export class SharedLinkService { if (!dto.albumId) { throw new BadRequestException('Invalid albumId'); } - await this.access.requirePermission(auth, Permission.ALBUM_SHARE, dto.albumId); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] }); break; } @@ -78,7 +76,7 @@ export class SharedLinkService { throw new BadRequestException('Invalid assetIds'); } - await this.access.requirePermission(auth, Permission.ASSET_SHARE, dto.assetIds); + await requireAccess(this.access, { auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds }); break; } @@ -139,7 +137,11 @@ export class SharedLinkService { const existingAssetIds = new Set(sharedLink.assets.map((asset) => asset.id)); const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId)); - const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, notPresentAssetIds); + const allowedAssetIds = await checkAccess(this.access, { + auth, + permission: Permission.ASSET_SHARE, + ids: notPresentAssetIds, + }); const results: AssetIdsResponseDto[] = []; for (const assetId of dto.assetIds) { diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts index 70234dee56..bebc8517d6 100644 --- a/server/src/services/stack.service.ts +++ b/server/src/services/stack.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AccessCore } from 'src/cores/access.core'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto'; @@ -7,18 +6,15 @@ import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; +import { requireAccess } from 'src/utils/access'; @Injectable() export class StackService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IStackRepository) private stackRepository: IStackRepository, - ) { - this.access = AccessCore.create(accessRepository); - } + ) {} async search(auth: AuthDto, dto: StackSearchDto): Promise { const stacks = await this.stackRepository.search({ @@ -30,7 +26,7 @@ export class StackService { } async create(auth: AuthDto, dto: StackCreateDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, dto.assetIds); + await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds }); @@ -40,13 +36,13 @@ export class StackService { } async get(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.STACK_READ, id); + await requireAccess(this.access, { auth, permission: Permission.STACK_READ, ids: [id] }); const stack = await this.findOrFail(id); return mapStack(stack, { auth }); } async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise { - await this.access.requirePermission(auth, Permission.STACK_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.STACK_UPDATE, ids: [id] }); const stack = await this.findOrFail(id); if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) { throw new BadRequestException('Primary asset must be in the stack'); @@ -60,14 +56,14 @@ export class StackService { } async delete(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.STACK_DELETE, id); + await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: [id] }); await this.stackRepository.delete(id); this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); } async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise { - await this.access.requirePermission(auth, Permission.STACK_DELETE, dto.ids); + await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: dto.ids }); await this.stackRepository.deleteAll(dto.ids); this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 6af43d6ebc..7da3fbd9be 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,7 +1,6 @@ import { Inject } from '@nestjs/common'; import { DateTime } from 'luxon'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; -import { AccessCore } from 'src/cores/access.core'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto'; @@ -10,27 +9,24 @@ import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { requireAccess } from 'src/utils/access'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { setIsEqual } from 'src/utils/set'; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; export class SyncService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IAuditRepository) private auditRepository: IAuditRepository, - ) { - this.access = AccessCore.create(accessRepository); - } + ) {} async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise { // mobile implementation is faster if this is a single id const userId = dto.userId || auth.user.id; - await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId); + await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [userId] }); const assets = await this.assetRepository.getAllForUserFullSync({ ownerId: userId, updatedUntil: dto.updatedUntil, @@ -54,7 +50,7 @@ export class SyncService { return FULL_SYNC; } - await this.access.requirePermission(auth, Permission.TIMELINE_READ, dto.userIds); + await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: dto.userIds }); const limit = 10_000; const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds }); diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 44f1136da1..052565fca9 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Inject } from '@nestjs/common'; -import { AccessCore } from 'src/cores/access.core'; import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; @@ -7,18 +6,15 @@ import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { requireAccess } from 'src/utils/access'; import { getMyPartnerIds } from 'src/utils/asset.util'; export class TimelineService { - private accessCore: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private repository: IAssetRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - ) { - this.accessCore = AccessCore.create(accessRepository); - } + ) {} async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { await this.timeBucketChecks(auth, dto); @@ -60,15 +56,15 @@ export class TimelineService { private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { if (dto.albumId) { - await this.accessCore.requirePermission(auth, Permission.ALBUM_READ, [dto.albumId]); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); } else { dto.userId = dto.userId || auth.user.id; } if (dto.userId) { - await this.accessCore.requirePermission(auth, Permission.TIMELINE_READ, [dto.userId]); + await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] }); if (dto.isArchived !== false) { - await this.accessCore.requirePermission(auth, Permission.ARCHIVE_READ, [dto.userId]); + await requireAccess(this.access, { auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] }); } } diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index 7e2582fd24..f64aef0516 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -1,6 +1,5 @@ import { Inject } from '@nestjs/common'; import { DateTime } from 'luxon'; -import { AccessCore } from 'src/cores/access.core'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { Permission } from 'src/enum'; @@ -8,23 +7,20 @@ import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/interfaces/job.interface'; +import { requireAccess } from 'src/utils/access'; import { usePagination } from 'src/utils/pagination'; export class TrashService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, - ) { - this.access = AccessCore.create(accessRepository); - } + ) {} async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise { const { ids } = dto; - await this.access.requirePermission(auth, Permission.ASSET_RESTORE, ids); + await requireAccess(this.access, { auth, permission: Permission.ASSET_RESTORE, ids }); await this.restoreAndSend(auth, ids); } diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index cd24087d9b..9367b0987e 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -1,5 +1,9 @@ -import { Permission } from 'src/enum'; -import { setIsSuperset } from 'src/utils/set'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { AlbumUserRole, Permission } from 'src/enum'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set'; export type GrantedRequest = { requested: Permission[]; @@ -13,3 +17,268 @@ export const isGranted = ({ requested, current }: GrantedRequest) => { return setIsSuperset(new Set(current), new Set(requested)); }; + +export type AccessRequest = { + auth: AuthDto; + permission: Permission; + ids: Set | string[]; +}; + +type SharedLinkAccessRequest = { sharedLink: SharedLinkEntity; permission: Permission; ids: Set }; +type OtherAccessRequest = { auth: AuthDto; permission: Permission; ids: Set }; + +export const requireUploadAccess = (auth: AuthDto | null): AuthDto => { + if (!auth || (auth.sharedLink && !auth.sharedLink.allowUpload)) { + throw new UnauthorizedException(); + } + return auth; +}; + +export const requireAccess = async (access: IAccessRepository, request: AccessRequest) => { + const allowedIds = await checkAccess(access, request); + if (!setIsEqual(new Set(request.ids), allowedIds)) { + throw new BadRequestException(`Not found or no ${request.permission} access`); + } +}; + +export const checkAccess = async (access: IAccessRepository, { ids, auth, permission }: AccessRequest) => { + const idSet = Array.isArray(ids) ? new Set(ids) : ids; + if (idSet.size === 0) { + return new Set(); + } + + return auth.sharedLink + ? checkSharedLinkAccess(access, { sharedLink: auth.sharedLink, permission, ids: idSet }) + : checkOtherAccess(access, { auth, permission, ids: idSet }); +}; + +const checkSharedLinkAccess = async (access: IAccessRepository, request: SharedLinkAccessRequest) => { + const { sharedLink, permission, ids } = request; + const sharedLinkId = sharedLink.id; + + switch (permission) { + case Permission.ASSET_READ: { + return await access.asset.checkSharedLinkAccess(sharedLinkId, ids); + } + + case Permission.ASSET_VIEW: { + return await access.asset.checkSharedLinkAccess(sharedLinkId, ids); + } + + case Permission.ASSET_DOWNLOAD: { + return sharedLink.allowDownload ? await access.asset.checkSharedLinkAccess(sharedLinkId, ids) : new Set(); + } + + case Permission.ASSET_UPLOAD: { + return sharedLink.allowUpload ? ids : new Set(); + } + + case Permission.ASSET_SHARE: { + // TODO: fix this to not use sharedLink.userId for access control + return await access.asset.checkOwnerAccess(sharedLink.userId, ids); + } + + case Permission.ALBUM_READ: { + return await access.album.checkSharedLinkAccess(sharedLinkId, ids); + } + + case Permission.ALBUM_DOWNLOAD: { + return sharedLink.allowDownload ? await access.album.checkSharedLinkAccess(sharedLinkId, ids) : new Set(); + } + + case Permission.ALBUM_ADD_ASSET: { + return sharedLink.allowUpload ? await access.album.checkSharedLinkAccess(sharedLinkId, ids) : new Set(); + } + + default: { + return new Set(); + } + } +}; + +const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest) => { + const { auth, permission, ids } = request; + + switch (permission) { + // uses album id + case Permission.ACTIVITY_CREATE: { + return await access.activity.checkCreateAccess(auth.user.id, ids); + } + + // uses activity id + case Permission.ACTIVITY_DELETE: { + const isOwner = await access.activity.checkOwnerAccess(auth.user.id, ids); + const isAlbumOwner = await access.activity.checkAlbumOwnerAccess(auth.user.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isAlbumOwner); + } + + case Permission.ASSET_READ: { + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); + const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); + return setUnion(isOwner, isAlbum, isPartner); + } + + case Permission.ASSET_SHARE: { + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isPartner); + } + + case Permission.ASSET_VIEW: { + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); + const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); + return setUnion(isOwner, isAlbum, isPartner); + } + + case Permission.ASSET_DOWNLOAD: { + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); + const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); + return setUnion(isOwner, isAlbum, isPartner); + } + + case Permission.ASSET_UPDATE: { + return await access.asset.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ASSET_DELETE: { + return await access.asset.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ASSET_RESTORE: { + return await access.asset.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ALBUM_READ: { + const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.VIEWER, + ); + return setUnion(isOwner, isShared); + } + + case Permission.ALBUM_ADD_ASSET: { + const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.EDITOR, + ); + return setUnion(isOwner, isShared); + } + + case Permission.ALBUM_UPDATE: { + return await access.album.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ALBUM_DELETE: { + return await access.album.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ALBUM_SHARE: { + return await access.album.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ALBUM_DOWNLOAD: { + const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.VIEWER, + ); + return setUnion(isOwner, isShared); + } + + case Permission.ALBUM_REMOVE_ASSET: { + const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.EDITOR, + ); + return setUnion(isOwner, isShared); + } + + case Permission.ASSET_UPLOAD: { + return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); + } + + case Permission.ARCHIVE_READ: { + return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); + } + + case Permission.AUTH_DEVICE_DELETE: { + return await access.authDevice.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.TIMELINE_READ: { + const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); + const isPartner = await access.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isPartner); + } + + case Permission.TIMELINE_DOWNLOAD: { + return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); + } + + case Permission.MEMORY_READ: { + return access.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.MEMORY_UPDATE: { + return access.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.MEMORY_DELETE: { + return access.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.MEMORY_DELETE: { + return access.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_READ: { + return await access.person.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_UPDATE: { + return await access.person.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_MERGE: { + return await access.person.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_CREATE: { + return access.person.checkFaceOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_REASSIGN: { + return access.person.checkFaceOwnerAccess(auth.user.id, ids); + } + + case Permission.PARTNER_UPDATE: { + return await access.partner.checkUpdateAccess(auth.user.id, ids); + } + + case Permission.STACK_READ: { + return access.stack.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.STACK_UPDATE: { + return access.stack.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.STACK_DELETE: { + return access.stack.checkOwnerAccess(auth.user.id, ids); + } + + default: { + return new Set(); + } + } +}; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 31f708611d..26d5f9292e 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,10 +1,10 @@ -import { AccessCore } from 'src/cores/access.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { checkAccess } from 'src/utils/access'; export interface IBulkAsset { getAssetIds: (id: string, assetIds: string[]) => Promise>; @@ -23,15 +23,17 @@ export const getAssetFiles = (files?: AssetFileEntity[]) => ({ export const addAssets = async ( auth: AuthDto, - repositories: { accessRepository: IAccessRepository; repository: IBulkAsset }, + repositories: { access: IAccessRepository; bulk: IBulkAsset }, dto: { parentId: string; assetIds: string[] }, ) => { - const { accessRepository, repository } = repositories; - const access = AccessCore.create(accessRepository); - - const existingAssetIds = await repository.getAssetIds(dto.parentId, dto.assetIds); + const { access, bulk } = repositories; + const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds); const notPresentAssetIds = dto.assetIds.filter((id) => !existingAssetIds.has(id)); - const allowedAssetIds = await access.checkAccess(auth, Permission.ASSET_SHARE, notPresentAssetIds); + const allowedAssetIds = await checkAccess(access, { + auth, + permission: Permission.ASSET_SHARE, + ids: notPresentAssetIds, + }); const results: BulkIdResponseDto[] = []; for (const assetId of dto.assetIds) { @@ -53,7 +55,7 @@ export const addAssets = async ( const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id); if (newAssetIds.length > 0) { - await repository.addAssetIds(dto.parentId, newAssetIds); + await bulk.addAssetIds(dto.parentId, newAssetIds); } return results; @@ -61,18 +63,17 @@ export const addAssets = async ( export const removeAssets = async ( auth: AuthDto, - repositories: { accessRepository: IAccessRepository; repository: IBulkAsset }, + repositories: { access: IAccessRepository; bulk: IBulkAsset }, dto: { parentId: string; assetIds: string[]; canAlwaysRemove: Permission }, ) => { - const { accessRepository, repository } = repositories; - const access = AccessCore.create(accessRepository); + const { access, bulk } = repositories; // check if the user can always remove from the parent album, memory, etc. - const canAlwaysRemove = await access.checkAccess(auth, dto.canAlwaysRemove, [dto.parentId]); - const existingAssetIds = await repository.getAssetIds(dto.parentId, dto.assetIds); + const canAlwaysRemove = await checkAccess(access, { auth, permission: dto.canAlwaysRemove, ids: [dto.parentId] }); + const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds); const allowedAssetIds = canAlwaysRemove.has(dto.parentId) ? existingAssetIds - : await access.checkAccess(auth, Permission.ASSET_SHARE, existingAssetIds); + : await checkAccess(access, { auth, permission: Permission.ASSET_SHARE, ids: existingAssetIds }); const results: BulkIdResponseDto[] = []; for (const assetId of dto.assetIds) { @@ -94,7 +95,7 @@ export const removeAssets = async ( const removedIds = results.filter(({ success }) => success).map(({ id }) => id); if (removedIds.length > 0) { - await repository.removeAssetIds(dto.parentId, removedIds); + await bulk.removeAssetIds(dto.parentId, removedIds); } return results; diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index befe9c77a8..c9db8cd76a 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -1,4 +1,3 @@ -import { AccessCore } from 'src/cores/access.core'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { Mocked, vitest } from 'vitest'; @@ -14,11 +13,7 @@ export interface IAccessRepositoryMock { timeline: Mocked; } -export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => { - if (reset) { - AccessCore.reset(); - } - +export const newAccessRepositoryMock = (): IAccessRepositoryMock => { return { activity: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), From cde0458dc858b0923638b503e1a0bc2759d2c72e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 20 Aug 2024 07:50:09 -0400 Subject: [PATCH 138/723] fix(server): coverage reports (#11925) --- server/vitest.config.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/vitest.config.mjs b/server/vitest.config.mjs index 8811dafaf8..3c0ea00c84 100644 --- a/server/vitest.config.mjs +++ b/server/vitest.config.mjs @@ -6,6 +6,16 @@ export default defineConfig({ test: { root: './', globals: true, + coverage: { + provider: 'v8', + include: ['src/cores/**', 'src/interfaces/**', 'src/services/**', 'src/utils/**'], + thresholds: { + lines: 80, + statements: 80, + branches: 85, + functions: 85, + }, + }, server: { deps: { fallbackCJS: true, From ef9a06be5c6a06e5875bed6baed4a10920c2300a Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 20 Aug 2024 07:50:36 -0400 Subject: [PATCH 139/723] fix(server): album statistics endpoint (#11924) --- e2e/src/api/specs/album.e2e-spec.ts | 6 +- mobile/openapi/README.md | 4 +- mobile/openapi/lib/api.dart | 2 +- mobile/openapi/lib/api/albums_api.dart | 82 +++++++++---------- mobile/openapi/lib/api_client.dart | 4 +- ...art => album_statistics_response_dto.dart} | 36 ++++---- open-api/immich-openapi-specs.json | 44 +++++----- open-api/typescript-sdk/src/fetch-client.ts | 8 +- server/src/controllers/album.controller.ts | 14 ++-- server/src/dtos/album.dto.ts | 2 +- server/src/services/album.service.spec.ts | 4 +- server/src/services/album.service.ts | 4 +- .../side-bar/more-information-albums.svelte | 8 +- .../side-bar/side-bar.svelte | 4 +- 14 files changed, 111 insertions(+), 111 deletions(-) rename mobile/openapi/lib/model/{album_count_response_dto.dart => album_statistics_response_dto.dart} (63%) diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 2a35eb3c92..9e925c4021 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -344,16 +344,16 @@ describe('/albums', () => { }); }); - describe('GET /albums/count', () => { + describe('GET /albums/statistics', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/albums/count'); + const { status, body } = await request(app).get('/albums/statistics'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should return total count of albums the user has access to', async () => { const { status, body } = await request(app) - .get('/albums/count') + .get('/albums/statistics') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index f2effe1c20..c49b5052d8 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -86,8 +86,8 @@ Class | Method | HTTP request | Description *AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users | *AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums | *AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} | -*AlbumsApi* | [**getAlbumCount**](doc//AlbumsApi.md#getalbumcount) | **GET** /albums/count | *AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} | +*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics | *AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | *AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | *AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | @@ -265,8 +265,8 @@ Class | Method | HTTP request | Description - [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.md) - [AddUsersDto](doc//AddUsersDto.md) - [AdminOnboardingUpdateDto](doc//AdminOnboardingUpdateDto.md) - - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md) - [AlbumResponseDto](doc//AlbumResponseDto.md) + - [AlbumStatisticsResponseDto](doc//AlbumStatisticsResponseDto.md) - [AlbumUserAddDto](doc//AlbumUserAddDto.md) - [AlbumUserCreateDto](doc//AlbumUserCreateDto.md) - [AlbumUserResponseDto](doc//AlbumUserResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 6ee06d5304..a6f860dda2 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -73,8 +73,8 @@ part 'model/activity_response_dto.dart'; part 'model/activity_statistics_response_dto.dart'; part 'model/add_users_dto.dart'; part 'model/admin_onboarding_update_dto.dart'; -part 'model/album_count_response_dto.dart'; part 'model/album_response_dto.dart'; +part 'model/album_statistics_response_dto.dart'; part 'model/album_user_add_dto.dart'; part 'model/album_user_create_dto.dart'; part 'model/album_user_response_dto.dart'; diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index fb81c04616..eb2bb7c0bd 100644 --- a/mobile/openapi/lib/api/albums_api.dart +++ b/mobile/openapi/lib/api/albums_api.dart @@ -218,47 +218,6 @@ class AlbumsApi { } } - /// Performs an HTTP 'GET /albums/count' operation and returns the [Response]. - Future getAlbumCountWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/albums/count'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - Future getAlbumCount() async { - final response = await getAlbumCountWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumCountResponseDto',) as AlbumCountResponseDto; - - } - return null; - } - /// Performs an HTTP 'GET /albums/{id}' operation and returns the [Response]. /// Parameters: /// @@ -322,6 +281,47 @@ class AlbumsApi { return null; } + /// Performs an HTTP 'GET /albums/statistics' operation and returns the [Response]. + Future getAlbumStatisticsWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/albums/statistics'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getAlbumStatistics() async { + final response = await getAlbumStatisticsWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumStatisticsResponseDto',) as AlbumStatisticsResponseDto; + + } + return null; + } + /// Performs an HTTP 'GET /albums' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 935324272d..c9ed2a508d 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -201,10 +201,10 @@ class ApiClient { return AddUsersDto.fromJson(value); case 'AdminOnboardingUpdateDto': return AdminOnboardingUpdateDto.fromJson(value); - case 'AlbumCountResponseDto': - return AlbumCountResponseDto.fromJson(value); case 'AlbumResponseDto': return AlbumResponseDto.fromJson(value); + case 'AlbumStatisticsResponseDto': + return AlbumStatisticsResponseDto.fromJson(value); case 'AlbumUserAddDto': return AlbumUserAddDto.fromJson(value); case 'AlbumUserCreateDto': diff --git a/mobile/openapi/lib/model/album_count_response_dto.dart b/mobile/openapi/lib/model/album_statistics_response_dto.dart similarity index 63% rename from mobile/openapi/lib/model/album_count_response_dto.dart rename to mobile/openapi/lib/model/album_statistics_response_dto.dart index 531a17a083..90dbe52016 100644 --- a/mobile/openapi/lib/model/album_count_response_dto.dart +++ b/mobile/openapi/lib/model/album_statistics_response_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class AlbumCountResponseDto { - /// Returns a new [AlbumCountResponseDto] instance. - AlbumCountResponseDto({ +class AlbumStatisticsResponseDto { + /// Returns a new [AlbumStatisticsResponseDto] instance. + AlbumStatisticsResponseDto({ required this.notShared, required this.owned, required this.shared, @@ -25,7 +25,7 @@ class AlbumCountResponseDto { int shared; @override - bool operator ==(Object other) => identical(this, other) || other is AlbumCountResponseDto && + bool operator ==(Object other) => identical(this, other) || other is AlbumStatisticsResponseDto && other.notShared == notShared && other.owned == owned && other.shared == shared; @@ -38,7 +38,7 @@ class AlbumCountResponseDto { (shared.hashCode); @override - String toString() => 'AlbumCountResponseDto[notShared=$notShared, owned=$owned, shared=$shared]'; + String toString() => 'AlbumStatisticsResponseDto[notShared=$notShared, owned=$owned, shared=$shared]'; Map toJson() { final json = {}; @@ -48,14 +48,14 @@ class AlbumCountResponseDto { return json; } - /// Returns a new [AlbumCountResponseDto] instance and imports its values from + /// Returns a new [AlbumStatisticsResponseDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static AlbumCountResponseDto? fromJson(dynamic value) { + static AlbumStatisticsResponseDto? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return AlbumCountResponseDto( + return AlbumStatisticsResponseDto( notShared: mapValueOfType(json, r'notShared')!, owned: mapValueOfType(json, r'owned')!, shared: mapValueOfType(json, r'shared')!, @@ -64,11 +64,11 @@ class AlbumCountResponseDto { return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = AlbumCountResponseDto.fromJson(row); + final value = AlbumStatisticsResponseDto.fromJson(row); if (value != null) { result.add(value); } @@ -77,12 +77,12 @@ class AlbumCountResponseDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = AlbumCountResponseDto.fromJson(entry.value); + final value = AlbumStatisticsResponseDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -91,14 +91,14 @@ class AlbumCountResponseDto { return map; } - // maps a json object with a list of AlbumCountResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of AlbumStatisticsResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = AlbumCountResponseDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = AlbumStatisticsResponseDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index a9b08fc400..16c25562a6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -660,16 +660,16 @@ ] } }, - "/albums/count": { + "/albums/statistics": { "get": { - "operationId": "getAlbumCount", + "operationId": "getAlbumStatistics", "parameters": [], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AlbumCountResponseDto" + "$ref": "#/components/schemas/AlbumStatisticsResponseDto" } } }, @@ -7505,25 +7505,6 @@ ], "type": "object" }, - "AlbumCountResponseDto": { - "properties": { - "notShared": { - "type": "integer" - }, - "owned": { - "type": "integer" - }, - "shared": { - "type": "integer" - } - }, - "required": [ - "notShared", - "owned", - "shared" - ], - "type": "object" - }, "AlbumResponseDto": { "properties": { "albumName": { @@ -7611,6 +7592,25 @@ ], "type": "object" }, + "AlbumStatisticsResponseDto": { + "properties": { + "notShared": { + "type": "integer" + }, + "owned": { + "type": "integer" + }, + "shared": { + "type": "integer" + } + }, + "required": [ + "notShared", + "owned", + "shared" + ], + "type": "object" + }, "AlbumUserAddDto": { "properties": { "role": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 8b503821f7..c6d8d3e3ba 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -268,7 +268,7 @@ export type CreateAlbumDto = { assetIds?: string[]; description?: string; }; -export type AlbumCountResponseDto = { +export type AlbumStatisticsResponseDto = { notShared: number; owned: number; shared: number; @@ -1369,11 +1369,11 @@ export function createAlbum({ createAlbumDto }: { body: createAlbumDto }))); } -export function getAlbumCount(opts?: Oazapfts.RequestOpts) { +export function getAlbumStatistics(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AlbumCountResponseDto; - }>("/albums/count", { + data: AlbumStatisticsResponseDto; + }>("/albums/statistics", { ...opts })); } diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index 06f2066c29..49ec5a82ea 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -2,9 +2,9 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@ import { ApiTags } from '@nestjs/swagger'; import { AddUsersDto, - AlbumCountResponseDto, AlbumInfoDto, AlbumResponseDto, + AlbumStatisticsResponseDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto, @@ -22,12 +22,6 @@ import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation'; export class AlbumController { constructor(private service: AlbumService) {} - @Get('count') - @Authenticated({ permission: Permission.ALBUM_STATISTICS }) - getAlbumCount(@Auth() auth: AuthDto): Promise { - return this.service.getCount(auth); - } - @Get() @Authenticated({ permission: Permission.ALBUM_READ }) getAllAlbums(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise { @@ -40,6 +34,12 @@ export class AlbumController { return this.service.create(auth, dto); } + @Get('statistics') + @Authenticated({ permission: Permission.ALBUM_STATISTICS }) + getAlbumStatistics(@Auth() auth: AuthDto): Promise { + return this.service.getStatistics(auth); + } + @Authenticated({ permission: Permission.ALBUM_READ, sharedLink: true }) @Get(':id') getAlbumInfo( diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 8f5c996cae..b12847ee62 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -95,7 +95,7 @@ export class GetAlbumsDto { assetId?: string; } -export class AlbumCountResponseDto { +export class AlbumStatisticsResponseDto { @ApiProperty({ type: 'integer' }) owned!: number; diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 406302ece9..16b2d97fdd 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -43,12 +43,12 @@ describe(AlbumService.name, () => { expect(sut).toBeDefined(); }); - describe('getCount', () => { + describe('getStatistics', () => { it('should get the album count', async () => { albumMock.getOwned.mockResolvedValue([]); albumMock.getShared.mockResolvedValue([]); albumMock.getNotShared.mockResolvedValue([]); - await expect(sut.getCount(authStub.admin)).resolves.toEqual({ + await expect(sut.getStatistics(authStub.admin)).resolves.toEqual({ owned: 0, shared: 0, notShared: 0, diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 02dab1a740..b2b5ea32a2 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -1,9 +1,9 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { AddUsersDto, - AlbumCountResponseDto, AlbumInfoDto, AlbumResponseDto, + AlbumStatisticsResponseDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto, @@ -37,7 +37,7 @@ export class AlbumService { @Inject(IAlbumUserRepository) private albumUserRepository: IAlbumUserRepository, ) {} - async getCount(auth: AuthDto): Promise { + async getStatistics(auth: AuthDto): Promise { const [owned, shared, notShared] = await Promise.all([ this.albumRepository.getOwned(auth.user.id), this.albumRepository.getShared(auth.user.id), diff --git a/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte b/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte index e47daaf86b..68c58ab155 100644 --- a/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte +++ b/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte @@ -1,13 +1,13 @@ + + + + +{#if isExpanded} +
      + {#each Object.entries(content) as [subFolderName, subContent], index (index)} +
    • + +
    • + {/each} +
    +{/if} diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 8222007d57..495c1aae30 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -3,6 +3,7 @@ import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte'; import SideBar from '../shared-components/side-bar/side-bar.svelte'; import AdminSideBar from '../shared-components/side-bar/admin-side-bar.svelte'; + import FolderSideBar from '$lib/components/shared-components/side-bar/folder-side-bar.svelte'; export let hideNavbar = false; export let showUploadButton = false; @@ -10,6 +11,7 @@ export let description: string | undefined = undefined; export let scrollbar = true; export let admin = false; + export let isFolderView = false; $: scrollbarClass = scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden'; $: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full'; @@ -29,6 +31,8 @@ {#if admin} + {:else if isFolderView} + {:else} {/if} diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 337b681a22..f977d91a99 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -23,6 +23,7 @@ export let disableAssetSelect = false; export let showArchiveIcon = false; export let viewport: Viewport; + export let showAssetName = false; let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; @@ -121,6 +122,7 @@ class="absolute" style="width: {geometry.boxes[i].width}px; height: {geometry.boxes[i].height}px; top: {geometry.boxes[i] .top}px; left: {geometry.boxes[i].left}px" + title={showAssetName ? asset.originalFileName : ''} > + {#if showAssetName} +
    + {asset.originalFileName} +
    + {/if}
    {/each}
    diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 52825850f3..b8df9cbbbe 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -19,6 +19,7 @@ import AccountInfoPanel from './account-info-panel.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { t } from 'svelte-i18n'; + import { foldersStore } from '$lib/stores/folders.store'; export let showUploadButton = true; @@ -38,6 +39,7 @@ window.location.href = redirectUri; } resetSavedUser(); + foldersStore.clearCache(); }; diff --git a/web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte b/web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte new file mode 100644 index 0000000000..8e744c23aa --- /dev/null +++ b/web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte @@ -0,0 +1,32 @@ + + +
    +
    {$t('explorer').toUpperCase()}
    +
    + {#each Object.entries(folderTree) as [folderName, content]} + + {/each} +
    +
    diff --git a/web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte b/web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte new file mode 100644 index 0000000000..ff1cd514e6 --- /dev/null +++ b/web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index 05ae856919..1985160b27 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -20,6 +20,7 @@ mdiTrashCanOutline, mdiToolbox, mdiToolboxOutline, + mdiFolderOutline, } from '@mdi/js'; import SideBarSection from './side-bar-section.svelte'; import SideBarLink from './side-bar-link.svelte'; @@ -104,6 +105,8 @@ + + ({ ...state, uniquePaths })); + } + } + + async function fetchAssetsByPath(path: string) { + const state = get(foldersStore); + + if (state.assets[path]) { + return; + } + + const assets = await getAssetsByOriginalPath({ path }); + if (assets) { + update((state) => ({ + ...state, + assets: { ...state.assets, [path]: assets }, + })); + } + } + + function clearCache() { + set(initialState); + } + + return { + subscribe, + fetchUniquePaths, + fetchAssetsByPath, + clearCache, + }; +} + +export const foldersStore = createFoldersStore(); diff --git a/web/src/lib/utils/folder-utils.ts b/web/src/lib/utils/folder-utils.ts new file mode 100644 index 0000000000..0305f89672 --- /dev/null +++ b/web/src/lib/utils/folder-utils.ts @@ -0,0 +1,18 @@ +export interface RecursiveObject { + [key: string]: RecursiveObject; +} + +export function buildFolderTree(paths: string[]) { + const root: RecursiveObject = {}; + for (const path of paths) { + const parts = path.split('/'); + let current = root; + for (const part of parts) { + if (!current[part]) { + current[part] = {}; + } + current = current[part]; + } + } + return root; +} diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000..bf914ff8f9 --- /dev/null +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,112 @@ + + + +
    + {#if data.path} + + {/if} + +
    + + + + {#each pathSegments as segment, index} + +

    + {#if index < pathSegments.length - 1} + + {/if} +

    + {/each} +
    +
    + +
    + + {#if data.currentFolders.length > 0} +
    + {#each data.currentFolders as folder} + + {/each} +
    + {/if} + + +
    0} + > + {#if data.pathAssets && data.pathAssets.length > 0} + + {/if} +
    +
    + diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000..f04d7840e5 --- /dev/null +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,41 @@ +import { foldersStore } from '$lib/stores/folders.store'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import { get } from 'svelte/store'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(); + const asset = await getAssetInfoFromParam(params); + const $t = await getFormatter(); + + await foldersStore.fetchUniquePaths(); + const { uniquePaths } = get(foldersStore); + + let pathAssets = null; + const path = url.searchParams.get('folder'); + + if (path) { + await foldersStore.fetchAssetsByPath(path); + const { assets } = get(foldersStore); + pathAssets = assets[path] || null; + } + + const currentPath = path ? `${path}/`.replaceAll('//', '/') : ''; + + const currentFolders = (uniquePaths || []) + .filter((path) => path.startsWith(currentPath) && path !== currentPath) + .map((path) => path.replaceAll(currentPath, '').split('/')[0]) + .filter((value, index, self) => self.indexOf(value) === index); + + return { + asset, + path, + currentFolders, + pathAssets, + meta: { + title: $t('folders'), + }, + }; +}) satisfies PageLoad; From 837b1e4929ed5e029c16a3c45b6bd4020a964c4c Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Wed, 21 Aug 2024 22:15:21 -0400 Subject: [PATCH 147/723] feat(web): Scroll to asset in gridview; increase gridview perf; reduce memory; scrollbar ticks in fixed position (#10646) * Squashed * Change strategy - now pre-measure buckets offscreen, so don't need to worry about sub-bucket scroll preservation * Reduce jank on scroll, delay DOM updates until after scroll * css opt, log measure time * Trickle out queue while scrolling, flush when stopped * yay * Cleanup cleanup... * everybody... * everywhere... * Clean up cleanup! * Everybody do their share * CLEANUP! * package-lock ? * dynamic measure, todo * Fix web test * type lint * fix e2e * e2e test * Better scrollbar * Tuning, and more tunables * Tunable tweaks, more tunables * Scrollbar dots and viewport events * lint * Tweaked tunnables, use requestIdleCallback for garbage tasks, bug fixes * New tunables, and don't update url by default * Bug fixes * Bug fix, with debug * Fix flickr, fix graybox bug, reduced debug * Refactor/cleanup * Fix * naming * Final cleanup * review comment * Forgot to update this after naming change * scrubber works, with debug * cleanup * Rename scrollbar to scrubber * rename to * left over rename and change to previous album bar * bugfix addassets, comments * missing destroy(), cleanup --------- Co-authored-by: Alex --- e2e/src/web/specs/shared-link.e2e-spec.ts | 2 +- web/src/lib/actions/autogrow.ts | 3 + web/src/lib/actions/intersection-observer.ts | 152 +++++ web/src/lib/actions/resize-observer.ts | 43 ++ web/src/lib/actions/thumbhash.ts | 14 + .../components/album-page/album-viewer.svelte | 6 +- .../asset-viewer/asset-viewer.svelte | 22 +- .../asset-viewer/detail-panel.svelte | 1 - .../asset-viewer/intersection-observer.svelte | 82 --- .../asset-viewer/photo-viewer.svelte | 38 +- .../__test__/image-thumbnail.spec.ts | 12 +- .../assets/thumbnail/image-thumbnail.svelte | 93 ++- .../assets/thumbnail/thumbnail.svelte | 218 +++++-- .../assets/thumbnail/video-thumbnail.svelte | 56 +- .../faces-page/assign-face-side-panel.svelte | 1 - .../faces-page/person-side-panel.svelte | 4 - .../memory-page/memory-viewer.svelte | 29 +- .../photos-page/asset-date-group.svelte | 281 +++++---- .../components/photos-page/asset-grid.svelte | 597 ++++++++++++++---- .../photos-page/measure-date-group.svelte | 89 +++ .../components/photos-page/memory-lane.svelte | 5 +- .../components/photos-page/skeleton.svelte | 35 + .../gallery-viewer/gallery-viewer.svelte | 30 +- .../scrollbar/scrollbar.svelte | 183 ------ .../scrubber/scrubber.svelte | 281 +++++++++ .../duplicates-compare-control.svelte | 8 +- web/src/lib/stores/asset-viewing.store.ts | 3 + web/src/lib/stores/asset.store.spec.ts | 75 ++- web/src/lib/stores/assets.store.ts | 528 +++++++++++++--- web/src/lib/utils/asset-store-task-manager.ts | 465 ++++++++++++++ web/src/lib/utils/asset-utils.ts | 4 +- web/src/lib/utils/idle-callback-support.ts | 20 + web/src/lib/utils/keyed-priority-queue.ts | 50 ++ web/src/lib/utils/navigation.ts | 78 ++- web/src/lib/utils/priority-queue.ts | 21 + web/src/lib/utils/timeline-util.ts | 83 ++- web/src/lib/utils/tunables.ts | 63 ++ web/src/routes/(user)/+layout.svelte | 8 +- .../[[assetId=id]]/+page.svelte | 52 +- .../[[assetId=id]]/+page.svelte | 7 +- .../[[assetId=id]]/+page.svelte | 7 +- .../[[assetId=id]]/+page.svelte | 5 +- .../[[assetId=id]]/+page.svelte | 3 +- .../[[assetId=id]]/+page.svelte | 8 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 6 + .../[[assetId=id]]/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 10 + .../[[assetId=id]]/+page.svelte | 7 +- web/static/dark_skeleton.png | Bin 0 -> 4988 bytes web/static/light_skeleton.png | Bin 0 -> 4989 bytes 50 files changed, 2947 insertions(+), 843 deletions(-) create mode 100644 web/src/lib/actions/intersection-observer.ts create mode 100644 web/src/lib/actions/resize-observer.ts create mode 100644 web/src/lib/actions/thumbhash.ts delete mode 100644 web/src/lib/components/asset-viewer/intersection-observer.svelte create mode 100644 web/src/lib/components/photos-page/measure-date-group.svelte create mode 100644 web/src/lib/components/photos-page/skeleton.svelte delete mode 100644 web/src/lib/components/shared-components/scrollbar/scrollbar.svelte create mode 100644 web/src/lib/components/shared-components/scrubber/scrubber.svelte create mode 100644 web/src/lib/utils/asset-store-task-manager.ts create mode 100644 web/src/lib/utils/idle-callback-support.ts create mode 100644 web/src/lib/utils/keyed-priority-queue.ts create mode 100644 web/src/lib/utils/priority-queue.ts create mode 100644 web/src/lib/utils/tunables.ts create mode 100644 web/static/dark_skeleton.png create mode 100644 web/static/light_skeleton.png diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index e40c20388b..fe7da0b2c0 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -44,7 +44,7 @@ test.describe('Shared Links', () => { test('download from a shared link', async ({ page }) => { await page.goto(`/share/${sharedLink.key}`); await page.getByRole('heading', { name: 'Test Album' }).waitFor(); - await page.locator('.group > div').first().hover(); + await page.locator('.group').first().hover(); await page.waitForSelector('#asset-group-by-date svg'); await page.getByRole('checkbox').click(); await page.getByRole('button', { name: 'Download' }).click(); diff --git a/web/src/lib/actions/autogrow.ts b/web/src/lib/actions/autogrow.ts index b79671afc8..ff80454ef3 100644 --- a/web/src/lib/actions/autogrow.ts +++ b/web/src/lib/actions/autogrow.ts @@ -1,4 +1,7 @@ export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => { + if (!textarea) { + return; + } textarea.style.height = height; textarea.style.height = `${textarea.scrollHeight}px`; }; diff --git a/web/src/lib/actions/intersection-observer.ts b/web/src/lib/actions/intersection-observer.ts new file mode 100644 index 0000000000..222f76be63 --- /dev/null +++ b/web/src/lib/actions/intersection-observer.ts @@ -0,0 +1,152 @@ +type Config = IntersectionObserverActionProperties & { + observer?: IntersectionObserver; +}; +type TrackedProperties = { + root?: Element | Document | null; + threshold?: number | number[]; + top?: string; + right?: string; + bottom?: string; + left?: string; +}; +type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown; +type OnSeperateCallback = (element: HTMLElement) => unknown; +type IntersectionObserverActionProperties = { + key?: string; + onSeparate?: OnSeperateCallback; + onIntersect?: OnIntersectCallback; + + root?: Element | Document | null; + threshold?: number | number[]; + top?: string; + right?: string; + bottom?: string; + left?: string; + + disabled?: boolean; +}; +type TaskKey = HTMLElement | string; + +function isEquivalent(a: TrackedProperties, b: TrackedProperties) { + return ( + a?.bottom === b?.bottom && + a?.top === b?.top && + a?.left === b?.left && + a?.right == b?.right && + a?.threshold === b?.threshold && + a?.root === b?.root + ); +} + +const elementToConfig = new Map(); + +const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => { + if (!target.isConnected) { + elementToConfig.get(key)?.observer?.unobserve(target); + return; + } + const { + root, + threshold, + top = '0px', + right = '0px', + bottom = '0px', + left = '0px', + onSeparate, + onIntersect, + } = properties; + const rootMargin = `${top} ${right} ${bottom} ${left}`; + const observer = new IntersectionObserver( + (entries: IntersectionObserverEntry[]) => { + // This IntersectionObserver is limited to observing a single element, the one the + // action is attached to. If there are multiple entries, it means that this + // observer is being notified of multiple events that have occured quickly together, + // and the latest element is the one we are interested in. + + entries.sort((a, b) => a.time - b.time); + + const latestEntry = entries.pop(); + if (latestEntry?.isIntersecting) { + onIntersect?.(latestEntry); + } else { + onSeparate?.(target); + } + }, + { + rootMargin, + threshold, + root, + }, + ); + observer.observe(target); + elementToConfig.set(key, { ...properties, observer }); +}; + +function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) { + elementToConfig.set(key, properties); + observe(key, element, properties); +} + +function _intersectionObserver( + key: HTMLElement | string, + element: HTMLElement, + properties: IntersectionObserverActionProperties, +) { + if (properties.disabled) { + properties.onIntersect?.(element); + } else { + configure(key, element, properties); + } + return { + update(properties: IntersectionObserverActionProperties) { + const config = elementToConfig.get(key); + if (!config) { + return; + } + if (isEquivalent(config, properties)) { + return; + } + configure(key, element, properties); + }, + destroy: () => { + if (properties.disabled) { + properties.onSeparate?.(element); + } else { + const config = elementToConfig.get(key); + const { observer, onSeparate } = config || {}; + observer?.unobserve(element); + elementToConfig.delete(key); + if (onSeparate) { + onSeparate?.(element); + } + } + }, + }; +} + +export function intersectionObserver( + element: HTMLElement, + properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[], +) { + // svelte doesn't allow multiple use:action directives of the same kind on the same element, + // so accept an array when multiple configurations are needed. + if (Array.isArray(properties)) { + if (!properties.every((p) => p.key)) { + throw new Error('Multiple configurations must specify key'); + } + const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p)); + return { + update: (properties: IntersectionObserverActionProperties[]) => { + for (const [i, props] of properties.entries()) { + observers[i].update(props); + } + }, + destroy: () => { + for (const observer of observers) { + observer.destroy(); + } + }, + }; + } + return _intersectionObserver(element, element, properties); +} diff --git a/web/src/lib/actions/resize-observer.ts b/web/src/lib/actions/resize-observer.ts new file mode 100644 index 0000000000..9f3adc44b0 --- /dev/null +++ b/web/src/lib/actions/resize-observer.ts @@ -0,0 +1,43 @@ +type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void; + +let observer: ResizeObserver; +let callbacks: WeakMap; + +/** + * Installs a resizeObserver on the given element - when the element changes + * size, invokes a callback function with the width/height. Intended as a + * replacement for bind:clientWidth and bind:clientHeight in svelte4 which use + * an iframe to measure the size of the element, which can be bad for + * performance and memory usage. In svelte5, they adapted bind:clientHeight and + * bind:clientWidth to use an internal resize observer. + * + * TODO: When svelte5 is ready, go back to bind:clientWidth and + * bind:clientHeight. + */ +export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) { + if (!observer) { + callbacks = new WeakMap(); + observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const onResize = callbacks.get(entry.target as HTMLElement); + if (onResize) { + onResize({ + target: entry.target as HTMLElement, + width: entry.borderBoxSize[0].inlineSize, + height: entry.borderBoxSize[0].blockSize, + }); + } + } + }); + } + + callbacks.set(element, onResize); + observer.observe(element); + + return { + destroy: () => { + callbacks.delete(element); + observer.unobserve(element); + }, + }; +} diff --git a/web/src/lib/actions/thumbhash.ts b/web/src/lib/actions/thumbhash.ts new file mode 100644 index 0000000000..ab9d28ffc9 --- /dev/null +++ b/web/src/lib/actions/thumbhash.ts @@ -0,0 +1,14 @@ +import { decodeBase64 } from '$lib/utils'; +import { thumbHashToRGBA } from 'thumbhash'; + +export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) { + const ctx = canvas.getContext('2d'); + if (ctx) { + const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash)); + const pixels = ctx.createImageData(w, h); + canvas.width = w; + canvas.height = h; + pixels.data.set(rgba); + ctx.putImageData(pixels, 0, 0); + } +} diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 7a88aa740b..2256c79eb0 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -19,6 +19,7 @@ import { handlePromiseError } from '$lib/utils'; import AlbumSummary from './album-summary.svelte'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let sharedLink: SharedLinkResponseDto; export let user: UserResponseDto | undefined = undefined; @@ -38,6 +39,9 @@ dragAndDropFilesStore.set({ isDragging: false, files: [] }); } }); + onDestroy(() => { + assetStore.destroy(); + });
    - +

    (); @@ -201,7 +201,6 @@ websocketEvents.on('on_asset_update', onAssetUpdate), ); - await navigate({ targetRoute: 'current', assetId: asset.id }); slideshowStateUnsubscribe = slideshowState.subscribe((value) => { if (value === SlideshowState.PlaySlideshow) { slideshowHistory.reset(); @@ -268,9 +267,8 @@ $isShowDetail = !$isShowDetail; }; - const closeViewer = async () => { - dispatch('close'); - await navigate({ targetRoute: 'current', assetId: null }); + const closeViewer = () => { + dispatch('close', { asset }); }; const closeEditor = () => { @@ -378,9 +376,7 @@ } }; - const handleStackedAssetMouseEvent = (e: CustomEvent<{ isMouseOver: boolean }>, asset: AssetResponseDto) => { - const { isMouseOver } = e.detail; - + const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => { previewStackedAsset = isMouseOver ? asset : undefined; }; @@ -392,8 +388,7 @@ } case AssetAction.UNSTACK: { - await closeViewer(); - break; + closeViewer(); } } @@ -585,12 +580,11 @@ ? 'bg-transparent border-2 border-white' : 'bg-gray-700/40'} inline-block hover:bg-transparent" asset={stackedAsset} - onClick={(stackedAsset, event) => { - event.preventDefault(); + onClick={(stackedAsset) => { asset = stackedAsset; preloadAssets = index + 1 >= stackedAssets.length ? [] : [stackedAssets[index + 1]]; }} - on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)} + onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} readonly thumbnailSize={stackedAsset.id == asset.id ? 65 : 60} showStackedIcon={false} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 4ff2084b9a..88417f248f 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -212,7 +212,6 @@ title={person.name} widthStyle="90px" heightStyle="90px" - thumbhash={null} hidden={person.isHidden} />

    diff --git a/web/src/lib/components/asset-viewer/intersection-observer.svelte b/web/src/lib/components/asset-viewer/intersection-observer.svelte deleted file mode 100644 index df89a2ed7d..0000000000 --- a/web/src/lib/components/asset-viewer/intersection-observer.svelte +++ /dev/null @@ -1,82 +0,0 @@ - - -
    - -
    diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 966f382838..3919033e4a 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -12,7 +12,7 @@ import { AssetTypeEnum, type AssetResponseDto, AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk'; import { zoomImageAction, zoomed } from '$lib/actions/zoom-image'; import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard'; - import { onDestroy } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import { fade } from 'svelte/transition'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; @@ -33,6 +33,7 @@ let imageLoaded: boolean = false; let imageError: boolean = false; let forceUseOriginal: boolean = false; + let loader: HTMLImageElement; $: isWebCompatible = isWebCompatibleImage(asset); $: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile; @@ -108,6 +109,25 @@ event.preventDefault(); handlePromiseError(copyImage()); }; + + onMount(() => { + const onload = () => { + imageLoaded = true; + assetFileUrl = imageLoaderUrl; + }; + const onerror = () => { + imageError = imageLoaded = true; + }; + if (loader.complete) { + onload(); + } + loader.addEventListener('load', onload); + loader.addEventListener('error', onerror); + return () => { + loader?.removeEventListener('load', onload); + loader?.removeEventListener('error', onerror); + }; + }); {$t('error_loading_image')}
    {/if} + +
    (imageError = imageLoaded = true)} /> {#if !imageLoaded} -
    +
    {:else if !imageError} @@ -159,3 +181,15 @@
    {/if}
    + + diff --git a/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts index 91ea7d3ab1..2525b86160 100644 --- a/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts +++ b/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts @@ -3,8 +3,8 @@ import { render } from '@testing-library/svelte'; describe('ImageThumbnail component', () => { beforeAll(() => { - Object.defineProperty(HTMLImageElement.prototype, 'decode', { - value: vi.fn(), + Object.defineProperty(HTMLImageElement.prototype, 'complete', { + value: true, }); }); @@ -12,13 +12,11 @@ describe('ImageThumbnail component', () => { const sut = render(ImageThumbnail, { url: 'http://localhost/img.png', altText: 'test', - thumbhash: '1QcSHQRnh493V4dIh4eXh1h4kJUI', + base64ThumbHash: '1QcSHQRnh493V4dIh4eXh1h4kJUI', widthStyle: '250px', }); - const [_, thumbhash] = sut.getAllByRole('img'); - expect(thumbhash.getAttribute('src')).toContain( - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAgCAYAAAD5VeO1AAAMRklEQVR4AQBdAKL/', // truncated - ); + const thumbhash = sut.getByTestId('thumbhash'); + expect(thumbhash).not.toBeFalsy(); }); }); diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 8e391ecb59..e03dd35653 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -1,17 +1,19 @@ -{altText} +{#if errored} +
    + +
    +{:else} + {loaded +{/if} {#if hidden}
    @@ -57,18 +80,18 @@
    {/if} -{#if thumbhash && !complete} - {altText} {/if} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 6b0bd2ee75..c9fbf133c8 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -1,5 +1,5 @@ - - - {#if intersecting} +
    + {#if !loaded && asset.thumbhash} + + {/if} + + {#if display} + +
    { + if (evt.key === 'Enter') { + callClickHandlers(); + } + }} + tabindex={0} + on:click={handleClick} + role="link" + > + {#if mouseOver} + + evt.preventDefault()} + tabindex={0} + > + + {/if}
    {#if !readonly && (mouseOver || selected || selectionCandidate)} @@ -189,11 +303,11 @@ altText={$getAltText(asset)} widthStyle="{width}px" heightStyle="{height}px" - thumbhash={asset.thumbhash} curve={selected} + onComplete={() => (loaded = true)} /> {:else} -
    +
    {/if} @@ -201,6 +315,7 @@ {#if asset.type === AssetTypeEnum.Video}
    {/if} - {/if} - - +
    + {/if} +
    diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index 5c4196e54b..5cac0b1945 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -3,7 +3,11 @@ import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; + import { AssetStore } from '$lib/stores/assets.store'; + import { generateId } from '$lib/utils/generate-id'; + import { onDestroy } from 'svelte'; + export let assetStore: AssetStore | undefined = undefined; export let url: string; export let durationInSeconds = 0; export let enablePlayback = false; @@ -13,6 +17,7 @@ export let playIcon = mdiPlayCircleOutline; export let pauseIcon = mdiPauseCircleOutline; + const componentId = generateId(); let remainingSeconds = durationInSeconds; let loading = true; let error = false; @@ -27,6 +32,43 @@ player.src = ''; } } + const onMouseEnter = () => { + if (assetStore) { + assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + if (playbackOnIconHover) { + enablePlayback = true; + } + }, + }); + } else { + if (playbackOnIconHover) { + enablePlayback = true; + } + } + }; + + const onMouseLeave = () => { + if (assetStore) { + assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + if (playbackOnIconHover) { + enablePlayback = false; + } + }, + }); + } else { + if (playbackOnIconHover) { + enablePlayback = false; + } + } + }; + + onDestroy(() => { + assetStore?.taskManager.removeAllTasksForComponent(componentId); + });
    @@ -37,19 +79,7 @@ {/if} - { - if (playbackOnIconHover) { - enablePlayback = true; - } - }} - on:mouseleave={() => { - if (playbackOnIconHover) { - enablePlayback = false; - } - }} - > + {#if enablePlayback} {#if loading} diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index 0dd4251dab..eba26e6e61 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -113,7 +113,6 @@ title={$getPersonNameWithHiddenValue(person.name, person.isHidden)} widthStyle="90px" heightStyle="90px" - thumbhash={null} hidden={person.isHidden} />
    diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index 712100763c..fd4fbdf964 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -265,8 +265,6 @@ title={$t('face_unassigned')} widthStyle="90px" heightStyle="90px" - thumbhash={null} - hidden={false} /> {:then data}
    - (galleryInView = true)} - on:hidden={() => (galleryInView = false)} - bottom={-200} +
    {/if}
    diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index dd57160fb4..5ca29967fe 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -1,84 +1,69 @@ -
    - {#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)} - {@const asset = groupAssets[0]} - {@const groupTitle = formatGroupTitle(fromLocalDateTime(asset.localDateTime).startOf('day'))} - +
    + {#each dateGroups as dateGroup, groupIndex (dateGroup.date)} + {@const display = + dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === $assetStore.pendingScrollAssetId)} -
    { - isMouseOverGroup = true; - assetMouseEventHandler(groupTitle, null); - }} - on:mouseleave={() => { - isMouseOverGroup = false; - assetMouseEventHandler(groupTitle, null); + id="date-group" + use:intersectionObserver={{ + onIntersect: () => { + $assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () => + assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }), + ); + }, + onSeparate: () => { + $assetStore.taskManager.seperatedDateGroup(componentId, dateGroup, () => + assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }), + ); + }, + top: INTERSECTION_ROOT_TOP, + bottom: INTERSECTION_ROOT_BOTTOM, + root: assetGridElement, + disabled: INTERSECTION_DISABLED, }} + data-display={display} + data-date-group={dateGroup.date} + style:height={dateGroup.height + 'px'} + style:width={dateGroup.geometry.containerWidth + 'px'} + style:overflow={'clip'} > - -
    - {#if !singleSelect && ((hoveredDateGroup == groupTitle && isMouseOverGroup) || $selectedGroup.has(groupTitle))} + {#if !display} + + {/if} + {#if display} + + +
    + $assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + isMouseOverGroup = true; + assetMouseEventHandler(dateGroup.groupTitle, null); + }, + })} + on:mouseleave={() => { + $assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + isMouseOverGroup = false; + assetMouseEventHandler(dateGroup.groupTitle, null); + }, + }); + }} + > +
    handleSelectGroup(groupTitle, groupAssets)} - on:keydown={() => handleSelectGroup(groupTitle, groupAssets)} + class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" + style:width={dateGroup.geometry.containerWidth + 'px'} > - {#if $selectedGroup.has(groupTitle)} - - {:else} - + {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroup.groupTitle))} +
    handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} + on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} + > + {#if $selectedGroup.has(dateGroup.groupTitle)} + + {:else} + + {/if} +
    {/if} + + + {dateGroup.groupTitle} +
    - {/if} - - {groupTitle} - -
    - - -
    - {#each groupAssets as asset, index (asset.id)} - {@const box = geometry[groupIndex].boxes[index]} +
    - { - if (isSelectionMode || $isMultiSelectState) { - event.preventDefault(); - assetSelectHandler(asset, groupAssets, groupTitle); - return; - } - - assetViewingStore.setAsset(asset); - }} - on:select={() => assetSelectHandler(asset, groupAssets, groupTitle)} - on:mouse-event={() => assetMouseEventHandler(groupTitle, asset)} - selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} - selectionCandidate={$assetSelectionCandidates.has(asset)} - disabled={$assetStore.albumAssets.has(asset.id)} - thumbnailWidth={box.width} - thumbnailHeight={box.height} - /> + {#each dateGroup.assets as asset, index (asset.id)} + {@const box = dateGroup.geometry.boxes[index]} + +
    onAssetInGrid?.(asset), + top: `-${TITLE_HEIGHT}px`, + bottom: `-${viewport.height - TITLE_HEIGHT - 1}px`, + right: `-${viewport.width - 1}px`, + root: assetGridElement, + }} + data-asset-id={asset.id} + class="absolute" + style:width={box.width + 'px'} + style:height={box.height + 'px'} + style:top={box.top + 'px'} + style:left={box.left + 'px'} + > + onRetrieveElement(dateGroup, asset, element)} + showStackedIcon={withStacked} + {showArchiveIcon} + {asset} + {groupIndex} + onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)} + onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)} + onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)} + selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} + selectionCandidate={$assetSelectionCandidates.has(asset)} + disabled={$assetStore.albumAssets.has(asset.id)} + thumbnailWidth={box.width} + thumbnailHeight={box.height} + /> +
    + {/each}
    - {/each} -
    +
    + {/if}
    {/each}
    diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 3e0935d938..db030ed14c 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -1,11 +1,17 @@ @@ -427,78 +763,97 @@ (showShortcuts = !showShortcuts)} /> {/if} - (element.scrollTop = detail)} + height={safeViewport.height} + timelineTopOffset={topSectionHeight} + timelineBottomOffset={bottomSectionHeight} + {leadout} + {scrubOverallPercent} + {scrubBucketPercent} + {scrubBucket} + {onScrub} + {stopScrub} />
    ((viewport.width = width), (viewport.height = height))} bind:this={element} - on:scroll={handleTimelineScroll} + on:scroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())} > - - {#if showSkeleton} -
    -
    -
    - {#each Array.from({ length: 100 }) as _} -
    - {/each} -
    -
    - {/if} - - {#if element} +
    ((topSectionHeight = height), (topSectionOffset = target.offsetTop))} + class:invisible={showSkeleton} + > - - {#if isEmpty} + {/if} -
    - {#each $assetStore.buckets as bucket (bucket.bucketDate)} - assetStore.cancelBucket(bucket)} - let:intersecting - top={750} - bottom={750} - root={element} - > -
    - {#if intersecting} - handleGroupSelect(group.title, group.assets)} - on:shift={handleScrollTimeline} - on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)} - on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)} - assets={bucket.assets} - bucketDate={bucket.bucketDate} - bucketHeight={bucket.bucketHeight} - {viewport} - /> - {/if} -
    -
    - {/each} -
    - {/if} +
    + +
    + {#each $assetStore.buckets as bucket (bucket.bucketDate)} + {@const isPremeasure = preMeasure.includes(bucket)} + {@const display = bucket.intersecting || bucket === $assetStore.pendingScrollBucket || isPremeasure} +
    intersectedHandler(bucket), + onSeparate: () => seperatedHandler(bucket), + top: BUCKET_INTERSECTION_ROOT_TOP, + bottom: BUCKET_INTERSECTION_ROOT_BOTTOM, + root: element, + }} + data-bucket-display={bucket.intersecting} + data-bucket-date={bucket.bucketDate} + style:height={bucket.bucketHeight + 'px'} + > + {#if display && !bucket.measured} + (preMeasure = preMeasure.filter((b) => b !== bucket))} + > + {/if} + + {#if !display || !bucket.measured} + + {/if} + {#if display && bucket.measured} + handleGroupSelect(group.title, group.assets)} + on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)} + on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)} + /> + {/if} +
    + {/each} +
    +
    @@ -522,7 +877,7 @@ diff --git a/web/src/lib/components/photos-page/measure-date-group.svelte b/web/src/lib/components/photos-page/measure-date-group.svelte new file mode 100644 index 0000000000..98e423ae94 --- /dev/null +++ b/web/src/lib/components/photos-page/measure-date-group.svelte @@ -0,0 +1,89 @@ + + + + +
    + {#each bucket.dateGroups as dateGroup} +
    +
    $assetStore.updateBucketDateGroup(bucket, dateGroup, { height: height })} + > +
    + + {dateGroup.groupTitle} + +
    + +
    +
    +
    + {/each} +
    diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index 43c2958944..5bc55796ae 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -1,4 +1,5 @@ + +
    + {#if title} +
    + {title} +
    + {/if} +
    +
    + + diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index f977d91a99..c7b49f6012 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -4,25 +4,25 @@ import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import { AppRoute, AssetAction } from '$lib/constants'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { BucketPosition, Viewport } from '$lib/stores/assets.store'; + import type { Viewport } from '$lib/stores/assets.store'; import { getAssetRatio } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { navigate } from '$lib/utils/navigation'; import { calculateWidth } from '$lib/utils/timeline-util'; import { type AssetResponseDto } from '@immich/sdk'; import justifiedLayout from 'justified-layout'; - import { createEventDispatcher, onDestroy } from 'svelte'; + import { onDestroy } from 'svelte'; import { t } from 'svelte-i18n'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import Portal from '../portal/portal.svelte'; - - const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>(); + import { handlePromiseError } from '$lib/utils'; export let assets: AssetResponseDto[]; export let selectedAssets: Set = new Set(); export let disableAssetSelect = false; export let showArchiveIcon = false; export let viewport: Viewport; + export let onIntersected: (() => void) | undefined = undefined; export let showAssetName = false; let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; @@ -127,18 +127,15 @@ { - e.preventDefault(); - + onClick={(asset) => { if (isMultiSelectionMode) { selectAssetHandler(asset); return; } - await viewAssetHandler(asset); + void viewAssetHandler(asset); }} - on:select={(e) => selectAssetHandler(e.detail.asset)} - on:intersected={(event) => - i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined} + onSelect={(asset) => selectAssetHandler(asset)} + onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)} selected={selectedAssets.has(asset)} {showArchiveIcon} thumbnailWidth={geometry.boxes[i].width} @@ -159,6 +156,15 @@ {#if $isViewerOpen} - + { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} + /> {/if} diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte deleted file mode 100644 index 9282c760c2..0000000000 --- a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte +++ /dev/null @@ -1,183 +0,0 @@ - - - (isDragging || isHover) && handleMouseEvent({ clientY })} - on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })} - on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} -/> - - - -{#if $assetStore.timelineHeight > height} -
    (isHover = true)} - on:mouseleave={() => (isHover = false)} - > - {#if isHover || isDragging} -
    - {hoverLabel} -
    - {/if} - - - {#if !isDragging} -
    - {/if} - - {#each segments as segment} -
    - {#if segment.hasLabel} -
    - {segment.date.year} -
    - {:else if segment.height > 5} -
    - {/if} -
    - {/each} -
    -{/if} - - diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte new file mode 100644 index 0000000000..e2cc638650 --- /dev/null +++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte @@ -0,0 +1,281 @@ + + + (isDragging || isHover) && handleMouseEvent({ clientY })} + on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })} + on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} +/> + + + +
    (isHover = true)} + on:mouseleave={() => (isHover = false)} +> + {#if hoverLabel && (isHover || isDragging)} +
    + {hoverLabel} +
    + {/if} + + {#if !isDragging} +
    + {/if} +
    + {#if relativeTopOffset > 6} +
    + {/if} +
    + + {#each segments as segment} +
    + {#if segment.hasLabel} +
    + {segment.date.year} +
    + {/if} + {#if segment.hasDot} +
    + {/if} +
    + {/each} +
    +
    + + diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index fcf68fdb91..2f1efc487c 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -4,7 +4,8 @@ import Portal from '$lib/components/shared-components/portal/portal.svelte'; import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { suggestDuplicateByFileSize } from '$lib/utils'; + import { handlePromiseError, suggestDuplicateByFileSize } from '$lib/utils'; + import { navigate } from '$lib/utils/navigation'; import { shortcuts } from '$lib/actions/shortcut'; import { type AssetResponseDto } from '@immich/sdk'; import { mdiCheck, mdiTrashCanOutline, mdiImageMultipleOutline } from '@mdi/js'; @@ -158,7 +159,10 @@ const index = getAssetIndex($viewingAsset.id) - 1 + assets.length; setAsset(assets[index % assets.length]); }} - on:close={() => assetViewingStore.showAssetViewer(false)} + on:close={() => { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} /> {/await} diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index cabe2e85a1..2e6e44511d 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -1,4 +1,5 @@ import { getKey } from '$lib/utils'; +import { type AssetGridRouteSearchParams } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { readonly, writable } from 'svelte/store'; @@ -6,6 +7,7 @@ function createAssetViewingStore() { const viewingAssetStoreState = writable(); const preloadAssets = writable([]); const viewState = writable(false); + const gridScrollTarget = writable(); const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => { preloadAssets.set(assetsToPreload); @@ -26,6 +28,7 @@ function createAssetViewingStore() { asset: readonly(viewingAssetStoreState), preloadAssets: readonly(preloadAssets), isViewing: viewState, + gridScrollTarget, setAsset, setAssetId, showAssetViewer, diff --git a/web/src/lib/stores/asset.store.spec.ts b/web/src/lib/stores/asset.store.spec.ts index 3fd9e1e981..7787bf794d 100644 --- a/web/src/lib/stores/asset.store.spec.ts +++ b/web/src/lib/stores/asset.store.spec.ts @@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { AbortError } from '$lib/utils'; import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; import { assetFactory } from '@test-data/factories/asset-factory'; -import { AssetStore, BucketPosition } from './assets.store'; +import { AssetStore } from './assets.store'; describe('AssetStore', () => { beforeEach(() => { @@ -26,7 +26,8 @@ describe('AssetStore', () => { ]); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('should load buckets in viewport', () => { @@ -38,15 +39,15 @@ describe('AssetStore', () => { it('calculates bucket height', () => { expect(assetStore.buckets).toEqual( expect.arrayContaining([ - expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 235 }), - expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3760 }), - expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 235 }), + expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 286 }), + expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3811 }), + expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }), ]), ); }); it('calculates timeline height', () => { - expect(assetStore.timelineHeight).toBe(4230); + expect(assetStore.timelineHeight).toBe(4383); }); }); @@ -72,35 +73,28 @@ describe('AssetStore', () => { return bucketAssets[timeBucket]; }); - await assetStore.init({ width: 0, height: 0 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 0, height: 0 }); }); it('loads a bucket', async () => { expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0); - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(3); }); it('ignores invalid buckets', async () => { - await assetStore.loadBucket('2023-01-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2023-01-01T00:00:00.000Z'); expect(sdkMock.getTimeBucket).toBeCalledTimes(0); }); - it('only updates the position of loaded buckets', async () => { - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown); - expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Unknown); - - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); - expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Visible); - }); - it('cancels bucket loading', async () => { const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); - const loadPromise = assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown); + const loadPromise = assetStore.loadBucket(bucket!.bucketDate); const abortSpy = vi.spyOn(bucket!.cancelToken!, 'abort'); - assetStore.cancelBucket(bucket!); + bucket?.cancel(); expect(abortSpy).toBeCalledTimes(1); await loadPromise; @@ -109,24 +103,24 @@ describe('AssetStore', () => { it('prevents loading buckets multiple times', async () => { await Promise.all([ - assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown), - assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown), + assetStore.loadBucket('2024-01-01T00:00:00.000Z'), + assetStore.loadBucket('2024-01-01T00:00:00.000Z'), ]); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); }); it('allows loading a canceled bucket', async () => { const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); - const loadPromise = assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown); + const loadPromise = assetStore.loadBucket(bucket!.bucketDate); - assetStore.cancelBucket(bucket!); + bucket?.cancel(); await loadPromise; expect(bucket?.assets.length).toEqual(0); - await assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown); + await assetStore.loadBucket(bucket!.bucketDate); expect(bucket!.assets.length).toEqual(3); }); }); @@ -137,7 +131,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('is empty initially', () => { @@ -219,7 +214,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('ignores non-existing assets', () => { @@ -263,7 +259,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('ignores invalid IDs', () => { @@ -312,7 +309,8 @@ describe('AssetStore', () => { ]); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); - await assetStore.init({ width: 0, height: 0 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 0, height: 0 }); }); it('returns null for invalid assetId', async () => { @@ -321,15 +319,15 @@ describe('AssetStore', () => { }); it('returns previous assetId', async () => { - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); expect(await assetStore.getPreviousAsset(bucket!.assets[1])).toEqual(bucket!.assets[0]); }); it('returns previous assetId spanning multiple buckets', async () => { - await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); - await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z'); const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z'); @@ -337,7 +335,7 @@ describe('AssetStore', () => { }); it('loads previous bucket', async () => { - await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket'); const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z'); @@ -347,9 +345,9 @@ describe('AssetStore', () => { }); it('skips removed assets', async () => { - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); - await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); - await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); + await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); const [assetOne, assetTwo, assetThree] = assetStore.assets; assetStore.removeAssets([assetTwo.id]); @@ -357,7 +355,7 @@ describe('AssetStore', () => { }); it('returns null when no more assets', async () => { - await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); expect(await assetStore.getPreviousAsset(assetStore.assets[0])).toBeNull(); }); }); @@ -368,7 +366,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 0, height: 0 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 0, height: 0 }); }); it('returns null for invalid buckets', () => { diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 1022729e91..7fd82b4c3a 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -1,6 +1,11 @@ +import { locale } from '$lib/stores/preferences.store'; import { getKey } from '$lib/utils'; -import { fromLocalDateTime } from '$lib/utils/timeline-util'; -import { TimeBucketSize, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk'; +import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager'; +import { getAssetRatio } from '$lib/utils/asset-utils'; +import type { AssetGridRouteSearchParams } from '$lib/utils/navigation'; +import { calculateWidth, fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util'; +import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk'; +import createJustifiedLayout from 'justified-layout'; import { throttle } from 'lodash-es'; import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; @@ -8,19 +13,24 @@ import { get, writable, type Unsubscriber } from 'svelte/store'; import { handleError } from '../utils/handle-error'; import { websocketEvents } from './websocket'; -export enum BucketPosition { - Above = 'above', - Below = 'below', - Visible = 'visible', - Unknown = 'unknown', -} type AssetApiGetTimeBucketsRequest = Parameters[0]; export type AssetStoreOptions = Omit; +const LAYOUT_OPTIONS = { + boxSpacing: 2, + containerPadding: 0, + targetRowHeightTolerance: 0.15, + targetRowHeight: 235, +}; + export interface Viewport { width: number; height: number; } +export type ViewportXY = Viewport & { + x: number; + y: number; +}; interface AssetLookup { bucket: AssetBucket; @@ -29,16 +39,89 @@ interface AssetLookup { } export class AssetBucket { + store!: AssetStore; + bucketDate!: string; /** * The DOM height of the bucket in pixel * This value is first estimated by the number of asset and later is corrected as the user scroll */ - bucketHeight!: number; - bucketDate!: string; - bucketCount!: number; - assets!: AssetResponseDto[]; - cancelToken!: AbortController | null; - position!: BucketPosition; + bucketHeight: number = 0; + isBucketHeightActual: boolean = false; + bucketDateFormattted!: string; + bucketCount: number = 0; + assets: AssetResponseDto[] = []; + dateGroups: DateGroup[] = []; + cancelToken: AbortController | undefined; + /** + * Prevent this asset's load from being canceled; i.e. to force load of offscreen asset. + */ + isPreventCancel: boolean = false; + /** + * A promise that resolves once the bucket is loaded, and rejects if bucket is canceled. + */ + complete!: Promise; + loading: boolean = false; + isLoaded: boolean = false; + intersecting: boolean = false; + measured: boolean = false; + measuredPromise!: Promise; + + constructor(props: Partial & { store: AssetStore; bucketDate: string }) { + Object.assign(this, props); + this.init(); + } + + private init() { + // create a promise, and store its resolve/reject callbacks. The loadedSignal callback + // will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal + // callback will be called if the bucket is canceled before it was loaded, rejecting the + // promise. + this.complete = new Promise((resolve, reject) => { + this.loadedSignal = resolve; + this.canceledSignal = reject; + }); + // if no-one waits on complete, and its rejected a uncaught rejection message is logged. + // We this message with an empty reject handler, since waiting on a bucket is optional. + this.complete.catch(() => void 0); + this.measuredPromise = new Promise((resolve) => { + this.measuredSignal = resolve; + }); + + this.bucketDateFormattted = fromLocalDateTime(this.bucketDate) + .startOf('month') + .toJSDate() + .toLocaleString(get(locale), { + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }); + } + + private loadedSignal: (() => void) | undefined; + private canceledSignal: (() => void) | undefined; + measuredSignal: (() => void) | undefined; + + cancel() { + if (this.isLoaded) { + return; + } + if (this.isPreventCancel) { + return; + } + this.cancelToken?.abort(); + this.canceledSignal?.(); + this.init(); + } + + loaded() { + this.loadedSignal?.(); + this.isLoaded = true; + } + + errored() { + this.canceledSignal?.(); + this.init(); + } } const isMismatched = (option: boolean | undefined, value: boolean): boolean => @@ -65,34 +148,101 @@ interface TrashAssets { type: 'trash'; values: string[]; } +interface UpdateStackAssets { + type: 'update_stack_assets'; + values: string[]; +} export const photoViewer = writable(null); -type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets; +type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets; + +export type BucketListener = ( + event: + | ViewPortEvent + | BucketLoadEvent + | BucketLoadedEvent + | BucketCancelEvent + | BucketHeightEvent + | DateGroupIntersecting + | DateGroupHeightEvent, +) => void; + +type ViewPortEvent = { + type: 'viewport'; +}; +type BucketLoadEvent = { + type: 'load'; + bucket: AssetBucket; +}; +type BucketLoadedEvent = { + type: 'loaded'; + bucket: AssetBucket; +}; +type BucketCancelEvent = { + type: 'cancel'; + bucket: AssetBucket; +}; +type BucketHeightEvent = { + type: 'bucket-height'; + bucket: AssetBucket; + delta: number; +}; +type DateGroupIntersecting = { + type: 'intersecting'; + bucket: AssetBucket; + dateGroup: DateGroup; +}; +type DateGroupHeightEvent = { + type: 'height'; + bucket: AssetBucket; + dateGroup: DateGroup; + delta: number; + height: number; +}; export class AssetStore { - private store$ = writable(this); private assetToBucket: Record = {}; private pendingChanges: PendingChange[] = []; private unsubscribers: Unsubscriber[] = []; private options: AssetApiGetTimeBucketsRequest; + private viewport: Viewport = { + height: 0, + width: 0, + }; + private initializedSignal!: () => void; + private store$ = writable(this); + lastScrollTime: number = 0; + subscribe = this.store$.subscribe; + /** + * A promise that resolves once the store is initialized. + */ + taskManager = new AssetGridTaskManager(this); + complete!: Promise; initialized = false; timelineHeight = 0; buckets: AssetBucket[] = []; assets: AssetResponseDto[] = []; albumAssets: Set = new Set(); + pendingScrollBucket: AssetBucket | undefined; + pendingScrollAssetId: string | undefined; + + listeners: BucketListener[] = []; constructor( options: AssetStoreOptions, private albumId?: string, ) { this.options = { ...options, size: TimeBucketSize.Month }; + // create a promise, and store its resolve callbacks. The initializedSignal callback + // will be invoked when a the assetstore is initialized. + this.complete = new Promise((resolve) => { + this.initializedSignal = resolve; + }); this.store$.set(this); } - subscribe = this.store$.subscribe; - private addPendingChanges(...changes: PendingChange[]) { // prevent websocket events from happening before local client events setTimeout(() => { @@ -182,8 +332,35 @@ export class AssetStore { this.emit(true); }, 2500); - async init(viewport: Viewport) { - this.initialized = false; + addListener(bucketListener: BucketListener) { + this.listeners.push(bucketListener); + } + removeListener(bucketListener: BucketListener) { + this.listeners = this.listeners.filter((l) => l != bucketListener); + } + private notifyListeners( + event: + | ViewPortEvent + | BucketLoadEvent + | BucketLoadedEvent + | BucketCancelEvent + | BucketHeightEvent + | DateGroupIntersecting + | DateGroupHeightEvent, + ) { + for (const fn of this.listeners) { + fn(event); + } + } + async init({ bucketListener }: { bucketListener?: BucketListener } = {}) { + if (this.initialized) { + throw 'Can only init once'; + } + if (bucketListener) { + this.addListener(bucketListener); + } + // uncaught rejection go away + this.complete.catch(() => void 0); this.timelineHeight = 0; this.buckets = []; this.assets = []; @@ -194,65 +371,118 @@ export class AssetStore { ...this.options, key: getKey(), }); - + this.buckets = timebuckets.map( + (bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, bucketCount: bucket.count }), + ); + this.initializedSignal(); this.initialized = true; - - this.buckets = timebuckets.map((bucket) => ({ - bucketDate: bucket.timeBucket, - bucketHeight: 0, - bucketCount: bucket.count, - assets: [], - cancelToken: null, - position: BucketPosition.Unknown, - })); - - // if loading an asset, the grid-view may be hidden, which means - // it has 0 width and height. No need to update bucket or timeline - // heights in this case. Later, updateViewport will be called to - // update the heights. - if (viewport.height !== 0 && viewport.width !== 0) { - await this.updateViewport(viewport); - } } - async updateViewport(viewport: Viewport) { + public destroy() { + this.taskManager.destroy(); + this.listeners = []; + this.initialized = false; + } + + async updateViewport(viewport: Viewport, force?: boolean) { + if (!this.initialized) { + return; + } + if (viewport.height === 0 && viewport.width === 0) { + return; + } + + if (!force && this.viewport.height === viewport.height && this.viewport.width === viewport.width) { + return; + } + + // changing width invalidates the actual height, and needs to be remeasured, since width changes causes + // layout reflows. + const changedWidth = this.viewport.width != viewport.width; + this.viewport = { ...viewport }; + for (const bucket of this.buckets) { - const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); - const rows = Math.ceil(unwrappedWidth / viewport.width); - const height = rows * THUMBNAIL_HEIGHT; - bucket.bucketHeight = height; + this.updateGeometry(bucket, changedWidth); } this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0); - let height = 0; const loaders = []; + let height = 0; for (const bucket of this.buckets) { - if (height < viewport.height) { - height += bucket.bucketHeight; - loaders.push(this.loadBucket(bucket.bucketDate, BucketPosition.Visible)); - continue; + if (height >= viewport.height) { + break; } - break; + height += bucket.bucketHeight; + loaders.push(this.loadBucket(bucket.bucketDate)); } await Promise.all(loaders); + this.notifyListeners({ type: 'viewport' }); this.emit(false); } - async loadBucket(bucketDate: string, position: BucketPosition): Promise { + private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) { + if (invalidateHeight) { + bucket.isBucketHeightActual = false; + bucket.measured = false; + for (const assetGroup of bucket.dateGroups) { + assetGroup.heightActual = false; + } + } + if (!bucket.isBucketHeightActual) { + const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); + const rows = Math.ceil(unwrappedWidth / this.viewport.width); + const height = 51 + rows * THUMBNAIL_HEIGHT; + bucket.bucketHeight = height; + } + + for (const assetGroup of bucket.dateGroups) { + if (!assetGroup.heightActual) { + const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10); + const rows = Math.ceil(unwrappedWidth / this.viewport.width); + const height = rows * THUMBNAIL_HEIGHT; + assetGroup.height = height; + } + + const layoutResult = createJustifiedLayout( + assetGroup.assets.map((g) => getAssetRatio(g)), + { + ...LAYOUT_OPTIONS, + containerWidth: Math.floor(this.viewport.width), + }, + ); + assetGroup.geometry = { + ...layoutResult, + containerWidth: calculateWidth(layoutResult.boxes), + }; + } + } + + async loadBucket(bucketDate: string, options: { preventCancel?: boolean; pending?: boolean } = {}): Promise { const bucket = this.getBucketByDate(bucketDate); if (!bucket) { return; } - - bucket.position = position; - - if (bucket.cancelToken || bucket.assets.length > 0) { - this.emit(false); + if (bucket.bucketCount === bucket.assets.length) { + // already loaded return; } - bucket.cancelToken = new AbortController(); + if (bucket.cancelToken != null && bucket.bucketCount !== bucket.assets.length) { + // if promise is pending, and preventCancel is requested, then don't overwrite it + if (!bucket.isPreventCancel && options.preventCancel) { + bucket.isPreventCancel = options.preventCancel; + } + await bucket.complete; + return; + } + if (options.pending) { + this.pendingScrollBucket = bucket; + } + this.notifyListeners({ type: 'load', bucket }); + bucket.isPreventCancel = !!options.preventCancel; + + const cancelToken = (bucket.cancelToken = new AbortController()); try { const assets = await getTimeBucket( { @@ -260,9 +490,14 @@ export class AssetStore { timeBucket: bucketDate, key: getKey(), }, - { signal: bucket.cancelToken.signal }, + { signal: cancelToken.signal }, ); + if (cancelToken.signal.aborted) { + this.notifyListeners({ type: 'cancel', bucket }); + return; + } + if (this.albumId) { const albumAssets = await getTimeBucket( { @@ -271,50 +506,87 @@ export class AssetStore { size: this.options.size, key: getKey(), }, - { signal: bucket.cancelToken.signal }, + { signal: cancelToken.signal }, ); - + if (cancelToken.signal.aborted) { + this.notifyListeners({ type: 'cancel', bucket }); + return; + } for (const asset of albumAssets) { this.albumAssets.add(asset.id); } } - if (bucket.cancelToken.signal.aborted) { + bucket.assets = assets; + bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); + this.updateGeometry(bucket, true); + this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0); + bucket.loaded(); + this.notifyListeners({ type: 'loaded', bucket }); + } catch (error) { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + if ((error as any).name === 'AbortError') { return; } - - bucket.assets = assets; - - this.emit(true); - } catch (error) { const $t = get(t); handleError(error, $t('errors.failed_to_load_assets')); + bucket.errored(); } finally { - bucket.cancelToken = null; + bucket.cancelToken = undefined; + this.emit(true); } } - cancelBucket(bucket: AssetBucket) { - bucket.cancelToken?.abort(); - } - - updateBucket(bucketDate: string, height: number) { + updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) { const bucket = this.getBucketByDate(bucketDate); if (!bucket) { - return 0; + return {}; + } + let delta = 0; + if ('height' in properties) { + const height = properties.height!; + delta = height - bucket.bucketHeight; + bucket.isBucketHeightActual = true; + bucket.bucketHeight = height; + this.timelineHeight += delta; + this.notifyListeners({ type: 'bucket-height', bucket, delta }); + } + if ('intersecting' in properties) { + bucket.intersecting = properties.intersecting!; + } + if ('measured' in properties) { + if (properties.measured) { + bucket.measuredSignal?.(); + } + bucket.measured = properties.measured!; } - - const delta = height - bucket.bucketHeight; - const scrollTimeline = bucket.position == BucketPosition.Above; - - bucket.bucketHeight = height; - bucket.position = BucketPosition.Unknown; - - this.timelineHeight += delta; - this.emit(false); + return { delta }; + } - return scrollTimeline ? delta : 0; + updateBucketDateGroup( + bucket: AssetBucket, + dateGroup: DateGroup, + properties: { height?: number; intersecting?: boolean }, + ) { + let delta = 0; + if ('height' in properties) { + const height = properties.height!; + if (height > 0) { + delta = height - dateGroup.height; + dateGroup.heightActual = true; + dateGroup.height = height; + this.notifyListeners({ type: 'height', bucket, dateGroup, delta, height }); + } + } + if ('intersecting' in properties) { + dateGroup.intersecting = properties.intersecting!; + if (dateGroup.intersecting) { + this.notifyListeners({ type: 'intersecting', bucket, dateGroup }); + } + } + this.emit(false); + return { delta }; } addAssets(assets: AssetResponseDto[]) { @@ -354,15 +626,7 @@ export class AssetStore { let bucket = this.getBucketByDate(timeBucket); if (!bucket) { - bucket = { - bucketDate: timeBucket, - bucketHeight: THUMBNAIL_HEIGHT, - bucketCount: 0, - assets: [], - cancelToken: null, - position: BucketPosition.Unknown, - }; - + bucket = new AssetBucket({ store: this, bucketDate: timeBucket, bucketHeight: THUMBNAIL_HEIGHT }); this.buckets.push(bucket); } @@ -383,6 +647,8 @@ export class AssetStore { const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC(); return bDate.diff(aDate).milliseconds; }); + bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); + this.updateGeometry(bucket, true); } this.emit(true); @@ -392,18 +658,73 @@ export class AssetStore { return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null; } - async getBucketInfoForAssetId({ id, localDateTime }: Pick) { + async findAndLoadBucketAsPending(id: string) { const bucketInfo = this.assetToBucket[id]; if (bucketInfo) { - return bucketInfo; + const bucket = bucketInfo.bucket; + this.pendingScrollBucket = bucket; + this.pendingScrollAssetId = id; + this.emit(false); + return bucket; } + const asset = await getAssetInfo({ id }); + if (asset) { + if (this.options.isArchived !== asset.isArchived) { + return; + } + const bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true }); + if (bucket) { + this.pendingScrollBucket = bucket; + this.pendingScrollAssetId = asset.id; + this.emit(false); + } + return bucket; + } + } + + /* Must be paired with matching clearPendingScroll() call */ + async scheduleScrollToAssetId(scrollTarget: AssetGridRouteSearchParams, onFailure: () => void) { + try { + const { at: assetId } = scrollTarget; + if (assetId) { + await this.complete; + const bucket = await this.findAndLoadBucketAsPending(assetId); + if (bucket) { + return; + } + } + } catch { + // failure + } + onFailure(); + } + + clearPendingScroll() { + this.pendingScrollBucket = undefined; + this.pendingScrollAssetId = undefined; + } + + private async loadBucketAtTime(localDateTime: string, options: { preventCancel?: boolean; pending?: boolean }) { let date = fromLocalDateTime(localDateTime); if (this.options.size == TimeBucketSize.Month) { date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }); } else if (this.options.size == TimeBucketSize.Day) { date = date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); } - await this.loadBucket(date.toISO()!, BucketPosition.Unknown); + const iso = date.toISO()!; + await this.loadBucket(iso, options); + return this.getBucketByDate(iso); + } + + private async getBucketInfoForAsset( + { id, localDateTime }: Pick, + options: { preventCancel?: boolean; pending?: boolean } = {}, + ) { + const bucketInfo = this.assetToBucket[id]; + if (bucketInfo) { + return bucketInfo; + } + await this.loadBucketAtTime(localDateTime, options); return this.assetToBucket[id] || null; } @@ -417,7 +738,7 @@ export class AssetStore { ); for (const bucket of this.buckets) { if (index < bucket.bucketCount) { - await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown); + await this.loadBucket(bucket.bucketDate); return bucket.assets[index] || null; } @@ -458,6 +779,7 @@ export class AssetStore { // Iterate in reverse to allow array splicing. for (let index = this.buckets.length - 1; index >= 0; index--) { const bucket = this.buckets[index]; + let changed = false; for (let index_ = bucket.assets.length - 1; index_ >= 0; index_--) { const asset = bucket.assets[index_]; if (!idSet.has(asset.id)) { @@ -465,17 +787,22 @@ export class AssetStore { } bucket.assets.splice(index_, 1); + changed = true; if (bucket.assets.length === 0) { this.buckets.splice(index, 1); } } + if (changed) { + bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); + this.updateGeometry(bucket, true); + } } this.emit(true); } async getPreviousAsset(asset: AssetResponseDto): Promise { - const info = await this.getBucketInfoForAssetId(asset); + const info = await this.getBucketInfoForAsset(asset); if (!info) { return null; } @@ -491,12 +818,12 @@ export class AssetStore { } const previousBucket = this.buckets[bucketIndex - 1]; - await this.loadBucket(previousBucket.bucketDate, BucketPosition.Unknown); + await this.loadBucket(previousBucket.bucketDate); return previousBucket.assets.at(-1) || null; } async getNextAsset(asset: AssetResponseDto): Promise { - const info = await this.getBucketInfoForAssetId(asset); + const info = await this.getBucketInfoForAsset(asset); if (!info) { return null; } @@ -512,7 +839,7 @@ export class AssetStore { } const nextBucket = this.buckets[bucketIndex + 1]; - await this.loadBucket(nextBucket.bucketDate, BucketPosition.Unknown); + await this.loadBucket(nextBucket.bucketDate); return nextBucket.assets[0] || null; } @@ -537,8 +864,7 @@ export class AssetStore { } this.assetToBucket = assetToBucket; } - - this.store$.update(() => this); + this.store$.set(this); } } diff --git a/web/src/lib/utils/asset-store-task-manager.ts b/web/src/lib/utils/asset-store-task-manager.ts new file mode 100644 index 0000000000..6ece1327c4 --- /dev/null +++ b/web/src/lib/utils/asset-store-task-manager.ts @@ -0,0 +1,465 @@ +import type { AssetBucket, AssetStore } from '$lib/stores/assets.store'; +import { generateId } from '$lib/utils/generate-id'; +import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support'; +import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue'; +import { type DateGroup } from '$lib/utils/timeline-util'; +import { TUNABLES } from '$lib/utils/tunables'; +import { type AssetResponseDto } from '@immich/sdk'; +import { clamp } from 'lodash-es'; + +type Task = () => void; + +class InternalTaskManager { + assetStore: AssetStore; + componentTasks = new Map>(); + priorityQueue = new KeyedPriorityQueue(); + idleQueue = new Map(); + taskCleaners = new Map(); + + queueTimer: ReturnType | undefined; + lastIdle: number | undefined; + + constructor(assetStore: AssetStore) { + this.assetStore = assetStore; + } + destroy() { + this.componentTasks.clear(); + this.priorityQueue.clear(); + this.idleQueue.clear(); + this.taskCleaners.clear(); + clearTimeout(this.queueTimer); + if (this.lastIdle) { + cancelIdleCB(this.lastIdle); + } + } + getOrCreateComponentTasks(componentId: string) { + let componentTaskSet = this.componentTasks.get(componentId); + if (!componentTaskSet) { + componentTaskSet = new Set(); + this.componentTasks.set(componentId, componentTaskSet); + } + + return componentTaskSet; + } + deleteFromComponentTasks(componentId: string, taskId: string) { + if (this.componentTasks.has(componentId)) { + const componentTaskSet = this.componentTasks.get(componentId); + componentTaskSet?.delete(taskId); + if (componentTaskSet?.size === 0) { + this.componentTasks.delete(componentId); + } + } + } + + drainIntersectedQueue() { + let count = 0; + for (let t = this.priorityQueue.shift(); t; t = this.priorityQueue.shift()) { + t.value(); + if (this.taskCleaners.has(t.key)) { + this.taskCleaners.get(t.key)!(); + this.taskCleaners.delete(t.key); + } + if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) { + this.scheduleDrainIntersectedQueue(TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS); + break; + } + } + } + + scheduleDrainIntersectedQueue(delay: number = TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS) { + clearTimeout(this.queueTimer); + this.queueTimer = setTimeout(() => { + const delta = Date.now() - this.assetStore.lastScrollTime; + if (delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) { + let amount = clamp( + 1 + Math.round(this.priorityQueue.length / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR), + 1, + TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS * 2, + ); + + const nextDelay = clamp( + amount > 1 + ? Math.round(delay / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR) + : TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS, + TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY, + TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY, + ); + + while (amount > 0) { + this.priorityQueue.shift()?.value(); + amount--; + } + if (this.priorityQueue.length > 0) { + this.scheduleDrainIntersectedQueue(nextDelay); + } + } else { + this.drainIntersectedQueue(); + } + }, delay); + } + + removeAllTasksForComponent(componentId: string) { + if (this.componentTasks.has(componentId)) { + const tasksIds = this.componentTasks.get(componentId) || []; + for (const taskId of tasksIds) { + this.priorityQueue.remove(taskId); + this.idleQueue.delete(taskId); + if (this.taskCleaners.has(taskId)) { + const cleanup = this.taskCleaners.get(taskId); + this.taskCleaners.delete(taskId); + cleanup!(); + } + } + } + this.componentTasks.delete(componentId); + } + + queueScrollSensitiveTask({ + task, + cleanup, + componentId, + priority = 10, + taskId = generateId(), + }: { + task: Task; + cleanup?: Task; + componentId: string; + priority?: number; + taskId?: string; + }) { + this.priorityQueue.push(taskId, task, priority); + if (cleanup) { + this.taskCleaners.set(taskId, cleanup); + } + this.getOrCreateComponentTasks(componentId).add(taskId); + const lastTime = this.assetStore.lastScrollTime; + const delta = Date.now() - lastTime; + if (lastTime != 0 && delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) { + this.scheduleDrainIntersectedQueue(); + } else { + // flush the queue early + clearTimeout(this.queueTimer); + this.drainIntersectedQueue(); + } + } + + scheduleDrainSeparatedQueue() { + if (this.lastIdle) { + cancelIdleCB(this.lastIdle); + } + this.lastIdle = idleCB( + () => { + let count = 0; + let entry = this.idleQueue.entries().next().value; + while (entry) { + const [taskId, task] = entry; + this.idleQueue.delete(taskId); + task(); + if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) { + break; + } + entry = this.idleQueue.entries().next().value; + } + if (this.idleQueue.size > 0) { + this.scheduleDrainSeparatedQueue(); + } + }, + { timeout: 1000 }, + ); + } + queueSeparateTask({ + task, + cleanup, + componentId, + taskId, + }: { + task: Task; + cleanup: Task; + componentId: string; + taskId: string; + }) { + this.idleQueue.set(taskId, task); + this.taskCleaners.set(taskId, cleanup); + this.getOrCreateComponentTasks(componentId).add(taskId); + this.scheduleDrainSeparatedQueue(); + } + + removeIntersectedTask(taskId: string) { + const removed = this.priorityQueue.remove(taskId); + if (this.taskCleaners.has(taskId)) { + const cleanup = this.taskCleaners.get(taskId); + this.taskCleaners.delete(taskId); + cleanup!(); + } + return removed; + } + + removeSeparateTask(taskId: string) { + const removed = this.idleQueue.delete(taskId); + if (this.taskCleaners.has(taskId)) { + const cleanup = this.taskCleaners.get(taskId); + this.taskCleaners.delete(taskId); + cleanup!(); + } + return removed; + } +} + +export class AssetGridTaskManager { + private internalManager: InternalTaskManager; + constructor(assetStore: AssetStore) { + this.internalManager = new InternalTaskManager(assetStore); + } + + tasks: Map = new Map(); + + queueScrollSensitiveTask({ + task, + cleanup, + componentId, + priority = 10, + taskId = generateId(), + }: { + task: Task; + cleanup?: Task; + componentId: string; + priority?: number; + taskId?: string; + }) { + return this.internalManager.queueScrollSensitiveTask({ task, cleanup, componentId, priority, taskId }); + } + + removeAllTasksForComponent(componentId: string) { + return this.internalManager.removeAllTasksForComponent(componentId); + } + + destroy() { + return this.internalManager.destroy(); + } + + private getOrCreateBucketTask(bucket: AssetBucket) { + let bucketTask = this.tasks.get(bucket); + if (!bucketTask) { + bucketTask = this.createBucketTask(bucket); + } + return bucketTask; + } + + private createBucketTask(bucket: AssetBucket) { + const bucketTask = new BucketTask(this.internalManager, this, bucket); + this.tasks.set(bucket, bucketTask); + return bucketTask; + } + + intersectedBucket(componentId: string, bucket: AssetBucket, task: Task) { + const bucketTask = this.getOrCreateBucketTask(bucket); + bucketTask.scheduleIntersected(componentId, task); + } + + seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) { + const bucketTask = this.getOrCreateBucketTask(bucket); + bucketTask.scheduleSeparated(componentId, seperated); + } + + intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + bucketTask.intersectedDateGroup(componentId, dateGroup, intersected); + } + + seperatedDateGroup(componentId: string, dateGroup: DateGroup, seperated: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + bucketTask.separatedDateGroup(componentId, dateGroup, seperated); + } + + intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.intersectedThumbnail(componentId, asset, intersected); + } + + seperatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, seperated: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.separatedThumbnail(componentId, asset, seperated); + } +} + +class IntersectionTask { + internalTaskManager: InternalTaskManager; + seperatedKey; + intersectedKey; + priority; + + intersected: Task | undefined; + separated: Task | undefined; + + constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) { + this.internalTaskManager = internalTaskManager; + this.seperatedKey = keyPrefix + ':s:' + key; + this.intersectedKey = keyPrefix + ':i:' + key; + this.priority = priority; + } + + trackIntersectedTask(componentId: string, task: Task) { + const execTask = () => { + if (this.separated) { + return; + } + task?.(); + }; + this.intersected = execTask; + const cleanup = () => { + this.intersected = undefined; + this.internalTaskManager.deleteFromComponentTasks(componentId, this.intersectedKey); + }; + return { task: execTask, cleanup }; + } + + trackSeperatedTask(componentId: string, task: Task) { + const execTask = () => { + if (this.intersected) { + return; + } + task?.(); + }; + this.separated = execTask; + const cleanup = () => { + this.separated = undefined; + this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey); + }; + return { task: execTask, cleanup }; + } + + removePendingSeparated() { + if (this.separated) { + this.internalTaskManager.removeSeparateTask(this.seperatedKey); + } + } + removePendingIntersected() { + if (this.intersected) { + this.internalTaskManager.removeIntersectedTask(this.intersectedKey); + } + } + + scheduleIntersected(componentId: string, intersected: Task) { + this.removePendingSeparated(); + if (this.intersected) { + return; + } + const { task, cleanup } = this.trackIntersectedTask(componentId, intersected); + this.internalTaskManager.queueScrollSensitiveTask({ + task, + cleanup, + componentId: componentId, + priority: this.priority, + taskId: this.intersectedKey, + }); + } + + scheduleSeparated(componentId: string, separated: Task) { + this.removePendingIntersected(); + + if (this.separated) { + return; + } + + const { task, cleanup } = this.trackSeperatedTask(componentId, separated); + this.internalTaskManager.queueSeparateTask({ + task, + cleanup, + componentId: componentId, + taskId: this.seperatedKey, + }); + } +} +class BucketTask extends IntersectionTask { + assetBucket: AssetBucket; + assetGridTaskManager: AssetGridTaskManager; + // indexed by dateGroup's date + dateTasks: Map = new Map(); + + constructor(internalTaskManager: InternalTaskManager, parent: AssetGridTaskManager, assetBucket: AssetBucket) { + super(internalTaskManager, 'b', assetBucket.bucketDate, TUNABLES.BUCKET.PRIORITY); + this.assetBucket = assetBucket; + this.assetGridTaskManager = parent; + } + + getOrCreateDateGroupTask(dateGroup: DateGroup) { + let dateGroupTask = this.dateTasks.get(dateGroup); + if (!dateGroupTask) { + dateGroupTask = this.createDateGroupTask(dateGroup); + } + return dateGroupTask; + } + + createDateGroupTask(dateGroup: DateGroup) { + const dateGroupTask = new DateGroupTask(this.internalTaskManager, this, dateGroup); + this.dateTasks.set(dateGroup, dateGroupTask); + return dateGroupTask; + } + + removePendingSeparated() { + super.removePendingSeparated(); + for (const dateGroupTask of this.dateTasks.values()) { + dateGroupTask.removePendingSeparated(); + } + } + + intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) { + const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.scheduleIntersected(componentId, intersected); + } + + separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) { + const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.scheduleSeparated(componentId, separated); + } +} +class DateGroupTask extends IntersectionTask { + dateGroup: DateGroup; + bucketTask: BucketTask; + // indexed by thumbnail's asset + thumbnailTasks: Map = new Map(); + + constructor(internalTaskManager: InternalTaskManager, parent: BucketTask, dateGroup: DateGroup) { + super(internalTaskManager, 'dg', dateGroup.date.toString(), TUNABLES.DATEGROUP.PRIORITY); + this.dateGroup = dateGroup; + this.bucketTask = parent; + } + + removePendingSeparated() { + super.removePendingSeparated(); + for (const thumbnailTask of this.thumbnailTasks.values()) { + thumbnailTask.removePendingSeparated(); + } + } + + getOrCreateThumbnailTask(asset: AssetResponseDto) { + let thumbnailTask = this.thumbnailTasks.get(asset); + if (!thumbnailTask) { + thumbnailTask = new ThumbnailTask(this.internalTaskManager, this, asset); + this.thumbnailTasks.set(asset, thumbnailTask); + } + return thumbnailTask; + } + + intersectedThumbnail(componentId: string, asset: AssetResponseDto, intersected: Task) { + const thumbnailTask = this.getOrCreateThumbnailTask(asset); + thumbnailTask.scheduleIntersected(componentId, intersected); + } + + separatedThumbnail(componentId: string, asset: AssetResponseDto, seperated: Task) { + const thumbnailTask = this.getOrCreateThumbnailTask(asset); + thumbnailTask.scheduleSeparated(componentId, seperated); + } +} +class ThumbnailTask extends IntersectionTask { + asset: AssetResponseDto; + dateGroupTask: DateGroupTask; + + constructor(internalTaskManager: InternalTaskManager, parent: DateGroupTask, asset: AssetResponseDto) { + super(internalTaskManager, 't', asset.id, TUNABLES.THUMBNAIL.PRIORITY); + this.asset = asset; + this.dateGroupTask = parent; + } +} diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 2722745317..576b14b201 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -4,7 +4,7 @@ import { NotificationType, notificationController } from '$lib/components/shared import { AppRoute } from '$lib/constants'; import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; -import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; +import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { downloadManager } from '$lib/stores/download'; import { preferences } from '$lib/stores/user.store'; import { downloadRequest, getKey, withError } from '$lib/utils'; @@ -403,7 +403,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt try { for (const bucket of assetStore.buckets) { - await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown); + await assetStore.loadBucket(bucket.bucketDate); if (!get(isSelectingAllAssets)) { break; // Cancelled diff --git a/web/src/lib/utils/idle-callback-support.ts b/web/src/lib/utils/idle-callback-support.ts new file mode 100644 index 0000000000..0f7f060084 --- /dev/null +++ b/web/src/lib/utils/idle-callback-support.ts @@ -0,0 +1,20 @@ +interface RequestIdleCallback { + didTimeout?: boolean; + timeRemaining?(): DOMHighResTimeStamp; +} +interface RequestIdleCallbackOptions { + timeout?: number; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function fake_requestIdleCallback(cb: (deadline: RequestIdleCallback) => any, _?: RequestIdleCallbackOptions) { + const start = Date.now(); + return setTimeout(cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) }), 100); +} + +function fake_cancelIdleCallback(id: number) { + return clearTimeout(id); +} + +export const idleCB = window.requestIdleCallback || fake_requestIdleCallback; +export const cancelIdleCB = window.cancelIdleCallback || fake_cancelIdleCallback; diff --git a/web/src/lib/utils/keyed-priority-queue.ts b/web/src/lib/utils/keyed-priority-queue.ts new file mode 100644 index 0000000000..2483b22c6d --- /dev/null +++ b/web/src/lib/utils/keyed-priority-queue.ts @@ -0,0 +1,50 @@ +export class KeyedPriorityQueue { + private items: { key: K; value: T; priority: number }[] = []; + private set: Set = new Set(); + + clear() { + this.items = []; + this.set.clear(); + } + + remove(key: K) { + const removed = this.set.delete(key); + if (removed) { + const idx = this.items.findIndex((i) => i.key === key); + if (idx >= 0) { + this.items.splice(idx, 1); + } + } + return removed; + } + + push(key: K, value: T, priority: number) { + if (this.set.has(key)) { + return this.length; + } + for (let i = 0; i < this.items.length; i++) { + if (this.items[i].priority > priority) { + this.set.add(key); + this.items.splice(i, 0, { key, value, priority }); + return this.length; + } + } + this.set.add(key); + return this.items.push({ key, value, priority }); + } + + shift() { + let item = this.items.shift(); + while (item) { + if (this.set.has(item.key)) { + this.set.delete(item.key); + return item; + } + item = this.items.shift(); + } + } + + get length() { + return this.set.size; + } +} diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts index 4d5660f173..304376b347 100644 --- a/web/src/lib/utils/navigation.ts +++ b/web/src/lib/utils/navigation.ts @@ -5,6 +5,9 @@ import { getAssetInfo } from '@immich/sdk'; import type { NavigationTarget } from '@sveltejs/kit'; import { get } from 'svelte/store'; +export type AssetGridRouteSearchParams = { + at: string | null | undefined; +}; export const isExternalUrl = (url: string): boolean => { return new URL(url, window.location.href).origin !== window.location.origin; }; @@ -33,17 +36,38 @@ function currentUrlWithoutAsset() { export function currentUrlReplaceAssetId(assetId: string) { const $page = get(page); + const params = new URLSearchParams($page.url.search); + // always remove the assetGridScrollTargetParams + params.delete('at'); + const searchparams = params.size > 0 ? '?' + params.toString() : ''; // this contains special casing for the /photos/:assetId photos route, which hangs directly // off / instead of a subpath, unlike every other asset-containing route. return isPhotosRoute($page.route.id) - ? `${AppRoute.PHOTOS}/${assetId}${$page.url.search}` - : `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${$page.url.search}`; + ? `${AppRoute.PHOTOS}/${assetId}${searchparams}` + : `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${searchparams}`; +} + +function replaceScrollTarget(url: string, searchParams?: AssetGridRouteSearchParams | null) { + const $page = get(page); + const parsed = new URL(url, $page.url); + + const { at: assetId } = searchParams || { at: null }; + + if (!assetId) { + return parsed.pathname; + } + + const params = new URLSearchParams($page.url.search); + if (assetId) { + params.set('at', assetId); + } + return parsed.pathname + '?' + params.toString(); } function currentUrl() { const $page = get(page); const current = $page.url; - return current.pathname + current.search; + return current.pathname + current.search + current.hash; } interface Route { @@ -55,24 +79,58 @@ interface Route { interface AssetRoute extends Route { targetRoute: 'current'; - assetId: string | null; + assetId: string | null | undefined; } +interface AssetGridRoute extends Route { + targetRoute: 'current'; + assetId: string | null | undefined; + assetGridRouteSearchParams: AssetGridRouteSearchParams | null | undefined; +} + +type ImmichRoute = AssetRoute | AssetGridRoute; + +type NavOptions = { + /* navigate even if url is the same */ + forceNavigate?: boolean | undefined; + replaceState?: boolean | undefined; + noScroll?: boolean | undefined; + keepFocus?: boolean | undefined; + invalidateAll?: boolean | undefined; + state?: App.PageState | undefined; +}; function isAssetRoute(route: Route): route is AssetRoute { return route.targetRoute === 'current' && 'assetId' in route; } -async function navigateAssetRoute(route: AssetRoute) { +function isAssetGridRoute(route: Route): route is AssetGridRoute { + return route.targetRoute === 'current' && 'assetId' in route && 'assetGridRouteSearchParams' in route; +} + +async function navigateAssetRoute(route: AssetRoute, options?: NavOptions) { const { assetId } = route; const next = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset(); - if (next !== currentUrl()) { - await goto(next, { replaceState: false }); + const current = currentUrl(); + if (next !== current || options?.forceNavigate) { + await goto(next, options); } } -export function navigate(change: T): Promise { - if (isAssetRoute(change)) { - return navigateAssetRoute(change); +async function navigateAssetGridRoute(route: AssetGridRoute, options?: NavOptions) { + const { assetId, assetGridRouteSearchParams: assetGridScrollTarget } = route; + const assetUrl = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset(); + const next = replaceScrollTarget(assetUrl, assetGridScrollTarget); + const current = currentUrl(); + if (next !== current || options?.forceNavigate) { + await goto(next, options); + } +} + +export function navigate(change: ImmichRoute, options?: NavOptions): Promise { + if (isAssetGridRoute(change)) { + return navigateAssetGridRoute(change, options); + } else if (isAssetRoute(change)) { + return navigateAssetRoute(change, options); } // future navigation requests here throw `Invalid navigation: ${JSON.stringify(change)}`; diff --git a/web/src/lib/utils/priority-queue.ts b/web/src/lib/utils/priority-queue.ts new file mode 100644 index 0000000000..6b08ffe7ad --- /dev/null +++ b/web/src/lib/utils/priority-queue.ts @@ -0,0 +1,21 @@ +export class PriorityQueue { + private items: { value: T; priority: number }[] = []; + + push(value: T, priority: number) { + for (let i = 0; i < this.items.length; i++) { + if (this.items[i].priority > priority) { + this.items.splice(i, 0, { value, priority }); + return this.length; + } + } + return this.items.push({ value, priority }); + } + + shift() { + return this.items.shift(); + } + + get length() { + return this.items.length; + } +} diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 76a0d1b5cb..3a8f66ee08 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -1,9 +1,38 @@ +import type { AssetBucket } from '$lib/stores/assets.store'; import { locale } from '$lib/stores/preferences.store'; import type { AssetResponseDto } from '@immich/sdk'; -import { groupBy, sortBy } from 'lodash-es'; +import type createJustifiedLayout from 'justified-layout'; +import { groupBy, memoize, sortBy } from 'lodash-es'; import { DateTime } from 'luxon'; import { get } from 'svelte/store'; +export type DateGroup = { + date: DateTime; + groupTitle: string; + assets: AssetResponseDto[]; + height: number; + heightActual: boolean; + intersecting: boolean; + geometry: Geometry; + bucket: AssetBucket; +}; +export type ScrubberListener = ( + bucketDate: string | undefined, + overallScrollPercent: number, + bucketScrollPercent: number, +) => void | Promise; +export type ScrollTargetListener = ({ + bucket, + dateGroup, + asset, + offset, +}: { + bucket: AssetBucket; + dateGroup: DateGroup; + asset: AssetResponseDto; + offset: number; +}) => void; + export const fromLocalDateTime = (localDateTime: string) => DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) }); @@ -48,20 +77,48 @@ export function formatGroupTitle(_date: DateTime): string { return date.toLocaleString(groupDateFormat); } -export function splitBucketIntoDateGroups( - assets: AssetResponseDto[], - locale: string | undefined, -): AssetResponseDto[][] { - const grouped = groupBy(assets, (asset) => +type Geometry = ReturnType & { + containerWidth: number; +}; + +function emptyGeometry() { + return { + containerWidth: 0, + containerHeight: 0, + widowCount: 0, + boxes: [], + }; +} + +const formatDateGroupTitle = memoize(formatGroupTitle); + +export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] { + const grouped = groupBy(bucket.assets, (asset) => fromLocalDateTime(asset.localDateTime).toLocaleString(groupDateFormat, { locale }), ); - return sortBy(grouped, (group) => assets.indexOf(group[0])); + const sorted = sortBy(grouped, (group) => bucket.assets.indexOf(group[0])); + return sorted.map((group) => { + const date = fromLocalDateTime(group[0].localDateTime).startOf('day'); + return { + date, + groupTitle: formatDateGroupTitle(date), + assets: group, + height: 0, + heightActual: false, + intersecting: false, + geometry: emptyGeometry(), + bucket: bucket, + }; + }); } export type LayoutBox = { + aspectRatio: number; top: number; - left: number; width: number; + height: number; + left: number; + forcedAspectRatio?: boolean; }; export function calculateWidth(boxes: LayoutBox[]): number { @@ -71,6 +128,14 @@ export function calculateWidth(boxes: LayoutBox[]): number { width = box.left + box.width; } } - return width; } + +export function findTotalOffset(element: HTMLElement, stop: HTMLElement) { + let offset = 0; + while (element.offsetParent && element !== stop) { + offset += element.offsetTop; + element = element.offsetParent as HTMLElement; + } + return offset; +} diff --git a/web/src/lib/utils/tunables.ts b/web/src/lib/utils/tunables.ts new file mode 100644 index 0000000000..e21c30de77 --- /dev/null +++ b/web/src/lib/utils/tunables.ts @@ -0,0 +1,63 @@ +function getBoolean(string: string | null, fallback: boolean) { + if (string === null) { + return fallback; + } + return 'true' === string; +} +function getNumber(string: string | null, fallback: number) { + if (string === null) { + return fallback; + } + return Number.parseInt(string); +} +function getFloat(string: string | null, fallback: number) { + if (string === null) { + return fallback; + } + return Number.parseFloat(string); +} +export const TUNABLES = { + SCROLL_TASK_QUEUE: { + TRICKLE_BONUS_FACTOR: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR'), 25), + TRICKLE_ACCELERATION_FACTOR: getFloat(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR'), 1.5), + TRICKLE_ACCELERATED_MIN_DELAY: getNumber( + localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY'), + 8, + ), + TRICKLE_ACCELERATED_MAX_DELAY: getNumber( + localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY'), + 2000, + ), + DRAIN_MAX_TASKS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS'), 15), + DRAIN_MAX_TASKS_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS'), 16), + MIN_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.MIN_DELAY_MS')!, 200), + CHECK_INTERVAL_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS'), 16), + }, + INTERSECTION_OBSERVER_QUEUE: { + DRAIN_MAX_TASKS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.DRAIN_MAX_TASKS'), 15), + THROTTLE_MS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE_MS'), 16), + THROTTLE: getBoolean(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE'), true), + }, + ASSET_GRID: { + NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false), + }, + BUCKET: { + PRIORITY: getNumber(localStorage.getItem('BUCKET.PRIORITY'), 2), + INTERSECTION_ROOT_TOP: localStorage.getItem('BUCKET.INTERSECTION_ROOT_TOP') || '300%', + INTERSECTION_ROOT_BOTTOM: localStorage.getItem('BUCKET.INTERSECTION_ROOT_BOTTOM') || '300%', + }, + DATEGROUP: { + PRIORITY: getNumber(localStorage.getItem('DATEGROUP.PRIORITY'), 4), + INTERSECTION_DISABLED: getBoolean(localStorage.getItem('DATEGROUP.INTERSECTION_DISABLED'), false), + INTERSECTION_ROOT_TOP: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_TOP') || '150%', + INTERSECTION_ROOT_BOTTOM: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_BOTTOM') || '150%', + }, + THUMBNAIL: { + PRIORITY: getNumber(localStorage.getItem('THUMBNAIL.PRIORITY'), 8), + INTERSECTION_ROOT_TOP: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_TOP') || '250%', + INTERSECTION_ROOT_BOTTOM: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_BOTTOM') || '250%', + }, + IMAGE_THUMBNAIL: { + THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150), + }, +}; diff --git a/web/src/routes/(user)/+layout.svelte b/web/src/routes/(user)/+layout.svelte index 23f38b86f4..bf24d0e7e4 100644 --- a/web/src/routes/(user)/+layout.svelte +++ b/web/src/routes/(user)/+layout.svelte @@ -1,10 +1,10 @@ diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 9e670f714c..ff5709df99 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -43,7 +43,13 @@ import { downloadAlbum } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; - import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation'; + import { + isAlbumsRoute, + isPeopleRoute, + isSearchRoute, + navigate, + type AssetGridRouteSearchParams, + } from '$lib/utils/navigation'; import { AlbumUserRole, AssetOrder, @@ -78,12 +84,15 @@ import type { PageData } from './$types'; import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; - let { isViewing: showAssetViewer, setAsset } = assetViewingStore; + let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore; let { slideshowState, slideshowNavigation } = slideshowStore; + let oldAt: AssetGridRouteSearchParams | null | undefined; + $: album = data.album; $: albumId = album.id; $: albumKey = `${albumId}_${albumOrder}`; @@ -244,7 +253,7 @@ } if (viewMode === ViewMode.SELECT_ASSETS) { - handleCloseSelectAssets(); + await handleCloseSelectAssets(); return; } if (viewMode === ViewMode.LINK_SHARING) { @@ -289,20 +298,37 @@ timelineInteractionStore.clearMultiselect(); viewMode = ViewMode.VIEW; + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }, + { replaceState: true, forceNavigate: true }, + ); } catch (error) { handleError(error, $t('errors.error_adding_assets_to_album')); } }; - const handleCloseSelectAssets = () => { + const setModeToView = async () => { viewMode = ViewMode.VIEW; + assetStore.destroy(); + assetStore = new AssetStore({ albumId, order: albumOrder }); + timelineStore.destroy(); + timelineStore = new AssetStore({ isArchived: false }, albumId); + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: oldAt?.at } }, + { replaceState: true, forceNavigate: true }, + ); + oldAt = null; + }; + + const handleCloseSelectAssets = async () => { timelineInteractionStore.clearMultiselect(); + await setModeToView(); }; const handleSelectFromComputer = async () => { await openFileUploadDialog({ albumId: album.id }); timelineInteractionStore.clearMultiselect(); - viewMode = ViewMode.VIEW; + await setModeToView(); }; const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => { @@ -400,6 +426,11 @@ await deleteAlbum(album); } }); + + onDestroy(() => { + assetStore.destroy(); + timelineStore.destroy(); + });
    @@ -444,7 +475,14 @@ {#if isEditor} (viewMode = ViewMode.SELECT_ASSETS)} + on:click={async () => { + viewMode = ViewMode.SELECT_ASSETS; + oldAt = { at: $gridScrollTarget?.at }; + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: null } }, + { replaceState: true }, + ); + }} icon={mdiImagePlusOutline} /> {/if} @@ -530,12 +568,14 @@ {#key albumKey} {#if viewMode === ViewMode.SELECT_ASSETS} {:else} asset.isFavorite); + + onDestroy(() => { + assetStore.destroy(); + }); {#if $isMultiSelectState} @@ -45,7 +50,7 @@ {/if} - + diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 49af165ac9..13e70c9161 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -19,6 +19,7 @@ import type { PageData } from './$types'; import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; @@ -27,6 +28,10 @@ const { isMultiSelectState, selectedAssets } = assetInteractionStore; $: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived); + + onDestroy(() => { + assetStore.destroy(); + }); @@ -50,7 +55,7 @@ {/if} - + diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3eb65ca1bd..0ea0ed18bb 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -124,7 +124,10 @@ showNavigation={viewingAssets.length > 1} on:next={navigateNext} on:previous={navigatePrevious} - on:close={() => assetViewingStore.showAssetViewer(false)} + on:close={() => { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} isShared={false} /> {/await} diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 83e2ba3c1f..b580c4faa5 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -23,6 +23,7 @@ onDestroy(() => { assetInteractionStore.clearMultiselect(); + assetStore.destroy(); }); @@ -45,5 +46,5 @@ {/if} - + diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 02afe7f610..26e803deb6 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -52,7 +52,7 @@ mdiEyeOutline, mdiPlus, } from '@mdi/js'; - import { onMount } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import type { PageData } from './$types'; import { listNavigation } from '$lib/actions/list-navigation'; import { t } from 'svelte-i18n'; @@ -155,6 +155,7 @@ } if (previousPersonId !== data.person.id) { handlePromiseError(updateAssetCount()); + assetStore.destroy(); assetStore = new AssetStore({ isArchived: false, personId: data.person.id, @@ -344,6 +345,10 @@ await goto($page.url); } }; + + onDestroy(() => { + assetStore.destroy(); + }); {#if viewMode === ViewMode.UNASSIGN_ASSETS} @@ -442,6 +447,7 @@
    {#key refreshAssetGrid} { + assetStore.destroy(); + }); {#if $isMultiSelectState} @@ -84,6 +89,7 @@ diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5ebb0e294c..f4fac282ba 100644 --- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -11,8 +11,13 @@ import type { PageData } from './$types'; import { setSharedLink } from '$lib/utils'; import { t } from 'svelte-i18n'; + import { navigate } from '$lib/utils/navigation'; + import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { tick } from 'svelte'; export let data: PageData; + + let { gridScrollTarget } = assetViewingStore; let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = data; let { title, description } = meta; let isOwned = $user ? $user.id === sharedLink?.userId : false; @@ -29,6 +34,11 @@ description = sharedLink.description || $t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } }); + await tick(); + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }, + { forceNavigate: true, replaceState: true }, + ); } catch (error) { handleError(error, $t('errors.unable_to_get_shared_link')); } diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2907a542b3..27ad5bb3f0 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -25,6 +25,7 @@ import { handlePromiseError } from '$lib/utils'; import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; @@ -84,6 +85,10 @@ handleError(error, $t('errors.unable_to_restore_trash')); } }; + + onDestroy(() => { + assetStore.destroy(); + }); {#if $isMultiSelectState} @@ -111,7 +116,7 @@
    - +

    {$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}

    diff --git a/web/static/dark_skeleton.png b/web/static/dark_skeleton.png new file mode 100644 index 0000000000000000000000000000000000000000..2a115a849680b3d560afe3f767fec1ff656978f2 GIT binary patch literal 4988 zcmeAS@N?(olHy`uVBq!ia0y~yV7vvw9Be?5_joHcP{l=S2b`op9TstH4m_tCpp@E^%C4n>d zQPva&MkW@HKP>+i*4o5-K&5<{?w^m&Fpr$22vo?(WX`zos|X87PC-QAmcVrrkQM=f zD0{FhixcBQ-a=ntkfA_@%%ciNgNJD}QH*AV(UM}cd>pM6Mk~$Hrow0gX|!25+G+;% zG)DUhqdlb24kV~kJ(?9pdq|@_Bs%nv8ly8Nf=9dk5I#s+gu9;7luw W$ueMl-wGT~WAJqKb6Mw<&;$V8ks&Yu literal 0 HcmV?d00001 diff --git a/web/static/light_skeleton.png b/web/static/light_skeleton.png new file mode 100644 index 0000000000000000000000000000000000000000..22c7eae75473c4b931a67ecb1db6e1773c0c688b GIT binary patch literal 4989 zcmeAS@N?(olHy`uVBq!ia0y~yV7vvw9Be?5#ggMh2!I;Jj%qW@nN0$a2rQkaC6f8TVIsm>tk<=UAu+m#&}7#NvYgc?=1 zbgm9$V&M=__#phF{o7y5i2@2>x!Ejz*Ml?6BWEdsx#o-uzlwmAIVgw-+!DBM0@TeR zAP{8_G02H=A#b6tFj(utugs$gM}vonWi(NYW`)s`0$k9JW`)tLFq#!cv%+WtX|(+~ z+G-x{D~$G#MmvzeF5+mjaI{%C+AO4fv#>Eb12(SpoAuh6GyfvH!9!FH2UI_>-e37t VT{&u5Ja9aX!PC{xWt~$(696s{BJuzL literal 0 HcmV?d00001 From c24cc8a33bacb72f2022d99c69dfcd7b59e90f41 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 22 Aug 2024 07:48:31 -0400 Subject: [PATCH 148/723] chore: ignore sql queries when building docker (#11933) --- .dockerignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.dockerignore b/.dockerignore index a3096e7d40..e182865ae0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,6 +22,7 @@ open-api/typescript-sdk/node_modules/ server/coverage/ server/node_modules/ server/upload/ +server/src/queries server/dist/ server/www/ From 296bbeb2fc79ccdae2f3db3d7100f4ae4236ec93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carles=20Alb=C3=A0s=20Boix?= <43018489+carlesalbasboix@users.noreply.github.com> Date: Thu, 22 Aug 2024 16:40:15 +0200 Subject: [PATCH 149/723] feat(web): Left hand navigation for memories (#11913) --- web/src/lib/components/memory-page/memory-viewer.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 250cb379cc..77dbf5614c 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -153,7 +153,9 @@ canGoForward && toNext() }, + { shortcut: { key: 'd' }, onShortcut: () => canGoForward && toNext() }, { shortcut: { key: 'ArrowLeft' }, onShortcut: () => canGoBack && toPrevious() }, + { shortcut: { key: 'a' }, onShortcut: () => canGoBack && toPrevious() }, { shortcut: { key: 'Escape' }, onShortcut: () => goto(AppRoute.PHOTOS) }, ]} /> From f69ce6ad8a486f8c80b6b03986f7f54ea2702039 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 22 Aug 2024 11:38:19 -0400 Subject: [PATCH 150/723] refactor(web): folder view (#11967) refactor(web): tree view --- .../components/folder-tree/folder-tree.svelte | 65 --------------- .../layouts/user-page-layout.svelte | 4 - .../side-bar/folder-browser-sidebar.svelte | 32 -------- .../side-bar/folder-side-bar.svelte | 8 -- .../tree/tree-item-thumbnails.svelte | 25 ++++++ .../shared-components/tree/tree-items.svelte | 17 ++++ .../shared-components/tree/tree.svelte | 39 +++++++++ web/src/lib/constants.ts | 1 + .../utils/{folder-utils.ts => tree-utils.ts} | 4 +- .../[[assetId=id]]/+page.svelte | 82 +++++++++---------- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 17 ++-- 11 files changed, 135 insertions(+), 159 deletions(-) delete mode 100644 web/src/lib/components/folder-tree/folder-tree.svelte delete mode 100644 web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte delete mode 100644 web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte create mode 100644 web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte create mode 100644 web/src/lib/components/shared-components/tree/tree-items.svelte create mode 100644 web/src/lib/components/shared-components/tree/tree.svelte rename web/src/lib/utils/{folder-utils.ts => tree-utils.ts} (71%) diff --git a/web/src/lib/components/folder-tree/folder-tree.svelte b/web/src/lib/components/folder-tree/folder-tree.svelte deleted file mode 100644 index 7f8289ce74..0000000000 --- a/web/src/lib/components/folder-tree/folder-tree.svelte +++ /dev/null @@ -1,65 +0,0 @@ - - - - - -{#if isExpanded} -
      - {#each Object.entries(content) as [subFolderName, subContent], index (index)} -
    • - -
    • - {/each} -
    -{/if} diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 495c1aae30..8222007d57 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -3,7 +3,6 @@ import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte'; import SideBar from '../shared-components/side-bar/side-bar.svelte'; import AdminSideBar from '../shared-components/side-bar/admin-side-bar.svelte'; - import FolderSideBar from '$lib/components/shared-components/side-bar/folder-side-bar.svelte'; export let hideNavbar = false; export let showUploadButton = false; @@ -11,7 +10,6 @@ export let description: string | undefined = undefined; export let scrollbar = true; export let admin = false; - export let isFolderView = false; $: scrollbarClass = scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden'; $: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full'; @@ -31,8 +29,6 @@ {#if admin} - {:else if isFolderView} - {:else} {/if} diff --git a/web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte b/web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte deleted file mode 100644 index 8e744c23aa..0000000000 --- a/web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - -
    -
    {$t('explorer').toUpperCase()}
    -
    - {#each Object.entries(folderTree) as [folderName, content]} - - {/each} -
    -
    diff --git a/web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte b/web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte deleted file mode 100644 index ff1cd514e6..0000000000 --- a/web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte +++ /dev/null @@ -1,8 +0,0 @@ - - - - - diff --git a/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte b/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte new file mode 100644 index 0000000000..759a3e5e65 --- /dev/null +++ b/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte @@ -0,0 +1,25 @@ + + +{#if items.length > 0} +
    + {#each items as item} + + {/each} +
    +{/if} diff --git a/web/src/lib/components/shared-components/tree/tree-items.svelte b/web/src/lib/components/shared-components/tree/tree-items.svelte new file mode 100644 index 0000000000..bf04e6ae1f --- /dev/null +++ b/web/src/lib/components/shared-components/tree/tree-items.svelte @@ -0,0 +1,17 @@ + + +
      + {#each Object.entries(items) as [path, tree], index (index)} +
    • + +
    • + {/each} +
    diff --git a/web/src/lib/components/shared-components/tree/tree.svelte b/web/src/lib/components/shared-components/tree/tree.svelte new file mode 100644 index 0000000000..7975825c5e --- /dev/null +++ b/web/src/lib/components/shared-components/tree/tree.svelte @@ -0,0 +1,39 @@ + + + + +
    + +
    + {value} +
    + +{#if isOpen} + +{/if} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 184e913d9e..34d6409848 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -80,6 +80,7 @@ export enum QueryParameter { SEARCHED_PEOPLE = 'searchedPeople', SMART_SEARCH = 'smartSearch', PAGE = 'page', + PATH = 'path', } export enum OpenSettingQueryParameterValue { diff --git a/web/src/lib/utils/folder-utils.ts b/web/src/lib/utils/tree-utils.ts similarity index 71% rename from web/src/lib/utils/folder-utils.ts rename to web/src/lib/utils/tree-utils.ts index 0305f89672..cc17784eb6 100644 --- a/web/src/lib/utils/folder-utils.ts +++ b/web/src/lib/utils/tree-utils.ts @@ -2,7 +2,9 @@ export interface RecursiveObject { [key: string]: RecursiveObject; } -export function buildFolderTree(paths: string[]) { +export const normalizeTreePath = (path: string) => path.replace(/^\//, '').replace(/\/$/, ''); + +export function buildTree(paths: string[]) { const root: RecursiveObject = {}; for (const path of paths) { const parts = path.split('/'); diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index bf914ff8f9..b530184342 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,14 +1,22 @@ - + + +
    +
    {$t('explorer').toUpperCase()}
    +
    + +
    +
    +
    +
    {#if data.path} @@ -71,42 +93,20 @@
    -
    - - {#if data.currentFolders.length > 0} -
    - {#each data.currentFolders as folder} - - {/each} -
    - {/if} +
    + -
    0} - > - {#if data.pathAssets && data.pathAssets.length > 0} + {#if data.pathAssets && data.pathAssets.length > 0} +
    - {/if} -
    +
    + {/if}
    diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts index f04d7840e5..41800c1a7d 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -1,7 +1,9 @@ +import { QueryParameter } from '$lib/constants'; import { foldersStore } from '$lib/stores/folders.store'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import { get } from 'svelte/store'; import type { PageLoad } from './$types'; @@ -14,25 +16,24 @@ export const load = (async ({ params, url }) => { const { uniquePaths } = get(foldersStore); let pathAssets = null; - const path = url.searchParams.get('folder'); + const path = url.searchParams.get(QueryParameter.PATH); if (path) { await foldersStore.fetchAssetsByPath(path); const { assets } = get(foldersStore); pathAssets = assets[path] || null; } - const currentPath = path ? `${path}/`.replaceAll('//', '/') : ''; - - const currentFolders = (uniquePaths || []) - .filter((path) => path.startsWith(currentPath) && path !== currentPath) - .map((path) => path.replaceAll(currentPath, '').split('/')[0]) - .filter((value, index, self) => self.indexOf(value) === index); + let tree = buildTree(uniquePaths || []); + const parts = normalizeTreePath(path || '').split('/'); + for (const part of parts) { + tree = tree?.[part]; + } return { asset, path, - currentFolders, + currentFolders: Object.keys(tree || {}), pathAssets, meta: { title: $t('folders'), From 7fbf50a75e567ed4a9c5243532499efd383576ce Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 22 Aug 2024 23:24:49 -0400 Subject: [PATCH 151/723] fix: remove `asset.resized` (#11983) fix: remove resized --- e2e/src/api/specs/asset.e2e-spec.ts | 10 ----- .../openapi/lib/model/asset_response_dto.dart | 10 +---- open-api/immich-openapi-specs.json | 4 -- open-api/typescript-sdk/src/fetch-client.ts | 1 - server/src/dtos/asset-response.dto.ts | 4 -- server/src/queries/asset.repository.sql | 45 ------------------- server/src/repositories/asset.repository.ts | 5 +-- server/test/fixtures/shared-link.stub.ts | 2 - .../asset-viewer/asset-viewer.svelte | 21 +++------ .../asset-viewer/photo-viewer.svelte | 10 ++--- .../lib/components/assets/broken-asset.svelte | 25 +++++++++++ .../assets/thumbnail/image-thumbnail.svelte | 16 +++---- .../assets/thumbnail/thumbnail.svelte | 23 ++++------ .../covers/__tests__/share-cover.spec.ts | 6 ++- .../covers/asset-cover.svelte | 25 +++++++---- .../covers/share-cover.svelte | 4 +- .../sharedlinks-page/shared-link-card.svelte | 2 +- web/src/test-data/factories/asset-factory.ts | 1 - 18 files changed, 78 insertions(+), 136 deletions(-) create mode 100644 web/src/lib/components/assets/broken-asset.svelte diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 99b33dfed8..82ce17865a 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -843,7 +843,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '8bit-sRGB.avif', - resized: true, exifInfo: { description: '', exifImageHeight: 1080, @@ -859,7 +858,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'el_torcal_rocks.jpg', - resized: true, exifInfo: { dateTimeOriginal: '2012-08-05T11:39:59.000Z', exifImageWidth: 512, @@ -883,7 +881,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '8bit-sRGB.jxl', - resized: true, exifInfo: { description: '', exifImageHeight: 1080, @@ -899,7 +896,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'IMG_2682.heic', - resized: true, fileCreatedAt: '2019-03-21T16:04:22.348Z', exifInfo: { dateTimeOriginal: '2019-03-21T16:04:22.348Z', @@ -924,7 +920,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'density_plot.png', - resized: true, exifInfo: { exifImageWidth: 800, exifImageHeight: 800, @@ -939,7 +934,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'glarus.nef', - resized: true, fileCreatedAt: '2010-07-20T17:27:12.000Z', exifInfo: { make: 'NIKON CORPORATION', @@ -961,7 +955,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'philadelphia.nef', - resized: true, fileCreatedAt: '2016-09-22T22:10:29.060Z', exifInfo: { make: 'NIKON CORPORATION', @@ -984,7 +977,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '4_3.rw2', - resized: true, fileCreatedAt: '2018-05-10T08:42:37.842Z', exifInfo: { make: 'Panasonic', @@ -1008,7 +1000,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '12bit-compressed-(3_2).arw', - resized: true, fileCreatedAt: '2016-09-27T10:51:44.000Z', exifInfo: { make: 'SONY', @@ -1033,7 +1024,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '14bit-uncompressed-(3_2).arw', - resized: true, fileCreatedAt: '2016-01-08T14:08:01.000Z', exifInfo: { make: 'SONY', diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 561a42cc85..4217e133b8 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -36,7 +36,6 @@ class AssetResponseDto { this.owner, required this.ownerId, this.people = const [], - required this.resized, this.smartInfo, this.stack, this.tags = const [], @@ -112,8 +111,6 @@ class AssetResponseDto { List people; - bool resized; - /// /// 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 @@ -159,7 +156,6 @@ class AssetResponseDto { other.owner == owner && other.ownerId == ownerId && _deepEquality.equals(other.people, people) && - other.resized == resized && other.smartInfo == smartInfo && other.stack == stack && _deepEquality.equals(other.tags, tags) && @@ -194,7 +190,6 @@ class AssetResponseDto { (owner == null ? 0 : owner!.hashCode) + (ownerId.hashCode) + (people.hashCode) + - (resized.hashCode) + (smartInfo == null ? 0 : smartInfo!.hashCode) + (stack == null ? 0 : stack!.hashCode) + (tags.hashCode) + @@ -204,7 +199,7 @@ class AssetResponseDto { (updatedAt.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]'; + String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, smartInfo=$smartInfo, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -255,7 +250,6 @@ class AssetResponseDto { } json[r'ownerId'] = this.ownerId; json[r'people'] = this.people; - json[r'resized'] = this.resized; if (this.smartInfo != null) { json[r'smartInfo'] = this.smartInfo; } else { @@ -309,7 +303,6 @@ class AssetResponseDto { owner: UserResponseDto.fromJson(json[r'owner']), ownerId: mapValueOfType(json, r'ownerId')!, people: PersonWithFacesResponseDto.listFromJson(json[r'people']), - resized: mapValueOfType(json, r'resized')!, smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']), stack: AssetStackResponseDto.fromJson(json[r'stack']), tags: TagResponseDto.listFromJson(json[r'tags']), @@ -380,7 +373,6 @@ class AssetResponseDto { 'originalFileName', 'originalPath', 'ownerId', - 'resized', 'thumbhash', 'type', 'updatedAt', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 02a887370a..2137bf7b11 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8335,9 +8335,6 @@ }, "type": "array" }, - "resized": { - "type": "boolean" - }, "smartInfo": { "$ref": "#/components/schemas/SmartInfoResponseDto" }, @@ -8390,7 +8387,6 @@ "originalFileName", "originalPath", "ownerId", - "resized", "thumbhash", "type", "updatedAt" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9642f4c817..bf0c63c2b8 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -229,7 +229,6 @@ export type AssetResponseDto = { owner?: UserResponseDto; ownerId: string; people?: PersonWithFacesResponseDto[]; - resized: boolean; smartInfo?: SmartInfoResponseDto; stack?: (AssetStackResponseDto) | null; tags?: TagResponseDto[]; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 332f258d49..caeae2971a 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -14,7 +14,6 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { AssetType } from 'src/enum'; -import { getAssetFiles } from 'src/utils/asset.util'; import { mimeTypes } from 'src/utils/mime-types'; export class SanitizedAssetResponseDto { @@ -23,7 +22,6 @@ export class SanitizedAssetResponseDto { type!: AssetType; thumbhash!: string | null; originalMimeType?: string; - resized!: boolean; localDateTime!: Date; duration!: string; livePhotoVideoId?: string | null; @@ -112,7 +110,6 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash?.toString('base64') ?? null, localDateTime: entity.localDateTime, - resized: !!getAssetFiles(entity.files).previewFile, duration: entity.duration ?? '0:00:00.00000', livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, @@ -131,7 +128,6 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As originalPath: entity.originalPath, originalFileName: entity.originalFileName, originalMimeType: mimeTypes.lookup(entity.originalFileName), - resized: !!getAssetFiles(entity.files).previewFile, thumbhash: entity.thumbhash?.toString('base64') ?? null, fileCreatedAt: entity.fileCreatedAt, fileModifiedAt: entity.fileModifiedAt, diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index fd5dc15c0a..b08130b183 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -598,12 +598,6 @@ SELECT "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", "asset"."duplicateId" AS "asset_duplicateId", - "files"."id" AS "files_id", - "files"."assetId" AS "files_assetId", - "files"."createdAt" AS "files_createdAt", - "files"."updatedAt" AS "files_updatedAt", - "files"."type" AS "files_type", - "files"."path" AS "files_path", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -665,7 +659,6 @@ SELECT "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" FROM "assets" "asset" - LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" @@ -692,7 +685,6 @@ SELECT )::timestamptz AS "timeBucket" FROM "assets" "asset" - LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" @@ -744,12 +736,6 @@ SELECT "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", "asset"."duplicateId" AS "asset_duplicateId", - "files"."id" AS "files_id", - "files"."assetId" AS "files_assetId", - "files"."createdAt" AS "files_createdAt", - "files"."updatedAt" AS "files_updatedAt", - "files"."type" AS "files_type", - "files"."path" AS "files_path", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -811,7 +797,6 @@ SELECT "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" FROM "assets" "asset" - LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" @@ -865,12 +850,6 @@ SELECT "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", "asset"."duplicateId" AS "asset_duplicateId", - "files"."id" AS "files_id", - "files"."assetId" AS "files_assetId", - "files"."createdAt" AS "files_createdAt", - "files"."updatedAt" AS "files_updatedAt", - "files"."type" AS "files_type", - "files"."path" AS "files_path", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -932,7 +911,6 @@ SELECT "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" FROM "assets" "asset" - LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" @@ -964,7 +942,6 @@ SELECT DISTINCT c.city AS "value" FROM "assets" "asset" - LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" INNER JOIN "exif" "e" ON "asset"."id" = e."assetId" INNER JOIN "cities" "c" ON c.city = "e"."city" WHERE @@ -995,7 +972,6 @@ SELECT DISTINCT unnest("si"."tags") AS "value" FROM "assets" "asset" - LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" INNER JOIN "smart_info" "si" ON "asset"."id" = si."assetId" INNER JOIN "random_tags" "t" ON "si"."tags" @> ARRAY[t.tag] WHERE @@ -1038,12 +1014,6 @@ SELECT "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", "asset"."duplicateId" AS "asset_duplicateId", - "files"."id" AS "files_id", - "files"."assetId" AS "files_assetId", - "files"."createdAt" AS "files_createdAt", - "files"."updatedAt" AS "files_updatedAt", - "files"."type" AS "files_type", - "files"."path" AS "files_path", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -1078,7 +1048,6 @@ SELECT "stack"."primaryAssetId" AS "stack_primaryAssetId" FROM "assets" "asset" - LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" WHERE @@ -1120,12 +1089,6 @@ SELECT "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", "asset"."duplicateId" AS "asset_duplicateId", - "files"."id" AS "files_id", - "files"."assetId" AS "files_assetId", - "files"."createdAt" AS "files_createdAt", - "files"."updatedAt" AS "files_updatedAt", - "files"."type" AS "files_type", - "files"."path" AS "files_path", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -1160,7 +1123,6 @@ SELECT "stack"."primaryAssetId" AS "stack_primaryAssetId" FROM "assets" "asset" - LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" WHERE @@ -1197,12 +1159,6 @@ SELECT "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", "asset"."duplicateId" AS "asset_duplicateId", - "files"."id" AS "files_id", - "files"."assetId" AS "files_assetId", - "files"."createdAt" AS "files_createdAt", - "files"."updatedAt" AS "files_updatedAt", - "files"."type" AS "files_type", - "files"."path" AS "files_path", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -1264,7 +1220,6 @@ SELECT "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" FROM "assets" "asset" - LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 50ed724f9f..b95db5f3a8 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -710,10 +710,7 @@ export class AssetRepository implements IAssetRepository { } private getBuilder(options: AssetBuilderOptions) { - const builder = this.repository - .createQueryBuilder('asset') - .where('asset.isVisible = true') - .leftJoinAndSelect('asset.files', 'files'); + const builder = this.repository.createQueryBuilder('asset').where('asset.isVisible = true'); if (options.assetType !== undefined) { builder.andWhere('asset.type = :assetType', { assetType: options.assetType }); diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 9ea252b5f7..54898d8693 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -54,7 +54,6 @@ const assetResponse: AssetResponseDto = { originalMimeType: 'image/jpeg', originalPath: 'fake_path/jpeg', originalFileName: 'asset_1.jpeg', - resized: false, thumbhash: null, fileModifiedAt: today, isOffline: false, @@ -82,7 +81,6 @@ const assetResponseWithoutMetadata = { id: 'id_1', type: AssetType.VIDEO, originalMimeType: 'image/jpeg', - resized: false, thumbhash: null, localDateTime: today, duration: '0:00:00.00000', diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 3ed955848b..4e98546069 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -4,9 +4,9 @@ import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte'; import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; - import Icon from '$lib/components/elements/icon.svelte'; import { AssetAction, ProjectionType } from '$lib/constants'; import { updateNumberOfComments } from '$lib/stores/activity.store'; + import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import type { AssetStore } from '$lib/stores/assets.store'; import { isShowDetail } from '$lib/stores/preferences.store'; @@ -25,14 +25,13 @@ getActivities, getActivityStatistics, getAllAlbums, + getStack, runAssetJobs, type ActivityResponseDto, type AlbumResponseDto, type AssetResponseDto, - getStack, type StackResponseDto, } from '@immich/sdk'; - import { mdiImageBrokenVariant } from '@mdi/js'; import { createEventDispatcher, onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; @@ -42,13 +41,13 @@ import ActivityViewer from './activity-viewer.svelte'; import AssetViewerNavBar from './asset-viewer-nav-bar.svelte'; import DetailPanel from './detail-panel.svelte'; + import CropArea from './editor/crop-tool/crop-area.svelte'; + import EditorPanel from './editor/editor-panel.svelte'; import PanoramaViewer from './panorama-viewer.svelte'; import PhotoViewer from './photo-viewer.svelte'; import SlideshowBar from './slideshow-bar.svelte'; import VideoViewer from './video-wrapper-viewer.svelte'; - import EditorPanel from './editor/editor-panel.svelte'; - import CropArea from './editor/crop-tool/crop-area.svelte'; - import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; + export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; export let preloadAssets: AssetResponseDto[] = []; @@ -481,15 +480,7 @@ {/key} {:else} {#key asset.id} - {#if !asset.resized} -
    -
    - -
    -
    - {:else if asset.type === AssetTypeEnum.Image} + {#if asset.type === AssetTypeEnum.Image} {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} import { shortcuts } from '$lib/actions/shortcut'; + import { zoomImageAction, zoomed } from '$lib/actions/zoom-image'; + import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import { photoViewer } from '$lib/stores/assets.store'; import { boundingBoxesArray } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; @@ -9,15 +11,13 @@ import { isWebCompatibleImage } from '$lib/utils/asset-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; import { getAltText } from '$lib/utils/thumbnail-util'; - import { AssetTypeEnum, type AssetResponseDto, AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk'; - import { zoomImageAction, zoomed } from '$lib/actions/zoom-image'; + import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard'; import { onDestroy, onMount } from 'svelte'; - + import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; - import { t } from 'svelte-i18n'; export let asset: AssetResponseDto; export let preloadAssets: AssetResponseDto[] | undefined = undefined; @@ -137,7 +137,7 @@ ]} /> {#if imageError} -
    {$t('error_loading_image')}
    + {/if} diff --git a/web/src/lib/components/assets/broken-asset.svelte b/web/src/lib/components/assets/broken-asset.svelte new file mode 100644 index 0000000000..216a8f6f84 --- /dev/null +++ b/web/src/lib/components/assets/broken-asset.svelte @@ -0,0 +1,25 @@ + + +
    +
    + + {#if !noMessage} +
    {$t('error_loading_image')}
    + {/if} +
    +
    + +
    +
    +
    diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index e03dd35653..38f2ff4dbb 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -1,12 +1,12 @@ {#if errored} -
    - -
    + +
    {$t('error_loading_image')}
    +
    {:else} {/if} - {#if asset.resized} - (loaded = true)} - /> - {:else} -
    - -
    - {/if} + (loaded = true)} + /> {#if asset.type === AssetTypeEnum.Video}
    diff --git a/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts b/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts index 1f1fa65cf8..2952498b1a 100644 --- a/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts +++ b/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts @@ -47,13 +47,15 @@ describe('ShareCover component', () => { expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square text'); }); - it('renders fallback image when asset is not resized', () => { - const link = sharedLinkFactory.build({ assets: [assetFactory.build({ resized: false })] }); + it.skip('renders fallback image when asset is not resized', () => { + const link = sharedLinkFactory.build({ assets: [assetFactory.build()] }); render(ShareCover, { link: link, preload: false, }); + // TODO emit image error event and check if fallback image is rendered + const img = screen.getByTestId('album-image'); expect(img.alt).toBe('unnamed_share'); }); diff --git a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte index b8335be6b0..69c11e079c 100644 --- a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte @@ -1,16 +1,25 @@ - +{#if isBroken} + +{:else} + (isBroken = true)} + class="z-0 rounded-xl object-cover aspect-square {className}" + data-testid="album-image" + draggable="false" + loading={preload ? 'eager' : 'lazy'} + {src} + /> +{/if} diff --git a/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte index 3a21a60989..09f32d7dac 100644 --- a/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte @@ -12,10 +12,10 @@ export { className as class }; -
    +
    {#if link?.album} - {:else if link.assets[0]?.resized} + {:else if link.assets[0]} - +
    diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 5f31b8af44..700b98c180 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -12,7 +12,6 @@ export const assetFactory = Sync.makeFactory({ originalPath: Sync.each(() => faker.system.filePath()), originalFileName: Sync.each(() => faker.system.fileName()), originalMimeType: Sync.each(() => faker.system.mimeType()), - resized: true, thumbhash: Sync.each(() => faker.string.alphanumeric(28)), fileCreatedAt: Sync.each(() => faker.date.past().toISOString()), fileModifiedAt: Sync.each(() => faker.date.past().toISOString()), From c14e2914f89b378d7ddfde08d9240af4882c2b59 Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Fri, 23 Aug 2024 12:34:12 -0400 Subject: [PATCH 152/723] fix(web): rating stars accessibility (#11966) * fix(web): exif ratings accessibility * chore: add tests * fix: eslint errors * fix: clean up issues from changes in use:focusOutside --- web/src/lib/actions/focus-outside.ts | 5 +- .../detail-panel-star-rating.svelte | 2 +- .../__test__/star-rating.spec.ts | 78 ++++++++++++ .../shared-components/combobox.svelte | 2 - .../search-bar/search-bar.svelte | 5 +- .../shared-components/star-rating.svelte | 117 ++++++++++++++---- web/src/lib/i18n/en.json | 2 + 7 files changed, 180 insertions(+), 31 deletions(-) create mode 100644 web/src/lib/components/shared-components/__test__/star-rating.spec.ts diff --git a/web/src/lib/actions/focus-outside.ts b/web/src/lib/actions/focus-outside.ts index 07a85b021e..2266ea8f0f 100644 --- a/web/src/lib/actions/focus-outside.ts +++ b/web/src/lib/actions/focus-outside.ts @@ -6,7 +6,10 @@ export function focusOutside(node: HTMLElement, options: Options = {}) { const { onFocusOut } = options; const handleFocusOut = (event: FocusEvent) => { - if (onFocusOut && event.relatedTarget instanceof Node && !node.contains(event.relatedTarget as Node)) { + if ( + onFocusOut && + (!event.relatedTarget || (event.relatedTarget instanceof Node && !node.contains(event.relatedTarget as Node))) + ) { onFocusOut(event); } }; diff --git a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte index 131d2ca436..8b18d14f03 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte @@ -21,7 +21,7 @@ {#if !isSharedLink() && $preferences?.rating?.enabled} -
    +
    handlePromiseError(handleChangeRating(rating))} />
    {/if} diff --git a/web/src/lib/components/shared-components/__test__/star-rating.spec.ts b/web/src/lib/components/shared-components/__test__/star-rating.spec.ts new file mode 100644 index 0000000000..cf33573b77 --- /dev/null +++ b/web/src/lib/components/shared-components/__test__/star-rating.spec.ts @@ -0,0 +1,78 @@ +import StarRating from '$lib/components/shared-components/star-rating.svelte'; +import { render } from '@testing-library/svelte'; + +describe('StarRating component', () => { + it('renders correctly', () => { + const component = render(StarRating, { + count: 3, + rating: 2, + readOnly: false, + onRating: vi.fn(), + }); + const container = component.getByTestId('star-container') as HTMLImageElement; + expect(container.className).toBe('flex flex-row'); + + const radioButtons = component.getAllByRole('radio') as HTMLInputElement[]; + expect(radioButtons.length).toBe(3); + const labels = component.getAllByTestId('star') as HTMLLabelElement[]; + expect(labels.length).toBe(3); + const labelText = component.getAllByText('rating_count') as HTMLSpanElement[]; + expect(labelText.length).toBe(3); + const clearButton = component.getByRole('button') as HTMLButtonElement; + expect(clearButton).toBeInTheDocument(); + + // Check the clear button content + expect(clearButton.textContent).toBe('rating_clear'); + + // Check the initial state + expect(radioButtons[0].checked).toBe(false); + expect(radioButtons[1].checked).toBe(true); + expect(radioButtons[2].checked).toBe(false); + + // Check the radio button attributes + for (const [index, radioButton] of radioButtons.entries()) { + expect(radioButton.id).toBe(labels[index].htmlFor); + expect(radioButton.name).toBe('stars'); + expect(radioButton.value).toBe((index + 1).toString()); + expect(radioButton.disabled).toBe(false); + expect(radioButton.className).toBe('sr-only'); + } + + // Check the label attributes + for (const label of labels) { + expect(label.className).toBe('cursor-pointer'); + expect(label.tabIndex).toBe(-1); + } + }); + + it('renders correctly with readOnly', () => { + const component = render(StarRating, { + count: 3, + rating: 2, + readOnly: true, + onRating: vi.fn(), + }); + const radioButtons = component.getAllByRole('radio') as HTMLInputElement[]; + expect(radioButtons.length).toBe(3); + const labels = component.getAllByTestId('star') as HTMLLabelElement[]; + expect(labels.length).toBe(3); + const clearButton = component.queryByRole('button'); + expect(clearButton).toBeNull(); + + // Check the initial state + expect(radioButtons[0].checked).toBe(false); + expect(radioButtons[1].checked).toBe(true); + expect(radioButtons[2].checked).toBe(false); + + // Check the radio button attributes + for (const [index, radioButton] of radioButtons.entries()) { + expect(radioButton.id).toBe(labels[index].htmlFor); + expect(radioButton.disabled).toBe(true); + } + + // Check the label attributes + for (const label of labels) { + expect(label.className).toBe(''); + } + }); +}); diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 7cdcef9e40..64ec16fda6 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -23,7 +23,6 @@ import { createEventDispatcher, tick } from 'svelte'; import type { FormEventHandler } from 'svelte/elements'; import { shortcuts } from '$lib/actions/shortcut'; - import { clickOutside } from '$lib/actions/click-outside'; import { focusOutside } from '$lib/actions/focus-outside'; import { generateId } from '$lib/utils/generate-id'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; @@ -124,7 +123,6 @@
    -
    +
    -
    +
    + import { focusOutside } from '$lib/actions/focus-outside'; + import { shortcuts } from '$lib/actions/shortcut'; import Icon from '$lib/components/elements/icon.svelte'; + import { generateId } from '$lib/utils/generate-id'; + import { t } from 'svelte-i18n'; export let count = 5; export let rating: number; export let readOnly = false; export let onRating: (rating: number) => void | undefined; + let ratingSelection = 0; let hoverRating = 0; + let focusRating = 0; + let timeoutId: ReturnType | undefined; + + $: ratingSelection = rating; const starIcon = 'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z'; + const id = generateId(); const handleSelect = (newRating: number) => { if (readOnly) { @@ -17,34 +27,93 @@ } if (newRating === rating) { - newRating = 0; + return; } - rating = newRating; + onRating(newRating); + }; - onRating?.(rating); + const setHoverRating = (value: number) => { + if (readOnly) { + return; + } + hoverRating = value; + }; + + const reset = () => { + setHoverRating(0); + focusRating = 0; + }; + + const handleSelectDebounced = (value: number) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + handleSelect(value); + }, 300); }; -
    (hoverRating = 0)} on:blur|preventDefault> - {#each { length: count } as _, index} - {@const value = index + 1} - {@const filled = hoverRating >= value || (hoverRating === 0 && rating >= value)} - - {/each} -
    + {/each} +
    + +{#if ratingSelection > 0 && !readOnly} + +{/if} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 91fb1aba43..3609b9c274 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -972,6 +972,8 @@ "purchase_server_title": "Server", "purchase_settings_server_activated": "The server product key is managed by the admin", "rating": "Star rating", + "rating_clear": "Clear rating", + "rating_count": "{count, plural, one {# star} other {# stars}}", "rating_description": "Display the exif rating in the info panel", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", From da12d5f567d000e288e5db0cd68d9dcd848d94a4 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sat, 24 Aug 2024 01:03:36 +0200 Subject: [PATCH 153/723] feat(web): my immich shortcut (#12007) feat: my immich shortcut in web --- web/src/routes/+layout.svelte | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index d086129d7f..1ad9066c4e 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -13,13 +13,14 @@ import { loadConfig, serverConfig } from '$lib/stores/server-config.store'; import { user } from '$lib/stores/user.store'; import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket'; - import { setKey } from '$lib/utils'; + import { copyToClipboard, setKey } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { onDestroy, onMount } from 'svelte'; import '../app.css'; import { isAssetViewerRoute, isSharedLinkRoute } from '$lib/utils/navigation'; import DialogWrapper from '$lib/components/shared-components/dialog/dialog-wrapper.svelte'; import { t } from 'svelte-i18n'; + import { shortcut } from '$lib/actions/shortcut'; let showNavigationLoadingBar = false; $: changeTheme($colorTheme); @@ -49,6 +50,10 @@ } }; + const getMyImmichLink = () => { + return new URL($page.url.pathname + $page.url.search, 'https://my.immich.app'); + }; + onMount(() => { // if the browser theme changes, changes the Immich theme too window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handleChangeTheme); @@ -123,6 +128,12 @@ + copyToClipboard(getMyImmichLink().toString()), + }} +/> {#if showNavigationLoadingBar} From 00a7b801844a16caf5ec40ac48141d1cc0446992 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 24 Aug 2024 17:50:05 +0000 Subject: [PATCH 154/723] fix(deps): update machine-learning (#11921) --- machine-learning/poetry.lock | 230 ++++++++++++++++++----------------- 1 file changed, 117 insertions(+), 113 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 9d19b671d1..31949aee84 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -680,18 +680,18 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi-slim" -version = "0.112.0" +version = "0.112.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_slim-0.112.0-py3-none-any.whl", hash = "sha256:7663edfbb5036d641aa45b4f5dad341cf78d98885216e78743a8cdd39a38883e"}, - {file = "fastapi_slim-0.112.0.tar.gz", hash = "sha256:2420f700b7dc2d1a6d02c7230f7aa2ae9fa0320d8d481094062ff717659c0843"}, + {file = "fastapi_slim-0.112.1-py3-none-any.whl", hash = "sha256:cc227cf9402d0ba54a24f80eb205c33bcb25d3ea18d53fdac3fd76ea5af8e76d"}, + {file = "fastapi_slim-0.112.1.tar.gz", hash = "sha256:876ebd24e72273986709db2d469b75dc18f04c3ab9140ffd78b29d7785d26687"}, ] [package.dependencies] pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.37.2,<0.38.0" +starlette = ">=0.37.2,<0.39.0" typing-extensions = ">=4.8.0" [package.extras] @@ -1236,13 +1236,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "huggingface-hub" -version = "0.24.5" +version = "0.24.6" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" files = [ - {file = "huggingface_hub-0.24.5-py3-none-any.whl", hash = "sha256:d93fb63b1f1a919a22ce91a14518974e81fc4610bf344dfe7572343ce8d3aced"}, - {file = "huggingface_hub-0.24.5.tar.gz", hash = "sha256:7b45d6744dd53ce9cbf9880957de00e9d10a9ae837f1c9b7255fc8fa4e8264f3"}, + {file = "huggingface_hub-0.24.6-py3-none-any.whl", hash = "sha256:a990f3232aa985fe749bc9474060cbad75e8b2f115f6665a9fda5b9c97818970"}, + {file = "huggingface_hub-0.24.6.tar.gz", hash = "sha256:cc2579e761d070713eaa9c323e3debe39d5b464ae3a7261c39a9195b27bb8000"}, ] [package.dependencies] @@ -1530,13 +1530,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "locust" -version = "2.31.2" +version = "2.31.3" description = "Developer-friendly load testing framework" optional = false python-versions = ">=3.9" files = [ - {file = "locust-2.31.2-py3-none-any.whl", hash = "sha256:9bcb8b777d9844ac9498d6eebe17a0afa21712419c42da27b1d1cac5895cd182"}, - {file = "locust-2.31.2.tar.gz", hash = "sha256:a31f8e1d24535494eb809bd8dfd545ada9514df4581b69bdc2ecf3e109b7a1dd"}, + {file = "locust-2.31.3-py3-none-any.whl", hash = "sha256:03122e007519b371a5a553d578af502826755de83551d79ea8a412ea1c660115"}, + {file = "locust-2.31.3.tar.gz", hash = "sha256:25f4603f24afa11ef1ee1f26b1c86a232eb9a1140be30b2a4642c12d7a7af8ae"}, ] [package.dependencies] @@ -1962,42 +1962,42 @@ reference = ["Pillow", "google-re2"] [[package]] name = "onnxruntime" -version = "1.18.1" +version = "1.19.0" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = "*" files = [ - {file = "onnxruntime-1.18.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:29ef7683312393d4ba04252f1b287d964bd67d5e6048b94d2da3643986c74d80"}, - {file = "onnxruntime-1.18.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc706eb1df06ddf55776e15a30519fb15dda7697f987a2bbda4962845e3cec05"}, - {file = "onnxruntime-1.18.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7de69f5ced2a263531923fa68bbec52a56e793b802fcd81a03487b5e292bc3a"}, - {file = "onnxruntime-1.18.1-cp310-cp310-win32.whl", hash = "sha256:221e5b16173926e6c7de2cd437764492aa12b6811f45abd37024e7cf2ae5d7e3"}, - {file = "onnxruntime-1.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:75211b619275199c861ee94d317243b8a0fcde6032e5a80e1aa9ded8ab4c6060"}, - {file = "onnxruntime-1.18.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:f26582882f2dc581b809cfa41a125ba71ad9e715738ec6402418df356969774a"}, - {file = "onnxruntime-1.18.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef36f3a8b768506d02be349ac303fd95d92813ba3ba70304d40c3cd5c25d6a4c"}, - {file = "onnxruntime-1.18.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:170e711393e0618efa8ed27b59b9de0ee2383bd2a1f93622a97006a5ad48e434"}, - {file = "onnxruntime-1.18.1-cp311-cp311-win32.whl", hash = "sha256:9b6a33419b6949ea34e0dc009bc4470e550155b6da644571ecace4b198b0d88f"}, - {file = "onnxruntime-1.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:5c1380a9f1b7788da742c759b6a02ba771fe1ce620519b2b07309decbd1a2fe1"}, - {file = "onnxruntime-1.18.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:31bd57a55e3f983b598675dfc7e5d6f0877b70ec9864b3cc3c3e1923d0a01919"}, - {file = "onnxruntime-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9e03c4ba9f734500691a4d7d5b381cd71ee2f3ce80a1154ac8f7aed99d1ecaa"}, - {file = "onnxruntime-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:781aa9873640f5df24524f96f6070b8c550c66cb6af35710fd9f92a20b4bfbf6"}, - {file = "onnxruntime-1.18.1-cp312-cp312-win32.whl", hash = "sha256:3a2d9ab6254ca62adbb448222e630dc6883210f718065063518c8f93a32432be"}, - {file = "onnxruntime-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:ad93c560b1c38c27c0275ffd15cd7f45b3ad3fc96653c09ce2931179982ff204"}, - {file = "onnxruntime-1.18.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:3b55dc9d3c67626388958a3eb7ad87eb7c70f75cb0f7ff4908d27b8b42f2475c"}, - {file = "onnxruntime-1.18.1-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f80dbcfb6763cc0177a31168b29b4bd7662545b99a19e211de8c734b657e0669"}, - {file = "onnxruntime-1.18.1-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1ff2c61a16d6c8631796c54139bafea41ee7736077a0fc64ee8ae59432f5c58"}, - {file = "onnxruntime-1.18.1-cp38-cp38-win32.whl", hash = "sha256:219855bd272fe0c667b850bf1a1a5a02499269a70d59c48e6f27f9c8bcb25d02"}, - {file = "onnxruntime-1.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:afdf16aa607eb9a2c60d5ca2d5abf9f448e90c345b6b94c3ed14f4fb7e6a2d07"}, - {file = "onnxruntime-1.18.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:128df253ade673e60cea0955ec9d0e89617443a6d9ce47c2d79eb3f72a3be3de"}, - {file = "onnxruntime-1.18.1-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9839491e77e5c5a175cab3621e184d5a88925ee297ff4c311b68897197f4cde9"}, - {file = "onnxruntime-1.18.1-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad3187c1faff3ac15f7f0e7373ef4788c582cafa655a80fdbb33eaec88976c66"}, - {file = "onnxruntime-1.18.1-cp39-cp39-win32.whl", hash = "sha256:34657c78aa4e0b5145f9188b550ded3af626651b15017bf43d280d7e23dbf195"}, - {file = "onnxruntime-1.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:9c14fd97c3ddfa97da5feef595e2c73f14c2d0ec1d4ecbea99c8d96603c89589"}, + {file = "onnxruntime-1.19.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6ce22a98dfec7b646ae305f52d0ce14a189a758b02ea501860ca719f4b0ae04b"}, + {file = "onnxruntime-1.19.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19019c72873f26927aa322c54cf2bf7312b23451b27451f39b88f57016c94f8b"}, + {file = "onnxruntime-1.19.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8eaa16df99171dc636e30108d15597aed8c4c2dd9dbfdd07cc464d57d73fb275"}, + {file = "onnxruntime-1.19.0-cp310-cp310-win32.whl", hash = "sha256:0eb0f8dbe596fd0f4737fe511fdbb17603853a7d204c5b2ca38d3c7808fc556b"}, + {file = "onnxruntime-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:616092d54ba8023b7bc0a5f6d900a07a37cc1cfcc631873c15f8c1d6e9e184d4"}, + {file = "onnxruntime-1.19.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a2b53b3c287cd933e5eb597273926e899082d8c84ab96e1b34035764a1627e17"}, + {file = "onnxruntime-1.19.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e94984663963e74fbb468bde9ec6f19dcf890b594b35e249c4dc8789d08993c5"}, + {file = "onnxruntime-1.19.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f379d1f050cfb55ce015d53727b78ee362febc065c38eed81512b22b757da73"}, + {file = "onnxruntime-1.19.0-cp311-cp311-win32.whl", hash = "sha256:4ccb48faea02503275ae7e79e351434fc43c294c4cb5c4d8bcb7479061396614"}, + {file = "onnxruntime-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:9cdc8d311289a84e77722de68bd22b8adfb94eea26f4be6f9e017350faac8b18"}, + {file = "onnxruntime-1.19.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1b59eaec1be9a8613c5fdeaafe67f73a062edce3ac03bbbdc9e2d98b58a30617"}, + {file = "onnxruntime-1.19.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be4144d014a4b25184e63ce7a463a2e7796e2f3df931fccc6a6aefa6f1365dc5"}, + {file = "onnxruntime-1.19.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10d7e7d4ca7021ce7f29a66dbc6071addf2de5839135339bd855c6d9c2bba371"}, + {file = "onnxruntime-1.19.0-cp312-cp312-win32.whl", hash = "sha256:87f2c58b577a1fb31dc5d92b647ecc588fd5f1ea0c3ad4526f5f80a113357c8d"}, + {file = "onnxruntime-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a1f50d49676d7b69566536ff039d9e4e95fc482a55673719f46528218ecbb94"}, + {file = "onnxruntime-1.19.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:71423c8c4b2d7a58956271534302ec72721c62a41efd0c4896343249b8399ab0"}, + {file = "onnxruntime-1.19.0-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d63630d45e9498f96e75bbeb7fd4a56acb10155de0de4d0e18d1b6cbb0b358a"}, + {file = "onnxruntime-1.19.0-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3bfd15db1e8794d379a86c1a9116889f47f2cca40cc82208fc4f7e8c38e8522"}, + {file = "onnxruntime-1.19.0-cp38-cp38-win32.whl", hash = "sha256:3b098003b6b4cb37cc84942e5f1fe27f945dd857cbd2829c824c26b0ba4a247e"}, + {file = "onnxruntime-1.19.0-cp38-cp38-win_amd64.whl", hash = "sha256:cea067a6541d6787d903ee6843401c5b1332a266585160d9700f9f0939443886"}, + {file = "onnxruntime-1.19.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:c4fcff12dc5ca963c5f76b9822bb404578fa4a98c281e8c666b429192799a099"}, + {file = "onnxruntime-1.19.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6dcad8a4db908fbe70b98c79cea1c8b6ac3316adf4ce93453136e33a524ac59"}, + {file = "onnxruntime-1.19.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bc449907c6e8d99eee5ae5cc9c8fdef273d801dcd195393d3f9ab8ad3f49522"}, + {file = "onnxruntime-1.19.0-cp39-cp39-win32.whl", hash = "sha256:947febd48405afcf526e45ccff97ff23b15e530434705f734870d22ae7fcf236"}, + {file = "onnxruntime-1.19.0-cp39-cp39-win_amd64.whl", hash = "sha256:f60be47eff5ee77fd28a466b0fd41d7debc42a32179d1ddb21e05d6067d7b48b"}, ] [package.dependencies] coloredlogs = "*" flatbuffers = "*" -numpy = ">=1.21.6,<2.0" +numpy = ">=1.21.6" packaging = "*" protobuf = "*" sympy = "*" @@ -2082,64 +2082,68 @@ numpy = [ [[package]] name = "orjson" -version = "3.10.6" +version = "3.10.7" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false python-versions = ">=3.8" files = [ - {file = "orjson-3.10.6-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fb0ee33124db6eaa517d00890fc1a55c3bfe1cf78ba4a8899d71a06f2d6ff5c7"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c1c4b53b24a4c06547ce43e5fee6ec4e0d8fe2d597f4647fc033fd205707365"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eadc8fd310edb4bdbd333374f2c8fec6794bbbae99b592f448d8214a5e4050c0"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61272a5aec2b2661f4fa2b37c907ce9701e821b2c1285d5c3ab0207ebd358d38"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57985ee7e91d6214c837936dc1608f40f330a6b88bb13f5a57ce5257807da143"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:633a3b31d9d7c9f02d49c4ab4d0a86065c4a6f6adc297d63d272e043472acab5"}, - {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1c680b269d33ec444afe2bdc647c9eb73166fa47a16d9a75ee56a374f4a45f43"}, - {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f759503a97a6ace19e55461395ab0d618b5a117e8d0fbb20e70cfd68a47327f2"}, - {file = "orjson-3.10.6-cp310-none-win32.whl", hash = "sha256:95a0cce17f969fb5391762e5719575217bd10ac5a189d1979442ee54456393f3"}, - {file = "orjson-3.10.6-cp310-none-win_amd64.whl", hash = "sha256:df25d9271270ba2133cc88ee83c318372bdc0f2cd6f32e7a450809a111efc45c"}, - {file = "orjson-3.10.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b1ec490e10d2a77c345def52599311849fc063ae0e67cf4f84528073152bb2ba"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d43d3feb8f19d07e9f01e5b9be4f28801cf7c60d0fa0d279951b18fae1932b"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3045267e98fe749408eee1593a142e02357c5c99be0802185ef2170086a863"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c27bc6a28ae95923350ab382c57113abd38f3928af3c80be6f2ba7eb8d8db0b0"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d27456491ca79532d11e507cadca37fb8c9324a3976294f68fb1eff2dc6ced5a"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05ac3d3916023745aa3b3b388e91b9166be1ca02b7c7e41045da6d12985685f0"}, - {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1335d4ef59ab85cab66fe73fd7a4e881c298ee7f63ede918b7faa1b27cbe5212"}, - {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4bbc6d0af24c1575edc79994c20e1b29e6fb3c6a570371306db0993ecf144dc5"}, - {file = "orjson-3.10.6-cp311-none-win32.whl", hash = "sha256:450e39ab1f7694465060a0550b3f6d328d20297bf2e06aa947b97c21e5241fbd"}, - {file = "orjson-3.10.6-cp311-none-win_amd64.whl", hash = "sha256:227df19441372610b20e05bdb906e1742ec2ad7a66ac8350dcfd29a63014a83b"}, - {file = "orjson-3.10.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ea2977b21f8d5d9b758bb3f344a75e55ca78e3ff85595d248eee813ae23ecdfb"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6f3d167d13a16ed263b52dbfedff52c962bfd3d270b46b7518365bcc2121eed"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f710f346e4c44a4e8bdf23daa974faede58f83334289df80bc9cd12fe82573c7"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7275664f84e027dcb1ad5200b8b18373e9c669b2a9ec33d410c40f5ccf4b257e"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0943e4c701196b23c240b3d10ed8ecd674f03089198cf503105b474a4f77f21f"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:446dee5a491b5bc7d8f825d80d9637e7af43f86a331207b9c9610e2f93fee22a"}, - {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:64c81456d2a050d380786413786b057983892db105516639cb5d3ee3c7fd5148"}, - {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"}, - {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"}, - {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"}, - {file = "orjson-3.10.6-cp313-none-win32.whl", hash = "sha256:efdf2c5cde290ae6b83095f03119bdc00303d7a03b42b16c54517baa3c4ca3d0"}, - {file = "orjson-3.10.6-cp313-none-win_amd64.whl", hash = "sha256:8e190fe7888e2e4392f52cafb9626113ba135ef53aacc65cd13109eb9746c43e"}, - {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2c116072a8533f2fec435fde4d134610f806bdac20188c7bd2081f3e9e0133f"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6eeb13218c8cf34c61912e9df2de2853f1d009de0e46ea09ccdf3d757896af0a"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965a916373382674e323c957d560b953d81d7a8603fbeee26f7b8248638bd48b"}, - {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03c95484d53ed8e479cade8628c9cea00fd9d67f5554764a1110e0d5aa2de96e"}, - {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e060748a04cccf1e0a6f2358dffea9c080b849a4a68c28b1b907f272b5127e9b"}, - {file = "orjson-3.10.6-cp38-none-win32.whl", hash = "sha256:738dbe3ef909c4b019d69afc19caf6b5ed0e2f1c786b5d6215fbb7539246e4c6"}, - {file = "orjson-3.10.6-cp38-none-win_amd64.whl", hash = "sha256:d40f839dddf6a7d77114fe6b8a70218556408c71d4d6e29413bb5f150a692ff7"}, - {file = "orjson-3.10.6-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:697a35a083c4f834807a6232b3e62c8b280f7a44ad0b759fd4dce748951e70db"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd502f96bf5ea9a61cbc0b2b5900d0dd68aa0da197179042bdd2be67e51a1e4b"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f215789fb1667cdc874c1b8af6a84dc939fd802bf293a8334fce185c79cd359b"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2debd8ddce948a8c0938c8c93ade191d2f4ba4649a54302a7da905a81f00b56"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5410111d7b6681d4b0d65e0f58a13be588d01b473822483f77f513c7f93bd3b2"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb1f28a137337fdc18384079fa5726810681055b32b92253fa15ae5656e1dddb"}, - {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bf2fbbce5fe7cd1aa177ea3eab2b8e6a6bc6e8592e4279ed3db2d62e57c0e1b2"}, - {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:79b9b9e33bd4c517445a62b90ca0cc279b0f1f3970655c3df9e608bc3f91741a"}, - {file = "orjson-3.10.6-cp39-none-win32.whl", hash = "sha256:30b0a09a2014e621b1adf66a4f705f0809358350a757508ee80209b2d8dae219"}, - {file = "orjson-3.10.6-cp39-none-win_amd64.whl", hash = "sha256:49e3bc615652617d463069f91b867a4458114c5b104e13b7ae6872e5f79d0844"}, - {file = "orjson-3.10.6.tar.gz", hash = "sha256:e54b63d0a7c6c54a5f5f726bc93a2078111ef060fec4ecbf34c5db800ca3b3a7"}, + {file = "orjson-3.10.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:74f4544f5a6405b90da8ea724d15ac9c36da4d72a738c64685003337401f5c12"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a566f22c28222b08875b18b0dfbf8a947e69df21a9ed5c51a6bf91cfb944ac"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf6ba8ebc8ef5792e2337fb0419f8009729335bb400ece005606336b7fd7bab7"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac7cf6222b29fbda9e3a472b41e6a5538b48f2c8f99261eecd60aafbdb60690c"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de817e2f5fc75a9e7dd350c4b0f54617b280e26d1631811a43e7e968fa71e3e9"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:348bdd16b32556cf8d7257b17cf2bdb7ab7976af4af41ebe79f9796c218f7e91"}, + {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:479fd0844ddc3ca77e0fd99644c7fe2de8e8be1efcd57705b5c92e5186e8a250"}, + {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fdf5197a21dd660cf19dfd2a3ce79574588f8f5e2dbf21bda9ee2d2b46924d84"}, + {file = "orjson-3.10.7-cp310-none-win32.whl", hash = "sha256:d374d36726746c81a49f3ff8daa2898dccab6596864ebe43d50733275c629175"}, + {file = "orjson-3.10.7-cp310-none-win_amd64.whl", hash = "sha256:cb61938aec8b0ffb6eef484d480188a1777e67b05d58e41b435c74b9d84e0b9c"}, + {file = "orjson-3.10.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7db8539039698ddfb9a524b4dd19508256107568cdad24f3682d5773e60504a2"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480f455222cb7a1dea35c57a67578848537d2602b46c464472c995297117fa09"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a9c9b168b3a19e37fe2778c0003359f07822c90fdff8f98d9d2a91b3144d8e0"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8de062de550f63185e4c1c54151bdddfc5625e37daf0aa1e75d2a1293e3b7d9a"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b0dd04483499d1de9c8f6203f8975caf17a6000b9c0c54630cef02e44ee624e"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b58d3795dafa334fc8fd46f7c5dc013e6ad06fd5b9a4cc98cb1456e7d3558bd6"}, + {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33cfb96c24034a878d83d1a9415799a73dc77480e6c40417e5dda0710d559ee6"}, + {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e724cebe1fadc2b23c6f7415bad5ee6239e00a69f30ee423f319c6af70e2a5c0"}, + {file = "orjson-3.10.7-cp311-none-win32.whl", hash = "sha256:82763b46053727a7168d29c772ed5c870fdae2f61aa8a25994c7984a19b1021f"}, + {file = "orjson-3.10.7-cp311-none-win_amd64.whl", hash = "sha256:eb8d384a24778abf29afb8e41d68fdd9a156cf6e5390c04cc07bbc24b89e98b5"}, + {file = "orjson-3.10.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44a96f2d4c3af51bfac6bc4ef7b182aa33f2f054fd7f34cc0ee9a320d051d41f"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ac14cd57df0572453543f8f2575e2d01ae9e790c21f57627803f5e79b0d3c3"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdbb61dcc365dd9be94e8f7df91975edc9364d6a78c8f7adb69c1cdff318ec93"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b48b3db6bb6e0a08fa8c83b47bc169623f801e5cc4f24442ab2b6617da3b5313"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23820a1563a1d386414fef15c249040042b8e5d07b40ab3fe3efbfbbcbcb8864"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0c6a008e91d10a2564edbb6ee5069a9e66df3fbe11c9a005cb411f441fd2c09"}, + {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d352ee8ac1926d6193f602cbe36b1643bbd1bbcb25e3c1a657a4390f3000c9a5"}, + {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2d9f990623f15c0ae7ac608103c33dfe1486d2ed974ac3f40b693bad1a22a7b"}, + {file = "orjson-3.10.7-cp312-none-win32.whl", hash = "sha256:7c4c17f8157bd520cdb7195f75ddbd31671997cbe10aee559c2d613592e7d7eb"}, + {file = "orjson-3.10.7-cp312-none-win_amd64.whl", hash = "sha256:1d9c0e733e02ada3ed6098a10a8ee0052dd55774de3d9110d29868d24b17faa1"}, + {file = "orjson-3.10.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:77d325ed866876c0fa6492598ec01fe30e803272a6e8b10e992288b009cbe149"}, + {file = "orjson-3.10.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea2c232deedcb605e853ae1db2cc94f7390ac776743b699b50b071b02bea6fe"}, + {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3dcfbede6737fdbef3ce9c37af3fb6142e8e1ebc10336daa05872bfb1d87839c"}, + {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11748c135f281203f4ee695b7f80bb1358a82a63905f9f0b794769483ea854ad"}, + {file = "orjson-3.10.7-cp313-none-win32.whl", hash = "sha256:a7e19150d215c7a13f39eb787d84db274298d3f83d85463e61d277bbd7f401d2"}, + {file = "orjson-3.10.7-cp313-none-win_amd64.whl", hash = "sha256:eef44224729e9525d5261cc8d28d6b11cafc90e6bd0be2157bde69a52ec83024"}, + {file = "orjson-3.10.7-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6ea2b2258eff652c82652d5e0f02bd5e0463a6a52abb78e49ac288827aaa1469"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:430ee4d85841e1483d487e7b81401785a5dfd69db5de01314538f31f8fbf7ee1"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b6146e439af4c2472c56f8540d799a67a81226e11992008cb47e1267a9b3225"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:084e537806b458911137f76097e53ce7bf5806dda33ddf6aaa66a028f8d43a23"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4829cf2195838e3f93b70fd3b4292156fc5e097aac3739859ac0dcc722b27ac0"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1193b2416cbad1a769f868b1749535d5da47626ac29445803dae7cc64b3f5c98"}, + {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4e6c3da13e5a57e4b3dca2de059f243ebec705857522f188f0180ae88badd354"}, + {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c31008598424dfbe52ce8c5b47e0752dca918a4fdc4a2a32004efd9fab41d866"}, + {file = "orjson-3.10.7-cp38-none-win32.whl", hash = "sha256:7122a99831f9e7fe977dc45784d3b2edc821c172d545e6420c375e5a935f5a1c"}, + {file = "orjson-3.10.7-cp38-none-win_amd64.whl", hash = "sha256:a763bc0e58504cc803739e7df040685816145a6f3c8a589787084b54ebc9f16e"}, + {file = "orjson-3.10.7-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e76be12658a6fa376fcd331b1ea4e58f5a06fd0220653450f0d415b8fd0fbe20"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed350d6978d28b92939bfeb1a0570c523f6170efc3f0a0ef1f1df287cd4f4960"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144888c76f8520e39bfa121b31fd637e18d4cc2f115727865fdf9fa325b10412"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09b2d92fd95ad2402188cf51573acde57eb269eddabaa60f69ea0d733e789fe9"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b24a579123fa884f3a3caadaed7b75eb5715ee2b17ab5c66ac97d29b18fe57f"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591bcfe7512353bd609875ab38050efe3d55e18934e2f18950c108334b4ff"}, + {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f4db56635b58cd1a200b0a23744ff44206ee6aa428185e2b6c4a65b3197abdcd"}, + {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0fa5886854673222618638c6df7718ea7fe2f3f2384c452c9ccedc70b4a510a5"}, + {file = "orjson-3.10.7-cp39-none-win32.whl", hash = "sha256:8272527d08450ab16eb405f47e0f4ef0e5ff5981c3d82afe0efd25dcbef2bcd2"}, + {file = "orjson-3.10.7-cp39-none-win_amd64.whl", hash = "sha256:974683d4618c0c7dbf4f69c95a979734bf183d0658611760017f6e70a145af58"}, + {file = "orjson-3.10.7.tar.gz", hash = "sha256:75ef0640403f945f3a1f9f6400686560dbfb0fb5b16589ad62cd477043c4eee3"}, ] [[package]] @@ -2829,29 +2833,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.5.7" +version = "0.6.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"}, - {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"}, - {file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"}, - {file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"}, - {file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"}, - {file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"}, - {file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"}, + {file = "ruff-0.6.2-py3-none-linux_armv6l.whl", hash = "sha256:5c8cbc6252deb3ea840ad6a20b0f8583caab0c5ef4f9cca21adc5a92b8f79f3c"}, + {file = "ruff-0.6.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:17002fe241e76544448a8e1e6118abecbe8cd10cf68fde635dad480dba594570"}, + {file = "ruff-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3dbeac76ed13456f8158b8f4fe087bf87882e645c8e8b606dd17b0b66c2c1158"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:094600ee88cda325988d3f54e3588c46de5c18dae09d683ace278b11f9d4d534"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:316d418fe258c036ba05fbf7dfc1f7d3d4096db63431546163b472285668132b"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d72b8b3abf8a2d51b7b9944a41307d2f442558ccb3859bbd87e6ae9be1694a5d"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2aed7e243be68487aa8982e91c6e260982d00da3f38955873aecd5a9204b1d66"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d371f7fc9cec83497fe7cf5eaf5b76e22a8efce463de5f775a1826197feb9df8"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8f310d63af08f583363dfb844ba8f9417b558199c58a5999215082036d795a1"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7db6880c53c56addb8638fe444818183385ec85eeada1d48fc5abe045301b2f1"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1175d39faadd9a50718f478d23bfc1d4da5743f1ab56af81a2b6caf0a2394f23"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939f9c86d51635fe486585389f54582f0d65b8238e08c327c1534844b3bb9a"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d0d62ca91219f906caf9b187dea50d17353f15ec9bb15aae4a606cd697b49b4c"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7438a7288f9d67ed3c8ce4d059e67f7ed65e9fe3aa2ab6f5b4b3610e57e3cb56"}, + {file = "ruff-0.6.2-py3-none-win32.whl", hash = "sha256:279d5f7d86696df5f9549b56b9b6a7f6c72961b619022b5b7999b15db392a4da"}, + {file = "ruff-0.6.2-py3-none-win_amd64.whl", hash = "sha256:d9f3469c7dd43cd22eb1c3fc16926fb8258d50cb1b216658a07be95dd117b0f2"}, + {file = "ruff-0.6.2-py3-none-win_arm64.whl", hash = "sha256:f28fcd2cd0e02bdf739297516d5643a945cc7caf09bd9bcb4d932540a5ea4fa9"}, + {file = "ruff-0.6.2.tar.gz", hash = "sha256:239ee6beb9e91feb8e0ec384204a763f36cb53fb895a1a364618c6abb076b3be"}, ] [[package]] @@ -3264,13 +3268,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "uvicorn" -version = "0.30.5" +version = "0.30.6" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" files = [ - {file = "uvicorn-0.30.5-py3-none-any.whl", hash = "sha256:b2d86de274726e9878188fa07576c9ceeff90a839e2b6e25c917fe05f5a6c835"}, - {file = "uvicorn-0.30.5.tar.gz", hash = "sha256:ac6fdbd4425c5fd17a9fe39daf4d4d075da6fdc80f653e5894cdc2fd98752bee"}, + {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, + {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, ] [package.dependencies] From 843345df4ffc45f6808d47973e2c02da1d30b015 Mon Sep 17 00:00:00 2001 From: Yuvraj P Date: Sat, 24 Aug 2024 16:30:31 -0400 Subject: [PATCH 155/723] fix(mobile): Fix for incorrectly naming edited files and structure change (#11741) * Fix null name * Fix null name and Fix button * Remove extension correctly * Refactoring the code and formatting * formatting * Fix for the extension name --- mobile/lib/pages/editing/crop.page.dart | 12 ++- mobile/lib/pages/editing/edit.page.dart | 100 +++++++++--------- mobile/lib/routing/router.gr.dart | 35 +++--- .../asset_viewer/bottom_gallery_bar.dart | 9 +- 4 files changed, 88 insertions(+), 68 deletions(-) diff --git a/mobile/lib/pages/editing/crop.page.dart b/mobile/lib/pages/editing/crop.page.dart index 8a21cdf769..a3ac34dfa0 100644 --- a/mobile/lib/pages/editing/crop.page.dart +++ b/mobile/lib/pages/editing/crop.page.dart @@ -3,6 +3,7 @@ import 'package:crop_image/crop_image.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'edit.page.dart'; import 'package:auto_route/auto_route.dart'; @@ -14,7 +15,8 @@ import 'package:auto_route/auto_route.dart'; @RoutePage() class CropImagePage extends HookWidget { final Image image; - const CropImagePage({super.key, required this.image}); + final Asset asset; + const CropImagePage({super.key, required this.image, required this.asset}); @override Widget build(BuildContext context) { @@ -34,7 +36,13 @@ class CropImagePage extends HookWidget { ), onPressed: () async { final croppedImage = await cropController.croppedImage(); - context.pushRoute(EditImageRoute(image: croppedImage)); + context.pushRoute( + EditImageRoute( + asset: asset, + image: croppedImage, + isEdited: true, + ), + ); }, ), ], diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart index 22fb345e0f..b9017e940b 100644 --- a/mobile/lib/pages/editing/edit.page.dart +++ b/mobile/lib/pages/editing/edit.page.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:auto_route/auto_route.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:photo_manager/photo_manager.dart'; +import 'package:path/path.dart' as p; import 'package:immich_mobile/providers/album/album.provider.dart'; /// A stateless widget that provides functionality for editing an image. @@ -24,18 +25,16 @@ import 'package:immich_mobile/providers/album/album.provider.dart'; @immutable @RoutePage() class EditImagePage extends ConsumerWidget { - final Asset? asset; - final Image? image; + final Asset asset; + final Image image; + final bool isEdited; const EditImagePage({ super.key, - this.image, - this.asset, - }) : assert( - (image != null && asset == null) || (image == null && asset != null), - 'Must supply one of asset or image', - ); - + required this.asset, + required this.image, + required this.isEdited, + }); Future _imageToUint8List(Image image) async { final Completer completer = Completer(); image.image.resolve(const ImageConfiguration()).addListener( @@ -58,19 +57,34 @@ class EditImagePage extends ConsumerWidget { return completer.future; } + Future _saveEditedImage( + BuildContext context, + Asset asset, + Image image, + WidgetRef ref, + ) async { + try { + final Uint8List imageData = await _imageToUint8List(image); + await PhotoManager.editor.saveImage( + imageData, + title: "${p.withoutExtension(asset.fileName)}_edited.jpg", + ); + await ref.read(albumProvider.notifier).getDeviceAlbums(); + Navigator.of(context).popUntil((route) => route.isFirst); + } catch (e) { + ImmichToast.show( + durationInSecond: 6, + context: context, + msg: 'Error: $e', + gravity: ToastGravity.CENTER, + ); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { - final ImageProvider provider = (asset != null) - ? ImmichImage.imageProvider(asset: asset!) - : (image != null) - ? image!.image - : throw Exception('Invalid image source type'); - - final Image imageWidget = (asset != null) - ? Image(image: ImmichImage.imageProvider(asset: asset!)) - : (image != null) - ? image! - : throw Exception('Invalid image source type'); + final Image imageWidget = + Image(image: ImmichImage.imageProvider(asset: asset)); return Scaffold( appBar: AppBar( @@ -85,44 +99,24 @@ class EditImagePage extends ConsumerWidget { Navigator.of(context).popUntil((route) => route.isFirst), ), actions: [ - if (image != null) - TextButton( - onPressed: () async { - try { - final Uint8List imageData = await _imageToUint8List(image!); - ImmichToast.show( - durationInSecond: 3, - context: context, - msg: 'Image Saved!', - gravity: ToastGravity.CENTER, - ); - - await PhotoManager.editor.saveImage( - imageData, - title: '${asset!.fileName}_edited.jpg', - ); - await ref.read(albumProvider.notifier).getDeviceAlbums(); - Navigator.of(context).popUntil((route) => route.isFirst); - } catch (e) { - ImmichToast.show( - durationInSecond: 6, - context: context, - msg: 'Error: ${e.toString()}', - gravity: ToastGravity.BOTTOM, - ); - } - }, - child: Text( - 'Save to gallery', - style: Theme.of(context).textTheme.displayMedium, + TextButton( + onPressed: isEdited + ? () => _saveEditedImage(context, asset, image, ref) + : null, + child: Text( + 'Save to gallery', + style: TextStyle( + color: + isEdited ? Theme.of(context).iconTheme.color : Colors.grey, ), ), + ), ], ), body: Column( children: [ Expanded( - child: Image(image: provider), + child: image, ), Container( height: 80, @@ -148,7 +142,9 @@ class EditImagePage extends ConsumerWidget { color: Theme.of(context).iconTheme.color, ), onPressed: () { - context.pushRoute(CropImageRoute(image: imageWidget)); + context.pushRoute( + CropImageRoute(asset: asset, image: imageWidget), + ); }, ), Text('Crop', style: Theme.of(context).textTheme.displayMedium), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index a4259676c7..90fc4cb0fe 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -613,12 +613,14 @@ class CropImageRoute extends PageRouteInfo { CropImageRoute({ Key? key, required Image image, + required Asset asset, List? children, }) : super( CropImageRoute.name, args: CropImageRouteArgs( key: key, image: image, + asset: asset, ), initialChildren: children, ); @@ -632,6 +634,7 @@ class CropImageRoute extends PageRouteInfo { return CropImagePage( key: args.key, image: args.image, + asset: args.asset, ); }, ); @@ -641,15 +644,18 @@ class CropImageRouteArgs { const CropImageRouteArgs({ this.key, required this.image, + required this.asset, }); final Key? key; final Image image; + final Asset asset; + @override String toString() { - return 'CropImageRouteArgs{key: $key, image: $image}'; + return 'CropImageRouteArgs{key: $key, image: $image, asset: $asset}'; } } @@ -658,15 +664,17 @@ class CropImageRouteArgs { class EditImageRoute extends PageRouteInfo { EditImageRoute({ Key? key, - Image? image, - Asset? asset, + required Asset asset, + required Image image, + required bool isEdited, List? children, }) : super( EditImageRoute.name, args: EditImageRouteArgs( key: key, - image: image, asset: asset, + image: image, + isEdited: isEdited, ), initialChildren: children, ); @@ -676,12 +684,12 @@ class EditImageRoute extends PageRouteInfo { static PageInfo page = PageInfo( name, builder: (data) { - final args = data.argsAs( - orElse: () => const EditImageRouteArgs()); + final args = data.argsAs(); return EditImagePage( key: args.key, - image: args.image, asset: args.asset, + image: args.image, + isEdited: args.isEdited, ); }, ); @@ -690,19 +698,22 @@ class EditImageRoute extends PageRouteInfo { class EditImageRouteArgs { const EditImageRouteArgs({ this.key, - this.image, - this.asset, + required this.asset, + required this.image, + required this.isEdited, }); final Key? key; - final Image? image; + final Asset asset; - final Asset? asset; + final Image image; + + final bool isEdited; @override String toString() { - return 'EditImageRouteArgs{key: $key, image: $image, asset: $asset}'; + return 'EditImageRouteArgs{key: $key, asset: $asset, image: $image, isEdited: $isEdited}'; } } diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 7d9e49bd29..7e6136c256 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart' import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -184,6 +185,7 @@ class BottomGalleryBar extends ConsumerWidget { } void handleEdit() async { + final image = Image(image: ImmichImage.imageProvider(asset: asset)); if (asset.isOffline) { ImmichToast.show( durationInSecond: 1, @@ -195,8 +197,11 @@ class BottomGalleryBar extends ConsumerWidget { } Navigator.of(context).push( MaterialPageRoute( - builder: (context) => - EditImagePage(asset: asset), // Send the Asset object + builder: (context) => EditImagePage( + asset: asset, + image: image, + isEdited: false, + ), ), ); } From 7a4fccb1b2a3f48286a2cd8babc49ef8fc644963 Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Sat, 24 Aug 2024 23:59:18 -0500 Subject: [PATCH 156/723] fix(web): show a clearer confirmation message when deleting an unnamed album (#11988) * fix(web): show a different confirmation message when deleting an unnamed album * Rename the function * Fix formatting --- .../lib/components/album-page/albums-list.svelte | 7 ++----- web/src/lib/i18n/en.json | 4 +++- web/src/lib/utils/album-utils.ts | 14 ++++++++++++++ .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 6 ++---- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index dcecd01d9e..4355aca94d 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -17,7 +17,7 @@ import { handleError } from '$lib/utils/handle-error'; import { downloadAlbum } from '$lib/utils/asset-utils'; import { normalizeSearchString } from '$lib/utils/string-utils'; - import { getSelectedAlbumGroupOption, type AlbumGroup } from '$lib/utils/album-utils'; + import { getSelectedAlbumGroupOption, type AlbumGroup, confirmAlbumDelete } from '$lib/utils/album-utils'; import type { ContextMenuPosition } from '$lib/utils/context-menu'; import { user } from '$lib/stores/user.store'; import { @@ -31,7 +31,6 @@ } from '$lib/stores/preferences.store'; import { goto } from '$app/navigation'; import { AppRoute } from '$lib/constants'; - import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; export let ownedAlbums: AlbumResponseDto[] = []; @@ -302,9 +301,7 @@ return; } - const isConfirmed = await dialogController.show({ - prompt: $t('album_delete_confirmation', { values: { album: albumToDelete.albumName } }), - }); + const isConfirmed = await confirmAlbumDelete(albumToDelete); if (!isConfirmed) { return; diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 3609b9c274..43050fabdc 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -335,7 +335,8 @@ "album_added": "Album added", "album_added_notification_setting_description": "Receive an email notification when you are added to a shared album", "album_cover_updated": "Album cover updated", - "album_delete_confirmation": "Are you sure you want to delete the album {album}?\nIf this album is shared, other users will not be able to access it anymore.", + "album_delete_confirmation": "Are you sure you want to delete the album {album}?", + "album_delete_confirmation_description": "If this album is shared, other users will not be able to access it anymore.", "album_info_updated": "Album info updated", "album_leave": "Leave album?", "album_leave_confirmation": "Are you sure you want to leave {album}?", @@ -1189,6 +1190,7 @@ "unlink_oauth": "Unlink OAuth", "unlinked_oauth_account": "Unlinked OAuth account", "unnamed_album": "Unnamed Album", + "unnamed_album_delete_confirmation": "Are you sure you want to delete this album?", "unnamed_share": "Unnamed Share", "unsaved_change": "Unsaved change", "unselect_all": "Unselect all", diff --git a/web/src/lib/utils/album-utils.ts b/web/src/lib/utils/album-utils.ts index aff76ef88e..028aa721c7 100644 --- a/web/src/lib/utils/album-utils.ts +++ b/web/src/lib/utils/album-utils.ts @@ -1,4 +1,5 @@ import { goto } from '$app/navigation'; +import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { AppRoute } from '$lib/constants'; import { AlbumFilter, @@ -199,3 +200,16 @@ export const collapseAllAlbumGroups = (groupIds: string[]) => { export const expandAllAlbumGroups = () => { collapseAllAlbumGroups([]); }; + +export const confirmAlbumDelete = async (album: AlbumResponseDto) => { + const $t = get(t); + const confirmation = + album.albumName.length > 0 + ? $t('album_delete_confirmation', { values: { album: album.albumName } }) + : $t('unnamed_album_delete_confirmation'); + + const description = $t('album_delete_confirmation_description'); + const prompt = `${confirmation} ${description}`; + + return dialogController.show({ prompt }); +}; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index ff5709df99..1dfc494f5e 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -82,9 +82,9 @@ } from '@mdi/js'; import { fly } from 'svelte/transition'; import type { PageData } from './$types'; - import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; + import { confirmAlbumDelete } from '$lib/utils/album-utils'; export let data: PageData; @@ -365,9 +365,7 @@ }; const handleRemoveAlbum = async () => { - const isConfirmed = await dialogController.show({ - prompt: $t('album_delete_confirmation', { values: { album: album.albumName } }), - }); + const isConfirmed = await confirmAlbumDelete(album); if (!isConfirmed) { viewMode = ViewMode.VIEW; From b41af659972ce0c1b9aad14e32f76494d58614ab Mon Sep 17 00:00:00 2001 From: Christopher Makarem <23037854+x24git@users.noreply.github.com> Date: Sat, 24 Aug 2024 22:00:15 -0700 Subject: [PATCH 157/723] fix: align camera model drop down behavior with other drop downs on web and mobile (#11951) * fix(web): align search filter behavior to show all camera models * fix(mobile): align search filter behavior to clear camera model when make is set * (mobile) correctly clear the model controller * fix(mobile) re-add text controller to dropdown --------- Co-authored-by: Alex --- mobile/lib/widgets/search/search_filter/camera_picker.dart | 6 +++++- .../lib/widgets/search/search_filter/common/dropdown.dart | 1 + .../search-bar/search-camera-section.svelte | 5 ++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/mobile/lib/widgets/search/search_filter/camera_picker.dart b/mobile/lib/widgets/search/search_filter/camera_picker.dart index 2e5618c9e0..e2110c9c29 100644 --- a/mobile/lib/widgets/search/search_filter/camera_picker.dart +++ b/mobile/lib/widgets/search/search_filter/camera_picker.dart @@ -51,10 +51,14 @@ class CameraPicker extends HookConsumerWidget { controller: makeTextController, leadingIcon: const Icon(Icons.photo_camera_rounded), onSelected: (value) { + if (value.toString() == selectedMake.value) { + return; + } selectedMake.value = value.toString(); + modelTextController.value = TextEditingValue.empty; onSelect({ 'make': selectedMake.value, - 'model': selectedModel.value, + 'model': null, }); }, ); diff --git a/mobile/lib/widgets/search/search_filter/common/dropdown.dart b/mobile/lib/widgets/search/search_filter/common/dropdown.dart index 230d7dd4da..dd8785459f 100644 --- a/mobile/lib/widgets/search/search_filter/common/dropdown.dart +++ b/mobile/lib/widgets/search/search_filter/common/dropdown.dart @@ -29,6 +29,7 @@ class SearchDropdown extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { return DropdownMenu( + controller: controller, leadingIcon: leadingIcon, width: constraints.maxWidth, dropdownMenuEntries: dropdownMenuEntries, diff --git a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte index 839c17ecce..f1cd0c8596 100644 --- a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte @@ -18,13 +18,12 @@ $: makeFilter = filters.make; $: modelFilter = filters.model; - $: handlePromiseError(updateMakes(modelFilter)); + $: handlePromiseError(updateMakes()); $: handlePromiseError(updateModels(makeFilter)); - async function updateMakes(model?: string) { + async function updateMakes() { const results: Array = await getSearchSuggestions({ $type: SearchSuggestionType.CameraMake, - model, includeNull: true, }); From e457d8d62eb15cdb75fa9982744fbc1734767fc0 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 25 Aug 2024 00:09:37 -0500 Subject: [PATCH 158/723] chore(mobile): patch download > includeEmbeddedVideos user preferences (#11910) * chore(mobile): patch download > includeEmbeddedVideos user preferences * correct patch --- mobile/lib/providers/authentication.provider.dart | 3 +++ mobile/lib/utils/openapi_patching.dart | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart index 5846bb78cc..5d3ae5bc22 100644 --- a/mobile/lib/providers/authentication.provider.dart +++ b/mobile/lib/providers/authentication.provider.dart @@ -190,6 +190,9 @@ class AuthenticationNotifier extends StateNotifier { error, stackTrace, ); + debugPrint( + "Error getting user information from the server [CATCH ALL] $error $stackTrace", + ); } // If the user information is successfully retrieved, update the store diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 7b27f59aee..7a2f7396eb 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -7,6 +7,11 @@ dynamic upgradeDto(dynamic value, String targetType) { if (value['rating'] == null) { value['rating'] = RatingResponse().toJson(); } + + if (value['download']['includeEmbeddedVideos'] == null) { + value['download']['includeEmbeddedVideos'] = false; + } } + break; } } From 868aedd2120da5899598b85fe9eea53e5e6deae7 Mon Sep 17 00:00:00 2001 From: Thomas Clarke <43609027+Tonux599@users.noreply.github.com> Date: Sun, 25 Aug 2024 18:54:12 +0100 Subject: [PATCH 159/723] fix: docs link to breaking changes (#12027) Fix link to breaking changes --- docs/docs/install/docker-compose.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/install/docker-compose.mdx b/docs/docs/install/docker-compose.mdx index 0b69bd8639..9ef63523a0 100644 --- a/docs/docs/install/docker-compose.mdx +++ b/docs/docs/install/docker-compose.mdx @@ -109,7 +109,7 @@ Immich is currently under heavy development, which means you can expect [breakin [compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml [env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env [watchtower]: https://containrrr.dev/watchtower/ -[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Abreaking-change+sort%3Adate_created +[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created [container-auth]: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry [releases]: https://github.com/immich-app/immich/releases [docker-repo]: https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository From b653a20d1562c389a3e5bac99306a104ce4d6c1c Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 25 Aug 2024 16:53:14 -0500 Subject: [PATCH 160/723] fix(web): sort folders (#12038) chore(web): sort folders --- web/src/lib/utils/tree-utils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/src/lib/utils/tree-utils.ts b/web/src/lib/utils/tree-utils.ts index cc17784eb6..13fb6c1605 100644 --- a/web/src/lib/utils/tree-utils.ts +++ b/web/src/lib/utils/tree-utils.ts @@ -6,6 +6,9 @@ export const normalizeTreePath = (path: string) => path.replace(/^\//, '').repla export function buildTree(paths: string[]) { const root: RecursiveObject = {}; + + paths.sort(); + for (const path of paths) { const parts = path.split('/'); let current = root; From b2dd5a3152982d173e5200438e1f12341b932453 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Sun, 25 Aug 2024 18:34:08 -0400 Subject: [PATCH 161/723] feat: loading screen, initSDK on bootstrap, fix FOUC for theme (#10350) * feat: loading screen, initSDK on bootstrap, fix FOUC for theme * pulsate immich logo, don't set localstorage * Make it spin * Rework error handling a bit * Cleanup * fix test * rename, memoize --------- Co-authored-by: Alex Tran --- web/src/app.html | 156 +++++++++++++++--- .../admin-page/settings/admin-settings.svelte | 4 +- web/src/lib/components/error.svelte | 105 ++++++++++++ .../forms/admin-registration-form.svelte | 2 + .../lib/components/forms/login-form.svelte | 6 +- web/src/lib/stores/server-config.store.ts | 2 +- web/src/lib/utils.ts | 2 +- web/src/lib/utils/server.ts | 21 +++ web/src/routes/+error.svelte | 104 +----------- web/src/routes/+layout.svelte | 26 +-- web/src/routes/+layout.ts | 16 +- web/src/routes/+page.ts | 36 ++-- web/src/routes/auth/login/+page.ts | 11 +- web/src/routes/auth/onboarding/+page.ts | 2 - web/src/routes/auth/register/+page.ts | 8 +- 15 files changed, 328 insertions(+), 173 deletions(-) create mode 100644 web/src/lib/components/error.svelte create mode 100644 web/src/lib/utils/server.ts diff --git a/web/src/app.html b/web/src/app.html index d1db02f493..aa8450e9be 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -1,5 +1,5 @@ - + @@ -14,35 +14,96 @@ %sveltekit.head% + + +
    +
    +
    + + + +
    +
    + +
    +
    +
    +
    +
    +

    + 🚨 {$t('error_title')} +

    +
    + handleCopy()} + /> +
    +
    + +
    + +
    +
    +

    {error?.message} ({error?.code})

    + {#if error?.stack} + +
    {error?.stack || 'No stack'}
    + {/if} +
    +
    + +
    + + +
    +
    +
    +
    +
    diff --git a/web/src/lib/components/forms/admin-registration-form.svelte b/web/src/lib/components/forms/admin-registration-form.svelte index c66b09040f..d49ab55439 100644 --- a/web/src/lib/components/forms/admin-registration-form.svelte +++ b/web/src/lib/components/forms/admin-registration-form.svelte @@ -6,6 +6,7 @@ import Button from '../elements/buttons/button.svelte'; import PasswordField from '../shared-components/password-field.svelte'; import { t } from 'svelte-i18n'; + import { retrieveServerConfig } from '$lib/stores/server-config.store'; let email = ''; let password = ''; @@ -31,6 +32,7 @@ try { await signUpAdmin({ signUpDto: { email, password, name } }); + await retrieveServerConfig(); await goto(AppRoute.AUTH_LOGIN); } catch (error) { handleError(error, $t('errors.unable_to_create_admin_account')); diff --git a/web/src/lib/components/forms/login-form.svelte b/web/src/lib/components/forms/login-form.svelte index 828927a13a..b1af7a01f4 100644 --- a/web/src/lib/components/forms/login-form.svelte +++ b/web/src/lib/components/forms/login-form.svelte @@ -5,7 +5,7 @@ import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; import { oauth } from '$lib/utils'; import { getServerErrorMessage, handleError } from '$lib/utils/handle-error'; - import { getServerConfig, login } from '@immich/sdk'; + import { login } from '@immich/sdk'; import { onMount } from 'svelte'; import { fade } from 'svelte/transition'; import Button from '../elements/buttons/button.svelte'; @@ -58,11 +58,9 @@ try { errorMessage = ''; loading = true; - const user = await login({ loginCredentialDto: { email, password } }); - const serverConfig = await getServerConfig(); - if (user.isAdmin && !serverConfig.isOnboarded) { + if (user.isAdmin && !$serverConfig.isOnboarded) { await onOnboarding(); return; } diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index 40670df25f..1d3c4bc00e 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -33,7 +33,7 @@ export const serverConfig = writable({ externalDomain: '', }); -export const loadConfig = async () => { +export const retrieveServerConfig = async () => { const [flags, config] = await Promise.all([getServerFeatures(), getServerConfig()]); featureFlags.update(() => ({ ...flags, loaded: true })); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index b805cf8132..6c3add70ce 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -33,7 +33,7 @@ interface DownloadRequestOptions { onDownloadProgress?: (event: ProgressEvent) => void; } -export const initApp = async () => { +export const initLanguage = async () => { const preferenceLang = get(lang); for (const { code, loader } of langs) { register(code, loader); diff --git a/web/src/lib/utils/server.ts b/web/src/lib/utils/server.ts new file mode 100644 index 0000000000..d2c5ab1851 --- /dev/null +++ b/web/src/lib/utils/server.ts @@ -0,0 +1,21 @@ +import { retrieveServerConfig } from '$lib/stores/server-config.store'; +import { initLanguage } from '$lib/utils'; +import { defaults } from '@immich/sdk'; +import { memoize } from 'lodash-es'; + +type fetchType = typeof fetch; + +export function initSDK(fetch: fetchType) { + // set event.fetch on the fetch-client used by @immich/sdk + // https://kit.svelte.dev/docs/load#making-fetch-requests + // https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options + defaults.fetch = fetch; +} + +async function _init(fetch: fetchType) { + initSDK(fetch); + await initLanguage(); + await retrieveServerConfig(); +} + +export const init = memoize(_init, () => 'singlevalue'); diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte index e82605d83e..23e8fd3ff1 100644 --- a/web/src/routes/+error.svelte +++ b/web/src/routes/+error.svelte @@ -1,106 +1,6 @@ -
    -
    -
    - - - -
    -
    - -
    -
    -
    -
    -
    -

    - 🚨 {$t('error_title')} -

    -
    - handleCopy()} - /> -
    -
    - -
    - -
    -
    -

    {$page.error?.message} ({$page.error?.code})

    - {#if $page.error?.stack} - -
    {$page.error?.stack || 'No stack'}
    - {/if} -
    -
    - -
    - - -
    -
    -
    -
    -
    + diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 1ad9066c4e..b7335dea59 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -10,16 +10,18 @@ import VersionAnnouncementBox from '$lib/components/shared-components/version-announcement-box.svelte'; import { Theme } from '$lib/constants'; import { colorTheme, handleToggleTheme, type ThemeSetting } from '$lib/stores/preferences.store'; - import { loadConfig, serverConfig } from '$lib/stores/server-config.store'; + + import { serverConfig } from '$lib/stores/server-config.store'; + import { user } from '$lib/stores/user.store'; import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket'; import { copyToClipboard, setKey } from '$lib/utils'; - import { handleError } from '$lib/utils/handle-error'; import { onDestroy, onMount } from 'svelte'; import '../app.css'; import { isAssetViewerRoute, isSharedLinkRoute } from '$lib/utils/navigation'; import DialogWrapper from '$lib/components/shared-components/dialog/dialog-wrapper.svelte'; import { t } from 'svelte-i18n'; + import Error from '$lib/components/error.svelte'; import { shortcut } from '$lib/actions/shortcut'; let showNavigationLoadingBar = false; @@ -33,8 +35,7 @@ const changeTheme = (theme: ThemeSetting) => { if (theme.system) { - theme.value = - window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT; + theme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT; } if (theme.value === Theme.LIGHT) { @@ -55,6 +56,8 @@ }; onMount(() => { + const element = document.querySelector('#stencil'); + element?.remove(); // if the browser theme changes, changes the Immich theme too window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handleChangeTheme); }); @@ -77,14 +80,6 @@ afterNavigate(() => { showNavigationLoadingBar = false; }); - - onMount(async () => { - try { - await loadConfig(); - } catch (error) { - handleError(error, $t('errors.unable_to_connect_to_server')); - } - }); @@ -134,7 +129,12 @@ onShortcut: () => copyToClipboard(getMyImmichLink().toString()), }} /> - + +{#if $page.data.error} + +{:else} + +{/if} {#if showNavigationLoadingBar} diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts index e8f665e0e4..b5edece09e 100644 --- a/web/src/routes/+layout.ts +++ b/web/src/routes/+layout.ts @@ -1,19 +1,19 @@ -import { initApp } from '$lib/utils'; -import { defaults } from '@immich/sdk'; +import { init } from '$lib/utils/server'; import type { LayoutLoad } from './$types'; export const ssr = false; export const csr = true; export const load = (async ({ fetch }) => { - // set event.fetch on the fetch-client used by @immich/sdk - // https://kit.svelte.dev/docs/load#making-fetch-requests - // https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options - defaults.fetch = fetch; - - await initApp(); + let error; + try { + await init(fetch); + } catch (initError) { + error = initError; + } return { + error, meta: { title: 'Immich', }, diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts index f9897336af..bcc854cc3c 100644 --- a/web/src/routes/+page.ts +++ b/web/src/routes/+page.ts @@ -1,26 +1,38 @@ import { AppRoute } from '$lib/constants'; +import { serverConfig } from '$lib/stores/server-config.store'; import { getFormatter } from '$lib/utils/i18n'; -import { getServerConfig } from '@immich/sdk'; +import { init } from '$lib/utils/server'; + import { redirect } from '@sveltejs/kit'; +import { get } from 'svelte/store'; import { loadUser } from '../lib/utils/auth'; import type { PageLoad } from './$types'; export const ssr = false; export const csr = true; -export const load = (async () => { - const authenticated = await loadUser(); - if (authenticated) { - redirect(302, AppRoute.PHOTOS); - } +export const load = (async ({ fetch }) => { + let $t = (arg: string) => arg; + try { + await init(fetch); + const authenticated = await loadUser(); + if (authenticated) { + redirect(302, AppRoute.PHOTOS); + } - const { isInitialized } = await getServerConfig(); - if (isInitialized) { - // Redirect to login page if there exists an admin account (i.e. server is initialized) - redirect(302, AppRoute.AUTH_LOGIN); - } + const { isInitialized } = get(serverConfig); + if (isInitialized) { + // Redirect to login page if there exists an admin account (i.e. server is initialized) + redirect(302, AppRoute.AUTH_LOGIN); + } - const $t = await getFormatter(); + $t = await getFormatter(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (redirectError: any) { + if (redirectError?.status === 302) { + throw redirectError; + } + } return { meta: { diff --git a/web/src/routes/auth/login/+page.ts b/web/src/routes/auth/login/+page.ts index 427287c8ea..847992ab20 100644 --- a/web/src/routes/auth/login/+page.ts +++ b/web/src/routes/auth/login/+page.ts @@ -1,12 +1,15 @@ import { AppRoute } from '$lib/constants'; +import { serverConfig } from '$lib/stores/server-config.store'; import { getFormatter } from '$lib/utils/i18n'; -import { defaults, getServerConfig } from '@immich/sdk'; + import { redirect } from '@sveltejs/kit'; +import { get } from 'svelte/store'; import type { PageLoad } from './$types'; -export const load = (async ({ fetch }) => { - defaults.fetch = fetch; - const { isInitialized } = await getServerConfig(); +export const load = (async ({ parent }) => { + await parent(); + const { isInitialized } = get(serverConfig); + if (!isInitialized) { // Admin not registered redirect(302, AppRoute.AUTH_REGISTER); diff --git a/web/src/routes/auth/onboarding/+page.ts b/web/src/routes/auth/onboarding/+page.ts index 7bd307a3ee..db16c8e514 100644 --- a/web/src/routes/auth/onboarding/+page.ts +++ b/web/src/routes/auth/onboarding/+page.ts @@ -1,11 +1,9 @@ -import { loadConfig } from '$lib/stores/server-config.store'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import type { PageLoad } from './$types'; export const load = (async () => { await authenticate({ admin: true }); - await loadConfig(); const $t = await getFormatter(); diff --git a/web/src/routes/auth/register/+page.ts b/web/src/routes/auth/register/+page.ts index 00574043c1..88b56caa47 100644 --- a/web/src/routes/auth/register/+page.ts +++ b/web/src/routes/auth/register/+page.ts @@ -1,11 +1,13 @@ import { AppRoute } from '$lib/constants'; +import { serverConfig } from '$lib/stores/server-config.store'; import { getFormatter } from '$lib/utils/i18n'; -import { getServerConfig } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; +import { get } from 'svelte/store'; import type { PageLoad } from './$types'; -export const load = (async () => { - const { isInitialized } = await getServerConfig(); +export const load = (async ({ parent }) => { + await parent(); + const { isInitialized } = get(serverConfig); if (isInitialized) { // Admin has been registered, redirect to login redirect(302, AppRoute.AUTH_LOGIN); From 96056208fc823e81051482a8189bca8d5aed97fd Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Sun, 25 Aug 2024 19:50:54 -0400 Subject: [PATCH 162/723] fix(web): announce current theme to screen reader users (#12039) --- .../components/shared-components/theme-button.svelte | 10 +++++++++- web/src/lib/i18n/en.json | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/shared-components/theme-button.svelte b/web/src/lib/components/shared-components/theme-button.svelte index 7fc823f8ed..8376b72359 100644 --- a/web/src/lib/components/shared-components/theme-button.svelte +++ b/web/src/lib/components/shared-components/theme-button.svelte @@ -7,8 +7,16 @@ $: icon = $colorTheme.value === Theme.LIGHT ? moonPath : sunPath; $: viewBox = $colorTheme.value === Theme.LIGHT ? moonViewBox : sunViewBox; + $: isDark = $colorTheme.value === Theme.DARK; {#if !$colorTheme.system} - + {/if} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 43050fabdc..d8d0c3f8c8 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1171,7 +1171,7 @@ "to_login": "Login", "to_trash": "Trash", "toggle_settings": "Toggle settings", - "toggle_theme": "Toggle theme", + "toggle_theme": "Toggle dark theme", "total_usage": "Total usage", "trash": "Trash", "trash_all": "Trash All", From 4f02412493a5758e32ee3830a35fc112de1c32dd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 25 Aug 2024 22:50:51 -0400 Subject: [PATCH 163/723] chore(deps): update dependency node to v20.17.0 (#12040) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/.nvmrc | 2 +- cli/package.json | 2 +- docs/.nvmrc | 2 +- docs/package.json | 2 +- e2e/.nvmrc | 2 +- e2e/package.json | 2 +- open-api/typescript-sdk/.nvmrc | 2 +- open-api/typescript-sdk/package.json | 2 +- server/.nvmrc | 2 +- server/package.json | 2 +- web/.nvmrc | 2 +- web/package.json | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cli/.nvmrc b/cli/.nvmrc index 8ce7030825..3516580bbb 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -20.16.0 +20.17.0 diff --git a/cli/package.json b/cli/package.json index ddd6730887..cce73afa37 100644 --- a/cli/package.json +++ b/cli/package.json @@ -67,6 +67,6 @@ "lodash-es": "^4.17.21" }, "volta": { - "node": "20.16.0" + "node": "20.17.0" } } diff --git a/docs/.nvmrc b/docs/.nvmrc index 8ce7030825..3516580bbb 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -20.16.0 +20.17.0 diff --git a/docs/package.json b/docs/package.json index e32fe09499..cdcdf53446 100644 --- a/docs/package.json +++ b/docs/package.json @@ -56,6 +56,6 @@ "node": ">=20" }, "volta": { - "node": "20.16.0" + "node": "20.17.0" } } diff --git a/e2e/.nvmrc b/e2e/.nvmrc index 8ce7030825..3516580bbb 100644 --- a/e2e/.nvmrc +++ b/e2e/.nvmrc @@ -1 +1 @@ -20.16.0 +20.17.0 diff --git a/e2e/package.json b/e2e/package.json index 1c19526e83..be072e44f3 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -53,6 +53,6 @@ "vitest": "^2.0.5" }, "volta": { - "node": "20.16.0" + "node": "20.17.0" } } diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index 8ce7030825..3516580bbb 100644 --- a/open-api/typescript-sdk/.nvmrc +++ b/open-api/typescript-sdk/.nvmrc @@ -1 +1 @@ -20.16.0 +20.17.0 diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 6f54670789..90fa525fa0 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -28,6 +28,6 @@ "directory": "open-api/typescript-sdk" }, "volta": { - "node": "20.16.0" + "node": "20.17.0" } } diff --git a/server/.nvmrc b/server/.nvmrc index 8ce7030825..3516580bbb 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -20.16.0 +20.17.0 diff --git a/server/package.json b/server/package.json index d918582a58..8a9149bf84 100644 --- a/server/package.json +++ b/server/package.json @@ -137,6 +137,6 @@ "vitest": "^2.0.5" }, "volta": { - "node": "20.16.0" + "node": "20.17.0" } } diff --git a/web/.nvmrc b/web/.nvmrc index 8ce7030825..3516580bbb 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -20.16.0 +20.17.0 diff --git a/web/package.json b/web/package.json index 7d7751b67f..7163b04788 100644 --- a/web/package.json +++ b/web/package.json @@ -86,6 +86,6 @@ "thumbhash": "^0.1.1" }, "volta": { - "node": "20.16.0" + "node": "20.17.0" } } From fe672d4f35d1899ee09d58f773cc1f2b46af5d36 Mon Sep 17 00:00:00 2001 From: Anil Madhavapeddy Date: Mon, 26 Aug 2024 13:16:24 +0100 Subject: [PATCH 164/723] feat(format): nrw format (#12048) --- server/src/utils/mime-types.spec.ts | 1 + server/src/utils/mime-types.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index 996ea6c744..50fe760a04 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -30,6 +30,7 @@ describe('mimeTypes', () => { { mimetype: 'image/kdc', extension: '.kdc' }, { mimetype: 'image/mrw', extension: '.mrw' }, { mimetype: 'image/nef', extension: '.nef' }, + { mimetype: 'image/nrw', extension: '.nrw' }, { mimetype: 'image/orf', extension: '.orf' }, { mimetype: 'image/ori', extension: '.ori' }, { mimetype: 'image/pef', extension: '.pef' }, diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 6b59d2cd41..cbf6e5b489 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -19,6 +19,7 @@ const raw: Record = { '.kdc': ['image/kdc', 'image/x-kodak-kdc'], '.mrw': ['image/mrw', 'image/x-minolta-mrw'], '.nef': ['image/nef', 'image/x-nikon-nef'], + '.nrw': ['image/nrw', 'image/x-nikon-nrw'], '.orf': ['image/orf', 'image/x-olympus-orf'], '.ori': ['image/ori', 'image/x-olympus-ori'], '.pef': ['image/pef', 'image/x-pentax-pef'], From 129e5eae66974055cbdaed90f694019939dca746 Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Mon, 26 Aug 2024 17:33:01 +0200 Subject: [PATCH 165/723] fix: do not code format repro steps in issue template (#12054) issue template: do not use "bash" to render a list of text items --- .github/ISSUE_TEMPLATE/bug_report.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 12ffc89ea2..346c6e60f2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -83,7 +83,6 @@ body: 2. 3. ... - render: bash validations: required: true From 3ac42edc74c0cc713a99505ecf907d261870eddc Mon Sep 17 00:00:00 2001 From: Matt Tyree Date: Mon, 26 Aug 2024 12:06:21 -0400 Subject: [PATCH 166/723] docs: add Immich Kiosk and Immich Power Tools to Community Projects (#12055) Add Immich Kiosk and Immich Power Tools Added Immich Kiosk and Immich Power Tools to Community Projects --- docs/src/components/community-projects.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx index 0fd4cc25c1..0f9b2b2413 100644 --- a/docs/src/components/community-projects.tsx +++ b/docs/src/components/community-projects.tsx @@ -68,6 +68,16 @@ const projects: CommunityProjectProps[] = [ description: 'Snap package for easy install and zero-care auto updates of Immich. Self-hosted photo management.', url: 'https://immich-distribution.nsg.cc', }, + { + title: 'Immich Kiosk', + description: 'Lightweight slideshow to run on kiosk devices and browsers.', + url: 'https://github.com/damongolding/immich-kiosk', + }, + { + title: 'Immich Power Tools', + description: 'An unofficial immich client providing tools to speed up your workflows in Immich to organize your people and albums.', + url: 'https://github.com/varun-raj/immich-power-tools', + }, ]; function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element { From edf47dbbd008811ad0bf46e87286c1cadaf669bf Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 26 Aug 2024 11:26:23 -0500 Subject: [PATCH 167/723] feat(web): restore scroll position on navigating back to search page (#12042) * feat(web): restore scroll position on navigating back to search page * set 0 for scroll X * lint * simplify --- .../[[assetId=id]]/+page.svelte | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index cd4def1765..da85eb49c8 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -40,6 +40,7 @@ import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte'; import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation'; import { t } from 'svelte-i18n'; + import { afterUpdate, tick } from 'svelte'; const MAX_ASSET_COUNT = 5000; let { isViewing: showAssetViewer } = assetViewingStore; @@ -54,6 +55,8 @@ let searchResultAlbums: AlbumResponseDto[] = []; let searchResultAssets: AssetResponseDto[] = []; let isLoading = true; + let scrollY = 0; + let scrollYHistory = 0; const onEscape = () => { if ($showAssetViewer) { @@ -70,6 +73,13 @@ $preventRaceConditionSearchBar = false; }; + // save and restore scroll position + afterUpdate(() => { + if (scrollY) { + scrollYHistory = scrollY; + } + }); + afterNavigate(({ from }) => { // Prevent setting previousRoute to the current page. if (from?.url && from.route.id !== $page.route.id) { @@ -84,6 +94,14 @@ if (isAlbumsRoute(route)) { previousRoute = AppRoute.EXPLORE; } + + tick() + .then(() => { + window.scrollTo(0, scrollYHistory); + }) + .catch(() => { + // do nothing + }); }); let selectedAssets: Set = new Set(); @@ -203,7 +221,7 @@ } - +
    {#if isMultiSelectionMode} From f4371578f5b7be2ff06b3c1c6d651514d1d83a18 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 26 Aug 2024 12:20:50 -0500 Subject: [PATCH 168/723] fix(web): show supporter badge for account less than 14 days (#12058) --- .../side-bar/purchase-info.svelte | 78 +++++++++---------- 1 file changed, 38 insertions(+), 40 deletions(-) diff --git a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte index 6f40dc4923..a284c7efc1 100644 --- a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte +++ b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte @@ -76,48 +76,46 @@ (isOpen = false)} /> {/if} -{#if getAccountAge() > 14} - -{/if} + +
    + +
    +
    + + {/if} +
    {#if showMessage} From 6b6d2a6621ff68e342a3879af274f18b860ee7ce Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 26 Aug 2024 13:21:19 -0500 Subject: [PATCH 169/723] feat(mobile): preserve mobile album info on upload (#11965) * curating assets with albums to upload * sorting for background backup * background upload works * transform fields string array to javascript array * send json array * generate sql * refactor upload callback * remove albums info from upload payload * mechanism to create album on album selection * album creation * Sync to upload album * Remove unused service * unify name changes * Add mechanism to sync uploaded assets to albums * Put add to album operation after updating the UI state * clean up * background album sync * add to album in background context * remove add to album in callback * refactor * refactor * refactor * fix: make sure all selected albums are selected for building upload candidate * clean up * add manual sync button * lint * revert server changes * pr feedback * revert time filtering * const * sync album on manual upload * linting * pr feedback and proper time filtering * wording --- mobile/assets/i18n/en-US.json | 6 +- mobile/lib/entities/store.entity.dart | 2 + .../models/backup/backup_candidate.model.dart | 19 + .../lib/models/backup/backup_state.model.dart | 6 +- .../backup/success_upload_asset.model.dart | 42 +++ .../backup/backup_album_selection.page.dart | 70 ++-- .../lib/providers/album/album.provider.dart | 18 + .../lib/providers/backup/backup.provider.dart | 85 +++-- .../backup/manual_upload.provider.dart | 44 ++- mobile/lib/services/album.service.dart | 44 ++- mobile/lib/services/app_settings.service.dart | 1 + mobile/lib/services/asset.service.dart | 71 ++++ mobile/lib/services/background.service.dart | 64 +++- mobile/lib/services/backup.service.dart | 324 ++++++++++++------ .../lib/widgets/backup/album_info_card.dart | 14 +- .../widgets/backup/album_info_list_tile.dart | 11 +- .../backup_settings/backup_settings.dart | 34 ++ .../settings/settings_button_list_tile.dart | 5 +- .../settings/settings_switch_list_tile.dart | 30 +- 19 files changed, 657 insertions(+), 233 deletions(-) create mode 100644 mobile/lib/models/backup/backup_candidate.model.dart create mode 100644 mobile/lib/models/backup/success_upload_asset.model.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index decb0a72e1..c092b79bd1 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -573,5 +573,9 @@ "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", - "viewer_unstack": "Un-Stack" + "viewer_unstack": "Un-Stack", + "sync_albums": "Sync albums", + "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", + "sync": "Sync" } diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart index a84f980001..1dda2b9a12 100644 --- a/mobile/lib/entities/store.entity.dart +++ b/mobile/lib/entities/store.entity.dart @@ -234,6 +234,8 @@ enum StoreKey { primaryColor(128, type: String), dynamicTheme(129, type: bool), colorfulInterface(130, type: bool), + + syncAlbums(131, type: bool), ; const StoreKey( diff --git a/mobile/lib/models/backup/backup_candidate.model.dart b/mobile/lib/models/backup/backup_candidate.model.dart new file mode 100644 index 0000000000..5ef1516745 --- /dev/null +++ b/mobile/lib/models/backup/backup_candidate.model.dart @@ -0,0 +1,19 @@ +import 'package:photo_manager/photo_manager.dart'; + +class BackupCandidate { + BackupCandidate({required this.asset, required this.albumNames}); + + AssetEntity asset; + List albumNames; + + @override + int get hashCode => asset.hashCode; + + @override + bool operator ==(Object other) { + if (other is! BackupCandidate) { + return false; + } + return asset == other.asset; + } +} diff --git a/mobile/lib/models/backup/backup_state.model.dart b/mobile/lib/models/backup/backup_state.model.dart index bb693a5b75..d829f411fc 100644 --- a/mobile/lib/models/backup/backup_state.model.dart +++ b/mobile/lib/models/backup/backup_state.model.dart @@ -2,7 +2,7 @@ import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -41,7 +41,7 @@ class BackUpState { final Set excludedBackupAlbums; /// Assets that are not overlapping in selected backup albums and excluded backup albums - final Set allUniqueAssets; + final Set allUniqueAssets; /// All assets from the selected albums that have been backup final Set selectedAlbumsBackupAssetsIds; @@ -94,7 +94,7 @@ class BackUpState { List? availableAlbums, Set? selectedBackupAlbums, Set? excludedBackupAlbums, - Set? allUniqueAssets, + Set? allUniqueAssets, Set? selectedAlbumsBackupAssetsIds, CurrentUploadAsset? currentUploadAsset, }) { diff --git a/mobile/lib/models/backup/success_upload_asset.model.dart b/mobile/lib/models/backup/success_upload_asset.model.dart new file mode 100644 index 0000000000..045715e8cb --- /dev/null +++ b/mobile/lib/models/backup/success_upload_asset.model.dart @@ -0,0 +1,42 @@ +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; + +class SuccessUploadAsset { + final BackupCandidate candidate; + final String remoteAssetId; + final bool isDuplicate; + + SuccessUploadAsset({ + required this.candidate, + required this.remoteAssetId, + required this.isDuplicate, + }); + + SuccessUploadAsset copyWith({ + BackupCandidate? candidate, + String? remoteAssetId, + bool? isDuplicate, + }) { + return SuccessUploadAsset( + candidate: candidate ?? this.candidate, + remoteAssetId: remoteAssetId ?? this.remoteAssetId, + isDuplicate: isDuplicate ?? this.isDuplicate, + ); + } + + @override + String toString() => + 'SuccessUploadAsset(asset: $candidate, remoteAssetId: $remoteAssetId, isDuplicate: $isDuplicate)'; + + @override + bool operator ==(covariant SuccessUploadAsset other) { + if (identical(this, other)) return true; + + return other.candidate == candidate && + other.remoteAssetId == remoteAssetId && + other.isDuplicate == isDuplicate; + } + + @override + int get hashCode => + candidate.hashCode ^ remoteAssetId.hashCode ^ isDuplicate.hashCode; +} diff --git a/mobile/lib/pages/backup/backup_album_selection.page.dart b/mobile/lib/pages/backup/backup_album_selection.page.dart index 9f3e387755..8dccece325 100644 --- a/mobile/lib/pages/backup/backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/backup_album_selection.page.dart @@ -4,19 +4,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/backup/album_info_card.dart'; import 'package:immich_mobile/widgets/backup/album_info_list_tile.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; +import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; @RoutePage() class BackupAlbumSelectionPage extends HookConsumerWidget { const BackupAlbumSelectionPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - // final availableAlbums = ref.watch(backupProvider).availableAlbums; final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums; final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums; + final enableSyncUploadAlbum = + useAppSettingsState(AppSettingsEnum.syncAlbums); final isDarkTheme = context.isDarkTheme; final albums = ref.watch(backupProvider).availableAlbums; @@ -144,47 +149,14 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { }).toSet(); } - // buildSearchBar() { - // return Padding( - // padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0), - // child: TextFormField( - // onChanged: (searchValue) { - // // if (searchValue.isEmpty) { - // // albums = availableAlbums; - // // } else { - // // albums.value = availableAlbums - // // .where( - // // (album) => album.name - // // .toLowerCase() - // // .contains(searchValue.toLowerCase()), - // // ) - // // .toList(); - // // } - // }, - // decoration: InputDecoration( - // contentPadding: const EdgeInsets.symmetric( - // horizontal: 8.0, - // vertical: 8.0, - // ), - // hintText: "Search", - // hintStyle: TextStyle( - // color: isDarkTheme ? Colors.white : Colors.grey, - // fontSize: 14.0, - // ), - // prefixIcon: const Icon( - // Icons.search, - // color: Colors.grey, - // ), - // border: OutlineInputBorder( - // borderRadius: BorderRadius.circular(10), - // borderSide: BorderSide.none, - // ), - // filled: true, - // fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200], - // ), - // ), - // ); - // } + handleSyncAlbumToggle(bool isEnable) async { + if (isEnable) { + await ref.read(albumProvider.notifier).getAllAlbums(); + for (final album in selectedBackupAlbums) { + await ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } + } + } return Scaffold( appBar: AppBar( @@ -226,6 +198,20 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { ), ), + SettingsSwitchListTile( + valueNotifier: enableSyncUploadAlbum, + title: "sync_albums".tr(), + subtitle: "sync_upload_album_setting_subtitle".tr(), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + titleStyle: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + subtitleStyle: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.primary, + ), + onChanged: handleSyncAlbumToggle, + ), + ListTile( title: Text( "backup_album_selection_page_albums_device".tr( diff --git a/mobile/lib/providers/album/album.provider.dart b/mobile/lib/providers/album/album.provider.dart index 8251d5e66b..ed9dc07f5e 100644 --- a/mobile/lib/providers/album/album.provider.dart +++ b/mobile/lib/providers/album/album.provider.dart @@ -23,6 +23,7 @@ class AlbumNotifier extends StateNotifier> { }); _streamSub = query.watch().listen((data) => state = data); } + final AlbumService _albumService; late final StreamSubscription> _streamSub; @@ -41,6 +42,23 @@ class AlbumNotifier extends StateNotifier> { ) => _albumService.createAlbum(albumTitle, assets, []); + Future getAlbumByName(String albumName, {bool remoteOnly = false}) => + _albumService.getAlbumByName(albumName, remoteOnly); + + /// Create an album on the server with the same name as the selected album for backup + /// First this will check if the album already exists on the server with name + /// If it does not exist, it will create the album on the server + Future createSyncAlbum( + String albumName, + ) async { + final album = await getAlbumByName(albumName, remoteOnly: true); + if (album != null) { + return; + } + + await createAlbum(albumName, {}); + } + @override void dispose() { _streamSub.cancel(); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 58027e3b94..02f1f07904 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -2,13 +2,16 @@ import 'dart:io'; import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; +import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; @@ -290,8 +293,8 @@ class BackupNotifier extends StateNotifier { /// Future _updateBackupAssetCount() async { final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds(); - final Set assetsFromSelectedAlbums = {}; - final Set assetsFromExcludedAlbums = {}; + final Set assetsFromSelectedAlbums = {}; + final Set assetsFromExcludedAlbums = {}; for (final album in state.selectedBackupAlbums) { final assetCount = await album.albumEntity.assetCountAsync; @@ -304,7 +307,27 @@ class BackupNotifier extends StateNotifier { start: 0, end: assetCount, ); - assetsFromSelectedAlbums.addAll(assets); + + // Add album's name to the asset info + for (final asset in assets) { + List albumNames = [album.name]; + + final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull( + (a) => a.asset.id == asset.id, + ); + + if (existingAsset != null) { + albumNames.addAll(existingAsset.albumNames); + assetsFromSelectedAlbums.remove(existingAsset); + } + + assetsFromSelectedAlbums.add( + BackupCandidate( + asset: asset, + albumNames: albumNames, + ), + ); + } } for (final album in state.excludedBackupAlbums) { @@ -318,11 +341,17 @@ class BackupNotifier extends StateNotifier { start: 0, end: assetCount, ); - assetsFromExcludedAlbums.addAll(assets); + + for (final asset in assets) { + assetsFromExcludedAlbums.add( + BackupCandidate(asset: asset, albumNames: [album.name]), + ); + } } - final Set allUniqueAssets = + final Set allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums); + final allAssetsInDatabase = await _backupService.getDeviceBackupAsset(); if (allAssetsInDatabase == null) { @@ -331,14 +360,14 @@ class BackupNotifier extends StateNotifier { // Find asset that were backup from selected albums final Set selectedAlbumsBackupAssets = - Set.from(allUniqueAssets.map((e) => e.id)); + Set.from(allUniqueAssets.map((e) => e.asset.id)); selectedAlbumsBackupAssets .removeWhere((assetId) => !allAssetsInDatabase.contains(assetId)); // Remove duplicated asset from all unique assets allUniqueAssets.removeWhere( - (asset) => duplicatedAssetIds.contains(asset.id), + (candidate) => duplicatedAssetIds.contains(candidate.asset.id), ); if (allUniqueAssets.isEmpty) { @@ -433,10 +462,10 @@ class BackupNotifier extends StateNotifier { return; } - Set assetsWillBeBackup = Set.from(state.allUniqueAssets); + Set assetsWillBeBackup = Set.from(state.allUniqueAssets); // Remove item that has already been backed up for (final assetId in state.allAssetsInDatabase) { - assetsWillBeBackup.removeWhere((e) => e.id == assetId); + assetsWillBeBackup.removeWhere((e) => e.asset.id == assetId); } if (assetsWillBeBackup.isEmpty) { @@ -456,11 +485,11 @@ class BackupNotifier extends StateNotifier { await _backupService.backupAsset( assetsWillBeBackup, state.cancelToken, - pmProgressHandler, - _onAssetUploaded, - _onUploadProgress, - _onSetCurrentBackupAsset, - _onBackupError, + pmProgressHandler: pmProgressHandler, + onSuccess: _onAssetUploaded, + onProgress: _onUploadProgress, + onCurrentAsset: _onSetCurrentBackupAsset, + onError: _onBackupError, ); await notifyBackgroundServiceCanRun(); } else { @@ -497,34 +526,36 @@ class BackupNotifier extends StateNotifier { ); } - void _onAssetUploaded( - String deviceAssetId, - String deviceId, - bool isDuplicated, - ) { - if (isDuplicated) { + void _onAssetUploaded(SuccessUploadAsset result) async { + if (result.isDuplicate) { state = state.copyWith( allUniqueAssets: state.allUniqueAssets - .where((asset) => asset.id != deviceAssetId) + .where( + (candidate) => candidate.asset.id != result.candidate.asset.id, + ) .toSet(), ); } else { state = state.copyWith( selectedAlbumsBackupAssetsIds: { ...state.selectedAlbumsBackupAssetsIds, - deviceAssetId, + result.candidate.asset.id, }, - allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId], + allAssetsInDatabase: [ + ...state.allAssetsInDatabase, + result.candidate.asset.id, + ], ); } if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) { - final latestAssetBackup = - state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce( - (v, e) => e.isAfter(v) ? e : v, - ); + final latestAssetBackup = state.allUniqueAssets + .map((candidate) => candidate.asset.modifiedDateTime) + .reduce( + (v, e) => e.isAfter(v) ? e : v, + ); state = state.copyWith( selectedBackupAlbums: state.selectedBackupAlbums .map((e) => e.copyWith(lastBackup: latestAssetBackup)) diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index b446711226..a76b56fea7 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -6,6 +6,8 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; +import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -22,6 +24,7 @@ import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; +import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -31,6 +34,7 @@ final manualUploadProvider = return ManualUploadNotifier( ref.watch(localNotificationService), ref.watch(backupProvider.notifier), + ref.watch(backupServiceProvider), ref, ); }); @@ -39,11 +43,13 @@ class ManualUploadNotifier extends StateNotifier { final Logger _log = Logger("ManualUploadNotifier"); final LocalNotificationService _localNotificationService; final BackupNotifier _backupProvider; + final BackupService _backupService; final Ref ref; ManualUploadNotifier( this._localNotificationService, this._backupProvider, + this._backupService, this.ref, ) : super( ManualUploadState( @@ -115,11 +121,7 @@ class ManualUploadNotifier extends StateNotifier { } } - void _onAssetUploaded( - String deviceAssetId, - String deviceId, - bool isDuplicated, - ) { + void _onAssetUploaded(SuccessUploadAsset result) { state = state.copyWith(successfulUploads: state.successfulUploads + 1); _backupProvider.updateDiskInfo(); } @@ -209,9 +211,23 @@ class ManualUploadNotifier extends StateNotifier { ); } - Set allUploadAssets = allAssetsFromDevice.nonNulls.toSet(); + final selectedBackupAlbums = + _backupService.selectedAlbumsQuery().findAllSync(); + final excludedBackupAlbums = + _backupService.excludedAlbumsQuery().findAllSync(); - if (allUploadAssets.isEmpty) { + // Get candidates from selected albums and excluded albums + Set candidates = + await _backupService.buildUploadCandidates( + selectedBackupAlbums, + excludedBackupAlbums, + ); + + // Extrack candidate from allAssetsFromDevice.nonNulls + final uploadAssets = candidates + .where((e) => allAssetsFromDevice.nonNulls.contains(e.asset)); + + if (uploadAssets.isEmpty) { debugPrint("[_startUpload] No Assets to upload - Abort Process"); _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); return false; @@ -221,7 +237,7 @@ class ManualUploadNotifier extends StateNotifier { progressInPercentage: 0, progressInFileSize: "0 B / 0 B", progressInFileSpeed: 0, - totalAssetsToUpload: allUploadAssets.length, + totalAssetsToUpload: uploadAssets.length, successfulUploads: 0, currentAssetIndex: 0, currentUploadAsset: CurrentUploadAsset( @@ -250,13 +266,13 @@ class ManualUploadNotifier extends StateNotifier { final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final bool ok = await ref.read(backupServiceProvider).backupAsset( - allUploadAssets, + uploadAssets, state.cancelToken, - pmProgressHandler, - _onAssetUploaded, - _onProgress, - _onSetCurrentBackupAsset, - _onAssetUploadError, + pmProgressHandler: pmProgressHandler, + onSuccess: _onAssetUploaded, + onProgress: _onProgress, + onCurrentAsset: _onSetCurrentBackupAsset, + onError: _onAssetUploadError, ); // Close detailed notification diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index c2494680c7..ef56f9bf6c 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -28,7 +27,6 @@ final albumServiceProvider = Provider( ref.watch(userServiceProvider), ref.watch(syncServiceProvider), ref.watch(dbProvider), - ref.watch(backupServiceProvider), ), ); @@ -37,7 +35,6 @@ class AlbumService { final UserService _userService; final SyncService _syncService; final Isar _db; - final BackupService _backupService; final Logger _log = Logger('AlbumService'); Completer _localCompleter = Completer()..complete(false); Completer _remoteCompleter = Completer()..complete(false); @@ -47,9 +44,15 @@ class AlbumService { this._userService, this._syncService, this._db, - this._backupService, ); + QueryBuilder + selectedAlbumsQuery() => + _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); + QueryBuilder + excludedAlbumsQuery() => + _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); + /// Checks all selected device albums for changes of albums and their assets /// Updates the local database and returns `true` if there were any changes Future refreshDeviceAlbums() async { @@ -63,9 +66,9 @@ class AlbumService { bool changes = false; try { final List excludedIds = - await _backupService.excludedAlbumsQuery().idProperty().findAll(); + await excludedAlbumsQuery().idProperty().findAll(); final List selectedIds = - await _backupService.selectedAlbumsQuery().idProperty().findAll(); + await selectedAlbumsQuery().idProperty().findAll(); if (selectedIds.isEmpty) { final numLocal = await _db.albums.where().localIdIsNotNull().count(); if (numLocal > 0) { @@ -441,4 +444,33 @@ class AlbumService { return false; } } + + Future getAlbumByName(String name, bool remoteOnly) async { + return _db.albums + .filter() + .optional(remoteOnly, (q) => q.localIdIsNull()) + .nameEqualTo(name) + .sharedEqualTo(false) + .findFirst(); + } + + /// + /// Add the uploaded asset to the selected albums + /// + Future syncUploadAlbums( + List albumNames, + List assetIds, + ) async { + for (final albumName in albumNames) { + Album? album = await getAlbumByName(albumName, true); + album ??= await createAlbum(albumName, []); + + if (album != null && album.remoteId != null) { + await _apiService.albumsApi.addAssetsToAlbum( + album.remoteId!, + BulkIdsDto(ids: assetIds), + ); + } + } + } } diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index bd25403215..8f773e1bb3 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -76,6 +76,7 @@ enum AppSettingsEnum { false, ), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), + syncAlbums(StoreKey.syncAlbums, null, false), ; const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index d37133a63b..17508cba51 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -2,15 +2,20 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:isar/isar.dart'; @@ -23,6 +28,8 @@ final assetServiceProvider = Provider( ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), + ref.watch(backupServiceProvider), + ref.watch(albumServiceProvider), ref.watch(dbProvider), ), ); @@ -31,6 +38,8 @@ class AssetService { final ApiService _apiService; final SyncService _syncService; final UserService _userService; + final BackupService _backupService; + final AlbumService _albumService; final log = Logger('AssetService'); final Isar _db; @@ -38,6 +47,8 @@ class AssetService { this._apiService, this._syncService, this._userService, + this._backupService, + this._albumService, this._db, ); @@ -284,4 +295,64 @@ class AssetService { return Future.value(null); } } + + Future syncUploadedAssetToAlbums() async { + try { + final [selectedAlbums, excludedAlbums] = await Future.wait([ + _backupService.selectedAlbumsQuery().findAll(), + _backupService.excludedAlbumsQuery().findAll(), + ]); + + final candidates = await _backupService.buildUploadCandidates( + selectedAlbums, + excludedAlbums, + useTimeFilter: false, + ); + + final duplicates = await _apiService.assetsApi.checkExistingAssets( + CheckExistingAssetsDto( + deviceAssetIds: candidates.map((c) => c.asset.id).toList(), + deviceId: Store.get(StoreKey.deviceId), + ), + ); + + if (duplicates != null) { + candidates + .removeWhere((c) => !duplicates.existingIds.contains(c.asset.id)); + } + + await refreshRemoteAssets(); + final remoteAssets = await _db.assets + .where() + .localIdIsNotNull() + .filter() + .remoteIdIsNotNull() + .findAll(); + + /// Map + Map> assetToAlbums = {}; + + for (BackupCandidate candidate in candidates) { + final asset = remoteAssets.firstWhereOrNull( + (a) => a.localId == candidate.asset.id, + ); + + if (asset != null) { + for (final albumName in candidate.albumNames) { + assetToAlbums.putIfAbsent(albumName, () => []).add(asset.remoteId!); + } + } + } + + // Upload assets to albums + for (final entry in assetToAlbums.entries) { + final albumName = entry.key; + final assetIds = entry.value; + + await _albumService.syncUploadAlbums([albumName], assetIds); + } + } catch (error, stack) { + log.severe("Error while syncing uploaded asset to albums", error, stack); + } + } } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index ba8f5c01ed..b27ed34b94 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -10,6 +10,10 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/main.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; +import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -18,6 +22,9 @@ import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/partner.service.dart'; +import 'package:immich_mobile/services/sync.service.dart'; +import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; @@ -345,8 +352,16 @@ class BackgroundService { ApiService apiService = ApiService(); apiService.setAccessToken(Store.get(StoreKey.accessToken)); AppSettingsService settingService = AppSettingsService(); - BackupService backupService = BackupService(apiService, db, settingService); AppSettingsService settingsService = AppSettingsService(); + PartnerService partnerService = PartnerService(apiService, db); + HashService hashService = HashService(db, this); + SyncService syncSerive = SyncService(db, hashService); + UserService userService = + UserService(apiService, db, syncSerive, partnerService); + AlbumService albumService = + AlbumService(apiService, userService, syncSerive, db); + BackupService backupService = + BackupService(apiService, db, settingService, albumService); final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); @@ -416,7 +431,7 @@ class BackgroundService { return false; } - List toUpload = await backupService.buildUploadCandidates( + Set toUpload = await backupService.buildUploadCandidates( selectedAlbums, excludedAlbums, ); @@ -460,29 +475,47 @@ class BackgroundService { final bool ok = await backupService.backupAsset( toUpload, _cancellationToken!, - pmProgressHandler, - notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {}, - notifySingleProgress ? _onProgress : (sent, total) {}, - notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {}, - _onBackupError, - sortAssets: true, + pmProgressHandler: pmProgressHandler, + onSuccess: (result) => _onAssetUploaded( + result: result, + shouldNotify: notifyTotalProgress, + ), + onProgress: (bytes, totalBytes) => + _onProgress(bytes, totalBytes, shouldNotify: notifySingleProgress), + onCurrentAsset: (asset) => + _onSetCurrentBackupAsset(asset, shouldNotify: notifySingleProgress), + onError: _onBackupError, + isBackground: true, ); + if (!ok && !_cancellationToken!.isCancelled) { _showErrorNotification( title: "backup_background_service_error_title".tr(), content: "backup_background_service_backup_failed_message".tr(), ); } + return ok; } - void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) { + void _onAssetUploaded({ + required SuccessUploadAsset result, + bool shouldNotify = false, + }) async { + if (!shouldNotify) { + return; + } + _uploadedAssetsCount++; _throttledNotifiy(); } - void _onProgress(int sent, int total) { - _throttledDetailNotify(progress: sent, total: total); + void _onProgress(int bytes, int totalBytes, {bool shouldNotify = false}) { + if (!shouldNotify) { + return; + } + + _throttledDetailNotify(progress: bytes, total: totalBytes); } void _updateDetailProgress(String? title, int progress, int total) { @@ -522,7 +555,14 @@ class BackgroundService { ); } - void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) { + void _onSetCurrentBackupAsset( + CurrentUploadAsset currentUploadAsset, { + bool shouldNotify = false, + }) { + if (!shouldNotify) { + return; + } + _throttledDetailNotify.title = "backup_background_service_current_upload_notification" .tr(args: [currentUploadAsset.fileName]); diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 64d683dc2a..12edd14d60 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -9,11 +9,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; +import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:isar/isar.dart'; @@ -28,6 +31,7 @@ final backupServiceProvider = Provider( ref.watch(apiServiceProvider), ref.watch(dbProvider), ref.watch(appSettingsServiceProvider), + ref.watch(albumServiceProvider), ), ); @@ -37,8 +41,14 @@ class BackupService { final Isar _db; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; + final AlbumService _albumService; - BackupService(this._apiService, this._db, this._appSetting); + BackupService( + this._apiService, + this._db, + this._appSetting, + this._albumService, + ); Future?> getDeviceBackupAsset() async { final String deviceId = Store.get(StoreKey.deviceId); @@ -70,10 +80,12 @@ class BackupService { _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); /// Returns all assets newer than the last successful backup per album - Future> buildUploadCandidates( + /// if `useTimeFilter` is set to true, all assets will be returned + Future> buildUploadCandidates( List selectedBackupAlbums, - List excludedBackupAlbums, - ) async { + List excludedBackupAlbums, { + bool useTimeFilter = true, + }) async { final filter = FilterOptionGroup( containsPathModified: true, orders: [const OrderOption(type: OrderOptionType.updateDate)], @@ -82,105 +94,156 @@ class BackupService { videoOption: const FilterOption(needTitle: true), ); final now = DateTime.now(); + final List selectedAlbums = - await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now); + await _loadAlbumsWithTimeFilter( + selectedBackupAlbums, + filter, + now, + useTimeFilter: useTimeFilter, + ); + if (selectedAlbums.every((e) => e == null)) { - return []; - } - final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll); - if (allIdx != -1) { - final List excludedAlbums = - await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now); - final List toAdd = await _fetchAssetsAndUpdateLastBackup( - selectedAlbums.slice(allIdx, allIdx + 1), - selectedBackupAlbums.slice(allIdx, allIdx + 1), - now, - ); - final List toRemove = await _fetchAssetsAndUpdateLastBackup( - excludedAlbums, - excludedBackupAlbums, - now, - ); - return toAdd.toSet().difference(toRemove.toSet()).toList(); - } else { - return await _fetchAssetsAndUpdateLastBackup( - selectedAlbums, - selectedBackupAlbums, - now, - ); + return {}; } + + final List excludedAlbums = + await _loadAlbumsWithTimeFilter( + excludedBackupAlbums, + filter, + now, + useTimeFilter: useTimeFilter, + ); + + final Set toAdd = await _fetchAssetsAndUpdateLastBackup( + selectedAlbums, + selectedBackupAlbums, + now, + useTimeFilter: useTimeFilter, + ); + + final Set toRemove = await _fetchAssetsAndUpdateLastBackup( + excludedAlbums, + excludedBackupAlbums, + now, + useTimeFilter: useTimeFilter, + ); + + return toAdd.difference(toRemove); } Future> _loadAlbumsWithTimeFilter( List albums, FilterOptionGroup filter, - DateTime now, - ) async { + DateTime now, { + bool useTimeFilter = true, + }) async { List result = []; - for (BackupAlbum a in albums) { + for (BackupAlbum backupAlbum in albums) { try { + final optionGroup = useTimeFilter + ? filter.copyWith( + updateTimeCond: DateTimeCond( + // subtract 2 seconds to prevent missing assets due to rounding issues + min: backupAlbum.lastBackup + .subtract(const Duration(seconds: 2)), + max: now, + ), + ) + : filter; + final AssetPathEntity album = await AssetPathEntity.obtainPathFromProperties( - id: a.id, - optionGroup: filter.copyWith( - updateTimeCond: DateTimeCond( - // subtract 2 seconds to prevent missing assets due to rounding issues - min: a.lastBackup.subtract(const Duration(seconds: 2)), - max: now, - ), - ), + id: backupAlbum.id, + optionGroup: optionGroup, maxDateTimeToNow: false, ); + result.add(album); } on StateError { // either there are no assets matching the filter criteria OR the album no longer exists } } + return result; } - Future> _fetchAssetsAndUpdateLastBackup( - List albums, + Future> _fetchAssetsAndUpdateLastBackup( + List localAlbums, List backupAlbums, - DateTime now, - ) async { - List result = []; - for (int i = 0; i < albums.length; i++) { - final AssetPathEntity? a = albums[i]; - if (a != null && - a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) { - result.addAll( - await a.getAssetListRange(start: 0, end: await a.assetCountAsync), - ); - backupAlbums[i].lastBackup = now; + DateTime now, { + bool useTimeFilter = true, + }) async { + Set candidate = {}; + + for (int i = 0; i < localAlbums.length; i++) { + final localAlbum = localAlbums[i]; + if (localAlbum == null) { + continue; } + + if (useTimeFilter && + localAlbum.lastModified?.isBefore(backupAlbums[i].lastBackup) == + true) { + continue; + } + + final assets = await localAlbum.getAssetListRange( + start: 0, + end: await localAlbum.assetCountAsync, + ); + + // Add album's name to the asset info + for (final asset in assets) { + List albumNames = [localAlbum.name]; + + final existingAsset = candidate.firstWhereOrNull( + (a) => a.asset.id == asset.id, + ); + + if (existingAsset != null) { + albumNames.addAll(existingAsset.albumNames); + candidate.remove(existingAsset); + } + + candidate.add( + BackupCandidate( + asset: asset, + albumNames: albumNames, + ), + ); + } + + backupAlbums[i].lastBackup = now; } - return result; + + return candidate; } /// Returns a new list of assets not yet uploaded - Future> removeAlreadyUploadedAssets( - List candidates, + Future> removeAlreadyUploadedAssets( + Set candidates, ) async { if (candidates.isEmpty) { return candidates; } + final Set duplicatedAssetIds = await getDuplicatedAssetIds(); - candidates = duplicatedAssetIds.isEmpty - ? candidates - : candidates - .whereNot((asset) => duplicatedAssetIds.contains(asset.id)) - .toList(); + candidates.removeWhere( + (candidate) => duplicatedAssetIds.contains(candidate.asset.id), + ); + if (candidates.isEmpty) { return candidates; } + final Set existing = {}; try { final String deviceId = Store.get(StoreKey.deviceId); final CheckExistingAssetsResponseDto? duplicates = await _apiService.assetsApi.checkExistingAssets( CheckExistingAssetsDto( - deviceAssetIds: candidates.map((e) => e.id).toList(), + deviceAssetIds: candidates.map((c) => c.asset.id).toList(), deviceId: deviceId, ), ); @@ -194,55 +257,75 @@ class BackupService { existing.addAll(allAssetsInDatabase); } } - return existing.isEmpty - ? candidates - : candidates.whereNot((e) => existing.contains(e.id)).toList(); + + if (existing.isNotEmpty) { + candidates.removeWhere((c) => existing.contains(c.asset.id)); + } + + return candidates; } - Future backupAsset( - Iterable assetList, - http.CancellationToken cancelToken, - PMProgressHandler? pmProgressHandler, - Function(String, String, bool) uploadSuccessCb, - Function(int, int) uploadProgressCb, - Function(CurrentUploadAsset) setCurrentUploadAssetCb, - Function(ErrorUploadAsset) errorCb, { - bool sortAssets = false, - }) async { - final bool isIgnoreIcloudAssets = - _appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets); - + Future _checkPermissions() async { if (Platform.isAndroid && !(await pm.Permission.accessMediaLocation.status).isGranted) { // double check that permission is granted here, to guard against // uploading corrupt assets without EXIF information _log.warning("Media location permission is not granted. " "Cannot access original assets for backup."); + return false; } - final String deviceId = Store.get(StoreKey.deviceId); - final String savedEndpoint = Store.get(StoreKey.serverEndpoint); - bool anyErrors = false; - final List duplicatedAssetIds = []; // DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS if (Platform.isIOS) { await PhotoManager.requestPermissionExtend(); } - List assetsToUpload = sortAssets - // Upload images before video assets - // these are further sorted by using their creation date - ? assetList.sorted( - (a, b) { - final cmp = a.typeInt - b.typeInt; - if (cmp != 0) return cmp; - return a.createDateTime.compareTo(b.createDateTime); - }, - ) - : assetList.toList(); + return true; + } - for (var entity in assetsToUpload) { + /// Upload images before video assets for background tasks + /// these are further sorted by using their creation date + List _sortPhotosFirst(List candidates) { + return candidates.sorted( + (a, b) { + final cmp = a.asset.typeInt - b.asset.typeInt; + if (cmp != 0) return cmp; + return a.asset.createDateTime.compareTo(b.asset.createDateTime); + }, + ); + } + + Future backupAsset( + Iterable assets, + http.CancellationToken cancelToken, { + bool isBackground = false, + PMProgressHandler? pmProgressHandler, + required void Function(SuccessUploadAsset result) onSuccess, + required void Function(int bytes, int totalBytes) onProgress, + required void Function(CurrentUploadAsset asset) onCurrentAsset, + required void Function(ErrorUploadAsset error) onError, + }) async { + final bool isIgnoreIcloudAssets = + _appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets); + final shouldSyncAlbums = _appSetting.getSetting(AppSettingsEnum.syncAlbums); + final String deviceId = Store.get(StoreKey.deviceId); + final String savedEndpoint = Store.get(StoreKey.serverEndpoint); + final List duplicatedAssetIds = []; + bool anyErrors = false; + + final hasPermission = await _checkPermissions(); + if (!hasPermission) { + return false; + } + + List candidates = assets.toList(); + if (isBackground) { + candidates = _sortPhotosFirst(candidates); + } + + for (final candidate in candidates) { + final AssetEntity entity = candidate.asset; File? file; File? livePhotoFile; @@ -257,7 +340,7 @@ class BackupService { continue; } - setCurrentUploadAssetCb( + onCurrentAsset( CurrentUploadAsset( id: entity.id, fileCreatedAt: entity.createDateTime.year == 1970 @@ -299,23 +382,22 @@ class BackupService { } } - var fileStream = file.openRead(); - var assetRawUploadData = http.MultipartFile( + final fileStream = file.openRead(); + final assetRawUploadData = http.MultipartFile( "assetData", fileStream, file.lengthSync(), filename: originalFileName, ); - var baseRequest = MultipartRequest( + final baseRequest = MultipartRequest( 'POST', Uri.parse('$savedEndpoint/assets'), - onProgress: ((bytes, totalBytes) => - uploadProgressCb(bytes, totalBytes)), + onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)), ); + baseRequest.headers.addAll(ApiService.getRequestHeaders()); baseRequest.headers["Transfer-Encoding"] = "chunked"; - baseRequest.fields['deviceAssetId'] = entity.id; baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['fileCreatedAt'] = @@ -324,12 +406,9 @@ class BackupService { entity.modifiedDateTime.toUtc().toIso8601String(); baseRequest.fields['isFavorite'] = entity.isFavorite.toString(); baseRequest.fields['duration'] = entity.videoDuration.toString(); - baseRequest.files.add(assetRawUploadData); - var fileSize = file.lengthSync(); - - setCurrentUploadAssetCb( + onCurrentAsset( CurrentUploadAsset( id: entity.id, fileCreatedAt: entity.createDateTime.year == 1970 @@ -337,7 +416,7 @@ class BackupService { : entity.createDateTime, fileName: originalFileName, fileType: _getAssetType(entity.type), - fileSize: fileSize, + fileSize: file.lengthSync(), iCloudAsset: false, ), ); @@ -356,22 +435,23 @@ class BackupService { baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; } - var response = await httpClient.send( + final response = await httpClient.send( baseRequest, cancellationToken: cancelToken, ); - var responseBody = jsonDecode(await response.stream.bytesToString()); + final responseBody = + jsonDecode(await response.stream.bytesToString()); if (![200, 201].contains(response.statusCode)) { - var error = responseBody; - var errorMessage = error['message'] ?? error['error']; + final error = responseBody; + final errorMessage = error['message'] ?? error['error']; debugPrint( "Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}", ); - errorCb( + onError( ErrorUploadAsset( asset: entity, id: entity.id, @@ -386,23 +466,37 @@ class BackupService { anyErrors = true; break; } + continue; } - var isDuplicate = false; + bool isDuplicate = false; if (response.statusCode == 200) { isDuplicate = true; duplicatedAssetIds.add(entity.id); } - uploadSuccessCb(entity.id, deviceId, isDuplicate); + onSuccess( + SuccessUploadAsset( + candidate: candidate, + remoteAssetId: responseBody['id'] as String, + isDuplicate: isDuplicate, + ), + ); + + if (shouldSyncAlbums && !isDuplicate) { + await _albumService.syncUploadAlbums( + candidate.albumNames, + [responseBody['id'] as String], + ); + } } } on http.CancelledException { debugPrint("Backup was cancelled by the user"); anyErrors = true; break; - } catch (e) { - debugPrint("ERROR backupAsset: ${e.toString()}"); + } catch (error, stackTrace) { + debugPrint("Error backup asset: ${error.toString()}: $stackTrace"); anyErrors = true; continue; } finally { @@ -416,9 +510,11 @@ class BackupService { } } } + if (duplicatedAssetIds.isNotEmpty) { await _saveDuplicatedAssetIds(duplicatedAssetIds); } + return !anyErrors; } diff --git a/mobile/lib/widgets/backup/album_info_card.dart b/mobile/lib/widgets/backup/album_info_card.dart index e9349bd69e..0c9cd2d89d 100644 --- a/mobile/lib/widgets/backup/album_info_card.dart +++ b/mobile/lib/widgets/backup/album_info_card.dart @@ -5,15 +5,21 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class AlbumInfoCard extends HookConsumerWidget { final AvailableAlbum album; - const AlbumInfoCard({super.key, required this.album}); + const AlbumInfoCard({ + super.key, + required this.album, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -21,6 +27,9 @@ class AlbumInfoCard extends HookConsumerWidget { ref.watch(backupProvider).selectedBackupAlbums.contains(album); final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(album); + final syncAlbum = ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.syncAlbums); final isDarkTheme = context.isDarkTheme; @@ -85,6 +94,9 @@ class AlbumInfoCard extends HookConsumerWidget { ref.read(backupProvider.notifier).removeAlbumForBackup(album); } else { ref.read(backupProvider.notifier).addAlbumForBackup(album); + if (syncAlbum) { + ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } } }, onDoubleTap: () { diff --git a/mobile/lib/widgets/backup/album_info_list_tile.dart b/mobile/lib/widgets/backup/album_info_list_tile.dart index 7cdc595c7f..d326bad3e0 100644 --- a/mobile/lib/widgets/backup/album_info_list_tile.dart +++ b/mobile/lib/widgets/backup/album_info_list_tile.dart @@ -5,9 +5,12 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class AlbumInfoListTile extends HookConsumerWidget { @@ -21,7 +24,10 @@ class AlbumInfoListTile extends HookConsumerWidget { ref.watch(backupProvider).selectedBackupAlbums.contains(album); final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(album); - var assetCount = useState(0); + final assetCount = useState(0); + final syncAlbum = ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.syncAlbums); useEffect( () { @@ -98,6 +104,9 @@ class AlbumInfoListTile extends HookConsumerWidget { ref.read(backupProvider.notifier).removeAlbumForBackup(album); } else { ref.read(backupProvider.notifier).addAlbumForBackup(album); + if (syncAlbum) { + ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } } }, leading: buildIcon(), diff --git a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart index 25bcf2d06e..c093e8f1e3 100644 --- a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart @@ -1,9 +1,12 @@ import 'dart:io'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/backup/backup_verification.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/background_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/foreground_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart'; @@ -23,7 +26,21 @@ class BackupSettings extends HookConsumerWidget { useAppSettingsState(AppSettingsEnum.ignoreIcloudAssets); final isAdvancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); + final albumSync = useAppSettingsState(AppSettingsEnum.syncAlbums); final isCorruptCheckInProgress = ref.watch(backupVerificationProvider); + final isAlbumSyncInProgress = useState(false); + + syncAlbums() async { + isAlbumSyncInProgress.value = true; + try { + await ref.read(assetServiceProvider).syncUploadedAssetToAlbums(); + } catch (_) { + } finally { + Future.delayed(const Duration(seconds: 1), () { + isAlbumSyncInProgress.value = false; + }); + } + } final backupSettings = [ const ForegroundBackupSettings(), @@ -58,6 +75,23 @@ class BackupSettings extends HookConsumerWidget { .performBackupCheck(context) : null, ), + if (albumSync.value) + SettingsButtonListTile( + icon: Icons.photo_album_outlined, + title: 'sync_albums'.tr(), + subtitle: Text( + "sync_albums_manual_subtitle".tr(), + ), + buttonText: 'sync_albums'.tr(), + child: isAlbumSyncInProgress.value + ? const CircularProgressIndicator.adaptive( + strokeWidth: 2, + ) + : ElevatedButton( + onPressed: syncAlbums, + child: Text('sync'.tr()), + ), + ), ]; return SettingsSubPageScaffold( diff --git a/mobile/lib/widgets/settings/settings_button_list_tile.dart b/mobile/lib/widgets/settings/settings_button_list_tile.dart index 196e3d170f..c8bd8e4b58 100644 --- a/mobile/lib/widgets/settings/settings_button_list_tile.dart +++ b/mobile/lib/widgets/settings/settings_button_list_tile.dart @@ -9,6 +9,7 @@ class SettingsButtonListTile extends StatelessWidget { final Widget? subtitle; final String? subtileText; final String buttonText; + final Widget? child; final void Function()? onButtonTap; const SettingsButtonListTile({ @@ -18,6 +19,7 @@ class SettingsButtonListTile extends StatelessWidget { this.subtileText, this.subtitle, required this.buttonText, + this.child, this.onButtonTap, super.key, }); @@ -48,7 +50,8 @@ class SettingsButtonListTile extends StatelessWidget { ), if (subtitle != null) subtitle!, const SizedBox(height: 6), - ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)), + child ?? + ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)), ], ), ); diff --git a/mobile/lib/widgets/settings/settings_switch_list_tile.dart b/mobile/lib/widgets/settings/settings_switch_list_tile.dart index 78f1738266..8aa4ec0a60 100644 --- a/mobile/lib/widgets/settings/settings_switch_list_tile.dart +++ b/mobile/lib/widgets/settings/settings_switch_list_tile.dart @@ -9,6 +9,9 @@ class SettingsSwitchListTile extends StatelessWidget { final String? subtitle; final IconData? icon; final Function(bool)? onChanged; + final EdgeInsets? contentPadding; + final TextStyle? titleStyle; + final TextStyle? subtitleStyle; const SettingsSwitchListTile({ required this.valueNotifier, @@ -17,6 +20,9 @@ class SettingsSwitchListTile extends StatelessWidget { this.icon, this.enabled = true, this.onChanged, + this.contentPadding = const EdgeInsets.symmetric(horizontal: 20), + this.titleStyle, + this.subtitleStyle, super.key, }); @@ -30,7 +36,7 @@ class SettingsSwitchListTile extends StatelessWidget { } return SwitchListTile.adaptive( - contentPadding: const EdgeInsets.symmetric(horizontal: 20), + contentPadding: contentPadding, selectedTileColor: enabled ? null : context.themeData.disabledColor, value: valueNotifier.value, onChanged: onSwitchChanged, @@ -45,20 +51,22 @@ class SettingsSwitchListTile extends StatelessWidget { : null, title: Text( title, - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - color: enabled ? null : context.themeData.disabledColor, - height: 1.5, - ), + style: titleStyle ?? + context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: enabled ? null : context.themeData.disabledColor, + height: 1.5, + ), ), subtitle: subtitle != null ? Text( subtitle!, - style: context.textTheme.bodyMedium?.copyWith( - color: enabled - ? context.colorScheme.onSurfaceSecondary - : context.themeData.disabledColor, - ), + style: subtitleStyle ?? + context.textTheme.bodyMedium?.copyWith( + color: enabled + ? context.colorScheme.onSurfaceSecondary + : context.themeData.disabledColor, + ), ) : null, ); From 9894b9513bcc27615d2656a0afcad6c65028bef3 Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Mon, 26 Aug 2024 21:05:23 -0400 Subject: [PATCH 170/723] fix(web): shared link expiration date accessibility (#12060) - use native select - shows focus, automatically has keyboard navigation, accessible for screen readers - remove DropdownButton component - fix dropdown styling in Safari --- .../create-shared-link-modal.svelte | 47 ++++++------ .../shared-components/dropdown-button.svelte | 74 ------------------- .../settings/setting-select.svelte | 38 ++++++---- 3 files changed, 46 insertions(+), 113 deletions(-) delete mode 100644 web/src/lib/components/shared-components/dropdown-button.svelte diff --git a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte index 97c3aaf17e..c50a07ad37 100644 --- a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte +++ b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte @@ -8,7 +8,6 @@ import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk'; import { mdiContentCopy, mdiLink } from '@mdi/js'; import { createEventDispatcher } from 'svelte'; - import DropdownButton, { type DropDownOption } from '../dropdown-button.svelte'; import { NotificationType, notificationController } from '../notification/notification'; import SettingInputField, { SettingInputFieldType } from '../settings/setting-input-field.svelte'; import SettingSwitch from '../settings/setting-switch.svelte'; @@ -16,6 +15,7 @@ import { t } from 'svelte-i18n'; import { locale } from '$lib/stores/preferences.store'; import { DateTime, Duration } from 'luxon'; + import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; export let onClose: () => void; export let albumId: string | undefined = undefined; @@ -27,7 +27,7 @@ let allowDownload = true; let allowUpload = false; let showMetadata = true; - let expirationOption: DropDownOption | undefined; + let expirationOption: number = 0; let password = ''; let shouldChangeExpirationTime = false; let enablePassword = false; @@ -48,14 +48,12 @@ ]; $: relativeTime = new Intl.RelativeTimeFormat($locale); - $: expiredDateOption = [ - { label: $t('never'), value: 0 }, - ...expirationOptions.map( - ([value, unit]): DropDownOption => ({ - label: relativeTime.format(value, unit), - value: Duration.fromObject({ [unit]: value }).toMillis(), - }), - ), + $: expiredDateOptions = [ + { text: $t('never'), value: 0 }, + ...expirationOptions.map(([value, unit]) => ({ + text: relativeTime.format(value, unit), + value: Duration.fromObject({ [unit]: value }).toMillis(), + })), ]; $: shareType = albumId ? SharedLinkType.Album : SharedLinkType.Individual; @@ -82,8 +80,7 @@ } const handleCreateSharedLink = async () => { - const expirationDate = - expirationOption && expirationOption.value > 0 ? DateTime.now().plus(expirationOption.value).toISO() : undefined; + const expirationDate = expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : undefined; try { const data = await createSharedLink({ @@ -112,8 +109,7 @@ } try { - const expirationDate = - expirationOption && expirationOption.value > 0 ? DateTime.now().plus(expirationOption.value).toISO() : null; + const expirationDate = expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : null; await updateSharedLink({ id: editingLink.id, @@ -212,19 +208,18 @@
    -
    - {#if editingLink} -

    - -

    - {:else} -

    {$t('expire_after')}

    - {/if} - - + +
    + {/if} +
    +
    diff --git a/web/src/lib/components/shared-components/dropdown-button.svelte b/web/src/lib/components/shared-components/dropdown-button.svelte deleted file mode 100644 index 450b3d5ce6..0000000000 --- a/web/src/lib/components/shared-components/dropdown-button.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - - - -
    - - - {#if isOpen} -
    - {#each options as option} - - {/each} -
    - {/if} -
    - - diff --git a/web/src/lib/components/shared-components/settings/setting-select.svelte b/web/src/lib/components/shared-components/settings/setting-select.svelte index b4efd90056..c5b9e2c02e 100644 --- a/web/src/lib/components/shared-components/settings/setting-select.svelte +++ b/web/src/lib/components/shared-components/settings/setting-select.svelte @@ -3,6 +3,8 @@ import { fly } from 'svelte/transition'; import { createEventDispatcher } from 'svelte'; import { t } from 'svelte-i18n'; + import Icon from '$lib/components/elements/icon.svelte'; + import { mdiChevronDown } from '@mdi/js'; export let value: string | number; export let options: { value: string | number; text: string }[]; @@ -46,17 +48,27 @@

    {/if} - +
    + + +
    From b051b29eca418bb867edba58af13df3bccb8230c Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 27 Aug 2024 03:48:39 +0200 Subject: [PATCH 171/723] feat(server): Storage template support album condition (#12000) feat(server): Storage template support album condition ([Request](https://github.com/immich-app/immich/discussions/11999)) --- .../services/storage-template.service.spec.ts | 44 ++++++++++++++++++- .../src/services/storage-template.service.ts | 4 +- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index c1e0410a3d..92d11eaa12 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -15,6 +15,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { StorageTemplateService } from 'src/services/storage-template.service'; +import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; @@ -83,7 +84,7 @@ describe(StorageTemplateService.name, () => { newConfig: { storageTemplate: { template: - '{{y}}{{M}}{{W}}{{d}}{{h}}{{m}}{{s}}{{filename}}{{ext}}{{filetype}}{{filetypefull}}{{assetId}}{{album}}', + '{{y}}{{M}}{{W}}{{d}}{{h}}{{m}}{{s}}{{filename}}{{ext}}{{filetype}}{{filetypefull}}{{assetId}}{{#if album}}{{album}}{{else}}other{{/if}}', }, } as SystemConfig, oldConfig: {} as SystemConfig, @@ -163,6 +164,47 @@ describe(StorageTemplateService.name, () => { originalPath: newMotionPicturePath, }); }); + it('Should use handlebar if condition for album', async () => { + const asset = assetStub.image; + const user = userStub.user1; + const album = albumStub.oneAsset; + const config = structuredClone(defaults); + config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; + SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + + userMock.get.mockResolvedValue(user); + assetMock.getByIds.mockResolvedValueOnce([asset]); + albumMock.getByAssetId.mockResolvedValueOnce([album]); + + expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); + + expect(moveMock.create).toHaveBeenCalledWith({ + entityId: asset.id, + newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`, + oldPath: asset.originalPath, + pathType: AssetPathType.ORIGINAL, + }); + }); + it('Should use handlebar else condition for album', async () => { + const asset = assetStub.image; + const user = userStub.user1; + const config = structuredClone(defaults); + config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; + SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + + userMock.get.mockResolvedValue(user); + assetMock.getByIds.mockResolvedValueOnce([asset]); + + expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); + + const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0'); + expect(moveMock.create).toHaveBeenCalledWith({ + entityId: asset.id, + newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`, + oldPath: asset.originalPath, + pathType: AssetPathType.ORIGINAL, + }); + }); it('should migrate previously failed move from original path when it still exists', async () => { userMock.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 0ee5bdd3b5..4855d602d7 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -308,7 +308,7 @@ export class StorageTemplateService { filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO', assetId: asset.id, //just throw into the root if it doesn't belong to an album - album: (albumName && sanitize(albumName.replaceAll(/\.+/g, ''))) || '.', + album: (albumName && sanitize(albumName.replaceAll(/\.+/g, ''))) || '', }; const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -329,6 +329,6 @@ export class StorageTemplateService { substitutions[token] = dt.toFormat(token); } - return template(substitutions); + return template(substitutions).replaceAll(/\/{2,}/gm, '/'); } } From f70dcaa6cc460cf7f76df97198666eec536fc2eb Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Tue, 27 Aug 2024 11:54:53 -0400 Subject: [PATCH 172/723] docs: mTLS/self signed FAQ entry (#12074) mTLS/self signed --- docs/docs/FAQ.mdx | 5 +++++ docs/src/components/community-projects.tsx | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index 117ca74c03..a5d9b6e3d3 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -52,6 +52,11 @@ On iOS (iPhone and iPad), the operating system determines if a particular app ca - Disable Background App Refresh for apps that don't need background tasks to run. This will reduce the competition for background task invocation for Immich. - Use the Immich app more often. +### Why are features not working with a self-signed cert or mTLS? + +Due to limitations in the upstream app/video library, using a self-signed TLS certificate or mutual TLS may break video playback or asset upload (both foreground and/or background). +We recommend using a real SSL certificate from a free provider, for example [Let's Encrypt](https://letsencrypt.org/). + --- ## Assets diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx index 0f9b2b2413..0f30bac60f 100644 --- a/docs/src/components/community-projects.tsx +++ b/docs/src/components/community-projects.tsx @@ -75,7 +75,8 @@ const projects: CommunityProjectProps[] = [ }, { title: 'Immich Power Tools', - description: 'An unofficial immich client providing tools to speed up your workflows in Immich to organize your people and albums.', + description: + 'An unofficial immich client providing tools to speed up your workflows in Immich to organize your people and albums.', url: 'https://github.com/varun-raj/immich-power-tools', }, ]; From 3e970bc2d333d470edec42c5af7e12a895de6aed Mon Sep 17 00:00:00 2001 From: Yuvraj P Date: Tue, 27 Aug 2024 12:06:16 -0400 Subject: [PATCH 173/723] fix(mobile): Changes in the UI for the image editor pages (#12018) * Ui enchancements and fixes * Reruning the github review thing * conflicts fix, apparently * conflicts fix, apparently * Fixed edit.page.dart * Fixed crop page; localization etc * Updated es-US.json; for Localization * Formatting * Changing the es-US.json back * Update en-US.json * localization --------- Co-authored-by: Alex --- mobile/assets/i18n/en-US.json | 5 ++ mobile/assets/i18n/es-US.json | 2 +- mobile/lib/pages/editing/crop.page.dart | 18 +++-- mobile/lib/pages/editing/edit.page.dart | 65 +++++++++++++------ .../lib/utils/hooks/crop_controller_hook.dart | 2 +- 5 files changed, 64 insertions(+), 28 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index c092b79bd1..d8aa678e33 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -574,6 +574,11 @@ "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", "viewer_unstack": "Un-Stack", + "edit_image_title": "Edit", + "crop": "Crop", + "save_to_gallery": "Save to gallery", + "error_saving_image": "Error: {}", + "image_saved_successfully": "Image saved", "sync_albums": "Sync albums", "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", diff --git a/mobile/assets/i18n/es-US.json b/mobile/assets/i18n/es-US.json index 9a17fba787..394139767e 100644 --- a/mobile/assets/i18n/es-US.json +++ b/mobile/assets/i18n/es-US.json @@ -575,4 +575,4 @@ "viewer_remove_from_stack": "Eliminar de la pila", "viewer_stack_use_as_main_asset": "Utilizar como recurso principal", "viewer_unstack": "Desapilar" -} \ No newline at end of file +} diff --git a/mobile/lib/pages/editing/crop.page.dart b/mobile/lib/pages/editing/crop.page.dart index a3ac34dfa0..729b59ded5 100644 --- a/mobile/lib/pages/editing/crop.page.dart +++ b/mobile/lib/pages/editing/crop.page.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:crop_image/crop_image.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'edit.page.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:auto_route/auto_route.dart'; /// A widget for cropping an image. @@ -25,13 +27,14 @@ class CropImagePage extends HookWidget { return Scaffold( appBar: AppBar( - backgroundColor: Theme.of(context).bottomAppBarTheme.color, - leading: CloseButton(color: Theme.of(context).iconTheme.color), + backgroundColor: context.scaffoldBackgroundColor, + title: Text("crop".tr()), + leading: CloseButton(color: context.primaryColor), actions: [ IconButton( icon: Icon( Icons.done_rounded, - color: Theme.of(context).iconTheme.color, + color: context.primaryColor, size: 24, ), onPressed: () async { @@ -47,13 +50,14 @@ class CropImagePage extends HookWidget { ), ], ), + backgroundColor: context.scaffoldBackgroundColor, body: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return Column( children: [ Container( padding: const EdgeInsets.only(top: 20), - width: double.infinity, + width: constraints.maxWidth * 0.9, height: constraints.maxHeight * 0.6, child: CropImage( controller: cropController, @@ -65,7 +69,7 @@ class CropImagePage extends HookWidget { child: Container( width: double.infinity, decoration: BoxDecoration( - color: Theme.of(context).bottomAppBarTheme.color, + color: context.scaffoldBackgroundColor, borderRadius: const BorderRadius.only( topLeft: Radius.circular(20), topRight: Radius.circular(20), @@ -196,7 +200,7 @@ class _AspectRatioButton extends StatelessWidget { icon: Icon( iconData, color: aspectRatio.value == ratio - ? Colors.indigo + ? context.primaryColor : Theme.of(context).iconTheme.color, ), onPressed: () { @@ -205,7 +209,7 @@ class _AspectRatioButton extends StatelessWidget { cropController.aspectRatio = ratio; }, ), - Text(label, style: Theme.of(context).textTheme.bodyMedium), + Text(label, style: context.textTheme.displayMedium), ], ); } diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart index b9017e940b..c81e84877b 100644 --- a/mobile/lib/pages/editing/edit.page.dart +++ b/mobile/lib/pages/editing/edit.page.dart @@ -7,13 +7,15 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:auto_route/auto_route.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:photo_manager/photo_manager.dart'; -import 'package:path/path.dart' as p; import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:path/path.dart' as p; /// A stateless widget that provides functionality for editing an image. /// @@ -71,11 +73,17 @@ class EditImagePage extends ConsumerWidget { ); await ref.read(albumProvider.notifier).getDeviceAlbums(); Navigator.of(context).popUntil((route) => route.isFirst); + ImmichToast.show( + durationInSecond: 3, + context: context, + msg: 'Image Saved!', + gravity: ToastGravity.CENTER, + ); } catch (e) { ImmichToast.show( durationInSecond: 6, context: context, - msg: 'Error: $e', + msg: "error_saving_image".tr(args: [e.toString()]), gravity: ToastGravity.CENTER, ); } @@ -88,11 +96,12 @@ class EditImagePage extends ConsumerWidget { return Scaffold( appBar: AppBar( - backgroundColor: Theme.of(context).appBarTheme.backgroundColor, + title: Text("edit_image_title".tr()), + backgroundColor: context.scaffoldBackgroundColor, leading: IconButton( icon: Icon( Icons.close_rounded, - color: Theme.of(context).iconTheme.color, + color: context.primaryColor, size: 24, ), onPressed: () => @@ -104,31 +113,48 @@ class EditImagePage extends ConsumerWidget { ? () => _saveEditedImage(context, asset, image, ref) : null, child: Text( - 'Save to gallery', + "save_to_gallery".tr(), style: TextStyle( - color: - isEdited ? Theme.of(context).iconTheme.color : Colors.grey, + color: isEdited ? context.primaryColor : Colors.grey, ), ), ), ], ), - body: Column( - children: [ - Expanded( - child: image, + backgroundColor: context.scaffoldBackgroundColor, + body: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + maxWidth: MediaQuery.of(context).size.width * 0.9, ), - Container( - height: 80, - color: Theme.of(context).bottomAppBarTheme.color, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(7), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + spreadRadius: 2, + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Image( + image: image.image, + fit: BoxFit.contain, + ), + ), ), - ], + ), ), bottomNavigationBar: Container( - height: 80, - margin: const EdgeInsets.only(bottom: 20, right: 10, left: 10, top: 10), + height: 70, + margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10), decoration: BoxDecoration( - color: Theme.of(context).bottomAppBarTheme.color, + color: context.scaffoldBackgroundColor, borderRadius: BorderRadius.circular(30), ), child: Column( @@ -140,6 +166,7 @@ class EditImagePage extends ConsumerWidget { ? Icons.crop_rotate_rounded : Icons.crop_rotate_rounded, color: Theme.of(context).iconTheme.color, + size: 25, ), onPressed: () { context.pushRoute( @@ -147,7 +174,7 @@ class EditImagePage extends ConsumerWidget { ); }, ), - Text('Crop', style: Theme.of(context).textTheme.displayMedium), + Text("crop".tr(), style: context.textTheme.displayMedium), ], ), ), diff --git a/mobile/lib/utils/hooks/crop_controller_hook.dart b/mobile/lib/utils/hooks/crop_controller_hook.dart index b03d9ccdb0..04bc978754 100644 --- a/mobile/lib/utils/hooks/crop_controller_hook.dart +++ b/mobile/lib/utils/hooks/crop_controller_hook.dart @@ -6,7 +6,7 @@ import 'dart:ui'; // Import the dart:ui library for Rect CropController useCropController() { return useMemoized( () => CropController( - defaultCrop: const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9), + defaultCrop: const Rect.fromLTRB(0, 0, 1, 1), ), ); } From 16d5996f773e725ca2cf3f6754fafc53e3fa077d Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Tue, 27 Aug 2024 15:30:01 -0400 Subject: [PATCH 174/723] docs: external library deletion/edits (#12079) * external lib * edit 2 * Update FAQ.mdx * fixes --- docs/docs/FAQ.mdx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index a5d9b6e3d3..501a67d5f2 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -63,8 +63,9 @@ We recommend using a real SSL certificate from a free provider, for example [Let ### Does Immich change the file? -No, Immich does not touch the original file under any circumstances, -all edited metadata are saved in the companion sidecar file and the database. +No, Immich does not modify the original files. +All edited metadata is saved in companion `.xmp` sidecar files and the database. +However, Immich will delete original files that have been trashed when the trash is emptied in the Immich UI. ### Can I add my existing photo library? @@ -162,6 +163,19 @@ We haven't implemented an official mechanism for creating albums from external l Duplicate checking only exists for upload libraries, using the file hash. Furthermore, duplicate checking is not global, but _per library_. Therefore, a situation where the same file appears twice in the timeline is possible, especially for external libraries. +### Why are my edits to files not being saved in read-only external libraries? + +Images in read-write external libraries (the default) can be edited as normal. +In read-only libraries (`:ro` in the `docker-compose.yml`), Immich is unable to create the `.xmp` sidecar files to store edited file metadata. +For this reason, the metadata (timestamp, location, description, star rating, etc.) cannot be edited for files in read-only external libraries. + +### How are deletions of files handled in external libraries? + +Immich will attempt to delete original files that have been trashed when the trash is emptied. +In read-write external libraries (the default), Immich will delete the original file. +In read-only libraries (`:ro` in the `docker-compose.yml`), files can still be trashed in the UI. +However, when the trash is emptied, the files will re-appear in the main timeline since Immich is unable to delete the original file. + --- ## Machine Learning From aac6a4b0524ac19a47e4f6632e161f6e166800b7 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Aug 2024 16:50:25 -0500 Subject: [PATCH 175/723] chore(web): ignore shortcut toggle when entering email and password (#12082) --- web/src/lib/actions/shortcut.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/src/lib/actions/shortcut.ts b/web/src/lib/actions/shortcut.ts index fca1ed7ef8..d28c294a89 100644 --- a/web/src/lib/actions/shortcut.ts +++ b/web/src/lib/actions/shortcut.ts @@ -20,7 +20,7 @@ export const shouldIgnoreShortcut = (event: KeyboardEvent): boolean => { return false; } const type = (event.target as HTMLInputElement).type; - return ['textarea', 'text', 'date', 'datetime-local'].includes(type); + return ['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type); }; export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => { @@ -53,7 +53,6 @@ export const shortcuts = ( ): ActionReturn[]> => { function onKeydown(event: KeyboardEvent) { const ignoreShortcut = shouldIgnoreShortcut(event); - for (const { shortcut, onShortcut, ignoreInputFields = true, preventDefault = true } of options) { if (ignoreInputFields && ignoreShortcut) { continue; From 0be3c4472f6eee522babe5c7d917aa604ac90f53 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 27 Aug 2024 18:06:50 -0400 Subject: [PATCH 176/723] refactor(server): event names (#12084) --- server/src/app.module.ts | 8 ++++---- server/src/interfaces/event.interface.ts | 14 +++++++------- server/src/services/album.service.spec.ts | 8 ++++---- server/src/services/album.service.ts | 6 +++--- server/src/services/database.service.ts | 2 +- server/src/services/library.service.ts | 7 ++++--- server/src/services/metadata.service.ts | 10 +++++----- server/src/services/microservices.service.ts | 4 ++-- server/src/services/notification.service.ts | 16 ++++++++-------- server/src/services/server.service.ts | 2 +- server/src/services/smart-info.service.ts | 12 ++++++------ server/src/services/storage-template.service.ts | 4 ++-- server/src/services/storage.service.ts | 2 +- server/src/services/system-config.service.ts | 10 +++++----- server/src/services/user-admin.service.ts | 2 +- server/src/services/version.service.ts | 2 +- 16 files changed, 55 insertions(+), 54 deletions(-) diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 1a8a05fd4d..c6cd68a96f 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -62,7 +62,7 @@ export class ApiModule implements OnModuleInit, OnModuleDestroy { async onModuleInit() { const items = setupEventHandlers(this.moduleRef); - await this.eventRepository.emit('onBootstrap', 'api'); + await this.eventRepository.emit('app.bootstrap', 'api'); this.logger.setContext('EventLoader'); const eventMap = _.groupBy(items, 'event'); @@ -74,7 +74,7 @@ export class ApiModule implements OnModuleInit, OnModuleDestroy { } async onModuleDestroy() { - await this.eventRepository.emit('onShutdown'); + await this.eventRepository.emit('app.shutdown'); } } @@ -90,11 +90,11 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy { async onModuleInit() { setupEventHandlers(this.moduleRef); - await this.eventRepository.emit('onBootstrap', 'microservices'); + await this.eventRepository.emit('app.bootstrap', 'microservices'); } async onModuleDestroy() { - await this.eventRepository.emit('onShutdown'); + await this.eventRepository.emit('app.shutdown'); } } diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 613a6423a4..609f42cc32 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -6,19 +6,19 @@ export const IEventRepository = 'IEventRepository'; type EmitEventMap = { // app events - onBootstrap: ['api' | 'microservices']; - onShutdown: []; + 'app.bootstrap': ['api' | 'microservices']; + 'app.shutdown': []; // config events - onConfigUpdate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; - onConfigValidate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + 'config.update': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; // album events - onAlbumUpdate: [{ id: string; updatedBy: string }]; - onAlbumInvite: [{ id: string; userId: string }]; + 'album.update': [{ id: string; updatedBy: string }]; + 'album.invite': [{ id: string; userId: string }]; // user events - onUserSignup: [{ notify: boolean; id: string; tempPassword?: string }]; + 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; }; export type EmitEvent = keyof EmitEventMap; diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 16b2d97fdd..164e823336 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -205,7 +205,7 @@ describe(AlbumService.name, () => { expect(userMock.get).toHaveBeenCalledWith('user-id', {}); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInvite', { + expect(eventMock.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.empty.id, userId: 'user-id', }); @@ -384,7 +384,7 @@ describe(AlbumService.name, () => { userId: authStub.user2.user.id, albumId: albumStub.sharedWithAdmin.id, }); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInvite', { + expect(eventMock.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.sharedWithAdmin.id, userId: userStub.user2.id, }); @@ -572,7 +572,7 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdate', { + expect(eventMock.emit).toHaveBeenCalledWith('album.update', { id: 'album-123', updatedBy: authStub.admin.user.id, }); @@ -616,7 +616,7 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdate', { + expect(eventMock.emit).toHaveBeenCalledWith('album.update', { id: 'album-123', updatedBy: authStub.user1.user.id, }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index b2b5ea32a2..1cd5237b7a 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -140,7 +140,7 @@ export class AlbumService { }); for (const { userId } of albumUsers) { - await this.eventRepository.emit('onAlbumInvite', { id: album.id, userId }); + await this.eventRepository.emit('album.invite', { id: album.id, userId }); } return mapAlbumWithAssets(album); @@ -192,7 +192,7 @@ export class AlbumService { albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId, }); - await this.eventRepository.emit('onAlbumUpdate', { id, updatedBy: auth.user.id }); + await this.eventRepository.emit('album.update', { id, updatedBy: auth.user.id }); } return results; @@ -240,7 +240,7 @@ export class AlbumService { } await this.albumUserRepository.create({ userId: userId, albumId: id, role }); - await this.eventRepository.emit('onAlbumInvite', { id, userId }); + await this.eventRepository.emit('album.invite', { id, userId }); } return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets); diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index b6d61c578d..d2a2813a05 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -68,7 +68,7 @@ export class DatabaseService { this.logger.setContext(DatabaseService.name); } - @OnEmit({ event: 'onBootstrap', priority: -200 }) + @OnEmit({ event: 'app.bootstrap', priority: -200 }) async onBootstrap() { const version = await this.databaseRepository.getPostgresVersion(); const current = semver.coerce(version); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 1bee2d32c3..4b82c9811d 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -66,7 +66,7 @@ export class LibraryService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'onBootstrap' }) + @OnEmit({ event: 'app.bootstrap' }) async onBootstrap() { const config = await this.configCore.getConfig({ withCache: false }); @@ -104,7 +104,8 @@ export class LibraryService { }); } - onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { + @OnEmit({ event: 'config.validate' }) + onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { const { scan } = newConfig.library; if (!validateCronExpression(scan.cronExpression)) { throw new Error(`Invalid cron expression ${scan.cronExpression}`); @@ -189,7 +190,7 @@ export class LibraryService { } } - @OnEmit({ event: 'onShutdown' }) + @OnEmit({ event: 'app.shutdown' }) async onShutdown() { await this.unwatchAll(); } diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index dcdf07b8c3..3c938a4e59 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -121,8 +121,8 @@ export class MetadataService { ); } - @OnEmit({ event: 'onBootstrap' }) - async onBootstrap(app: ArgOf<'onBootstrap'>) { + @OnEmit({ event: 'app.bootstrap' }) + async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; } @@ -130,8 +130,8 @@ export class MetadataService { await this.init(config); } - @OnEmit({ event: 'onConfigUpdate' }) - async onConfigUpdate({ newConfig }: ArgOf<'onConfigUpdate'>) { + @OnEmit({ event: 'config.update' }) + async onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { await this.init(newConfig); } @@ -153,7 +153,7 @@ export class MetadataService { } } - @OnEmit({ event: 'onShutdown' }) + @OnEmit({ event: 'app.shutdown' }) async onShutdown() { await this.repository.teardown(); } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 46ca4118d1..5b28e6a00a 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -39,8 +39,8 @@ export class MicroservicesService { private versionService: VersionService, ) {} - @OnEmit({ event: 'onBootstrap' }) - async onBootstrap(app: ArgOf<'onBootstrap'>) { + @OnEmit({ event: 'app.bootstrap' }) + async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; } diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 31701013b7..fa4f79f6d6 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -42,8 +42,8 @@ export class NotificationService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } - @OnEmit({ event: 'onConfigValidate', priority: -100 }) - async onConfigValidate({ oldConfig, newConfig }: ArgOf<'onConfigValidate'>) { + @OnEmit({ event: 'config.validate', priority: -100 }) + async onConfigValidate({ oldConfig, newConfig }: ArgOf<'config.validate'>) { try { if ( newConfig.notifications.smtp.enabled && @@ -57,20 +57,20 @@ export class NotificationService { } } - @OnEmit({ event: 'onUserSignup' }) - async onUserSignup({ notify, id, tempPassword }: ArgOf<'onUserSignup'>) { + @OnEmit({ event: 'user.signup' }) + async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { if (notify) { await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } }); } } - @OnEmit({ event: 'onAlbumUpdate' }) - async onAlbumUpdate({ id, updatedBy }: ArgOf<'onAlbumUpdate'>) { + @OnEmit({ event: 'album.update' }) + async onAlbumUpdate({ id, updatedBy }: ArgOf<'album.update'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } }); } - @OnEmit({ event: 'onAlbumInvite' }) - async onAlbumInvite({ id, userId }: ArgOf<'onAlbumInvite'>) { + @OnEmit({ event: 'album.invite' }) + async onAlbumInvite({ id, userId }: ArgOf<'album.invite'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index faf4d98164..5ea8a3e459 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -42,7 +42,7 @@ export class ServerService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'onBootstrap' }) + @OnEmit({ event: 'app.bootstrap' }) async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index d57b5fb54f..a75594100f 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -39,8 +39,8 @@ export class SmartInfoService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'onBootstrap' }) - async onBootstrap(app: ArgOf<'onBootstrap'>) { + @OnEmit({ event: 'app.bootstrap' }) + async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; } @@ -49,8 +49,8 @@ export class SmartInfoService { await this.init(config); } - @OnEmit({ event: 'onConfigValidate' }) - onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { + @OnEmit({ event: 'config.validate' }) + onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { getCLIPModelInfo(newConfig.machineLearning.clip.modelName); } catch { @@ -60,8 +60,8 @@ export class SmartInfoService { } } - @OnEmit({ event: 'onConfigUpdate' }) - async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'onConfigUpdate'>) { + @OnEmit({ event: 'config.update' }) + async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { await this.init(newConfig, oldConfig); } diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 4855d602d7..829863e228 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -89,8 +89,8 @@ export class StorageTemplateService { ); } - @OnEmit({ event: 'onConfigValidate' }) - onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { + @OnEmit({ event: 'config.validate' }) + onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { const { compiled } = this.compile(newConfig.storageTemplate.template); this.render(compiled, { diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 1535d53d95..c3f2c06438 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -14,7 +14,7 @@ export class StorageService { this.logger.setContext(StorageService.name); } - @OnEmit({ event: 'onBootstrap' }) + @OnEmit({ event: 'app.bootstrap' }) onBootstrap() { const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); this.storageRepository.mkdirSync(libraryBase); diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index b4e6f903b1..26a91f1d09 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -33,7 +33,7 @@ export class SystemConfigService { this.core.config$.subscribe((config) => this.setLogLevel(config)); } - @OnEmit({ event: 'onBootstrap', priority: -100 }) + @OnEmit({ event: 'app.bootstrap', priority: -100 }) async onBootstrap() { const config = await this.core.getConfig({ withCache: false }); this.core.config$.next(config); @@ -48,8 +48,8 @@ export class SystemConfigService { return mapConfig(defaults); } - @OnEmit({ event: 'onConfigValidate' }) - onConfigValidate({ newConfig, oldConfig }: ArgOf<'onConfigValidate'>) { + @OnEmit({ event: 'config.validate' }) + onConfigValidate({ newConfig, oldConfig }: ArgOf<'config.validate'>) { if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); } @@ -63,7 +63,7 @@ export class SystemConfigService { const oldConfig = await this.core.getConfig({ withCache: false }); try { - await this.eventRepository.emit('onConfigValidate', { newConfig: dto, oldConfig }); + await this.eventRepository.emit('config.validate', { newConfig: dto, oldConfig }); } catch (error) { this.logger.warn(`Unable to save system config due to a validation error: ${error}`); throw new BadRequestException(error instanceof Error ? error.message : error); @@ -74,7 +74,7 @@ export class SystemConfigService { // TODO probably move web socket emits to a separate service this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null); - await this.eventRepository.emit('onConfigUpdate', { newConfig, oldConfig }); + await this.eventRepository.emit('config.update', { newConfig, oldConfig }); return mapConfig(newConfig); } diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 95eeed0475..6a5b6ea06e 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -45,7 +45,7 @@ export class UserAdminService { const { notify, ...rest } = dto; const user = await this.userCore.createUser(rest); - await this.eventRepository.emit('onUserSignup', { + await this.eventRepository.emit('user.signup', { notify: !!notify, id: user.id, tempPassword: user.shouldChangePassword ? rest.password : undefined, diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 2f04a51014..468e8c9bdd 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -37,7 +37,7 @@ export class VersionService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'onBootstrap' }) + @OnEmit({ event: 'app.bootstrap' }) async onBootstrap(): Promise { await this.handleVersionCheck(); } From 98b3441cb1457c009d434fc0fcf64fe9a69652e6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 27 Aug 2024 18:08:01 -0400 Subject: [PATCH 177/723] chore(deps): update prom/prometheus docker digest to f663933 (#12072) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 2fec915a42..733905e01d 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -79,7 +79,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:cafe963e591c872d38f3ea41ff8eb22cee97917b7c97b5c0ccd43a419f11f613 + image: prom/prometheus@sha256:f6639335d34a77d9d9db382b92eeb7fc00934be8eae81dbc03b31cfe90411a94 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus From 72ab664936926a62c82b1ec46a0c51dc663991d8 Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Tue, 27 Aug 2024 18:13:17 -0400 Subject: [PATCH 178/723] feat(web): announce notifications to screen readers (#12071) --- e2e/src/web/specs/photo-viewer.e2e-spec.ts | 2 +- .../context-menu/context-menu.svelte | 2 +- .../shared-components/loading-spinner.svelte | 1 + .../__tests__/notification-card.spec.ts | 23 +++++++++++++++++++ .../__tests__/notification-list.spec.ts | 6 +++-- .../notification/notification-card.svelte | 4 ++++ .../notification/notification-list.svelte | 22 ++++++++++-------- 7 files changed, 46 insertions(+), 14 deletions(-) diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts index bc3f6843ca..09340e98cb 100644 --- a/e2e/src/web/specs/photo-viewer.e2e-spec.ts +++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts @@ -33,7 +33,7 @@ test.describe('Photo Viewer', () => { await page.waitForLoadState('load'); // this is the spinner await page.waitForSelector('svg[role=status]'); - await expect(page.getByRole('status')).toBeVisible(); + await expect(page.getByTestId('loading-spinner')).toBeVisible(); }); test('loads high resolution photo when zoomed', async ({ page }) => { diff --git a/web/src/lib/components/shared-components/context-menu/context-menu.svelte b/web/src/lib/components/shared-components/context-menu/context-menu.svelte index c6975fdc19..8f5ebfa2cf 100644 --- a/web/src/lib/components/shared-components/context-menu/context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/context-menu.svelte @@ -50,7 +50,7 @@ bind:this={menuElement} class:max-h-[100vh]={isVisible} class:max-h-0={!isVisible} - class="flex flex-col transition-all duration-[250ms] ease-in-out" + class="flex flex-col transition-all duration-[250ms] ease-in-out outline-none" role="menu" tabindex="-1" > diff --git a/web/src/lib/components/shared-components/loading-spinner.svelte b/web/src/lib/components/shared-components/loading-spinner.svelte index 7835e17310..48626a50f4 100644 --- a/web/src/lib/components/shared-components/loading-spinner.svelte +++ b/web/src/lib/components/shared-components/loading-spinner.svelte @@ -11,6 +11,7 @@ viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg" + data-testid="loading-spinner" > { expect(sut.getByTestId('message')).toHaveTextContent('Notification message'); }); + it('makes all buttons non-focusable and hidden from screen readers', () => { + sut = render(NotificationCard, { + notification: { + id: 1234, + message: 'Notification message', + timeout: 1000, + type: NotificationType.Info, + action: { type: 'discard' }, + button: { + text: 'button', + onClick: vi.fn(), + }, + }, + }); + const buttons = sut.container.querySelectorAll('button'); + + expect(buttons).toHaveLength(2); + for (const button of buttons) { + expect(button.getAttribute('tabindex')).toBe('-1'); + expect(button.getAttribute('aria-hidden')).toBe('true'); + } + }); + it('shows title and renders component', () => { sut = render(NotificationCard, { notification: { diff --git a/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts b/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts index 44634d6b20..669b7d75bd 100644 --- a/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts +++ b/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts @@ -9,8 +9,6 @@ function _getNotificationListElement(sut: RenderResult): HTMLA } describe('NotificationList component', () => { - const sut: RenderResult = render(NotificationList); - beforeAll(() => { // https://testing-library.com/docs/svelte-testing-library/faq#why-arent-transition-events-running vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => { @@ -23,6 +21,10 @@ describe('NotificationList component', () => { }); it('shows a notification when added and closes it automatically after the delay timeout', async () => { + const sut: RenderResult = render(NotificationList); + const status = await sut.findAllByRole('status'); + + expect(status).toHaveLength(1); expect(_getNotificationListElement(sut)).not.toBeInTheDocument(); notificationController.show({ diff --git a/web/src/lib/components/shared-components/notification/notification-card.svelte b/web/src/lib/components/shared-components/notification/notification-card.svelte index aac0823bf5..61e710a170 100644 --- a/web/src/lib/components/shared-components/notification/notification-card.svelte +++ b/web/src/lib/components/shared-components/notification/notification-card.svelte @@ -91,6 +91,8 @@ size="20" padding="2" on:click={discard} + aria-hidden="true" + tabindex={-1} />
    @@ -108,6 +110,8 @@ type="button" class="{buttonStyle[notification.type]} rounded px-3 pt-1.5 pb-1 transition-all duration-200" on:click={handleButtonClick} + aria-hidden="true" + tabindex={-1} > {notification.button.text} diff --git a/web/src/lib/components/shared-components/notification/notification-list.svelte b/web/src/lib/components/shared-components/notification/notification-list.svelte index d94ff5c14d..c7c54be267 100644 --- a/web/src/lib/components/shared-components/notification/notification-list.svelte +++ b/web/src/lib/components/shared-components/notification/notification-list.svelte @@ -1,7 +1,7 @@ -{#if $notificationList.length > 0} -
    - {#each $notificationList as notification (notification.id)} -
    - -
    - {/each} -
    -{/if} +
    + {#if $notificationList.length > 0} +
    + {#each $notificationList as notification (notification.id)} +
    + +
    + {/each} +
    + {/if} +
    From 028be6738e4fa0a4e4cc3a1e2006cc51d7cb8661 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Tue, 27 Aug 2024 23:19:04 +0100 Subject: [PATCH 179/723] ci: use push-o-matic app for release process (#12075) ci: use push-o-matic for release process --- .github/workflows/prepare-release.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 9d50f6f8f9..6668976bcf 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -40,16 +40,22 @@ jobs: - name: Bump version run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}" + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} + private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} + - name: Commit and tag id: push-tag uses: EndBug/add-and-commit@v9 with: - author_name: Alex The Bot - author_email: alex.tran1502@gmail.com - default_author: user_info - message: 'Version ${{ env.IMMICH_VERSION }}' + default_author: github_actions + message: 'chore: version ${{ env.IMMICH_VERSION }}' tag: ${{ env.IMMICH_VERSION }} push: true + github-token: ${{ steps.generate-token.outputs.token }} build_mobile: uses: ./.github/workflows/build-mobile.yml From be476d7982c1ec77baac44acf6b59e5a72112d99 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Aug 2024 17:29:50 -0500 Subject: [PATCH 180/723] chore(web): ensure goto is awaited for login page (#12087) * chore(web): ensure goto is await for login page * ensure server config is updated after onboarding is finished --- web/src/routes/auth/login/+page.svelte | 6 +++--- web/src/routes/auth/onboarding/+page.svelte | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index 9c22439c56..dd0f64c5a8 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -17,9 +17,9 @@

    goto(AppRoute.PHOTOS, { invalidateAll: true })} - onFirstLogin={() => goto(AppRoute.AUTH_CHANGE_PASSWORD)} - onOnboarding={() => goto(AppRoute.AUTH_ONBOARDING)} + onSuccess={async () => await goto(AppRoute.PHOTOS, { invalidateAll: true })} + onFirstLogin={async () => await goto(AppRoute.AUTH_CHANGE_PASSWORD)} + onOnboarding={async () => await goto(AppRoute.AUTH_ONBOARDING)} /> {/if} diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte index 0fe2c68c84..ddb30d1b45 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -6,6 +6,7 @@ import OnboadingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte'; import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; + import { retrieveServerConfig } from '$lib/stores/server-config.store'; import { updateAdminOnboarding } from '@immich/sdk'; let index = 0; @@ -35,6 +36,7 @@ const handleDoneClicked = async () => { if (index >= onboardingSteps.length - 1) { await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } }); + await retrieveServerConfig(); await goto(AppRoute.PHOTOS); } else { index++; From d4cdd590bd1491c4725a5d585c9793dc5b9ce1de Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Tue, 27 Aug 2024 20:48:23 -0400 Subject: [PATCH 181/723] docs: sql query for duplicate files (#12086) --- docs/docs/guides/database-queries.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md index 20b841f402..2b4f27cfce 100644 --- a/docs/docs/guides/database-queries.md +++ b/docs/docs/guides/database-queries.md @@ -23,7 +23,7 @@ SELECT * FROM "assets" WHERE "originalFileName" LIKE '%_2023_%'; -- all files wi ``` ```sql title="Find by path" -SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09-03/PXL_20230903_232542848.jpg'; +SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09-03/PXL_2023.jpg'; SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%'; ``` @@ -37,6 +37,12 @@ SELECT * FROM "assets" WHERE "checksum" = decode('69de19c87658c4c15d9cacb9967b8e SELECT * FROM "assets" WHERE "checksum" = '\x69de19c87658c4c15d9cacb9967b8e033bf74dd1'; -- alternate notation ``` +```sql title="Find duplicate assets with identical checksum (SHA-1) (excluding trashed files)" +SELECT T1."checksum", array_agg(T2."id") ids FROM "assets" T1 + INNER JOIN "assets" T2 ON T1."checksum" = T2."checksum" AND T1."id" != T2."id" AND T2."deletedAt" IS NULL + WHERE T1."deletedAt" IS NULL GROUP BY T1."checksum"; +``` + ```sql title="Live photos" SELECT * FROM "assets" WHERE "livePhotoVideoId" IS NOT NULL; ``` @@ -79,8 +85,7 @@ SELECT "assets"."type", COUNT(*) FROM "assets" GROUP BY "assets"."type"; ```sql title="Count by type (per user)" SELECT "users"."email", "assets"."type", COUNT(*) FROM "assets" JOIN "users" ON "assets"."ownerId" = "users"."id" - GROUP BY "assets"."type", "users"."email" - ORDER BY "users"."email"; + GROUP BY "assets"."type", "users"."email" ORDER BY "users"."email"; ``` ```sql title="Failed file movements" From 1fd00d8262338a89151595ed25f84d0a6539180d Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Aug 2024 22:31:32 -0500 Subject: [PATCH 182/723] chore(web): resolve timeline flashing temporarily (#12088) --- web/src/lib/stores/assets.store.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 7fd82b4c3a..763d5b1874 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -253,8 +253,9 @@ export class AssetStore { connect() { this.unsubscribers.push( - websocketEvents.on('on_upload_success', (asset) => { - this.addPendingChanges({ type: 'add', values: [asset] }); + websocketEvents.on('on_upload_success', (_) => { + // TODO!: Temporarily disable to avoid flashing effect of the timeline + // this.addPendingChanges({ type: 'add', values: [asset] }); }), websocketEvents.on('on_asset_trash', (ids) => { this.addPendingChanges({ type: 'trash', values: ids }); From 1239066adaad1ba85d4155027a7e83da9ce837d7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 Aug 2024 08:51:02 -0400 Subject: [PATCH 183/723] chore(deps): update base-image to v20240827 (major) (#12073) chore(deps): update base-image to v20240827 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 45c68e65e0..1c671f2332 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20240820@sha256:a28296b40c1247e539894ac4013e6a3e20588d5aefe697fe2ada15f1bd23f6e5 AS dev +FROM ghcr.io/immich-app/base-server-dev:20240827@sha256:c882c0a354faaac4f7256d30ecc2c45435eafa9b64d60793a171abb74ec5ca95 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -41,7 +41,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20240813@sha256:51537e98ac601aa8401604a6aa9421e94aa55e03c303f355cc5870142adcc471 +FROM ghcr.io/immich-app/base-server-prod:20240827@sha256:72a419dd703b0f530c43f3e00f3aa56be9efb61b4eb9fe911bae8c6f98237967 WORKDIR /usr/src/app ENV NODE_ENV=production \ From d8aec81ae05ddb547d52c0b52e4e5f0639221d24 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 Aug 2024 08:52:24 -0400 Subject: [PATCH 184/723] fix(deps): update dependency react-email to v3 (#12077) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/package-lock.json | 2807 +++++++------------------------------- server/package.json | 2 +- 2 files changed, 476 insertions(+), 2333 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 05c1469d1e..33a4cd51ad 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -53,7 +53,7 @@ "openid-client": "^5.4.3", "pg": "^8.11.3", "picomatch": "^4.0.0", - "react-email": "^2.1.2", + "react-email": "^3.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", @@ -115,6 +115,7 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -123,6 +124,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "peer": true, "engines": { "node": ">=10" }, @@ -598,17 +600,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz", - "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.6.tgz", @@ -740,21 +731,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@emotion/is-prop-valid": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", - "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "optional": true, - "dependencies": { - "@emotion/memoize": "0.7.4" - } - }, - "node_modules/@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "optional": true - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -1127,6 +1103,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, "dependencies": { "eslint-visitor-keys": "^3.3.0" }, @@ -1141,6 +1118,7 @@ "version": "4.11.0", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -1149,6 +1127,7 @@ "version": "0.17.1", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.1.tgz", "integrity": "sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==", + "dev": true, "dependencies": { "@eslint/object-schema": "^2.1.4", "debug": "^4.3.1", @@ -1162,6 +1141,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^6.12.4", @@ -1185,6 +1165,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1200,6 +1181,7 @@ "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1211,12 +1193,14 @@ "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "node_modules/@eslint/js": { "version": "9.8.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1226,6 +1210,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -1239,40 +1224,6 @@ "node": ">=14" } }, - "node_modules/@floating-ui/core": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.4.tgz", - "integrity": "sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA==", - "dependencies": { - "@floating-ui/utils": "^0.2.4" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.7.tgz", - "integrity": "sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng==", - "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.4" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.1.tgz", - "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==", - "dependencies": { - "@floating-ui/dom": "^1.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.4.tgz", - "integrity": "sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==" - }, "node_modules/@golevelup/nestjs-discovery": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.1.tgz", @@ -1377,6 +1328,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, "engines": { "node": ">=12.22" }, @@ -1389,6 +1341,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==", + "dev": true, "engines": { "node": ">=18.18" }, @@ -1964,6 +1917,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -2457,14 +2411,14 @@ } }, "node_modules/@next/env": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.4.tgz", - "integrity": "sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==" + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", + "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==" }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.4.tgz", - "integrity": "sha512-ubmUkbmW65nIAOmoxT1IROZdmmJMmdYvXIe8211send9ZYJu+SqxSnJM4TrPj9wmL6g9Atvj0S/2cFmMSS99jg==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz", + "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==", "cpu": [ "arm64" ], @@ -2477,9 +2431,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.4.tgz", - "integrity": "sha512-b0Xo1ELj3u7IkZWAKcJPJEhBop117U78l70nfoQGo4xUSvv0PJSTaV4U9xQBLvZlnjsYkc8RwQN1HoH/oQmLlQ==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz", + "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==", "cpu": [ "x64" ], @@ -2492,9 +2446,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.4.tgz", - "integrity": "sha512-457G0hcLrdYA/u1O2XkRMsDKId5VKe3uKPvrKVOyuARa6nXrdhJOOYU9hkKKyQTMru1B8qEP78IAhf/1XnVqKA==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz", + "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==", "cpu": [ "arm64" ], @@ -2507,9 +2461,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.4.tgz", - "integrity": "sha512-l/kMG+z6MB+fKA9KdtyprkTQ1ihlJcBh66cf0HvqGP+rXBbOXX0dpJatjZbHeunvEHoBBS69GYQG5ry78JMy3g==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz", + "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==", "cpu": [ "arm64" ], @@ -2522,9 +2476,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.4.tgz", - "integrity": "sha512-BapIFZ3ZRnvQ1uWbmqEGJuPT9cgLwvKtxhK/L2t4QYO7l+/DxXuIGjvp1x8rvfa/x1FFSsipERZK70pewbtJtw==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz", + "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==", "cpu": [ "x64" ], @@ -2537,9 +2491,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.4.tgz", - "integrity": "sha512-mqVxTwk4XuBl49qn2A5UmzFImoL1iLm0KQQwtdRJRKl21ylQwwGCxJtIYo2rbfkZHoSKlh/YgztY0qH3wG1xIg==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz", + "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==", "cpu": [ "x64" ], @@ -2552,9 +2506,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.4.tgz", - "integrity": "sha512-xzxF4ErcumXjO2Pvg/wVGrtr9QQJLk3IyQX1ddAC/fi6/5jZCZ9xpuL9Tzc4KPWMFq8GGWFVDMshZOdHGdkvag==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz", + "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==", "cpu": [ "arm64" ], @@ -2567,9 +2521,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.4.tgz", - "integrity": "sha512-WZiz8OdbkpRw6/IU/lredZWKKZopUMhcI2F+XiMAcPja0uZYdMTZQRoQ0WZcvinn9xZAidimE7tN9W5v9Yyfyw==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz", + "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==", "cpu": [ "ia32" ], @@ -2582,9 +2536,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.4.tgz", - "integrity": "sha512-4Rto21sPfw555sZ/XNLqfxDUNeLhNYGO2dlPqsnuCg8N8a2a9u1ltqBOPQ4vj1Gf7eJC0W2hHG2eYUHuiXgY2w==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz", + "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==", "cpu": [ "x64" ], @@ -4317,92 +4271,6 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, - "node_modules/@radix-ui/colors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-1.0.1.tgz", - "integrity": "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg==" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", - "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", - "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.0.tgz", - "integrity": "sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", - "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", @@ -4417,280 +4285,6 @@ } } }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", - "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz", - "integrity": "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-escape-keydown": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz", - "integrity": "sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", - "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", - "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz", - "integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-focus-guards": "1.1.0", - "@radix-ui/react-focus-scope": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", - "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-use-rect": "1.1.0", - "@radix-ui/react-use-size": "1.1.0", - "@radix-ui/rect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", - "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", - "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", - "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", - "dependencies": { - "@radix-ui/react-slot": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", - "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", @@ -4708,214 +4302,6 @@ } } }, - "node_modules/@radix-ui/react-toggle": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz", - "integrity": "sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.0.tgz", - "integrity": "sha512-PpTJV68dZU2oqqgq75Uzto5o/XfOVgkrJ9rulVmfTKxWp3HfUjHE6CP/WLRR4AzPX9HWxw7vFow2me85Yu+Naw==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-toggle": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.1.tgz", - "integrity": "sha512-LLE8nzNE4MzPMw3O2zlVlkLFid3y9hMUs7uCbSHyKSo+tCN4yMCf+ZCCcfrYgsOC0TiHBPQ1mtpJ2liY3ZT3SQ==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", - "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", - "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", - "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", - "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", - "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", - "dependencies": { - "@radix-ui/rect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", - "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", - "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", - "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" - }, "node_modules/@react-email/body": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.9.tgz", @@ -5686,10 +5072,11 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "node_modules/@swc/helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", - "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", "dependencies": { + "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, @@ -5697,6 +5084,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.12.tgz", "integrity": "sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==", + "devOptional": true, "dependencies": { "@swc/counter": "^0.1.3" } @@ -5877,6 +5265,7 @@ "version": "8.44.3", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.3.tgz", "integrity": "sha512-iM/WfkwAhwmPff3wZuPLYiHX18HI24jU8k1ZSH7P8FHwxTjZ2P6CoX2wnF43oprR+YXJM6UUxATkNvyv/JHd+g==", + "dev": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5886,6 +5275,7 @@ "version": "3.7.5", "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.5.tgz", "integrity": "sha512-JNvhIEyxVW6EoMIFIvj93ZOywYFatlpu9deeH6eSx6PE3WHYvHaQtmHmQeNw7aA81bYGBPPQqdtBm6b1SsQMmA==", + "dev": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -5894,7 +5284,8 @@ "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true }, "node_modules/@types/express": { "version": "4.17.21", @@ -5954,7 +5345,8 @@ "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true }, "node_modules/@types/lodash": { "version": "4.17.7", @@ -6112,15 +5504,12 @@ "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", "dev": true }, - "node_modules/@types/prismjs": { - "version": "1.26.3", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.3.tgz", - "integrity": "sha512-A0D0aTXvjlqJ5ZILMz3rNfDBOx9hHxLZYv2by47Sm/pqW35zzjusrZTryatjN/Rf8Us2gZrJD+KeHbUSTux1Cw==" - }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", + "optional": true, + "peer": true }, "node_modules/@types/qs": { "version": "6.9.8", @@ -6138,19 +5527,13 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", + "optional": true, + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, - "node_modules/@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/readdir-glob": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.2.tgz", @@ -6160,11 +5543,6 @@ "@types/node": "*" } }, - "node_modules/@types/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==" - }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -6265,16 +5643,6 @@ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.8.tgz", "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, - "node_modules/@types/webpack": { - "version": "5.28.5", - "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", - "integrity": "sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==", - "dependencies": { - "@types/node": "*", - "tapable": "^2.2.0", - "webpack": "^5" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", @@ -6610,6 +5978,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "dev": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6" @@ -6618,22 +5987,26 @@ "node_modules/@webassemblyjs/floating-point-hex-parser": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==" + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==" + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "dev": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.11.6", "@webassemblyjs/helper-api-error": "1.11.6", @@ -6643,12 +6016,14 @@ "node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -6660,6 +6035,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -6668,6 +6044,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -6675,12 +6052,14 @@ "node_modules/@webassemblyjs/utf8": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -6696,6 +6075,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", @@ -6708,6 +6088,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -6719,6 +6100,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", @@ -6732,6 +6114,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" @@ -6740,12 +6123,14 @@ "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true }, "node_modules/abbrev": { "version": "1.1.1", @@ -6798,6 +6183,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -7100,17 +6486,6 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, - "node_modules/aria-hidden": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", - "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -7164,38 +6539,6 @@ "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==" }, - "node_modules/autoprefixer": { - "version": "10.4.14", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", - "integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - } - ], - "dependencies": { - "browserslist": "^4.21.5", - "caniuse-lite": "^1.0.30001464", - "fraction.js": "^4.2.0", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/b4a": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", @@ -7596,6 +6939,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "peer": true, "engines": { "node": ">= 6" } @@ -7699,6 +7043,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, "engines": { "node": ">=6.0" } @@ -7863,14 +7208,6 @@ "node": ">=0.8" } }, - "node_modules/clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", - "engines": { - "node": ">=6" - } - }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -8271,6 +7608,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "peer": true, "bin": { "cssesc": "bin/cssesc" }, @@ -8281,7 +7619,9 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "optional": true, + "peer": true }, "node_modules/dayjs": { "version": "1.11.10", @@ -8327,7 +7667,8 @@ "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true }, "node_modules/deepmerge": { "version": "4.3.1", @@ -8403,11 +7744,6 @@ "node": ">=8" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, "node_modules/diacritics": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", @@ -8416,7 +7752,8 @@ "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "peer": true }, "node_modules/diff": { "version": "4.0.2", @@ -8449,7 +7786,8 @@ "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "peer": true }, "node_modules/docker-compose": { "version": "0.24.8", @@ -8700,18 +8038,6 @@ "node": ">=10.2.0" } }, - "node_modules/engine.io-client": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", - "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.11.0", - "xmlhttprequest-ssl": "~2.0.0" - } - }, "node_modules/engine.io-parser": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", @@ -8724,6 +8050,7 @@ "version": "5.17.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", "integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==", + "dev": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -8773,7 +8100,8 @@ "node_modules/es-module-lexer": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.1.tgz", - "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==" + "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==", + "dev": true }, "node_modules/esbuild": { "version": "0.20.2", @@ -8830,6 +8158,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "engines": { "node": ">=10" }, @@ -8841,6 +8170,7 @@ "version": "9.8.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", + "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", @@ -8899,17 +8229,6 @@ "eslint": ">=7.0.0" } }, - "node_modules/eslint-config-turbo": { - "version": "1.10.12", - "resolved": "https://registry.npmjs.org/eslint-config-turbo/-/eslint-config-turbo-1.10.12.tgz", - "integrity": "sha512-z3jfh+D7UGYlzMWGh+Kqz++hf8LOE96q3o5R8X4HTjmxaBWlLAWG+0Ounr38h+JLR2TJno0hU9zfzoPNkR9BdA==", - "dependencies": { - "eslint-plugin-turbo": "1.10.12" - }, - "peerDependencies": { - "eslint": ">6.6.0" - } - }, "node_modules/eslint-plugin-prettier": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", @@ -8940,25 +8259,6 @@ } } }, - "node_modules/eslint-plugin-turbo": { - "version": "1.10.12", - "resolved": "https://registry.npmjs.org/eslint-plugin-turbo/-/eslint-plugin-turbo-1.10.12.tgz", - "integrity": "sha512-uNbdj+ohZaYo4tFJ6dStRXu2FZigwulR1b3URPXe0Q8YaE7thuekKNP+54CHtZPH9Zey9dmDx5btAQl9mfzGOw==", - "dependencies": { - "dotenv": "16.0.3" - }, - "peerDependencies": { - "eslint": ">6.6.0" - } - }, - "node_modules/eslint-plugin-turbo/node_modules/dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", - "engines": { - "node": ">=12" - } - }, "node_modules/eslint-plugin-unicorn": { "version": "55.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-55.0.0.tgz", @@ -8996,6 +8296,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==", + "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -9011,6 +8312,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -9022,6 +8324,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -9037,6 +8340,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -9048,6 +8352,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -9058,12 +8363,14 @@ "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "node_modules/espree": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", + "dev": true, "dependencies": { "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", @@ -9080,6 +8387,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -9104,6 +8412,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -9115,6 +8424,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -9126,6 +8436,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "engines": { "node": ">=4.0" } @@ -9143,6 +8454,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -9360,7 +8672,8 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "node_modules/fast-diff": { "version": "1.3.0", @@ -9391,12 +8704,14 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true }, "node_modules/fast-safe-stringify": { "version": "2.1.1", @@ -9437,6 +8752,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -9497,6 +8813,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -9512,6 +8829,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -9523,7 +8841,8 @@ "node_modules/flatted": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==" + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true }, "node_modules/fluent-ffmpeg": { "version": "2.1.3", @@ -9604,41 +8923,6 @@ "node": ">= 0.6" } }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/framer-motion": { - "version": "10.17.4", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.17.4.tgz", - "integrity": "sha512-CYBSs6cWfzcasAX8aofgKFZootmkQtR4qxbfTOksBLny/lbUfkGbQAFOS3qnl6Uau1N9y8tUpI7mVIrHgkFjLQ==", - "dependencies": { - "tslib": "^2.4.0" - }, - "optionalDependencies": { - "@emotion/is-prop-valid": "^0.8.2" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -9874,14 +9158,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "engines": { - "node": ">=6" - } - }, "node_modules/get-port": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", @@ -9954,7 +9230,8 @@ "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.1", @@ -10303,6 +9580,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, "engines": { "node": ">= 4" } @@ -10337,6 +9615,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "engines": { "node": ">=0.8.19" } @@ -10394,14 +9673,6 @@ "node": ">=12.0.0" } }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/ioredis": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", @@ -10522,6 +9793,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -10652,6 +9924,7 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -10765,7 +10038,8 @@ "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -10781,7 +10055,8 @@ "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true }, "node_modules/json5": { "version": "2.2.3", @@ -10816,6 +10091,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "dependencies": { "json-buffer": "3.0.1" } @@ -10870,6 +10146,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -10887,6 +10164,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "peer": true, "engines": { "node": ">=10" } @@ -10909,6 +10187,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, "engines": { "node": ">=6.11.5" } @@ -10917,6 +10196,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -10976,6 +10256,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -11110,7 +10391,8 @@ "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true }, "node_modules/merge2": { "version": "1.4.1", @@ -11440,7 +10722,8 @@ "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true }, "node_modules/nearley": { "version": "2.20.1", @@ -11548,12 +10831,12 @@ } }, "node_modules/next": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/next/-/next-14.1.4.tgz", - "integrity": "sha512-1WTaXeSrUwlz/XcnhGTY7+8eiaFvdet5z9u3V2jb+Ek1vFo0VhHKSAIJvDWfQpttWjnyw14kBeq28TPq7bTeEQ==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", + "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==", "dependencies": { - "@next/env": "14.1.4", - "@swc/helpers": "0.5.2", + "@next/env": "14.2.3", + "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "graceful-fs": "^4.2.11", @@ -11567,18 +10850,19 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.1.4", - "@next/swc-darwin-x64": "14.1.4", - "@next/swc-linux-arm64-gnu": "14.1.4", - "@next/swc-linux-arm64-musl": "14.1.4", - "@next/swc-linux-x64-gnu": "14.1.4", - "@next/swc-linux-x64-musl": "14.1.4", - "@next/swc-win32-arm64-msvc": "14.1.4", - "@next/swc-win32-ia32-msvc": "14.1.4", - "@next/swc-win32-x64-msvc": "14.1.4" + "@next/swc-darwin-arm64": "14.2.3", + "@next/swc-darwin-x64": "14.2.3", + "@next/swc-linux-arm64-gnu": "14.2.3", + "@next/swc-linux-arm64-musl": "14.2.3", + "@next/swc-linux-x64-gnu": "14.2.3", + "@next/swc-linux-x64-musl": "14.2.3", + "@next/swc-win32-arm64-msvc": "14.2.3", + "@next/swc-win32-ia32-msvc": "14.2.3", + "@next/swc-win32-x64-msvc": "14.2.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", "react": "^18.2.0", "react-dom": "^18.2.0", "sass": "^1.3.0" @@ -11587,6 +10871,9 @@ "@opentelemetry/api": { "optional": true }, + "@playwright/test": { + "optional": true + }, "sass": { "optional": true } @@ -11719,14 +11006,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/notepack.io": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", @@ -11895,6 +11174,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, "dependencies": { "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", @@ -11941,6 +11221,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -11955,6 +11236,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -12049,6 +11331,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "engines": { "node": ">=8" } @@ -12263,6 +11546,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12271,6 +11555,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "peer": true, "engines": { "node": ">= 6" } @@ -12315,6 +11600,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "peer": true, "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", @@ -12331,6 +11617,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "peer": true, "dependencies": { "camelcase-css": "^2.0.1" }, @@ -12359,6 +11646,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" @@ -12383,6 +11671,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", + "peer": true, "engines": { "node": ">=14" }, @@ -12394,6 +11683,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "peer": true, "dependencies": { "postcss-selector-parser": "^6.0.11" }, @@ -12412,6 +11702,7 @@ "version": "6.0.16", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12423,7 +11714,8 @@ "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "peer": true }, "node_modules/postgres-array": { "version": "2.0.0", @@ -12469,6 +11761,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "engines": { "node": ">= 0.8.0" } @@ -12519,26 +11812,6 @@ } } }, - "node_modules/prism-react-renderer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.1.0.tgz", - "integrity": "sha512-I5cvXHjA1PVGbGm1MsWCpvBCRrYyxEri0MC7/JbfIfYfcXAxHyO5PaUjs3A8H5GW6kJcLhTHxxMaOZZpRZD2iQ==", - "dependencies": { - "@types/prismjs": "^1.26.0", - "clsx": "^1.2.1" - }, - "peerDependencies": { - "react": ">=16.0.0" - } - }, - "node_modules/prism-react-renderer/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "engines": { - "node": ">=6" - } - }, "node_modules/prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", @@ -12664,6 +11937,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true, "engines": { "node": ">=6" } @@ -12729,6 +12003,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -12759,6 +12034,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -12770,6 +12046,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12779,53 +12056,27 @@ } }, "node_modules/react-email": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-2.1.6.tgz", - "integrity": "sha512-BtR9VI1CMq4953wfiBmzupKlWcRThaWG2dDgl1vWAllK3tNNmJNerwY4VlmASRDQZE3LpLXU3+lf8N/VAKdbZQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-3.0.1.tgz", + "integrity": "sha512-G4Bkx2ULIScy/0Z8nnWywHt0W1iTkaYCdh9rWNuQ3eVZ6B3ttTUDE9uUy3VNQ8dtQbmG0cpt8+XmImw7mMBW6Q==", "dependencies": { "@babel/core": "7.24.5", "@babel/parser": "7.24.5", - "@radix-ui/colors": "1.0.1", - "@radix-ui/react-collapsible": "1.1.0", - "@radix-ui/react-popover": "1.1.1", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-toggle-group": "1.1.0", - "@radix-ui/react-tooltip": "1.1.1", - "@swc/core": "1.3.101", - "@types/react": "18.2.47", - "@types/react-dom": "^18.2.0", - "@types/webpack": "5.28.5", - "autoprefixer": "10.4.14", "chalk": "4.1.2", - "chokidar": "3.5.3", - "clsx": "2.1.0", + "chokidar": "3.6.0", "commander": "11.1.0", "debounce": "2.0.0", "esbuild": "0.19.11", - "eslint-config-prettier": "9.0.0", - "eslint-config-turbo": "1.10.12", - "framer-motion": "10.17.4", "glob": "10.3.4", "log-symbols": "4.1.0", "mime-types": "2.1.35", - "next": "14.1.4", + "next": "14.2.3", "normalize-path": "3.0.0", "ora": "5.4.1", - "postcss": "8.4.38", - "prism-react-renderer": "2.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "socket.io": "4.7.3", - "socket.io-client": "4.7.3", - "sonner": "1.3.1", - "source-map-js": "1.0.2", - "stacktrace-parser": "0.1.10", - "tailwind-merge": "2.2.0", - "tailwindcss": "3.4.0", - "typescript": "5.1.6" + "socket.io": "4.7.5" }, "bin": { - "email": "cli/index.js" + "email": "dist/cli/index.js" }, "engines": { "node": ">=18.0.0" @@ -13176,208 +12427,6 @@ "node": ">=12" } }, - "node_modules/react-email/node_modules/@swc/core": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.101.tgz", - "integrity": "sha512-w5aQ9qYsd/IYmXADAnkXPGDMTqkQalIi+kfFf/MHRKTpaOL7DHjMXwPp/n8hJ0qNjRvchzmPtOqtPBiER50d8A==", - "hasInstallScript": true, - "dependencies": { - "@swc/counter": "^0.1.1", - "@swc/types": "^0.1.5" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.3.101", - "@swc/core-darwin-x64": "1.3.101", - "@swc/core-linux-arm-gnueabihf": "1.3.101", - "@swc/core-linux-arm64-gnu": "1.3.101", - "@swc/core-linux-arm64-musl": "1.3.101", - "@swc/core-linux-x64-gnu": "1.3.101", - "@swc/core-linux-x64-musl": "1.3.101", - "@swc/core-win32-arm64-msvc": "1.3.101", - "@swc/core-win32-ia32-msvc": "1.3.101", - "@swc/core-win32-x64-msvc": "1.3.101" - }, - "peerDependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/react-email/node_modules/@swc/core-darwin-arm64": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.101.tgz", - "integrity": "sha512-mNFK+uHNPRXSnfTOG34zJOeMl2waM4hF4a2NY7dkMXrPqw9CoJn4MwTXJcyMiSz1/BnNjjTCHF3Yhj0jPxmkzQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-darwin-x64": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.101.tgz", - "integrity": "sha512-B085j8XOx73Fg15KsHvzYWG262bRweGr3JooO1aW5ec5pYbz5Ew9VS5JKYS03w2UBSxf2maWdbPz2UFAxg0whw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.101.tgz", - "integrity": "sha512-9xLKRb6zSzRGPqdz52Hy5GuB1lSjmLqa0lST6MTFads3apmx4Vgs8Y5NuGhx/h2I8QM4jXdLbpqQlifpzTlSSw==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.101.tgz", - "integrity": "sha512-oE+r1lo7g/vs96Weh2R5l971dt+ZLuhaUX+n3BfDdPxNHfObXgKMjO7E+QS5RbGjv/AwiPCxQmbdCp/xN5ICJA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-arm64-musl": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.101.tgz", - "integrity": "sha512-OGjYG3H4BMOTnJWJyBIovCez6KiHF30zMIu4+lGJTCrxRI2fAjGLml3PEXj8tC3FMcud7U2WUn6TdG0/te2k6g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-x64-gnu": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.101.tgz", - "integrity": "sha512-/kBMcoF12PRO/lwa8Z7w4YyiKDcXQEiLvM+S3G9EvkoKYGgkkz4Q6PSNhF5rwg/E3+Hq5/9D2R+6nrkF287ihg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-x64-musl": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.101.tgz", - "integrity": "sha512-kDN8lm4Eew0u1p+h1l3JzoeGgZPQ05qDE0czngnjmfpsH2sOZxVj1hdiCwS5lArpy7ktaLu5JdRnx70MkUzhXw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.101.tgz", - "integrity": "sha512-9Wn8TTLWwJKw63K/S+jjrZb9yoJfJwCE2RV5vPCCWmlMf3U1AXj5XuWOLUX+Rp2sGKau7wZKsvywhheWm+qndQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.101.tgz", - "integrity": "sha512-onO5KvICRVlu2xmr4//V2je9O2XgS1SGKpbX206KmmjcJhXN5EYLSxW9qgg+kgV5mip+sKTHTAu7IkzkAtElYA==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-win32-x64-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.101.tgz", - "integrity": "sha512-T3GeJtNQV00YmiVw/88/nxJ/H43CJvFnpvBHCVn17xbahiVUOPOduh3rc9LgAkKiNt/aV8vU3OJR+6PhfMR7UQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@types/react": { - "version": "18.2.47", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.47.tgz", - "integrity": "sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ==", - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/react-email/node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" - }, "node_modules/react-email/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -13386,32 +12435,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/react-email/node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/react-email/node_modules/commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -13457,17 +12480,6 @@ "@esbuild/win32-x64": "0.19.11" } }, - "node_modules/react-email/node_modules/eslint-config-prettier": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", - "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, "node_modules/react-email/node_modules/glob": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.4.tgz", @@ -13503,90 +12515,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/react-email/node_modules/socket.io": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.3.tgz", - "integrity": "sha512-SE+UIQXBQE+GPG2oszWMlsEmWtHVqw/h1VrYJGK5/MC7CH5p58N448HwIrtREcvR4jfdOJAY4ieQfxMr55qbbw==", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.3.2", - "engine.io": "~6.5.2", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/react-email/node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-email/node_modules/tailwindcss": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", - "integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.19.1", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/react-email/node_modules/tailwindcss/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/react-email/node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/react-promise-suspense": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", @@ -13600,77 +12528,11 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==" }, - "node_modules/react-remove-scroll": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", - "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", - "dependencies": { - "react-remove-scroll-bar": "^2.3.4", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", - "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", - "dependencies": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "dependencies": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "peer": true, "dependencies": { "pify": "^2.3.0" } @@ -13863,11 +12725,6 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -14251,6 +13108,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -14259,6 +13117,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", @@ -14276,6 +13135,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14291,6 +13151,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, "peerDependencies": { "ajv": "^6.9.1" } @@ -14298,7 +13159,8 @@ "node_modules/schema-utils/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "node_modules/selderee": { "version": "0.11.0", @@ -14367,6 +13229,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, "dependencies": { "randombytes": "^2.1.0" } @@ -14624,20 +13487,6 @@ } } }, - "node_modules/socket.io-client": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.3.tgz", - "integrity": "sha512-nU+ywttCyBitXIl9Xe0RSEfek4LneYkJxCeNnKCuhwoH4jGXO1ipIUw/VA/+Vvv2G1MTym11fzFC0SxkrcfXDw==", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.2", - "engine.io-client": "~6.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -14650,15 +13499,6 @@ "node": ">=10.0.0" } }, - "node_modules/sonner": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.3.1.tgz", - "integrity": "sha512-+rOAO56b2eI3q5BtgljERSn2umRk63KFIvgb2ohbZ5X+Eb5u+a/7/0ZgswYqgBMg8dyl7n6OXd9KasA8QF9ToA==", - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -14680,6 +13520,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -14689,6 +13530,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -14787,25 +13629,6 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, - "node_modules/stacktrace-parser": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.10.tgz", - "integrity": "sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg==", - "dependencies": { - "type-fest": "^0.7.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/stacktrace-parser/node_modules/type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", - "engines": { - "node": ">=8" - } - }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -14937,6 +13760,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "engines": { "node": ">=8" }, @@ -14970,6 +13794,7 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", @@ -15064,18 +13889,6 @@ "url": "https://www.buymeacoffee.com/systeminfo" } }, - "node_modules/tailwind-merge": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.2.0.tgz", - "integrity": "sha512-SqqhhaL0T06SW59+JVNfAqKdqLs0497esifRrZ7jOaefP3o64fdFNDMrAQWZFMxTLJPiHVjRLUywT8uFz1xNWQ==", - "dependencies": { - "@babel/runtime": "^7.23.5" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, "node_modules/tailwindcss": { "version": "3.4.6", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz", @@ -15166,6 +13979,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, "engines": { "node": ">=6" } @@ -15238,6 +14052,7 @@ "version": "5.27.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", + "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -15255,6 +14070,7 @@ "version": "5.3.10", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.20", "jest-worker": "^27.4.5", @@ -15288,6 +14104,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -15301,6 +14118,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -15314,7 +14132,8 @@ "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true }, "node_modules/test-exclude": { "version": "7.0.1", @@ -15403,7 +14222,8 @@ "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true }, "node_modules/thenify": { "version": "3.3.1", @@ -15550,7 +14370,8 @@ "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "peer": true }, "node_modules/ts-node": { "version": "10.9.2", @@ -15668,6 +14489,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -16042,51 +14864,11 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "dependencies": { "punycode": "^2.1.0" } }, - "node_modules/use-callback-ref": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", - "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/utf8-byte-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", @@ -16343,6 +15125,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", + "dev": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -16368,6 +15151,7 @@ "version": "5.92.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==", + "dev": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", @@ -16423,6 +15207,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, "engines": { "node": ">=10.13.0" } @@ -16437,6 +15222,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -16449,6 +15235,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, "engines": { "node": ">=4.0" } @@ -16560,14 +15347,6 @@ } } }, - "node_modules/xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -16644,6 +15423,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "engines": { "node": ">=10" }, @@ -16707,12 +15487,14 @@ "@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==" + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true }, "@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==" + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "peer": true }, "@ampproject/remapping": { "version": "2.3.0", @@ -17050,14 +15832,6 @@ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==" }, - "@babel/runtime": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz", - "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==", - "requires": { - "regenerator-runtime": "^0.14.0" - } - }, "@babel/template": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.6.tgz", @@ -17165,21 +15939,6 @@ "tslib": "^2.4.0" } }, - "@emotion/is-prop-valid": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", - "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "optional": true, - "requires": { - "@emotion/memoize": "0.7.4" - } - }, - "@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "optional": true - }, "@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -17345,6 +16104,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, "requires": { "eslint-visitor-keys": "^3.3.0" } @@ -17352,12 +16112,14 @@ "@eslint-community/regexpp": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", - "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==" + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "dev": true }, "@eslint/config-array": { "version": "0.17.1", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.1.tgz", "integrity": "sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==", + "dev": true, "requires": { "@eslint/object-schema": "^2.1.4", "debug": "^4.3.1", @@ -17368,6 +16130,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, "requires": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -17384,6 +16147,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -17394,24 +16158,28 @@ "globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==" + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true } } }, "@eslint/js": { "version": "9.8.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", - "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==" + "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", + "dev": true }, "@eslint/object-schema": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", - "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==" + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true }, "@fastify/busboy": { "version": "2.1.1", @@ -17419,36 +16187,6 @@ "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", "dev": true }, - "@floating-ui/core": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.4.tgz", - "integrity": "sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA==", - "requires": { - "@floating-ui/utils": "^0.2.4" - } - }, - "@floating-ui/dom": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.7.tgz", - "integrity": "sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng==", - "requires": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.4" - } - }, - "@floating-ui/react-dom": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.1.tgz", - "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==", - "requires": { - "@floating-ui/dom": "^1.0.0" - } - }, - "@floating-ui/utils": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.4.tgz", - "integrity": "sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==" - }, "@golevelup/nestjs-discovery": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.1.tgz", @@ -17529,12 +16267,14 @@ "@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==" + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true }, "@humanwhocodes/retry": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", - "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==" + "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==", + "dev": true }, "@img/sharp-darwin-arm64": { "version": "0.33.4", @@ -17770,6 +16510,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, "requires": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -18056,62 +16797,62 @@ } }, "@next/env": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.4.tgz", - "integrity": "sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==" + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", + "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==" }, "@next/swc-darwin-arm64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.4.tgz", - "integrity": "sha512-ubmUkbmW65nIAOmoxT1IROZdmmJMmdYvXIe8211send9ZYJu+SqxSnJM4TrPj9wmL6g9Atvj0S/2cFmMSS99jg==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz", + "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==", "optional": true }, "@next/swc-darwin-x64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.4.tgz", - "integrity": "sha512-b0Xo1ELj3u7IkZWAKcJPJEhBop117U78l70nfoQGo4xUSvv0PJSTaV4U9xQBLvZlnjsYkc8RwQN1HoH/oQmLlQ==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz", + "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==", "optional": true }, "@next/swc-linux-arm64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.4.tgz", - "integrity": "sha512-457G0hcLrdYA/u1O2XkRMsDKId5VKe3uKPvrKVOyuARa6nXrdhJOOYU9hkKKyQTMru1B8qEP78IAhf/1XnVqKA==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz", + "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==", "optional": true }, "@next/swc-linux-arm64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.4.tgz", - "integrity": "sha512-l/kMG+z6MB+fKA9KdtyprkTQ1ihlJcBh66cf0HvqGP+rXBbOXX0dpJatjZbHeunvEHoBBS69GYQG5ry78JMy3g==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz", + "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==", "optional": true }, "@next/swc-linux-x64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.4.tgz", - "integrity": "sha512-BapIFZ3ZRnvQ1uWbmqEGJuPT9cgLwvKtxhK/L2t4QYO7l+/DxXuIGjvp1x8rvfa/x1FFSsipERZK70pewbtJtw==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz", + "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==", "optional": true }, "@next/swc-linux-x64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.4.tgz", - "integrity": "sha512-mqVxTwk4XuBl49qn2A5UmzFImoL1iLm0KQQwtdRJRKl21ylQwwGCxJtIYo2rbfkZHoSKlh/YgztY0qH3wG1xIg==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz", + "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==", "optional": true }, "@next/swc-win32-arm64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.4.tgz", - "integrity": "sha512-xzxF4ErcumXjO2Pvg/wVGrtr9QQJLk3IyQX1ddAC/fi6/5jZCZ9xpuL9Tzc4KPWMFq8GGWFVDMshZOdHGdkvag==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz", + "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==", "optional": true }, "@next/swc-win32-ia32-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.4.tgz", - "integrity": "sha512-WZiz8OdbkpRw6/IU/lredZWKKZopUMhcI2F+XiMAcPja0uZYdMTZQRoQ0WZcvinn9xZAidimE7tN9W5v9Yyfyw==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz", + "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==", "optional": true }, "@next/swc-win32-x64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.4.tgz", - "integrity": "sha512-4Rto21sPfw555sZ/XNLqfxDUNeLhNYGO2dlPqsnuCg8N8a2a9u1ltqBOPQ4vj1Gf7eJC0W2hHG2eYUHuiXgY2w==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz", + "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==", "optional": true }, "@nodelib/fs.scandir": { @@ -19267,185 +18008,12 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, - "@radix-ui/colors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-1.0.1.tgz", - "integrity": "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg==" - }, - "@radix-ui/primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", - "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" - }, - "@radix-ui/react-arrow": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", - "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", - "requires": { - "@radix-ui/react-primitive": "2.0.0" - } - }, - "@radix-ui/react-collapsible": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.0.tgz", - "integrity": "sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-collection": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", - "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", - "requires": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0" - } - }, "@radix-ui/react-compose-refs": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", "requires": {} }, - "@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", - "requires": {} - }, - "@radix-ui/react-direction": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", - "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", - "requires": {} - }, - "@radix-ui/react-dismissable-layer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz", - "integrity": "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-escape-keydown": "1.1.0" - } - }, - "@radix-ui/react-focus-guards": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz", - "integrity": "sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==", - "requires": {} - }, - "@radix-ui/react-focus-scope": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", - "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", - "requires": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0" - } - }, - "@radix-ui/react-id": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", - "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", - "requires": { - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-popover": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz", - "integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-focus-guards": "1.1.0", - "@radix-ui/react-focus-scope": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.7" - } - }, - "@radix-ui/react-popper": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", - "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", - "requires": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-use-rect": "1.1.0", - "@radix-ui/react-use-size": "1.1.0", - "@radix-ui/rect": "1.1.0" - } - }, - "@radix-ui/react-portal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", - "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", - "requires": { - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-presence": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", - "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", - "requires": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-primitive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", - "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", - "requires": { - "@radix-ui/react-slot": "1.1.0" - } - }, - "@radix-ui/react-roving-focus": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", - "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - } - }, "@radix-ui/react-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", @@ -19454,106 +18022,6 @@ "@radix-ui/react-compose-refs": "1.1.0" } }, - "@radix-ui/react-toggle": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz", - "integrity": "sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - } - }, - "@radix-ui/react-toggle-group": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.0.tgz", - "integrity": "sha512-PpTJV68dZU2oqqgq75Uzto5o/XfOVgkrJ9rulVmfTKxWp3HfUjHE6CP/WLRR4AzPX9HWxw7vFow2me85Yu+Naw==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-toggle": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - } - }, - "@radix-ui/react-tooltip": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.1.tgz", - "integrity": "sha512-LLE8nzNE4MzPMw3O2zlVlkLFid3y9hMUs7uCbSHyKSo+tCN4yMCf+ZCCcfrYgsOC0TiHBPQ1mtpJ2liY3ZT3SQ==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0" - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", - "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", - "requires": {} - }, - "@radix-ui/react-use-controllable-state": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", - "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", - "requires": { - "@radix-ui/react-use-callback-ref": "1.1.0" - } - }, - "@radix-ui/react-use-escape-keydown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", - "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", - "requires": { - "@radix-ui/react-use-callback-ref": "1.1.0" - } - }, - "@radix-ui/react-use-layout-effect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", - "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", - "requires": {} - }, - "@radix-ui/react-use-rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", - "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", - "requires": { - "@radix-ui/rect": "1.1.0" - } - }, - "@radix-ui/react-use-size": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", - "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", - "requires": { - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-visually-hidden": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", - "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", - "requires": { - "@radix-ui/react-primitive": "2.0.0" - } - }, - "@radix-ui/rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", - "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" - }, "@react-email/body": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.9.tgz", @@ -19991,10 +18459,11 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "@swc/helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", - "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", "requires": { + "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, @@ -20002,6 +18471,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.12.tgz", "integrity": "sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==", + "devOptional": true, "requires": { "@swc/counter": "^0.1.3" } @@ -20173,6 +18643,7 @@ "version": "8.44.3", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.3.tgz", "integrity": "sha512-iM/WfkwAhwmPff3wZuPLYiHX18HI24jU8k1ZSH7P8FHwxTjZ2P6CoX2wnF43oprR+YXJM6UUxATkNvyv/JHd+g==", + "dev": true, "requires": { "@types/estree": "*", "@types/json-schema": "*" @@ -20182,6 +18653,7 @@ "version": "3.7.5", "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.5.tgz", "integrity": "sha512-JNvhIEyxVW6EoMIFIvj93ZOywYFatlpu9deeH6eSx6PE3WHYvHaQtmHmQeNw7aA81bYGBPPQqdtBm6b1SsQMmA==", + "dev": true, "requires": { "@types/eslint": "*", "@types/estree": "*" @@ -20190,7 +18662,8 @@ "@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true }, "@types/express": { "version": "4.17.21", @@ -20250,7 +18723,8 @@ "@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true }, "@types/lodash": { "version": "4.17.7", @@ -20395,15 +18869,12 @@ "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", "dev": true }, - "@types/prismjs": { - "version": "1.26.3", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.3.tgz", - "integrity": "sha512-A0D0aTXvjlqJ5ZILMz3rNfDBOx9hHxLZYv2by47Sm/pqW35zzjusrZTryatjN/Rf8Us2gZrJD+KeHbUSTux1Cw==" - }, "@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", + "optional": true, + "peer": true }, "@types/qs": { "version": "6.9.8", @@ -20421,19 +18892,13 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", + "optional": true, + "peer": true, "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, - "@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "requires": { - "@types/react": "*" - } - }, "@types/readdir-glob": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.2.tgz", @@ -20443,11 +18908,6 @@ "@types/node": "*" } }, - "@types/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==" - }, "@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -20548,16 +19008,6 @@ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.8.tgz", "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, - "@types/webpack": { - "version": "5.28.5", - "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", - "integrity": "sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==", - "requires": { - "@types/node": "*", - "tapable": "^2.2.0", - "webpack": "^5" - } - }, "@typescript-eslint/eslint-plugin": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", @@ -20783,6 +19233,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "dev": true, "requires": { "@webassemblyjs/helper-numbers": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6" @@ -20791,22 +19242,26 @@ "@webassemblyjs/floating-point-hex-parser": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==" + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true }, "@webassemblyjs/helper-api-error": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true }, "@webassemblyjs/helper-buffer": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==" + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "dev": true }, "@webassemblyjs/helper-numbers": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, "requires": { "@webassemblyjs/floating-point-hex-parser": "1.11.6", "@webassemblyjs/helper-api-error": "1.11.6", @@ -20816,12 +19271,14 @@ "@webassemblyjs/helper-wasm-bytecode": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true }, "@webassemblyjs/helper-wasm-section": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -20833,6 +19290,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, "requires": { "@xtuc/ieee754": "^1.2.0" } @@ -20841,6 +19299,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, "requires": { "@xtuc/long": "4.2.2" } @@ -20848,12 +19307,14 @@ "@webassemblyjs/utf8": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true }, "@webassemblyjs/wasm-edit": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -20869,6 +19330,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", @@ -20881,6 +19343,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -20892,6 +19355,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", @@ -20905,6 +19369,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" @@ -20913,12 +19378,14 @@ "@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true }, "@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true }, "abbrev": { "version": "1.1.1", @@ -20957,6 +19424,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "requires": {} }, "acorn-walk": { @@ -21165,14 +19633,6 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, - "aria-hidden": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", - "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", - "requires": { - "tslib": "^2.0.0" - } - }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -21220,19 +19680,6 @@ "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==" }, - "autoprefixer": { - "version": "10.4.14", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", - "integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==", - "requires": { - "browserslist": "^4.21.5", - "caniuse-lite": "^1.0.30001464", - "fraction.js": "^4.2.0", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - } - }, "b4a": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", @@ -21528,7 +19975,8 @@ "camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==" + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "peer": true }, "caniuse-lite": { "version": "1.0.30001618", @@ -21591,7 +20039,8 @@ "chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==" + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true }, "ci-info": { "version": "4.0.0", @@ -21709,11 +20158,6 @@ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==" }, - "clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==" - }, "cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -22006,12 +20450,15 @@ "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==" + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "peer": true }, "csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "optional": true, + "peer": true }, "dayjs": { "version": "1.11.10", @@ -22040,7 +20487,8 @@ "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true }, "deepmerge": { "version": "4.3.1", @@ -22091,11 +20539,6 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==" }, - "detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, "diacritics": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", @@ -22104,7 +20547,8 @@ "didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "peer": true }, "diff": { "version": "4.0.2", @@ -22131,7 +20575,8 @@ "dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "peer": true }, "docker-compose": { "version": "0.24.8", @@ -22326,18 +20771,6 @@ "ws": "~8.11.0" } }, - "engine.io-client": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", - "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", - "requires": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.11.0", - "xmlhttprequest-ssl": "~2.0.0" - } - }, "engine.io-parser": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", @@ -22347,6 +20780,7 @@ "version": "5.17.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", "integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==", + "dev": true, "requires": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -22381,7 +20815,8 @@ "es-module-lexer": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.1.tgz", - "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==" + "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==", + "dev": true }, "esbuild": { "version": "0.20.2", @@ -22427,12 +20862,14 @@ "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true }, "eslint": { "version": "9.8.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", + "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", @@ -22474,6 +20911,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -22484,12 +20922,14 @@ "eslint-visitor-keys": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", - "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==" + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true }, "glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "requires": { "is-glob": "^4.0.3" } @@ -22497,7 +20937,8 @@ "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true } } }, @@ -22508,14 +20949,6 @@ "dev": true, "requires": {} }, - "eslint-config-turbo": { - "version": "1.10.12", - "resolved": "https://registry.npmjs.org/eslint-config-turbo/-/eslint-config-turbo-1.10.12.tgz", - "integrity": "sha512-z3jfh+D7UGYlzMWGh+Kqz++hf8LOE96q3o5R8X4HTjmxaBWlLAWG+0Ounr38h+JLR2TJno0hU9zfzoPNkR9BdA==", - "requires": { - "eslint-plugin-turbo": "1.10.12" - } - }, "eslint-plugin-prettier": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", @@ -22526,21 +20959,6 @@ "synckit": "^0.9.1" } }, - "eslint-plugin-turbo": { - "version": "1.10.12", - "resolved": "https://registry.npmjs.org/eslint-plugin-turbo/-/eslint-plugin-turbo-1.10.12.tgz", - "integrity": "sha512-uNbdj+ohZaYo4tFJ6dStRXu2FZigwulR1b3URPXe0Q8YaE7thuekKNP+54CHtZPH9Zey9dmDx5btAQl9mfzGOw==", - "requires": { - "dotenv": "16.0.3" - }, - "dependencies": { - "dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==" - } - } - }, "eslint-plugin-unicorn": { "version": "55.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-55.0.0.tgz", @@ -22569,6 +20987,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==", + "dev": true, "requires": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -22577,12 +20996,14 @@ "eslint-visitor-keys": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==" + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true }, "espree": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", + "dev": true, "requires": { "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", @@ -22592,7 +21013,8 @@ "eslint-visitor-keys": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", - "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==" + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true } } }, @@ -22606,6 +21028,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, "requires": { "estraverse": "^5.1.0" } @@ -22614,6 +21037,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "requires": { "estraverse": "^5.2.0" } @@ -22621,7 +21045,8 @@ "estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true }, "estree-walker": { "version": "3.0.3", @@ -22635,7 +21060,8 @@ "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true }, "etag": { "version": "1.8.1", @@ -22804,7 +21230,8 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "fast-diff": { "version": "1.3.0", @@ -22832,12 +21259,14 @@ "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, "fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true }, "fast-safe-stringify": { "version": "2.1.1", @@ -22871,6 +21300,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, "requires": { "flat-cache": "^4.0.0" } @@ -22924,6 +21354,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "requires": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -22933,6 +21364,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, "requires": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -22941,7 +21373,8 @@ "flatted": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==" + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true }, "fluent-ffmpeg": { "version": "2.1.3", @@ -23001,20 +21434,6 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" }, - "fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==" - }, - "framer-motion": { - "version": "10.17.4", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.17.4.tgz", - "integrity": "sha512-CYBSs6cWfzcasAX8aofgKFZootmkQtR4qxbfTOksBLny/lbUfkGbQAFOS3qnl6Uau1N9y8tUpI7mVIrHgkFjLQ==", - "requires": { - "@emotion/is-prop-valid": "^0.8.2", - "tslib": "^2.4.0" - } - }, "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -23193,11 +21612,6 @@ "hasown": "^2.0.0" } }, - "get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" - }, "get-port": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", @@ -23267,7 +21681,8 @@ "glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true }, "globals": { "version": "15.9.0", @@ -23479,7 +21894,8 @@ "ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==" + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true }, "import-fresh": { "version": "3.3.0", @@ -23504,7 +21920,8 @@ "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true }, "indent-string": { "version": "4.0.0", @@ -23553,14 +21970,6 @@ "wrap-ansi": "^6.0.1" } }, - "invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "requires": { - "loose-envify": "^1.0.0" - } - }, "ioredis": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", @@ -23643,7 +22052,8 @@ "is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==" + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true }, "is-stream": { "version": "2.0.1", @@ -23731,7 +22141,8 @@ "jiti": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==" + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "peer": true }, "joi": { "version": "17.13.3", @@ -23812,7 +22223,8 @@ "json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true }, "json-parse-even-better-errors": { "version": "2.3.1", @@ -23828,7 +22240,8 @@ "json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true }, "json5": { "version": "2.2.3", @@ -23855,6 +22268,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "requires": { "json-buffer": "3.0.1" } @@ -23905,6 +22319,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "requires": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -23918,7 +22333,8 @@ "lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==" + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "peer": true }, "lines-and-columns": { "version": "1.2.4", @@ -23934,12 +22350,14 @@ "loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==" + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "requires": { "p-locate": "^5.0.0" } @@ -23987,6 +22405,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, "requires": { "js-tokens": "^3.0.0 || ^4.0.0" } @@ -24090,7 +22509,8 @@ "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true }, "merge2": { "version": "1.4.1", @@ -24345,7 +22765,8 @@ "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true }, "nearley": { "version": "2.20.1", @@ -24421,21 +22842,21 @@ } }, "next": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/next/-/next-14.1.4.tgz", - "integrity": "sha512-1WTaXeSrUwlz/XcnhGTY7+8eiaFvdet5z9u3V2jb+Ek1vFo0VhHKSAIJvDWfQpttWjnyw14kBeq28TPq7bTeEQ==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", + "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==", "requires": { - "@next/env": "14.1.4", - "@next/swc-darwin-arm64": "14.1.4", - "@next/swc-darwin-x64": "14.1.4", - "@next/swc-linux-arm64-gnu": "14.1.4", - "@next/swc-linux-arm64-musl": "14.1.4", - "@next/swc-linux-x64-gnu": "14.1.4", - "@next/swc-linux-x64-musl": "14.1.4", - "@next/swc-win32-arm64-msvc": "14.1.4", - "@next/swc-win32-ia32-msvc": "14.1.4", - "@next/swc-win32-x64-msvc": "14.1.4", - "@swc/helpers": "0.5.2", + "@next/env": "14.2.3", + "@next/swc-darwin-arm64": "14.2.3", + "@next/swc-darwin-x64": "14.2.3", + "@next/swc-linux-arm64-gnu": "14.2.3", + "@next/swc-linux-arm64-musl": "14.2.3", + "@next/swc-linux-x64-gnu": "14.2.3", + "@next/swc-linux-x64-musl": "14.2.3", + "@next/swc-win32-arm64-msvc": "14.2.3", + "@next/swc-win32-ia32-msvc": "14.2.3", + "@next/swc-win32-x64-msvc": "14.2.3", + "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "graceful-fs": "^4.2.11", @@ -24526,11 +22947,6 @@ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" }, - "normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==" - }, "notepack.io": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", @@ -24658,6 +23074,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, "requires": { "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", @@ -24692,6 +23109,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "requires": { "yocto-queue": "^0.1.0" } @@ -24700,6 +23118,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "requires": { "p-limit": "^3.0.2" } @@ -24771,7 +23190,8 @@ "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true }, "path-is-absolute": { "version": "1.0.1", @@ -24927,12 +23347,14 @@ "pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==" + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "peer": true }, "pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==" + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "peer": true }, "pluralize": { "version": "8.0.0", @@ -24954,6 +23376,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "peer": true, "requires": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", @@ -24964,6 +23387,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "peer": true, "requires": { "camelcase-css": "^2.0.1" } @@ -24972,6 +23396,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "peer": true, "requires": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" @@ -24980,7 +23405,8 @@ "lilconfig": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", - "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==" + "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", + "peer": true } } }, @@ -24988,6 +23414,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "peer": true, "requires": { "postcss-selector-parser": "^6.0.11" } @@ -24996,6 +23423,7 @@ "version": "6.0.16", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", + "peer": true, "requires": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -25004,7 +23432,8 @@ "postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "peer": true }, "postgres-array": { "version": "2.0.0", @@ -25037,7 +23466,8 @@ "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true }, "prettier": { "version": "3.3.3", @@ -25060,22 +23490,6 @@ "dev": true, "requires": {} }, - "prism-react-renderer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.1.0.tgz", - "integrity": "sha512-I5cvXHjA1PVGbGm1MsWCpvBCRrYyxEri0MC7/JbfIfYfcXAxHyO5PaUjs3A8H5GW6kJcLhTHxxMaOZZpRZD2iQ==", - "requires": { - "@types/prismjs": "^1.26.0", - "clsx": "^1.2.1" - }, - "dependencies": { - "clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==" - } - } - }, "prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", @@ -25178,7 +23592,8 @@ "punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==" + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true }, "qs": { "version": "6.11.0", @@ -25218,6 +23633,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, "requires": { "safe-buffer": "^5.1.0" } @@ -25242,6 +23658,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "requires": { "loose-envify": "^1.1.0" } @@ -25250,56 +23667,31 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" } }, "react-email": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-2.1.6.tgz", - "integrity": "sha512-BtR9VI1CMq4953wfiBmzupKlWcRThaWG2dDgl1vWAllK3tNNmJNerwY4VlmASRDQZE3LpLXU3+lf8N/VAKdbZQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-3.0.1.tgz", + "integrity": "sha512-G4Bkx2ULIScy/0Z8nnWywHt0W1iTkaYCdh9rWNuQ3eVZ6B3ttTUDE9uUy3VNQ8dtQbmG0cpt8+XmImw7mMBW6Q==", "requires": { "@babel/core": "7.24.5", "@babel/parser": "7.24.5", - "@radix-ui/colors": "1.0.1", - "@radix-ui/react-collapsible": "1.1.0", - "@radix-ui/react-popover": "1.1.1", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-toggle-group": "1.1.0", - "@radix-ui/react-tooltip": "1.1.1", - "@swc/core": "1.3.101", - "@types/react": "18.2.47", - "@types/react-dom": "^18.2.0", - "@types/webpack": "5.28.5", - "autoprefixer": "10.4.14", "chalk": "4.1.2", - "chokidar": "3.5.3", - "clsx": "2.1.0", + "chokidar": "3.6.0", "commander": "11.1.0", "debounce": "2.0.0", "esbuild": "0.19.11", - "eslint-config-prettier": "9.0.0", - "eslint-config-turbo": "1.10.12", - "framer-motion": "10.17.4", "glob": "10.3.4", "log-symbols": "4.1.0", "mime-types": "2.1.35", - "next": "14.1.4", + "next": "14.2.3", "normalize-path": "3.0.0", "ora": "5.4.1", - "postcss": "8.4.38", - "prism-react-renderer": "2.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "socket.io": "4.7.3", - "socket.io-client": "4.7.3", - "sonner": "1.3.1", - "source-map-js": "1.0.2", - "stacktrace-parser": "0.1.10", - "tailwind-merge": "2.2.0", - "tailwindcss": "3.4.0", - "typescript": "5.1.6" + "socket.io": "4.7.5" }, "dependencies": { "@esbuild/aix-ppc64": { @@ -25440,100 +23832,6 @@ "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", "optional": true }, - "@swc/core": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.101.tgz", - "integrity": "sha512-w5aQ9qYsd/IYmXADAnkXPGDMTqkQalIi+kfFf/MHRKTpaOL7DHjMXwPp/n8hJ0qNjRvchzmPtOqtPBiER50d8A==", - "requires": { - "@swc/core-darwin-arm64": "1.3.101", - "@swc/core-darwin-x64": "1.3.101", - "@swc/core-linux-arm-gnueabihf": "1.3.101", - "@swc/core-linux-arm64-gnu": "1.3.101", - "@swc/core-linux-arm64-musl": "1.3.101", - "@swc/core-linux-x64-gnu": "1.3.101", - "@swc/core-linux-x64-musl": "1.3.101", - "@swc/core-win32-arm64-msvc": "1.3.101", - "@swc/core-win32-ia32-msvc": "1.3.101", - "@swc/core-win32-x64-msvc": "1.3.101", - "@swc/counter": "^0.1.1", - "@swc/types": "^0.1.5" - } - }, - "@swc/core-darwin-arm64": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.101.tgz", - "integrity": "sha512-mNFK+uHNPRXSnfTOG34zJOeMl2waM4hF4a2NY7dkMXrPqw9CoJn4MwTXJcyMiSz1/BnNjjTCHF3Yhj0jPxmkzQ==", - "optional": true - }, - "@swc/core-darwin-x64": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.101.tgz", - "integrity": "sha512-B085j8XOx73Fg15KsHvzYWG262bRweGr3JooO1aW5ec5pYbz5Ew9VS5JKYS03w2UBSxf2maWdbPz2UFAxg0whw==", - "optional": true - }, - "@swc/core-linux-arm-gnueabihf": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.101.tgz", - "integrity": "sha512-9xLKRb6zSzRGPqdz52Hy5GuB1lSjmLqa0lST6MTFads3apmx4Vgs8Y5NuGhx/h2I8QM4jXdLbpqQlifpzTlSSw==", - "optional": true - }, - "@swc/core-linux-arm64-gnu": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.101.tgz", - "integrity": "sha512-oE+r1lo7g/vs96Weh2R5l971dt+ZLuhaUX+n3BfDdPxNHfObXgKMjO7E+QS5RbGjv/AwiPCxQmbdCp/xN5ICJA==", - "optional": true - }, - "@swc/core-linux-arm64-musl": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.101.tgz", - "integrity": "sha512-OGjYG3H4BMOTnJWJyBIovCez6KiHF30zMIu4+lGJTCrxRI2fAjGLml3PEXj8tC3FMcud7U2WUn6TdG0/te2k6g==", - "optional": true - }, - "@swc/core-linux-x64-gnu": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.101.tgz", - "integrity": "sha512-/kBMcoF12PRO/lwa8Z7w4YyiKDcXQEiLvM+S3G9EvkoKYGgkkz4Q6PSNhF5rwg/E3+Hq5/9D2R+6nrkF287ihg==", - "optional": true - }, - "@swc/core-linux-x64-musl": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.101.tgz", - "integrity": "sha512-kDN8lm4Eew0u1p+h1l3JzoeGgZPQ05qDE0czngnjmfpsH2sOZxVj1hdiCwS5lArpy7ktaLu5JdRnx70MkUzhXw==", - "optional": true - }, - "@swc/core-win32-arm64-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.101.tgz", - "integrity": "sha512-9Wn8TTLWwJKw63K/S+jjrZb9yoJfJwCE2RV5vPCCWmlMf3U1AXj5XuWOLUX+Rp2sGKau7wZKsvywhheWm+qndQ==", - "optional": true - }, - "@swc/core-win32-ia32-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.101.tgz", - "integrity": "sha512-onO5KvICRVlu2xmr4//V2je9O2XgS1SGKpbX206KmmjcJhXN5EYLSxW9qgg+kgV5mip+sKTHTAu7IkzkAtElYA==", - "optional": true - }, - "@swc/core-win32-x64-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.101.tgz", - "integrity": "sha512-T3GeJtNQV00YmiVw/88/nxJ/H43CJvFnpvBHCVn17xbahiVUOPOduh3rc9LgAkKiNt/aV8vU3OJR+6PhfMR7UQ==", - "optional": true - }, - "@types/react": { - "version": "18.2.47", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.47.tgz", - "integrity": "sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ==", - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" - }, "brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -25542,21 +23840,6 @@ "balanced-match": "^1.0.0" } }, - "chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "requires": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "fsevents": "~2.3.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - } - }, "commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -25592,12 +23875,6 @@ "@esbuild/win32-x64": "0.19.11" } }, - "eslint-config-prettier": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", - "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", - "requires": {} - }, "glob": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.4.tgz", @@ -25617,69 +23894,6 @@ "requires": { "brace-expansion": "^2.0.1" } - }, - "socket.io": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.3.tgz", - "integrity": "sha512-SE+UIQXBQE+GPG2oszWMlsEmWtHVqw/h1VrYJGK5/MC7CH5p58N448HwIrtREcvR4jfdOJAY4ieQfxMr55qbbw==", - "requires": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.3.2", - "engine.io": "~6.5.2", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - } - }, - "source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" - }, - "tailwindcss": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", - "integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==", - "requires": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.19.1", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "dependencies": { - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "requires": { - "is-glob": "^4.0.3" - } - } - } - }, - "typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==" } } }, @@ -25698,41 +23912,11 @@ } } }, - "react-remove-scroll": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", - "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", - "requires": { - "react-remove-scroll-bar": "^2.3.4", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - } - }, - "react-remove-scroll-bar": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", - "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", - "requires": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - } - }, - "react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "requires": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - } - }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "peer": true, "requires": { "pify": "^2.3.0" } @@ -25882,11 +24066,6 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, - "regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -26148,6 +24327,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "peer": true, "requires": { "loose-envify": "^1.1.0" } @@ -26156,6 +24336,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, "requires": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", @@ -26166,6 +24347,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -26177,12 +24359,14 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, "requires": {} }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true } } }, @@ -26245,6 +24429,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, "requires": { "randombytes": "^2.1.0" } @@ -26447,17 +24632,6 @@ } } }, - "socket.io-client": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.3.tgz", - "integrity": "sha512-nU+ywttCyBitXIl9Xe0RSEfek4LneYkJxCeNnKCuhwoH4jGXO1ipIUw/VA/+Vvv2G1MTym11fzFC0SxkrcfXDw==", - "requires": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.2", - "engine.io-client": "~6.5.2", - "socket.io-parser": "~4.2.4" - } - }, "socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -26467,12 +24641,6 @@ "debug": "~4.3.1" } }, - "sonner": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.3.1.tgz", - "integrity": "sha512-+rOAO56b2eI3q5BtgljERSn2umRk63KFIvgb2ohbZ5X+Eb5u+a/7/0ZgswYqgBMg8dyl7n6OXd9KasA8QF9ToA==", - "requires": {} - }, "source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -26488,6 +24656,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "requires": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -26496,7 +24665,8 @@ "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true } } }, @@ -26582,21 +24752,6 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, - "stacktrace-parser": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.10.tgz", - "integrity": "sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg==", - "requires": { - "type-fest": "^0.7.1" - }, - "dependencies": { - "type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==" - } - } - }, "standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -26696,7 +24851,8 @@ "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true }, "styled-jsx": { "version": "5.1.1", @@ -26710,6 +24866,7 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "peer": true, "requires": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", @@ -26759,14 +24916,6 @@ "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.22.0.tgz", "integrity": "sha512-oAP80ymt8ssrAzjX8k3frbL7ys6AotqC35oikG6/SG15wBw+tG9nCk4oPaXIhEaAOAZ8XngxUv3ORq2IuR3r4Q==" }, - "tailwind-merge": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.2.0.tgz", - "integrity": "sha512-SqqhhaL0T06SW59+JVNfAqKdqLs0497esifRrZ7jOaefP3o64fdFNDMrAQWZFMxTLJPiHVjRLUywT8uFz1xNWQ==", - "requires": { - "@babel/runtime": "^7.23.5" - } - }, "tailwindcss": { "version": "3.4.6", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz", @@ -26838,7 +24987,8 @@ "tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==" + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true }, "tar": { "version": "6.2.0", @@ -26896,6 +25046,7 @@ "version": "5.27.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", + "dev": true, "requires": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -26906,7 +25057,8 @@ "commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true } } }, @@ -26914,6 +25066,7 @@ "version": "5.3.10", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, "requires": { "@jridgewell/trace-mapping": "^0.3.20", "jest-worker": "^27.4.5", @@ -26926,6 +25079,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, "requires": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -26936,6 +25090,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -27020,7 +25175,8 @@ "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true }, "thenify": { "version": "3.3.1", @@ -27132,7 +25288,8 @@ "ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "peer": true }, "ts-node": { "version": "10.9.2", @@ -27208,6 +25365,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "requires": { "prelude-ls": "^1.2.1" } @@ -27389,27 +25547,11 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "requires": { "punycode": "^2.1.0" } }, - "use-callback-ref": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", - "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", - "requires": { - "tslib": "^2.0.0" - } - }, - "use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "requires": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - } - }, "utf8-byte-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", @@ -27553,6 +25695,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", + "dev": true, "requires": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -27575,6 +25718,7 @@ "version": "5.92.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==", + "dev": true, "requires": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", @@ -27606,6 +25750,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, "requires": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -27614,7 +25759,8 @@ "estraverse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true } } }, @@ -27627,7 +25773,8 @@ "webpack-sources": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==" + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true }, "webpack-virtual-modules": { "version": "0.6.1", @@ -27706,11 +25853,6 @@ "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", "requires": {} }, - "xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==" - }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -27767,7 +25909,8 @@ "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true }, "zip-stream": { "version": "6.0.1", diff --git a/server/package.json b/server/package.json index 8a9149bf84..55f80a0718 100644 --- a/server/package.json +++ b/server/package.json @@ -79,7 +79,7 @@ "openid-client": "^5.4.3", "pg": "^8.11.3", "picomatch": "^4.0.0", - "react-email": "^2.1.2", + "react-email": "^3.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", From 365facfc5173044b6c3f3de1a457819222cab4ca Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 Aug 2024 08:52:49 -0400 Subject: [PATCH 185/723] chore(deps): update node (#12063) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/Dockerfile | 2 +- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- e2e/package-lock.json | 6 +++--- e2e/package.json | 2 +- open-api/typescript-sdk/package-lock.json | 2 +- open-api/typescript-sdk/package.json | 2 +- server/Dockerfile | 2 +- server/package-lock.json | 2 +- server/package.json | 2 +- web/Dockerfile | 2 +- 11 files changed, 14 insertions(+), 14 deletions(-) diff --git a/cli/Dockerfile b/cli/Dockerfile index 2c4aaf8718..e3cce6d448 100644 --- a/cli/Dockerfile +++ b/cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.16.0-alpine3.20@sha256:eb8101caae9ac02229bd64c024919fe3d4504ff7f329da79ca60a04db08cef52 AS core +FROM node:20.17.0-alpine3.20@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 AS core WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ diff --git a/cli/package-lock.json b/cli/package-lock.json index 6044069672..2fdb1a5d59 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -24,7 +24,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.15", + "@types/node": "^20.16.1", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -59,7 +59,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.15", + "@types/node": "^20.16.1", "typescript": "^5.3.3" } }, diff --git a/cli/package.json b/cli/package.json index cce73afa37..d739cc3895 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.15", + "@types/node": "^20.16.1", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index bc08cb0f92..5b85eb0147 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.14.15", + "@types/node": "^20.16.1", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -64,7 +64,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.15", + "@types/node": "^20.16.1", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -99,7 +99,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.15", + "@types/node": "^20.16.1", "typescript": "^5.3.3" } }, diff --git a/e2e/package.json b/e2e/package.json index be072e44f3..add072df84 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.14.15", + "@types/node": "^20.16.1", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 89322e1e07..afa002a5a3 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -12,7 +12,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.15", + "@types/node": "^20.16.1", "typescript": "^5.3.3" } }, diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 90fa525fa0..d7d6ba6cc5 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.15", + "@types/node": "^20.16.1", "typescript": "^5.3.3" }, "repository": { diff --git a/server/Dockerfile b/server/Dockerfile index 1c671f2332..c961f0db64 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl # web build -FROM node:20.16.0-alpine3.20@sha256:eb8101caae9ac02229bd64c024919fe3d4504ff7f329da79ca60a04db08cef52 AS web +FROM node:20.17.0-alpine3.20@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 AS web WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ diff --git a/server/package-lock.json b/server/package-lock.json index 33a4cd51ad..780ff2a63e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -83,7 +83,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.14.15", + "@types/node": "^20.16.1", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/semver": "^7.5.8", diff --git a/server/package.json b/server/package.json index 55f80a0718..9f82378c1a 100644 --- a/server/package.json +++ b/server/package.json @@ -109,7 +109,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.14.15", + "@types/node": "^20.16.1", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/semver": "^7.5.8", diff --git a/web/Dockerfile b/web/Dockerfile index 5e1dd28020..4bc711e15e 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.16.0-alpine3.20@sha256:eb8101caae9ac02229bd64c024919fe3d4504ff7f329da79ca60a04db08cef52 +FROM node:20.17.0-alpine3.20@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 RUN apk add --no-cache tini USER node From cf272fc7fd927680e4629db1db7b383080ccffde Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 Aug 2024 14:15:20 +0100 Subject: [PATCH 186/723] chore(deps): update terraform cloudflare to v4.40.0 (#11740) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../docs-release/.terraform.lock.hcl | 60 +++++++++---------- .../modules/cloudflare/docs-release/config.tf | 2 +- .../cloudflare/docs/.terraform.lock.hcl | 60 +++++++++---------- deployment/modules/cloudflare/docs/config.tf | 2 +- 4 files changed, 62 insertions(+), 62 deletions(-) diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index 4774e1cacf..096177bb05 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.38.0" - constraints = "4.38.0" + version = "4.40.0" + constraints = "4.40.0" hashes = [ - "h1:+27KAHKHBDvv3dqyJv5vhtdKQZJzoZXoMqIyronlHNw=", - "h1:/uV9RgOUhkxElkHhWs8fs5ZbX9vj6RCBfP0oJO0JF30=", - "h1:1DNAdMugJJOAWD/XYiZenYYZLy7fw2ctjT4YZmkRCVQ=", - "h1:1wn4PmCLdT7mvd74JkCGmJDJxTQDkcxc+1jNbmwnMHA=", - "h1:BIHB4fBxHg2bA9KbL92njhyctxKC8b6hNDp60y5QBss=", - "h1:HCQpvKPsMsR4HO5eDqt+Kao7T7CYeEH7KZIO7xMcC6M=", - "h1:HTomuzocukpNLwtWzeSF3yteCVsyVKbwKmN66u9iPac=", - "h1:YDxsUBhBAwHSXLzVwrSlSBOwv1NvLyry7s5SfCV7VqQ=", - "h1:dchVhxo+Acd1l2RuZ88tW9lWj4422QMfgtxKvKCjYrw=", - "h1:eypa+P4ZpsEGMPFuCE+6VkRefu0TZRFmVBOpK+PDOPY=", - "h1:f3yjse2OsRZj7ZhR7BLintJMlI4fpyt8HyDP/zcEavw=", - "h1:mSJ7xj8K+xcnEmGg7lH0jjzyQb157wH94ULTAlIV+HQ=", - "h1:tt+2J2Ze8VIdDq2Hr6uHlTJzAMBRpErBwTYx0uD5ilE=", - "h1:uQW8SKxmulqrAisO+365mIf2FueINAp5PY28bqCPCug=", - "zh:171ab67cccceead4514fafb2d39e4e708a90cce79000aaf3c29aab7ed4457071", - "zh:18aa7228447baaaefc49a43e8eff970817a7491a63d8937e796357a3829dd979", - "zh:2cbaab6092e81ba6f41fa60a50f14e980c8ec327ee11d0b21f16a478be4b7567", - "zh:53b8e49c06f5b31a8c681f8c0669cf43e78abe71657b8182a221d096bb514965", - "zh:6037cfc60b4b647aabae155fcb46d649ed7c650e0287f05db52b2068f1e27c8a", - "zh:62460982ce1a869eebfca675603fbbd50416cf6b69459fb855bfbe5ae2b97607", - "zh:65f6f3a8470917b6398baa5eb4f74b3932b213eac7c0202798bfad6fd1ee17df", + "h1:GP2N1tXrmpxu+qEDvFAmkfv9aeZNhag3bchyJpGpYbU=", + "h1:HDJKZBQkVU0kQl4gViQ5L7EcFLn9hB0iuvO+ORJiDS4=", + "h1:KrbeEsZoCJOnnX68yNI5h3QhMjc5bBCQW4yvYaEFq3s=", + "h1:LelwnzU0OVn6g2+T9Ub9XdpC+vbheraIL/qgXhWBs/k=", + "h1:TIq9CynfWrKgCxKL97Akj89cYlvJKn/AL4UXogd8/FM=", + "h1:Uoy5oPdm1ipDG7yIMCUN1IXMpsTGXahPw3I0rVA/6wA=", + "h1:Wunfpm+IZhENdoimrh4iXiakVnCsfKOHo80yJUjMQXM=", + "h1:cRdCuahMOFrNyldnCInqGQRBT1DTkRPSfPnaf5r05iw=", + "h1:k+zpXg8BO7gdbTIfSGyQisHhs5aVWQVbPLa5uUdr2UA=", + "h1:kWNrzZ8Rh0OpHikexkmwJIIucD6SMZPi4oGyDsKJitw=", + "h1:lomfTTjK78BdSEVTFcJUBQRy7IQHuGQImMaPWaYpfgQ=", + "h1:oWcWlZe52ZRyLQciNe94RaWzhHifSTu03nlK0uL7rlM=", + "h1:p3JJrhGEPlPQP7Uwy9FNMdvqCyD8tuT4lnXuJ+pSF/M=", + "h1:wtB0sKxG2K/H41hWJI4uJdImWquuaP34Sip5LmfE410=", + "zh:01742e5946f936548f8e42120287ffc757abf97e7cbbe34e25c266a438fb54fd", + "zh:08d81f5a5aab4cc269f983b8c6b5be0e278105136aca9681740802619577371f", + "zh:0d75131ba70902cfc94a7a5900369bdde56528b2aad6e10b164449cc97d57396", + "zh:3890a715a012e197541daacdacb8cceec6d364814daa4640ddfe98a8ba9036cb", + "zh:58254ce5ebe1faed4664df86210c39d660bcdc60280f17b25fe4d4dbea21ea8c", + "zh:6b0abc1adbc2edee79368ce9f7338ebcb5d0bf941e8d7d9ac505b750f20f80a2", + "zh:81cc415d1477174a1ca288d25fdb57e5ee488c2d7f61f265ef995b255a53b0ce", + "zh:8680140c7fe5beaefe61c5cfa471bf88422dc0c0f05dad6d3cb482d4ffd22be4", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:8b5cebe64bf04105a49178a165b6a8800a9a33bae6767143a47fe4977755f805", - "zh:a5596635db0993ee3c3060fbc2227d91b239466e96d2d82642625a5aa2486988", - "zh:b3a9c63038441f13c311fd4b2c7e69e571445e5a7365a20c7cc9046b7e6c8aba", - "zh:b585e7e4d7648a540b14b9182819214896ca9337729eeb1f2034833b17db754d", - "zh:d2c3c545318ac8542369e9fc8228e29ee585febdf203a450fad3e0eded71ce02", - "zh:e95dd2d6c3525073af47d47b763cb81b6a51b20cabf76f789c69328922da9ecf", - "zh:eee6e590b36d6c6168a7daae8afa74a8721fd7aa9f62a710f04a311975100722", + "zh:a491d26236122ccb83dac8cb490d2c0aa1f4d3a0b4abe99300fd49b1a624f42f", + "zh:a70d9c469dc8d55715ba77c9d1a4ede1fdebf79e60ee18438a0844868db54e0d", + "zh:a7fcb7d5c4222e14ec6d9a15adf8b9a083d84b102c3d0e4a0d102df5a1360b62", + "zh:b4f9677174fabd199c8ebd2e9e5eb3528cf887e700569a4fb61eef4e070cec5e", + "zh:c27f0f7519221d75dae4a3787a59e05acd5cc9a0d30a390eff349a77d20d52e6", + "zh:db00d8605dbf43ca42fe1481a6c67fdcaa73debb7d2a0f613cb95ae5c5e7150e", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index b7c70f1c21..63c96fc498 100644 --- a/deployment/modules/cloudflare/docs-release/config.tf +++ b/deployment/modules/cloudflare/docs-release/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.38.0" + version = "4.40.0" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index 4774e1cacf..096177bb05 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.38.0" - constraints = "4.38.0" + version = "4.40.0" + constraints = "4.40.0" hashes = [ - "h1:+27KAHKHBDvv3dqyJv5vhtdKQZJzoZXoMqIyronlHNw=", - "h1:/uV9RgOUhkxElkHhWs8fs5ZbX9vj6RCBfP0oJO0JF30=", - "h1:1DNAdMugJJOAWD/XYiZenYYZLy7fw2ctjT4YZmkRCVQ=", - "h1:1wn4PmCLdT7mvd74JkCGmJDJxTQDkcxc+1jNbmwnMHA=", - "h1:BIHB4fBxHg2bA9KbL92njhyctxKC8b6hNDp60y5QBss=", - "h1:HCQpvKPsMsR4HO5eDqt+Kao7T7CYeEH7KZIO7xMcC6M=", - "h1:HTomuzocukpNLwtWzeSF3yteCVsyVKbwKmN66u9iPac=", - "h1:YDxsUBhBAwHSXLzVwrSlSBOwv1NvLyry7s5SfCV7VqQ=", - "h1:dchVhxo+Acd1l2RuZ88tW9lWj4422QMfgtxKvKCjYrw=", - "h1:eypa+P4ZpsEGMPFuCE+6VkRefu0TZRFmVBOpK+PDOPY=", - "h1:f3yjse2OsRZj7ZhR7BLintJMlI4fpyt8HyDP/zcEavw=", - "h1:mSJ7xj8K+xcnEmGg7lH0jjzyQb157wH94ULTAlIV+HQ=", - "h1:tt+2J2Ze8VIdDq2Hr6uHlTJzAMBRpErBwTYx0uD5ilE=", - "h1:uQW8SKxmulqrAisO+365mIf2FueINAp5PY28bqCPCug=", - "zh:171ab67cccceead4514fafb2d39e4e708a90cce79000aaf3c29aab7ed4457071", - "zh:18aa7228447baaaefc49a43e8eff970817a7491a63d8937e796357a3829dd979", - "zh:2cbaab6092e81ba6f41fa60a50f14e980c8ec327ee11d0b21f16a478be4b7567", - "zh:53b8e49c06f5b31a8c681f8c0669cf43e78abe71657b8182a221d096bb514965", - "zh:6037cfc60b4b647aabae155fcb46d649ed7c650e0287f05db52b2068f1e27c8a", - "zh:62460982ce1a869eebfca675603fbbd50416cf6b69459fb855bfbe5ae2b97607", - "zh:65f6f3a8470917b6398baa5eb4f74b3932b213eac7c0202798bfad6fd1ee17df", + "h1:GP2N1tXrmpxu+qEDvFAmkfv9aeZNhag3bchyJpGpYbU=", + "h1:HDJKZBQkVU0kQl4gViQ5L7EcFLn9hB0iuvO+ORJiDS4=", + "h1:KrbeEsZoCJOnnX68yNI5h3QhMjc5bBCQW4yvYaEFq3s=", + "h1:LelwnzU0OVn6g2+T9Ub9XdpC+vbheraIL/qgXhWBs/k=", + "h1:TIq9CynfWrKgCxKL97Akj89cYlvJKn/AL4UXogd8/FM=", + "h1:Uoy5oPdm1ipDG7yIMCUN1IXMpsTGXahPw3I0rVA/6wA=", + "h1:Wunfpm+IZhENdoimrh4iXiakVnCsfKOHo80yJUjMQXM=", + "h1:cRdCuahMOFrNyldnCInqGQRBT1DTkRPSfPnaf5r05iw=", + "h1:k+zpXg8BO7gdbTIfSGyQisHhs5aVWQVbPLa5uUdr2UA=", + "h1:kWNrzZ8Rh0OpHikexkmwJIIucD6SMZPi4oGyDsKJitw=", + "h1:lomfTTjK78BdSEVTFcJUBQRy7IQHuGQImMaPWaYpfgQ=", + "h1:oWcWlZe52ZRyLQciNe94RaWzhHifSTu03nlK0uL7rlM=", + "h1:p3JJrhGEPlPQP7Uwy9FNMdvqCyD8tuT4lnXuJ+pSF/M=", + "h1:wtB0sKxG2K/H41hWJI4uJdImWquuaP34Sip5LmfE410=", + "zh:01742e5946f936548f8e42120287ffc757abf97e7cbbe34e25c266a438fb54fd", + "zh:08d81f5a5aab4cc269f983b8c6b5be0e278105136aca9681740802619577371f", + "zh:0d75131ba70902cfc94a7a5900369bdde56528b2aad6e10b164449cc97d57396", + "zh:3890a715a012e197541daacdacb8cceec6d364814daa4640ddfe98a8ba9036cb", + "zh:58254ce5ebe1faed4664df86210c39d660bcdc60280f17b25fe4d4dbea21ea8c", + "zh:6b0abc1adbc2edee79368ce9f7338ebcb5d0bf941e8d7d9ac505b750f20f80a2", + "zh:81cc415d1477174a1ca288d25fdb57e5ee488c2d7f61f265ef995b255a53b0ce", + "zh:8680140c7fe5beaefe61c5cfa471bf88422dc0c0f05dad6d3cb482d4ffd22be4", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:8b5cebe64bf04105a49178a165b6a8800a9a33bae6767143a47fe4977755f805", - "zh:a5596635db0993ee3c3060fbc2227d91b239466e96d2d82642625a5aa2486988", - "zh:b3a9c63038441f13c311fd4b2c7e69e571445e5a7365a20c7cc9046b7e6c8aba", - "zh:b585e7e4d7648a540b14b9182819214896ca9337729eeb1f2034833b17db754d", - "zh:d2c3c545318ac8542369e9fc8228e29ee585febdf203a450fad3e0eded71ce02", - "zh:e95dd2d6c3525073af47d47b763cb81b6a51b20cabf76f789c69328922da9ecf", - "zh:eee6e590b36d6c6168a7daae8afa74a8721fd7aa9f62a710f04a311975100722", + "zh:a491d26236122ccb83dac8cb490d2c0aa1f4d3a0b4abe99300fd49b1a624f42f", + "zh:a70d9c469dc8d55715ba77c9d1a4ede1fdebf79e60ee18438a0844868db54e0d", + "zh:a7fcb7d5c4222e14ec6d9a15adf8b9a083d84b102c3d0e4a0d102df5a1360b62", + "zh:b4f9677174fabd199c8ebd2e9e5eb3528cf887e700569a4fb61eef4e070cec5e", + "zh:c27f0f7519221d75dae4a3787a59e05acd5cc9a0d30a390eff349a77d20d52e6", + "zh:db00d8605dbf43ca42fe1481a6c67fdcaa73debb7d2a0f613cb95ae5c5e7150e", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index b7c70f1c21..63c96fc498 100644 --- a/deployment/modules/cloudflare/docs/config.tf +++ b/deployment/modules/cloudflare/docs/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.38.0" + version = "4.40.0" } } } From c44280a50b28cba041ebf00cae7e06ec47472019 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 28 Aug 2024 08:20:56 -0500 Subject: [PATCH 187/723] chore(web): subtler spinner FOUC animation (#12090) --- web/src/app.html | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/web/src/app.html b/web/src/app.html index aa8450e9be..778375c1e1 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -20,43 +20,27 @@ height: 100%; width: 100%; } + body, html { margin: 0; padding: 0; } + @keyframes delayedVisibility { to { visibility: visible; } } - @keyframes stencil-pulse { - 0% { - transform: scale(0.93); - filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0.7)); - } - 70% { - transform: scale(1); - filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0)); - } - - 100% { - transform: scale(0.93); - filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0)); - } - } @keyframes loadspin { 100% { transform: rotate(360deg); } } - #stencil svg { - height: 35%; - animation: stencil-pulse 1s linear infinite; - } + #stencil { - --stencil-width: 25vw; + --stencil-width: 150px; display: flex; width: var(--stencil-width); margin-left: auto; @@ -69,11 +53,13 @@ visibility: hidden; animation: 0s linear 0.3s forwards delayedVisibility, - loadspin 2s linear infinite; + loadspin 8s linear infinite; } + .bg-immich-bg { background-color: white; } + .dark .dark\:bg-immich-dark-bg { background-color: black; } From 6867bae770a2fe180d358ca4196cc0fd1a118d47 Mon Sep 17 00:00:00 2001 From: Lena Tauchner <48085877+Tiefseetauchner@users.noreply.github.com> Date: Wed, 28 Aug 2024 15:25:58 +0200 Subject: [PATCH 188/723] fix(cli): Update build instructions for CLI (#11874) Update build instructions for CLI --- cli/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cli/README.md b/cli/README.md index a570a55239..8fa2ace483 100644 --- a/cli/README.md +++ b/cli/README.md @@ -4,8 +4,18 @@ Please see the [Immich CLI documentation](https://immich.app/docs/features/comma # For developers +Before building the CLI, you must build the immich server and the open-api client. To build the server run the following in the server folder: + + $ npm install + $ npm run build + +Then, to build the open-api client run the following in the open-api folder: + + $ ./bin/generate-open-api.sh + To run the Immich CLI from source, run the following in the cli folder: + $ npm install $ npm run build $ ts-node . @@ -17,3 +27,4 @@ You can also build and install the CLI using $ npm run build $ npm install -g . +**** From e705831e67ffd290c983cc871904d66585cda2c9 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Wed, 28 Aug 2024 16:33:21 +0100 Subject: [PATCH 189/723] ci: fix permissions when pr-label-validation runs from fork (#12093) --- .github/workflows/pr-label-validation.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml index 510995aa54..1557b3d15c 100644 --- a/.github/workflows/pr-label-validation.yml +++ b/.github/workflows/pr-label-validation.yml @@ -1,12 +1,15 @@ name: PR Label Validation on: - pull_request: + pull_request_target: types: [opened, labeled, unlabeled, synchronize] jobs: validate-release-label: runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read steps: - name: Require PR to have a changelog label uses: mheap/github-action-required-labels@v5 From cc4e5298ffc91e1f5ee36873979e3a3bb8652da0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 Aug 2024 12:00:10 -0400 Subject: [PATCH 190/723] fix(deps): update typescript-projects (#11927) * fix(deps): update typescript-projects * chore: clean up --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Jason Rasmussen --- cli/package-lock.json | 285 ++-- docs/package-lock.json | 466 +++--- docs/src/components/version-switcher.tsx | 1 - docs/tailwind.config.js | 2 +- e2e/package-lock.json | 148 +- server/package-lock.json | 1487 ++++++++--------- server/package.json | 4 +- server/src/emails/album-invite.email.tsx | 4 +- server/src/emails/album-update.email.tsx | 4 +- .../src/emails/components/immich.layout.tsx | 3 +- server/src/emails/license.email.tsx | 21 +- server/src/emails/test.email.tsx | 2 +- server/src/emails/welcome.email.tsx | 4 +- .../src/interfaces/notification.interface.ts | 2 +- .../repositories/notification.repository.ts | 6 +- server/src/services/notification.service.ts | 8 +- web/package-lock.json | 331 ++-- 17 files changed, 1414 insertions(+), 1364 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 2fdb1a5d59..fa38bd275e 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -825,9 +825,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", - "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.0.tgz", + "integrity": "sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==", "dev": true, "license": "MIT", "engines": { @@ -1054,169 +1054,224 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz", - "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.1.tgz", + "integrity": "sha512-2thheikVEuU7ZxFXubPDOtspKn1x0yqaYQwvALVtEcvFhMifPADBrgRPyHV0TF3b+9BgvgjgagVyvA/UqPZHmg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz", - "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.1.tgz", + "integrity": "sha512-t1lLYn4V9WgnIFHXy1d2Di/7gyzBWS8G5pQSXdZqfrdCGTwi1VasRMSS81DTYb+avDs/Zz4A6dzERki5oRYz1g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz", - "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.1.tgz", + "integrity": "sha512-AH/wNWSEEHvs6t4iJ3RANxW5ZCK3fUnmf0gyMxWCesY1AlUj8jY7GC+rQE4wd3gwmZ9XDOpL0kcFnCjtN7FXlA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz", - "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.1.tgz", + "integrity": "sha512-dO0BIz/+5ZdkLZrVgQrDdW7m2RkrLwYTh2YMFG9IpBtlC1x1NPNSXkfczhZieOlOLEqgXOFH3wYHB7PmBtf+Bg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz", - "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.1.tgz", + "integrity": "sha512-sWWgdQ1fq+XKrlda8PsMCfut8caFwZBmhYeoehJ05FdI0YZXk6ZyUjWLrIgbR/VgiGycrFKMMgp7eJ69HOF2pQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.1.tgz", + "integrity": "sha512-9OIiSuj5EsYQlmwhmFRA0LRO0dRRjdCVZA3hnmZe1rEwRk11Jy3ECGGq3a7RrVEZ0/pCsYWx8jG3IvcrJ6RCew==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz", - "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.1.tgz", + "integrity": "sha512-0kuAkRK4MeIUbzQYu63NrJmfoUVicajoRAL1bpwdYIYRcs57iyIV9NLcuyDyDXE2GiZCL4uhKSYAnyWpjZkWow==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz", - "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.1.tgz", + "integrity": "sha512-/6dYC9fZtfEY0vozpc5bx1RP4VrtEOhNQGb0HwvYNwXD1BBbwQ5cKIbUVVU7G2d5WRE90NfB922elN8ASXAJEA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.1.tgz", + "integrity": "sha512-ltUWy+sHeAh3YZ91NUsV4Xg3uBXAlscQe8ZOXRCVAKLsivGuJsrkawYPUEyCV3DYa9urgJugMLn8Z3Z/6CeyRQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz", - "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.1.tgz", + "integrity": "sha512-BggMndzI7Tlv4/abrgLwa/dxNEMn2gC61DCLrTzw8LkpSKel4o+O+gtjbnkevZ18SKkeN3ihRGPuBxjaetWzWg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.1.tgz", + "integrity": "sha512-z/9rtlGd/OMv+gb1mNSjElasMf9yXusAxnRDrBaYB+eS1shFm6/4/xDH1SAISO5729fFKUkJ88TkGPRUh8WSAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz", - "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.1.tgz", + "integrity": "sha512-kXQVcWqDcDKw0S2E0TmhlTLlUgAmMVqPrJZR+KpH/1ZaZhLSl23GZpQVmawBQGVhyP5WXIsIQ/zqbDBBYmxm5w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz", - "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.1.tgz", + "integrity": "sha512-CbFv/WMQsSdl+bpX6rVbzR4kAjSSBuDgCqb1l4J68UYsQNalz5wOqLGYj4ZI0thGpyX5kc+LLZ9CL+kpqDovZA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz", - "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.1.tgz", + "integrity": "sha512-3Q3brDgA86gHXWHklrwdREKIrIbxC0ZgU8lwpj0eEKGBQH+31uPqr0P2v11pn0tSIxHvcdOWxa4j+YvLNx1i6g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz", - "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.1.tgz", + "integrity": "sha512-tNg+jJcKR3Uwe4L0/wY3Ro0H+u3nrb04+tcq1GSYzBEmKLeOQF2emk1whxlzNqb6MMrQ2JOcQEpuuiPLyRcSIw==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz", - "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.1.tgz", + "integrity": "sha512-xGiIH95H1zU7naUyTKEyOA/I0aexNMUdO9qRv0bLKN3qu25bBdrxZHqA3PTJ24YNN/GdMzG4xkDcd/GvjuhfLg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1285,17 +1340,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", - "integrity": "sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.2.0.tgz", + "integrity": "sha512-02tJIs655em7fvt9gps/+4k4OsKULYGtLBPJfOsmOq1+3cdClYiF0+d6mHu6qDnTcg88wJBkcPLpQhq7FyDz0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/type-utils": "8.0.1", - "@typescript-eslint/utils": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/type-utils": "8.2.0", + "@typescript-eslint/utils": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1319,16 +1374,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.1.tgz", - "integrity": "sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.2.0.tgz", + "integrity": "sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4" }, "engines": { @@ -1348,14 +1403,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", - "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.2.0.tgz", + "integrity": "sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1" + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1366,14 +1421,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.1.tgz", - "integrity": "sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.2.0.tgz", + "integrity": "sha512-g1CfXGFMQdT5S+0PSO0fvGXUaiSkl73U1n9LTK5aRAFnPlJ8dLKkXr4AaLFvPedW8lVDoMgLLE3JN98ZZfsj0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/utils": "8.0.1", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/utils": "8.2.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1391,9 +1446,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.2.0.tgz", + "integrity": "sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==", "dev": true, "license": "MIT", "engines": { @@ -1405,14 +1460,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", - "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.2.0.tgz", + "integrity": "sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1434,16 +1489,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.1.tgz", - "integrity": "sha512-CBFR0G0sCt0+fzfnKaciu9IBsKvEKYwN9UZ+eeogK1fYHg4Qxk1yf/wLQkLXlq8wbU2dFlgAesxt8Gi76E8RTA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.2.0.tgz", + "integrity": "sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1" + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1457,13 +1512,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.2.0.tgz", + "integrity": "sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/types": "8.2.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2080,9 +2135,9 @@ } }, "node_modules/eslint": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", - "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.0.tgz", + "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", "dev": true, "license": "MIT", "dependencies": { @@ -2090,7 +2145,7 @@ "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.17.1", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.8.0", + "@eslint/js": "9.9.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", @@ -2129,6 +2184,14 @@ }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-prettier": { @@ -3709,10 +3772,11 @@ } }, "node_modules/rollup": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz", - "integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.1.tgz", + "integrity": "sha512-ZnYyKvscThhgd3M5+Qt3pmhO4jIRR5RGzaSovB6Q7rGNrK5cUncrtLmcTTJVSdcKXyZjW8X8MB0JMSuH9bcAJg==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "1.0.5" }, @@ -3724,19 +3788,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.13.0", - "@rollup/rollup-android-arm64": "4.13.0", - "@rollup/rollup-darwin-arm64": "4.13.0", - "@rollup/rollup-darwin-x64": "4.13.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.13.0", - "@rollup/rollup-linux-arm64-gnu": "4.13.0", - "@rollup/rollup-linux-arm64-musl": "4.13.0", - "@rollup/rollup-linux-riscv64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-musl": "4.13.0", - "@rollup/rollup-win32-arm64-msvc": "4.13.0", - "@rollup/rollup-win32-ia32-msvc": "4.13.0", - "@rollup/rollup-win32-x64-msvc": "4.13.0", + "@rollup/rollup-android-arm-eabi": "4.21.1", + "@rollup/rollup-android-arm64": "4.21.1", + "@rollup/rollup-darwin-arm64": "4.21.1", + "@rollup/rollup-darwin-x64": "4.21.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.1", + "@rollup/rollup-linux-arm-musleabihf": "4.21.1", + "@rollup/rollup-linux-arm64-gnu": "4.21.1", + "@rollup/rollup-linux-arm64-musl": "4.21.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.1", + "@rollup/rollup-linux-riscv64-gnu": "4.21.1", + "@rollup/rollup-linux-s390x-gnu": "4.21.1", + "@rollup/rollup-linux-x64-gnu": "4.21.1", + "@rollup/rollup-linux-x64-musl": "4.21.1", + "@rollup/rollup-win32-arm64-msvc": "4.21.1", + "@rollup/rollup-win32-ia32-msvc": "4.21.1", + "@rollup/rollup-win32-x64-msvc": "4.21.1", "fsevents": "~2.3.2" } }, @@ -4207,15 +4274,15 @@ } }, "node_modules/vite": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz", - "integrity": "sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", + "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.40", - "rollup": "^4.13.0" + "postcss": "^8.4.41", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" diff --git a/docs/package-lock.json b/docs/package-lock.json index e5fb9f8b2a..c67c2b64fc 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -2155,9 +2155,9 @@ } }, "node_modules/@docusaurus/core": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.4.0.tgz", - "integrity": "sha512-g+0wwmN2UJsBqy2fQRQ6fhXruoEa62JDeEa5d8IdTJlMoaDaEDfHh7WjwGRn4opuTQWpjAwP/fbcgyHKlE+64w==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.5.2.tgz", + "integrity": "sha512-4Z1WkhCSkX4KO0Fw5m/Vuc7Q3NxBG53NE5u59Rs96fWkMPZVSrzEPP16/Nk6cWb/shK7xXPndTmalJtw7twL/w==", "license": "MIT", "dependencies": { "@babel/core": "^7.23.3", @@ -2170,12 +2170,12 @@ "@babel/runtime": "^7.22.6", "@babel/runtime-corejs3": "^7.22.6", "@babel/traverse": "^7.22.8", - "@docusaurus/cssnano-preset": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/cssnano-preset": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "autoprefixer": "^10.4.14", "babel-loader": "^9.1.3", "babel-plugin-dynamic-import-node": "^2.3.3", @@ -2236,14 +2236,15 @@ "node": ">=18.0" }, "peerDependencies": { + "@mdx-js/react": "^3.0.0", "react": "^18.0.0", "react-dom": "^18.0.0" } }, "node_modules/@docusaurus/cssnano-preset": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.4.0.tgz", - "integrity": "sha512-qwLFSz6v/pZHy/UP32IrprmH5ORce86BGtN0eBtG75PpzQJAzp9gefspox+s8IEOr0oZKuQ/nhzZ3xwyc3jYJQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.5.2.tgz", + "integrity": "sha512-D3KiQXOMA8+O0tqORBrTOEQyQxNIfPm9jEaJoALjjSjc2M/ZAWcUfPQEnwr2JB2TadHw2gqWgpZckQmrVWkytA==", "license": "MIT", "dependencies": { "cssnano-preset-advanced": "^6.1.2", @@ -2256,9 +2257,9 @@ } }, "node_modules/@docusaurus/logger": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.4.0.tgz", - "integrity": "sha512-bZwkX+9SJ8lB9kVRkXw+xvHYSMGG4bpYHKGXeXFvyVc79NMeeBSGgzd4TQLHH+DYeOJoCdl8flrFJVxlZ0wo/Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.5.2.tgz", + "integrity": "sha512-LHC540SGkeLfyT3RHK3gAMK6aS5TRqOD4R72BEU/DE2M/TY8WwEUAMY576UUc/oNJXv8pGhBmQB6N9p3pt8LQw==", "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -2269,14 +2270,14 @@ } }, "node_modules/@docusaurus/mdx-loader": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.4.0.tgz", - "integrity": "sha512-kSSbrrk4nTjf4d+wtBA9H+FGauf2gCax89kV8SUSJu3qaTdSIKdWERlngsiHaCFgZ7laTJ8a67UFf+xlFPtuTw==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.5.2.tgz", + "integrity": "sha512-ku3xO9vZdwpiMIVd8BzWV0DCqGEbCP5zs1iHfKX50vw6jX8vQo0ylYo1YJMZyz6e+JFJ17HYHT5FzVidz2IflA==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/logger": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "@mdx-js/mdx": "^3.0.0", "@slorber/remark-comment": "^1.0.0", "escape-html": "^1.0.3", @@ -2308,12 +2309,12 @@ } }, "node_modules/@docusaurus/module-type-aliases": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.4.0.tgz", - "integrity": "sha512-A1AyS8WF5Bkjnb8s+guTDuYmUiwJzNrtchebBHpc0gz0PyHJNMaybUlSrmJjHVcGrya0LKI4YcR3lBDQfXRYLw==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.5.2.tgz", + "integrity": "sha512-Z+Xu3+2rvKef/YKTMxZHsEXp1y92ac0ngjDiExRdqGTmEKtCUpkbNYH8v5eXo5Ls+dnW88n6WTa+Q54kLOkwPg==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.4.0", + "@docusaurus/types": "3.5.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -2326,52 +2327,21 @@ "react-dom": "*" } }, - "node_modules/@docusaurus/plugin-content-blog": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.4.0.tgz", - "integrity": "sha512-vv6ZAj78ibR5Jh7XBUT4ndIjmlAxkijM3Sx5MAAzC1gyv0vupDQNhzuFg1USQmQVj3P5I6bquk12etPV3LJ+Xw==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", - "cheerio": "^1.0.0-rc.12", - "feed": "^4.2.2", - "fs-extra": "^11.1.1", - "lodash": "^4.17.21", - "reading-time": "^1.5.0", - "srcset": "^4.0.0", - "tslib": "^2.6.0", - "unist-util-visit": "^5.0.0", - "utility-types": "^3.10.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "node_modules/@docusaurus/plugin-content-docs": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.4.0.tgz", - "integrity": "sha512-HkUCZffhBo7ocYheD9oZvMcDloRnGhBMOZRyVcAQRFmZPmNqSyISlXA1tQCIxW+r478fty97XXAGjNYzBjpCsg==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.5.2.tgz", + "integrity": "sha512-Bt+OXn/CPtVqM3Di44vHjE7rPCEsRCB/DMo2qoOuozB9f7+lsdrHvD0QCHdBs0uhz6deYJDppAr2VgqybKPlVQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/module-type-aliases": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/module-type-aliases": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "@types/react-router-config": "^5.0.7", "combine-promises": "^1.1.0", "fs-extra": "^11.1.1", @@ -2389,38 +2359,15 @@ "react-dom": "^18.0.0" } }, - "node_modules/@docusaurus/plugin-content-pages": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.4.0.tgz", - "integrity": "sha512-h2+VN/0JjpR8fIkDEAoadNjfR3oLzB+v1qSXbIAKjQ46JAHx3X22n9nqS+BWSQnTnp1AjkjSvZyJMekmcwxzxg==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", - "fs-extra": "^11.1.1", - "tslib": "^2.6.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "node_modules/@docusaurus/plugin-debug": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.4.0.tgz", - "integrity": "sha512-uV7FDUNXGyDSD3PwUaf5YijX91T5/H9SX4ErEcshzwgzWwBtK37nUWPU3ZLJfeTavX3fycTOqk9TglpOLaWkCg==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.5.2.tgz", + "integrity": "sha512-kBK6GlN0itCkrmHuCS6aX1wmoWc5wpd5KJlqQ1FyrF0cLDnvsYSnh7+ftdwzt7G6lGBho8lrVwkkL9/iQvaSOA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", "fs-extra": "^11.1.1", "react-json-view-lite": "^1.2.0", "tslib": "^2.6.0" @@ -2434,14 +2381,14 @@ } }, "node_modules/@docusaurus/plugin-google-analytics": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.4.0.tgz", - "integrity": "sha512-mCArluxEGi3cmYHqsgpGGt3IyLCrFBxPsxNZ56Mpur0xSlInnIHoeLDH7FvVVcPJRPSQ9/MfRqLsainRw+BojA==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.5.2.tgz", + "integrity": "sha512-rjEkJH/tJ8OXRE9bwhV2mb/WP93V441rD6XnM6MIluu7rk8qg38iSxS43ga2V2Q/2ib53PcqbDEJDG/yWQRJhQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "tslib": "^2.6.0" }, "engines": { @@ -2453,14 +2400,14 @@ } }, "node_modules/@docusaurus/plugin-google-gtag": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.4.0.tgz", - "integrity": "sha512-Dsgg6PLAqzZw5wZ4QjUYc8Z2KqJqXxHxq3vIoyoBWiLEEfigIs7wHR+oiWUQy3Zk9MIk6JTYj7tMoQU0Jm3nqA==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.5.2.tgz", + "integrity": "sha512-lm8XL3xLkTPHFKKjLjEEAHUrW0SZBSHBE1I+i/tmYMBsjCcUB5UJ52geS5PSiOCFVR74tbPGcPHEV/gaaxFeSA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "@types/gtag.js": "^0.0.12", "tslib": "^2.6.0" }, @@ -2473,14 +2420,14 @@ } }, "node_modules/@docusaurus/plugin-google-tag-manager": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.4.0.tgz", - "integrity": "sha512-O9tX1BTwxIhgXpOLpFDueYA9DWk69WCbDRrjYoMQtFHSkTyE7RhNgyjSPREUWJb9i+YUg3OrsvrBYRl64FCPCQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.5.2.tgz", + "integrity": "sha512-QkpX68PMOMu10Mvgvr5CfZAzZQFx8WLlOiUQ/Qmmcl6mjGK6H21WLT5x7xDmcpCoKA/3CegsqIqBR+nA137lQg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "tslib": "^2.6.0" }, "engines": { @@ -2492,17 +2439,17 @@ } }, "node_modules/@docusaurus/plugin-sitemap": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.4.0.tgz", - "integrity": "sha512-+0VDvx9SmNrFNgwPoeoCha+tRoAjopwT0+pYO1xAbyLcewXSemq+eLxEa46Q1/aoOaJQ0qqHELuQM7iS2gp33Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.5.2.tgz", + "integrity": "sha512-DnlqYyRAdQ4NHY28TfHuVk414ft2uruP4QWCH//jzpHjqvKyXjj2fmDtI8RPUBh9K8iZKFMHRnLtzJKySPWvFA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "fs-extra": "^11.1.1", "sitemap": "^7.1.1", "tslib": "^2.6.0" @@ -2516,24 +2463,81 @@ } }, "node_modules/@docusaurus/preset-classic": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.4.0.tgz", - "integrity": "sha512-Ohj6KB7siKqZaQhNJVMBBUzT3Nnp6eTKqO+FXO3qu/n1hJl3YLwVKTWBg28LF7MWrKu46UuYavwMRxud0VyqHg==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.5.2.tgz", + "integrity": "sha512-3ihfXQ95aOHiLB5uCu+9PRy2gZCeSZoDcqpnDvf3B+sTrMvMTr8qRUzBvWkoIqc82yG5prCboRjk1SVILKx6sg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/plugin-content-blog": "3.4.0", - "@docusaurus/plugin-content-docs": "3.4.0", - "@docusaurus/plugin-content-pages": "3.4.0", - "@docusaurus/plugin-debug": "3.4.0", - "@docusaurus/plugin-google-analytics": "3.4.0", - "@docusaurus/plugin-google-gtag": "3.4.0", - "@docusaurus/plugin-google-tag-manager": "3.4.0", - "@docusaurus/plugin-sitemap": "3.4.0", - "@docusaurus/theme-classic": "3.4.0", - "@docusaurus/theme-common": "3.4.0", - "@docusaurus/theme-search-algolia": "3.4.0", - "@docusaurus/types": "3.4.0" + "@docusaurus/core": "3.5.2", + "@docusaurus/plugin-content-blog": "3.5.2", + "@docusaurus/plugin-content-docs": "3.5.2", + "@docusaurus/plugin-content-pages": "3.5.2", + "@docusaurus/plugin-debug": "3.5.2", + "@docusaurus/plugin-google-analytics": "3.5.2", + "@docusaurus/plugin-google-gtag": "3.5.2", + "@docusaurus/plugin-google-tag-manager": "3.5.2", + "@docusaurus/plugin-sitemap": "3.5.2", + "@docusaurus/theme-classic": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/theme-search-algolia": "3.5.2", + "@docusaurus/types": "3.5.2" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/preset-classic/node_modules/@docusaurus/plugin-content-blog": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.5.2.tgz", + "integrity": "sha512-R7ghWnMvjSf+aeNDH0K4fjyQnt5L0KzUEnUhmf1e3jZrv3wogeytZNN6n7X8yHcMsuZHPOrctQhXWnmxu+IRRg==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", + "cheerio": "1.0.0-rc.12", + "feed": "^4.2.2", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "reading-time": "^1.5.0", + "srcset": "^4.0.0", + "tslib": "^2.6.0", + "unist-util-visit": "^5.0.0", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "@docusaurus/plugin-content-docs": "*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/preset-classic/node_modules/@docusaurus/plugin-content-pages": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.5.2.tgz", + "integrity": "sha512-WzhHjNpoQAUz/ueO10cnundRz+VUtkjFhhaQ9jApyv1a46FPURO4cef89pyNIOMny1fjDz/NUN2z6Yi+5WUrCw==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", + "fs-extra": "^11.1.1", + "tslib": "^2.6.0", + "webpack": "^5.88.1" }, "engines": { "node": ">=18.0" @@ -2544,27 +2548,27 @@ } }, "node_modules/@docusaurus/theme-classic": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.4.0.tgz", - "integrity": "sha512-0IPtmxsBYv2adr1GnZRdMkEQt1YW6tpzrUPj02YxNpvJ5+ju4E13J5tB4nfdaen/tfR1hmpSPlTFPvTf4kwy8Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.5.2.tgz", + "integrity": "sha512-XRpinSix3NBv95Rk7xeMF9k4safMkwnpSgThn0UNQNumKvmcIYjfkwfh2BhwYh/BxMXQHJ/PdmNh22TQFpIaYg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/module-type-aliases": "3.4.0", - "@docusaurus/plugin-content-blog": "3.4.0", - "@docusaurus/plugin-content-docs": "3.4.0", - "@docusaurus/plugin-content-pages": "3.4.0", - "@docusaurus/theme-common": "3.4.0", - "@docusaurus/theme-translations": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/module-type-aliases": "3.5.2", + "@docusaurus/plugin-content-blog": "3.5.2", + "@docusaurus/plugin-content-docs": "3.5.2", + "@docusaurus/plugin-content-pages": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/theme-translations": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "copy-text-to-clipboard": "^3.2.0", - "infima": "0.2.0-alpha.43", + "infima": "0.2.0-alpha.44", "lodash": "^4.17.21", "nprogress": "^0.2.0", "postcss": "^8.4.26", @@ -2583,19 +2587,73 @@ "react-dom": "^18.0.0" } }, - "node_modules/@docusaurus/theme-common": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.4.0.tgz", - "integrity": "sha512-0A27alXuv7ZdCg28oPE8nH/Iz73/IUejVaCazqu9elS4ypjiLhK3KfzdSQBnL/g7YfHSlymZKdiOHEo8fJ0qMA==", + "node_modules/@docusaurus/theme-classic/node_modules/@docusaurus/plugin-content-blog": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.5.2.tgz", + "integrity": "sha512-R7ghWnMvjSf+aeNDH0K4fjyQnt5L0KzUEnUhmf1e3jZrv3wogeytZNN6n7X8yHcMsuZHPOrctQhXWnmxu+IRRg==", "license": "MIT", "dependencies": { - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/module-type-aliases": "3.4.0", - "@docusaurus/plugin-content-blog": "3.4.0", - "@docusaurus/plugin-content-docs": "3.4.0", - "@docusaurus/plugin-content-pages": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", + "cheerio": "1.0.0-rc.12", + "feed": "^4.2.2", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "reading-time": "^1.5.0", + "srcset": "^4.0.0", + "tslib": "^2.6.0", + "unist-util-visit": "^5.0.0", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "@docusaurus/plugin-content-docs": "*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/theme-classic/node_modules/@docusaurus/plugin-content-pages": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.5.2.tgz", + "integrity": "sha512-WzhHjNpoQAUz/ueO10cnundRz+VUtkjFhhaQ9jApyv1a46FPURO4cef89pyNIOMny1fjDz/NUN2z6Yi+5WUrCw==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", + "fs-extra": "^11.1.1", + "tslib": "^2.6.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/theme-common": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.5.2.tgz", + "integrity": "sha512-QXqlm9S6x9Ibwjs7I2yEDgsCocp708DrCrgHgKwg2n2AY0YQ6IjU0gAK35lHRLOvAoJUfCKpQAwUykB0R7+Eew==", + "license": "MIT", + "dependencies": { + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/module-type-aliases": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -2609,24 +2667,25 @@ "node": ">=18.0" }, "peerDependencies": { + "@docusaurus/plugin-content-docs": "*", "react": "^18.0.0", "react-dom": "^18.0.0" } }, "node_modules/@docusaurus/theme-search-algolia": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.4.0.tgz", - "integrity": "sha512-aiHFx7OCw4Wck1z6IoShVdUWIjntC8FHCw9c5dR8r3q4Ynh+zkS8y2eFFunN/DL6RXPzpnvKCg3vhLQYJDmT9Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.5.2.tgz", + "integrity": "sha512-qW53kp3VzMnEqZGjakaV90sst3iN1o32PH+nawv1uepROO8aEGxptcq2R5rsv7aBShSRbZwIobdvSYKsZ5pqvA==", "license": "MIT", "dependencies": { "@docsearch/react": "^3.5.2", - "@docusaurus/core": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/plugin-content-docs": "3.4.0", - "@docusaurus/theme-common": "3.4.0", - "@docusaurus/theme-translations": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/plugin-content-docs": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/theme-translations": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "algoliasearch": "^4.18.0", "algoliasearch-helper": "^3.13.3", "clsx": "^2.0.0", @@ -2645,9 +2704,9 @@ } }, "node_modules/@docusaurus/theme-translations": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.4.0.tgz", - "integrity": "sha512-zSxCSpmQCCdQU5Q4CnX/ID8CSUUI3fvmq4hU/GNP/XoAWtXo9SAVnM3TzpU8Gb//H3WCsT8mJcTfyOk3d9ftNg==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.5.2.tgz", + "integrity": "sha512-GPZLcu4aT1EmqSTmbdpVrDENGR2yObFEX8ssEFYTCiAIVc0EihNSdOIBTazUvgNqwvnoU1A8vIs1xyzc3LITTw==", "license": "MIT", "dependencies": { "fs-extra": "^11.1.1", @@ -2658,9 +2717,9 @@ } }, "node_modules/@docusaurus/types": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.4.0.tgz", - "integrity": "sha512-4jcDO8kXi5Cf9TcyikB/yKmz14f2RZ2qTRerbHAsS+5InE9ZgSLBNLsewtFTcTOXSVcbU3FoGOzcNWAmU1TR0A==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.5.2.tgz", + "integrity": "sha512-N6GntLXoLVUwkZw7zCxwy9QiuEXIcTVzA9AkmNw16oc0AP3SXLrMmDMMBIfgqwuKWa6Ox6epHol9kMtJqekACw==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.0.0", @@ -2679,13 +2738,13 @@ } }, "node_modules/@docusaurus/utils": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.4.0.tgz", - "integrity": "sha512-fRwnu3L3nnWaXOgs88BVBmG1yGjcQqZNHG+vInhEa2Sz2oQB+ZjbEMO5Rh9ePFpZ0YDiDUhpaVjwmS+AU2F14g==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.5.2.tgz", + "integrity": "sha512-33QvcNFh+Gv+C2dP9Y9xWEzMgf3JzrpL2nW9PopidiohS1nDcyknKRx2DWaFvyVTTYIkkABVSr073VTj/NITNA==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.4.0", - "@docusaurus/utils-common": "3.4.0", + "@docusaurus/logger": "3.5.2", + "@docusaurus/utils-common": "3.5.2", "@svgr/webpack": "^8.1.0", "escape-string-regexp": "^4.0.0", "file-loader": "^6.2.0", @@ -2718,9 +2777,9 @@ } }, "node_modules/@docusaurus/utils-common": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.4.0.tgz", - "integrity": "sha512-NVx54Wr4rCEKsjOH5QEVvxIqVvm+9kh7q8aYTU5WzUU9/Hctd6aTrcZ3G0Id4zYJ+AeaG5K5qHA4CY5Kcm2iyQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.5.2.tgz", + "integrity": "sha512-i0AZjHiRgJU6d7faQngIhuHKNrszpL/SHQPgF1zH4H+Ij6E9NBYGy6pkcGWToIv7IVPbs+pQLh1P3whn0gWXVg==", "license": "MIT", "dependencies": { "tslib": "^2.6.0" @@ -2738,14 +2797,14 @@ } }, "node_modules/@docusaurus/utils-validation": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.4.0.tgz", - "integrity": "sha512-hYQ9fM+AXYVTWxJOT1EuNaRnrR2WGpRdLDQG07O8UOpsvCPWUVOeo26Rbm0JWY2sGLfzAb+tvJ62yF+8F+TV0g==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.5.2.tgz", + "integrity": "sha512-m+Foq7augzXqB6HufdS139PFxDC5d5q2QKZy8q0qYYvGdI6nnlNsGH4cIGsgBnV7smz+mopl3g4asbSDvMV0jA==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", + "@docusaurus/logger": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", "fs-extra": "^11.2.0", "joi": "^17.9.2", "js-yaml": "^4.1.0", @@ -8767,9 +8826,10 @@ } }, "node_modules/infima": { - "version": "0.2.0-alpha.43", - "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.43.tgz", - "integrity": "sha512-2uw57LvUqW0rK/SWYnd/2rRfxNA5DDNOh33jxF7fy46VWoNhGxiUQyVZHbBMjQ33mQem0cjdDVwgWVAmlRfgyQ==", + "version": "0.2.0-alpha.44", + "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.44.tgz", + "integrity": "sha512-tuRkUSO/lB3rEhLJk25atwAjgLuzq070+pOW8XcvpHky/YbENnRRdPd85IBkyeTgttmOy5ah+yHYsK1HhUd4lQ==", + "license": "MIT", "engines": { "node": ">=12" } @@ -16020,9 +16080,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.9", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz", - "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==", + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", + "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", diff --git a/docs/src/components/version-switcher.tsx b/docs/src/components/version-switcher.tsx index dae822f4f7..b89a65c6e4 100644 --- a/docs/src/components/version-switcher.tsx +++ b/docs/src/components/version-switcher.tsx @@ -1,4 +1,3 @@ -import '@docusaurus/theme-classic/lib/theme/Unlisted/index'; import { useWindowSize } from '@docusaurus/theme-common'; import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem'; import React, { useEffect, useState } from 'react'; diff --git a/docs/tailwind.config.js b/docs/tailwind.config.js index d3ed1f3cda..1ef26facbb 100644 --- a/docs/tailwind.config.js +++ b/docs/tailwind.config.js @@ -4,7 +4,7 @@ module.exports = { corePlugins: { preflight: false, // disable Tailwind's reset }, - content: ['./src/**/*.{js,jsx,ts,tsx}', '../docs/**/*.mdx'], // my markdown stuff is in ../docs, not /src + content: ['./src/**/*.{js,jsx,ts,tsx}', './{docs,blog}/**/*.{md,mdx}'], // my markdown stuff is in ../docs, not /src darkMode: ['class', '[data-theme="dark"]'], // hooks into docusaurus' dark mode settigns theme: { extend: { diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 5b85eb0147..cd591270db 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -799,9 +799,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", - "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.0.tgz", + "integrity": "sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==", "dev": true, "license": "MIT", "engines": { @@ -1113,13 +1113,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.46.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.0.tgz", - "integrity": "sha512-/QYft5VArOrGRP5pgkrfKksqsKA6CEFyGQ/gjNe6q0y4tZ1aaPfq4gIjudr1s3D+pXyrPRdsy4opKDrjBabE5w==", + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.1.tgz", + "integrity": "sha512-Fq6SwLujA/DOIvNC2EL/SojJnkKf/rAwJ//APpJJHRyMi1PdKrY3Az+4XNQ51N4RTbItbIByQ0jgd1tayq1aeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.46.0" + "playwright": "1.46.1" }, "bin": { "playwright": "cli.js" @@ -1532,10 +1532,11 @@ "dev": true }, "node_modules/@types/oidc-provider": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-8.5.1.tgz", - "integrity": "sha512-NS8tBPOj9GG6SxyrUHWBzglOtAYNDX41J4cRE45oeK0iSqI6V6tDW70aPWg25pJFNSC1evccXFm9evfwjxm7HQ==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-8.5.2.tgz", + "integrity": "sha512-NiD3VG49+cRCAAe8+uZLM4onOcX8y9+cwaml8JG1qlgc98rWoCRgsnOB4Ypx+ysays5jiwzfUgT0nWyXPB/9uQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/koa": "*", "@types/node": "*" @@ -1673,17 +1674,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", - "integrity": "sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.2.0.tgz", + "integrity": "sha512-02tJIs655em7fvt9gps/+4k4OsKULYGtLBPJfOsmOq1+3cdClYiF0+d6mHu6qDnTcg88wJBkcPLpQhq7FyDz0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/type-utils": "8.0.1", - "@typescript-eslint/utils": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/type-utils": "8.2.0", + "@typescript-eslint/utils": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1707,16 +1708,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.1.tgz", - "integrity": "sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.2.0.tgz", + "integrity": "sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4" }, "engines": { @@ -1736,14 +1737,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", - "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.2.0.tgz", + "integrity": "sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1" + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1754,14 +1755,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.1.tgz", - "integrity": "sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.2.0.tgz", + "integrity": "sha512-g1CfXGFMQdT5S+0PSO0fvGXUaiSkl73U1n9LTK5aRAFnPlJ8dLKkXr4AaLFvPedW8lVDoMgLLE3JN98ZZfsj0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/utils": "8.0.1", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/utils": "8.2.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1779,9 +1780,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.2.0.tgz", + "integrity": "sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==", "dev": true, "license": "MIT", "engines": { @@ -1793,14 +1794,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", - "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.2.0.tgz", + "integrity": "sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1848,16 +1849,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.1.tgz", - "integrity": "sha512-CBFR0G0sCt0+fzfnKaciu9IBsKvEKYwN9UZ+eeogK1fYHg4Qxk1yf/wLQkLXlq8wbU2dFlgAesxt8Gi76E8RTA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.2.0.tgz", + "integrity": "sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1" + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1871,13 +1872,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.2.0.tgz", + "integrity": "sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/types": "8.2.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2888,9 +2889,9 @@ } }, "node_modules/eslint": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", - "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.0.tgz", + "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", "dev": true, "license": "MIT", "dependencies": { @@ -2898,7 +2899,7 @@ "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.17.1", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.8.0", + "@eslint/js": "9.9.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", @@ -2937,6 +2938,14 @@ }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-prettier": { @@ -4125,10 +4134,11 @@ } }, "node_modules/jose": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.6.3.tgz", - "integrity": "sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.7.0.tgz", + "integrity": "sha512-3P9qfTYDVnNn642LCAqIKbTGb9a1TBxZ9ti5zEVEr48aDdflgRjhspWFb6WM4PzAfFbGMJYC4+803v8riCRAKw==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } @@ -4436,9 +4446,9 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { @@ -5178,13 +5188,13 @@ } }, "node_modules/playwright": { - "version": "1.46.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.0.tgz", - "integrity": "sha512-XYJ5WvfefWONh1uPAUAi0H2xXV5S3vrtcnXe6uAOgdGi3aSpqOSXX08IAjXW34xitfuOJsvXU5anXZxPSEQiJw==", + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.1.tgz", + "integrity": "sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.46.0" + "playwright-core": "1.46.1" }, "bin": { "playwright": "cli.js" @@ -5197,9 +5207,9 @@ } }, "node_modules/playwright-core": { - "version": "1.46.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.0.tgz", - "integrity": "sha512-9Y/d5UIwuJk8t3+lhmMSAJyNP1BUC/DqP3cQJDQQL/oWqAiuPTLgy7Q5dzglmTLwcBRdetzgNM/gni7ckfTr6A==", + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.1.tgz", + "integrity": "sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/server/package-lock.json b/server/package-lock.json index 780ff2a63e..972d116463 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -24,7 +24,7 @@ "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.52.0", "@opentelemetry/sdk-node": "^0.52.0", - "@react-email/components": "^0.0.22", + "@react-email/components": "^0.0.23", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", @@ -53,6 +53,7 @@ "openid-client": "^5.4.3", "pg": "^8.11.3", "picomatch": "^4.0.0", + "react": "^18.3.1", "react-email": "^3.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -86,6 +87,7 @@ "@types/node": "^20.16.1", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", + "@types/react": "^18.3.4", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", "@types/ua-parser-js": "^0.7.36", @@ -723,9 +725,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.1.1.tgz", - "integrity": "sha512-3bfqkzuR1KLx57nZfjr2NLnFOobvyS0aTszaEGCGqmYMVDRaGvgIZbjGSV/MHSSmLgQ/b9JFHQ5xm5WRZYd+XQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", + "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -1197,11 +1199,10 @@ "dev": true }, "node_modules/@eslint/js": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", - "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.0.tgz", + "integrity": "sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -1351,9 +1352,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.4.tgz", - "integrity": "sha512-p0suNqXufJs9t3RqLBO6vvrgr5OhgbWp76s5gTRvdmxmuv9E1rcaqGUsl3l4mKVmXPkTkTErXediAui4x+8PSA==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", "cpu": [ "arm64" ], @@ -1362,23 +1363,19 @@ "darwin" ], "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.2" + "@img/sharp-libvips-darwin-arm64": "1.0.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.4.tgz", - "integrity": "sha512-0l7yRObwtTi82Z6ebVI2PnHT8EB2NxBgpK2MiKJZJ7cz32R4lxd001ecMhzzsZig3Yv9oclvqqdV93jo9hy+Dw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", "cpu": [ "x64" ], @@ -1387,23 +1384,19 @@ "darwin" ], "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.2" + "@img/sharp-libvips-darwin-x64": "1.0.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-tcK/41Rq8IKlSaKRCCAuuY3lDJjQnYIW1UXU1kxcEKrfL8WR7N6+rzNoOxoQRJWTAECuKwgAHnPvqXGN8XfkHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", "cpu": [ "arm64" ], @@ -1411,20 +1404,14 @@ "os": [ "darwin" ], - "engines": { - "macos": ">=11", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.2.tgz", - "integrity": "sha512-Ofw+7oaWa0HiiMiKWqqaZbaYV3/UGL2wAPeLuJTx+9cXpCRdvQhCLG0IH8YGwM0yGWGLpsF4Su9vM1o6aer+Fw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", "cpu": [ "x64" ], @@ -1432,20 +1419,14 @@ "os": [ "darwin" ], - "engines": { - "macos": ">=10.13", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.2.tgz", - "integrity": "sha512-iLWCvrKgeFoglQxdEwzu1eQV04o8YeYGFXtfWU26Zr2wWT3q3MTzC+QTCO3ZQfWd3doKHT4Pm2kRmLbupT+sZw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", "cpu": [ "arm" ], @@ -1453,20 +1434,14 @@ "os": [ "linux" ], - "engines": { - "glibc": ">=2.28", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.2.tgz", - "integrity": "sha512-x7kCt3N00ofFmmkkdshwj3vGPCnmiDh7Gwnd4nUwZln2YjqPxV1NlTyZOvoDWdKQVDL911487HOueBvrpflagw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", "cpu": [ "arm64" ], @@ -1474,20 +1449,14 @@ "os": [ "linux" ], - "engines": { - "glibc": ">=2.26", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.2.tgz", - "integrity": "sha512-cmhQ1J4qVhfmS6szYW7RT+gLJq9dH2i4maq+qyXayUSn9/3iY2ZeWpbAgSpSVbV2E1JUL2Gg7pwnYQ1h8rQIog==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", "cpu": [ "s390x" ], @@ -1495,20 +1464,14 @@ "os": [ "linux" ], - "engines": { - "glibc": ">=2.28", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.2.tgz", - "integrity": "sha512-E441q4Qdb+7yuyiADVi5J+44x8ctlrqn8XgkDTwr4qPJzWkaHwD489iZ4nGDgcuya4iMN3ULV6NwbhRZJ9Z7SQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", "cpu": [ "x64" ], @@ -1516,20 +1479,14 @@ "os": [ "linux" ], - "engines": { - "glibc": ">=2.26", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.2.tgz", - "integrity": "sha512-3CAkndNpYUrlDqkCM5qhksfE+qSIREVpyoeHIU6jd48SJZViAmznoQQLAv4hVXF7xyUB9zf+G++e2v1ABjCbEQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", "cpu": [ "arm64" ], @@ -1537,20 +1494,14 @@ "os": [ "linux" ], - "engines": { - "musl": ">=1.2.2", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.2.tgz", - "integrity": "sha512-VI94Q6khIHqHWNOh6LLdm9s2Ry4zdjWJwH56WoiJU7NTeDwyApdZZ8c+SADC8OH98KWNQXnE01UdJ9CSfZvwZw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", "cpu": [ "x64" ], @@ -1558,20 +1509,14 @@ "os": [ "linux" ], - "engines": { - "musl": ">=1.2.2", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.4.tgz", - "integrity": "sha512-RUgBD1c0+gCYZGCCe6mMdTiOFS0Zc/XrN0fYd6hISIKcDUbAW5NtSQW9g/powkrXYm6Vzwd6y+fqmExDuCdHNQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", "cpu": [ "arm" ], @@ -1580,23 +1525,19 @@ "linux" ], "engines": { - "glibc": ">=2.28", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.2" + "@img/sharp-libvips-linux-arm": "1.0.5" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.4.tgz", - "integrity": "sha512-2800clwVg1ZQtxwSoTlHvtm9ObgAax7V6MTAB/hDT945Tfyy3hVkmiHpeLPCKYqYR1Gcmv1uDZ3a4OFwkdBL7Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", "cpu": [ "arm64" ], @@ -1605,23 +1546,19 @@ "linux" ], "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.2" + "@img/sharp-libvips-linux-arm64": "1.0.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.4.tgz", - "integrity": "sha512-h3RAL3siQoyzSoH36tUeS0PDmb5wINKGYzcLB5C6DIiAn2F3udeFAum+gj8IbA/82+8RGCTn7XW8WTFnqag4tQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", "cpu": [ "s390x" ], @@ -1630,23 +1567,19 @@ "linux" ], "engines": { - "glibc": ">=2.31", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.2" + "@img/sharp-libvips-linux-s390x": "1.0.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.4.tgz", - "integrity": "sha512-GoR++s0XW9DGVi8SUGQ/U4AeIzLdNjHka6jidVwapQ/JebGVQIpi52OdyxCNVRE++n1FCLzjDovJNozif7w/Aw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", "cpu": [ "x64" ], @@ -1655,23 +1588,19 @@ "linux" ], "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.2" + "@img/sharp-libvips-linux-x64": "1.0.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.4.tgz", - "integrity": "sha512-nhr1yC3BlVrKDTl6cO12gTpXMl4ITBUZieehFvMntlCXFzH2bvKG76tBL2Y/OqhupZt81pR7R+Q5YhJxW0rGgQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", "cpu": [ "arm64" ], @@ -1680,23 +1609,19 @@ "linux" ], "engines": { - "musl": ">=1.2.2", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.4.tgz", - "integrity": "sha512-uCPTku0zwqDmZEOi4ILyGdmW76tH7dm8kKlOIV1XC5cLyJ71ENAAqarOHQh0RLfpIpbV5KOpXzdU6XkJtS0daw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", "cpu": [ "x64" ], @@ -1705,44 +1630,37 @@ "linux" ], "engines": { - "musl": ">=1.2.2", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.2" + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.4.tgz", - "integrity": "sha512-Bmmauh4sXUsUqkleQahpdNXKvo+wa1V9KhT2pDA4VJGKwnKMJXiSTGphn0gnJrlooda0QxCtXc6RX1XAU6hMnQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", "cpu": [ "wasm32" ], "optional": true, "dependencies": { - "@emnapi/runtime": "^1.1.1" + "@emnapi/runtime": "^1.2.0" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.4.tgz", - "integrity": "sha512-99SJ91XzUhYHbx7uhK3+9Lf7+LjwMGQZMDlO/E/YVJ7Nc3lyDFZPGhjwiYdctoH2BOzW9+TnfqcaMKt0jHLdqw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", "cpu": [ "ia32" ], @@ -1751,19 +1669,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.4.tgz", - "integrity": "sha512-3QLocdTRVIrFNye5YocZl+KKpYKP+fksi1QhmOArgx7GyhIbQp/WrJRu176jm8IxromS7RIkzMiMINVdBtC8Aw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", "cpu": [ "x64" ], @@ -1772,10 +1687,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -2036,9 +1948,9 @@ ] }, "node_modules/@nestjs/bull-shared": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.0.tgz", - "integrity": "sha512-cSi6CyPECHDFumnHWWfwLCnbc6hm5jXt7FqzJ0Id6EhGqdz5ja0FmgRwXoS4xoMA2RRjlxn2vGXr4YOaHBAeig==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.1.tgz", + "integrity": "sha512-zvnTvSq6OJ92omcsFUwaUmPbM3PRgWkIusHPB5TE3IFS7nNdM3OwF+kfe56sgKjMtQQMe/56lok0S04OtPMX5Q==", "dependencies": { "tslib": "2.6.3" }, @@ -2048,11 +1960,11 @@ } }, "node_modules/@nestjs/bullmq": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.0.tgz", - "integrity": "sha512-lHXWDocXh1Yl6unsUzGFEKmK02mu0DdI35cdBp3Fq/9D5V3oLuWjwAPFnTztedshIjlFmNW6x5mdaT5WZ0AV1Q==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.1.tgz", + "integrity": "sha512-nDR0hDabmtXt5gsb5R786BJsGIJoWh/79sVmRETXf4S45+fvdqG1XkCKAeHF9TO9USodw9m+XBNKysTnkY41gw==", "dependencies": { - "@nestjs/bull-shared": "^10.2.0", + "@nestjs/bull-shared": "^10.2.1", "tslib": "2.6.3" }, "peerDependencies": { @@ -2062,9 +1974,9 @@ } }, "node_modules/@nestjs/cli": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.2.tgz", - "integrity": "sha512-fQexIfLHfp6GUgX+CO4fOg+AEwV5ox/LHotQhyZi9wXUQDyIqS0NTTbumr//62EcX35qV4nU0359nYnuEdzG+A==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.4.tgz", + "integrity": "sha512-WKERbSZJGof0+9XeeMmWnb/9FpNxogcB5eTJTHjc9no0ymdTw3jTzT+KZL9iC/hGqBpuomDLaNFCYbAOt29nBw==", "dev": true, "dependencies": { "@angular-devkit/core": "17.3.8", @@ -2084,7 +1996,7 @@ "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.1.0", "typescript": "5.3.3", - "webpack": "5.92.1", + "webpack": "5.93.0", "webpack-node-externals": "3.0.0" }, "bin": { @@ -2120,9 +2032,9 @@ } }, "node_modules/@nestjs/common": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.10.tgz", - "integrity": "sha512-H8k0jZtxk1IdtErGDmxFRy0PfcOAUg41Prrqpx76DQusGGJjsaovs1zjXVD1rZWaVYchfT1uczJ6L4Kio10VNg==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", + "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", "dependencies": { "iterare": "1.2.1", "tslib": "2.6.3", @@ -2162,9 +2074,9 @@ } }, "node_modules/@nestjs/core": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.10.tgz", - "integrity": "sha512-ZbQ4jovQyzHtCGCrzK5NdtW1SYO2fHSsgSY1+/9WdruYCUra+JDkWEXgZ4M3Hv480Dl3OXehAmY1wCOojeMyMQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.1.tgz", + "integrity": "sha512-9I1WdfOBCCHdUm+ClBJupOuZQS6UxzIWHIq6Vp1brAA5ZKl/Wq6BVwSsbnUJGBy3J3PM2XHmR0EQ4fwX3nR7lA==", "hasInstallScript": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", @@ -2230,9 +2142,9 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.10.tgz", - "integrity": "sha512-wK2ow3CZI2KFqWeEpPmoR300OB6BcBLxARV1EiClJLCj4S1mZsoCmS0YWgpk3j1j6mo0SI8vNLi/cC2iZPEPQA==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.1.tgz", + "integrity": "sha512-ccfqIDAq/bg1ShLI5KGtaLaYGykuAdvCi57ohewH7eKJSIpWY1DQjbgKlFfXokALYUq1YOMGqjeZ244OWHfDQg==", "dependencies": { "body-parser": "1.20.2", "cors": "2.8.5", @@ -2250,9 +2162,9 @@ } }, "node_modules/@nestjs/platform-socket.io": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.10.tgz", - "integrity": "sha512-LRd+nGWhUu9hND1txCLPZd78Hea+qKJVENb+c9aDU04T24GRjsInDF2RANMR16JLQFcI9mclktDWX4plE95SHg==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", + "integrity": "sha512-cxn5vKBAbqtEVPl0qVcJpR4sC12+hzcY/mYXGW6ippOKQDBNc2OF8oZXu6V3O1MvAl+VM7eNNEsLmP9DRKQlnw==", "dependencies": { "socket.io": "4.7.5", "tslib": "2.6.3" @@ -2293,9 +2205,9 @@ } }, "node_modules/@nestjs/schematics": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.3.tgz", - "integrity": "sha512-aLJ4Nl/K/u6ZlgLa0NjKw5CuBOIgc6vudF42QvmGueu5FaMGM6IJrAuEvB5T2kr0PAfVwYmDFBBHCWdYhTw4Tg==", + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.4.tgz", + "integrity": "sha512-QpY8ez9cTvXXPr3/KBrtSgXQHMSV6BkOUYy2c2TTe6cBqriEdGnCYqGl8cnfrQl3632q3lveQPaZ/c127dHsEw==", "dev": true, "dependencies": { "@angular-devkit/core": "17.3.8", @@ -2347,9 +2259,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.10.tgz", - "integrity": "sha512-i3HAtVQJijxNxJq1k39aelyJlyEIBRONys7IipH/4r8W0J+M1V+y5EKDOyi4j1SdNSb/vmNyWpZ2/ewZjl3kRA==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.1.tgz", + "integrity": "sha512-pR+su5+YGqCLH0RhhVkPowQK7FCORU0/PWAywPK7LScAOtD67ZoviZ7hAU4vnGdwkg4HCB0D7W8Bkg19CGU8Xw==", "dev": true, "dependencies": { "tslib": "2.6.3" @@ -2389,9 +2301,9 @@ } }, "node_modules/@nestjs/websockets": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.10.tgz", - "integrity": "sha512-F/fhAC0ylAhjfCZj4Xrgc0yTJ/qltooDCa+fke7BFZLofLmE0yj7WzBVrBHsk/46kppyRcs5XrYjIQLqcDze8g==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.1.tgz", + "integrity": "sha512-p0Eq94WneczV2bnLEu9hl24iCIfH5eUCGgBuYOkVDySBwvya5L+gD4wUoqIqGoX1c6rkhQa+pMR7pi1EY4t93w==", "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -4271,60 +4183,29 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@react-email/body": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.9.tgz", - "integrity": "sha512-bSGF6j+MbfQKYnnN+Kf57lGp/J+ci+435OMIv/BKAtfmNzHL+ptRrsINJELiO8QzwnZmQjTGKSMAMMJiQS+xwQ==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.10.tgz", + "integrity": "sha512-dMJyL9aU25ieatdPtVjCyQ/WHZYHwNc+Hy/XpF8Cc18gu21cUynVEeYQzFSeigDRMeBQ3PGAyjVDPIob7YlGwA==", "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/button": { - "version": "0.0.16", - "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.16.tgz", - "integrity": "sha512-paptUerzDhKHEUmBuT0UecCoqo3N6ZQSyDKC1hFALTwKReGW2xQATisinho9Ybh9ZGw6IZ3n1nGtmX5k2sX70Q==", + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.17.tgz", + "integrity": "sha512-ioHdsk+BpGS/PqjU6JS7tUrVy9yvbUx92Z+Cem2+MbYp55oEwQ9VHf7u4f5NoM0gdhfKSehBwRdYlHt/frEMcg==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/code-block": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.6.tgz", - "integrity": "sha512-i+TEeI7AyG1pmtO2Mr+TblV08zQnOtTlYB/v45kFMlDWWKTkvIV33oLRqLYOFhCIvoO5fDZA9T+4m6PvhmcNwQ==", + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.7.tgz", + "integrity": "sha512-3lYLwn9rK16I4JmTR/sTzAJMVHzUmmcT1PT27+TXnQyBCfpfDV+VockSg1qhsgCusA/u6j0C97BMsa96AWEbbw==", "dependencies": { "prismjs": "1.29.0" }, @@ -4332,156 +4213,153 @@ "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/code-inline": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.3.tgz", - "integrity": "sha512-SY5Nn4KhjcqqEBHvUwFlOLNmUT78elIGR+Y14eg02LrVKQJ38mFCfXNGDLk4wbP/2dnidkLYq9+60nf7mFMhnQ==", + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.4.tgz", + "integrity": "sha512-zj3oMQiiUCZbddSNt3k0zNfIBFK0ZNDIzzDyBaJKy6ZASTtWfB+1WFX0cpTX8q0gUiYK+A94rk5Qp68L6YXjXQ==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/column": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.11.tgz", - "integrity": "sha512-KvrPuQFn0hlItRRL3vmRuOJgKG+8I0oO9HM5ReLMi5Ns313JSEQogCJaXuOEFkOVeuu5YyY6zy/+5Esccc1AxQ==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.12.tgz", + "integrity": "sha512-Rsl7iSdDaeHZO938xb+0wR5ud0Z3MVfdtPbNKJNojZi2hApwLAQXmDrnn/AcPDM5Lpl331ZljJS8vHTWxxkvKw==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/components": { - "version": "0.0.22", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.22.tgz", - "integrity": "sha512-GO6F+fS3c3aQ6OnqL8esQ/KqtrPGwz80U6uQ8Nd/ETpgFt7y1PXvSGfr8v12wyLffAagdowc/JjoThfIr0L6aA==", + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.23.tgz", + "integrity": "sha512-RcBoffx2IZG6quLBXo5sj3fF47rKmmkiMhG1ZBua4nFjHYlmW8j1uUMyO5HNglxIF9E52NYq4sF7XeZRp9jYjg==", "dependencies": { - "@react-email/body": "0.0.9", - "@react-email/button": "0.0.16", - "@react-email/code-block": "0.0.6", - "@react-email/code-inline": "0.0.3", - "@react-email/column": "0.0.11", - "@react-email/container": "0.0.13", - "@react-email/font": "0.0.7", - "@react-email/head": "0.0.10", - "@react-email/heading": "0.0.13", - "@react-email/hr": "0.0.9", - "@react-email/html": "0.0.9", - "@react-email/img": "0.0.9", - "@react-email/link": "0.0.9", - "@react-email/markdown": "0.0.11", - "@react-email/preview": "0.0.10", - "@react-email/render": "0.0.17", - "@react-email/row": "0.0.9", - "@react-email/section": "0.0.13", - "@react-email/tailwind": "0.0.19", - "@react-email/text": "0.0.9" + "@react-email/body": "0.0.10", + "@react-email/button": "0.0.17", + "@react-email/code-block": "0.0.7", + "@react-email/code-inline": "0.0.4", + "@react-email/column": "0.0.12", + "@react-email/container": "0.0.14", + "@react-email/font": "0.0.8", + "@react-email/head": "0.0.11", + "@react-email/heading": "0.0.14", + "@react-email/hr": "0.0.10", + "@react-email/html": "0.0.10", + "@react-email/img": "0.0.10", + "@react-email/link": "0.0.10", + "@react-email/markdown": "0.0.12", + "@react-email/preview": "0.0.11", + "@react-email/render": "1.0.0", + "@react-email/row": "0.0.10", + "@react-email/section": "0.0.14", + "@react-email/tailwind": "0.1.0", + "@react-email/text": "0.0.10" }, "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/container": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.13.tgz", - "integrity": "sha512-ftke0N1FZl8MX3XXxXiiOaiJOnrQz7ZXUyqNj81K+BK+DePWIVaSmgK6Bu8fFnsgwdKuBdqjZTEtF4sIkU3FuQ==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.14.tgz", + "integrity": "sha512-NgoaJJd9tTtsrveL86Ocr/AYLkGyN3prdXKd/zm5fQpfDhy/NXezyT3iF6VlwAOEUIu64ErHpAJd+P6ygR+vjg==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/font": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.7.tgz", - "integrity": "sha512-R0/mfUV/XcUQIALjZUFT9GP+XGmIP1KPz20h9rpS5e4ji6VkQ3ENWlisxrdK5U+KA9iZQrlan+/6tUoTJ9bFsg==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.8.tgz", + "integrity": "sha512-fSBEqYyVPAyyACBBHcs3wEYzNknpHMuwcSAAKE8fOoDfGqURr/vSxKPdh4tOa9z7G4hlcEfgGrCYEa2iPT22cw==", "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/head": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.10.tgz", - "integrity": "sha512-VoH399w0/i3dJFnwH0Ixf9BTuiWhSA/y8PpsCJ7CPw8Mv8WNBqMAAsw0rmrITYI8uPd15LZ2zk2uwRDvqasMRw==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.11.tgz", + "integrity": "sha512-skw5FUgyamIMK+LN+fZQ5WIKQYf0dPiRAvsUAUR2eYoZp9oRsfkIpFHr0GWPkKAYjFEj+uJjaxQ/0VzQH7svVg==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/heading": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.13.tgz", - "integrity": "sha512-MYDzjJwljKHBLueLuyqkaHxu6N4aGOL1ms2NNyJ9WXC9mmBnLs4Y/QEf9SjE4Df3AW4iT9uyfVHuaNUb7uq5QA==", - "dependencies": { - "@radix-ui/react-slot": "1.1.0" - }, + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.14.tgz", + "integrity": "sha512-jZM7IVuZOXa0G110ES8OkxajPTypIKlzlO1K1RIe1auk76ukQRiCg1IRV4HZlWk1GGUbec5hNxsvZa2kU8cb9w==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/hr": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.9.tgz", - "integrity": "sha512-Rte+EZL3ptH3rkVU3a7fh8/06mZ6Q679tDaWDjsw3878RQC9afWqUPp5lwgA/1pTouLmJlDs2BjRnV6H84O7iw==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.10.tgz", + "integrity": "sha512-3AA4Yjgl3zEid/KVx6uf6TuLJHVZvUc2cG9Wm9ZpWeAX4ODA+8g9HyuC0tfnjbRsVMhMcCGiECuWWXINi+60vA==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/html": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.9.tgz", - "integrity": "sha512-NB74xwWaOJZxhpiy6pzkhHvugBa2vvmUa0KKnSwOEIX+WEQH8wj5UUhRN4F+Pmkiqz3QBTETUJiSsNWWFtrHgA==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.10.tgz", + "integrity": "sha512-06uiuSKJBWQJfhCKv4MPupELei4Lepyz9Sth7Yq7Fq29CAeB1ejLgKkGqn1I+FZ72hQxPLdYF4iq4yloKv3JCg==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/img": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.9.tgz", - "integrity": "sha512-zDlQWmlSANb2dBYhDaKD12Z4xaGD5mEf3peawBYHGxYySzMLwRT2ANGvFqpDNd7iT0C5po+/9EWR8fS1dLy0QQ==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.10.tgz", + "integrity": "sha512-pJ8glJjDNaJ53qoM95pvX9SK05yh0bNQY/oyBKmxlBDdUII6ixuMc3SCwYXPMl+tgkQUyDgwEBpSTrLAnjL3hA==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/link": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.9.tgz", - "integrity": "sha512-rRqWGPUTGFwwtMCtsdCHNh0ewOsd4UBG/D12UcwJYFKRb0U6hUG/6VJZE3tB1QYZpLIESdvOLL6ztznh+D749g==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.10.tgz", + "integrity": "sha512-tva3wvAWSR10lMJa9fVA09yRn7pbEki0ZZpHE6GD1jKbFhmzt38VgLO9B797/prqoDZdAr4rVK7LJFcdPx3GwA==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/markdown": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.11.tgz", - "integrity": "sha512-KeDTS0bAvvtgavYAIAmxKpRxWUSr1/jufckDzu9g4QsQtth8wYaSR5wCPXuTPmhFgJMIlNSlOiBnVp+oRbDtKA==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.12.tgz", + "integrity": "sha512-wsuvj1XAb6O63aizCLNEeqVgKR3oFjAwt9vjfg2y2oh4G1dZeo8zonZM2x1fmkEkBZhzwSHraNi70jSXhA3A9w==", "dependencies": { "md-to-react-email": "5.0.2" }, @@ -4489,24 +4367,24 @@ "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/preview": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.10.tgz", - "integrity": "sha512-bRrv8teMMBlF7ttLp1zZUejkPUzrwMQXrigdagtEBOqsB8HxvJU2MR6Yyb3XOqBYldaIDOQJ1z61zyD2wRlKAw==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.11.tgz", + "integrity": "sha512-7O/CT4b16YlSGrj18htTPx3Vbhu2suCGv/cSe5c+fuSrIM/nMiBSZ3Js16Vj0XJbAmmmlVmYFZw9L20wXJ+LjQ==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/render": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.17.tgz", - "integrity": "sha512-xBQ+/73+WsGuXKY7r1U73zMBNV28xdV0cp9cFjhNYipBReDHhV97IpA6v7Hl0dDtDzt+yS/72dY5vYXrF1v8NA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.0.tgz", + "integrity": "sha512-seN2p3JRUSZhwIUiymh9N6ZfhRZ14ywOraQqAokY63DkDeHZW2pA2a6nWpNc/igfOcNyt09Wsoi1Aj0esxhdzw==", "dependencies": { "html-to-text": "9.0.5", "js-beautify": "^1.14.11", @@ -4516,52 +4394,52 @@ "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/row": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.9.tgz", - "integrity": "sha512-ZDASHVvyKrWBS00o5pSH5khfMf46UtZhrHcSAfPSiC4nj7R8A0bf+3Wmbk8YmsaV+qWXUCUSHWwIAAlMRnJoAA==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.10.tgz", + "integrity": "sha512-jPyEhG3gsLX+Eb9U+A30fh0gK6hXJwF4ghJ+ZtFQtlKAKqHX+eCpWlqB3Xschd/ARJLod8WAswg0FB+JD9d0/A==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/section": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.13.tgz", - "integrity": "sha512-McsCQ5NQlNWEMEAR3EtCxHgRhxGmLD+jPvj7A3FD7y2X3fXG0hbmUGX12B63rIywSWjJoQi6tojx/8RpzbyeTA==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.14.tgz", + "integrity": "sha512-+fYWLb4tPU1A/+GE5J1+SEMA7/wR3V30lQ+OR9t2kAJqNrARDbMx0bLnYnR1QL5TiFRz0pCF05SQUobk6gHEDQ==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/tailwind": { - "version": "0.0.19", - "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.0.19.tgz", - "integrity": "sha512-bA0w4D7mSNowxWhcO0jBJauFIPf2Ok7QuKlrHwCcxyX35L2pb5D6ZmXYOrD9C6ADQuVz5oEX+oed3zpSLROgPg==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.1.0.tgz", + "integrity": "sha512-qysVUEY+M3SKUvu35XDpzn7yokhqFOT3tPU6Mj/pgc62TL5tQFj6msEbBtwoKs2qO3WZvai0DIHdLhaOxBQSow==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/text": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.9.tgz", - "integrity": "sha512-UNFPGerER3zywpb1ODOS2VgHP7rgOmiTxMHn75pjvQf/gi3/jN9edEQLYvRgPv/mNn4IpJFkOrlP8jcammLeew==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.10.tgz", + "integrity": "sha512-wNAnxeEAiFs6N+SxS0y6wTJWfewEzUETuyS2aZmT00xk50VijwyFRuhm4sYSjusMyshevomFwz5jNISCxRsGWw==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@rollup/pluginutils": { @@ -4869,9 +4747,9 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "node_modules/@swc/core": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.6.tgz", - "integrity": "sha512-FZxyao9eQks1MRmUshgsZTmlg/HB2oXK5fghkoWJm/1CU2q2kaJlVDll2as5j+rmWiwkp0Gidlq8wlXcEEAO+g==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.14.tgz", + "integrity": "sha512-9aeXeifnyuvc2pcuuhPQgVUwdpGEzZ+9nJu0W8/hNl/aESFsJGR5i9uQJRGu0atoNr01gK092fvmqMmQAPcKow==", "devOptional": true, "hasInstallScript": true, "dependencies": { @@ -4886,16 +4764,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.7.6", - "@swc/core-darwin-x64": "1.7.6", - "@swc/core-linux-arm-gnueabihf": "1.7.6", - "@swc/core-linux-arm64-gnu": "1.7.6", - "@swc/core-linux-arm64-musl": "1.7.6", - "@swc/core-linux-x64-gnu": "1.7.6", - "@swc/core-linux-x64-musl": "1.7.6", - "@swc/core-win32-arm64-msvc": "1.7.6", - "@swc/core-win32-ia32-msvc": "1.7.6", - "@swc/core-win32-x64-msvc": "1.7.6" + "@swc/core-darwin-arm64": "1.7.14", + "@swc/core-darwin-x64": "1.7.14", + "@swc/core-linux-arm-gnueabihf": "1.7.14", + "@swc/core-linux-arm64-gnu": "1.7.14", + "@swc/core-linux-arm64-musl": "1.7.14", + "@swc/core-linux-x64-gnu": "1.7.14", + "@swc/core-linux-x64-musl": "1.7.14", + "@swc/core-win32-arm64-msvc": "1.7.14", + "@swc/core-win32-ia32-msvc": "1.7.14", + "@swc/core-win32-x64-msvc": "1.7.14" }, "peerDependencies": { "@swc/helpers": "*" @@ -4907,9 +4785,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.6.tgz", - "integrity": "sha512-6lYHey84ZzsdtC7UuPheM4Rm0Inzxm6Sb8U6dmKc4eCx8JL0LfWG4LC5RsdsrTxnjTsbriWlnhZBffh8ijUHIQ==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.14.tgz", + "integrity": "sha512-V0OUXjOH+hdGxDYG8NkQzy25mKOpcNKFpqtZEzLe5V/CpLJPnpg1+pMz70m14s9ZFda9OxsjlvPbg1FLUwhgIQ==", "cpu": [ "arm64" ], @@ -4923,9 +4801,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.6.tgz", - "integrity": "sha512-Fyl+8aH9O5rpx4O7r2KnsPpoi32iWoKOYKiipeTbGjQ/E95tNPxbmsz4yqE8Ovldcga60IPJ5OKQA3HWRiuzdw==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.14.tgz", + "integrity": "sha512-9iFvUnxG6FC3An5ogp5jbBfQuUmTTwy8KMB+ZddUoPB3NR1eV+Y9vOh/tfWcenSJbgOKDLgYC5D/b1mHAprsrQ==", "cpu": [ "x64" ], @@ -4939,9 +4817,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.6.tgz", - "integrity": "sha512-2WxYTqFaOx48GKC2cbO1/IntA+w+kfCFy436Ij7qRqqtV/WAvTM9TC1OmiFbqq436rSot52qYmX8fkwdB5UcLQ==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.14.tgz", + "integrity": "sha512-zGJsef9qPivKSH8Vv4F/HiBXBTHZ5Hs3ZjVGo/UIdWPJF8fTL9OVADiRrl34Q7zOZEtGXRwEKLUW1SCQcbDvZA==", "cpu": [ "arm" ], @@ -4955,9 +4833,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.6.tgz", - "integrity": "sha512-TBEGMSe0LhvPe4S7E68c7VzgT3OMu4VTmBLS7B2aHv4v8uZO92Khpp7L0WqgYU1y5eMjk+XLDLi4kokiNHv/Hg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.14.tgz", + "integrity": "sha512-AxV3MPsoI7i4B8FXOew3dx3N8y00YoJYvIPfxelw07RegeCEH3aHp2U2DtgbP/NV1ugZMx0TL2Z2DEvocmA51g==", "cpu": [ "arm64" ], @@ -4971,9 +4849,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.6.tgz", - "integrity": "sha512-QI8QGL0HGT42tj7F1A+YAzhGkJjUcvvTfI1e2m704W0Enl2/UIK9v5D1zvQzYwusRyKuaQfbeBRYDh0NcLOGLg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.14.tgz", + "integrity": "sha512-JDLdNjUj3zPehd4+DrQD8Ltb3B5lD8D05IwePyDWw+uR/YPc7w/TX1FUVci5h3giJnlMCJRvi1IQYV7K1n7KtQ==", "cpu": [ "arm64" ], @@ -4987,9 +4865,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.6.tgz", - "integrity": "sha512-61AYVzhjuNQAVIKKWOJu3H0/pFD28RYJGxnGg3YMhvRLRyuWNyY5Nyyj2WkKcz/ON+g38Arlz00NT1LDIViRLg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.14.tgz", + "integrity": "sha512-Siy5OvPCLLWmMdx4msnEs8HvEVUEigSn0+3pbLjv78iwzXd0qSBNHUPZyC1xeurVaUbpNDxZTpPRIwpqNE2+Og==", "cpu": [ "x64" ], @@ -5003,9 +4881,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.6.tgz", - "integrity": "sha512-hQFznpfLK8XajfAAN9Cjs0w/aVmO7iu9VZvInyrTCRcPqxV5O+rvrhRxKvC1LRMZXr5M6JRSRtepp5w+TK4kAw==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.14.tgz", + "integrity": "sha512-FtEGm9mwtRYQNK43WMtUIadxHs/ja2rnDurB99os0ZoFTGG2IHuht2zD97W0wB8JbqEabT1XwSG9Y5wmN+ciEQ==", "cpu": [ "x64" ], @@ -5019,9 +4897,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.6.tgz", - "integrity": "sha512-Aqsd9afykVMuekzjm4X4TDqwxmG4CrzoOSFe0hZrn9SMio72l5eAPnMtYoe5LsIqtjV8MNprLfXaNbjHjTegmA==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.14.tgz", + "integrity": "sha512-Jp8KDlfq7Ntt2/BXr0y344cYgB1zf0DaLzDZ1ZJR6rYlAzWYSccLYcxHa97VGnsYhhPspMpmCvHid97oe2hl4A==", "cpu": [ "arm64" ], @@ -5035,9 +4913,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.6.tgz", - "integrity": "sha512-9h0hYnOeRVNeQgHQTvD1Im67faNSSzBZ7Adtxyu9urNLfBTJilMllFd2QuGHlKW5+uaT6ZH7ZWDb+c/enx7Lcg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.14.tgz", + "integrity": "sha512-I+cFsXF0OU0J9J4zdWiQKKLURO5dvCujH9Jr8N0cErdy54l9d4gfIxdctfTF+7FyXtWKLTCkp+oby9BQhkFGWA==", "cpu": [ "ia32" ], @@ -5051,9 +4929,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.6.tgz", - "integrity": "sha512-izeoB8glCSe6IIDQmrVm6bvR9muk9TeKgmtY7b6l1BwL4BFnTUk4dMmpbntT90bEVQn3JPCaPtUG4HfL8VuyuA==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.14.tgz", + "integrity": "sha512-NNrprQCK6d28mG436jVo2TD+vACHseUECacEBGZ9Ef0qfOIWS1XIt2MisQKG0Oea2VvLFl6tF/V4Lnx/H0Sn3Q==", "cpu": [ "x64" ], @@ -5508,8 +5386,7 @@ "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "optional": true, - "peer": true + "dev": true }, "node_modules/@types/qs": { "version": "6.9.8", @@ -5524,11 +5401,10 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", - "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", - "optional": true, - "peer": true, + "version": "18.3.4", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.4.tgz", + "integrity": "sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==", + "dev": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -5644,16 +5520,16 @@ "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", - "integrity": "sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.2.0.tgz", + "integrity": "sha512-02tJIs655em7fvt9gps/+4k4OsKULYGtLBPJfOsmOq1+3cdClYiF0+d6mHu6qDnTcg88wJBkcPLpQhq7FyDz0A==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/type-utils": "8.0.1", - "@typescript-eslint/utils": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/type-utils": "8.2.0", + "@typescript-eslint/utils": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -5677,15 +5553,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.1.tgz", - "integrity": "sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.2.0.tgz", + "integrity": "sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4" }, "engines": { @@ -5705,13 +5581,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", - "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.2.0.tgz", + "integrity": "sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1" + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5722,13 +5598,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.1.tgz", - "integrity": "sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.2.0.tgz", + "integrity": "sha512-g1CfXGFMQdT5S+0PSO0fvGXUaiSkl73U1n9LTK5aRAFnPlJ8dLKkXr4AaLFvPedW8lVDoMgLLE3JN98ZZfsj0w==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/utils": "8.0.1", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/utils": "8.2.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -5746,9 +5622,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.2.0.tgz", + "integrity": "sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5759,13 +5635,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", - "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.2.0.tgz", + "integrity": "sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -5811,15 +5687,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.1.tgz", - "integrity": "sha512-CBFR0G0sCt0+fzfnKaciu9IBsKvEKYwN9UZ+eeogK1fYHg4Qxk1yf/wLQkLXlq8wbU2dFlgAesxt8Gi76E8RTA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.2.0.tgz", + "integrity": "sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1" + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5833,12 +5709,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.2.0.tgz", + "integrity": "sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/types": "8.2.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -7620,8 +7496,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "optional": true, - "peer": true + "dev": true }, "node_modules/dayjs": { "version": "1.11.10", @@ -8167,16 +8042,16 @@ } }, "node_modules/eslint": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", - "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.0.tgz", + "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.17.1", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.8.0", + "@eslint/js": "9.9.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", @@ -8215,6 +8090,14 @@ }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-prettier": { @@ -10256,7 +10139,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -10767,9 +10649,9 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "node_modules/nest-commander": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.14.0.tgz", - "integrity": "sha512-3HEfsEzoKEZ/5/cptkXlL8/31qohPxtMevoFo4j9NMe3q5PgI/0TgTYN/6py9GnFD51jSasEfFGChs1BJ+Enag==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.15.0.tgz", + "integrity": "sha512-o9VEfFj/w2nm+hQi6fnkxL1GAFZW/KmuGcIE7/B/TX0gwm0QVy8svAF75EQm8wrDjcvWS7Cx/ArnkFn2C+iM2w==", "dependencies": { "@fig/complete-commander": "^3.0.0", "@golevelup/nestjs-discovery": "4.0.1", @@ -12034,7 +11916,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -13309,42 +13190,41 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "node_modules/sharp": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.4.tgz", - "integrity": "sha512-7i/dt5kGl7qR4gwPRD2biwD2/SvBn3O04J77XKFgL2OnZtQw+AG9wnuS/csmu80nPRHLYE9E41fyEiG8nhH6/Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", "hasInstallScript": true, "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", - "semver": "^7.6.0" + "semver": "^7.6.3" }, "engines": { - "libvips": ">=8.15.2", "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.4", - "@img/sharp-darwin-x64": "0.33.4", - "@img/sharp-libvips-darwin-arm64": "1.0.2", - "@img/sharp-libvips-darwin-x64": "1.0.2", - "@img/sharp-libvips-linux-arm": "1.0.2", - "@img/sharp-libvips-linux-arm64": "1.0.2", - "@img/sharp-libvips-linux-s390x": "1.0.2", - "@img/sharp-libvips-linux-x64": "1.0.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.2", - "@img/sharp-libvips-linuxmusl-x64": "1.0.2", - "@img/sharp-linux-arm": "0.33.4", - "@img/sharp-linux-arm64": "0.33.4", - "@img/sharp-linux-s390x": "0.33.4", - "@img/sharp-linux-x64": "0.33.4", - "@img/sharp-linuxmusl-arm64": "0.33.4", - "@img/sharp-linuxmusl-x64": "0.33.4", - "@img/sharp-wasm32": "0.33.4", - "@img/sharp-win32-ia32": "0.33.4", - "@img/sharp-win32-x64": "0.33.4" + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" } }, "node_modules/shebang-command": { @@ -13582,9 +13462,9 @@ } }, "node_modules/sql-formatter": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.3.2.tgz", - "integrity": "sha512-pNxSMf5DtwhpZ8gUcOGCGZIWtCcyAUx9oLgAtlO4ag7DvlfnETL0BGqXaISc84pNrXvTWmt8Wal1FWKxdTsL3Q==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.0.tgz", + "integrity": "sha512-h3uVulRmOfARvDejuSzs9GMbua/UmGCKiP08zyHT1PnG376zk9CHVsDAcKIc9TcIwIrDH3YULWwI4PrXdmLRVw==", "dev": true, "dependencies": { "argparse": "^2.0.1", @@ -15148,9 +15028,9 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { - "version": "5.92.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", - "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==", + "version": "5.93.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.93.0.tgz", + "integrity": "sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==", "dev": true, "dependencies": { "@types/eslint-scope": "^3.7.3", @@ -15931,9 +15811,9 @@ } }, "@emnapi/runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.1.1.tgz", - "integrity": "sha512-3bfqkzuR1KLx57nZfjr2NLnFOobvyS0aTszaEGCGqmYMVDRaGvgIZbjGSV/MHSSmLgQ/b9JFHQ5xm5WRZYd+XQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", + "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", "optional": true, "requires": { "tslib": "^2.4.0" @@ -16170,9 +16050,9 @@ } }, "@eslint/js": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", - "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.0.tgz", + "integrity": "sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==", "dev": true }, "@eslint/object-schema": { @@ -16277,144 +16157,144 @@ "dev": true }, "@img/sharp-darwin-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.4.tgz", - "integrity": "sha512-p0suNqXufJs9t3RqLBO6vvrgr5OhgbWp76s5gTRvdmxmuv9E1rcaqGUsl3l4mKVmXPkTkTErXediAui4x+8PSA==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", "optional": true, "requires": { - "@img/sharp-libvips-darwin-arm64": "1.0.2" + "@img/sharp-libvips-darwin-arm64": "1.0.4" } }, "@img/sharp-darwin-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.4.tgz", - "integrity": "sha512-0l7yRObwtTi82Z6ebVI2PnHT8EB2NxBgpK2MiKJZJ7cz32R4lxd001ecMhzzsZig3Yv9oclvqqdV93jo9hy+Dw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", "optional": true, "requires": { - "@img/sharp-libvips-darwin-x64": "1.0.2" + "@img/sharp-libvips-darwin-x64": "1.0.4" } }, "@img/sharp-libvips-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-tcK/41Rq8IKlSaKRCCAuuY3lDJjQnYIW1UXU1kxcEKrfL8WR7N6+rzNoOxoQRJWTAECuKwgAHnPvqXGN8XfkHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", "optional": true }, "@img/sharp-libvips-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.2.tgz", - "integrity": "sha512-Ofw+7oaWa0HiiMiKWqqaZbaYV3/UGL2wAPeLuJTx+9cXpCRdvQhCLG0IH8YGwM0yGWGLpsF4Su9vM1o6aer+Fw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", "optional": true }, "@img/sharp-libvips-linux-arm": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.2.tgz", - "integrity": "sha512-iLWCvrKgeFoglQxdEwzu1eQV04o8YeYGFXtfWU26Zr2wWT3q3MTzC+QTCO3ZQfWd3doKHT4Pm2kRmLbupT+sZw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", "optional": true }, "@img/sharp-libvips-linux-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.2.tgz", - "integrity": "sha512-x7kCt3N00ofFmmkkdshwj3vGPCnmiDh7Gwnd4nUwZln2YjqPxV1NlTyZOvoDWdKQVDL911487HOueBvrpflagw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", "optional": true }, "@img/sharp-libvips-linux-s390x": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.2.tgz", - "integrity": "sha512-cmhQ1J4qVhfmS6szYW7RT+gLJq9dH2i4maq+qyXayUSn9/3iY2ZeWpbAgSpSVbV2E1JUL2Gg7pwnYQ1h8rQIog==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", "optional": true }, "@img/sharp-libvips-linux-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.2.tgz", - "integrity": "sha512-E441q4Qdb+7yuyiADVi5J+44x8ctlrqn8XgkDTwr4qPJzWkaHwD489iZ4nGDgcuya4iMN3ULV6NwbhRZJ9Z7SQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", "optional": true }, "@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.2.tgz", - "integrity": "sha512-3CAkndNpYUrlDqkCM5qhksfE+qSIREVpyoeHIU6jd48SJZViAmznoQQLAv4hVXF7xyUB9zf+G++e2v1ABjCbEQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", "optional": true }, "@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.2.tgz", - "integrity": "sha512-VI94Q6khIHqHWNOh6LLdm9s2Ry4zdjWJwH56WoiJU7NTeDwyApdZZ8c+SADC8OH98KWNQXnE01UdJ9CSfZvwZw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", "optional": true }, "@img/sharp-linux-arm": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.4.tgz", - "integrity": "sha512-RUgBD1c0+gCYZGCCe6mMdTiOFS0Zc/XrN0fYd6hISIKcDUbAW5NtSQW9g/powkrXYm6Vzwd6y+fqmExDuCdHNQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", "optional": true, "requires": { - "@img/sharp-libvips-linux-arm": "1.0.2" + "@img/sharp-libvips-linux-arm": "1.0.5" } }, "@img/sharp-linux-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.4.tgz", - "integrity": "sha512-2800clwVg1ZQtxwSoTlHvtm9ObgAax7V6MTAB/hDT945Tfyy3hVkmiHpeLPCKYqYR1Gcmv1uDZ3a4OFwkdBL7Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", "optional": true, "requires": { - "@img/sharp-libvips-linux-arm64": "1.0.2" + "@img/sharp-libvips-linux-arm64": "1.0.4" } }, "@img/sharp-linux-s390x": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.4.tgz", - "integrity": "sha512-h3RAL3siQoyzSoH36tUeS0PDmb5wINKGYzcLB5C6DIiAn2F3udeFAum+gj8IbA/82+8RGCTn7XW8WTFnqag4tQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", "optional": true, "requires": { - "@img/sharp-libvips-linux-s390x": "1.0.2" + "@img/sharp-libvips-linux-s390x": "1.0.4" } }, "@img/sharp-linux-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.4.tgz", - "integrity": "sha512-GoR++s0XW9DGVi8SUGQ/U4AeIzLdNjHka6jidVwapQ/JebGVQIpi52OdyxCNVRE++n1FCLzjDovJNozif7w/Aw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", "optional": true, "requires": { - "@img/sharp-libvips-linux-x64": "1.0.2" + "@img/sharp-libvips-linux-x64": "1.0.4" } }, "@img/sharp-linuxmusl-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.4.tgz", - "integrity": "sha512-nhr1yC3BlVrKDTl6cO12gTpXMl4ITBUZieehFvMntlCXFzH2bvKG76tBL2Y/OqhupZt81pR7R+Q5YhJxW0rGgQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", "optional": true, "requires": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" } }, "@img/sharp-linuxmusl-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.4.tgz", - "integrity": "sha512-uCPTku0zwqDmZEOi4ILyGdmW76tH7dm8kKlOIV1XC5cLyJ71ENAAqarOHQh0RLfpIpbV5KOpXzdU6XkJtS0daw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", "optional": true, "requires": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.2" + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" } }, "@img/sharp-wasm32": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.4.tgz", - "integrity": "sha512-Bmmauh4sXUsUqkleQahpdNXKvo+wa1V9KhT2pDA4VJGKwnKMJXiSTGphn0gnJrlooda0QxCtXc6RX1XAU6hMnQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", "optional": true, "requires": { - "@emnapi/runtime": "^1.1.1" + "@emnapi/runtime": "^1.2.0" } }, "@img/sharp-win32-ia32": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.4.tgz", - "integrity": "sha512-99SJ91XzUhYHbx7uhK3+9Lf7+LjwMGQZMDlO/E/YVJ7Nc3lyDFZPGhjwiYdctoH2BOzW9+TnfqcaMKt0jHLdqw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", "optional": true }, "@img/sharp-win32-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.4.tgz", - "integrity": "sha512-3QLocdTRVIrFNye5YocZl+KKpYKP+fksi1QhmOArgx7GyhIbQp/WrJRu176jm8IxromS7RIkzMiMINVdBtC8Aw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", "optional": true }, "@ioredis/commands": { @@ -16600,26 +16480,26 @@ "optional": true }, "@nestjs/bull-shared": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.0.tgz", - "integrity": "sha512-cSi6CyPECHDFumnHWWfwLCnbc6hm5jXt7FqzJ0Id6EhGqdz5ja0FmgRwXoS4xoMA2RRjlxn2vGXr4YOaHBAeig==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.1.tgz", + "integrity": "sha512-zvnTvSq6OJ92omcsFUwaUmPbM3PRgWkIusHPB5TE3IFS7nNdM3OwF+kfe56sgKjMtQQMe/56lok0S04OtPMX5Q==", "requires": { "tslib": "2.6.3" } }, "@nestjs/bullmq": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.0.tgz", - "integrity": "sha512-lHXWDocXh1Yl6unsUzGFEKmK02mu0DdI35cdBp3Fq/9D5V3oLuWjwAPFnTztedshIjlFmNW6x5mdaT5WZ0AV1Q==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.1.tgz", + "integrity": "sha512-nDR0hDabmtXt5gsb5R786BJsGIJoWh/79sVmRETXf4S45+fvdqG1XkCKAeHF9TO9USodw9m+XBNKysTnkY41gw==", "requires": { - "@nestjs/bull-shared": "^10.2.0", + "@nestjs/bull-shared": "^10.2.1", "tslib": "2.6.3" } }, "@nestjs/cli": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.2.tgz", - "integrity": "sha512-fQexIfLHfp6GUgX+CO4fOg+AEwV5ox/LHotQhyZi9wXUQDyIqS0NTTbumr//62EcX35qV4nU0359nYnuEdzG+A==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.4.tgz", + "integrity": "sha512-WKERbSZJGof0+9XeeMmWnb/9FpNxogcB5eTJTHjc9no0ymdTw3jTzT+KZL9iC/hGqBpuomDLaNFCYbAOt29nBw==", "dev": true, "requires": { "@angular-devkit/core": "17.3.8", @@ -16639,7 +16519,7 @@ "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.1.0", "typescript": "5.3.3", - "webpack": "5.92.1", + "webpack": "5.93.0", "webpack-node-externals": "3.0.0" }, "dependencies": { @@ -16652,9 +16532,9 @@ } }, "@nestjs/common": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.10.tgz", - "integrity": "sha512-H8k0jZtxk1IdtErGDmxFRy0PfcOAUg41Prrqpx76DQusGGJjsaovs1zjXVD1rZWaVYchfT1uczJ6L4Kio10VNg==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", + "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", "requires": { "iterare": "1.2.1", "tslib": "2.6.3", @@ -16672,9 +16552,9 @@ } }, "@nestjs/core": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.10.tgz", - "integrity": "sha512-ZbQ4jovQyzHtCGCrzK5NdtW1SYO2fHSsgSY1+/9WdruYCUra+JDkWEXgZ4M3Hv480Dl3OXehAmY1wCOojeMyMQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.1.tgz", + "integrity": "sha512-9I1WdfOBCCHdUm+ClBJupOuZQS6UxzIWHIq6Vp1brAA5ZKl/Wq6BVwSsbnUJGBy3J3PM2XHmR0EQ4fwX3nR7lA==", "requires": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -16699,9 +16579,9 @@ "requires": {} }, "@nestjs/platform-express": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.10.tgz", - "integrity": "sha512-wK2ow3CZI2KFqWeEpPmoR300OB6BcBLxARV1EiClJLCj4S1mZsoCmS0YWgpk3j1j6mo0SI8vNLi/cC2iZPEPQA==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.1.tgz", + "integrity": "sha512-ccfqIDAq/bg1ShLI5KGtaLaYGykuAdvCi57ohewH7eKJSIpWY1DQjbgKlFfXokALYUq1YOMGqjeZ244OWHfDQg==", "requires": { "body-parser": "1.20.2", "cors": "2.8.5", @@ -16711,9 +16591,9 @@ } }, "@nestjs/platform-socket.io": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.10.tgz", - "integrity": "sha512-LRd+nGWhUu9hND1txCLPZd78Hea+qKJVENb+c9aDU04T24GRjsInDF2RANMR16JLQFcI9mclktDWX4plE95SHg==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", + "integrity": "sha512-cxn5vKBAbqtEVPl0qVcJpR4sC12+hzcY/mYXGW6ippOKQDBNc2OF8oZXu6V3O1MvAl+VM7eNNEsLmP9DRKQlnw==", "requires": { "socket.io": "4.7.5", "tslib": "2.6.3" @@ -16736,9 +16616,9 @@ } }, "@nestjs/schematics": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.3.tgz", - "integrity": "sha512-aLJ4Nl/K/u6ZlgLa0NjKw5CuBOIgc6vudF42QvmGueu5FaMGM6IJrAuEvB5T2kr0PAfVwYmDFBBHCWdYhTw4Tg==", + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.4.tgz", + "integrity": "sha512-QpY8ez9cTvXXPr3/KBrtSgXQHMSV6BkOUYy2c2TTe6cBqriEdGnCYqGl8cnfrQl3632q3lveQPaZ/c127dHsEw==", "dev": true, "requires": { "@angular-devkit/core": "17.3.8", @@ -16770,9 +16650,9 @@ } }, "@nestjs/testing": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.10.tgz", - "integrity": "sha512-i3HAtVQJijxNxJq1k39aelyJlyEIBRONys7IipH/4r8W0J+M1V+y5EKDOyi4j1SdNSb/vmNyWpZ2/ewZjl3kRA==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.1.tgz", + "integrity": "sha512-pR+su5+YGqCLH0RhhVkPowQK7FCORU0/PWAywPK7LScAOtD67ZoviZ7hAU4vnGdwkg4HCB0D7W8Bkg19CGU8Xw==", "dev": true, "requires": { "tslib": "2.6.3" @@ -16787,9 +16667,9 @@ } }, "@nestjs/websockets": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.10.tgz", - "integrity": "sha512-F/fhAC0ylAhjfCZj4Xrgc0yTJ/qltooDCa+fke7BFZLofLmE0yj7WzBVrBHsk/46kppyRcs5XrYjIQLqcDze8g==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.1.tgz", + "integrity": "sha512-p0Eq94WneczV2bnLEu9hl24iCIfH5eUCGgBuYOkVDySBwvya5L+gD4wUoqIqGoX1c6rkhQa+pMR7pi1EY4t93w==", "requires": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -18008,147 +17888,131 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, - "@radix-ui/react-compose-refs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", - "requires": {} - }, - "@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "requires": { - "@radix-ui/react-compose-refs": "1.1.0" - } - }, "@react-email/body": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.9.tgz", - "integrity": "sha512-bSGF6j+MbfQKYnnN+Kf57lGp/J+ci+435OMIv/BKAtfmNzHL+ptRrsINJELiO8QzwnZmQjTGKSMAMMJiQS+xwQ==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.10.tgz", + "integrity": "sha512-dMJyL9aU25ieatdPtVjCyQ/WHZYHwNc+Hy/XpF8Cc18gu21cUynVEeYQzFSeigDRMeBQ3PGAyjVDPIob7YlGwA==", "requires": {} }, "@react-email/button": { - "version": "0.0.16", - "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.16.tgz", - "integrity": "sha512-paptUerzDhKHEUmBuT0UecCoqo3N6ZQSyDKC1hFALTwKReGW2xQATisinho9Ybh9ZGw6IZ3n1nGtmX5k2sX70Q==", + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.17.tgz", + "integrity": "sha512-ioHdsk+BpGS/PqjU6JS7tUrVy9yvbUx92Z+Cem2+MbYp55oEwQ9VHf7u4f5NoM0gdhfKSehBwRdYlHt/frEMcg==", "requires": {} }, "@react-email/code-block": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.6.tgz", - "integrity": "sha512-i+TEeI7AyG1pmtO2Mr+TblV08zQnOtTlYB/v45kFMlDWWKTkvIV33oLRqLYOFhCIvoO5fDZA9T+4m6PvhmcNwQ==", + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.7.tgz", + "integrity": "sha512-3lYLwn9rK16I4JmTR/sTzAJMVHzUmmcT1PT27+TXnQyBCfpfDV+VockSg1qhsgCusA/u6j0C97BMsa96AWEbbw==", "requires": { "prismjs": "1.29.0" } }, "@react-email/code-inline": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.3.tgz", - "integrity": "sha512-SY5Nn4KhjcqqEBHvUwFlOLNmUT78elIGR+Y14eg02LrVKQJ38mFCfXNGDLk4wbP/2dnidkLYq9+60nf7mFMhnQ==", + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.4.tgz", + "integrity": "sha512-zj3oMQiiUCZbddSNt3k0zNfIBFK0ZNDIzzDyBaJKy6ZASTtWfB+1WFX0cpTX8q0gUiYK+A94rk5Qp68L6YXjXQ==", "requires": {} }, "@react-email/column": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.11.tgz", - "integrity": "sha512-KvrPuQFn0hlItRRL3vmRuOJgKG+8I0oO9HM5ReLMi5Ns313JSEQogCJaXuOEFkOVeuu5YyY6zy/+5Esccc1AxQ==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.12.tgz", + "integrity": "sha512-Rsl7iSdDaeHZO938xb+0wR5ud0Z3MVfdtPbNKJNojZi2hApwLAQXmDrnn/AcPDM5Lpl331ZljJS8vHTWxxkvKw==", "requires": {} }, "@react-email/components": { - "version": "0.0.22", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.22.tgz", - "integrity": "sha512-GO6F+fS3c3aQ6OnqL8esQ/KqtrPGwz80U6uQ8Nd/ETpgFt7y1PXvSGfr8v12wyLffAagdowc/JjoThfIr0L6aA==", + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.23.tgz", + "integrity": "sha512-RcBoffx2IZG6quLBXo5sj3fF47rKmmkiMhG1ZBua4nFjHYlmW8j1uUMyO5HNglxIF9E52NYq4sF7XeZRp9jYjg==", "requires": { - "@react-email/body": "0.0.9", - "@react-email/button": "0.0.16", - "@react-email/code-block": "0.0.6", - "@react-email/code-inline": "0.0.3", - "@react-email/column": "0.0.11", - "@react-email/container": "0.0.13", - "@react-email/font": "0.0.7", - "@react-email/head": "0.0.10", - "@react-email/heading": "0.0.13", - "@react-email/hr": "0.0.9", - "@react-email/html": "0.0.9", - "@react-email/img": "0.0.9", - "@react-email/link": "0.0.9", - "@react-email/markdown": "0.0.11", - "@react-email/preview": "0.0.10", - "@react-email/render": "0.0.17", - "@react-email/row": "0.0.9", - "@react-email/section": "0.0.13", - "@react-email/tailwind": "0.0.19", - "@react-email/text": "0.0.9" + "@react-email/body": "0.0.10", + "@react-email/button": "0.0.17", + "@react-email/code-block": "0.0.7", + "@react-email/code-inline": "0.0.4", + "@react-email/column": "0.0.12", + "@react-email/container": "0.0.14", + "@react-email/font": "0.0.8", + "@react-email/head": "0.0.11", + "@react-email/heading": "0.0.14", + "@react-email/hr": "0.0.10", + "@react-email/html": "0.0.10", + "@react-email/img": "0.0.10", + "@react-email/link": "0.0.10", + "@react-email/markdown": "0.0.12", + "@react-email/preview": "0.0.11", + "@react-email/render": "1.0.0", + "@react-email/row": "0.0.10", + "@react-email/section": "0.0.14", + "@react-email/tailwind": "0.1.0", + "@react-email/text": "0.0.10" } }, "@react-email/container": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.13.tgz", - "integrity": "sha512-ftke0N1FZl8MX3XXxXiiOaiJOnrQz7ZXUyqNj81K+BK+DePWIVaSmgK6Bu8fFnsgwdKuBdqjZTEtF4sIkU3FuQ==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.14.tgz", + "integrity": "sha512-NgoaJJd9tTtsrveL86Ocr/AYLkGyN3prdXKd/zm5fQpfDhy/NXezyT3iF6VlwAOEUIu64ErHpAJd+P6ygR+vjg==", "requires": {} }, "@react-email/font": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.7.tgz", - "integrity": "sha512-R0/mfUV/XcUQIALjZUFT9GP+XGmIP1KPz20h9rpS5e4ji6VkQ3ENWlisxrdK5U+KA9iZQrlan+/6tUoTJ9bFsg==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.8.tgz", + "integrity": "sha512-fSBEqYyVPAyyACBBHcs3wEYzNknpHMuwcSAAKE8fOoDfGqURr/vSxKPdh4tOa9z7G4hlcEfgGrCYEa2iPT22cw==", "requires": {} }, "@react-email/head": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.10.tgz", - "integrity": "sha512-VoH399w0/i3dJFnwH0Ixf9BTuiWhSA/y8PpsCJ7CPw8Mv8WNBqMAAsw0rmrITYI8uPd15LZ2zk2uwRDvqasMRw==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.11.tgz", + "integrity": "sha512-skw5FUgyamIMK+LN+fZQ5WIKQYf0dPiRAvsUAUR2eYoZp9oRsfkIpFHr0GWPkKAYjFEj+uJjaxQ/0VzQH7svVg==", "requires": {} }, "@react-email/heading": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.13.tgz", - "integrity": "sha512-MYDzjJwljKHBLueLuyqkaHxu6N4aGOL1ms2NNyJ9WXC9mmBnLs4Y/QEf9SjE4Df3AW4iT9uyfVHuaNUb7uq5QA==", - "requires": { - "@radix-ui/react-slot": "1.1.0" - } + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.14.tgz", + "integrity": "sha512-jZM7IVuZOXa0G110ES8OkxajPTypIKlzlO1K1RIe1auk76ukQRiCg1IRV4HZlWk1GGUbec5hNxsvZa2kU8cb9w==", + "requires": {} }, "@react-email/hr": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.9.tgz", - "integrity": "sha512-Rte+EZL3ptH3rkVU3a7fh8/06mZ6Q679tDaWDjsw3878RQC9afWqUPp5lwgA/1pTouLmJlDs2BjRnV6H84O7iw==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.10.tgz", + "integrity": "sha512-3AA4Yjgl3zEid/KVx6uf6TuLJHVZvUc2cG9Wm9ZpWeAX4ODA+8g9HyuC0tfnjbRsVMhMcCGiECuWWXINi+60vA==", "requires": {} }, "@react-email/html": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.9.tgz", - "integrity": "sha512-NB74xwWaOJZxhpiy6pzkhHvugBa2vvmUa0KKnSwOEIX+WEQH8wj5UUhRN4F+Pmkiqz3QBTETUJiSsNWWFtrHgA==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.10.tgz", + "integrity": "sha512-06uiuSKJBWQJfhCKv4MPupELei4Lepyz9Sth7Yq7Fq29CAeB1ejLgKkGqn1I+FZ72hQxPLdYF4iq4yloKv3JCg==", "requires": {} }, "@react-email/img": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.9.tgz", - "integrity": "sha512-zDlQWmlSANb2dBYhDaKD12Z4xaGD5mEf3peawBYHGxYySzMLwRT2ANGvFqpDNd7iT0C5po+/9EWR8fS1dLy0QQ==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.10.tgz", + "integrity": "sha512-pJ8glJjDNaJ53qoM95pvX9SK05yh0bNQY/oyBKmxlBDdUII6ixuMc3SCwYXPMl+tgkQUyDgwEBpSTrLAnjL3hA==", "requires": {} }, "@react-email/link": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.9.tgz", - "integrity": "sha512-rRqWGPUTGFwwtMCtsdCHNh0ewOsd4UBG/D12UcwJYFKRb0U6hUG/6VJZE3tB1QYZpLIESdvOLL6ztznh+D749g==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.10.tgz", + "integrity": "sha512-tva3wvAWSR10lMJa9fVA09yRn7pbEki0ZZpHE6GD1jKbFhmzt38VgLO9B797/prqoDZdAr4rVK7LJFcdPx3GwA==", "requires": {} }, "@react-email/markdown": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.11.tgz", - "integrity": "sha512-KeDTS0bAvvtgavYAIAmxKpRxWUSr1/jufckDzu9g4QsQtth8wYaSR5wCPXuTPmhFgJMIlNSlOiBnVp+oRbDtKA==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.12.tgz", + "integrity": "sha512-wsuvj1XAb6O63aizCLNEeqVgKR3oFjAwt9vjfg2y2oh4G1dZeo8zonZM2x1fmkEkBZhzwSHraNi70jSXhA3A9w==", "requires": { "md-to-react-email": "5.0.2" } }, "@react-email/preview": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.10.tgz", - "integrity": "sha512-bRrv8teMMBlF7ttLp1zZUejkPUzrwMQXrigdagtEBOqsB8HxvJU2MR6Yyb3XOqBYldaIDOQJ1z61zyD2wRlKAw==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.11.tgz", + "integrity": "sha512-7O/CT4b16YlSGrj18htTPx3Vbhu2suCGv/cSe5c+fuSrIM/nMiBSZ3Js16Vj0XJbAmmmlVmYFZw9L20wXJ+LjQ==", "requires": {} }, "@react-email/render": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.17.tgz", - "integrity": "sha512-xBQ+/73+WsGuXKY7r1U73zMBNV28xdV0cp9cFjhNYipBReDHhV97IpA6v7Hl0dDtDzt+yS/72dY5vYXrF1v8NA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.0.tgz", + "integrity": "sha512-seN2p3JRUSZhwIUiymh9N6ZfhRZ14ywOraQqAokY63DkDeHZW2pA2a6nWpNc/igfOcNyt09Wsoi1Aj0esxhdzw==", "requires": { "html-to-text": "9.0.5", "js-beautify": "^1.14.11", @@ -18156,27 +18020,27 @@ } }, "@react-email/row": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.9.tgz", - "integrity": "sha512-ZDASHVvyKrWBS00o5pSH5khfMf46UtZhrHcSAfPSiC4nj7R8A0bf+3Wmbk8YmsaV+qWXUCUSHWwIAAlMRnJoAA==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.10.tgz", + "integrity": "sha512-jPyEhG3gsLX+Eb9U+A30fh0gK6hXJwF4ghJ+ZtFQtlKAKqHX+eCpWlqB3Xschd/ARJLod8WAswg0FB+JD9d0/A==", "requires": {} }, "@react-email/section": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.13.tgz", - "integrity": "sha512-McsCQ5NQlNWEMEAR3EtCxHgRhxGmLD+jPvj7A3FD7y2X3fXG0hbmUGX12B63rIywSWjJoQi6tojx/8RpzbyeTA==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.14.tgz", + "integrity": "sha512-+fYWLb4tPU1A/+GE5J1+SEMA7/wR3V30lQ+OR9t2kAJqNrARDbMx0bLnYnR1QL5TiFRz0pCF05SQUobk6gHEDQ==", "requires": {} }, "@react-email/tailwind": { - "version": "0.0.19", - "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.0.19.tgz", - "integrity": "sha512-bA0w4D7mSNowxWhcO0jBJauFIPf2Ok7QuKlrHwCcxyX35L2pb5D6ZmXYOrD9C6ADQuVz5oEX+oed3zpSLROgPg==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.1.0.tgz", + "integrity": "sha512-qysVUEY+M3SKUvu35XDpzn7yokhqFOT3tPU6Mj/pgc62TL5tQFj6msEbBtwoKs2qO3WZvai0DIHdLhaOxBQSow==", "requires": {} }, "@react-email/text": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.9.tgz", - "integrity": "sha512-UNFPGerER3zywpb1ODOS2VgHP7rgOmiTxMHn75pjvQf/gi3/jN9edEQLYvRgPv/mNn4IpJFkOrlP8jcammLeew==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.10.tgz", + "integrity": "sha512-wNAnxeEAiFs6N+SxS0y6wTJWfewEzUETuyS2aZmT00xk50VijwyFRuhm4sYSjusMyshevomFwz5jNISCxRsGWw==", "requires": {} }, "@rollup/pluginutils": { @@ -18364,92 +18228,92 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "@swc/core": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.6.tgz", - "integrity": "sha512-FZxyao9eQks1MRmUshgsZTmlg/HB2oXK5fghkoWJm/1CU2q2kaJlVDll2as5j+rmWiwkp0Gidlq8wlXcEEAO+g==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.14.tgz", + "integrity": "sha512-9aeXeifnyuvc2pcuuhPQgVUwdpGEzZ+9nJu0W8/hNl/aESFsJGR5i9uQJRGu0atoNr01gK092fvmqMmQAPcKow==", "devOptional": true, "requires": { - "@swc/core-darwin-arm64": "1.7.6", - "@swc/core-darwin-x64": "1.7.6", - "@swc/core-linux-arm-gnueabihf": "1.7.6", - "@swc/core-linux-arm64-gnu": "1.7.6", - "@swc/core-linux-arm64-musl": "1.7.6", - "@swc/core-linux-x64-gnu": "1.7.6", - "@swc/core-linux-x64-musl": "1.7.6", - "@swc/core-win32-arm64-msvc": "1.7.6", - "@swc/core-win32-ia32-msvc": "1.7.6", - "@swc/core-win32-x64-msvc": "1.7.6", + "@swc/core-darwin-arm64": "1.7.14", + "@swc/core-darwin-x64": "1.7.14", + "@swc/core-linux-arm-gnueabihf": "1.7.14", + "@swc/core-linux-arm64-gnu": "1.7.14", + "@swc/core-linux-arm64-musl": "1.7.14", + "@swc/core-linux-x64-gnu": "1.7.14", + "@swc/core-linux-x64-musl": "1.7.14", + "@swc/core-win32-arm64-msvc": "1.7.14", + "@swc/core-win32-ia32-msvc": "1.7.14", + "@swc/core-win32-x64-msvc": "1.7.14", "@swc/counter": "^0.1.3", "@swc/types": "^0.1.12" } }, "@swc/core-darwin-arm64": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.6.tgz", - "integrity": "sha512-6lYHey84ZzsdtC7UuPheM4Rm0Inzxm6Sb8U6dmKc4eCx8JL0LfWG4LC5RsdsrTxnjTsbriWlnhZBffh8ijUHIQ==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.14.tgz", + "integrity": "sha512-V0OUXjOH+hdGxDYG8NkQzy25mKOpcNKFpqtZEzLe5V/CpLJPnpg1+pMz70m14s9ZFda9OxsjlvPbg1FLUwhgIQ==", "dev": true, "optional": true }, "@swc/core-darwin-x64": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.6.tgz", - "integrity": "sha512-Fyl+8aH9O5rpx4O7r2KnsPpoi32iWoKOYKiipeTbGjQ/E95tNPxbmsz4yqE8Ovldcga60IPJ5OKQA3HWRiuzdw==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.14.tgz", + "integrity": "sha512-9iFvUnxG6FC3An5ogp5jbBfQuUmTTwy8KMB+ZddUoPB3NR1eV+Y9vOh/tfWcenSJbgOKDLgYC5D/b1mHAprsrQ==", "dev": true, "optional": true }, "@swc/core-linux-arm-gnueabihf": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.6.tgz", - "integrity": "sha512-2WxYTqFaOx48GKC2cbO1/IntA+w+kfCFy436Ij7qRqqtV/WAvTM9TC1OmiFbqq436rSot52qYmX8fkwdB5UcLQ==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.14.tgz", + "integrity": "sha512-zGJsef9qPivKSH8Vv4F/HiBXBTHZ5Hs3ZjVGo/UIdWPJF8fTL9OVADiRrl34Q7zOZEtGXRwEKLUW1SCQcbDvZA==", "dev": true, "optional": true }, "@swc/core-linux-arm64-gnu": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.6.tgz", - "integrity": "sha512-TBEGMSe0LhvPe4S7E68c7VzgT3OMu4VTmBLS7B2aHv4v8uZO92Khpp7L0WqgYU1y5eMjk+XLDLi4kokiNHv/Hg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.14.tgz", + "integrity": "sha512-AxV3MPsoI7i4B8FXOew3dx3N8y00YoJYvIPfxelw07RegeCEH3aHp2U2DtgbP/NV1ugZMx0TL2Z2DEvocmA51g==", "dev": true, "optional": true }, "@swc/core-linux-arm64-musl": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.6.tgz", - "integrity": "sha512-QI8QGL0HGT42tj7F1A+YAzhGkJjUcvvTfI1e2m704W0Enl2/UIK9v5D1zvQzYwusRyKuaQfbeBRYDh0NcLOGLg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.14.tgz", + "integrity": "sha512-JDLdNjUj3zPehd4+DrQD8Ltb3B5lD8D05IwePyDWw+uR/YPc7w/TX1FUVci5h3giJnlMCJRvi1IQYV7K1n7KtQ==", "dev": true, "optional": true }, "@swc/core-linux-x64-gnu": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.6.tgz", - "integrity": "sha512-61AYVzhjuNQAVIKKWOJu3H0/pFD28RYJGxnGg3YMhvRLRyuWNyY5Nyyj2WkKcz/ON+g38Arlz00NT1LDIViRLg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.14.tgz", + "integrity": "sha512-Siy5OvPCLLWmMdx4msnEs8HvEVUEigSn0+3pbLjv78iwzXd0qSBNHUPZyC1xeurVaUbpNDxZTpPRIwpqNE2+Og==", "dev": true, "optional": true }, "@swc/core-linux-x64-musl": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.6.tgz", - "integrity": "sha512-hQFznpfLK8XajfAAN9Cjs0w/aVmO7iu9VZvInyrTCRcPqxV5O+rvrhRxKvC1LRMZXr5M6JRSRtepp5w+TK4kAw==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.14.tgz", + "integrity": "sha512-FtEGm9mwtRYQNK43WMtUIadxHs/ja2rnDurB99os0ZoFTGG2IHuht2zD97W0wB8JbqEabT1XwSG9Y5wmN+ciEQ==", "dev": true, "optional": true }, "@swc/core-win32-arm64-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.6.tgz", - "integrity": "sha512-Aqsd9afykVMuekzjm4X4TDqwxmG4CrzoOSFe0hZrn9SMio72l5eAPnMtYoe5LsIqtjV8MNprLfXaNbjHjTegmA==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.14.tgz", + "integrity": "sha512-Jp8KDlfq7Ntt2/BXr0y344cYgB1zf0DaLzDZ1ZJR6rYlAzWYSccLYcxHa97VGnsYhhPspMpmCvHid97oe2hl4A==", "dev": true, "optional": true }, "@swc/core-win32-ia32-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.6.tgz", - "integrity": "sha512-9h0hYnOeRVNeQgHQTvD1Im67faNSSzBZ7Adtxyu9urNLfBTJilMllFd2QuGHlKW5+uaT6ZH7ZWDb+c/enx7Lcg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.14.tgz", + "integrity": "sha512-I+cFsXF0OU0J9J4zdWiQKKLURO5dvCujH9Jr8N0cErdy54l9d4gfIxdctfTF+7FyXtWKLTCkp+oby9BQhkFGWA==", "dev": true, "optional": true }, "@swc/core-win32-x64-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.6.tgz", - "integrity": "sha512-izeoB8glCSe6IIDQmrVm6bvR9muk9TeKgmtY7b6l1BwL4BFnTUk4dMmpbntT90bEVQn3JPCaPtUG4HfL8VuyuA==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.14.tgz", + "integrity": "sha512-NNrprQCK6d28mG436jVo2TD+vACHseUECacEBGZ9Ef0qfOIWS1XIt2MisQKG0Oea2VvLFl6tF/V4Lnx/H0Sn3Q==", "dev": true, "optional": true }, @@ -18873,8 +18737,7 @@ "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "optional": true, - "peer": true + "dev": true }, "@types/qs": { "version": "6.9.8", @@ -18889,11 +18752,10 @@ "dev": true }, "@types/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", - "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", - "optional": true, - "peer": true, + "version": "18.3.4", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.4.tgz", + "integrity": "sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==", + "dev": true, "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -19009,16 +18871,16 @@ "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, "@typescript-eslint/eslint-plugin": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", - "integrity": "sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.2.0.tgz", + "integrity": "sha512-02tJIs655em7fvt9gps/+4k4OsKULYGtLBPJfOsmOq1+3cdClYiF0+d6mHu6qDnTcg88wJBkcPLpQhq7FyDz0A==", "dev": true, "requires": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/type-utils": "8.0.1", - "@typescript-eslint/utils": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/type-utils": "8.2.0", + "@typescript-eslint/utils": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -19026,54 +18888,54 @@ } }, "@typescript-eslint/parser": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.1.tgz", - "integrity": "sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.2.0.tgz", + "integrity": "sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", - "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.2.0.tgz", + "integrity": "sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==", "dev": true, "requires": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1" + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0" } }, "@typescript-eslint/type-utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.1.tgz", - "integrity": "sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.2.0.tgz", + "integrity": "sha512-g1CfXGFMQdT5S+0PSO0fvGXUaiSkl73U1n9LTK5aRAFnPlJ8dLKkXr4AaLFvPedW8lVDoMgLLE3JN98ZZfsj0w==", "dev": true, "requires": { - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/utils": "8.0.1", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/utils": "8.2.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" } }, "@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.2.0.tgz", + "integrity": "sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", - "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.2.0.tgz", + "integrity": "sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==", "dev": true, "requires": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -19103,24 +18965,24 @@ } }, "@typescript-eslint/utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.1.tgz", - "integrity": "sha512-CBFR0G0sCt0+fzfnKaciu9IBsKvEKYwN9UZ+eeogK1fYHg4Qxk1yf/wLQkLXlq8wbU2dFlgAesxt8Gi76E8RTA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.2.0.tgz", + "integrity": "sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1" + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0" } }, "@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.2.0.tgz", + "integrity": "sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==", "dev": true, "requires": { - "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/types": "8.2.0", "eslint-visitor-keys": "^3.4.3" } }, @@ -20457,8 +20319,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "optional": true, - "peer": true + "dev": true }, "dayjs": { "version": "1.11.10", @@ -20866,16 +20727,16 @@ "dev": true }, "eslint": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", - "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.0.tgz", + "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.17.1", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.8.0", + "@eslint/js": "9.9.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", @@ -22405,7 +22266,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "peer": true, "requires": { "js-tokens": "^3.0.0 || ^4.0.0" } @@ -22799,9 +22659,9 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "nest-commander": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.14.0.tgz", - "integrity": "sha512-3HEfsEzoKEZ/5/cptkXlL8/31qohPxtMevoFo4j9NMe3q5PgI/0TgTYN/6py9GnFD51jSasEfFGChs1BJ+Enag==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.15.0.tgz", + "integrity": "sha512-o9VEfFj/w2nm+hQi6fnkxL1GAFZW/KmuGcIE7/B/TX0gwm0QVy8svAF75EQm8wrDjcvWS7Cx/ArnkFn2C+iM2w==", "requires": { "@fig/complete-commander": "^3.0.0", "@golevelup/nestjs-discovery": "4.0.1", @@ -23658,7 +23518,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "requires": { "loose-envify": "^1.1.0" } @@ -24498,32 +24357,32 @@ } }, "sharp": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.4.tgz", - "integrity": "sha512-7i/dt5kGl7qR4gwPRD2biwD2/SvBn3O04J77XKFgL2OnZtQw+AG9wnuS/csmu80nPRHLYE9E41fyEiG8nhH6/Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", "requires": { - "@img/sharp-darwin-arm64": "0.33.4", - "@img/sharp-darwin-x64": "0.33.4", - "@img/sharp-libvips-darwin-arm64": "1.0.2", - "@img/sharp-libvips-darwin-x64": "1.0.2", - "@img/sharp-libvips-linux-arm": "1.0.2", - "@img/sharp-libvips-linux-arm64": "1.0.2", - "@img/sharp-libvips-linux-s390x": "1.0.2", - "@img/sharp-libvips-linux-x64": "1.0.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.2", - "@img/sharp-libvips-linuxmusl-x64": "1.0.2", - "@img/sharp-linux-arm": "0.33.4", - "@img/sharp-linux-arm64": "0.33.4", - "@img/sharp-linux-s390x": "0.33.4", - "@img/sharp-linux-x64": "0.33.4", - "@img/sharp-linuxmusl-arm64": "0.33.4", - "@img/sharp-linuxmusl-x64": "0.33.4", - "@img/sharp-wasm32": "0.33.4", - "@img/sharp-win32-ia32": "0.33.4", - "@img/sharp-win32-x64": "0.33.4", + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5", "color": "^4.2.3", "detect-libc": "^2.0.3", - "semver": "^7.6.0" + "semver": "^7.6.3" } }, "shebang-command": { @@ -24714,9 +24573,9 @@ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" }, "sql-formatter": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.3.2.tgz", - "integrity": "sha512-pNxSMf5DtwhpZ8gUcOGCGZIWtCcyAUx9oLgAtlO4ag7DvlfnETL0BGqXaISc84pNrXvTWmt8Wal1FWKxdTsL3Q==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.0.tgz", + "integrity": "sha512-h3uVulRmOfARvDejuSzs9GMbua/UmGCKiP08zyHT1PnG376zk9CHVsDAcKIc9TcIwIrDH3YULWwI4PrXdmLRVw==", "dev": true, "requires": { "argparse": "^2.0.1", @@ -25715,9 +25574,9 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "webpack": { - "version": "5.92.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", - "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==", + "version": "5.93.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.93.0.tgz", + "integrity": "sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==", "dev": true, "requires": { "@types/eslint-scope": "^3.7.3", diff --git a/server/package.json b/server/package.json index 9f82378c1a..f58ad98b08 100644 --- a/server/package.json +++ b/server/package.json @@ -50,7 +50,7 @@ "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.52.0", "@opentelemetry/sdk-node": "^0.52.0", - "@react-email/components": "^0.0.22", + "@react-email/components": "^0.0.23", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", @@ -79,6 +79,7 @@ "openid-client": "^5.4.3", "pg": "^8.11.3", "picomatch": "^4.0.0", + "react": "^18.3.1", "react-email": "^3.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -112,6 +113,7 @@ "@types/node": "^20.16.1", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", + "@types/react": "^18.3.4", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", "@types/ua-parser-js": "^0.7.36", diff --git a/server/src/emails/album-invite.email.tsx b/server/src/emails/album-invite.email.tsx index b804be0898..232ef5290d 100644 --- a/server/src/emails/album-invite.email.tsx +++ b/server/src/emails/album-invite.email.tsx @@ -1,8 +1,8 @@ import { Img, Link, Section, Text } from '@react-email/components'; import * as React from 'react'; +import { ImmichButton } from 'src/emails/components/button.component'; +import ImmichLayout from 'src/emails/components/immich.layout'; import { AlbumInviteEmailProps } from 'src/interfaces/notification.interface'; -import { ImmichButton } from './components/button.component'; -import ImmichLayout from './components/immich.layout'; export const AlbumInviteEmail = ({ baseUrl, diff --git a/server/src/emails/album-update.email.tsx b/server/src/emails/album-update.email.tsx index d05631a772..0fb5ad931c 100644 --- a/server/src/emails/album-update.email.tsx +++ b/server/src/emails/album-update.email.tsx @@ -1,8 +1,8 @@ import { Img, Link, Section, Text } from '@react-email/components'; import * as React from 'react'; +import { ImmichButton } from 'src/emails/components/button.component'; +import ImmichLayout from 'src/emails/components/immich.layout'; import { AlbumUpdateEmailProps } from 'src/interfaces/notification.interface'; -import { ImmichButton } from './components/button.component'; -import ImmichLayout from './components/immich.layout'; export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, cid }: AlbumUpdateEmailProps) => ( diff --git a/server/src/emails/components/immich.layout.tsx b/server/src/emails/components/immich.layout.tsx index 8e6de2eebc..bb7a2aab65 100644 --- a/server/src/emails/components/immich.layout.tsx +++ b/server/src/emails/components/immich.layout.tsx @@ -1,6 +1,6 @@ import { Body, Container, Font, Head, Hr, Html, Img, Preview, Section, Tailwind, Text } from '@react-email/components'; import * as React from 'react'; -import { ImmichFooter } from './footer.template'; +import { ImmichFooter } from 'src/emails/components/footer.template'; interface ImmichLayoutProps { children: React.ReactNode; @@ -11,6 +11,7 @@ export const ImmichLayout = ({ children, preview }: ImmichLayoutProps) => ( ( diff --git a/server/src/emails/welcome.email.tsx b/server/src/emails/welcome.email.tsx index d6b3fc13e7..e031ac6b97 100644 --- a/server/src/emails/welcome.email.tsx +++ b/server/src/emails/welcome.email.tsx @@ -1,8 +1,8 @@ import { Link, Section, Text } from '@react-email/components'; import * as React from 'react'; +import { ImmichButton } from 'src/emails/components/button.component'; +import ImmichLayout from 'src/emails/components/immich.layout'; import { WelcomeEmailProps } from 'src/interfaces/notification.interface'; -import { ImmichButton } from './components/button.component'; -import ImmichLayout from './components/immich.layout'; export const WelcomeEmail = ({ baseUrl, displayName, username, password }: WelcomeEmailProps) => ( diff --git a/server/src/interfaces/notification.interface.ts b/server/src/interfaces/notification.interface.ts index c0ba4e209d..ec0ecc534b 100644 --- a/server/src/interfaces/notification.interface.ts +++ b/server/src/interfaces/notification.interface.ts @@ -90,7 +90,7 @@ export type SendEmailResponse = { }; export interface INotificationRepository { - renderEmail(request: EmailRenderRequest): { html: string; text: string }; + renderEmail(request: EmailRenderRequest): Promise<{ html: string; text: string }>; sendEmail(options: SendEmailOptions): Promise; verifySmtp(options: SmtpOptions): Promise; } diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts index ef6c8c2f39..9814a7bd5e 100644 --- a/server/src/repositories/notification.repository.ts +++ b/server/src/repositories/notification.repository.ts @@ -33,10 +33,10 @@ export class NotificationRepository implements INotificationRepository { } } - renderEmail(request: EmailRenderRequest): { html: string; text: string } { + async renderEmail(request: EmailRenderRequest): Promise<{ html: string; text: string }> { const component = this.render(request); - const html = render(component, { pretty: true }); - const text = render(component, { plainText: true }); + const html = await render(component, { pretty: true }); + const text = await render(component, { plainText: true }); return { html, text }; } diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index fa4f79f6d6..ace8240b39 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -87,7 +87,7 @@ export class NotificationService { } const { server } = await this.configCore.getConfig({ withCache: false }); - const { html, text } = this.notificationRepository.renderEmail({ + const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.TEST_EMAIL, data: { baseUrl: server.externalDomain || DEFAULT_EXTERNAL_DOMAIN, @@ -113,7 +113,7 @@ export class NotificationService { } const { server } = await this.configCore.getConfig({ withCache: true }); - const { html, text } = this.notificationRepository.renderEmail({ + const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.WELCOME, data: { baseUrl: server.externalDomain || DEFAULT_EXTERNAL_DOMAIN, @@ -156,7 +156,7 @@ export class NotificationService { const attachment = await this.getAlbumThumbnailAttachment(album); const { server } = await this.configCore.getConfig({ withCache: false }); - const { html, text } = this.notificationRepository.renderEmail({ + const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.ALBUM_INVITE, data: { baseUrl: server.externalDomain || DEFAULT_EXTERNAL_DOMAIN, @@ -211,7 +211,7 @@ export class NotificationService { continue; } - const { html, text } = this.notificationRepository.renderEmail({ + const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.ALBUM_UPDATE, data: { baseUrl: server.externalDomain || DEFAULT_EXTERNAL_DOMAIN, diff --git a/web/package-lock.json b/web/package-lock.json index 73682c06cb..5670cf2cc9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -79,7 +79,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.15", + "@types/node": "^20.16.1", "typescript": "^5.3.3" } }, @@ -984,9 +984,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", - "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.0.tgz", + "integrity": "sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==", "dev": true, "license": "MIT", "engines": { @@ -1882,169 +1882,224 @@ "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz", - "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.1.tgz", + "integrity": "sha512-2thheikVEuU7ZxFXubPDOtspKn1x0yqaYQwvALVtEcvFhMifPADBrgRPyHV0TF3b+9BgvgjgagVyvA/UqPZHmg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz", - "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.1.tgz", + "integrity": "sha512-t1lLYn4V9WgnIFHXy1d2Di/7gyzBWS8G5pQSXdZqfrdCGTwi1VasRMSS81DTYb+avDs/Zz4A6dzERki5oRYz1g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz", - "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.1.tgz", + "integrity": "sha512-AH/wNWSEEHvs6t4iJ3RANxW5ZCK3fUnmf0gyMxWCesY1AlUj8jY7GC+rQE4wd3gwmZ9XDOpL0kcFnCjtN7FXlA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz", - "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.1.tgz", + "integrity": "sha512-dO0BIz/+5ZdkLZrVgQrDdW7m2RkrLwYTh2YMFG9IpBtlC1x1NPNSXkfczhZieOlOLEqgXOFH3wYHB7PmBtf+Bg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz", - "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.1.tgz", + "integrity": "sha512-sWWgdQ1fq+XKrlda8PsMCfut8caFwZBmhYeoehJ05FdI0YZXk6ZyUjWLrIgbR/VgiGycrFKMMgp7eJ69HOF2pQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.1.tgz", + "integrity": "sha512-9OIiSuj5EsYQlmwhmFRA0LRO0dRRjdCVZA3hnmZe1rEwRk11Jy3ECGGq3a7RrVEZ0/pCsYWx8jG3IvcrJ6RCew==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz", - "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.1.tgz", + "integrity": "sha512-0kuAkRK4MeIUbzQYu63NrJmfoUVicajoRAL1bpwdYIYRcs57iyIV9NLcuyDyDXE2GiZCL4uhKSYAnyWpjZkWow==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz", - "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.1.tgz", + "integrity": "sha512-/6dYC9fZtfEY0vozpc5bx1RP4VrtEOhNQGb0HwvYNwXD1BBbwQ5cKIbUVVU7G2d5WRE90NfB922elN8ASXAJEA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.1.tgz", + "integrity": "sha512-ltUWy+sHeAh3YZ91NUsV4Xg3uBXAlscQe8ZOXRCVAKLsivGuJsrkawYPUEyCV3DYa9urgJugMLn8Z3Z/6CeyRQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz", - "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.1.tgz", + "integrity": "sha512-BggMndzI7Tlv4/abrgLwa/dxNEMn2gC61DCLrTzw8LkpSKel4o+O+gtjbnkevZ18SKkeN3ihRGPuBxjaetWzWg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.1.tgz", + "integrity": "sha512-z/9rtlGd/OMv+gb1mNSjElasMf9yXusAxnRDrBaYB+eS1shFm6/4/xDH1SAISO5729fFKUkJ88TkGPRUh8WSAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz", - "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.1.tgz", + "integrity": "sha512-kXQVcWqDcDKw0S2E0TmhlTLlUgAmMVqPrJZR+KpH/1ZaZhLSl23GZpQVmawBQGVhyP5WXIsIQ/zqbDBBYmxm5w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz", - "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.1.tgz", + "integrity": "sha512-CbFv/WMQsSdl+bpX6rVbzR4kAjSSBuDgCqb1l4J68UYsQNalz5wOqLGYj4ZI0thGpyX5kc+LLZ9CL+kpqDovZA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz", - "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.1.tgz", + "integrity": "sha512-3Q3brDgA86gHXWHklrwdREKIrIbxC0ZgU8lwpj0eEKGBQH+31uPqr0P2v11pn0tSIxHvcdOWxa4j+YvLNx1i6g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz", - "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.1.tgz", + "integrity": "sha512-tNg+jJcKR3Uwe4L0/wY3Ro0H+u3nrb04+tcq1GSYzBEmKLeOQF2emk1whxlzNqb6MMrQ2JOcQEpuuiPLyRcSIw==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz", - "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.1.tgz", + "integrity": "sha512-xGiIH95H1zU7naUyTKEyOA/I0aexNMUdO9qRv0bLKN3qu25bBdrxZHqA3PTJ24YNN/GdMzG4xkDcd/GvjuhfLg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2056,9 +2111,9 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.2.tgz", - "integrity": "sha512-/EBFydZDwfwFfFEuF1vzUseBoRziwKP7AoHAwv+Ot3M084sE/HTVBHf9mCmXfdM9ijprY5YEugZjleflncX5fQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.4.tgz", + "integrity": "sha512-Qm4GAHCnRXwfWG9/AtnQ7mqjyjTs7i0Opyb8H2KH9rMR7fLxqiPx/tXeoE6HHo66+72CjyOb4nFH3lrejY4vzA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2066,9 +2121,9 @@ } }, "node_modules/@sveltejs/enhanced-img": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.1.tgz", - "integrity": "sha512-75A4YiXQp+GRc54EyiNOlhHnHt9O8e0CdCHLm3RWESLRaazd5OIciSa4SbKIo9DM84yGwSVShU0buyUmNJvgWg==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.3.tgz", + "integrity": "sha512-nsqJkVuYLUXARDLjMoGKAt4oLzwtY8X2E8rIl/TJl7ueLjpTISxrAhVRN3r8yMO+R+so4G6Taiix2mpiPpqZeg==", "dev": true, "license": "MIT", "dependencies": { @@ -2082,9 +2137,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.5.20", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.20.tgz", - "integrity": "sha512-47rJ5BoYwURE/Rp7FNMLp3NzdbWC9DQ/PmKd0mebxT2D/PrPxZxcLImcD3zsWdX2iS6oJk8ITJbO/N2lWnnUqA==", + "version": "2.5.24", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.24.tgz", + "integrity": "sha512-Nr2oxsCsDfEkdS/zzQQQbsPYTbu692Qs3/iE3L7VHzCVjG2+WujF9oMUozWI7GuX98KxYSoPMlAsfmDLSg44hQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2109,15 +2164,15 @@ "node": ">=18.13" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3" } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.1.tgz", - "integrity": "sha512-rimpFEAboBBHIlzISibg94iP09k/KYdHgVhJlcsTfn7KMBhc70jFX/GRWkRdFCc2fdnk+4+Bdfej23cMDnJS6A==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz", + "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", "dev": true, "license": "MIT", "dependencies": { @@ -2506,17 +2561,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", - "integrity": "sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.2.0.tgz", + "integrity": "sha512-02tJIs655em7fvt9gps/+4k4OsKULYGtLBPJfOsmOq1+3cdClYiF0+d6mHu6qDnTcg88wJBkcPLpQhq7FyDz0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/type-utils": "8.0.1", - "@typescript-eslint/utils": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/type-utils": "8.2.0", + "@typescript-eslint/utils": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2540,16 +2595,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.1.tgz", - "integrity": "sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.2.0.tgz", + "integrity": "sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4" }, "engines": { @@ -2569,14 +2624,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", - "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.2.0.tgz", + "integrity": "sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1" + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2587,14 +2642,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.1.tgz", - "integrity": "sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.2.0.tgz", + "integrity": "sha512-g1CfXGFMQdT5S+0PSO0fvGXUaiSkl73U1n9LTK5aRAFnPlJ8dLKkXr4AaLFvPedW8lVDoMgLLE3JN98ZZfsj0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/utils": "8.0.1", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/utils": "8.2.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -2612,9 +2667,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.2.0.tgz", + "integrity": "sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==", "dev": true, "license": "MIT", "engines": { @@ -2626,14 +2681,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", - "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.2.0.tgz", + "integrity": "sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2694,16 +2749,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.1.tgz", - "integrity": "sha512-CBFR0G0sCt0+fzfnKaciu9IBsKvEKYwN9UZ+eeogK1fYHg4Qxk1yf/wLQkLXlq8wbU2dFlgAesxt8Gi76E8RTA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.2.0.tgz", + "integrity": "sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1" + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2717,13 +2772,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.2.0.tgz", + "integrity": "sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/types": "8.2.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -3966,9 +4021,9 @@ } }, "node_modules/eslint": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", - "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.0.tgz", + "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", "dev": true, "license": "MIT", "dependencies": { @@ -3976,7 +4031,7 @@ "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.17.1", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.8.0", + "@eslint/js": "9.9.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", @@ -4015,6 +4070,14 @@ }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-compat-utils": { @@ -6956,10 +7019,11 @@ } }, "node_modules/rollup": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz", - "integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.1.tgz", + "integrity": "sha512-ZnYyKvscThhgd3M5+Qt3pmhO4jIRR5RGzaSovB6Q7rGNrK5cUncrtLmcTTJVSdcKXyZjW8X8MB0JMSuH9bcAJg==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "1.0.5" }, @@ -6971,19 +7035,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.13.0", - "@rollup/rollup-android-arm64": "4.13.0", - "@rollup/rollup-darwin-arm64": "4.13.0", - "@rollup/rollup-darwin-x64": "4.13.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.13.0", - "@rollup/rollup-linux-arm64-gnu": "4.13.0", - "@rollup/rollup-linux-arm64-musl": "4.13.0", - "@rollup/rollup-linux-riscv64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-musl": "4.13.0", - "@rollup/rollup-win32-arm64-msvc": "4.13.0", - "@rollup/rollup-win32-ia32-msvc": "4.13.0", - "@rollup/rollup-win32-x64-msvc": "4.13.0", + "@rollup/rollup-android-arm-eabi": "4.21.1", + "@rollup/rollup-android-arm64": "4.21.1", + "@rollup/rollup-darwin-arm64": "4.21.1", + "@rollup/rollup-darwin-x64": "4.21.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.1", + "@rollup/rollup-linux-arm-musleabihf": "4.21.1", + "@rollup/rollup-linux-arm64-gnu": "4.21.1", + "@rollup/rollup-linux-arm64-musl": "4.21.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.1", + "@rollup/rollup-linux-riscv64-gnu": "4.21.1", + "@rollup/rollup-linux-s390x-gnu": "4.21.1", + "@rollup/rollup-linux-x64-gnu": "4.21.1", + "@rollup/rollup-linux-x64-musl": "4.21.1", + "@rollup/rollup-win32-arm64-msvc": "4.21.1", + "@rollup/rollup-win32-ia32-msvc": "4.21.1", + "@rollup/rollup-win32-x64-msvc": "4.21.1", "fsevents": "~2.3.2" } }, @@ -7676,9 +7743,9 @@ } }, "node_modules/svelte-check": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.5.tgz", - "integrity": "sha512-3OGGgr9+bJ/+1nbPgsvulkLC48xBsqsgtc8Wam281H4G9F5v3mYGa2bHRsPuwHC5brKl4AxJH95QF73kmfihGQ==", + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.6.tgz", + "integrity": "sha512-ij0u4Lw/sOTREP13BdWZjiXD/BlHE6/e2e34XzmVmsp5IN4kVa3PWP65NM32JAgwjZlwBg/+JtiNV1MM8khu0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -8158,9 +8225,9 @@ } }, "node_modules/svelte-maplibre": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.9.10.tgz", - "integrity": "sha512-MYTMogRPzzgXDZGub4ivfdY1/P0uPxZfo/REQhne0zdBLc6cd4n1U4SqY9SoEGNN0CGW1KvSLfc7acx0kxzXlw==", + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.9.12.tgz", + "integrity": "sha512-92kYWgR+/qkO3lrsPoNFPpgULhcpKeOQ+IqqVsduDY7lOkhWKgCmx4r8i8UTfFZ6KGezSN0y7pweHEhBdhV3Xw==", "license": "MIT", "dependencies": { "d3-geo": "^3.1.0", @@ -8272,9 +8339,9 @@ "peer": true }, "node_modules/tailwindcss": { - "version": "3.4.9", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz", - "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==", + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", + "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==", "dev": true, "license": "MIT", "dependencies": { @@ -8758,15 +8825,15 @@ } }, "node_modules/vite": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz", - "integrity": "sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", + "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.40", - "rollup": "^4.13.0" + "postcss": "^8.4.41", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" From 2297d86569fdd0c0a806ef1b0211f4e831bc23c0 Mon Sep 17 00:00:00 2001 From: Kenneth Bingham Date: Wed, 28 Aug 2024 12:30:06 -0400 Subject: [PATCH 191/723] fix(mobile): use a valid OAuth callback URL (#10832) * add root resource path '/' to mobile oauth scheme * chore: add oauth-callback path * add root resource path '/' to mobile oauth scheme * chore: add oauth-callback path * fix: make sure there are three forward slash in callback URL --------- Co-authored-by: Jason Rasmussen Co-authored-by: Alex --- docs/docs/administration/oauth.md | 12 ++--- .../android/app/src/main/AndroidManifest.xml | 4 +- mobile/lib/pages/login/login.page.dart | 2 +- mobile/lib/services/oauth.service.dart | 42 +++++++++++------- .../lib/widgets/forms/login/login_form.dart | 44 +++++++++++++------ server/src/constants.ts | 2 +- server/src/services/auth.service.spec.ts | 40 ++++++++--------- server/src/services/auth.service.ts | 2 +- .../settings/auth/auth-settings.svelte | 4 +- web/src/lib/i18n/en.json | 2 +- 10 files changed, 92 insertions(+), 62 deletions(-) diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index ab317787bc..96dca68e4f 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -3,7 +3,7 @@ This page contains details about using OAuth in Immich. :::tip -Unable to set `app.immich:/` as a valid redirect URI? See [Mobile Redirect URI](#mobile-redirect-uri) for an alternative solution. +Unable to set `app.immich:///oauth-callback` as a valid redirect URI? See [Mobile Redirect URI](#mobile-redirect-uri) for an alternative solution. ::: ## Overview @@ -30,7 +30,7 @@ Before enabling OAuth in Immich, a new client application needs to be configured The **Sign-in redirect URIs** should include: - - `app.immich:/` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx) + - `app.immich:///oauth-callback` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx) - `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client - `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client @@ -38,7 +38,7 @@ Before enabling OAuth in Immich, a new client application needs to be configured Mobile - - `app.immich:/` (You **MUST** include this for iOS and Android mobile apps to work properly) + - `app.immich:///oauth-callback` (You **MUST** include this for iOS and Android mobile apps to work properly) Localhost @@ -96,16 +96,16 @@ When Auto Launch is enabled, the login page will automatically redirect the user ## Mobile Redirect URI -The redirect URI for the mobile app is `app.immich:/`, which is a [Custom Scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). If this custom scheme is an invalid redirect URI for your OAuth Provider, you can work around this by doing the following: +The redirect URI for the mobile app is `app.immich:///oauth-callback`, which is a [Custom Scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). If this custom scheme is an invalid redirect URI for your OAuth Provider, you can work around this by doing the following: -1. Configure an http(s) endpoint to forwards requests to `app.immich:/` +1. Configure an http(s) endpoint to forwards requests to `app.immich:///oauth-callback` 2. Whitelist the new endpoint as a valid redirect URI with your provider. 3. Specify the new endpoint as the `Mobile Redirect URI Override`, in the OAuth settings. With these steps in place, you should be able to use OAuth from the [Mobile App](/docs/features/mobile-app.mdx) without a custom scheme redirect URI. :::info -Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to forward requests to `app.immich:/`, and can be used for step 1. +Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to forward requests to `app.immich:///oauth-callback`, and can be used for step 1. ::: ## Example Configuration diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index edb41510f0..e5e3e2a396 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -69,7 +69,7 @@ - + HEVC) - (31 --> VP9) - (35 --> AV1).", + "transcoding_disabled_description": "هیچ ویدیویی را تبدیل فرمت نکنید، زیرا ممکن است پخش در برخی از کلاینت‌ها را مختل کند", + "transcoding_hardware_acceleration": "شتاب دهنده سخت افزاری", + "transcoding_hardware_acceleration_description": "آزمایشی؛ بسیار سریع‌تر است، اما در همان بیت‌ریت کیفیت کمتری خواهد داشت", + "transcoding_hardware_decoding": "رمزگشایی سخت افزاری", "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", + "transcoding_hevc_codec": "کدک HEVC", + "transcoding_max_b_frames": "بیشترین B-frames", + "transcoding_max_b_frames_description": "مقادیر بالاتر کارایی فشرده سازی را بهبود می‌بخشند، اما کدگذاری را کند می‌کنند. ممکن است با شتاب دهی سخت‌افزاری در دستگاه‌های قدیمی سازگار نباشد. مقدار( 0 ) B-frames را غیرفعال می‌کند، در حالی که مقدار ( 1 ) این مقدار را به صورت خودکار تنظیم می‌کند.", + "transcoding_max_bitrate": "بیشترین بیت ریت", + "transcoding_max_bitrate_description": "تنظیم حداکثر بیت‌ریت می‌تواند اندازه فایل‌ها را در حدی قابل پیش‌بینی‌تر کند، هرچند که هزینه کمی برای کیفیت دارد. در وضوح 720p، مقادیر معمول 2600k برای VP9 یا HEVC و 4500k برای H.264 است. اگر به 0 تنظیم شود، غیرفعال می‌شود.", + "transcoding_max_keyframe_interval": "حداکثر فاصله کلید فریم", + "transcoding_max_keyframe_interval_description": "حداکثر فاصله فریم بین کلیدفریم‌ها را تنظیم می‌کند. مقادیر پایین‌تر کارایی فشرده‌سازی را کاهش می‌دهند، اما زمان جستجو را بهبود می‌بخشند و ممکن است کیفیت را در صحنه‌های با حرکت سریع بهبود دهند. مقدار 0 این مقدار را به‌طور خودکار تنظیم می‌کند.", + "transcoding_optimal_description": "ویدیوهایی که از رزولوشن هدف بالاتر هستند یا در قالب پذیرفته شده نیستند", + "transcoding_preferred_hardware_device": "دستگاه سخت‌افزاری ترجیحی", + "transcoding_preferred_hardware_device_description": "این گزینه فقط به VAAPI و QSV اعمال می‌شود. DRI node مورد استفاده برای تبدیل فرمت سخت‌افزاری را تنظیم می‌کند.", + "transcoding_preset_preset": "پیش‌تنظیم (preset-)", + "transcoding_preset_preset_description": "سرعت فشرده‌سازی. پیش‌تنظیم‌های کندتر فایل‌های کوچک‌تری تولید می‌کنند و کیفیت را هنگام هدف‌گذاری بر روی یک بیت‌ریت خاص افزایش می‌دهند. VP9 سرعت‌های بالاتر از 'faster' را نادیده می‌گیرد.", + "transcoding_reference_frames": "فریم‌های مرجع", + "transcoding_reference_frames_description": "تعداد فریم‌هایی که هنگام فشرده‌سازی یک فریم مشخص به آن‌ها ارجاع داده می‌شود. مقادیر بالاتر کارایی فشرده‌سازی را بهبود می‌بخشند، اما کدگذاری را کندتر می‌کنند. مقدار 0 این مقدار را به‌طور خودکار تنظیم می‌کند.", + "transcoding_required_description": "فقط ویدیوهایی که در فرمت پذیرفته‌شده نیستند", + "transcoding_settings": "تنظیمات تبدیل ویدیو", + "transcoding_settings_description": "مدیریت وضوح و اطلاعات کدگذاری فایل‌های ویدئویی", + "transcoding_target_resolution": "وضوح هدف", + "transcoding_target_resolution_description": "وضوح‌های بالاتر می‌توانند جزئیات بیشتری را حفظ کنند، اما زمان بیشتری برای کدگذاری نیاز دارند، اندازه فایل‌های بزرگ‌تری دارند و ممکن است باعث کاهش پاسخگویی برنامه شوند.", + "transcoding_temporal_aq": "AQ موقتی", + "transcoding_temporal_aq_description": "این مورد فقط برای NVENC اعمال می شود. افزایش کیفیت در صحنه های با جزئیات بالا و حرکت کم. ممکن است با دستگاه های قدیمی تر سازگار نباشد.", + "transcoding_threads": "رشته ها ( موضوعات )", + "transcoding_threads_description": "مقادیر بالاتر منجر به رمزگذاری سریع تر می شود، اما فضای کمتری برای پردازش سایر وظایف سرور در حین فعالیت باقی می گذارد. این مقدار نباید بیشتر از تعداد هسته های CPU باشد. اگر روی 0 تنظیم شود، بیشترین استفاده را خواهد داشت.", "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", + "transcoding_tone_mapping_description": "تلاش برای حفظ ظاهر ویدیوهای HDR هنگام تبدیل به SDR. هر الگوریتم تعادل های متفاوتی را برای رنگ، جزئیات و روشنایی ایجاد می کند. Hable جزئیات را حفظ می کند، Mobius رنگ را حفظ می کند و Reinhard روشنایی را حفظ می کند.", "transcoding_tone_mapping_npl": "", - "transcoding_tone_mapping_npl_description": "", - "transcoding_transcode_policy": "", - "transcoding_transcode_policy_description": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", - "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", - "untracked_files": "", - "untracked_files_description": "", - "user_delete_delay": "", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", - "user_delete_immediately": "", - "user_management": "", - "user_password_has_been_reset": "", - "user_password_reset_description": "", - "user_restore_description": "", - "user_settings": "", - "user_settings_description": "", - "user_successfully_removed": "", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", - "video_conversion_job": "", - "video_conversion_job_description": "" + "transcoding_tone_mapping_npl_description": "رنگ ها برای ظاهر طبیعی در یک نمایشگر با این روشنایی تنظیم خواهند شد. برخلاف انتظار، مقادیر پایین تر باعث افزایش روشنایی ویدیو و برعکس می شوند، زیرا آن را برای روشنایی نمایشگر جبران می کند. مقدار 0 این مقدار را به طور خودکار تنظیم می کند.", + "transcoding_transcode_policy": "سیاست رمزگذاری", + "transcoding_transcode_policy_description": "سیاست برای زمانی که ویدیویی باید مجددا تبدیل (رمزگذاری) شود. ویدیوهای HDR همیشه تبدیل (رمزگذاری) مجدد خواهند شد (مگر رمزگذاری مجدد غیرفعال باشد).", + "transcoding_two_pass_encoding": "تبدیل (رمزگذاری) دو مرحله ای", + "transcoding_two_pass_encoding_setting_description": "تبدیل (رمزگذاری) ویدیو در دو مرحله برای تولید ویدیوهای رمزگذاری شده بهتر. وقتی حداکثر نرخ بیت فعال باشد (برای کار با H.264 و HEVC لازم است)، این حالت از یک محدوده نرخ بیت بر اساس حداکثر نرخ بیت استفاده می کند و CRF را نادیده می گیرد. برای VP9، اگر حداکثر نرخ بیت غیرفعال باشد، می توان از CRF استفاده کرد.", + "transcoding_video_codec": "کدک ویدیویی", + "transcoding_video_codec_description": "VP9 کارایی بالا و سازگاری وب را دارد، اما تبدیل (رمزگذاری) مجدد آن زمان بیشتری می گیرد. HEVC عملکرد مشابهی دارد، اما سازگاری وب کمتری دارد. H.264 سازگاری گسترده و رمزگذاری سریع دارد، اما فایل های بزرگتری تولید می کند. AV1 کدک کارآمدترین است، اما از پشتیبانی در دستگاه های قدیمی تر برخوردار نیست.", + "trash_enabled_description": "فعال سازی ویژگی های سطل بازیافت (سطل زباله)", + "trash_number_of_days": "تعداد روزها", + "trash_number_of_days_description": "تعداد روزهایی که دارایی ها(عکسها و فیملها) در زباله دان(سطل بازیافت) قبل از حذف دائمی نگهداری میشوند", + "trash_settings": "تنظیمات سطل بازیافت (سطل زباله)", + "trash_settings_description": "مدیریت تنظیمات سطل بازیافت (سطل زباله)", + "untracked_files": "فایل های ردیابی نشده", + "untracked_files_description": "این فایل ها توسط برنامه ردیابی نمی شوند. می توانند نتیجه انتقال ناموفق، بارگذاری متوقف شده یا به دلیل یک باگ باقی مانده باشند", + "user_delete_delay": "{user}'s حساب کاربری و دارایی ها(عکس و فیلم) برای حذف دائمی در {delay, plural, one {# روز} other {# روز}} برنامه ریزی خواهند شد.", + "user_delete_delay_settings": "تأخیر در حذف", + "user_delete_delay_settings_description": "تعداد روزهایی که پس از حذف، حساب کاربری و دارایی های(عکس و فیلم) کاربر به طور دائمی حذف می شوند. کار حذف کاربر در نیمه شب اجرا می شود تا کاربرانی که آماده حذف هستند را بررسی کند. تغییرات در این تنظیم در اجرای بعدی ارزیابی خواهند شد.", + "user_delete_immediately": "{user}'s حساب کاربری و دارایی ها (عکس و فیلم) فوراً برای حذف دائمی در صف قرار خواهند گرفت.", + "user_delete_immediately_checkbox": "کاربر و دارایی ها (عکس و فیلم) را برای حذف فوری در صف قرار بده", + "user_management": "مدیریت کاربر", + "user_password_has_been_reset": "رمز عبور کاربر بازنشانی شد:", + "user_password_reset_description": "لطفاً رمز عبور موقت را به کاربر ارائه دهید و به او اطلاع دهید که باید در ورود بعدی رمز عبور خود را تغییر دهد.", + "user_restore_description": "{user}'s حساب کاربری بازیابی خواهد شد.", + "user_restore_scheduled_removal": "بازیابی کاربر - حذف برنامه ریزی شده در {date, date, long}", + "user_settings": "تنظیمات کاربر", + "user_settings_description": "مدیریت تنظیمات کاربر", + "user_successfully_removed": "کاربر {email} با موفقیت حذف شد.", + "version_check_enabled_description": "فعال‌سازی بررسی نسخه", + "version_check_implications": "ویژگی بررسی نسخه به ارتباط دوره ای با github.com متکی است", + "version_check_settings": "بررسی نسخه", + "version_check_settings_description": "فعال یا غیرفعال کردن اعلان نسخه جدید", + "video_conversion_job": "تبدیل (رمزگذاری) ویدیوها", + "video_conversion_job_description": "تبدیل (رمزگذاری)ویدیوها برای سازگاری بیشتر با مرورگرها و دستگاه‌ها" }, - "admin_email": "", - "admin_password": "", - "administration": "", - "advanced": "", + "admin_email": "ایمیل مدیر", + "admin_password": "رمز عبور مدیر", + "administration": "مدیریت", + "advanced": "پیشرفته", "album_added": "", "album_added_notification_setting_description": "", "album_cover_updated": "", diff --git a/web/src/lib/i18n/fi.json b/web/src/lib/i18n/fi.json index f87e2eed4e..da9a71379c 100644 --- a/web/src/lib/i18n/fi.json +++ b/web/src/lib/i18n/fi.json @@ -129,6 +129,7 @@ "map_enable_description": "Ota käyttöön karttatoiminnot", "map_gps_settings": "Kartta & GPS- asetukset", "map_gps_settings_description": "Hallitse Kartan & GPS (Käänteinen Geokoodaus) Asetuksia", + "map_implications": "Kartta -ominaisuus käyttää ulkoista karttapalvelua", "map_light_style": "Vaalea teema", "map_manage_reverse_geocoding_settings": "Hallitse käänteisen geokoodauksen asetuksia", "map_reverse_geocoding": "Käänteinen Geokoodaus", @@ -333,8 +334,11 @@ "album_cover_updated": "Albumin kansikuva päivitetty", "album_delete_confirmation": "Haluatko varmasti poistaa albumin {album}?\nJos albumi on jaettu, muut eivät pääse siihen enää.", "album_info_updated": "Albumin tiedot päivitetty", + "album_leave": "Poistu albumista?", "album_name": "Albumin nimi", "album_options": "Albumin asetukset", + "album_remove_user": "Poista käyttäjä?", + "album_remove_user_confirmation": "Oletko varma että haluat poistaa {user}?", "album_share_no_users": "Näyttää että olet jakanut tämän albumin kaikkien kanssa, tai sinulla ei ole käyttäjiä joille jakaa.", "album_updated": "Albumi päivitetty", "album_updated_setting_description": "Saa sähköpostia kun jaetussa albumissa on uutta sisältöä", diff --git a/web/src/lib/i18n/fr.json b/web/src/lib/i18n/fr.json index 0aaa160729..9963105cd8 100644 --- a/web/src/lib/i18n/fr.json +++ b/web/src/lib/i18n/fr.json @@ -129,12 +129,13 @@ "map_enable_description": "Activer la carte", "map_gps_settings": "Paramètres de la carte et GPS", "map_gps_settings_description": "Gérer les paramètres de la Carte & GPS", + "map_implications": "La carte repose sur un service de tuiles externe (tiles.immich.cloud)", "map_light_style": "Thème clair", "map_manage_reverse_geocoding_settings": "Gérer les paramètres de géocodage inversé", "map_reverse_geocoding": "Géocodage inversé", "map_reverse_geocoding_enable_description": "Activer le géocodage inversé", "map_reverse_geocoding_settings": "Paramètres de géocodage inversé", - "map_settings": "Paramètres de la carte", + "map_settings": "Carte", "map_settings_description": "Gérer les paramètres de la carte", "map_style_description": "URL vers un thème de carte au format style.json", "metadata_extraction_job": "Extraction des métadonnées", @@ -320,7 +321,8 @@ "user_settings": "Paramètres utilisateur", "user_settings_description": "Gérer les paramètres utilisateur", "user_successfully_removed": "L'utilisateur {email} a bien été supprimé.", - "version_check_enabled_description": "Activer la vérification périodique de nouvelle version sur GitHub", + "version_check_enabled_description": "Activer la vérification périodique de nouvelle version", + "version_check_implications": "Le contrôle de version repose sur une communication périodique avec github.com", "version_check_settings": "Vérification de la version", "version_check_settings_description": "Gérer la vérification de nouvelle version d'Immich", "video_conversion_job": "Transcodage des vidéos", @@ -336,7 +338,8 @@ "album_added": "Album ajouté", "album_added_notification_setting_description": "Recevoir une notification par courriel lorsque vous êtes ajouté(e) à un album partagé", "album_cover_updated": "Couverture de l'album mise à jour", - "album_delete_confirmation": "Êtes-vous sûr de vouloir supprimer l'album {album} ?\nSi cet album est partagé, les autres utilisateurs ne pourront plus y accéder.", + "album_delete_confirmation": "Êtes-vous sûr de vouloir supprimer l'album {album} ?", + "album_delete_confirmation_description": "Si cet album est partagé, d'autres utilisateurs ne pourront plus y accéder.", "album_info_updated": "Détails de l'album mis à jour", "album_leave": "Quitter l'album ?", "album_leave_confirmation": "Êtes-vous sûr de vouloir quitter l'album {album} ?", @@ -360,6 +363,7 @@ "allow_edits": "Autoriser les modifications", "allow_public_user_to_download": "Permettre aux utilisateurs non connectés de télécharger", "allow_public_user_to_upload": "Permettre aux utilisateurs non connectés de téléverser", + "anti_clockwise": "Sens anti-horaire", "api_key": "Clé API", "api_key_description": "Cette valeur ne sera affichée qu'une seule fois. Assurez-vous de la copier avant de fermer la fenêtre.", "api_key_empty": "Le nom de votre clé API ne doit pas être vide", @@ -441,6 +445,7 @@ "clear_all_recent_searches": "Supprimer les recherches récentes", "clear_message": "Effacer le message", "clear_value": "Effacer la valeur", + "clockwise": "Sens horaire", "close": "Fermer", "collapse": "Réduire", "collapse_all": "Tout réduire", @@ -517,6 +522,8 @@ "do_not_show_again": "Ne plus afficher ce message", "done": "Terminé", "download": "Télécharger", + "download_include_embedded_motion_videos": "Vidéos embarquées", + "download_include_embedded_motion_videos_description": "Inclure des vidéos intégrées dans les photos de mouvement comme un fichier séparé", "download_settings": "Télécharger", "download_settings_description": "Gérer les paramètres de téléchargement des médias", "downloading": "Téléchargement", @@ -550,6 +557,10 @@ "edit_user": "Modifier l'utilisateur", "edited": "Modifié", "editor": "Editeur", + "editor_close_without_save_prompt": "Les changements ne seront pas enregistrés", + "editor_close_without_save_title": "Fermer l'éditeur ?", + "editor_crop_tool_h2_aspect_ratios": "Rapports hauteur/largeur", + "editor_crop_tool_h2_rotation": "Rotation", "email": "Courriel", "empty": "", "empty_album": "Album vide", @@ -699,6 +710,7 @@ "expired": "Expiré", "expires_date": "Expire le {date}", "explore": "Explorer", + "explorer": "Explorateur", "export": "Exporter", "export_as_json": "Exporter en JSON", "extension": "Extension", @@ -720,6 +732,7 @@ "filter_people": "Filtrer les personnes", "find_them_fast": "Pour les retrouver rapidement par leur nom", "fix_incorrect_match": "Corriger une association incorrecte", + "folders": "Dossiers", "force_re-scan_library_files": "Forcer la réactualisation de tous les fichiers de la bibliothèque", "forward": "Avant", "general": "Général", @@ -912,6 +925,7 @@ "ok": "Ok", "oldest_first": "Anciens en premier", "onboarding": "Accueil", + "onboarding_privacy_description": "Les fonctions suivantes (optionnelles) dépendent de services externes et peuvent être désactivées à tout moment dans les paramètres d'administration.", "onboarding_theme_description": "Choisissez un thème de couleur pour votre instance. Vous pouvez le changer plus tard dans vos paramètres.", "onboarding_welcome_description": "Mettons votre instance en place avec quelques paramètres communs.", "onboarding_welcome_user": "Bienvenue {user}", @@ -985,6 +999,7 @@ "previous_memory": "Souvenir précédent", "previous_or_next_photo": "Photo précédente ou suivante", "primary": "Primaire", + "privacy": "Vie privée", "profile_image_of_user": "Image de profil de {user}", "profile_picture_set": "Photo de profil définie.", "public_album": "Album public", @@ -1023,6 +1038,8 @@ "purchase_settings_server_activated": "La clé du produit pour le Serveur est gérée par l'administrateur", "range": "", "rating": "Étoile d'évaluation", + "rating_clear": "Effacer l'évaluation", + "rating_count": "{count, plural, one {# étoile} other {# étoiles}}", "rating_description": "Afficher l'évaluation d'exif dans le panneau d'information", "raw": "", "reaction_options": "Options de réaction", @@ -1146,6 +1163,7 @@ "shared_by_user": "Partagé par {user}", "shared_by_you": "Partagé par vous", "shared_from_partner": "Photos de {partner}", + "shared_link_options": "Options de lien partagé", "shared_links": "Liens partagés", "shared_photos_and_videos_count": "{assetCount, plural, other {# photos et vidéos partagées.}}", "shared_with_partner": "Partagé avec {partner}", @@ -1221,7 +1239,7 @@ "to_login": "Se connecter", "to_trash": "Corbeille", "toggle_settings": "Inverser les paramètres", - "toggle_theme": "Changer le thème", + "toggle_theme": "Inverser le thème sombre", "toggle_visibility": "Modifier la visibilité", "total_usage": "Utilisation globale", "trash": "Corbeille", @@ -1243,6 +1261,7 @@ "unlink_oauth": "Déconnecter OAuth", "unlinked_oauth_account": "Compte OAuth non connecté", "unnamed_album": "Album sans nom", + "unnamed_album_delete_confirmation": "Êtes-vous sûr de vouloir supprimer cet album ?", "unnamed_share": "Partage sans nom", "unsaved_change": "Modification non enregistrée", "unselect_all": "Annuler la sélection", diff --git a/web/src/lib/i18n/he.json b/web/src/lib/i18n/he.json index d73b06ad70..1679ecbc4d 100644 --- a/web/src/lib/i18n/he.json +++ b/web/src/lib/i18n/he.json @@ -129,12 +129,13 @@ "map_enable_description": "אפשר תכונות מפה", "map_gps_settings": "הגדרות מפה & GPS", "map_gps_settings_description": "נהל הגדרות מפה & GPS (קידוד גאוגרפי הפוך)", + "map_implications": "תכונת המפה מסתמכת על שירות אריח חיצוני (tiles.immich.cloud)", "map_light_style": "עיצוב בהיר", "map_manage_reverse_geocoding_settings": "נהל הגדרות קידוד גאוגרפי הפוך", "map_reverse_geocoding": "קידוד גיאוגרפי הפוך", "map_reverse_geocoding_enable_description": "אפשר קידוד גיאוגרפי הפוך", "map_reverse_geocoding_settings": "הגדרות קידוד גיאוגרפי הפוך", - "map_settings": "הגדרות מפה", + "map_settings": "מפה", "map_settings_description": "נהל הגדרות מפה", "map_style_description": "כתובת אתר לערכת נושא של מפה style.json", "metadata_extraction_job": "חלץ מטא-נתונים", @@ -320,7 +321,8 @@ "user_settings": "הגדרות משתמש", "user_settings_description": "נהל הגדרות משתמש", "user_successfully_removed": "המשתמש {email} הוסר בהצלחה.", - "version_check_enabled_description": "אפשר בקשות רשת תקופתיות ל-GitHub כדי לבדוק אם יש מהדורות חדשות", + "version_check_enabled_description": "אפשר בדיקת גרסה", + "version_check_implications": "תכונת בדיקת הגרסה מסתמכת על תקשורת תקופתית עם github.com", "version_check_settings": "בדיקת גרסה", "version_check_settings_description": "הפעל/השבת את ההתראה על גרסה חדשה", "video_conversion_job": "המרת קידוד סרטונים", @@ -360,6 +362,7 @@ "allow_edits": "אפשר עריכות", "allow_public_user_to_download": "אפשר למשתמש ציבורי להוריד", "allow_public_user_to_upload": "אפשר למשתמש ציבורי להעלות", + "anti_clockwise": "נגד כיוון השעון", "api_key": "מפתח API", "api_key_description": "הערך הזה יוצג רק פעם אחת. נא לוודא שהעתקת אותו לפני סגירת החלון.", "api_key_empty": "מפתח ה-API שלך לא אמור להיות ריק", @@ -441,6 +444,7 @@ "clear_all_recent_searches": "נקה את כל החיפושים האחרונים", "clear_message": "נקה הודעה", "clear_value": "נקה ערך", + "clockwise": "עם כיוון השעון", "close": "סגור", "collapse": "כווץ", "collapse_all": "כווץ הכל", @@ -517,6 +521,8 @@ "do_not_show_again": "אל תציג את ההודעה הזאת שוב", "done": "סיום", "download": "הורדה", + "download_include_embedded_motion_videos": "סרטונים מוטמעים", + "download_include_embedded_motion_videos_description": "כלול סרטונים מוטעמים בתמונות עם תנועה כקובץ נפרד", "download_settings": "הורדה", "download_settings_description": "נהל הגדרות הקשורות להורדת נכסים", "downloading": "מוריד", @@ -550,6 +556,10 @@ "edit_user": "ערוך משתמש", "edited": "נערך", "editor": "עורך", + "editor_close_without_save_prompt": "השינויים לא יישמרו", + "editor_close_without_save_title": "לסגור את העורך?", + "editor_crop_tool_h2_aspect_ratios": "יחסי רוחב גובה", + "editor_crop_tool_h2_rotation": "סיבוב", "email": "דוא\"ל", "empty": "", "empty_album": "אלבום ריק", @@ -912,6 +922,7 @@ "ok": "בסדר", "oldest_first": "הישן ביותר ראשון", "onboarding": "היכרות", + "onboarding_privacy_description": "התכונות (האופציונליות) הבאות מסתמכות על שירותים חיצוניים, וניתנות לביטול בכל עת בהגדרות הניהול.", "onboarding_theme_description": "בחר/י את צבע ערכת הנושא עבור ההתקנה שלך. את/ה יכול/ה לשנות את זה מאוחר יותר בהגדרות שלך.", "onboarding_welcome_description": "בואו נכין את ההתקנה שלכם עם כמה הגדרות נפוצות.", "onboarding_welcome_user": "ברוכ/ה הבא/ה, {user}", @@ -985,6 +996,7 @@ "previous_memory": "זיכרון קודם", "previous_or_next_photo": "התמונה הקודמת או הבאה", "primary": "ראשי", + "privacy": "פרטיות", "profile_image_of_user": "תמונת פרופיל של {user}", "profile_picture_set": "תמונת פרופיל נבחרה.", "public_album": "אלבום ציבורי", @@ -995,7 +1007,7 @@ "purchase_activated_title": "המפתח שלך הופעל בהצלחה", "purchase_button_activate": "הפעל", "purchase_button_buy": "קנה", - "purchase_button_buy_immich": "קנה Immich", + "purchase_button_buy_immich": "קנה את Immich", "purchase_button_never_show_again": "לעולם אל תראה שוב", "purchase_button_reminder": "הזכר לי בעוד 30 יום", "purchase_button_remove_key": "הסר מפתח", @@ -1146,6 +1158,7 @@ "shared_by_user": "משותף על ידי {user}", "shared_by_you": "משותף על ידך", "shared_from_partner": "תמונות מאת {partner}", + "shared_link_options": "אפשרויות קישור משותף", "shared_links": "קישורים משותפים", "shared_photos_and_videos_count": "{assetCount, plural, other {# תמונות וסרטונים משותפים.}}", "shared_with_partner": "משותף עם {partner}", diff --git a/web/src/lib/i18n/hr.json b/web/src/lib/i18n/hr.json index 6b07d5af6d..8882d80e25 100644 --- a/web/src/lib/i18n/hr.json +++ b/web/src/lib/i18n/hr.json @@ -1,5 +1,5 @@ { - "about": "Oko", + "about": "O", "account": "Račun", "account_settings": "Postavke računa", "acknowledge": "Potvrdi", @@ -25,7 +25,7 @@ "add_to_shared_album": "Dodaj u dijeljeni album", "added_to_archive": "Dodano u arhivu", "added_to_favorites": "Dodano u omiljeno", - "added_to_favorites_count": "Dodano {count} u omiljeno", + "added_to_favorites_count": "Dodano {count, number} u omiljeno", "admin": { "add_exclusion_pattern_description": "", "authentication_settings": "Postavke autentikacije", @@ -33,6 +33,7 @@ "authentication_settings_disable_all": "Jeste li sigurni da želite onemogućenit sve načine prijave? Prijava će biti potpuno onemogućena.", "background_task_job": "Pozadinski zadaci", "check_all": "Provjeri sve", + "cleared_jobs": "Izbrisani poslovi za: {job}", "config_set_by_file": "Konfiguracija je trenutno postavljena konfiguracijskom datotekom", "confirm_delete_library": "Jeste li sigurni da želite izbrisati biblioteku {library}?", "confirm_delete_library_assets": "Jeste li sigurni da želite izbrisati ovu biblioteku? Time će se izbrisati sva {count} sadržana sredstva iz Immicha i ne može se poništiti. Datoteke će ostati na disku.", @@ -48,6 +49,7 @@ "face_detection": "Detekcija lica", "face_detection_description": "Prepoznajte lica u sredstvima pomoću strojnog učenja. Za videozapise u obzir se uzima samo minijaturni prikaz. \"Sve\" (ponovno) obrađuje svu imovinu. \"Nedostaje\" stavlja u red čekanja sredstva koja još nisu obrađena. Otkrivena lica bit će stavljena u red čekanja za prepoznavanje lica nakon dovršetka prepoznavanja lica, grupirajući ih u postojeće ili nove osobe.", "facial_recognition_job_description": "Grupirajte otkrivena lica u osobe. Ovaj se korak pokreće nakon dovršetka prepoznavanja lica. \"Sve\" (ponovno) grupira sva lica. \"Nedostajuća\" lica u redovima kojima nije dodijeljena osoba.", + "failed_job_command": "Naredba {command} nije uspjela za posao: {job}", "force_delete_user_warning": "UPOZORENJE: Ovo će odmah ukloniti korisnika i sve pripadajuće podatke. Ovo se ne može poništiti i datoteke se ne mogu vratiti.", "forcing_refresh_library_files": "Prisilno osvježavanje svih datoteka knjižnice", "image_format_description": "WebP proizvodi manje datoteke od JPEG-a, ali se sporije kodira.", @@ -67,30 +69,33 @@ "image_thumbnail_resolution_description": "Koristi se prilikom pregledavanja grupa fotografija (glavna vremenska traka, prikaz albuma itd.). Veće razlučivosti mogu sačuvati više detalja, ali trebaju dulje za kodiranje, imaju veće veličine datoteka i mogu smanjiti odaziv aplikacije.", "job_concurrency": "{job} istovremenost", "job_not_concurrency_safe": "Ovaj posao nije siguran za istovremenost.", - "job_settings": "", - "job_settings_description": "", - "job_status": "", + "job_settings": "Postavke posla", + "job_settings_description": "Upravljajte istovremenošću poslova", + "job_status": "Status posla", "jobs_delayed": "", "jobs_failed": "", - "library_created": "", - "library_cron_expression": "", - "library_cron_expression_presets": "", - "library_deleted": "", - "library_import_path_description": "", - "library_scanning": "", - "library_scanning_description": "", - "library_scanning_enable_description": "", - "library_settings": "", - "library_settings_description": "", - "library_tasks_description": "", - "library_watching_enable_description": "", - "library_watching_settings": "", - "library_watching_settings_description": "", - "logging_enable_description": "", - "logging_level_description": "", - "logging_settings": "", - "machine_learning_clip_model": "", - "machine_learning_duplicate_detection": "", + "library_created": "Stvorena biblioteka: {library}", + "library_cron_expression": "Cron izraz", + "library_cron_expression_description": "Postavite interval skeniranja koristeći cron format. Za više informacija pogledajte npr. Crontab Guru", + "library_cron_expression_presets": "Unaprijed postavljene cron izraze", + "library_deleted": "Biblioteka izbrisana", + "library_import_path_description": "Navedite mapu za uvoz. Ova će se mapa, uključujući podmape, skenirati u potrazi za slikama i videozapisima.", + "library_scanning": "Periodično Skeniranje", + "library_scanning_description": "Konfigurirajte periodično skeniranje biblioteke", + "library_scanning_enable_description": "Omogući periodično skeniranje biblioteke", + "library_settings": "Externa biblioteka", + "library_settings_description": "Upravljajte postavkama vanjske biblioteke", + "library_tasks_description": "Obavljati bibliotekne zadatke", + "library_watching_enable_description": "Pratite vanjske biblioteke za promjena datoteke", + "library_watching_settings": "Gledanje biblioteke (EKSPERIMENTALNO)", + "library_watching_settings_description": "Automatsko praćenje promijenjenih datoteke", + "logging_enable_description": "Omogući zapisivanje", + "logging_level_description": "Kada je omogućeno, koju razinu zapisavanje koristiti.", + "logging_settings": "Zapisavanje", + "machine_learning_clip_model": "CLIP model", + "machine_learning_clip_model_description": "Naziv CLIP modela navedenog ovdje. Imajte na umu da morate ponovno pokrenuti posao 'Pametno Pretraživanje' za sve slike nakon promjene modela.", + "machine_learning_duplicate_detection": "Detekcija Duplikata", + "machine_learning_duplicate_detection_enabled": "Omogući detekciju duplikata", "machine_learning_duplicate_detection_enabled_description": "", "machine_learning_duplicate_detection_setting_description": "", "machine_learning_enabled_description": "", @@ -111,53 +116,59 @@ "machine_learning_settings_description": "", "machine_learning_smart_search": "", "machine_learning_smart_search_description": "", - "machine_learning_smart_search_enabled_description": "", - "machine_learning_url_description": "", - "manage_concurrency": "", - "manage_log_settings": "", - "map_dark_style": "", - "map_enable_description": "", - "map_light_style": "", - "map_reverse_geocoding": "", - "map_reverse_geocoding_enable_description": "", - "map_reverse_geocoding_settings": "", - "map_settings": "", - "map_settings_description": "", - "map_style_description": "", - "metadata_extraction_job": "", - "metadata_extraction_job_description": "", - "migration_job": "", - "migration_job_description": "", + "machine_learning_smart_search_enabled": "Omogući pametno pretraživanje", + "machine_learning_smart_search_enabled_description": "Ako je onemogućeno, slike neće biti kodirane za pametno pretraživanje.", + "machine_learning_url_description": "URL poslužitelja strojnog učenja", + "manage_concurrency": "Upravljanje Istovremenošću", + "manage_log_settings": "Upravljanje postavkama zapisivanje", + "map_dark_style": "Tamni stil", + "map_enable_description": "Omogući značajke karte", + "map_gps_settings": "Postavke Karte i GPS-a", + "map_gps_settings_description": "Upravljajte Postavkama Karte i GPS-a (Obrnuto Geokodiranje)", + "map_implications": "Značajka karte se oslanja na vanjsku uslugu pločica (tiles.immich.cloud)", + "map_light_style": "Svijetli stil", + "map_manage_reverse_geocoding_settings": "Upravljajte postavkama Obrnutog Geokodiranja", + "map_reverse_geocoding": "Obrnuto Geokodiranje", + "map_reverse_geocoding_enable_description": "Omogući obrnuto geokodiranje", + "map_reverse_geocoding_settings": "Postavke Obrnuto Geokodiranje", + "map_settings": "Karta", + "map_settings_description": "Upravljanje postavkama karte", + "map_style_description": "URL na style.json temu karte", + "metadata_extraction_job": "Izdvoj metapodatke", + "metadata_extraction_job_description": "Izdvojite podatke o metapodacima iz svakog sredstva, kao što su GPS i rezolucija", + "migration_job": "Migracija", + "migration_job_description": "Premjestite minijature za sredstva i lica u najnoviju strukturu mapa", "no_paths_added": "", - "no_pattern_added": "", - "note_apply_storage_label_previous_assets": "", - "note_cannot_be_changed_later": "", - "note_unlimited_quota": "", - "notification_email_from_address": "", - "notification_email_from_address_description": "", - "notification_email_host_description": "", - "notification_email_ignore_certificate_errors": "", - "notification_email_ignore_certificate_errors_description": "", - "notification_email_password_description": "", - "notification_email_port_description": "", - "notification_email_sent_test_email_button": "", - "notification_email_setting_description": "", - "notification_email_test_email_failed": "", - "notification_email_test_email_sent": "", - "notification_email_username_description": "", - "notification_enable_email_notifications": "", - "notification_settings": "", - "notification_settings_description": "", - "oauth_auto_launch": "", - "oauth_auto_launch_description": "", - "oauth_auto_register": "", - "oauth_auto_register_description": "", - "oauth_button_text": "", - "oauth_client_id": "", - "oauth_client_secret": "", - "oauth_enable_description": "", - "oauth_issuer_url": "", - "oauth_mobile_redirect_uri": "", + "no_pattern_added": "Nije dodan uzorak", + "note_apply_storage_label_previous_assets": "Napomena: da biste primijenili Oznaku Pohrane na prethodno prenesena sredstva, pokrenite", + "note_cannot_be_changed_later": "NAPOMENA: Ovo se ne može promijeniti kasnije!", + "note_unlimited_quota": "Napomena: Unesite 0 za neograničenu kvotu", + "notification_email_from_address": "Od adrese", + "notification_email_from_address_description": "E-mail adresa pošiljatelja, na primjer: \"Immich Photo Server \"", + "notification_email_host_description": "Poslužitelja e-pošte (npr. smtp.immich.app)", + "notification_email_ignore_certificate_errors": "Ignoriraj pogreške certifikata", + "notification_email_ignore_certificate_errors_description": "Ignoriraj pogreške provjere valjanosti TLS certifikata (nije preporučeno)", + "notification_email_password_description": "Lozinka za korištenje pri autentifikaciji s poslužiteljem e-pošte", + "notification_email_port_description": "Port poslužitelja e-pošte (npr. 25, 465, ili 587)", + "notification_email_sent_test_email_button": "Pošaljite probni e-mail i spremi", + "notification_email_setting_description": "Postavke za slanje e-mail obavijeste", + "notification_email_test_email": "Pošalji probni e-mail", + "notification_email_test_email_failed": "Slanje testne e-pošte nije uspjelo, provjerite svoje postavke", + "notification_email_test_email_sent": "Testna e-poruka poslana je na {email}. Provjerite svoju pristiglu poštu.", + "notification_email_username_description": "Korisničko ime koje se koristi pri autentifikaciji s poslužiteljem e-pošte", + "notification_enable_email_notifications": "Omogući obavijesti putem e-pošte", + "notification_settings": "Postavke Obavijesti", + "notification_settings_description": "Upravljanje postavkama obavijesti, uključujući e-poštu", + "oauth_auto_launch": "Automatsko pokretanje", + "oauth_auto_launch_description": "Automatski pokrenite OAuth prijavu nakon navigacije na stranicu za prijavu", + "oauth_auto_register": "Automatska registracija", + "oauth_auto_register_description": "Automatski registrirajte nove korisnike nakon prijave s OAuth", + "oauth_button_text": "Tekst gumba", + "oauth_client_id": "ID Klijenta", + "oauth_client_secret": "Tajna Klijenta", + "oauth_enable_description": "Prijavite se putem OAutha", + "oauth_issuer_url": "URL Izdavatelja", + "oauth_mobile_redirect_uri": "Mobilnog Preusmjeravanja URI", "oauth_mobile_redirect_uri_override": "", "oauth_mobile_redirect_uri_override_description": "", "oauth_scope": "", @@ -839,27 +850,31 @@ "storage_label": "", "storage_usage": "", "submit": "", - "suggestions": "", - "sunrise_on_the_beach": "", + "suggestions": "Prijedlozi", + "sunrise_on_the_beach": "Sunrise on the beach", "swap_merge_direction": "", - "sync": "", + "sync": "Sink.", "template": "", - "theme": "", - "theme_selection": "", - "theme_selection_description": "", - "time_based_memories": "", - "timezone": "", - "to_archive": "", - "to_favorite": "", - "toggle_settings": "", - "toggle_theme": "", + "theme": "Tema", + "theme_selection": "Izbor teme", + "theme_selection_description": "Automatski postavite temu na svijetlu ili tamnu ovisno o postavkama sustava vašeg preglednika", + "they_will_be_merged_together": "Oni ću biti spojeni zajedno", + "time_based_memories": "Uspomene temeljene na vremenu", + "timezone": "Vremenska zona", + "to_archive": "Arhivaj", + "to_change_password": "Promjeni lozinku", + "to_favorite": "Omiljeni", + "to_login": "Prijava", + "to_trash": "Smeće", + "toggle_settings": "Uključi/isključi postavke", + "toggle_theme": "Promjeni temu", "toggle_visibility": "", - "total_usage": "", - "trash": "", - "trash_all": "", - "trash_no_results_message": "", - "trashed_items_will_be_permanently_deleted_after": "", - "type": "", + "total_usage": "Ukupna upotreba", + "trash": "Smeće", + "trash_all": "Stavi sve u smeće", + "trash_no_results_message": "Ovdje će se prikazati bačene fotografije i videozapisi.", + "trashed_items_will_be_permanently_deleted_after": "Stavke bačene u smeće trajno će se izbrisati nakon {days, plural, one {# day} other {# days}}.", + "type": "Vrsta", "unarchive": "", "unarchived": "", "unfavorite": "", diff --git a/web/src/lib/i18n/hu.json b/web/src/lib/i18n/hu.json index c754035c7a..7869e956c7 100644 --- a/web/src/lib/i18n/hu.json +++ b/web/src/lib/i18n/hu.json @@ -129,12 +129,13 @@ "map_enable_description": "Térkép funkciók engedélyezése", "map_gps_settings": "Térkép és GPS beállítások", "map_gps_settings_description": "A térkép és a GPS (fordított geokódolás) beállításainak kezelése", + "map_implications": "A térkép szolgáltatás egy külső csempeszolgáltatót használ (tiles.immich.cloud)", "map_light_style": "Világos stílus", "map_manage_reverse_geocoding_settings": "A fordított geokódolás beállításainak kezelése", "map_reverse_geocoding": "Fordított Geokódolás", "map_reverse_geocoding_enable_description": "Fordított geokódolás engedélyezése", "map_reverse_geocoding_settings": "Fordított Geokódolási Beállítások", - "map_settings": "Térkép beállítások", + "map_settings": "Térkép", "map_settings_description": "Térkép beállítások kezelése", "map_style_description": "Egy style.json térképstílusra mutató URL", "metadata_extraction_job": "Metaadatok feldolgozása", @@ -227,7 +228,7 @@ "storage_template_migration_info": "A megváltozott sablon csak az újonnan feltöltött fájlokra lesz alkalmazva. A fájlok visszamenőleges megváltoztatásához futtatni kell a megfelelő munkát: {job}.", "storage_template_migration_job": "Tárhely Sablon Migrációja", "storage_template_more_details": "További információért erről a szolgáltatásról lásd Tárolási Sablont és az implikációkat", - "storage_template_onboarding_description": "Engedélyezve, ez a funkció automatikusan rendszerezi a fájlokat egy felhasználó által megadott sablon alapján. Stabilitási problémák miatt a funkció alapértelmezés szerint ki van kapcsolva. További információkért tekintse meg a dokumentációt.", + "storage_template_onboarding_description": "Ez a funkció, ha be van kapcsolva, automatikusan rendszerezi a fájlokat egy felhasználó által megadott sablon alapján. Stabilitási problémák miatt a funkció alapértelmezés szerint ki van kapcsolva. További információkért tekintse meg a dokumentációt.", "storage_template_path_length": "Út hozzávetőleges maximális hossza: {length, number}{limit, number}", "storage_template_settings": "Tárolási sablon", "storage_template_settings_description": "Kezelje a feltöltött képi vagyontárgyak mappaszerkezetét és fájlnevét", @@ -260,7 +261,7 @@ "transcoding_codecs_learn_more": "Hogy többet tudjon meg az itt felhasznált kifejezésekről, látogassa meg az FFmpeg dokumentációt a H.264 kodekhez, a HEVC kodekhez és a VP9 kodekhez.", "transcoding_constant_quality_mode": "Állandó minőségi mód", "transcoding_constant_quality_mode_description": "Az ICQ jobb, mint a CQP, viszont nem minden hardver támogatja. A rendszer az itt beállított módszert részesíti előnyben. A NVENC ignorálja a beállítást, mivel nem támogatja az ICQ-t.", - "transcoding_constant_rate_factor": "Állandó ráta tényező (árt)", + "transcoding_constant_rate_factor": "Állandó ráta tényező (-crf)", "transcoding_constant_rate_factor_description": "Videó minőségi szint. Jellemző értékek kodekenként: H.264: 23, HEVC: 28, VP9: 31, AV1: 35. Minél alacsonyabb, annál jobb minőséget eredményez, viszont nagyobb fájlmérettel is jár.", "transcoding_disabled_description": "Ne transzkódoljon videót. Nem lejátszható videókhoz vezethet néhány kliensen", "transcoding_hardware_acceleration": "Hardveres Gyorsítás", @@ -278,7 +279,7 @@ "transcoding_preferred_hardware_device": "Átkódoláshoz preferált hardver eszköz", "transcoding_preferred_hardware_device_description": "Csak VAAPI vagy QSV esetén. Beállítja a hardveres transzkódoláshoz használt DRI node-ot.", "transcoding_preset_preset": "Beállítás (-preset)", - "transcoding_preset_preset_description": "Tömörítési gyorsaság. Lassabb beállítások esetén kisebb fájlokat generál, valamint növeli a minőséget megcélzott bitráta esetén. A VP9 kódolás figyelmen kívül hagyja a `faster`-nél gyorsabb beállításokat.", + "transcoding_preset_preset_description": "Tömörítési gyorsaság. Lassabb beállítások esetén kisebb fájlokat generál, valamint növeli a minőséget megcélzott bitráta esetén. A VP9 kódolás figyelmen kívül hagyja a 'faster (gyorsabb)'-nál gyorsabb beállításokat.", "transcoding_reference_frames": "Referencia képkockák", "transcoding_reference_frames_description": "Ennyi képkockára hivatkozzon egy képkocka tömörítéséhez. Magasabb értékek növelik a tömörítési hatékonyságot, de lelassítják a kódolási folyamatot. 0 esetén a szoftver magának beállítja az értéket.", "transcoding_required_description": "Csak az el nem fogadott formátumú videókat", @@ -320,7 +321,8 @@ "user_settings": "Felhasználó Beállítások", "user_settings_description": "Felhasználó beállítások kezelése", "user_successfully_removed": "{email} felhasználó sikeresen törlésre került.", - "version_check_enabled_description": "Engedélyezze rendszeres kérések küldését a GitHub szervereinek új verzió elérhetőségének ellenőrzésére", + "version_check_enabled_description": "Új verziók elérhetőségének ellenőrzése", + "version_check_implications": "Az új verziók ellenőrzése szolgáltatás időszakos kommunikációt igényel a github.com oldallal", "version_check_settings": "Verzió Ellenőrzés", "version_check_settings_description": "Az új verzióról való értesítés be- és kikapcsolása", "video_conversion_job": "Videók Átkódolása", @@ -332,6 +334,7 @@ "advanced": "Haladó", "age_months": "Kor {months, plural, one {# month} other {# months}}", "age_year_months": "Kor 1 év, {months, plural, one {# month} other {# months}}", + "age_years": "{years, plural, other {# éves}}", "album_added": "Albumhoz hozzáadva", "album_added_notification_setting_description": "Küldjön emailes értesítőt, amikor hozzáadnak egy megosztott albumhoz", "album_cover_updated": "Album borító frissítve", @@ -358,7 +361,8 @@ "allow_dark_mode": "Sötét stílus engedélyezése", "allow_edits": "Szerkesztések engedélyezése", "allow_public_user_to_download": "Engedélyezze publikus felhasználónak, hogy letöltse", - "allow_public_user_to_upload": "Engedélyezze publikus felhasználónak, hogy feltöltsön", + "allow_public_user_to_upload": "Engedélyezze a feltöltést publikus felhasználónak", + "anti_clockwise": "Óramutató járásával ellentétes irány", "api_key": "API kulcs", "api_key_description": "Ez az érték csak egyszer jelenik meg. Az ablak bezárása előtt feltétlenül másolja át.", "api_key_empty": "A te API Kulcs neved nem kéne üres legyen", @@ -385,8 +389,18 @@ "asset_uploaded": "Feltöltve", "asset_uploading": "Feltöltés...", "assets": "elemek", + "assets_added_count": "{count, plural, other {# elem}} hozzáadva", + "assets_added_to_album_count": "{count, plural, other {# elem}} hozzáadva az albumhoz", + "assets_added_to_name_count": "{count, plural, other {# elem}} hozzáadva a(z) {hasName, select, true {{name}} other {új}} albumba", + "assets_count": "{count, plural, other {# elem}}", "assets_moved_to_trash": "{count, plural, one {# fájl} other {# fájl}} a lomtárba mozgatva", + "assets_moved_to_trash_count": "{count, plural, other {# elem}} szemétbe mozgatva", + "assets_permanently_deleted_count": "{count, plural, other {# elem}} örökre törölve", + "assets_removed_count": "{count, plural, other {# elem}} eltávolítva", "assets_restore_confirmation": "Biztosan visszaállítja a lomtárbeli elemeket? Ez a művelet nem visszavonható!", + "assets_restored_count": "{count, plural, other {# elem}} visszaállítva", + "assets_trashed_count": "{count, plural, other {# elem}} kidobva", + "assets_were_part_of_album_count": "{count, plural, other {# elem}} már az album része volt", "authorized_devices": "Engedélyezett készülékek", "back": "Vissza", "back_close_deselect": "Vissza, bezárás, vagy kijelölés törlése", @@ -394,7 +408,10 @@ "birthdate_saved": "Születésnap elmentve", "birthdate_set_description": "A születés napját a rendszer annak kijelzésére használja, hogy a fénykép készítésének idejében az illető hány éves volt.", "blurred_background": "Homályos háttér", + "build": "Építés", + "build_image": "Kép építése", "bulk_delete_duplicates_confirmation": "Biztosan kitöröl {count, plural, one {# duplikált fájlt} other {# duplikált fájlt}}? A művelet során minden hasonló fájlcsoportból a legnagyobb méretű fájlt megtartja, minden másik duplikált fájlt kitörli. Ez a művelet nem visszavonható!", + "bulk_keep_duplicates_confirmation": "Biztosan meg szeretne tartani {count, plural, other {# egyező elemet}}? Ez felold minden duplikátum csoportot elemek törlése nélkül.", "bulk_trash_duplicates_confirmation": "Biztosan kitöröl {count, plural, one {# duplikált fájlt} other {# duplikált fájlt}}? Ez a művelet megtartja minden fájlcsoportból a legnagyobb méretű elemet, és kitörli minden másik duplikáltat.", "buy": "Immich megvásárlása", "camera": "Fényképezőgép", @@ -427,6 +444,7 @@ "clear_all_recent_searches": "Legutóbbi keresések törlése", "clear_message": "Üzenet törlése", "clear_value": "Érték törlése", + "clockwise": "Óramutató járásával megegyező irány", "close": "Bezárás", "collapse": "Összecsuk", "collapse_all": "Mindet összecsuk", @@ -503,12 +521,15 @@ "do_not_show_again": "Ne mutassa többet ezt az üzenetet", "done": "Kész", "download": "Letöltés", + "download_include_embedded_motion_videos": "Beágyazott videók", + "download_include_embedded_motion_videos_description": "Mozgó képekbe beágyazott videók mutatása külön fájlként", "download_settings": "Letöltés", "download_settings_description": "Képi vagyontárgyak letöltésére vonatkozó beállítások", "downloading": "Letöltés", "downloading_asset_filename": "Fájl letöltése {filename}", "drop_files_to_upload": "Húzza a fájlokat bárhova a feltöltéshez", "duplicates": "Duplikátumok", + "duplicates_description": "Oldja fel a csoportokat a (ha léteznek) duplukátumok megjelölésével", "duration": "Időtartam", "durations": { "days": "{days, plural, one {nap} other {{days, number} nap}}", @@ -535,6 +556,10 @@ "edit_user": "Felhasználó módosítása", "edited": "Módosítva", "editor": "Szerkesztő", + "editor_close_without_save_prompt": "A változtatások nem lesznek mentve", + "editor_close_without_save_title": "Szerkesztő bezárása?", + "editor_crop_tool_h2_aspect_ratios": "Oldalarányok", + "editor_crop_tool_h2_rotation": "Forgatás", "email": "Email", "empty": "", "empty_album": "Üres Album", @@ -542,7 +567,7 @@ "empty_trash_confirmation": "Biztosan kiüríti a lomtárat? Ezzel minden lomtárbeli fájlt véglegesen letöröl az Immich szolgáltatásból.\nEz a művelet nem visszavonható!", "enable": "Engedélyezés", "enabled": "Engedélyezve", - "end_date": "", + "end_date": "Vég dátum", "error": "Hiba", "error_loading_image": "Hiba a kép betöltése közben", "error_title": "Hiba - valami félresikerült", @@ -550,7 +575,9 @@ "cannot_navigate_next_asset": "Nem lehet a következő elemhez navigálni", "cannot_navigate_previous_asset": "Nem lehet az előző elemhez navigálni", "cant_apply_changes": "Nem lehet alkalmazni a változtatásokat", + "cant_change_activity": "Nem lehet {enabled, select, true {engedélyezni} other {kikapcsolni}} tevékenységet", "cant_change_asset_favorite": "Nem lehet a kedvenc állapotot megváltoztatni ehhez az elemhez", + "cant_change_metadata_assets_count": "Nem lehet {count, plural, other {# elem}} metaadatát megváltoztatni", "cant_get_faces": "Arcok lekérdezése sikertelen", "cant_get_number_of_comments": "Hozzászólások számának lekérdezése sikertelen", "cant_search_people": "Emberek keresése sikertelen", @@ -564,6 +591,7 @@ "error_removing_assets_from_album": "Hiba történt az elemek albumból való eltávolítása során, további információért ellenőrizze a logokat", "error_selecting_all_assets": "Minden elem kijelölése közben hiba lépett fel", "exclusion_pattern_already_exists": "Ez a kizárási minta már létezik.", + "failed_job_command": "Parancs {command} hibával zárult a {job} munkában", "failed_to_create_album": "Album készítése sikertelen", "failed_to_create_shared_link": "Megosztott link készítése sikertelen", "failed_to_edit_shared_link": "Megosztott link szerkesztése sikertelen", @@ -572,81 +600,121 @@ "failed_to_load_assets": "Elemek betöltése sikertelen", "failed_to_load_people": "Emberek betöltése sikertelen", "failed_to_remove_product_key": "Termékkulcs eltávolítása sikertelen", + "failed_to_stack_assets": "Elemek csoportosítása sikertelen", + "failed_to_unstack_assets": "Elemek szétszedése sikertelen", "import_path_already_exists": "Ez az importálási útvonal már létezik.", "incorrect_email_or_password": "Helytelen e-mail vagy jelszó", "paths_validation_failed": "Sikertelen érvényesítés {paths, plural, one {# elérési útvonalon} other {# elérési útvonalon}}", "profile_picture_transparent_pixels": "Profilképek nem tartalmazhatnak átlátszó pixeleket. Közelítsen rá és/vagy mozgassa a képet.", "quota_higher_than_disk_size": "Az elérhető háttértárnál nagyobb kvótát állított be", + "repair_unable_to_check_items": "Nem sikerült {count, select, one {element} other {elemeket}} ellenőrizni", "unable_to_add_album_users": "Felhasználók hozzáadása albumhoz sikertelen", "unable_to_add_assets_to_shared_link": "Felhasználók hozzáadása megosztott linkhez sikertelen", "unable_to_add_comment": "Hozzászólás sikertelen", "unable_to_add_exclusion_pattern": "Kivétel minta hozzáadása sikertelen", "unable_to_add_import_path": "Importálási útvonal hozzáadása sikertelen", "unable_to_add_partners": "Partnerek hozzáadása sikertelen", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", + "unable_to_add_remove_archive": "Elem {archived, select, true {eltávolítása archívumból} other {hozzáadása archívumba}} sikertelen", + "unable_to_add_remove_favorites": "Elem {favorite, select, true {eltávolítása kedvencekből} other {hozzáadása kedvencekhez}} sikertelen", + "unable_to_archive_unarchive": "Elem {archived, select, true {archiválása} other {kivétele archívumból}} sikertelen", + "unable_to_change_album_user_role": "Album tagjának szerepének megváltoztatása sikertelen", + "unable_to_change_date": "Dátum megváltoztatása sikertelen", + "unable_to_change_favorite": "Kedvenc állapot megváltoztatása sikertelen", + "unable_to_change_location": "Hely megváltoztatása sikertelen", + "unable_to_change_password": "Jelszó megváltoztatása sikertelen", + "unable_to_change_visibility": "{count, plural, other {# ember}} láthatóságának a megváltoztatása sikertelen", "unable_to_check_item": "", "unable_to_check_items": "", + "unable_to_complete_oauth_login": "OAuth bejelentkezés sikertelen", + "unable_to_connect": "Csatlakozás sikertelen", + "unable_to_connect_to_server": "Szerverhez való csatlakozás sikertelen", "unable_to_copy_to_clipboard": "Vágólapra másolás sikertelen. Ellenőrizze, hogy a kapcsolat https-en keresztül történik", - "unable_to_create_admin_account": "", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", + "unable_to_create_admin_account": "Admin felhasználó létrehozása sikertelen", + "unable_to_create_api_key": "Új API kulcs létrehozása sikertelen", + "unable_to_create_library": "Könyvtár létrehozása sikertelen", + "unable_to_create_user": "Felhasználó létrehozása sikertelen", + "unable_to_delete_album": "Album törlése sikertelen", + "unable_to_delete_asset": "Elem törlése sikertelen", + "unable_to_delete_assets": "Hiba történt az elemek törlésekor", + "unable_to_delete_exclusion_pattern": "Kizárási minta törlése sikertelen", + "unable_to_delete_import_path": "Import útvonal törlése sikertelen", + "unable_to_delete_shared_link": "Megosztott link törlése sikertelen", "unable_to_delete_user": "Nem sikerült törölni a felhasználót", + "unable_to_download_files": "Fájlok letöltése sikertelen", + "unable_to_edit_exclusion_pattern": "Kizárási minta szerkesztése sikertelen", + "unable_to_edit_import_path": "Import útvonal szerkesztése sikertelen", "unable_to_empty_trash": "Nem sikerült a lomtár ürítése", "unable_to_enter_fullscreen": "Nem lehet belépni a teljes képernyőre", "unable_to_exit_fullscreen": "Nem lehet kilépni a teljes képernyőről", - "unable_to_hide_person": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", + "unable_to_get_comments_number": "Hozzászólások számának lekérdezése sikertelen", + "unable_to_get_shared_link": "Megosztott link lekérdezése sikertelen", + "unable_to_hide_person": "Személy elrejtése sikertelen", + "unable_to_link_oauth_account": "OAuth felhasználó csatlakoztatása sikertelen", + "unable_to_load_album": "Album betöltése sikertelen", + "unable_to_load_asset_activity": "Elem aktivitásának betöltése sikertelen", + "unable_to_load_items": "Elemek betöltése sikertelen", + "unable_to_load_liked_status": "Tetszik állapot betöltése sikertelen", + "unable_to_log_out_all_devices": "Minden eszközből való kijelentkeztetés sikertelen", + "unable_to_log_out_device": "Sikertelen kijelentkezés", + "unable_to_login_with_oauth": "Sikertelen bejelentkezés OAuth-tal", + "unable_to_play_video": "Videó lejátszása sikertelen", + "unable_to_reassign_assets_existing_person": "Nem sikerült az elemeket áthelyezni {name, select, null {egy létező személyhez} other {hozzá: {name}}}", + "unable_to_reassign_assets_new_person": "Elemek áthelyezése új személyhez sikertelen", + "unable_to_refresh_user": "Felhasználó újratöltése sikertelen", + "unable_to_remove_album_users": "Felhasználó albumból való eltávolítása sikertelen", + "unable_to_remove_api_key": "API kulcs eltávolítása sikertelen", + "unable_to_remove_assets_from_shared_link": "Elemek eltávolítása megosztott linkből sikertelen", "unable_to_remove_comment": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_library": "Könyvtár törlése sikertelen", + "unable_to_remove_offline_files": "Offline fájlok törlése sikertelen", + "unable_to_remove_partner": "Partner eltávolítása sikertelen", + "unable_to_remove_reaction": "Reakció eltávolítása sikertelen", "unable_to_remove_user": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", + "unable_to_repair_items": "Elemek javítása sikertelen", + "unable_to_reset_password": "Jelszó visszaállítása sikertelen", + "unable_to_resolve_duplicate": "Duplikátum feloldása sikertelen", + "unable_to_restore_assets": "Elemek szemeteskosárból való visszaállítása sikertelen", "unable_to_restore_trash": "Nem sikerült a lomtár visszaállítása", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", + "unable_to_restore_user": "Felhasználó visszaállítása sikertelen", + "unable_to_save_album": "Album mentése sikertelen", + "unable_to_save_api_key": "API kulcs mentése sikertelen", + "unable_to_save_date_of_birth": "Születési időpont mentése sikertelen", + "unable_to_save_name": "Név mentése sikertelen", + "unable_to_save_profile": "Profil mentése sikertelen", + "unable_to_save_settings": "Beállítások mentése sikertelen", + "unable_to_scan_libraries": "Könyvtárak ellenőrzése sikertelen", + "unable_to_scan_library": "Könyvtár ellenőrzése sikertelen", + "unable_to_set_feature_photo": "Kijelölt fénykép beállítása sikertelen", + "unable_to_set_profile_picture": "Profilkép beállítása sikertelen", "unable_to_submit_job": "Nem sikerült a profilt elmenteni", "unable_to_trash_asset": "Nem sikerült a fájl lomtárba mozgatása", "unable_to_unlink_account": "Nem sikerült a fiók lekapcsolása", + "unable_to_update_album_cover": "Albumborító beállítása sikertelen", + "unable_to_update_album_info": "Album információ frissítése sikertelen", "unable_to_update_library": "Nem sikerült a képtár módosítása", "unable_to_update_location": "Nem sikerült az elérés módosítása", "unable_to_update_settings": "Nem sikerült a beállítások módosítása", "unable_to_update_timeline_display_status": "Nem sikerült az idővonal kijelzési státuszának módosítása", - "unable_to_update_user": "Nem sikerült a felhasználó módosítása" + "unable_to_update_user": "Nem sikerült a felhasználó módosítása", + "unable_to_upload_file": "Fájlfeltöltés sikertelen" }, "every_day_at_onepm": "", "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", + "exif": "Exif", "exit_slideshow": "Kilépés a diavetítésből", - "expand_all": "", + "expand_all": "Minden kinyitása", "expire_after": "Lejárati idő", "expired": "Lejárt", + "expires_date": "Lejár {date}", "explore": "Felfedezés", "export": "Exportálás", "export_as_json": "Exportálás JSON formátumban", "extension": "Kiterjesztés", "external": "Külső", "external_libraries": "Külső Képtárak", + "face_unassigned": "Nincs hozzárendelve", "failed_to_get_people": "Személyek lekérése sikertelen", "favorite": "Kedvenc", "favorite_or_unfavorite_photo": "Fotó kedvencnek jelölése vagy annak visszavonása", @@ -671,15 +739,33 @@ "go_to_search": "Ugrás a kereséshez", "go_to_share_page": "Ugrás a megosztás oldalhoz", "group_albums_by": "Albumok csoportosítása...", - "has_quota": "", + "group_no": "Nincs csoportosítás", + "group_owner": "Csoportosítás tulajdonosonként", + "group_year": "Csoportosítás évenként", + "has_quota": "Van kvótája", + "hi_user": "Helló {name} ({email})", + "hide_all_people": "Minden személy elrejtése", "hide_gallery": "Galéria elrejtése", + "hide_named_person": "Személy {name} elrejtése", "hide_password": "Jelszó elrejtése", "hide_person": "Személy elrejtése", + "hide_unnamed_people": "Megnevezetlen emberek elrejtése", "host": "", "hour": "Óra", "image": "Kép", + "image_alt_text_date": "{isVideo, select, true {Videó} other {Kép}} készítési dátuma {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Videó} other {Kép}} vele: {person1} készítve {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Videó} other {Kép}} velük: {person1} és {person2}, ekkor: {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Videó} other {Kép}} velük: {person1}, {person2} és {person3} ekkor: {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Videó} other {Kép}} velük: {person1}, {person2} és {additionalCount, number} más ekkor: {date}", + "image_alt_text_date_place": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, ekkor: {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, vele: {person1}, ekkor: {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, velük: {person1} és {person2}, ekkor: {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, velük: {person1}, {person2} és {person3}, ekkor: {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, velük: {person1}, {person2} és {additionalCount, number} más, ekkor: {date}", "img": "", "immich_logo": "Immich Logó", + "immich_web_interface": "Immich web felület", "import_from_json": "Importálás JSON formátumból", "import_path": "Importálási útvonal", "in_albums": "{count, plural, one {# albumban} other {# albumban}}", @@ -691,12 +777,13 @@ "info": "Infó", "interval": { "day_at_onepm": "Minden nap 13 órakor", - "hours": "", + "hours": "{hours, plural, one {óránként} other {{hours, number} óránként}}", "night_at_midnight": "Minden éjjel éjfélkor", "night_at_twoam": "Minden éjjel 2 órakor" }, "invite_people": "Személyek Meghívása", "invite_to_album": "Meghívás az albumba", + "items_count": "{count, plural, other {# elem}}", "job_settings_description": "", "jobs": "Feladatok", "keep": "Megtartás", @@ -705,15 +792,18 @@ "language": "Nyelv", "language_setting_description": "Válassza ki preferált nyelvét", "last_seen": "Utoljára látva", + "latest_version": "Legfrissebb verzió", + "latitude": "Szélesség", "leave": "Elhagyás", "let_others_respond": "Engedd, hogy mások reagáljanak", "level": "Szint", "library": "Képtár", "library_options": "Képtár beállítások", "light": "Világos", + "like_deleted": "Tetszik törölve", "link_options": "Link beállítások", - "link_to_oauth": "", - "linked_oauth_account": "", + "link_to_oauth": "Csatlakoztatás OAuth-hoz", + "linked_oauth_account": "Csatlakoztatott OAuth felhasználó", "list": "Lista", "loading": "Betöltés", "loading_search_results_failed": "Keresési eredmények betöltése sikertelen", @@ -721,8 +811,12 @@ "log_out_all_devices": "Összes Eszköz Kijelentkeztetése", "logged_out_all_devices": "Az összes eszköz kijelentkeztetve", "logged_out_device": "Eszköz kijelentkeztetve", - "login_has_been_disabled": "", - "look": "", + "login": "Bejelentkezés", + "login_has_been_disabled": "Bejelentkezés le van tiltva.", + "logout_all_device_confirmation": "Biztos, hogy minden eszközből szeretne kijelentkezni?", + "logout_this_device_confirmation": "Biztos, hogy szeretne kijelentkezni ebből az eszközből?", + "longitude": "Hosszúság", + "look": "Kinézet", "loop_videos": "Videók ismétlése", "loop_videos_description": "Engedélyezi a videók folyamatosan ismételt lejátszását az elem megjelenítőben.", "make": "Gyártó", @@ -734,6 +828,7 @@ "manage_your_devices": "Engedélyezett készülékek kezelése", "manage_your_oauth_connection": "OAuth kapcsolat kezelése", "map": "Térkép", + "map_marker_for_images": "Térképjelölő a képekhez itt készült: {country}, {city}", "map_marker_with_image": "Térképjelölő képpel", "map_settings": "Térkép beállítások", "matches": "Megegyezések", @@ -741,13 +836,15 @@ "memories": "Emlékek", "memories_setting_description": "Emlékek tartalmának kezelése", "memory": "Emlék", + "memory_lane_title": "Emlékek {title}", "menu": "Menü", "merge": "Összevonás", "merge_people": "Személyek összevonása", "merge_people_limit": "Egyszerre legfeljebb 5 arcot vonhatsz össze", "merge_people_prompt": "Biztosan összevonod ezeket a személyeket? Ez a művelet nem visszavonható.", - "merge_people_successfully": "", - "minimize": "", + "merge_people_successfully": "Személyek sikeresen egyesítve", + "merged_people_count": "{count, plural, other {# személy}} egyesítve", + "minimize": "Lekicsinítés", "minute": "Perc", "missing": "Hiányzó", "model": "Modell", @@ -758,79 +855,110 @@ "name": "Név", "name_or_nickname": "Név vagy becenév", "never": "Soha", + "new_album": "Új album", "new_api_key": "Új API Kulcs", "new_password": "Új jelszó", "new_person": "Új személy", "new_user_created": "Új felhasználó létrehozva", + "new_version_available": "ÚJ VERZIÓ ELÉRHETŐ", "newest_first": "Legújabb először", "next": "Következő", "next_memory": "Következő emlék", "no": "Nem", "no_albums_message": "Hozzon létre új albumot a fotói és videói rendszerezéséhez", + "no_albums_with_name_yet": "Úgy tűnik, hogy nincs még ilyen névvel album.", + "no_albums_yet": "Úgy tűnik, hogy még nem lett album létrehozva.", "no_archived_assets_message": "Archiváljon fényképeket és videókat, hogy elrejtse azokat a Fényképek nézetből", "no_assets_message": "KATTINTSON AZ ELSŐ FÉNYKÉPE FELTÖLTÉSÉHEZ", - "no_exif_info_available": "", + "no_duplicates_found": "Duplikátumok nem találhatók.", + "no_exif_info_available": "Exif információ nem elérhető", "no_explore_results_message": "Töltsön fel több fényképet, hogy felfedezze a gyűjteményét.", "no_favorites_message": "Jelöljön meg kedvenceket, hogy gyorsan megtalálhassa legjobb fényképeit és videóit", "no_libraries_message": "Hozzon létre külső képtárat a fényképei és videói megtekintéséhez", "no_name": "Nincs Név", - "no_places": "", + "no_places": "Nincsenek helyek", "no_results": "Nincsenek eredmények", + "no_results_description": "Próbáljon egy szinonimát, vagy fogalmazzon általánosabban", "no_shared_albums_message": "Hozzon létre egy új albumot, hogy megoszthassa fényképeit és videóit másokkal", "not_in_any_album": "Nincs albumban", + "note_apply_storage_label_to_previously_uploaded assets": "Megjegyzés: hogy a Tárhelycímkézést végrehajtódjon a korábban feltöltött elemeken, futtassa a", + "note_unlimited_quota": "Megjegyzés: Írjon 0-t végtelen kvótához", "notes": "Jegyzetek", "notification_toggle_setting_description": "Emailes értesítések engedélyezése", "notifications": "Értesítések", "notifications_setting_description": "Értesítések kezelése", "oauth": "OAuth", "offline": "Offline", + "offline_paths": "Offline útvonalak", + "offline_paths_description": "Ezeket az eredményeket okozhatja a külső könyvtárhoz nem tartozó fájlok manuális törlése.", "ok": "Rendben", "oldest_first": "Legrégebbi először", + "onboarding": "Első lépések", + "onboarding_privacy_description": "Az alábbi (nem kötelező) szolgáltatások külső szolgáltatásokon alapulnak, és bármikor kikapcsolhatóak az adminisztrációs beállításokban.", + "onboarding_theme_description": "Válasszon egy színt az alkalmazásnak. Ezt bármikor megváltoztathatja a beállításokban.", + "onboarding_welcome_description": "Állítsunk be néhány gyakori beállítást.", + "onboarding_welcome_user": "Üdvözlöm, {user}", "online": "Online", "only_favorites": "Csak kedvencek", "only_refreshes_modified_files": "Csak a megváltoztatott fájlokat frissíti", - "open_the_search_filters": "", + "open_in_map_view": "Megnyitás térkép nézetben", + "open_in_openstreetmap": "Megnyitás OpenStreetMap-ben", + "open_the_search_filters": "Keresési szűrők megnyitása", "options": "Beállítások", + "or": "vagy", "organize_your_library": "Rendszerezze képtárát", + "original": "eredeti", "other": "Egyéb", "other_devices": "Egyéb eszközök", "other_variables": "Egyéb változók", "owned": "Tulajdonos", "owner": "Tulajdonos", + "partner": "Partner", + "partner_can_access": "{partner} hozzáférhet", + "partner_can_access_assets": "Minden fényképe és videója, kivéve amik archiválásra vagy törlésre kerültek", + "partner_can_access_location": "A fényképei készítési helye", "partner_sharing": "Társmegosztás", "partners": "Társak", "password": "Jelszó", - "password_does_not_match": "", - "password_required": "", - "password_reset_success": "", + "password_does_not_match": "Jelszavak nem egyeznek", + "password_required": "Jelszó szükséges", + "password_reset_success": "Jelszóvisszaállítás sikeres", "past_durations": { - "days": "", - "hours": "", - "years": "" + "days": "{days, plural, one {Tegnap} other {Elmúlt # nap}}", + "hours": "{hours, plural, one {Előző óra} other {Elmúlt # óra}}", + "years": "{years, plural, one {Tavaly} other {Elmúlt # év}}" }, "path": "Útvonal", "pattern": "Minta", "pause": "Szüneteltetés", "pause_memories": "Emlékek szüneteltetése", "paused": "Szüneteltetve", - "pending": "", + "pending": "Folyamatban lévő", "people": "Személyek", - "people_sidebar_description": "", + "people_edits_count": "{count, plural, other {# személy}} szerkesztve", + "people_sidebar_description": "Jelenítsen meg linket a Személyek fülhöz oldalt", "perform_library_tasks": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", + "permanent_deletion_warning": "Figyelmeztetés végleges törlésről", + "permanent_deletion_warning_setting_description": "Figyelmeztessen fájlok végleges törlése előtt", "permanently_delete": "Végleges törlés", - "permanently_deleted_asset": "", + "permanently_delete_assets_count": "{count, plural, one {Elem} other {Elemek}} végleges törlése", + "permanently_delete_assets_prompt": "Biztos, hogy véglegesen törölni szeretné ezt {count, plural, one {az elemet?} other {a(z) # elemet?}} Ez el fogja távolítani az albumokból, amikben {count, plural, one {szerepel} other {szerepelnek}}.", + "permanently_deleted_asset": "Elem véglegesen törölve", + "permanently_deleted_assets_count": "{count, plural, other {# elem}} véglegesen törölve", + "person": "Személy", + "person_hidden": "{name}{hidden, select, true { (rejtett)} other {}}", + "photo_shared_all_users": "Mindenkivel megosztotta a fényképeit, vagy nincs senki, akivel meg tudná osztani.", "photos": "Képek", + "photos_and_videos": "Fényképek és videók", "photos_count": "{count, plural, one {{count, number} Fotó} other {{count, number} Fotó}}", "photos_from_previous_years": "Képek előző évekből", - "pick_a_location": "", + "pick_a_location": "Válasszon egy helyet", "place": "Hely", "places": "Helyek", "play": "Lejátszás", "play_memories": "Emlékek lejátszása", "play_motion_photo": "Mozgókép lejátszása", - "play_or_pause_video": "", + "play_or_pause_video": "Videó elindítása vagy megállítása", "point": "", "port": "Port", "preset": "Sablon", @@ -839,99 +967,193 @@ "previous_memory": "Előző emlék", "previous_or_next_photo": "Előző vagy következő fotó", "primary": "Elsődleges", - "profile_picture_set": "", + "privacy": "Magánszféra", + "profile_image_of_user": "{user} profilképe", + "profile_picture_set": "Profilkép beállítva.", + "public_album": "Publikus album", "public_share": "Nyilvános Megosztás", + "purchase_account_info": "Támogató", + "purchase_activated_subtitle": "Köszönjük, hogy támogatja az Immich-et és a nyílt forráskódú programokat", + "purchase_activated_time": "Aktiválva ekkor: {date, date}", + "purchase_activated_title": "Kulcs sikeresen aktiválva", + "purchase_button_activate": "Aktiválás", + "purchase_button_buy": "Vásárlás", + "purchase_button_buy_immich": "Vásárolja meg az Immich-et", + "purchase_button_never_show_again": "Soha többé ne mutassa", + "purchase_button_reminder": "Emlékeztessen 30 nap múlva", + "purchase_button_remove_key": "Kulcs eltávolítása", + "purchase_button_select": "Kiválasztás", + "purchase_failed_activation": "Aktiválás sikertelen! Ellenőrizze az e-mailjét a helyes termékkulcsért!", + "purchase_individual_description_1": "Magánszemélynek", + "purchase_individual_description_2": "Támogató állapot", + "purchase_individual_title": "Magánszemély", + "purchase_input_suggestion": "Van termékkulcsa? Adja meg a kulcsot alább", + "purchase_license_subtitle": "Vásárolja meg az Immich-et, hogy támogassa a szolgáltatás fejlesztését a jövőben is", + "purchase_lifetime_description": "Élethosszú vásárlás", + "purchase_option_title": "VÁSÁRLÁSI LEHETŐSÉGEK", + "purchase_panel_info_1": "Az Immich készítése sok időt és erőfeszítést igényel, és teljes munkaidőben foglalkoztatunk szoftvermérnököket hogy olyan jóvá tegyük, amennyire csak lehet. Küldetésünk, hogy a nyílt forráskódú szoftver és etikus üzleti gyakorlat fenntartható bevételi forrás legyen a fejlesztőinknek, és egy magánszférát tiszteletben tartó ökoszisztéma készítése, amely valódi alternatívát nyújt a felhasználókat kihasználó felhőszolgáltatásoknak.", + "purchase_panel_info_2": "Mivel elkötelezettek vagyunk, hogy nem zárunk fizetés mögé szolgáltatásokat, ez a vásárlás az Immich semmilyen új részét nem oldja fel. Olyan felhasználóktól, mint Öntől, függünk, hogy az Immich-et tudjuk fejleszteni.", + "purchase_panel_title": "Támogassa a projektet", + "purchase_per_server": "Szerverenként", + "purchase_per_user": "Felhasználónként", + "purchase_remove_product_key": "Termékkulcs eltávolítása", + "purchase_remove_product_key_prompt": "Biztosan el szeretné távolítani a termékkulcsot?", + "purchase_remove_server_product_key": "Szerver termékkulcs eltávolítása", + "purchase_remove_server_product_key_prompt": "Biztosan el szeretné távolítani a szerver termékkulcsot?", + "purchase_server_description_1": "Az egész szerverre", + "purchase_server_description_2": "Támogító állapot", + "purchase_server_title": "Szerver", + "purchase_settings_server_activated": "A szerver termékkulcsot az admin menedzseli", "range": "", + "rating": "Értékelés csillagokkal", + "rating_description": "Exif értékelés megjelenítése az infópanelben", "raw": "", - "reaction_options": "", - "read_changelog": "", + "reaction_options": "Reakció lehetőségek", + "read_changelog": "Változtatások olvasása", + "reassign": "Áthelyezés", + "reassigned_assets_to_existing_person": "{count, plural, other {# elem}} áthelyezve {name, select, null {egy létező személyhez} other {{name}}}", + "reassigned_assets_to_new_person": "{count, plural, other {# elem}} áthelyezve egy új személyhez", + "reassing_hint": "Kijelölt média hozzáadása létező emberhez", "recent": "Friss", "recent_searches": "Friss keresések", "refresh": "Frissítés", + "refresh_encoded_videos": "Elkódolt videók frissítése", + "refresh_metadata": "Metaadatok frissítése", + "refresh_thumbnails": "Előnézetek frissítése", "refreshed": "Frissítve", - "refreshes_every_file": "", + "refreshes_every_file": "Minden fájl frissítése", + "refreshing_encoded_video": "Elkódolt videók frissítése", + "refreshing_metadata": "Metaadatok frissítése", + "regenerating_thumbnails": "Előnézetek újragenerálása", "remove": "Eltávolítás", + "remove_assets_album_confirmation": "Biztosan szeretne eltávolítani {count, plural, one {# elemet} other {# elemet}} az albumból?", + "remove_assets_shared_link_confirmation": "Biztosan szeretne eltávolítani {count, plural, one {# elemet} other {# elemet}} ebből a megosztott linkből?", + "remove_assets_title": "Elemek eltávolítása?", + "remove_custom_date_range": "Szabadon megadott időintervallum eltávolítása", "remove_from_album": "Eltávolítás az albumból", "remove_from_favorites": "Eltávolítás a kedvencekből", "remove_from_shared_link": "Eltávolítás a megosztott linkből", "remove_offline_files": "Offline Fájlok Eltávolítása", + "remove_user": "Felhasználó eltávolítása", + "removed_api_key": "API Kulcs eltávolítva: {name}", + "removed_from_archive": "Archívumból eltávolítva", + "removed_from_favorites": "Kedvencekből eltávolítva", + "removed_from_favorites_count": "A kedvencekből el lett távolítva {count, plural, other {# elem}}", + "rename": "Átnevezés", "repair": "Javítás", - "repair_no_results_message": "", + "repair_no_results_message": "Nem megfigyelt és hiányzó fájlok itt jelennek meg", "replace_with_upload": "Csere feltöltéssel", - "require_password": "", + "repository": "Adattár", + "require_password": "Jelszó szükségessé tétele", + "require_user_to_change_password_on_first_login": "Felhasználó első bejelentkezéskor való jelszóváltoztatásának szükségessé tétele", "reset": "Visszaállítás", "reset_password": "Jelszó visszaállítása", - "reset_people_visibility": "", + "reset_people_visibility": "Emberek láthatóságának visszaállítása", "reset_settings_to_default": "", + "reset_to_default": "Visszaállítás alapállapotba", + "resolve_duplicates": "Duplikátumok feloldása", + "resolved_all_duplicates": "Minden duplikátum feloldása", "restore": "Visszaállít", + "restore_all": "Minden visszaállítása", "restore_user": "Felhasználó visszaállítása", - "retry_upload": "", - "review_duplicates": "", + "restored_asset": "Elem visszaállítása", + "resume": "Folytatás", + "retry_upload": "Feltöltés újrapróbálása", + "review_duplicates": "Megegyező elemek átnézése", "role": "Szerep", + "role_editor": "Szerkesztő", + "role_viewer": "Néző", "save": "Mentés", - "saved_profile": "", - "saved_settings": "", + "saved_api_key": "API Kulcs elmentve", + "saved_profile": "Profil elmentve", + "saved_settings": "Beállítások elmentve", "say_something": "Szólj hozzá", - "scan_all_libraries": "", - "scan_all_library_files": "", - "scan_new_library_files": "", - "scan_settings": "", + "scan_all_libraries": "Minden könyvtár átnézése", + "scan_all_library_files": "Minden könyvtárbeli elem újraellenőrzése", + "scan_new_library_files": "Ellenőrzés új könyvtárbeli elemekért", + "scan_settings": "Felfedezési beállítások", "search": "Keresés", "search_albums": "Albumok keresése", - "search_by_context": "", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", - "search_for_existing_person": "", - "search_people": "", - "search_places": "", - "search_state": "", - "search_timezone": "", - "search_type": "", + "search_by_context": "Keresés kontextus alapján", + "search_by_filename": "Keresés fájlnév vagy kiterjesztés alapján", + "search_by_filename_example": "például IMG_1234.JPG vagy PNG", + "search_camera_make": "Kameragyártó keresése...", + "search_camera_model": "Kameramodell keresése...", + "search_city": "Város keresése...", + "search_country": "Ország keresése...", + "search_for_existing_person": "Már meglévő személy keresése", + "search_no_people": "Nincs személy", + "search_no_people_named": "Nincs személy \"{name}\" néven", + "search_people": "Személyek keresése", + "search_places": "Helyek keresése", + "search_state": "Régió keresése...", + "search_timezone": "Időzóna keresése...", + "search_type": "Típus keresése", "search_your_photos": "Fotók keresése", "searching_locales": "", "second": "Másodperc", - "select_album_cover": "", + "see_all_people": "Minden személy megtekintése", + "select_album_cover": "Albumborító kiválasztása", "select_all": "Összes kijelölése", - "select_avatar_color": "", - "select_face": "", - "select_featured_photo": "", - "select_library_owner": "", - "select_new_face": "", + "select_all_duplicates": "Minden duplikátum kiválasztása", + "select_avatar_color": "Avatár színének választása", + "select_face": "Arc kiválasztása", + "select_featured_photo": "Kijelölt fénykép kiválasztása", + "select_from_computer": "Kiválasztás számítógépről", + "select_keep_all": "Minden megtartása", + "select_library_owner": "Könyvtártulajdonos kijelölése", + "select_new_face": "Új arc kiválasztása", "select_photos": "Fotók választása", + "select_trash_all": "Minden szemétbe helyezése", "selected": "Kijelölt", - "send_message": "", + "selected_count": "{count, plural, other {# kiválasztva}}", + "send_message": "Üzenet küldése", + "send_welcome_email": "Üdvözlő üzenet küldése", "server": "Szerver", - "server_stats": "", - "set": "", - "set_as_album_cover": "", - "set_as_profile_picture": "", - "set_date_of_birth": "", - "set_profile_picture": "", - "set_slideshow_to_fullscreen": "", + "server_offline": "Szerver Nem Elérhető", + "server_online": "Szerver Elérhető", + "server_stats": "Szerver Statisztikák", + "server_version": "Szerver Verzió", + "set": "Beállítás", + "set_as_album_cover": "Beállítás albumborítóként", + "set_as_profile_picture": "Beállítás profilképként", + "set_date_of_birth": "Születési dátum beállítása", + "set_profile_picture": "Profilkép beállítása", + "set_slideshow_to_fullscreen": "Diavetítés teljes képernyőre állítása", "settings": "Beállítások", - "settings_saved": "", + "settings_saved": "Beállítások mentve", "share": "Megosztás", "shared": "Megosztva", - "shared_by": "", - "shared_by_you": "", + "shared_by": "Megosztva általa:", + "shared_by_user": "Megosztva {user} által", + "shared_by_you": "Megosztva Ön által", + "shared_from_partner": "Fényképek {partner}-tól/től", + "shared_link_options": "Megosztott link beállítások", "shared_links": "Megosztott Linkek", + "shared_photos_and_videos_count": "{assetCount, plural, other {# megosztott kép és videó.}}", + "shared_with_partner": "Megosztva vele: {partner}", "sharing": "Megosztás", - "sharing_sidebar_description": "", - "show_album_options": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", - "show_in_timeline": "", - "show_in_timeline_setting_description": "", - "show_keyboard_shortcuts": "", + "sharing_enter_password": "Jelszó megadása szükséges az oldal megtekintéséhez.", + "sharing_sidebar_description": "Jelenítsen meg linket a Megosztás fülhöz oldalt", + "shift_to_permanent_delete": "nyomja meg a ⇧-t hogy véglegesen törölje az elemet", + "show_album_options": "Albummegjelenítési beállítások", + "show_albums": "Albumok megtekintése", + "show_all_people": "Minden személy megjelenítése", + "show_and_hide_people": "Személyek megjelenítése és elrejtése", + "show_file_location": "Fájl helyének megjelenítése", + "show_gallery": "Galéria megjelenítése", + "show_hidden_people": "Rejtett személyek megjelenítése", + "show_in_timeline": "Megjelenítés az idővonalon", + "show_in_timeline_setting_description": "Ettől a felhasználótól származó képek és videók megjelenítése az Ön idővonalán", + "show_keyboard_shortcuts": "Billentyűparancsok megjelenítése", "show_metadata": "Metaadatok mutatása", "show_or_hide_info": "Info mutatása vagy elrejtése", "show_password": "Jelszó mutatása", "show_person_options": "Személy opciók mutatása", - "show_progress_bar": "", + "show_progress_bar": "Haladás megjelenítése", "show_search_options": "Keresési opciók mutatása", + "show_supporter_badge": "Támogató jelvény", + "show_supporter_badge_description": "Támogató jelvény megjelenítése", "shuffle": "Keverés", "sign_out": "Kilépés", "sign_up": "Feliratkozás", @@ -940,61 +1162,99 @@ "slideshow": "Diavetítés", "slideshow_settings": "Diavetítés beállításai", "sort_albums_by": "Albumok rendezése...", + "sort_created": "Létrehozva", + "sort_items": "Elemek száma", + "sort_modified": "Módosítva", + "sort_oldest": "Legrégebbi fénykép", + "sort_recent": "Legújabb fénykép", + "sort_title": "Cím", + "source": "Forrás", "stack": "Fotók csoportosítása", - "stack_selected_photos": "", - "stacktrace": "", - "start_date": "", + "stack_duplicates": "Duplikátumok csoportosítása", + "stack_select_one_photo": "Fő fénykép kiválasztása", + "stack_selected_photos": "Kiválasztott fényképek csoportosítása", + "stacked_assets_count": "{count, plural, other {# elem}} csoportba helyezve", + "stacktrace": "Stacktrace", + "start": "Kezdet", + "start_date": "Kezdet", "state": "Állam", "status": "Állapot", "stop_motion_photo": "Mozgókép megállítása", "stop_photo_sharing": "Fotók megosztásának megszűntetése?", - "storage": "", - "storage_label": "", + "stop_photo_sharing_description": "{partner} mostantól nem fog tudni hozzáférni az Ön fényképeihez.", + "stop_sharing_photos_with_user": "Fényképek megosztásának abbahagyása ezzel a felhasználóval", + "storage": "Tárhely", + "storage_label": "Tárolási címke", "storage_usage": "{used}/{available} használatban", - "submit": "", + "submit": "Beadás", "suggestions": "Javaslatok", - "sunrise_on_the_beach": "", - "swap_merge_direction": "", + "sunrise_on_the_beach": "Napkelte a tengerparton", + "swap_merge_direction": "Egyesítés irányának megfordítása", "sync": "Szinkronizálás", "template": "Minta", "theme": "Téma", "theme_selection": "Témaválasztás", "theme_selection_description": "A böngésző beállításának megfelelően automatikusan használjon világos vagy sötét témát", - "time_based_memories": "", + "they_will_be_merged_together": "Egyesítve lesznek", + "time_based_memories": "Emlékek idő alapján", "timezone": "Időzóna", "to_archive": "Archívum", + "to_change_password": "Jelszó megváltoztatása", "to_favorite": "Kedvenc", + "to_login": "Bejelentkezés", + "to_trash": "Szemétbe helyezés", "toggle_settings": "Beállítások változtatása", "toggle_theme": "Témaváltás", "toggle_visibility": "Láthatóság változtatása", "total_usage": "Összesen használatban", "trash": "Lomtár", "trash_all": "Mindet lomtárba", + "trash_count": "{count, number} elem szemétbe helyezése", + "trash_delete_asset": "Elem szemétbe helyezése / törlése", "trash_no_results_message": "Itt lesznek láthatóak a lomtárba tett képek és videok.", - "type": "", + "trashed_items_will_be_permanently_deleted_after": "A szemeteskosárban lévő elemek véglegesen törlésre kerülnek {days, plural, other {# nap}} múlva.", + "type": "Típus", "unarchive": "Archívumból kivétel", "unarchived": "Archívumból kivett", + "unarchived_count": "{count, plural, other {# elem kivéve az archívumból}}", "unfavorite": "Nem Kedvenc", "unhide_person": "Nem rejtett személy", "unknown": "Ismeretlen", "unknown_album": "Ismeretlen Album", "unknown_year": "Ismeretlen év", "unlimited": "Korlátlan", - "unlink_oauth": "", - "unlinked_oauth_account": "", + "unlink_oauth": "OAuth leválasztása", + "unlinked_oauth_account": "Leválasztott OAuth felhasználó", "unnamed_album": "Névtelen Album", "unnamed_share": "Névtelen Megosztás", + "unsaved_change": "Mentés nélküli változtatás", "unselect_all": "Összes kiválasztás törlése", + "unselect_all_duplicates": "Duplikátumok kijelölésének megszüntetése", "unstack": "Csoport Megszűntetése", + "unstacked_assets_count": "{count, plural, other {# elemből}} álló csoport szétszedve", + "untracked_files": "Nem megfigyelt fájlok", + "untracked_files_decription": "Ezek a fájlok nincsenek az alkalmazás által megfigyelve. Létrehozódhattak sikertelen mozgatástól, félbeszakított feltöltéstől, vagy hátrahagyva hiba miatt", "up_next": "Következik", "updated_password": "Jelszó megváltoztatva", "upload": "Feltöltés", "upload_concurrency": "", - "url": "", - "usage": "", + "upload_errors": "Feltöltés befejezve {count, plural, other {# hibával}}, frissítse az oldalt az újonnan feltöltött elemek megtekintéséhez.", + "upload_progress": "Hátra van {remaining, number} - Feldolgozva {processed, number}/{total, number}", + "upload_skipped_duplicates": "{count, plural, other {# megegyező elem}} kihagyva", + "upload_status_duplicates": "Duplikátumok", + "upload_status_errors": "Hibák", + "upload_status_uploaded": "Feltöltve", + "upload_success": "Feltöltés sikeres, frissítse az oldalt az újonnan feltöltött elemek megtekintéséhez.", + "url": "URL", + "usage": "Felhasználás", + "use_custom_date_range": "Szabadon megadott időintervallum használata", "user": "Felhasználó", "user_id": "Felhasználó azonosítója", - "user_usage_detail": "", + "user_liked": "{type, select, photo {ez a fénykép} video {ez a videó} other {ez}} tetszik neki: {user}", + "user_purchase_settings": "Megvásárlás", + "user_purchase_settings_description": "Vásárlás kezelése", + "user_role_set": "{user} beállítása {role} szerepbe", + "user_usage_detail": "Felhasználó használati adatai", "username": "Felhasználónév", "users": "Felhasználók", "utilities": "Eszközök", @@ -1006,15 +1266,21 @@ "video_hover_setting_description": "Ha az egér a bélyegkép felett időzik, a bélyegkép videó lejátszása induljon el. A lejátszás az indítás ikon feletti időzéssel akkor is elindul, ha ez az opció ki van kapcsolva.", "videos": "Videók", "videos_count": "{count, plural, one {# Videó} other {# Videó}}", + "view": "Nézet", + "view_album": "Album megtekintése", "view_all": "Összes mutatása", - "view_all_users": "", - "view_links": "", - "view_next_asset": "", - "view_previous_asset": "", + "view_all_users": "Minden felhasználó megtekintése", + "view_links": "Linkek megtekintése", + "view_next_asset": "Következő elem megtekintése", + "view_previous_asset": "Előző elem megtekintése", + "view_stack": "Csoport megtekintése", "viewer": "", - "waiting": "", - "week": "", - "welcome_to_immich": "", + "visibility_changed": "Láthatóság megváltozott {count, plural, other {# személy}} számára", + "waiting": "Várakozás", + "warning": "Figyelmeztetés", + "week": "Hét", + "welcome": "Üdv", + "welcome_to_immich": "Üdvözöljük az Immich-ben", "year": "Év", "years_ago": "{years, plural, one {# évvel} other {# évvel}} ezelőtt", "yes": "Igen", diff --git a/web/src/lib/i18n/id.json b/web/src/lib/i18n/id.json index ac29e27ec3..1a525485ea 100644 --- a/web/src/lib/i18n/id.json +++ b/web/src/lib/i18n/id.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Tambahkan ke album terbagi", "added_to_archive": "Ditambahkan ke arsip", "added_to_favorites": "Ditambahkan ke favorit", - "added_to_favorites_count": "Ditambahkan {count} ke favorit", + "added_to_favorites_count": "Ditambahkan {count, number} ke favorit", "admin": { "add_exclusion_pattern_description": "Tambahkan pola pengecualian. Glob menggunakan *, **, dan ? didukung. Untuk mengabaikan semua berkas dalam direktori apa pun bernama \"Raw\", gunakan \"**/Raw/**\". Untuk mengabaikan semua berkas berakhiran dengan \".tif\", gunakan \"**/*.tif\". Untuk mengabaikan jalur absolut, gunakan \"/jalur/untuk/diabaikan/**\".", "authentication_settings": "Pengaturan Autentikasi", @@ -127,12 +127,13 @@ "map_enable_description": "Aktifkan fitur peta", "map_gps_settings": "Pengaturan Peta & GPS", "map_gps_settings_description": "Kelola Pengaturan Peta & GPS (Pengodean Geografis Terbalik)", + "map_implications": "Fitur peta mengandalkan layanan tile eksternal", "map_light_style": "Gaya terang", "map_manage_reverse_geocoding_settings": "Kelola settingan Pengodean Geografis Terbalik", "map_reverse_geocoding": "Pengodean Geografis Terbalik", "map_reverse_geocoding_enable_description": "Aktifkan pengodean geografis terbalik", "map_reverse_geocoding_settings": "Pengaturan Pengodean Geografis Terbalik", - "map_settings": "Pengaturan Peta", + "map_settings": "Peta", "map_settings_description": "Kelola pengaturan peta", "map_style_description": "URL ke tema peta style.json", "metadata_extraction_job": "Ekstrak metadata", @@ -275,7 +276,7 @@ "transcoding_preferred_hardware_device": "Perangkat keras yang lebih disukai", "transcoding_preferred_hardware_device_description": "Hanya diterapkan pada VAAPI dan QSV. Menetapkan node dri yang digunakan untuk transkode perangkat keras.", "transcoding_preset_preset": "Prasetel (-preset)", - "transcoding_preset_preset_description": "Kecepatan pengompresan. Prasetel lebih lambat membuat berkas lebih kecil dan meningkatkan kualitas ketika menargetkan kecepatan bit tertentu. VP9 mengabaikan kecepatan di atas `faster`.", + "transcoding_preset_preset_description": "Kecepatan kompresi. Pra setel yang lebih lambat membuat berkas lebih kecil dan meningkatkan kualitas ketika menargetkan kecepatan bit tertentu. VP9 mengabaikan kecepatan di atas `faster`.", "transcoding_reference_frames": "Bingkai referensi", "transcoding_reference_frames_description": "Jumlah bingkai untuk direferensikan ketika mengompres bingkai tertentu. Nilai lebih tinggi meningkatkan efisiensi kompresi, tetapi membuat pengodean lambat. 0 menetapkan nilai ini secara otomatis.", "transcoding_required_description": "Hanya video dalam format yang tidak diterima", @@ -317,7 +318,8 @@ "user_settings": "Pengaturan Pengguna", "user_settings_description": "Kelola pengaturan pengguna", "user_successfully_removed": "Pengguna {email} berhasil dikeluarkan.", - "version_check_enabled_description": "Aktifkan permintaan berkala ke GitHub untuk memeriksa rilis baru", + "version_check_enabled_description": "Aktifkan pemeriksaan versi", + "version_check_implications": "Fitur pemeriksaan versi tergantung komunikasi berkala dengan github.com", "version_check_settings": "Pemeriksaan Versi", "version_check_settings_description": "Aktifkan/nonaktifkan notifikasi versi baru", "video_conversion_job": "Transkode video", @@ -333,7 +335,8 @@ "album_added": "Album ditambahkan", "album_added_notification_setting_description": "Terima notifikasi surel ketika Anda ditambahkan ke album terbagi", "album_cover_updated": "Kover album diperbarui", - "album_delete_confirmation": "Apakah Anda yakin ingin menghapus album {album}?\nJika album ini dibagikan, pengguna lain tidak akan dapat mengaksesnya lagi.", + "album_delete_confirmation": "Apakah Anda yakin ingin menghapus album {album}?", + "album_delete_confirmation_description": "Jika album ini dibagikan, pengguna lain tidak akan dapat mengaksesnya lagi.", "album_info_updated": "Info album diperbarui", "album_leave": "Tinggalkan album?", "album_leave_confirmation": "Apakah Anda yakin ingin keluar dari {album}?", @@ -357,6 +360,7 @@ "allow_edits": "Perbolehkan penyuntingan", "allow_public_user_to_download": "Perbolehkan pengguna publik untuk mengunduh", "allow_public_user_to_upload": "Perbolehkan pengguna publik untuk mengunggah", + "anti_clockwise": "Berlawanan arah jarum jam", "api_key": "Kunci API", "api_key_description": "Nilai ini hanya akan ditampilkan sekali. Pastikan untuk menyalin sebelum menutup jendela ini.", "api_key_empty": "Nama Kunci API Anda seharusnya jangan kosong", @@ -365,7 +369,7 @@ "appears_in": "Muncul dalam", "archive": "Arsip", "archive_or_unarchive_photo": "Arsipkan atau batalkan pengarsipan foto", - "archive_size": "Ukuran Arsip", + "archive_size": "Ukuran arsip", "archive_size_description": "Atur ukuran arsip untuk unduhan (dalam GiB)", "archived": "", "archived_count": "{count, plural, other {# terarsip}}", @@ -407,7 +411,7 @@ "bulk_delete_duplicates_confirmation": "Apakah Anda yakin ingin menghapus {count, plural, one {# aset duplikat} other {# aset duplikat}} secara bersamaan? Ini akan menjaga aset terbesar dari setiap kelompok dan menghapus semua duplikat lain secara permanen. Anda tidak dapat mengurungkan tindakan ini!", "bulk_keep_duplicates_confirmation": "Apakah Anda yakin ingin menyimpan {count, plural, one {# aset duplikat} other {# aset duplikat}}? Ini akan menyelesaikan semua kelompok duplikat tanpa menghapus apa pun.", "bulk_trash_duplicates_confirmation": "Apakah Anda yakin ingin membuang {count, plural, one {# aset duplikat} other {# aset duplikat}} secara bersamaan? Ini akan menyimpan aset terbesar dari setiap kelompok dan membuang semua duplikat lainnya.", - "buy": "Beli Lisensi", + "buy": "Beli Immich", "camera": "Kamera", "camera_brand": "Merek kamera", "camera_model": "Model kamera", @@ -435,8 +439,10 @@ "city": "Kota", "clear": "Hapus", "clear_all": "Hapus semua", + "clear_all_recent_searches": "Hapus semua pencarian terakhir", "clear_message": "Hapus pesan", "clear_value": "Hapus nilai", + "clockwise": "Searah jarum jam", "close": "Tutup", "collapse": "Tutup", "collapse_all": "Tutup Semua", @@ -513,6 +519,8 @@ "do_not_show_again": "Jangan tampilkan pesan ini lagi", "done": "Selesai", "download": "Unduh", + "download_include_embedded_motion_videos": "Video tersematkan", + "download_include_embedded_motion_videos_description": "Sertakan video yg tersematkan dalam foto gerak sebagai file terpisah", "download_settings": "Pengunduhan", "download_settings_description": "Kelola pengaturan berkaitan dengan pengunduhan aset", "downloading": "Mengunduh", @@ -538,7 +546,11 @@ "edit_title": "Sunting Judul", "edit_user": "Sunting pengguna", "edited": "Disunting", - "editor": "", + "editor": "Editor", + "editor_close_without_save_prompt": "Perubahan tidak akan di simpan", + "editor_close_without_save_title": "Tutup editor?", + "editor_crop_tool_h2_aspect_ratios": "Perbandingan aspek", + "editor_crop_tool_h2_rotation": "Rotasi", "email": "Surel", "empty_trash": "Kosongkan sampah", "empty_trash_confirmation": "Apakah Anda yakin ingin mengosongkan sampah? Ini akan menghapus semua aset dalam sampah secara permanen dari Immich.\nAnda tidak dapat mengurungkan tindakan ini!", @@ -564,6 +576,7 @@ "error_adding_users_to_album": "Terjadi kesalahan menambahkan pengguna ke album", "error_deleting_shared_user": "Terjadi eror menghapus pengguna terbagi", "error_downloading": "Terjadi eror mengunduh {filename}", + "error_hiding_buy_button": "Kesalahan menyembunyikan tombol beli", "error_removing_assets_from_album": "Terjadi eror menghapus aset dari album, lihat konsol untuk detail lebih lanjut", "error_selecting_all_assets": "Terjadi eror memilih semua aset", "exclusion_pattern_already_exists": "Pola pengecualian ini sudah ada.", @@ -574,6 +587,8 @@ "failed_to_get_people": "Gagal mendapatkan orang", "failed_to_load_asset": "Gagal membuka aset", "failed_to_load_assets": "Gagal membuka aset-aset", + "failed_to_load_people": "Gagal mengunggah orang", + "failed_to_remove_product_key": "Gagal menghapus kunci produk", "failed_to_stack_assets": "Gagal menumpuk aset", "failed_to_unstack_assets": "Gagal membatalkan penumpukan aset", "import_path_already_exists": "Jalur pengimporan ini sudah ada.", @@ -675,6 +690,7 @@ "expired": "Kedaluwarsa", "expires_date": "Kedaluwarsa pada {date}", "explore": "Jelajahi", + "explorer": "Jelajah", "export": "Ekspor", "export_as_json": "Ekspor sebagai JSON", "extension": "Ekstensi", @@ -693,6 +709,7 @@ "filter_people": "Saring orang", "find_them_fast": "Temukan dengan cepat berdasarkan nama dengan pencarian", "fix_incorrect_match": "Perbaiki pencocokan salah", + "folders": "Berkas", "force_re-scan_library_files": "Paksa Pindai Ulang Semua Berkas Pustaka", "forward": "Maju", "general": "Umum", @@ -716,7 +733,16 @@ "host": "Hos", "hour": "Jam", "image": "Gambar", - "image_alt_text_date": "pada {date}", + "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} pada tanggal {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} diambil oleh {person1} pada {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} diambil oleh {person1} dan {person2} pada {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} diambil oleh {person1}, {person2}, dan {person3} pada {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} diambil oleh {person1}, {person2}, dan {additionalCount, number} lainnya pada {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} pada {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} oleh {person1} pada {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} oleh {person1} dan {person2} pada {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} oleh {person1}, {person2}, dan {person3} pada {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} oleh {person1}, {person2}, dan {additionalCount, number} lainnya pada {date}", "image_alt_text_people": "{count, plural, =1 {dengan {person1}} =2 {dengan {person1} dan {person2}} =3 {dengan {person1}, {person2}, dan {person3}} other {dengan {person1}, {person2}, dan {others, number} lainnya}}", "image_alt_text_place": "di {city}, {country}", "image_taken": "{isVideo, select, true {Video diambil} other {Gambar diambil}}", @@ -835,6 +861,7 @@ "name": "Nama", "name_or_nickname": "Nama atau nama panggilan", "never": "Tidak pernah", + "new_album": "Album baru", "new_api_key": "Kunci API Baru", "new_password": "Kata sandi baru", "new_person": "Orang baru", @@ -873,12 +900,14 @@ "ok": "Oke", "oldest_first": "Terlawas dahulu", "onboarding": "Memulai", + "onboarding_privacy_description": "Fitur berikut (opsional) bergantung pada layanan eksternal, dan dapat dinonaktifkan kapan saja di pengaturan administrasi.", "onboarding_theme_description": "Pilih tema warna untuk server Anda. Ini dapat diubah lagi dalam pengaturan Anda.", "onboarding_welcome_description": "Mari menyiapkan server Anda dengan beberapa pengaturan umum.", "onboarding_welcome_user": "Selamat datang, {user}", "online": "Daring", "only_favorites": "Hanya favorit", "only_refreshes_modified_files": "Hanya menyegarkan berkas yang diubah", + "open_in_map_view": "Buka dalam tampilan peta", "open_in_openstreetmap": "Buka di OpenStreetMap", "open_the_search_filters": "Buka saringan pencarian", "options": "Opsi", @@ -890,7 +919,7 @@ "other_variables": "Variabel lain", "owned": "Dimiliki", "owner": "Pemilik", - "partner": "Partner", + "partner": "Rekan", "partner_can_access": "{partner} dapat mengakses", "partner_can_access_assets": "Semua foto dan video Anda kecuali yang ada di Arsip dan Terhapus", "partner_can_access_location": "Lokasi di mana foto Anda diambil", @@ -943,10 +972,47 @@ "previous_memory": "Kenangan sebelumnya", "previous_or_next_photo": "Foto sebelumnya atau berikutnya", "primary": "Utama", + "privacy": "Privasi", "profile_image_of_user": "Foto profil dari {user}", "profile_picture_set": "Foto profil ditetapkan.", "public_album": "Album publik", "public_share": "Pembagian Publik", + "purchase_account_info": "Pendukung", + "purchase_activated_subtitle": "Terima kasih telah mendukung Immich dan perangkat lunak sumber terbuka", + "purchase_activated_time": "Di aktivasi pada {date, date}", + "purchase_activated_title": "Kunci kamu telah sukses di aktivasi", + "purchase_button_activate": "Aktifkan", + "purchase_button_buy": "Beli", + "purchase_button_buy_immich": "Beli Immich", + "purchase_button_never_show_again": "Jangan tampilkan lagi", + "purchase_button_reminder": "Ingatkan saya pada 30 hari lagi", + "purchase_button_remove_key": "Hapus kunci", + "purchase_button_select": "Pilih", + "purchase_failed_activation": "Gagal mengaktifkan! Silakan periksa email kamu untuk kunci produk yang benar!", + "purchase_individual_description_1": "Untuk perorangan", + "purchase_individual_description_2": "Status pendukung", + "purchase_individual_title": "Perorangan", + "purchase_input_suggestion": "Punya kunci produk? Masukkan kunci di bawah ini", + "purchase_license_subtitle": "Beli Immich untuk keberlangsungan pengembangan layanan", + "purchase_lifetime_description": "Pembayaran seumur hidup", + "purchase_option_title": "PILIHAN PEMBAYARAN", + "purchase_panel_info_1": "Membangun Immich membutuhkan banyak waktu dan upaya, dan kami memiliki insinyur penuh waktu yang bekerja untuk membuatnya sebaik mungkin. Misi kami adalah agar perangkat lunak sumber terbuka dan praktik bisnis yang beretika menjadi sumber pendapatan yang berkelanjutan bagi para pengembang dan menciptakan ekosistem yang menghargai privasi dengan alternatif nyata untuk layanan cloud yang eksploitatif.", + "purchase_panel_info_2": "Karena kami berkomitmen untuk tidak menambahkan paywall, pembelian ini tidak akan memberi kamu fitur tambahan apa pun di Immich. Kami mengandalkan pengguna seperti kamu untuk mendukung pengembangan Immich yang sedang berlangsung.", + "purchase_panel_title": "Dukung proyek ini", + "purchase_per_server": "Per server", + "purchase_per_user": "Per pengguna", + "purchase_remove_product_key": "Hapus Kunci Produk", + "purchase_remove_product_key_prompt": "Apakah kamu yakin ingin menghapus kunci produk?", + "purchase_remove_server_product_key": "Hapus kunci produk Server", + "purchase_remove_server_product_key_prompt": "Apakah kamu yakin ingin menghapus kunci produk Server?", + "purchase_server_description_1": "Untuk keseluruhan server", + "purchase_server_description_2": "Status pendukung", + "purchase_server_title": "Server", + "purchase_settings_server_activated": "Kunci produk server dikelola oleh admin", + "rating": "Peringkat bintang", + "rating_clear": "Hapus peringkat", + "rating_count": "{count, plural, one {# peringkat} other {# peringkat}}", + "rating_description": "Tampilkan peringkat exif pada panel info", "reaction_options": "Opsi reaksi", "read_changelog": "Baca Log Perubahan", "reassign": "Tetapkan ulang", @@ -989,6 +1055,7 @@ "reset_password": "Atur ulang kata sandi", "reset_people_visibility": "Atur ulang keterlihatan orang", "reset_to_default": "Atur ulang ke bawaan", + "resolve_duplicates": "Mengatasi duplikat", "resolved_all_duplicates": "Semua duplikat terselesaikan", "restore": "Pulihkan", "restore_all": "Pulihkan semua", @@ -1033,6 +1100,7 @@ "see_all_people": "Lihat semua orang", "select_album_cover": "Pilih kover album", "select_all": "Pilih semua", + "select_all_duplicates": "Pilih semua duplikat", "select_avatar_color": "Pilih warna avatar", "select_face": "Pilih wajah", "select_featured_photo": "Pilih foto terfitur", @@ -1065,6 +1133,7 @@ "shared_by_user": "Dibagikan oleh {user}", "shared_by_you": "Dibagikan oleh Anda", "shared_from_partner": "Foto dari {partner}", + "shared_link_options": "Pilihan tautan bersama", "shared_links": "Tautan terbagi", "shared_photos_and_videos_count": "{assetCount, plural, other {# foto & video terbagi.}}", "shared_with_partner": "Dibagikan dengan {partner}", @@ -1073,6 +1142,7 @@ "sharing_sidebar_description": "Tampilkan tautan ke Pembagian dalam bilah samping", "shift_to_permanent_delete": "tekan ⇧ untuk menghapus aset secara permanen", "show_album_options": "Tampilkan opsi album", + "show_albums": "Tampilkan album", "show_all_people": "Tampilkan semua orang", "show_and_hide_people": "Tampilkan & sembunyikan orang", "show_file_location": "Tampilkan lokasi berkas", @@ -1087,6 +1157,8 @@ "show_person_options": "Tampilkan opsi orang", "show_progress_bar": "Tampilkan Bilah Progres", "show_search_options": "Tampilkan opsi pencarian", + "show_supporter_badge": "Lencana suporter", + "show_supporter_badge_description": "Tampilkan lencana suporter", "shuffle": "Acak", "sign_out": "Keluar", "sign_up": "Daftar", @@ -1103,6 +1175,8 @@ "sort_title": "Judul", "source": "Sumber", "stack": "Tumpukan", + "stack_duplicates": "Stack duplikat", + "stack_select_one_photo": "Pilih satu foto utama untuk stack", "stack_selected_photos": "Tumpuk foto terpilih", "stacked_assets_count": "{count, plural, one {# aset} other {# aset}} ditumpuk", "stacktrace": "Jejak tumpukan", @@ -1135,12 +1209,12 @@ "to_login": "Log masuk", "to_trash": "Sampah", "toggle_settings": "Saklar pengaturan", - "toggle_theme": "Saklar tema", + "toggle_theme": "Beralih tema gelap", "toggle_visibility": "Saklar keterlihatan", "total_usage": "Jumlah penggunaan", "trash": "Sampah", "trash_all": "Buang Semua", - "trash_count": "Buang {count}", + "trash_count": "Sampah {count, number}", "trash_delete_asset": "Hapus Aset", "trash_no_results_message": "Foto dan video di sampah akan muncul di sini.", "trashed_items_will_be_permanently_deleted_after": "Item yang dibuang akan dihapus secara permanen setelah {days, plural, one {# hari} other {# hari}}.", @@ -1156,9 +1230,11 @@ "unlink_oauth": "Putuskan OAuth", "unlinked_oauth_account": "Akun OAuth terputus", "unnamed_album": "Album Tanpa Nama", + "unnamed_album_delete_confirmation": "Apakah kamu yakin akan menghapus album ini?", "unnamed_share": "Pembagian Tanpa Nama", "unsaved_change": "Perubahan belum disimpan", "unselect_all": "Batalkan semua pilihan", + "unselect_all_duplicates": "Batal pilih semua duplikat", "unstack": "Batalkan penumpukan", "unstacked_assets_count": "Penumpukan {count, plural, one {# aset} other {# aset}} dibatalkan", "untracked_files": "Berkas tidak dilacak", @@ -1168,7 +1244,7 @@ "upload": "Unggah", "upload_concurrency": "Konkurensi pengunggahan", "upload_errors": "Unggahan selesai dengan {count, plural, one {# eror} other {# eror}}, muat ulang laman untuk melihat aset terunggah baru.", - "upload_progress": "Tersisa {remaining} - Memproses {processed}/{total}", + "upload_progress": "Tersisa {remaining, number} - Di proses {processed, number}/{total, number}", "upload_skipped_duplicates": "Melewati {count, plural, one {# aset duplikat} other {# aset duplikat}}", "upload_status_duplicates": "Duplikat", "upload_status_errors": "Eror", @@ -1181,7 +1257,9 @@ "user_id": "ID Pengguna", "user_license_settings": "Lisensi", "user_license_settings_description": "Kelola lisensi Anda", - "user_liked": "{user} menyukai {type, select, photo {foto ini} video {video ini} asset {aset ini} other {ini}}", + "user_liked": "{user} menyukai {type, select, photo {foto ini} video {tayangan ini} asset {aset ini} other {ini}}", + "user_purchase_settings": "Pembelian", + "user_purchase_settings_description": "Atur pembelian kamu", "user_role_set": "Tetapkan {user} sebagai {role}", "user_usage_detail": "Detail penggunaan pengguna", "username": "Nama pengguna", diff --git a/web/src/lib/i18n/it.json b/web/src/lib/i18n/it.json index 86c0079e96..486f2dfaa6 100644 --- a/web/src/lib/i18n/it.json +++ b/web/src/lib/i18n/it.json @@ -49,7 +49,7 @@ "external_library_created_at": "Libreria esterna (creata il {date})", "external_library_management": "Gestione Librerie Esterne", "face_detection": "Rilevamento Volti", - "face_detection_description": "Rileva i volti presenti negli assets utilizzando il machine learning. Per i video, viene presa in considerazione solo la miniatura. \"Tutto\" (ri-)processerà tutti gli assets. \"Mancanti\" selaziona solo gli assets che non sono ancora stati processati. I volti rilevati verranno selezionati per il riconoscimento facciale dopo che il rilevamento dei volti sarà stato completato, raggruppandoli in persone esistenti e/o nuove.", + "face_detection_description": "Rileva i volti presenti negli assets utilizzando il machine learning. Per i video, viene presa in considerazione solo la miniatura. \"Tutto\" (ri-)processerà tutti gli assets. \"Mancanti\" seleziona solo gli assets che non sono ancora stati processati. I volti rilevati verranno selezionati per il riconoscimento facciale dopo che il rilevamento dei volti sarà stato completato, raggruppandoli in persone esistenti e/o nuove.", "facial_recognition_job_description": "Raggruppa i volti rilevati in persone. Questo processo viene eseguito dopo che il rilevamento volti è stato completato. \"Tutti\" (ri-)unisce tutti i volti. \"Mancanti\" processa i volti che non hanno una persona assegnata.", "failed_job_command": "Il comando {command} è fallito per il processo: {job}", "force_delete_user_warning": "ATTENZIONE: Questo rimuoverà immediatamente l'utente e tutti i suoi assets. Non è possibile tornare indietro e i file non potranno essere recuperati.", @@ -68,7 +68,7 @@ "image_settings_description": "Gestisci qualità e risoluzione delle immagini generate", "image_thumbnail_format": "Formato miniatura", "image_thumbnail_resolution": "Risoluzione miniatura", - "image_thumbnail_resolution_description": "Utilizzato per vedere gruppi di foto (linea temporale,vista album, etc.). Risoluzioni piu' alte possono mantenere piu' dettaglio pero' l'encoding sara' piu' lungo, i file avranno dimensioni maggiori e potrebbero causare una riduzione nella responsivita' dell'applicazione.", + "image_thumbnail_resolution_description": "Utilizzato per vedere gruppi di foto (linea temporale, vista album, etc.). Risoluzioni più alte possono mantenere più dettaglio però l'encoding sarà più lungo, i file avranno dimensioni maggiori e potrebbero causare una riduzione nella responsività dell'applicazione.", "job_concurrency": "Concorrenza {job}", "job_not_concurrency_safe": "Questo processo non è eseguibile in maniera concorrente.", "job_settings": "Impostazioni dei processi", @@ -76,14 +76,14 @@ "job_status": "Stato Processi", "jobs_delayed": "{jobCount, plural, one {# posticipato} other {# posticipati}}", "jobs_failed": "{jobCount, plural, one {# fallito} other {# falliti}}", - "library_created": "Creata libreria {library}", + "library_created": "Creata libreria: {library}", "library_cron_expression": "Espressione cron", "library_cron_expression_description": "Imposta l'intervallo di rilevazione utilizzando il formato cron. Per più informazioni consulta es. Crontab Guru", "library_cron_expression_presets": "Espressioni cron preimpostate", "library_deleted": "Libreria eliminata", "library_import_path_description": "Specifica una cartella da importare. Questa cartella e le sue sottocartelle, verranno analizzate per cercare immagini e video.", "library_scanning": "Scansione periodica", - "library_scanning_description": "Conigura la scansione periodica della libreria", + "library_scanning_description": "Configura la scansione periodica della libreria", "library_scanning_enable_description": "Attiva la scansione periodica della libreria", "library_settings": "Libreria Esterna", "library_settings_description": "Gestisci le impostazioni della libreria esterna", @@ -95,7 +95,7 @@ "logging_level_description": "Quando attivato, che livello di log utilizzare.", "logging_settings": "Registro dei Log", "machine_learning_clip_model": "Modello CLIP", - "machine_learning_clip_model_description": "Il nome del modello CLIP mostrato qui. Bita cge devi rieseguire il processo 'Ricerca Intelligente' per tutte le immagini al cambio del modello.", + "machine_learning_clip_model_description": "Il nome del modello CLIP mostrato qui. Nota che devi rieseguire il processo 'Ricerca Intelligente' per tutte le immagini al cambio del modello.", "machine_learning_duplicate_detection": "Rilevamento Duplicati", "machine_learning_duplicate_detection_enabled": "Attiva rilevazione duplicati", "machine_learning_duplicate_detection_enabled_description": "Se disattivo, risorse perfettamente identiche saranno comunque deduplicate.", @@ -103,16 +103,16 @@ "machine_learning_enabled": "Attiva machine learning", "machine_learning_enabled_description": "Se disabilitato, tutte le funzioni di ML saranno disabilitate ignorando le importazioni sottostanti.", "machine_learning_facial_recognition": "Riconoscimento Facciale", - "machine_learning_facial_recognition_description": "Rileva, riconosci, e raggruppa faccie nelle immagini", + "machine_learning_facial_recognition_description": "Rileva, riconosci, e raggruppa facce nelle immagini", "machine_learning_facial_recognition_model": "Modello di riconoscimento facciale", - "machine_learning_facial_recognition_model_description": "I modelli sono mostrati in ordine decrescente in base alla dimensione. I modelli più grandi sono più lenti e utilizzano più memoria, peró producono risultati migliori. Nota che devi ri-eseguire il processo di rilevamento facciale per tutte le immagini quando cambi il modello.", + "machine_learning_facial_recognition_model_description": "I modelli sono mostrati in ordine decrescente in base alla dimensione. I modelli più grandi sono più lenti e utilizzano più memoria, però producono risultati migliori. Nota che devi ri-eseguire il processo di rilevamento facciale per tutte le immagini quando cambi il modello.", "machine_learning_facial_recognition_setting": "Attiva riconoscimento facciale", - "machine_learning_facial_recognition_setting_description": "Se disabilitato, le immagininon non saranno codificate per il riconoscimento facciale e non verranno mostrate nella sezione Persone della pagina Esplora.", + "machine_learning_facial_recognition_setting_description": "Se disabilitato, le immagini non saranno codificate per il riconoscimento facciale e non verranno mostrate nella sezione Persone della pagina Esplora.", "machine_learning_max_detection_distance": "Distanza massima di rilevazione", - "machine_learning_max_detection_distance_description": "Massima distanza fra due immagini per considerarle duplicate, variando da 0.001-0.1. Valori più alti rileveranno più duplicati, ma potrebbero causare risultati fasulli.", + "machine_learning_max_detection_distance_description": "Massima distanza fra due immagini per considerarle duplicate, variando da 0.001-0.1. Valori più alti rileveranno più duplicati, ma potrebbero causare falsi positivi.", "machine_learning_max_recognition_distance": "Distanza massima di riconoscimento", "machine_learning_max_recognition_distance_description": "La distanza massima tra due volti per essere considerati la stessa persona, che varia da 0 a 2. Abbassare questo valore può prevenire l'etichettatura di due persone come se fossero la stessa persona, mentre aumentarlo può prevenire l'etichettatura della stessa persona come se fossero due persone diverse. Nota che è più facile unire due persone che separare una persona in due, quindi è preferibile mantenere una soglia più bassa quando possibile.", - "machine_learning_min_detection_score": "Punteggio minimo di rilvazione", + "machine_learning_min_detection_score": "Punteggio minimo di rilevazione", "machine_learning_min_detection_score_description": "Punteggio di confidenza minimo per rilevare un volto, da 0 a 1. Valori più bassi rileveranno più volti, ma potrebbero generare risultati fasulli.", "machine_learning_min_recognized_faces": "Minimo volti rilevati", "machine_learning_min_recognized_faces_description": "Il numero minimo di volti riconosciuti per creare una persona. Aumentando questo valore si rende il riconoscimento facciale più preciso, ma aumenta la possibilità che un volto non venga assegnato a una persona.", @@ -129,7 +129,7 @@ "map_enable_description": "Abilita funzionalità della mappa", "map_gps_settings": "Impostazioni Mappe & GPS", "map_gps_settings_description": "Gestisci le impostazioni di Mappe & GPS (Geocoding Inverso)", - "map_implications": "La fnzione della mappa fa uso di un servizio tile esterno (tiles.immich.cloud)", + "map_implications": "La funzionalità mappa si basa su un servizio tile esterno (tiles.immich.cloud)", "map_light_style": "Tema chiaro", "map_manage_reverse_geocoding_settings": "Gestisci impostazioni Geocodifica inversa", "map_reverse_geocoding": "Geocodifica inversa", @@ -225,7 +225,7 @@ "storage_template_hash_verification_enabled_description": "Attiva verifica hash, non disabilitare questo se non sei certo delle implicazioni", "storage_template_migration": "Migrazione modello archiviazione", "storage_template_migration_description": "Applica il {template} attuale agli asset caricati in precedenza", - "storage_template_migration_info": "Le modifiche al modello di archiviazione verranno applicate solo agli asset nuovi. Per applicare le modifice retroattivamente esegui {job}.", + "storage_template_migration_info": "Le modifiche al modello di archiviazione verranno applicate solo agli asset nuovi. Per applicare le modifiche retroattivamente esegui {job}.", "storage_template_migration_job": "Processo Migrazione Modello di Archiviazione", "storage_template_more_details": "Per più informazioni riguardo a questa funzionalità, consulta il Modello Archiviazione e le sue conseguenze", "storage_template_onboarding_description": "Quando attivata, questa funzionalità organizzerà automaticamente i file utilizzando il modello di archiviazione definito dall'utente. Per ragioni di stabilità, questa funzionalità è disabilitata per impostazione predefinita. Per più informazioni, consulta la documentazione.", @@ -260,7 +260,7 @@ "transcoding_bitrate_description": "Video con bitrate superiore al massimo o in formato non accettato", "transcoding_codecs_learn_more": "Per saperne di più sulla terminologia utilizzata, fai riferimento alla documentazione di FFmpeg su codec H.264, codec HEVC e codec VP9.", "transcoding_constant_quality_mode": "Modalità qualità costante", - "transcoding_constant_quality_mode_description": "iCQ è migliore di CQP, peró alcuni dispositivi di accelerazione hardware non supportano questa modalità. Impostando questa opzione l'applicazione preferirà il modo specificato quando è in uso la codifica quality-based. Ignorato da NVENC perchè non supporta ICQ.", + "transcoding_constant_quality_mode_description": "iCQ è migliore di CQP, però alcuni dispositivi di accelerazione hardware non supportano questa modalità. Impostando questa opzione l'applicazione preferirà il modo specificato quando è in uso la codifica quality-based. Ignorato da NVENC perché non supporta ICQ.", "transcoding_constant_rate_factor": "Fattore di rateo costante (-crf)", "transcoding_constant_rate_factor_description": "Livello di qualità video. I valori tipici sono 23 per H.264, 28 per HEVC, 31 per VP9 e 35 per AV1. Un valore inferiore indica una qualità migliore, ma produce file di dimensioni maggiori.", "transcoding_disabled_description": "Non transcodificare alcun video, potrebbe rompere la riproduzione su alcuni client", @@ -274,12 +274,12 @@ "transcoding_max_bitrate": "Bitrate massimo", "transcoding_max_bitrate_description": "Impostare un bitrate massimo può rendere le dimensioni dei file più prevedibili a un costo minore per la qualità. A 720p, i valori tipici sono 2600k per VP9 o HEVC, o 4500k per H.264. Disabilitato se impostato su 0.", "transcoding_max_keyframe_interval": "Intervallo massimo dei keyframe", - "transcoding_max_keyframe_interval_description": "Imposta la distanza massima tra i keyframe. Valori più bassi peggiorano l'efficienza di compressione, peró migliorano i tempi di ricerca e possono migliorare la qualità nelle scene con movimenti rapidi. 0 imposta questo valore automaticamente.", + "transcoding_max_keyframe_interval_description": "Imposta la distanza massima tra i keyframe. Valori più bassi peggiorano l'efficienza di compressione, però migliorano i tempi di ricerca e possono migliorare la qualità nelle scene con movimenti rapidi. 0 imposta questo valore automaticamente.", "transcoding_optimal_description": "Video con risoluzione più alta rispetto alla risoluzione desiderata o in formato non accettato", "transcoding_preferred_hardware_device": "Dispositivo hardware preferito", "transcoding_preferred_hardware_device_description": "Si applica solo a VAAPI e QSV. Imposta il nodo DRI utilizzato per la transcodifica hardware.", "transcoding_preset_preset": "Preset (-preset)", - "transcoding_preset_preset_description": "Velocità di compressione. Presets più lenti producono file più piccoli e aumentano la qualità quando si punta a ottenere un certo bitrate. VP9 ignora velocità superiori a `faster`.", + "transcoding_preset_preset_description": "Velocità di compressione. Preset più lenti producono file più piccoli e aumentano la qualità quando viene impostato un certo bitrate. VP9 ignora velocità superiori a `faster`.", "transcoding_reference_frames": "Frame di riferimento", "transcoding_reference_frames_description": "Il numero di frame da prendere in considerazione nel comprimere un determinato frame. Valori più alti migliorano l'efficienza di compressione, ma rallentano la codifica. 0 imposta questo valore automaticamente.", "transcoding_required_description": "Solo video che non sono in un formato accettato", @@ -288,7 +288,7 @@ "transcoding_target_resolution": "Risoluzione desiderata", "transcoding_target_resolution_description": "Risoluzioni più elevate possono preservare più dettagli ma richiedono più tempo per la codifica, producono file di dimensioni maggiori e possono ridurre la reattività dell'applicazione.", "transcoding_temporal_aq": "AQ temporale", - "transcoding_temporal_aq_description": "Si applica solo a NVENC. Aumenta la qualita delle scene con molto dettaglio e poco movimento. Potrebbe non essere compatibile con dispositivi più vecchi.", + "transcoding_temporal_aq_description": "Si applica solo a NVENC. Aumenta la qualità delle scene con molto dettaglio e poco movimento. Potrebbe non essere compatibile con dispositivi più vecchi.", "transcoding_threads": "Thread", "transcoding_threads_description": "Valori più alti portano a una codifica più veloce, ma lasciano meno spazio al server per elaborare altre attività durante l'attività. Questo valore non dovrebbe essere superiore al numero di core CPU. Massimizza l'utilizzo se impostato su 0.", "transcoding_tone_mapping": "Mappatura della tonalità", @@ -310,14 +310,14 @@ "untracked_files_description": "Questi file non sono tracciati dall'applicazione. Potrebbero essere il risultato di spostamenti falliti, caricamenti interrotti o abbandonati a causa di un bug", "user_delete_delay": "L'account e gli asset dell'utente {user} verranno programmati per la cancellazione definitiva tra {delay, plural, one {# giorno} other {# giorni}}.", "user_delete_delay_settings": "Ritardo eliminazione", - "user_delete_delay_settings_description": "Numero di giorni dopo l'eliminazione per cancellare in modo definitivo l'account e gli asset di un utente. Il processo di cancellazione dell'utente viene eseguito a mezzanotte per verificare se esistono utenti pronti a essere eliminati. Le modifiche a questa impostazioni saranno prese in considerazione dalla possima esecuzione.", + "user_delete_delay_settings_description": "Numero di giorni dopo l'eliminazione per cancellare in modo definitivo l'account e gli asset di un utente. Il processo di cancellazione dell'utente viene eseguito a mezzanotte per verificare se esistono utenti pronti a essere eliminati. Le modifiche a questa impostazioni saranno prese in considerazione dalla prossima esecuzione.", "user_delete_immediately": "L'account e tutti gli asset dell'utente {user} verranno messi in coda per la cancellazione permanente immediata.", "user_delete_immediately_checkbox": "utente", "user_management": "Gestione Utenti", "user_password_has_been_reset": "La password dell'utente è stata reimpostata:", "user_password_reset_description": "Per favore inserisci una password temporanea per l'utente e informalo che dovrà cambiare la password al prossimo login.", "user_restore_description": "L'account di {user} verrà ripristinato.", - "user_restore_scheduled_removal": "Ripristina utente - rimozione progammata per il {date, date, long}", + "user_restore_scheduled_removal": "Ripristina utente - rimozione programmata per il {date, date, long}", "user_settings": "Impostazione Utente", "user_settings_description": "Gestisci impostazioni utente", "user_successfully_removed": "L'utente {email} è stato rimosso con successo.", @@ -362,6 +362,7 @@ "allow_edits": "Permetti modifiche", "allow_public_user_to_download": "Permetti di scaricare agli utenti pubblici", "allow_public_user_to_upload": "Permetti di caricare agli utenti pubblici", + "anti_clockwise": "Senso antiorario", "api_key": "Chiave API", "api_key_description": "Il campo verrà mostrato solo una volta. Abbi cura di copiarlo prima di chiudere la finestra.", "api_key_empty": "Il valore del nome dell'API Key non può essere vuoto", @@ -370,7 +371,7 @@ "appears_in": "Compare in", "archive": "Archivio", "archive_or_unarchive_photo": "Archivia o ripristina foto", - "archive_size": "Dimensioni Archivio", + "archive_size": "Dimensioni archivio", "archive_size_description": "Imposta le dimensioni dell'archivio per i download (in GiB)", "archived": "Archiviato", "archived_count": "{count, plural, other {Archiviati #}}", @@ -443,6 +444,7 @@ "clear_all_recent_searches": "Rimuovi tutte le ricerche recenti", "clear_message": "Pulisci messaggio", "clear_value": "Pulisci valore", + "clockwise": "Senso orario", "close": "Chiudi", "collapse": "Restringi", "collapse_all": "Comprimi tutto", @@ -455,7 +457,7 @@ "confirm_admin_password": "Conferma password amministratore", "confirm_delete_shared_link": "Sei sicuro di voler eliminare questo link condiviso?", "confirm_password": "Conferma password", - "contain": "Contieni", + "contain": "Adatta", "context": "Contesto", "continue": "Continua", "copied_image_to_clipboard": "Immagine copiata negli appunti.", @@ -468,7 +470,7 @@ "copy_password": "Copia password", "copy_to_clipboard": "Copia negli appunti", "country": "Nazione", - "cover": "Copri", + "cover": "Riempi", "covers": "Miniature", "create": "Crea", "create_album": "Crea album", @@ -519,8 +521,10 @@ "do_not_show_again": "Non mostrare questo messaggio di nuovo", "done": "Fatto", "download": "Scarica", + "download_include_embedded_motion_videos": "Video incorporati", + "download_include_embedded_motion_videos_description": "Includere i video incorporati nelle foto in movimento come file separato", "download_settings": "Scarica", - "download_settings_description": "Gestisci le impostazioni riguardandi il download degli asset", + "download_settings_description": "Gestisci le impostazioni relative al download degli asset", "downloading": "Scaricando", "downloading_asset_filename": "Scaricando l'asset {filename}", "drop_files_to_upload": "Rilascia i file ovunque per caricarli", @@ -552,6 +556,10 @@ "edit_user": "Modifica utente", "edited": "Modificato", "editor": "Editor", + "editor_close_without_save_prompt": "Le modifiche non verranno salvate", + "editor_close_without_save_title": "Vuoi chiudere l'editor?", + "editor_crop_tool_h2_aspect_ratios": "Proporzioni", + "editor_crop_tool_h2_rotation": "Rotazione", "email": "Email", "empty": "", "empty_album": "Album Vuoto", @@ -643,7 +651,7 @@ "unable_to_hide_person": "Impossibile nascondere persona", "unable_to_link_oauth_account": "Impossibile collegare l'account OAuth", "unable_to_load_album": "Impossibile caricare l'album", - "unable_to_load_asset_activity": "Impossiible caricare l'attività dell'asset", + "unable_to_load_asset_activity": "Impossibile caricare l'attività dell'asset", "unable_to_load_items": "Impossibile caricare gli elementi", "unable_to_load_liked_status": "Impossibile caricare lo stato dei preferiti", "unable_to_log_out_all_devices": "Impossibile eseguire il logout da tutti i dispositivi", @@ -663,7 +671,7 @@ "unable_to_remove_reaction": "Impossibile rimuovere reazione", "unable_to_remove_user": "", "unable_to_repair_items": "Impossibile riparare elementi", - "unable_to_reset_password": "Impossiible reimpostare la password", + "unable_to_reset_password": "Impossibile reimpostare la password", "unable_to_resolve_duplicate": "Impossibile risolvere duplicato", "unable_to_restore_assets": "Impossibile ripristinare gli asset", "unable_to_restore_trash": "Impossibile ripristinare cestino", @@ -671,23 +679,23 @@ "unable_to_save_album": "Impossibile salvare album", "unable_to_save_api_key": "Impossibile salvare chiave API", "unable_to_save_date_of_birth": "Impossible salvare la data di nascita", - "unable_to_save_name": "Impossibile salvare nome", - "unable_to_save_profile": "Impossibile salvare profilo", - "unable_to_save_settings": "Impossibile salvare impostazioni", - "unable_to_scan_libraries": "Impossibile analizzare librerie", - "unable_to_scan_library": "Impossibile analizzare libreria", + "unable_to_save_name": "Impossibile salvare il nome", + "unable_to_save_profile": "Impossibile salvare il profilo", + "unable_to_save_settings": "Impossibile salvare le impostazioni", + "unable_to_scan_libraries": "Impossibile analizzare le librerie", + "unable_to_scan_library": "Impossibile analizzare la libreria", "unable_to_set_feature_photo": "Impossibile impostare la foto in evidenza", - "unable_to_set_profile_picture": "Impossibile impostare foto profilo", - "unable_to_submit_job": "Impossibile confermare processo", - "unable_to_trash_asset": "Impossibile cestinare asset", - "unable_to_unlink_account": "Impossibile scollegare account", + "unable_to_set_profile_picture": "Impossibile impostare la foto profilo", + "unable_to_submit_job": "Impossibile eseguire l'attività", + "unable_to_trash_asset": "Impossibile cestinare l'asset", + "unable_to_unlink_account": "Impossibile scollegare l'account", "unable_to_update_album_cover": "Errore durante l'aggiornamento della copertina dell'album", - "unable_to_update_album_info": "Errore durante l'aggiornamento delle info dell'album", - "unable_to_update_library": "Impossibile aggiornare libreria", - "unable_to_update_location": "Impossibile aggiornare posizione", - "unable_to_update_settings": "Impossibile aggiornare impostazioni", - "unable_to_update_timeline_display_status": "Impossibile aggiornare lo stato visivo della linea temporale", - "unable_to_update_user": "Impossibile aggiornare utente", + "unable_to_update_album_info": "Impossibile aggiornare le informazioni sull'album", + "unable_to_update_library": "Impossibile aggiornare la libreria", + "unable_to_update_location": "Impossibile aggiornare la posizione", + "unable_to_update_settings": "Impossibile aggiornare le impostazioni", + "unable_to_update_timeline_display_status": "Impossibile aggiornare lo stato di visualizzazione della sequenza temporale", + "unable_to_update_user": "Impossibile aggiornare l'utente", "unable_to_upload_file": "Impossibile caricare il file" }, "every_day_at_onepm": "", @@ -695,7 +703,7 @@ "every_night_at_twoam": "", "every_six_hours": "", "exif": "Exif", - "exit_slideshow": "Esci dalla diapositiva", + "exit_slideshow": "Esci dalla presentazione", "expand_all": "Espandi tutto", "expire_after": "Scade dopo", "expired": "Scaduto", @@ -745,16 +753,16 @@ "host": "Host", "hour": "Ora", "image": "Immagine", - "image_alt_text_date": "{isVideo, select, true {Video} other {Immagine}} scattato il {date}", - "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} scattata con {person1} il giorno {date}", - "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} scattata con {person1} e {person2} il giorno {date}", - "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} scattata con {person1}, {person2}, e {person3} il giorno {date}", - "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} scattata con {person1}, {person2}, e altre {additionalCount, number} persone il giorno {date}", - "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} scattata a {city}, {country} il giorno {date}", - "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} scattata a {city}, {country} con {person1} il giorno {date}", - "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} scattata a {city}, {country} con {person1} e {person2} il giorno {date}", - "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} scattata a {city}, {country} con {person1}, {person2}, e {person3} il giorno {date}", - "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Immagine}} scattato a {city}, {country} con {person1}, {person2} e {additionalCount, number} altre persone il {date}", + "image_alt_text_date": "{isVideo, select, true {Video girato} other {Foto scattata}} il {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video girato} other {Foto scattata}} con {person1} il giorno {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video girato} other {Foto scattata}} con {person1} e {person2} il giorno {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video girato} other {Foto scattata}} con {person1}, {person2}, e {person3} il giorno {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video girato} other {Foto scattata}} con {person1}, {person2}, e altre {additionalCount, number} persone il giorno {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} il giorno {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} con {person1} il giorno {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} con {person1} e {person2} il giorno {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} con {person1}, {person2}, e {person3} il giorno {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} con {person1}, {person2} e {additionalCount, number} altre persone il {date}", "image_alt_text_people": "{count, plural, =1 {con {person1}} =2 {con {person1} e {person2}} =3 {con {person1}, {person2} e {person3}} other {con {person1}, {person2} e {others, number} altri}}", "image_alt_text_place": "a {city}, {country}", "image_taken": "{isVideo, select, true {Video registrato} other {Immagine scattata}}", @@ -783,7 +791,7 @@ "jobs": "Processi", "keep": "Mantieni", "keep_all": "Tieni tutto", - "keyboard_shortcuts": "Comandi rapidi", + "keyboard_shortcuts": "Scorciatoie da tastiera", "language": "Lingua", "language_setting_description": "Seleziona la tua lingua predefinita", "last_seen": "Ultimo accesso", @@ -828,7 +836,7 @@ "loading": "Caricamento", "loading_search_results_failed": "Impossibile caricare i risultati della ricerca", "log_out": "Esci", - "log_out_all_devices": "Esci da tutti i dispositivi", + "log_out_all_devices": "Disconnetti tutti i dispositivi", "logged_out_all_devices": "Disconnesso da tutti i dispositivi", "logged_out_device": "Disconnesso dal dispositivo", "login": "Login", @@ -846,7 +854,7 @@ "manage_your_account": "Gestisci il tuo account", "manage_your_api_keys": "Gestisci le tue chiavi API", "manage_your_devices": "Gestisci i tuoi dispositivi collegati", - "manage_your_oauth_connection": "Gestisci la tua connesione OAuth", + "manage_your_oauth_connection": "Gestisci la tua connessione OAuth", "map": "Mappa", "map_marker_for_images": "Indicatore mappa per le immagini scattate in {city}, {country}", "map_marker_with_image": "Segnaposto con immagine", @@ -863,7 +871,7 @@ "merge_people_limit": "Puoi unire al massimo 5 volti alla volta", "merge_people_prompt": "Vuoi unire queste persone? Questa azione è irreversibile.", "merge_people_successfully": "Unione persone completata con successo", - "merged_people_count": "Uniti {count, plural, one {# persona} other {# persone}}", + "merged_people_count": "{count, plural, one {Unita # persona} other {Unite # persone}}", "minimize": "Minimizza", "minute": "Minuto", "missing": "Mancante", @@ -886,8 +894,8 @@ "next_memory": "Prossima memoria", "no": "No", "no_albums_message": "Crea un album per organizzare le tue foto ed i tuoi video", - "no_albums_with_name_yet": "Nessun album con questo nome, per ora.", - "no_albums_yet": "Nessun album presente, per ora.", + "no_albums_with_name_yet": "Sembra che tu non abbia ancora nessun album con questo nome.", + "no_albums_yet": "Sembra che tu non abbia ancora nessun album.", "no_archived_assets_message": "Archivia foto e video per nasconderli dalla galleria di foto", "no_assets_message": "CLICCA PER CARICARE LA TUA PRIMA FOTO", "no_duplicates_found": "Nessun duplicato trovato.", @@ -914,9 +922,9 @@ "ok": "Ok", "oldest_first": "Prima vecchi", "onboarding": "Inserimento", - "onboarding_privacy_description": "Le seguenti funzioni (opzionali) fanno uso di servizi esterni, e possono essere disabilitate in qualsiasi momento dalle impostazioni d'amministratore.", + "onboarding_privacy_description": "Le seguenti funzioni (opzionali) fanno uso di servizi esterni, e possono essere disabilitate in qualsiasi momento nelle impostazioni di amministrazione.", "onboarding_theme_description": "Scegli un tema colore per la tua istanza. Potrai cambiarlo nelle impostazioni.", - "onboarding_welcome_description": "Andiamo ad impostare la tua istanza con alcuni settaggi comuni.", + "onboarding_welcome_description": "Andiamo ad impostare la tua istanza con alcune impostazioni comuni.", "onboarding_welcome_user": "Benvenuto, {user}", "online": "Online", "only_favorites": "Solo preferiti", @@ -956,7 +964,7 @@ "pending": "In attesa", "people": "Persone", "people_edits_count": "{count, plural, one {Modificata # persona} other {Modificate # persone}}", - "people_sidebar_description": "Mosta un link alle persone nella barra laterale", + "people_sidebar_description": "Mostra un link alle persone nella barra laterale", "perform_library_tasks": "", "permanent_deletion_warning": "Avviso eliminazione permanente", "permanent_deletion_warning_setting_description": "Mostra un avviso all'eliminazione definitiva di un asset", @@ -1046,10 +1054,10 @@ "refreshing_metadata": "Ricaricando i metadati", "regenerating_thumbnails": "Rigenerando le anteprime", "remove": "Rimuovi", - "remove_assets_album_confirmation": "Sei sicuro di voler rimuovere {count, plural, one {# asset} other {# assets}} dall'album?", - "remove_assets_shared_link_confirmation": "Sei sicuro di voler rimuovere {count, plural, one {# asset} other {# assets}} da questo link condiviso?", + "remove_assets_album_confirmation": "Sei sicuro di voler rimuovere {count, plural, one {# asset} other {# asset}} dall'album?", + "remove_assets_shared_link_confirmation": "Sei sicuro di voler rimuovere {count, plural, one {# asset} other {# asset}} da questo link condiviso?", "remove_assets_title": "Rimuovere asset?", - "remove_custom_date_range": "Cancella intervallo data personalizzato", + "remove_custom_date_range": "Rimuovi intervallo data personalizzato", "remove_from_album": "Rimuovere dall'album", "remove_from_favorites": "Rimuovi dai preferiti", "remove_from_shared_link": "Rimuovi dal link condiviso", @@ -1058,7 +1066,7 @@ "removed_api_key": "Rimossa chiave API: {name}", "removed_from_archive": "Rimosso dall'archivio", "removed_from_favorites": "Rimosso dai preferiti", - "removed_from_favorites_count": "{count, plural, other {Rimossi #}} dai preferiti", + "removed_from_favorites_count": "{count, plural, one {Rimosso } other {Rimossi #}} dai preferiti", "rename": "Rinomina", "repair": "Ripara", "repair_no_results_message": "I file mancanti e non tracciati saranno mostrati qui", @@ -1089,7 +1097,7 @@ "saved_settings": "Impostazioni salvate", "say_something": "Dici qualcosa", "scan_all_libraries": "Analizza tutte le librerie", - "scan_all_library_files": "Ri-analizza Tutti i File della Libreria", + "scan_all_library_files": "Scansiona nuovamente tutti i file della libreria", "scan_new_library_files": "Analizza i File Nuovi della Libreria", "scan_settings": "Impostazioni Analisi", "scanning_for_album": "Sto cercando l'album...", @@ -1098,7 +1106,7 @@ "search_by_context": "Cerca con contesto", "search_by_filename": "Cerca per nome del file o estensione", "search_by_filename_example": "es. IMG_1234.JPG o PNG", - "search_camera_make": "Cerca manufattore fotocamera...", + "search_camera_make": "Cerca produttore fotocamera...", "search_camera_model": "Cerca modello fotocamera...", "search_city": "Cerca città...", "search_country": "Cerca paese...", @@ -1109,7 +1117,7 @@ "search_places": "Cerca luoghi", "search_state": "Cerca stato...", "search_timezone": "Cerca fuso orario...", - "search_type": "Certa tipo", + "search_type": "Cerca tipo", "search_your_photos": "Cerca le tue foto", "searching_locales": "Cerca localizzazioni...", "second": "Secondo", @@ -1127,7 +1135,7 @@ "select_photos": "Seleziona foto", "select_trash_all": "Seleziona cestina tutto", "selected": "Selezionato", - "selected_count": "{count, plural, other {# selezionati}}", + "selected_count": "{count, plural, one {# selezionato} other {# selezionati}}", "send_message": "Manda messaggio", "send_welcome_email": "Invia email di benvenuto", "server": "Server", @@ -1140,7 +1148,7 @@ "set_as_profile_picture": "Imposta come foto profilo", "set_date_of_birth": "Imposta data di nascita", "set_profile_picture": "Imposta foto profilo", - "set_slideshow_to_fullscreen": "Imposta diapositiva a schermo intero", + "set_slideshow_to_fullscreen": "Imposta presentazione a schermo intero", "settings": "Impostazioni", "settings_saved": "Impostazioni salvate", "share": "Condivisione", @@ -1173,9 +1181,9 @@ "show_person_options": "Mostra opzioni persona", "show_progress_bar": "Mostra Barra Avanzamento", "show_search_options": "Mostra impostazioni di ricerca", - "show_supporter_badge": "Insignia di Contributore", - "show_supporter_badge_description": "Mostra un'insignia di contributore", - "shuffle": "Mescola", + "show_supporter_badge": "Medaglia di Contributore", + "show_supporter_badge_description": "Mostra la medaglia di contributore", + "shuffle": "Casuale", "sign_out": "Esci", "sign_up": "Registrati", "size": "Dimensione", @@ -1194,16 +1202,16 @@ "stack_duplicates": "Raggruppa i duplicati", "stack_select_one_photo": "Seleziona una foto principale per il gruppo", "stack_selected_photos": "Impila foto selezionate", - "stacked_assets_count": "{count, plural, one {Raggruppato # asset} other {Raggruppati # assets}}", + "stacked_assets_count": "{count, plural, one {Raggruppato # asset} other {Raggruppati # asset}}", "stacktrace": "Traccia dell'errore", "start": "Inizio", "start_date": "Data di inizio", "state": "Provincia", "status": "Stato", "stop_motion_photo": "Ferma Foto in Movimento", - "stop_photo_sharing": "Stoppare la condivisione delle tue foto?", + "stop_photo_sharing": "Interrompere la condivisione delle tue foto?", "stop_photo_sharing_description": "{partner} non potrà più accedere alle tue foto.", - "stop_sharing_photos_with_user": "Non condividere più le tue foto con questo utente", + "stop_sharing_photos_with_user": "Interrompi la condivisione delle tue foto con questo utente", "storage": "Spazio di archiviazione", "storage_label": "Etichetta archiviazione", "storage_usage": "{used} di {available} utilizzati", @@ -1235,7 +1243,7 @@ "trash_no_results_message": "Le foto cestinate saranno mostrate qui.", "trashed_items_will_be_permanently_deleted_after": "Gli elementi cestinati saranno eliminati definitivamente dopo {days, plural, one {# giorno} other {# giorni}}.", "type": "Tipo", - "unarchive": "Rimuovi dagli archivi", + "unarchive": "Annulla l'archiviazione", "unarchived": "Rimosso dall'archivio", "unarchived_count": "{count, plural, other {Non archiviati #}}", "unfavorite": "Rimuovi preferito", @@ -1252,16 +1260,16 @@ "unselect_all": "Deseleziona tutto", "unselect_all_duplicates": "Deseleziona tutti i duplicati", "unstack": "Rimuovi dal gruppo", - "unstacked_assets_count": "{count, plural, one {Separato # asset} other {Separati # assets}}", + "unstacked_assets_count": "{count, plural, one {Separato # asset} other {Separati # asset}}", "untracked_files": "File non tracciati", "untracked_files_decription": "Questi file non vengono tracciati dall'applicazione. Sono il risultato di spostamenti falliti, caricamenti interrotti, oppure sono stati abbandonati a causa di un bug", "up_next": "Prossimo", "updated_password": "Password aggiornata", "upload": "Carica", "upload_concurrency": "Caricamenti contemporanei", - "upload_errors": "Caricamento completato con {count, plural, one {# errore} other {# errori}}, ricarica la pagina per vedere gli assets caricati.", + "upload_errors": "Caricamento completato con {count, plural, one {# errore} other {# errori}}, ricarica la pagina per vedere gli asset caricati.", "upload_progress": "Rimanenti {remaining, number} - Processati {processed, number}/{total, number}", - "upload_skipped_duplicates": "{count, plural, one {Ignorato # asset duplicato} other {Ignorati # assets duplicati}}", + "upload_skipped_duplicates": "{count, plural, one {Ignorato # asset duplicato} other {Ignorati # asset duplicati}}", "upload_status_duplicates": "Duplicati", "upload_status_errors": "Errori", "upload_status_uploaded": "Caricato", @@ -1285,7 +1293,7 @@ "variables": "Variabili", "version": "Versione", "version_announcement_closing": "Il tuo amico, Alex", - "version_announcement_message": "Heilà! È stata rilasciata una nuova versione dell'applicazione. Leggi le note di rilascio e assicurati che i tuoi file docker-compose.yml/.env siano aggiornati per evitare problemi e incongruenze, sopratutto se utilizzi WatchTower o altri strumenti per aggiornare l'applicazione in automatico.", + "version_announcement_message": "Ehilà! È stata rilasciata una nuova versione dell'applicazione. Leggi le note di rilascio e assicurati che i tuoi file docker-compose.yml/.env siano aggiornati per evitare problemi e incongruenze, soprattutto se utilizzi WatchTower o altri strumenti per aggiornare l'applicazione in automatico.", "video": "Video", "video_hover_setting": "Riproduci l'anteprima del video al passaggio del mouse", "video_hover_setting_description": "Riproduci miniatura video quando il mouse passa sopra l'elemento. Anche se disabilitato, la riproduzione può essere avviata passando con il mouse sopra l'icona riproduci.", @@ -1305,7 +1313,7 @@ "warning": "Attenzione", "week": "Settimana", "welcome": "Benvenuto", - "welcome_to_immich": "Benvenuto a immich", + "welcome_to_immich": "Benvenuto in immich", "year": "Anno", "years_ago": "{years, plural, one {# anno} other {# anni}} fa", "yes": "Si", diff --git a/web/src/lib/i18n/ko.json b/web/src/lib/i18n/ko.json index 9d94a918fb..89c5ca068f 100644 --- a/web/src/lib/i18n/ko.json +++ b/web/src/lib/i18n/ko.json @@ -129,12 +129,13 @@ "map_enable_description": "지도 기능 활성화", "map_gps_settings": "지도 및 GPS 설정", "map_gps_settings_description": "지도 및 GPS (역지오코딩) 설정 관리", + "map_implications": "지도 기능은 외부 타일 서비스(tiles.immich.clou를 사용합니다.", "map_light_style": "라이트 스타일", "map_manage_reverse_geocoding_settings": "역지오코딩 설정 관리", "map_reverse_geocoding": "역지오코딩", "map_reverse_geocoding_enable_description": "역지오코딩 활성화", "map_reverse_geocoding_settings": "역지오코딩 설정", - "map_settings": "지도 설정", + "map_settings": "지도", "map_settings_description": "지도 설정 관리", "map_style_description": "지도 테마 style.json URL", "metadata_extraction_job": "메타데이터 추출", @@ -320,7 +321,8 @@ "user_settings": "사용자 설정", "user_settings_description": "사용자 설정 관리", "user_successfully_removed": "{email}이(가) 성공적으로 제거되었습니다.", - "version_check_enabled_description": "최신 버전 확인을 위한 주기적인 GitHub 확인 활성화", + "version_check_enabled_description": "버전 확인 활성화", + "version_check_implications": "버전 확인 기능은 주기적으로 github.com에 요청을 보냅니다.", "version_check_settings": "버전 확인", "version_check_settings_description": "최신 버전 알림 설정 관리", "video_conversion_job": "동영상 트랜스코드", @@ -336,7 +338,8 @@ "album_added": "공유 앨범 초대", "album_added_notification_setting_description": "공유 앨범으로 초대를 받은 경우 이메일 알림 받기", "album_cover_updated": "앨범 커버를 변경했습니다.", - "album_delete_confirmation": "{album} 앨범을 삭제하시겠습니까?\n이 앨범을 공유한 경우 다른 사용자가 더 이상 앨범에 접근할 수 없습니다.", + "album_delete_confirmation": "{album} 앨범을 삭제하시겠습니까?", + "album_delete_confirmation_description": "이 앨범을 공유한 경우 다른 사용자가 더 이상 앨범에 접근할 수 없습니다.", "album_info_updated": "앨범 정보가 수정되었습니다.", "album_leave": "앨범에서 나가시겠습니까?", "album_leave_confirmation": "{album} 앨범에서 나가시겠습니까?", @@ -360,6 +363,7 @@ "allow_edits": "편집자로 설정", "allow_public_user_to_download": "모든 사용자의 다운로드 허용", "allow_public_user_to_upload": "모든 사용자의 업로드 허용", + "anti_clockwise": "반시계 방향", "api_key": "API 키", "api_key_description": "이 값은 한 번만 표시됩니다. 창을 닫기 전 반드시 복사하세요.", "api_key_empty": "키 이름은 비어 있을 수 없습니다.", @@ -441,6 +445,7 @@ "clear_all_recent_searches": "검색 기록 전체 삭제", "clear_message": "메시지 지우기", "clear_value": "값 지우기", + "clockwise": "시계 방향", "close": "닫기", "collapse": "접기", "collapse_all": "모두 접기", @@ -550,6 +555,10 @@ "edit_user": "사용자 수정", "edited": "펀집되었습니다.", "editor": "편집자", + "editor_close_without_save_prompt": "변경 사항이 반영되지 않습니다.", + "editor_close_without_save_title": "편집을 종료하시겠습니까?", + "editor_crop_tool_h2_aspect_ratios": "종횡비", + "editor_crop_tool_h2_rotation": "회전", "email": "이메일", "empty": "", "empty_album": "", @@ -720,6 +729,7 @@ "filter_people": "인물 필터", "find_them_fast": "이름으로 검색하여 빠르게 찾기", "fix_incorrect_match": "잘못된 분류 수정", + "folders": "폴더", "force_re-scan_library_files": "모든 파일 강제 다시 스캔", "forward": "앞으로", "general": "일반", @@ -895,6 +905,7 @@ "ok": "확인", "oldest_first": "오래된 순", "onboarding": "온보딩", + "onboarding_privacy_description": "이 선택적 기능은 외부 서비스를 사용하며, 관리자 설정에서 언제든 비활성화할 수 있습니다.", "onboarding_storage_template_description": "활성화한 경우, 사용자 정의 템플릿을 기반으로 파일을 자동 분류합니다. 안정성 문제로 인해 해당 기능은 기본적으로 비활성화 되어 있습니다. 자세한 내용은 [공식 문서]를 참조하세요.", "onboarding_theme_description": "색상 테마를 선택하세요. 나중에 설정에서 변경할 수 있습니다.", "onboarding_welcome_description": "몇 가지 일반적인 설정을 진행하겠습니다.", @@ -969,6 +980,7 @@ "previous_memory": "이전 추억", "previous_or_next_photo": "이전 또는 다음 이미지로", "primary": "주요", + "privacy": "프라이버시", "profile_image_of_user": "{user}님의 프로필 이미지", "profile_picture_set": "프로필 사진이 설정되었습니다.", "public_album": "공개 앨범", @@ -1007,6 +1019,8 @@ "purchase_settings_server_activated": "서버 제품 키는 관리자가 관리합니다.", "range": "", "rating": "등급", + "rating_clear": "등급 초기화", + "rating_count": "{count, plural, one {#점} other {#점}}", "rating_description": "상세 정보에 EXIF의 등급 정보 표시", "raw": "", "reaction_options": "반응 옵션", @@ -1130,6 +1144,7 @@ "shared_by_user": "{user}님이 공유함", "shared_by_you": "내가 공유함", "shared_from_partner": "{partner}님의 사진", + "shared_link_options": "공유 링크 옵션", "shared_links": "공유 링크", "shared_photos_and_videos_count": "사진 및 동영상 {assetCount, plural, other {#개를 공유했습니다.}}", "shared_with_partner": "{partner}님과 공유함", @@ -1205,7 +1220,7 @@ "to_login": "로그인", "to_trash": "삭제", "toggle_settings": "설정 변경", - "toggle_theme": "테마 변경", + "toggle_theme": "다크 모드 사용", "toggle_visibility": "숨김 여부 변경", "total_usage": "총 사용량", "trash": "휴지통", @@ -1227,6 +1242,7 @@ "unlink_oauth": "OAuth 연결 해제", "unlinked_oauth_account": "OAuth 계정 연결이 해제되었습니다.", "unnamed_album": "이름 없는 앨범", + "unnamed_album_delete_confirmation": "선텍한 앨범을 삭제하시겠습니까?", "unnamed_share": "이름 없는 공유", "unsaved_change": "저장되지 않은 변경 사항", "unselect_all": "모두 선택 해제", @@ -1283,7 +1299,7 @@ "warning": "경고", "week": "주", "welcome": "환영합니다", - "welcome_to_immich": "Immich에 오신 것을 환영합니다", + "welcome_to_immich": "환영합니다", "year": "년", "years_ago": "{years, plural, one {#년} other {#년}} 전", "yes": "네", diff --git a/web/src/lib/i18n/lt.json b/web/src/lib/i18n/lt.json index e656754c7d..faf4dea292 100644 --- a/web/src/lib/i18n/lt.json +++ b/web/src/lib/i18n/lt.json @@ -7,6 +7,7 @@ "actions": "Veiksmai", "active": "Vykdoma", "activity": "Veikla", + "activity_changed": "Veikla yra {enabled, select, true {enabled} other {disabled}}", "add": "Pridėti", "add_a_description": "Pridėti aprašymą", "add_a_location": "Pridėti vietovę", @@ -34,43 +35,51 @@ "config_set_by_file": "Konfigūracija dabar nustatyta konfigūracinio failo", "confirm_delete_library": "Ar tikrai norite ištrinti {library} biblioteką?", "confirm_email_below": "Patvirtinimui įveskite \"{email}\" žemiau", + "confirm_reprocess_all_faces": "Ar tikrai norite iš naujo apdoroti visus veidus? Tai taip pat ištrins įvardytus asmenis.", "confirm_user_password_reset": "Ar tikrai norite iš naujo nustatyti {user} slaptažodį?", "crontab_guru": "", "disable_login": "Išjungti prisijungimą", "disabled": "", - "duplicate_detection_job_description": "", + "duplicate_detection_job_description": "Vykdykite mašininį mokymąsi tam, kad aptiktumėte panašius vaizdus. Nuo šios funkcijos priklauso išmanioji paieška", "exclusion_pattern_description": "Išimčių šablonai leidžia nepaisyti failų ir aplankų skenuojant jūsų biblioteką. Tai yra naudinga, jei turite aplankų su failais, kurių nenorite importuoti, pavyzdžiui, RAW failai.", "external_library_created_at": "Išorinė biblioteka (sukurta {date})", "external_library_management": "Išorinių bibliotekų tvarkymas", "face_detection": "Veido atpažinimas", - "image_format_description": "", - "image_prefer_embedded_preview": "", + "failed_job_command": "Darbo {job} komanda {command} nepavyko", + "force_delete_user_warning": "ĮSPĖJIMAS: Šis veiksmas iš karto pašalins naudotoją ir visą jo informaciją. Šis žingsnis nesugrąžinamas ir failų nebus galima atkurti.", + "forcing_refresh_library_files": "Priverstinai atnaujinami visi failai bilbiotekoje", + "image_format_description": "WebP sukuria mažesnius failus nei JPEG, bet lėčiau juos apdoroja.", + "image_prefer_embedded_preview": "Pageidautinai rodyti įterptą peržiūrą", "image_prefer_embedded_preview_setting_description": "", - "image_prefer_wide_gamut": "", + "image_prefer_wide_gamut": "Teikti pirmenybę plačiai gamai", "image_prefer_wide_gamut_setting_description": "", "image_preview_format": "Peržiūros formatas", "image_preview_resolution": "Peržiūros rezoliucija", - "image_preview_resolution_description": "", + "image_preview_resolution_description": "Naudojama peržiūrint vieną nuotrauką ir mašininiam mokymui. Didesnė rezoliucija gali išsaugoti daugiau detalių, bet ilgiau užtrukti apdoroti ir sumažinti programos greitumą.", "image_quality": "Kokybė", "image_quality_description": "Vaizdo kokybė nuo 1 iki 100. Aukštesnė kokybė yra geresnė, tačiau sukuriami didesni failai. Ši parinktis turi įtakos peržiūros ir miniatiūrų vaizdams.", - "image_settings": "", - "image_settings_description": "", + "image_settings": "Nuotraukos nustatymai", + "image_settings_description": "Keisti sugeneruotų nuotraukų kokybę ir rezoliuciją", "image_thumbnail_format": "Miniatūros formatas", "image_thumbnail_resolution": "Miniatūros rezoliucija", - "image_thumbnail_resolution_description": "", - "job_settings": "", - "job_settings_description": "", + "image_thumbnail_resolution_description": "Naudojama žiūrint nuotraukų grupes (pagrindinis nuotraukų puslapis, albumų peržiūra ir t.t.). Aukštesnė rezoliucija gali išlaikyti daugiau detalių, bet užtrunka ilgiau apdoroti, gali turėti didesnius failų dydžius ir gali sumažinti programos greitumą.", + "job_concurrency": "{job} lygiagretumas", + "job_not_concurrency_safe": "Šis darbas nėra saugus apdoroti lygiagrečiai.", + "job_settings": "Darbo nustatymai", + "job_settings_description": "Keisti darbų lygiagretumą", "job_status": "Darbų būsenos", "library_created": "Sukurta biblioteka: {library}", "library_cron_expression": "Cron išraiška", + "library_cron_expression_description": "Nustatykite nuskaitymo intervalą naudodami „cron“ formatą. Daugiau informacijos rasite pvz. Crontab Guru", "library_cron_expression_presets": "", "library_deleted": "Biblioteka ištrinta", + "library_import_path_description": "Nurodykite aplanką, kurį norite importuoti. Šiame aplanke, įskaitant poaplankius, bus nuskaityti vaizdai ir vaizdo įrašai.", "library_scanning": "Periodinis skanavimas", "library_scanning_description": "Konfigūruoti periodinį bibliotekos skanavimą", "library_scanning_enable_description": "Įgalinti periodinį bibliotekos skanavimą", "library_settings": "Išorinė biblioteka", "library_settings_description": "Tvarkyti išorinės bibliotekos parametrus", - "library_tasks_description": "", + "library_tasks_description": "Atlikit bibliotekos užduotis", "library_watching_enable_description": "", "library_watching_settings": "", "library_watching_settings_description": "", @@ -83,7 +92,7 @@ "machine_learning_duplicate_detection_enabled_description": "", "machine_learning_duplicate_detection_setting_description": "", "machine_learning_enabled": "Įgalinti mašininį mokymąsi", - "machine_learning_enabled_description": "", + "machine_learning_enabled_description": "Jei išjungta, visos „ML“ funkcijos bus išjungtos, nepaisant toliau pateiktų nustatymų.", "machine_learning_facial_recognition": "Veido atpažinimas", "machine_learning_facial_recognition_description": "Aptikti, atpažinti ir sugrupuoti veidus nuotraukose", "machine_learning_facial_recognition_model": "Veido atpažinimo modelis", @@ -91,20 +100,20 @@ "machine_learning_facial_recognition_setting": "Įgalinti veido atpažinimą", "machine_learning_facial_recognition_setting_description": "", "machine_learning_max_detection_distance": "", - "machine_learning_max_detection_distance_description": "", - "machine_learning_max_recognition_distance": "", + "machine_learning_max_detection_distance_description": "Didžiausias atstumas tarp dviejų vaizdų, kad jie būtų laikomi dublikatais, svyruoja nuo 0,001 iki 0,1. Didesnės vertės aptiks daugiau dublikatų, tačiau gali būti klaidingai teigiami.", + "machine_learning_max_recognition_distance": "Maksimalus atpažinimo atstumas", "machine_learning_max_recognition_distance_description": "", "machine_learning_min_detection_score": "", "machine_learning_min_detection_score_description": "", "machine_learning_min_recognized_faces": "", - "machine_learning_min_recognized_faces_description": "", + "machine_learning_min_recognized_faces_description": "Mažiausias atpažintų veidų skaičius asmeniui, kurį reikia sukurti. Tai padidinus, veido atpažinimas tampa tikslesnis, bet padidėja tikimybė, kad veidas žmogui nepriskirtas.", "machine_learning_settings": "Mašininio mokymosi nustatymai", "machine_learning_settings_description": "Tvarkyti mašininio mokymosi funkcijas ir nustatymus", "machine_learning_smart_search": "Išmanioji paieška", "machine_learning_smart_search_description": "", "machine_learning_smart_search_enabled": "Įjungti išmaniąją paiešką", - "machine_learning_smart_search_enabled_description": "", - "machine_learning_url_description": "", + "machine_learning_smart_search_enabled_description": "Jei išjungta, vaizdai nebus užkoduoti išmaniajai paieškai.", + "machine_learning_url_description": "Mašininio mokymosi serverio URL", "manage_log_settings": "", "map_dark_style": "Tamsioji tema", "map_enable_description": "", @@ -190,20 +199,21 @@ "thumbnail_generation_job": "Generuoti miniatiūras", "thumbnail_generation_job_description": "", "transcode_policy_description": "", - "transcoding_acceleration_api": "", + "transcoding_acceleration_api": "Spartinimo API", "transcoding_acceleration_api_description": "", - "transcoding_acceleration_nvenc": "", + "transcoding_acceleration_nvenc": "NVENC (reikalinga NVIDIA GPU)", "transcoding_acceleration_qsv": "", "transcoding_acceleration_rkmpp": "", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "", "transcoding_accepted_audio_codecs_description": "", + "transcoding_accepted_containers": "Priimami konteineriai", "transcoding_accepted_video_codecs": "", "transcoding_accepted_video_codecs_description": "", "transcoding_advanced_options_description": "Parinktys, kurių daugelis vartotojų keisti neturėtų", "transcoding_audio_codec": "Garso kodekas", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", + "transcoding_audio_codec_description": "Opus yra aukščiausios kokybės variantas, tačiau turi mažesnį suderinamumą su senesniais įrenginiais ar programine įranga.", + "transcoding_bitrate_description": "Vaizdo įrašai viršija maksimalią leistiną bitų spartą arba nėra priimtino formato", "transcoding_constant_quality_mode": "Pastovios kokybės režimas", "transcoding_constant_quality_mode_description": "", "transcoding_constant_rate_factor": "", @@ -216,7 +226,7 @@ "transcoding_hevc_codec": "HEVC kodekas", "transcoding_max_b_frames": "", "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", + "transcoding_max_bitrate": "Maksimalus bitų srautas", "transcoding_max_bitrate_description": "", "transcoding_max_keyframe_interval": "", "transcoding_max_keyframe_interval_description": "", @@ -785,13 +795,27 @@ "public_share": "", "purchase_account_info": "Rėmėjas", "purchase_activated_subtitle": "Dėkojame, kad remiate Immich ir atviro kodo programinę įrangą", + "purchase_activated_time": "Suaktyvinta {date, date}", "purchase_activated_title": "Jūsų raktas sėkmingai aktyvuotas", "purchase_button_activate": "Aktyvuoti", "purchase_button_buy": "Pirkti", "purchase_button_buy_immich": "Pirkti Immich", + "purchase_button_never_show_again": "Niekada daugiau nerodyti", + "purchase_button_reminder": "Priminti man po 30 dienų", + "purchase_button_remove_key": "Pašalinti produkto rakta", "purchase_button_select": "Pasirinkti", + "purchase_failed_activation": "Nepavyko suaktyvinti! Patikrinkite el. paštą, ar turite teisingo produkto koda!", + "purchase_individual_description_1": "Asmeniui", "purchase_individual_description_2": "Rėmėjo statusas", "purchase_input_suggestion": "Turite produkto raktą? Įveskite jį žemiau", + "purchase_license_subtitle": "Įsigykite „Immich“, kad palaikytumėte tolesnį paslaugos vystymą", + "purchase_lifetime_description": "Pirkimas visam gyvenimui", + "purchase_option_title": "PIRKIMO PASIRINKIMAS", + "purchase_panel_info_1": "„Immich“ kūrimas užima daug laiko ir pastangų, o visą darbo dieną dirba inžinieriai, kad jis būtų kuo geresnis. Mūsų misija yra, kad atvirojo kodo programinė įranga ir etiška verslo praktika taptų tvariu programuotojų pajamų šaltiniu ir sukurtų privatumą gerbiančią ekosistemą su realiomis alternatyvomis išnaudojamoms debesijos paslaugoms.", + "purchase_panel_info_2": "Kadangi esame įsipareigoję nepridėti mokamų sienų, šis pirkinys nesuteiks jums jokių papildomų „Immich“ funkcijų. Mes tikime, kad tokie vartotojai kaip jūs palaikys nuolatinį „Immich“ vystymąsi.", + "purchase_panel_title": "Palaikykite projektą", + "purchase_per_server": "Vienam serveriui", + "purchase_per_user": "Vienam naudotojui", "purchase_remove_product_key": "Pašalinti produkto raktą", "purchase_remove_product_key_prompt": "Ar tikrai norite pašalinti produkto raktą?", "purchase_remove_server_product_key": "Pašalinti serverio produkto raktą", @@ -801,6 +825,7 @@ "purchase_server_title": "Serveris", "purchase_settings_server_activated": "Serverio produkto raktas yra tvarkomas administratoriaus", "range": "", + "rating": "Įvertinimas žvaigždutėmis", "raw": "", "reaction_options": "", "read_changelog": "", diff --git a/web/src/lib/i18n/mn.json b/web/src/lib/i18n/mn.json index 54a4710a03..1bd96a43fd 100644 --- a/web/src/lib/i18n/mn.json +++ b/web/src/lib/i18n/mn.json @@ -1,32 +1,40 @@ { - "account": "", - "acknowledge": "", - "action": "", - "actions": "", - "active": "", - "activity": "", - "add": "", - "add_a_description": "", - "add_a_location": "", - "add_a_name": "", - "add_a_title": "", + "about": "Тухай", + "account": "Бүртгэл", + "account_settings": "Бүртгэлийн тохиргоо", + "acknowledge": "Ойлголоо", + "action": "Үйлдэл", + "actions": "Үйлдлүүд", + "active": "Идэвхтэй", + "activity": "Үйлдлийн бүртгэл", + "activity_changed": "Үйлдлийн бүртгэл {enabled, select, true {идэвхтэй} other {идэвхгүй}}", + "add": "Нэмэх", + "add_a_description": "Тайлбар оруулах", + "add_a_location": "Байршил нэмэх", + "add_a_name": "Нэр өгөх", + "add_a_title": "Гарчиг оруулах", "add_exclusion_pattern": "", "add_import_path": "", - "add_location": "", - "add_more_users": "", - "add_partner": "", + "add_location": "Байршил оруулах", + "add_more_users": "Өөр хэрэглэгчид нэмэх", + "add_partner": "Хамтрагч нэмэх", "add_path": "", - "add_photos": "", + "add_photos": "Зураг нэмэх", "add_to": "", - "add_to_album": "", - "add_to_shared_album": "", + "add_to_album": "Цомогт оруулах", + "add_to_shared_album": "Нээлттэй албумд оруулах", + "added_to_archive": "Архивд оруулах", + "added_to_favorites": "Дуртай зурганд нэмэх", + "added_to_favorites_count": "Дуртай зурагнуудад {count, number} нэмэгдлээ", "admin": { - "authentication_settings": "", + "authentication_settings": "Танин нэвтрэлт тохиргоо", "authentication_settings_description": "", + "check_all": "Бүгдийг сонгох", "crontab_guru": "", "disable_login": "", "disabled": "", "duplicate_detection_job_description": "", + "face_detection": "Нүүр илрүүлэх", "image_format_description": "", "image_prefer_embedded_preview": "", "image_prefer_embedded_preview_setting_description": "", @@ -35,15 +43,16 @@ "image_preview_format": "", "image_preview_resolution": "", "image_preview_resolution_description": "", - "image_quality": "", + "image_quality": "Чанар", "image_quality_description": "", "image_settings": "", "image_settings_description": "", "image_thumbnail_format": "", "image_thumbnail_resolution": "", "image_thumbnail_resolution_description": "", - "job_settings": "", + "job_settings": "Ажлын тохиргоо", "job_settings_description": "", + "job_status": "Ажлын төлөв", "library_cron_expression": "", "library_cron_expression_presets": "", "library_scanning": "", @@ -62,11 +71,13 @@ "machine_learning_duplicate_detection": "", "machine_learning_duplicate_detection_enabled_description": "", "machine_learning_duplicate_detection_setting_description": "", - "machine_learning_enabled_description": "", - "machine_learning_facial_recognition": "", - "machine_learning_facial_recognition_description": "", - "machine_learning_facial_recognition_model": "", - "machine_learning_facial_recognition_model_description": "", + "machine_learning_enabled": "Машин сургалт идэвхжүүлэх", + "machine_learning_enabled_description": "Идэвхгүй болгосон үед доорх тохиргооноос хамаарахгүйгээр бүх машин сургалтын боломж идэвхгүй болно.", + "machine_learning_facial_recognition": "Нүүр танилт", + "machine_learning_facial_recognition_description": "Зураг дээрх хүмүүсийн нүүрийг илрүүлж, таньж, бүлэглэнэ", + "machine_learning_facial_recognition_model": "Нүүр танилтын загвар", + "machine_learning_facial_recognition_model_description": "Загварууд хэмжээ нь буурах эрэмбээр жагссан. Том загварууд удаан, илүү их санах ой хэрэглэх боловч харьцангуй чанартай үр дүн үзүүлнэ. Загвар өөрчилсөн тохиолдолд нүүр илрүүлэлтийн ажлыг дахин эхлүүлэх шаардлагатайг санаарай.", + "machine_learning_facial_recognition_setting": "Нүүр танилт идэвхжүүлэх", "machine_learning_facial_recognition_setting_description": "", "machine_learning_max_detection_distance": "", "machine_learning_max_detection_distance_description": "", @@ -89,7 +100,7 @@ "map_reverse_geocoding": "", "map_reverse_geocoding_enable_description": "", "map_reverse_geocoding_settings": "", - "map_settings": "", + "map_settings": "Газрын зураг", "map_settings_description": "", "map_style_description": "", "metadata_extraction_job_description": "", @@ -210,14 +221,17 @@ "transcoding_two_pass_encoding_setting_description": "", "transcoding_video_codec": "", "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", + "trash_enabled_description": "Хогийн сав идэвхжүүлэх", + "trash_number_of_days": "Хоногийн тоо", + "trash_number_of_days_description": "Хогийн саванд хэд хоног хадгалаад бүр мөсөн устгах вэ", + "trash_settings": "Хогийн савны тохиргоо", + "trash_settings_description": "Хогийн савны тохиргоог өөрчлөх", "user_delete_delay_settings": "", "user_delete_delay_settings_description": "", - "user_settings": "", + "user_management": "Хэрэглэгчийн удирдлага", + "user_password_has_been_reset": "Хэрэглэгчийн нууц үг шинээр тохируулагдлаа:", + "user_restore_description": "{user}-н бүртгэл сэргэнэ.", + "user_settings": "Хэрэглэгчийн тохиргоо", "user_settings_description": "", "version_check_enabled_description": "", "version_check_settings": "", @@ -226,57 +240,70 @@ }, "admin_email": "", "admin_password": "", - "administration": "", + "administration": "Админ", "advanced": "", - "album_added": "", + "album_added": "Цомог нэмэгдлээ", "album_added_notification_setting_description": "", "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", - "album_options": "", + "album_info_updated": "Цомгийн мэлээлэл шинэчлэгдлээ", + "album_leave": "Цомгоос гарах уу?", + "album_leave_confirmation": "Та {album} цомгоос гарахдаа итгэлтэй байна уу?", + "album_name": "Цомгийн нэр", + "album_options": "Цомгийн тохиргоо", + "album_remove_user": "Хэрэглэгч хасах уу?", + "album_remove_user_confirmation": "{user} хэрэглэгчийг хасахдаа итгэлтэй байна уу?", "album_updated": "", "album_updated_setting_description": "", - "albums": "", - "all": "", - "all_people": "", - "allow_dark_mode": "", - "allow_edits": "", - "api_key": "", - "api_keys": "", - "app_settings": "", + "albums": "Цомгууд", + "all": "Бүгд", + "all_albums": "Бүх цомог", + "all_people": "Бүх хүн", + "all_videos": "Бүх бичлэг", + "allow_dark_mode": "Харанхуй горим зөвшөөрөх", + "allow_edits": "Засварлалт зөвшөөрөх", + "api_key": "API түлхүүр", + "api_key_description": "Энэ утга зөвхөн ганц л удаа харагдана. Цонхоо хаахаас өмнө хуулж аваарай.", + "api_key_empty": "Таны API түлхүүрийн нэр хоосон байж болохгүй", + "api_keys": "API түлхүүрүүд", + "app_settings": "Апп-н тохиргоо", "appears_in": "", - "archive": "", - "archive_or_unarchive_photo": "", + "archive": "Архив", + "archive_or_unarchive_photo": "Зургийг архивт хийх эсвэл гаргах", + "archive_size": "Архивын хэмжээ", + "archive_size_description": "Татах үеийн архивын хэмжээг тохируулах (GiB-р)", "archived": "", + "asset_added_to_album": "Цомогт нэмсэн", + "asset_adding_to_album": "Цомогт нэмж байна...", "asset_offline": "", "assets": "", "authorized_devices": "", "back": "", "backward": "", "blurred_background": "", - "camera": "", - "camera_brand": "", - "camera_model": "", + "buy": "Immich худалдаж авах", + "camera": "Камер", + "camera_brand": "Камерын үйлдвэр", + "camera_model": "Камерын загвар", "cancel": "Цуцлах", - "cancel_search": "", + "cancel_search": "Хайлт цуцлах", "cannot_merge_people": "", "cannot_update_the_description": "", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", "cant_search_places": "", - "change_date": "", + "change_date": "Огноо өөрчлөх", "change_expiration_time": "", - "change_location": "", - "change_name": "", - "change_name_successfully": "", - "change_password": "", + "change_location": "Байршил өөрчлөх", + "change_name": "Нэр өөрчлөх", + "change_name_successfully": "Нэр амжилттай өөрчлөгдлөө", + "change_password": "Нууц үг өөрчлөх", "change_your_password": "", "changed_visibility_successfully": "", "check_logs": "", - "city": "", - "clear": "", - "clear_all": "", + "city": "Хот", + "clear": "Цэвэрлэх", + "clear_all": "Бүгдийг цэвэрлэх", "clear_message": "", "clear_value": "", "close": "", @@ -371,7 +398,7 @@ "email": "", "empty": "", "empty_album": "", - "empty_trash": "", + "empty_trash": "Хогийн сав хоослох", "enable": "", "enabled": "", "end_date": "", @@ -392,7 +419,7 @@ "unable_to_delete_album": "", "unable_to_delete_asset": "", "unable_to_delete_user": "", - "unable_to_empty_trash": "", + "unable_to_empty_trash": "Хогийн савыг хоослож чадсангүй", "unable_to_enter_fullscreen": "", "unable_to_exit_fullscreen": "", "unable_to_hide_person": "", @@ -412,7 +439,7 @@ "unable_to_reset_password": "", "unable_to_resolve_duplicate": "", "unable_to_restore_assets": "", - "unable_to_restore_trash": "", + "unable_to_restore_trash": "Хогийн савнаас гаргаж чадсангүй", "unable_to_restore_user": "", "unable_to_save_album": "", "unable_to_save_name": "", @@ -437,13 +464,13 @@ "expand_all": "", "expire_after": "", "expired": "", - "explore": "", + "explore": "Эрж олох", "extension": "", "external_libraries": "", "failed_to_get_people": "", "favorite": "", "favorite_or_unfavorite_photo": "", - "favorites": "", + "favorites": "Дуртай", "feature": "", "feature_photo_updated": "", "featurecollection": "", @@ -485,7 +512,7 @@ "night_at_midnight": "", "night_at_twoam": "" }, - "invite_people": "", + "invite_people": "Хүмүүс урих", "invite_to_album": "", "job_settings_description": "", "jobs": "", @@ -497,7 +524,7 @@ "leave": "", "let_others_respond": "", "level": "", - "library": "", + "library": "Зургийн сан", "library_options": "", "light": "", "link_options": "", @@ -551,9 +578,9 @@ "no": "", "no_albums_message": "", "no_archived_assets_message": "", - "no_assets_message": "", + "no_assets_message": "Энд дарж та эхний зургаа хуулж үзэх үү", "no_exif_info_available": "", - "no_explore_results_message": "", + "no_explore_results_message": "Зураг хуулж оруулсаны дараа ашиглах боломжтой болно.", "no_favorites_message": "", "no_libraries_message": "", "no_name": "", @@ -570,7 +597,7 @@ "ok": "", "oldest_first": "", "online": "", - "only_favorites": "", + "only_favorites": "Зөвхөн дуртай зурагнууд", "only_refreshes_modified_files": "", "open_the_search_filters": "", "options": "", @@ -597,7 +624,7 @@ "pause_memories": "", "paused": "", "pending": "", - "people": "", + "people": "Хүмүүс", "people_sidebar_description": "", "perform_library_tasks": "", "permanent_deletion_warning": "", @@ -608,7 +635,7 @@ "photos_from_previous_years": "", "pick_a_location": "", "place": "", - "places": "", + "places": "Байршилууд", "play": "", "play_memories": "", "play_motion_photo": "", @@ -634,9 +661,11 @@ "refreshes_every_file": "", "remove": "", "remove_from_album": "", - "remove_from_favorites": "", + "remove_from_favorites": "Дуртай зурагнуудаас хасах", "remove_from_shared_link": "", "remove_offline_files": "", + "removed_from_favorites": "Дуртай зурагнуудаас хасагдсан", + "removed_from_favorites_count": "Дуртай зурагнуудаас {count, plural, other {Removed #}} хасагдлаа", "repair": "", "repair_no_results_message": "", "replace_with_upload": "", @@ -667,11 +696,11 @@ "search_country": "", "search_for_existing_person": "", "search_people": "", - "search_places": "", + "search_places": "Байршил хайх", "search_state": "", "search_timezone": "", "search_type": "", - "search_your_photos": "", + "search_your_photos": "Зурагнуудаасаа хайлт хийх", "searching_locales": "", "second": "", "select_album_cover": "", @@ -685,6 +714,7 @@ "selected": "", "send_message": "", "server": "", + "server_online": "Сервер Онлайн", "server_stats": "", "set": "", "set_as_album_cover": "", @@ -699,7 +729,7 @@ "shared_by": "", "shared_by_you": "", "shared_links": "", - "sharing": "", + "sharing": "Хуваалцах", "sharing_sidebar_description": "", "show_album_options": "", "show_file_location": "", @@ -715,6 +745,7 @@ "show_progress_bar": "", "show_search_options": "", "shuffle": "", + "sign_out": "Гарах", "sign_up": "", "size": "", "skip_to_content": "", @@ -728,8 +759,9 @@ "state": "", "status": "", "stop_motion_photo": "", - "storage": "", + "storage": "Дискний багтаамж", "storage_label": "", + "storage_usage": "Нийт {available} боломжтойгоос {used} хэрэглэсэн", "submit": "", "suggestions": "", "sunrise_on_the_beach": "", @@ -745,7 +777,7 @@ "toggle_theme": "", "toggle_visibility": "", "total_usage": "", - "trash": "", + "trash": "Хогийн сав", "trash_all": "", "trash_no_results_message": "", "type": "", @@ -762,7 +794,7 @@ "unstack": "", "up_next": "", "updated_password": "", - "upload": "", + "upload": "Зураг хуулах", "upload_concurrency": "", "url": "", "usage": "", @@ -771,15 +803,15 @@ "user_usage_detail": "", "username": "", "users": "", - "utilities": "", + "utilities": "Багаж хэрэгсэл", "validate": "", "variables": "", "version": "", "video": "", "video_hover_setting_description": "", "videos": "", - "view_all": "", - "view_all_users": "", + "view_all": "Бүгдийг харах", + "view_all_users": "Бүх хэрэглэгчийг харах", "view_links": "", "view_next_asset": "", "view_previous_asset": "", diff --git a/web/src/lib/i18n/nb_NO.json b/web/src/lib/i18n/nb_NO.json index b3851a2247..df56d27a23 100644 --- a/web/src/lib/i18n/nb_NO.json +++ b/web/src/lib/i18n/nb_NO.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Legg til delt album", "added_to_archive": "Lagt til i arkiv", "added_to_favorites": "Lagt til i favoritter", - "added_to_favorites_count": "Lagt til {count} i favoritter", + "added_to_favorites_count": "Lagt til {count, number} i favoritter", "admin": { "add_exclusion_pattern_description": "Legg til ekskluderingsmønstre. Globbing med *, ** og ? støttes. For å ignorere alle filer i en hvilken som helst mappe som heter \"Raw\", bruk \"**/Raw/**\". For å ignorere alle filer som slutter på \".tif\", bruk \"**/*.tif\". For å ignorere en absolutt filplassering, bruk \"/filbane/til/ignorer/**\".", "authentication_settings": "Autentiserings innstillinger", @@ -128,6 +128,7 @@ "map_dark_style": "Mørk stil", "map_enable_description": "Aktiver kartfunksjoner", "map_gps_settings": "Kart & GPS Innstillinger", + "map_gps_settings_description": "Administrer innstillinger for kart og GPS (Reversert geokoding)", "map_light_style": "Lys stil", "map_reverse_geocoding": "Omvendt geokoding", "map_reverse_geocoding_enable_description": "Aktiver omvendt geokoding", @@ -319,6 +320,7 @@ "user_settings_description": "Administrer brukerinnstillinger", "user_successfully_removed": "Brukeren {email} er nå fjernet.", "version_check_enabled_description": "Aktiver periodiske forespørsler til GitHub for å sjekke etter nye utgivelser", + "version_check_implications": "Versjonssjekkfunksjonen baserer seg på periodisk kommunikasjon med github.com", "version_check_settings": "Versjonssjekk", "version_check_settings_description": "Aktiver/deaktiver varsel om ny versjon", "video_conversion_job": "Transkod videoer", @@ -334,6 +336,7 @@ "album_added_notification_setting_description": "Motta en e-postvarsling når du legges til i et delt album", "album_cover_updated": "Albumomslag oppdatert", "album_delete_confirmation": "Er du sikker på at du vil slette albumet {album}?\nHvis dette albumet er delt, vil ikke andre brukere ha tilgang til det lenger.", + "album_delete_confirmation_description": "Hvis dette albumet deles, vil andre brukere miste tilgangen til dette.", "album_info_updated": "Albuminformasjon oppdatert", "album_leave": "Forlate album?", "album_leave_confirmation": "Er du sikker på at du vil forlate {album}?", @@ -357,6 +360,7 @@ "allow_edits": "Tillat redigering", "allow_public_user_to_download": "Tillat uautentiserte brukere å laste ned", "allow_public_user_to_upload": "Tillat uautentiserte brukere å laste opp", + "anti_clockwise": "Mot klokken", "api_key": "API Nøkkel", "api_key_description": "Denne verdien vil vises kun én gang. Pass på å kopiere den før du lukker vinduet.", "api_key_empty": "API Key-navnet bør ikke være tomt", diff --git a/web/src/lib/i18n/nl.json b/web/src/lib/i18n/nl.json index 36f9886b04..d448f1144f 100644 --- a/web/src/lib/i18n/nl.json +++ b/web/src/lib/i18n/nl.json @@ -338,7 +338,8 @@ "album_added": "Album toegevoegd", "album_added_notification_setting_description": "Ontvang een e-mailmelding wanneer je aan een gedeeld album wordt toegevoegd", "album_cover_updated": "Album cover is bijgewerkt", - "album_delete_confirmation": "Weet je zeker dat je het album {album} wilt verwijderen?\nAls dit album gedeeld is, hebben andere gebruikers er geen toegang meer toe.", + "album_delete_confirmation": "Weet je zeker dat je het album {album} wilt verwijderen?", + "album_delete_confirmation_description": "Als dit album gedeeld is, hebben andere gebruikers er geen toegang meer toe.", "album_info_updated": "Albumgegevens bijgewerkt", "album_leave": "Album verlaten?", "album_leave_confirmation": "Weet je zeker dat je {album} wilt verlaten?", @@ -362,6 +363,7 @@ "allow_edits": "Bewerkingen toestaan", "allow_public_user_to_download": "Sta openbare gebruiker toe om te downloaden", "allow_public_user_to_upload": "Sta openbare gebruiker toe om te uploaden", + "anti_clockwise": "Linksom", "api_key": "API sleutel", "api_key_description": "Deze waarde wordt slechts één keer getoond. Zorg ervoor dat je deze kopieert voordat je het venster sluit.", "api_key_empty": "De naam van uw API sleutel mag niet leeg zijn", @@ -443,6 +445,7 @@ "clear_all_recent_searches": "Wis alle recente zoekopdrachten", "clear_message": "Bericht wissen", "clear_value": "Waarde wissen", + "clockwise": "Rechtsom", "close": "Sluiten", "collapse": "Inklappen", "collapse_all": "Alles inklappen", @@ -519,6 +522,8 @@ "do_not_show_again": "Laat dit bericht niet meer zien", "done": "Klaar", "download": "Downloaden", + "download_include_embedded_motion_videos": "Ingesloten video's", + "download_include_embedded_motion_videos_description": "Voeg video's toe die ingesloten zijn in bewegende foto's als een apart bestand", "download_settings": "Downloaden", "download_settings_description": "Beheer instellingen voor het downloaden van assets", "downloading": "Downloaden", @@ -552,6 +557,10 @@ "edit_user": "Gebruiker bewerken", "edited": "Bijgewerkt", "editor": "Bewerker", + "editor_close_without_save_prompt": "De wijzigingen worden niet opgeslagen", + "editor_close_without_save_title": "Editor sluiten?", + "editor_crop_tool_h2_aspect_ratios": "Beeldverhoudingen", + "editor_crop_tool_h2_rotation": "Rotatie", "email": "E-mailadres", "empty": "", "empty_album": "Leeg album", @@ -701,6 +710,7 @@ "expired": "Verlopen", "expires_date": "Verloopt {date}", "explore": "Verkennen", + "explorer": "Verkenner", "export": "Exporteren", "export_as_json": "Exporteren als JSON", "extension": "Extensie", @@ -722,6 +732,7 @@ "filter_people": "Filter op mensen", "find_them_fast": "Vind ze snel op naam door te zoeken", "fix_incorrect_match": "Onjuiste overeenkomst corrigeren", + "folders": "Mappen", "force_re-scan_library_files": "Forceer herscan van alle bibliotheekbestanden", "forward": "Vooruit", "general": "Algemeen", @@ -923,7 +934,7 @@ "only_favorites": "Alleen favorieten", "only_refreshes_modified_files": "Vernieuwt alleen gewijzigde bestanden", "open_in_map_view": "Openen in kaartweergave", - "open_in_openstreetmap": "Openen met OpenStreetMap", + "open_in_openstreetmap": "Openen in OpenStreetMap", "open_the_search_filters": "Open de zoekfilters", "options": "Opties", "or": "of", @@ -1028,6 +1039,8 @@ "purchase_settings_server_activated": "De productcode van de server wordt beheerd door de beheerder", "range": "", "rating": "Ster waardering", + "rating_clear": "Waardering verwijderen", + "rating_count": "{count, plural, one {# ster} other {# sterren}}", "rating_description": "De exif-waardering weergeven in het infopaneel", "raw": "", "reaction_options": "Reactie opties", @@ -1227,7 +1240,7 @@ "to_login": "Inloggen", "to_trash": "Prullenbak", "toggle_settings": "Zichtbaarheid instellingen wisselen", - "toggle_theme": "Thema wisselen", + "toggle_theme": "Donker thema toepassen", "toggle_visibility": "Zichtbaarheid wisselen", "total_usage": "Totaal gebruik", "trash": "Prullenbak", @@ -1249,6 +1262,7 @@ "unlink_oauth": "Ontkoppel OAuth", "unlinked_oauth_account": "OAuth account ontkoppeld", "unnamed_album": "Naamloos album", + "unnamed_album_delete_confirmation": "Weet je zeker dat je dit album wilt verwijderen?", "unnamed_share": "Naamloze deellink", "unsaved_change": "Niet-opgeslagen wijziging", "unselect_all": "Alles deselecteren", diff --git a/web/src/lib/i18n/pl.json b/web/src/lib/i18n/pl.json index 682f6fcb55..267afd0141 100644 --- a/web/src/lib/i18n/pl.json +++ b/web/src/lib/i18n/pl.json @@ -57,7 +57,7 @@ "image_format_description": "Użycie formatu WebP skutkuje utworzeniem plików o rozmiarze mniejszym niż w przypadku JPEG ale jego kodowanie trwa dłużej.", "image_prefer_embedded_preview": "Preferuj podgląd wbudowany", "image_prefer_embedded_preview_setting_description": "Jeśli to możliwe, używaj osadzonych podglądów w zdjęciach RAW jako danych wejściowych do przetwarzania obrazu. Może to zapewnić dokładniejsze kolory w przypadku niektórych obrazów, ale jakość podglądu zależy od aparatu, a obraz może zawierać więcej artefaktów kompresji.", - "image_prefer_wide_gamut": "Preferuj szeroką przestrzeń barw", + "image_prefer_wide_gamut": "Preferuj szeroką gamę kolorów", "image_prefer_wide_gamut_setting_description": "Do wyświetlania miniatur użyj wyświetlacza P3. Dzięki temu lepiej zachowuje się intensywność obrazów o dużej ilości kolorów, ale obrazy mogą wyglądać inaczej na starych urządzeniach ze starą wersją przeglądarki. Obrazy sRGB są zachowywane jako sRGB, aby uniknąć przesunięć kolorów.", "image_preview_format": "Format podglądu", "image_preview_resolution": "Rozdzielczość podglądu", @@ -129,6 +129,7 @@ "map_enable_description": "Włącz funkcję mapy", "map_gps_settings": "Mapa i ustawienia lokalizacji", "map_gps_settings_description": "Zarządzaj mapą oraz ustawieniami odwróconego geokodowania", + "map_implications": "Funkcja mapy opiera się na zewnętrznej usłudze kafelków (tiles.immich.cloud)", "map_light_style": "Styl jasny", "map_manage_reverse_geocoding_settings": "Zarządzaj Ustawieniem Odwrotne Geokodowanie", "map_reverse_geocoding": "Odwrotne Geokodowanie", @@ -320,7 +321,8 @@ "user_settings": "Ustawienia Użytkownika", "user_settings_description": "Zarządzaj ustawieniami użytkownika", "user_successfully_removed": "Użytkownik {email} został usunięty pomyślnie.", - "version_check_enabled_description": "Włącz cykliczne sprawdzanie nowych wersji na GitHubie", + "version_check_enabled_description": "Włącz sprawdzanie wersji", + "version_check_implications": "Funkcja sprawdzania wersji opiera się na okresowej komunikacji z github.com", "version_check_settings": "Sprawdzenie Wersji", "version_check_settings_description": "Włącz/wyłącz powiadomienie o nowej wersji", "video_conversion_job": "Transkodowanie wideo", @@ -336,7 +338,8 @@ "album_added": "Album udostępniony", "album_added_notification_setting_description": "Otrzymaj powiadomienie email, gdy zostanie Ci udostępniony album", "album_cover_updated": "Okładka albumu została zaktualizowana", - "album_delete_confirmation": "Na pewno chcesz usunąć album {album}?\nJeśli został udostępniony, inni użytkownicy nie będą w stanie go obejrzeć.", + "album_delete_confirmation": "Czy na pewno chcesz usunąć album {album}?", + "album_delete_confirmation_description": "Jeżeli album jest udostępniany, inny stracą do niego dostęp.", "album_info_updated": "Szczegóły albumu zostały zaktualizowane", "album_leave": "Opuścić album?", "album_leave_confirmation": "Na pewno chcesz opuścić {album}?", @@ -360,6 +363,7 @@ "allow_edits": "Pozwól edytować", "allow_public_user_to_download": "Zezwól użytkownikowi publicznemu na pobieranie", "allow_public_user_to_upload": "Zezwól użytkownikowi publicznemu na przesyłanie plików", + "anti_clockwise": "Przeciwnie do ruchu wskazówek zegara", "api_key": "Klucz API", "api_key_description": "Widzisz tę wartość po raz pierwszy i ostatni, więc lepiej ją skopiuj przed zamknięciem okna.", "api_key_empty": "Twój Klucz API nie powinien być pusty", @@ -368,8 +372,8 @@ "appears_in": "W albumach", "archive": "Archiwum", "archive_or_unarchive_photo": "Dodaj lub usuń zasób z archiwum", - "archive_size": "Maksymalny Rozmiar Archiwum", - "archive_size_description": "Podziel pobierane pliki na więcej niż jedno archiwum, jeżeli rozmiar archiwum przekroczy tą wartość w GiB", + "archive_size": "Rozmiar archiwum", + "archive_size_description": "Podziel pobierane pliki na więcej niż jedno archiwum, jeżeli rozmiar archiwum przekroczy tę wartość w GiB", "archived": "Zarchiwizowano", "archived_count": "{count, plural, other {Zarchiwizowano #}}", "are_these_the_same_person": "Czy to jedna i ta sama osoba?", @@ -405,7 +409,7 @@ "birthdate_saved": "Data urodzenia zapisana pomyślnie", "birthdate_set_description": "Data urodzenia jest używana do obliczenia wieku danej osoby podczas wykonania zdjęcia.", "blurred_background": "Rozmyte tło", - "build": "Build", + "build": "Kompilacja", "build_image": "Obraz Buildu", "bulk_delete_duplicates_confirmation": "Czy na pewno chcesz trwale usunąć {count, plural, one {# zduplikowany zasób} few {# zduplikowane zasoby} many {# zduplikowanych zasobów} other {# zduplikowanych zasobów}}? Zostanie zachowany największy zasób z każdej grupy, a wszystkie pozostałe duplikaty zostaną trwale usunięte. Nie można cofnąć tej operacji!", "bulk_keep_duplicates_confirmation": "Czy na pewno chcesz zachować {count, plural, one {# zduplikowany zasób} few {# zduplikowane zasoby} many {# zduplikowanych zasobów} other {# zduplikowanych zasobów}}? To spowoduje rozwiązanie wszystkich grup duplikatów bez usuwania czegokolwiek.", @@ -441,6 +445,7 @@ "clear_all_recent_searches": "Usuń ostatnio wyszukiwane", "clear_message": "Zamknij wiadomość", "clear_value": "Wyczyść wartość", + "clockwise": "Zgodnie z ruchem wskazówek zegara", "close": "Zamknij", "collapse": "Zwiń", "collapse_all": "Zwiń wszystko", @@ -517,6 +522,8 @@ "do_not_show_again": "Nie pokazuj więcej tej wiadomości", "done": "Gotowe", "download": "Pobierz", + "download_include_embedded_motion_videos": "Osadzone filmy", + "download_include_embedded_motion_videos_description": "Dołącz filmy osadzone w ruchomych zdjęciach jako oddzielny plik", "download_settings": "Pobieranie", "download_settings_description": "Zarządzaj pobieraniem zasobów", "downloading": "Pobieranie", @@ -550,6 +557,10 @@ "edit_user": "Edytuj użytkownika", "edited": "Edytowane", "editor": "Edytor", + "editor_close_without_save_prompt": "Zmiany nie zostaną zapisane", + "editor_close_without_save_title": "Zamknąć edytor?", + "editor_crop_tool_h2_aspect_ratios": "Proporcje obrazu", + "editor_crop_tool_h2_rotation": "Obrót", "email": "E-mail", "empty": "", "empty_album": "Pusty Album", @@ -692,7 +703,7 @@ "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", - "exif": "Exif", + "exif": "Metadane EXIF", "exit_slideshow": "Zamknij Pokaz Slajdów", "expand_all": "Rozwiń wszystko", "expire_after": "Wygasa po", @@ -720,6 +731,7 @@ "filter_people": "Szukaj osoby", "find_them_fast": "Wyszukuj szybciej przypisując nazwę", "fix_incorrect_match": "Napraw nieprawidłowe dopasowanie", + "folders": "Foldery", "force_re-scan_library_files": "Wymuś ponowne przeskanowanie wszystkich plików biblioteki", "forward": "Do przodu", "general": "Ogólne", @@ -887,6 +899,7 @@ "ok": "Ok", "oldest_first": "Od najstarszych", "onboarding": "Wdrożenie", + "onboarding_privacy_description": "Śledzenie (opcjonalne) funkcja opiera się na zewnętrznych usługach i może zostać wyłączona w dowolnym momencie w ustawieniach administracyjnych.", "onboarding_theme_description": "Wybierz motyw kolorystyczny dla twojej instancji. Możesz go później zmienić w ustawieniach.", "onboarding_welcome_description": "Przejdźmy do konfiguracji twojej instancji, ustawiając kilka powszechnych opcji.", "onboarding_welcome_user": "Witaj, {user}", @@ -959,6 +972,7 @@ "previous_memory": "Poprzednie wspomnienie", "previous_or_next_photo": "Poprzednie lub następne zdjęcie", "primary": "Główny", + "privacy": "Prywatność", "profile_image_of_user": "Zdjęcie profilowe {user}", "profile_picture_set": "Zdjęcie profilowe ustawione.", "public_album": "Publiczny album", @@ -996,6 +1010,9 @@ "purchase_server_title": "Serwer", "purchase_settings_server_activated": "Klucz produktu serwera jest zarządzany przez administratora", "range": "", + "rating": "Ocena gwiazdkowa", + "rating_count": "{count, plural, one {# star} other {# stars}}", + "rating_description": "Wyświetl ocenę EXIF w panelu informacji", "raw": "", "reaction_options": "Opcje reakcji", "read_changelog": "Zobacz Zmiany", @@ -1118,6 +1135,7 @@ "shared_by_user": "Udostępnione przez {user}", "shared_by_you": "Udostępnione przez ciebie", "shared_from_partner": "Zdjęcia od {partner}", + "shared_link_options": "Opcje udostępniania linku", "shared_links": "Udostępnione linki", "shared_photos_and_videos_count": "{assetCount, plural, other {# udostępnione zdjęcia i filmy.}}", "shared_with_partner": "Dzielisz się z {partner}", @@ -1163,7 +1181,7 @@ "stack_select_one_photo": "Wybierz jedno główne zdjęcie do stosu", "stack_selected_photos": "Układaj wybrane zdjęcia", "stacked_assets_count": "Ułożone {count, plural, one {# zasób} other{# zasoby}}", - "stacktrace": "Stacktrace", + "stacktrace": "Ślad stosu", "start": "Start", "start_date": "Od dnia", "state": "Stan", @@ -1193,7 +1211,7 @@ "to_login": "Login", "to_trash": "Kosz", "toggle_settings": "Przełącz ustawienia", - "toggle_theme": "Przełącz motyw", + "toggle_theme": "Przełącz ciemny motyw", "toggle_visibility": "Zmień widoczność", "total_usage": "Całkowite wykorzystanie", "trash": "Kosz", @@ -1215,6 +1233,7 @@ "unlink_oauth": "Odłącz OAuth", "unlinked_oauth_account": "Odłączone konto OAuth", "unnamed_album": "Nienazwany album", + "unnamed_album_delete_confirmation": "Czy jesteś pewna/pewien, że chcesz usunąć te album?", "unnamed_share": "Nienazwany udział", "unsaved_change": "Niezapisana zmiana", "unselect_all": "Odznacz wszystko", diff --git a/web/src/lib/i18n/pt.json b/web/src/lib/i18n/pt.json index b146e2ee2f..943cde377d 100644 --- a/web/src/lib/i18n/pt.json +++ b/web/src/lib/i18n/pt.json @@ -129,12 +129,13 @@ "map_enable_description": "Ativar recursos do mapa", "map_gps_settings": "Mapas e Definições de GPS", "map_gps_settings_description": "Configurações de mapas e GPS (Geocoding inverso)", + "map_implications": "A funcionalidade do mapa necessita um servico externo (tiles.immich.cloud)", "map_light_style": "Tema Claro", "map_manage_reverse_geocoding_settings": "Gerir definições de Geocoding inverso", "map_reverse_geocoding": "Geocodificação reversa", "map_reverse_geocoding_enable_description": "Ativar geocodificação reversa", "map_reverse_geocoding_settings": "Configurações de geocodificação reversa", - "map_settings": "Configurações de mapas e GPS", + "map_settings": "Mapa", "map_settings_description": "Gerenciar configurações do mapa", "map_style_description": "URL para um tema de mapa style.json", "metadata_extraction_job": "Extrair metadados", @@ -217,6 +218,7 @@ "sidecar_job_description": "Descubra ou sincronize metadados secundários do sistema de arquivos", "slideshow_duration_description": "Tempo em segundos para exibir cada imagem", "smart_search_job_description": "Execute a aprendizagem automática em arquivos para oferecer suporte à pesquisa inteligente", + "storage_template_date_time_description": "O registro de data e hora da criação é usado para fornecer essas informações", "storage_template_date_time_sample": "Exemplo de tempo {date}", "storage_template_enable_description": "Habilitar mecanismo de modelo de armazenamento", "storage_template_hash_verification_enabled": "Verificação de hash ativada", @@ -315,10 +317,12 @@ "user_password_has_been_reset": "A senha do utilizador foi redefinida:", "user_password_reset_description": "Forneça a senha temporária ao utilizador e informe que ele precisará alterar a senha no próximo início de sessão.", "user_restore_description": "A conta de {user} será restaurada.", + "user_restore_scheduled_removal": "Restaurar usuário - planejar remoção em {date, date, long}", "user_settings": "Configurações do Utilizador", "user_settings_description": "Gerenciar configurações do utilizador", "user_successfully_removed": "O utilizador {email} foi removido com sucesso.", - "version_check_enabled_description": "Ativa verificações periódicas no GitHub para novas versões", + "version_check_enabled_description": "Ativa verificação de novas versões", + "version_check_implications": "A funcionalidade de verificação da versão necessita comunicação periodica com github.com", "version_check_settings": "Verificação de versão", "version_check_settings_description": "Ativar/desativar a notificação de nova versão", "video_conversion_job": "Transcodificar vídeos", @@ -334,7 +338,8 @@ "album_added": "Álbum adicionado", "album_added_notification_setting_description": "Receba uma notificação por e-mail quando você for adicionado a um álbum compartilhado", "album_cover_updated": "Capa do álbum atualizada", - "album_delete_confirmation": "De certeza que quer apagar o álbum {album}?\nSe o álbum for partilhado, os outros utilizadores não poderão acessá-lo novamente.", + "album_delete_confirmation": "Tem a certeza que quer apagar o álbum {album}? Se o álbum for partilhado, os outros utilizadores não poderão aceder-lhe novamente.", + "album_delete_confirmation_description": "Se este álbum for partilhado, os outros utilizadores deixam de poder aceder.", "album_info_updated": "Informações do álbum atualizadas", "album_leave": "Sair do álbum?", "album_leave_confirmation": "Tem a certeza que quer sair de {album}?", @@ -347,6 +352,7 @@ "album_updated_setting_description": "Receba uma notificação por e-mail quando um álbum compartilhado tiver novos arquivos", "album_user_left": "Saída {album}", "album_user_removed": "Utilizador {user} removido", + "album_with_link_access": "Permite acesso a fotos e pessoas deste album por qualquer pessoa com o link.", "albums": "Álbuns", "albums_count": "{count, plural, one {{count, number} Álbum} other {{count, number} Álbuns}}", "all": "Todos", @@ -355,23 +361,34 @@ "all_videos": "Todos os vídeos", "allow_dark_mode": "Permitir modo escuro", "allow_edits": "Permitir edições", + "allow_public_user_to_download": "Permit acesso de download ao user publico", + "allow_public_user_to_upload": "Permite acesso de upload ao user publico", + "anti_clockwise": "Sentido anti-horário", "api_key": "Chave de API", "api_key_description": "Este valor será apresentado apenas uma única vez. Por favor, certifique-se que o copiou antes de fechar a janela.", + "api_key_empty": "O nome da API Key não pode ser vazio", "api_keys": "Chaves de API", "app_settings": "Configurações do Aplicativo", "appears_in": "Aparece em", - "archive": "Arquivados", + "archive": "Arquivo", "archive_or_unarchive_photo": "Arquivar ou desarquivar foto", - "archive_size": "Tamanho do Arquivo", + "archive_size": "Tamanho do arquivo", "archive_size_description": "Configure o tamanho do arquivo para downloads (em GiB)", "archived": "Arquivado", + "archived_count": "{count, plural, other {Arquivado #}}", "are_these_the_same_person": "São a mesma pessoa?", + "are_you_sure_to_do_this": "Tem a certeza que quer fazer isto?", "asset_added_to_album": "Adicionado ao álbum", "asset_adding_to_album": "A adicionar ao álbum...", "asset_description_updated": "A descrição do arquivo foi atualizada", "asset_filename_is_offline": "O arquivo {filename} está offline", "asset_has_unassigned_faces": "O arquivo tem rostos sem atribuição", + "asset_hashing": "Hashing...", "asset_offline": "Ativo off-line", + "asset_offline_description": "Este arquivo está offline. Immich não consegue acessar o local do arquivo. Certifique-se de que o arquivo esteja disponível e, em seguida, escaneie a biblioteca novamente.", + "asset_skipped": "Ignorado", + "asset_uploaded": "Enviado", + "asset_uploading": "Em upload...", "assets": "Arquivos", "assets_added_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}}", "assets_added_to_album_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}} ao álbum", @@ -381,14 +398,19 @@ "assets_moved_to_trash_count": "{count, plural, one {# arquivo movido} other {# arquivos movidos}} para a lixeira", "assets_permanently_deleted_count": "{count, plural, one {# arquivo} other {# arquivos}} excluídos permanentemente", "assets_removed_count": "{count, plural, one {# arquivo excluído} other {# arquivos excluídos}}", + "assets_restore_confirmation": "Tem a certeza que quer recuperar todos os artigos apagados? Não é possivel voltar atrás nesta acção!", "assets_restored_count": "{count, plural, one {# arquivo restaurado} other {# arquivos restaurados}}", "assets_trashed_count": "{count, plural, one {# arquivo enviado} other {# arquivos enviados}} para a lixeira", + "assets_were_part_of_album_count": "{count, plural, one {Arquivo já era} other {Os arquivos já eram}} parte do álbum", "authorized_devices": "Dispositivos Autorizados", "back": "Voltar", + "back_close_deselect": "Voltar, fechar ou desmarcar", "backward": "Para trás", "birthdate_saved": "Data de nascimento guardada com sucesso", "birthdate_set_description": "A data de nascimento é usada para calcular a idade desta pessoa no momento em que uma fotografia foi tirada.", "blurred_background": "Fundo desfocado", + "build": "Construir", + "build_image": "Construir Imagem", "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja excluir {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Esta ação mantém o maior arquivo de cada grupo e exclui permanentemente todas as outras duplicidades. Você não pode desfazer esta ação!", "bulk_keep_duplicates_confirmation": "Tem certeza de que deseja manter {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso resolverá todos os grupos duplicados sem excluir nada.", "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a lixeira {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso manterá o maior arquivo de cada grupo e moverá para a lixeira todas as outras duplicidades.", @@ -399,6 +421,7 @@ "cancel": "Cancelar", "cancel_search": "Cancelar pesquisa", "cannot_merge_people": "Não é possível mesclar pessoas", + "cannot_undo_this_action": "Não pode voltar atrás nesta ação!", "cannot_update_the_description": "Não é possível atualizar a descrição", "cant_apply_changes": "Não é possível aplicar alterações", "cant_get_faces": "Não foi possível obter faces", @@ -410,6 +433,7 @@ "change_name": "Alterar nome", "change_name_successfully": "Nome alterado com sucesso", "change_password": "Mudar a senha", + "change_password_description": "Esta é a primeira vez que você está entrando no sistema ou uma solicitação foi feita para alterar sua senha. Insira a nova senha abaixo.", "change_your_password": "Alterar sua senha", "changed_visibility_successfully": "Visibilidade alterada com sucesso", "check_all": "Verificar tudo", @@ -421,12 +445,14 @@ "clear_all_recent_searches": "Limpar todas as pesquisas recentes", "clear_message": "Limpar mensagem", "clear_value": "Limpar valor", + "clockwise": "Sentido horário", "close": "Fechar", "collapse": "Colapsar", "collapse_all": "Colapsar tudo", "color_theme": "Tema de cores", "comment_deleted": "Comentário eliminado", "comment_options": "Opções de comentário", + "comments_and_likes": "Comentários e gostos", "comments_are_disabled": "Comentários estão desativados", "confirm": "Confirmar", "confirm_admin_password": "Confirmar senha de administrador", @@ -452,7 +478,9 @@ "create_library": "Criar biblioteca", "create_link": "Criar link", "create_link_to_share": "Criar link para partilhar", + "create_link_to_share_description": "Permiter a visualização desta imagem(s) a qualquer pessoa com este link", "create_new_person": "Criar nova pessoa", + "create_new_person_hint": "Associe os arquivos para uma nova pessoa", "create_new_user": "Criar novo utilizador", "create_user": "Criar utilizador", "created": "Criado", @@ -494,10 +522,13 @@ "do_not_show_again": "Não mostrar esta mensagem novamente", "done": "Feito", "download": "Transferir", + "download_include_embedded_motion_videos": "Vídeos incorporados", + "download_include_embedded_motion_videos_description": "Incluir vídeos incorporados em fotos em movimento como um arquivo separado", "download_settings": "Transferir", "download_settings_description": "Gerenciar configurações relacionadas a transferir ativos", "downloading": "Baixando", "downloading_asset_filename": "A transferir o arquivo {filename}", + "drop_files_to_upload": "Coloque os ficheiros em qualquer lugar para fazer o upload", "duplicates": "Duplicados", "duplicates_description": "Marque cada grupo indicando quais arquivos, se algum, são duplicados", "duration": "Duração", @@ -526,10 +557,15 @@ "edit_user": "Editar utilizador", "edited": "Editado", "editor": "Editar", + "editor_close_without_save_prompt": "As alterações não serão salvas", + "editor_close_without_save_title": "Fechar editor?", + "editor_crop_tool_h2_aspect_ratios": "Proporções de aspecto", + "editor_crop_tool_h2_rotation": "Rotação", "email": "E-mail", "empty": "", "empty_album": "", "empty_trash": "Esvaziar lixo", + "empty_trash_confirmation": "Tem certeza de que deseja esvaziar a lixeira? Isso removerá todos os arquivos da lixeira do Immich permanentemente.\nVocê não pode desfazer esta ação!", "enable": "Ativar", "enabled": "Ativado", "end_date": "Data final", @@ -537,7 +573,11 @@ "error_loading_image": "Erro ao carregar a página", "error_title": "Erro - Algo correu mal", "errors": { + "cannot_navigate_next_asset": "Não pode navegar para o proximo artigo", + "cannot_navigate_previous_asset": "Não pode navegar para o artigo anterior", "cant_apply_changes": "Não foi possível aplicar as alterações", + "cant_change_activity": "Não é possível {enabled, select, true {desativar} other {ativar}} atividade", + "cant_change_asset_favorite": "Não pode alterar o favorito deste artigo", "cant_change_metadata_assets_count": "Não foi possível alterar os metadados de {count, plural, one {# arquivo} other {# arquivos}}", "cant_get_faces": "Não foi possível obter os rostos", "cant_get_number_of_comments": "Não foi possível obter o número de comentários", @@ -545,34 +585,49 @@ "cant_search_places": "Não foi possível pesquisar locais", "cleared_jobs": "Trabalhos eliminados para: {job}", "error_adding_assets_to_album": "Erro ao adicionar arquivos ao álbum", + "error_adding_users_to_album": "Erro a adicionar utilizador ao album", + "error_deleting_shared_user": "Error a apagar o utilizador partilhado", "error_downloading": "Erro a transferir {filename}", "error_hiding_buy_button": "Erro ao esconder botão de compra", + "error_removing_assets_from_album": "Erro a eliminar artigos do album, verifique a consola para mais detalhes", "error_selecting_all_assets": "Erro ao selecionar todos os arquivos", "exclusion_pattern_already_exists": "Este padrão de exclusão já existe.", "failed_job_command": "Comando {command} falhou para o trabalho: {job}", "failed_to_create_album": "Falha ao criar álbum", + "failed_to_create_shared_link": "Falhou a criar um link partilhado", + "failed_to_edit_shared_link": "Falhou a editar o link partilhado", "failed_to_get_people": "Falha na obtenção de pessoas", "failed_to_load_asset": "Falha ao carregar arquivo", "failed_to_load_assets": "Falha ao carregar arquivos", "failed_to_load_people": "Falha ao carregar pessoas", "failed_to_remove_product_key": "Falha ao remover chave de produto", + "failed_to_stack_assets": "Falha ao empilhar os arquivos", + "failed_to_unstack_assets": "Falha ao desempilhar arquivos", "import_path_already_exists": "Este caminho de importação já existe.", + "incorrect_email_or_password": "Email ou password incorretos", "paths_validation_failed": "a validação de {paths, plural, one {# caminho falhou} other {# caminhos falharam}}", + "profile_picture_transparent_pixels": "Imagem de perfil não pode ter pixels transparentes. Por favor faça zoom in e/ou mova a imagem.", "quota_higher_than_disk_size": "Você definiu uma cota maior do que o tamanho do disco", "repair_unable_to_check_items": "Não foi possível verificar {count, select, one {um item} other {alguns itens}}", "unable_to_add_album_users": "Não foi possível adicionar utilizadores ao álbum", + "unable_to_add_assets_to_shared_link": "Não foi possivel adicionar os artigos ao link partilhado", "unable_to_add_comment": "Não foi possível adicionar o comentário", "unable_to_add_exclusion_pattern": "Não foi possível adicionar o padrão de exclusão", "unable_to_add_import_path": "Não foi possível adicionar o caminho de importação", "unable_to_add_partners": "Não foi possível adicionar parceiros", + "unable_to_add_remove_archive": "Não é possível {archived, select, true {remover o arquivo de} other {adicionar o arquivo}}", "unable_to_add_remove_favorites": "Não foi possível {favorite, select, true {adicionar arquivo aos} other {remover arquivo dos}} favoritos", + "unable_to_archive_unarchive": "Não é possível {archived, select, true {arquivar} other {desarquivar}}", "unable_to_change_album_user_role": "Não foi possível alterar a permissão do utilizador no álbum", "unable_to_change_date": "Não foi possível alterar a data", + "unable_to_change_favorite": "Não foi possivel mudar o favorito do artigo", "unable_to_change_location": "Não foi possível alterar a localização", "unable_to_change_password": "Não foi possível alterar a senha", + "unable_to_change_visibility": "Não é possível alterar a visibilidade de {count, plural, one {# pessoa} other {# pessoas}}", "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_complete_oauth_login": "Não foi possível completar início de sessão com OAuth", + "unable_to_connect": "Não é possível conectar", "unable_to_connect_to_server": "Não foi possível ligar ao servidor", "unable_to_copy_to_clipboard": "Não é possível copiar para a área de transferência, certifique-se que está acessando a pagina através de https", "unable_to_create_admin_account": "Não foi possível criar conta de administrador", @@ -593,6 +648,7 @@ "unable_to_enter_fullscreen": "Não foi possível entrar em modo de tela cheia", "unable_to_exit_fullscreen": "Não foi possível sair do modo de tela cheia", "unable_to_get_comments_number": "Não foi possível obter número de comentários", + "unable_to_get_shared_link": "Falha ao obter link compartilhado", "unable_to_hide_person": "Não foi possível esconder a pessoa", "unable_to_link_oauth_account": "Não foi possível associar a conta OAuth", "unable_to_load_album": "Não foi possível carregar o álbum", @@ -603,9 +659,12 @@ "unable_to_log_out_device": "Não foi possível terminar a sessão no dispositivo", "unable_to_login_with_oauth": "Não foi possível iniciar sessão com OAuth", "unable_to_play_video": "Não foi possível reproduzir o vídeo", + "unable_to_reassign_assets_existing_person": "Não é possível reatribuir arquivos para {name, select, null {uma pessoa existente} other {{name}}}", + "unable_to_reassign_assets_new_person": "Não é possível reatribuir os arquivos a uma nova pessoa", "unable_to_refresh_user": "Não foi possível atualizar o utilizador", "unable_to_remove_album_users": "Não foi possível remover utilizador do álbum", "unable_to_remove_api_key": "Não foi possível a Chave de API", + "unable_to_remove_assets_from_shared_link": "Não é possível remover os arquivos do link compartilhado", "unable_to_remove_comment": "", "unable_to_remove_library": "Não foi possível remover a biblioteca", "unable_to_remove_offline_files": "Não foi possível remover arquivos offline", @@ -626,6 +685,7 @@ "unable_to_save_settings": "Não foi possível salvar as configurações", "unable_to_scan_libraries": "Não foi possível escanear as bibliotecas", "unable_to_scan_library": "Não foi possível escanear a biblioteca", + "unable_to_set_feature_photo": "Não é possível definir a foto do recurso", "unable_to_set_profile_picture": "Não foi possível definir a foto de perfil", "unable_to_submit_job": "Não foi possível enviar o trabalho", "unable_to_trash_asset": "Não foi possível enviar o ativo para a lixeira", @@ -650,6 +710,7 @@ "expired": "Expirou", "expires_date": "Expira em {date}", "explore": "Explorar", + "explorer": "Explorador", "export": "Exportar", "export_as_json": "Exportar como JSON", "extension": "Extensão", @@ -671,6 +732,7 @@ "filter_people": "Filtrar pessoas", "find_them_fast": "Encontre pelo nome em uma pesquisa", "fix_incorrect_match": "Corrigir correspondência incorreta", + "folders": "Pastas", "force_re-scan_library_files": "Força escanear novamente todos os arquivos da biblioteca", "forward": "Para frente", "general": "Geral", @@ -724,6 +786,7 @@ }, "invite_people": "Convidar Pessoas", "invite_to_album": "Convidar para o álbum", + "items_count": "{count, plural, one {item #} other {itens #}}", "job_settings_description": "", "jobs": "Trabalhos", "keep": "Manter", @@ -740,6 +803,7 @@ "library": "Biblioteca", "library_options": "Opções da biblioteca", "light": "Claro", + "like_deleted": "Curtida removida", "link_options": "Opções do Link", "link_to_oauth": "Link do OAuth", "linked_oauth_account": "Conta OAuth Vinculada", @@ -752,6 +816,8 @@ "logged_out_device": "Sessão terminada no dispositivo", "login": "Iniciar sessão", "login_has_been_disabled": "Login foi desativado.", + "logout_all_device_confirmation": "Tem certeza de que deseja desconectar todos os dispositivos?", + "logout_this_device_confirmation": "Tem certeza de que deseja sair deste dispositivo?", "longitude": "Longitude", "look": "Estilo", "loop_videos": "Repetir vídeos", @@ -773,12 +839,14 @@ "memories": "Memórias", "memories_setting_description": "Gerencie o que vê em suas memórias", "memory": "Memória", + "memory_lane_title": "Memórias {title}", "menu": "Menu", "merge": "Mesclar", "merge_people": "Mesclar pessoas", "merge_people_limit": "Só é possível mesclar até 5 faces de uma só vez", "merge_people_prompt": "Tem certeza que deseja mesclar estas pessoas? Esta ação é irreversível.", "merge_people_successfully": "Pessoas mescladas com sucesso", + "merged_people_count": "Mesclada {count, plural, one {1 pessoa} other {# pessoas}}", "minimize": "Minimizar", "minute": "Minuto", "missing": "Faltando", @@ -801,6 +869,8 @@ "next_memory": "Próxima memória", "no": "Não", "no_albums_message": "Crie um álbum para organizar suas fotos e vídeos", + "no_albums_with_name_yet": "Parece que você ainda não tem nenhum álbum com este nome.", + "no_albums_yet": "Parece que você ainda não tem nenhum álbum.", "no_archived_assets_message": "Arquive fotos e vídeos para os ocultar da sua visualização de fotos", "no_assets_message": "CLIQUE PARA CARREGAR SUA PRIMEIRA FOTO", "no_duplicates_found": "Nenhuma duplicidade foi encontrada.", @@ -811,6 +881,7 @@ "no_name": "Sem nome", "no_places": "Sem lugares", "no_results": "Sem resultados", + "no_results_description": "Tente um sinônimo ou uma palavra-chave mais comum", "no_shared_albums_message": "Crie um álbum para compartilhar fotos e vídeos com pessoas em sua rede", "not_in_any_album": "Fora de álbum", "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar o rótulo de armazenamento a arquivos carregados anteriormente, execute o", @@ -825,10 +896,15 @@ "offline_paths_description": "Estes resultados podem ser devidos a arquivos deletados manualmente e que não são parte de uma biblioteca externa.", "ok": "Ok", "oldest_first": "Mais antigo primeiro", + "onboarding": "Integração", + "onboarding_privacy_description": "Os seguintes recursos (opcionais) dependem de serviços externos e podem ser desabilitados a qualquer momento nas configurações de administração.", + "onboarding_theme_description": "Escolha um tema de cor para sua instância. Você pode alterar isso mais tarde em suas configurações.", + "onboarding_welcome_description": "Vamos configurar sua instância com algumas configurações comuns.", "onboarding_welcome_user": "Bem-vindo(a), {user}", "online": "Online", "only_favorites": "Somente favoritos", "only_refreshes_modified_files": "Somente atualize arquivos modificados", + "open_in_map_view": "Abrir na visualização do mapa", "open_in_openstreetmap": "Abrir no OpenStreetMap", "open_the_search_filters": "Abre os filtros de pesquisa", "options": "Opções", @@ -840,6 +916,7 @@ "other_variables": "Outras variáveis", "owned": "Seu", "owner": "Dono", + "partner": "Parceiro", "partner_can_access": "{partner} pode acessar", "partner_can_access_assets": "Todas as suas fotos e vídeos, exceto os Arquivados ou Excluídos", "partner_can_access_location": "A localização onde as fotos foram tiradas", @@ -850,9 +927,9 @@ "password_required": "A senha é obrigatório", "password_reset_success": "Senha resetada com sucesso", "past_durations": { - "days": "{days, plural, one {Último dia} other {Últimos {days, number} dias}}", - "hours": "{hours, plural, one {Última hora} other {Últimas {hours, number} horas}}", - "years": "{years, plural, one {Último ano} other {Últimos {years, number} anos}}" + "days": "{days, plural, one {Último dia} other {# últimos dias}}", + "hours": "Últimas {hours, plural, one {horas} other {# horas}}", + "years": "{years, plural, one {Último ano} other {Últimos # anos}}" }, "path": "Caminho", "pattern": "Padrão", @@ -868,10 +945,13 @@ "permanent_deletion_warning_setting_description": "Exibe um aviso ao excluir arquivos de forma permanente", "permanently_delete": "Deletar permanentemente", "permanently_delete_assets_count": "Excluir permanentemente {count, plural, one {arquivo} other {arquivos}}", + "permanently_delete_assets_prompt": "Tem certeza que deseja excluir permanentemente {count, plural, one {esse arquivo?} other {estes # arquivos?}} Essa ação também removerá {count, plural, one {isto do} other {isto dos}} álbum(s).", "permanently_deleted_asset": "Ativo deletado permanentemente", "permanently_deleted_assets": "{count, plural, one {# ativo deletado} other {# ativos deletados}} permanentemente", "permanently_deleted_assets_count": "{count, plural, one {# arquivo excluído} other {# arquivos excluídos}} permanentemente", "person": "Pessoa", + "person_hidden": "{name}{hidden, select, true { (oculto)} other {}}", + "photo_shared_all_users": "Parece que você compartilhou suas fotos com todos os usuários ou não tem nenhum usuário para compartilhar.", "photos": "Fotos", "photos_and_videos": "Fotos & Vídeos", "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}", @@ -891,41 +971,69 @@ "previous_memory": "Memória anterior", "previous_or_next_photo": "Foto anterior ou próxima", "primary": "Primário", + "privacy": "Privacidade", "profile_image_of_user": "Imagem de perfil de {user}", "profile_picture_set": "Foto de perfil definida.", "public_album": "Álbum público", "public_share": "Compartilhar Publicamente", + "purchase_account_info": "Apoiador", "purchase_activated_subtitle": "Agradecemos por apoiar o Immich e software de código aberto", + "purchase_activated_time": "Ativado em {date, date}", + "purchase_activated_title": "Sua chave foi ativada com sucesso", "purchase_button_activate": "Ativar", "purchase_button_buy": "Comprar", "purchase_button_buy_immich": "Comprar Immich", "purchase_button_never_show_again": "Nunca mostrar novamente", "purchase_button_reminder": "Relembrar-me daqui a 30 dias", - "purchase_individual_title": "Individual", + "purchase_button_remove_key": "Remover chave", + "purchase_button_select": "Selecionar", + "purchase_failed_activation": "Falha ao ativar! Verifique seu e-mail para obter a chave de produto correta!", + "purchase_individual_description_1": "Para uma pessoa", + "purchase_individual_description_2": "Status de apoiador", + "purchase_individual_title": "Particular", + "purchase_input_suggestion": "Tem uma chave de produto? Insira a chave abaixo", + "purchase_license_subtitle": "Compre Immich para apoiar o desenvolvimento contínuo do serviço", "purchase_lifetime_description": "Compra vitalícia", "purchase_option_title": "OPÇÕES DE COMPRA", "purchase_panel_info_1": "O desenvolvimento do Immich requer muito tempo e esforço, e temos engenheiros a tempo inteiro a trabalhar nele para melhorá-lo quanto possível. A nossa missão é para que o software de código aberto e práticas de negócio éticas se tornem numa fonte de rendimento sustentável para os desenvolvedores e criar um ecossistema que respeite a privacidade dos utilizadores e que ofereça alternativas reais a serviços cloud explorativos.", + "purchase_panel_info_2": "Como estamos comprometidos em não adicionar acesso pago, esta compra não lhe dará nenhum recurso adicional no Immich. Contamos com usuários como você para dar suporte ao desenvolvimento contínuo do Immich.", "purchase_panel_title": "Apoie o projeto", "purchase_per_server": "Por servidor", "purchase_per_user": "Por utilizador", "purchase_remove_product_key": "Remover chave de produto", + "purchase_remove_product_key_prompt": "Tem certeza de que deseja remover a chave do produto?", + "purchase_remove_server_product_key": "Remover chave do produto do servidor", + "purchase_remove_server_product_key_prompt": "Tem certeza de que deseja remover a chave do produto do servidor?", "purchase_server_description_1": "Para o servidor inteiro", + "purchase_server_description_2": "Status de apoiador", "purchase_server_title": "Servidor", "purchase_settings_server_activated": "A chave de produto para servidor é gerida pelo administrador", "range": "", + "rating": "Classificação por estrelas", + "rating_clear": "Limpar classificação", + "rating_count": "{contar, plural, um {# estrela} outro {# estrelas}}", + "rating_description": "Exibir a classificação exif no painel de informações", "raw": "", "reaction_options": "Opções de reação", "read_changelog": "Ler Novidades", + "reassign": "Reatribuir", + "reassigned_assets_to_existing_person": "Reatribuir {count, plural, one {# arquivo} other {# arquivos}} PARA {name, select, null {uma pessoa existente} other {{name}}}", + "reassigned_assets_to_new_person": "Reatribuir {count, plural, one {# arquivo} other {# arquivos}} a uma nova pessoa", + "reassing_hint": "Atribuir ativos selecionados a uma pessoa existente", "recent": "Recente", "recent_searches": "Pesquisas recentes", "refresh": "Atualizar", + "refresh_encoded_videos": "Atualizar vídeos codificados", "refresh_metadata": "Atualizar metadados", "refresh_thumbnails": "Atualizar miniaturas", "refreshed": "Atualizado", "refreshes_every_file": "Atualiza todos arquivos", + "refreshing_encoded_video": "Atualizando vídeo codificado", "refreshing_metadata": "A atualizar metadados", "regenerating_thumbnails": "A atualizar miniaturas", "remove": "Remover", + "remove_assets_album_confirmation": "Tem certeza que deseja remover {count, plural, one {# arquivo} other {# arquivos}} do álbum?", + "remove_assets_shared_link_confirmation": "Tem certeza que deseja remover {count, plural, one {# arquivo} other {# arquivos}} desse link compartilhado?", "remove_assets_title": "Remover arquivos?", "remove_custom_date_range": "Remover intervalo de datas personalizado", "remove_from_album": "Remover do álbum", @@ -934,7 +1042,9 @@ "remove_offline_files": "Remover arquivos offline", "remove_user": "Remover utilizador", "removed_api_key": "Removido a Chave de API: {name}", + "removed_from_archive": "Removido do arquivo", "removed_from_favorites": "Removido dos favoritos", + "removed_from_favorites_count": "{count, plural, other {Removido #}} dos favoritos", "rename": "Renomear", "repair": "Reparar", "repair_no_results_message": "Arquivos perdidos ou não rastreados aparecem aqui", @@ -947,15 +1057,18 @@ "reset_people_visibility": "Resetar pessoas ocultas", "reset_settings_to_default": "", "reset_to_default": "Repor predefinições", + "resolve_duplicates": "Resolver itens duplicados", "resolved_all_duplicates": "Todas duplicidades resolvidas", "restore": "Restaurar", "restore_all": "Restaurar tudo", "restore_user": "Restaurar utilizador", + "restored_asset": "Arquivo restaurado", "resume": "Continuar", "retry_upload": "Tentar carregar novamente", "review_duplicates": "Revisar duplicidade", "role": "Função", "role_editor": "Editor", + "role_viewer": "Visualizador", "save": "Guardar", "saved_api_key": "Chave de API salva", "saved_profile": "Perfil Salvo", @@ -976,6 +1089,8 @@ "search_city": "Pesquisar cidade...", "search_country": "Pesquisar país...", "search_for_existing_person": "Pesquisar por pessoas", + "search_no_people": "Nenhuma pessoa", + "search_no_people_named": "Nenhuma pessoa chamada \"{name}\"", "search_people": "Pesquisar pessoas", "search_places": "Pesquisar lugares", "search_state": "Pesquisar estado...", @@ -987,15 +1102,18 @@ "see_all_people": "Ver todas as pessoas", "select_album_cover": "Escolher capa do álbum", "select_all": "Selecionar todos", + "select_all_duplicates": "Selecionar todos os itens duplicados", "select_avatar_color": "Selecionar cor do avatar", "select_face": "Selecionar face", "select_featured_photo": "Selecionar foto principal", + "select_from_computer": "Selecionar do computador", "select_keep_all": "Marcar manter em todos", "select_library_owner": "Selecione o dono da biblioteca", "select_new_face": "Selecionar nova face", "select_photos": "Selecionar fotos", "select_trash_all": "Marcar lixo em todos", "selected": "Selecionados", + "selected_count": "{count, plural, other {# selecionado}}", "send_message": "Enviar mensagem", "send_welcome_email": "Enviar E-mail de boas vindas", "server": "Servidor", @@ -1017,12 +1135,16 @@ "shared_by_user": "Partilhado por {user}", "shared_by_you": "Compartilhado por você", "shared_from_partner": "Fotos de {partner}", + "shared_link_options": "Opções de link compartilhado", "shared_links": "Links compartilhados", - "shared_photos_and_videos_count": "{assetCount} fotos & vídeos compartilhados.", + "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos & videos compartilhados.}}", "shared_with_partner": "Compartilhado com {partner}", "sharing": "Compartilhar", + "sharing_enter_password": "Por favor, digite a senha para visualizar esta página.", "sharing_sidebar_description": "Exibe o link Compartilhar na barra lateral", + "shift_to_permanent_delete": "Pressione ⇧ para excluir o arquivo permanentemente", "show_album_options": "Exibir opções do álbum", + "show_albums": "Mostrar álbuns", "show_all_people": "Mostrar todas as pessoas", "show_and_hide_people": "Mostrar & ocultar pessoas", "show_file_location": "Exibir local do arquivo", @@ -1037,6 +1159,8 @@ "show_person_options": "Exibir opções da pessoa", "show_progress_bar": "Exibir barra de progresso", "show_search_options": "Exibir opções de pesquisa", + "show_supporter_badge": "Emblema de apoiador", + "show_supporter_badge_description": "Mostrar um emblema de apoiador", "shuffle": "Aleatório", "sign_out": "Sair", "sign_up": "Registrar", @@ -1046,13 +1170,17 @@ "slideshow_settings": "Opções de apresentação", "sort_albums_by": "Ordenar álbuns por...", "sort_created": "Data de criação", + "sort_items": "Número de itens", "sort_modified": "Data de modificação", "sort_oldest": "Foto mais antiga", "sort_recent": "Foto mais recente", "sort_title": "Título", "source": "Fonte", "stack": "Empilhar", + "stack_duplicates": "Empilhar duplicados", + "stack_select_one_photo": "Selecione uma foto principal para a pilha", "stack_selected_photos": "Empilhar fotos selecionadas", + "stacked_assets_count": "Empilhado {count, plural, one {# arquivo} other {# arquivos}}", "stacktrace": "Stacktrace", "start": "Início", "start_date": "Data inicial", @@ -1074,9 +1202,11 @@ "theme": "Tema", "theme_selection": "Selecionar tema", "theme_selection_description": "Defina automaticamente o tema como claro ou escuro com base na preferência do sistema do seu navegador", + "they_will_be_merged_together": "Eles serão mesclados", "time_based_memories": "Memórias baseada no tempo", "timezone": "Fuso horário", "to_archive": "Arquivar", + "to_change_password": "Alterar senha", "to_favorite": "Favorito", "to_login": "Iniciar sessão", "to_trash": "Lixo", @@ -1087,11 +1217,13 @@ "trash": "Lixeira", "trash_all": "Todos para o lixo", "trash_count": "Lixeira {count, number}", + "trash_delete_asset": "Excluir arquivo", "trash_no_results_message": "Fotos e vídeos enviados para o lixo aparecem aqui.", "trashed_items_will_be_permanently_deleted_after": "Os itens da lixeira são deletados permanentemente após {days, plural, one {# dia} other {# dias}}.", "type": "Tipo", "unarchive": "Desarquivar", "unarchived": "Restaurado do arquivo", + "unarchived_count": "{count, plural, other {Não arquivado #}}", "unfavorite": "Remover favorito", "unhide_person": "Exibir pessoa", "unknown": "Desconhecido", @@ -1101,25 +1233,34 @@ "unlink_oauth": "Desvincular OAuth", "unlinked_oauth_account": "Conta OAuth desvinculada", "unnamed_album": "Álbum sem nome", + "unnamed_album_delete_confirmation": "Tem a certeza que pretende remover este album?", "unnamed_share": "Compartilhamento sem nome", "unsaved_change": "Alteração não guardada", "unselect_all": "Limpar seleção", + "unselect_all_duplicates": "Remover seleção de todos os duplicados", "unstack": "Desempilhar", + "unstacked_assets_count": "Desempilhar {count, plural, one {# arquivo} other {# arquivos}}", "untracked_files": "Arquivos não monitorados", "untracked_files_decription": "Estes arquivos não são monitorados pela aplicação. Podem ser resultados de falhas em uma movimentação, carregamentos interrompidos, ou deixados para trás por causa de um problema", "up_next": "A seguir", "updated_password": "Senha atualizada", "upload": "Carregar", "upload_concurrency": "Carregar simultâneo", + "upload_errors": "Envio completo com {count, plural, one {# erro} other {# erros}}, atualize a página para ver novos arquivos enviados.", "upload_progress": "Restante(s) {remaining, number} - Processado(s) {processed, number}/{total, number}", + "upload_skipped_duplicates": "Ignorado {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}", "upload_status_duplicates": "Duplicados", "upload_status_errors": "Erros", + "upload_status_uploaded": "Enviado", + "upload_success": "Upload realizado com sucesso, atualize a página para ver os novos ativos de upload.", "url": "URL", "usage": "Uso", "use_custom_date_range": "Usar um intervalo de datas personalizado", "user": "Utilizador", "user_id": "ID do utilizador", + "user_liked": "{user} gostou {type, select, photo {dessa foto} video {deste video} asset {deste arquivo} other {disto}}", "user_purchase_settings": "Compra", + "user_purchase_settings_description": "Gerencie sua compra", "user_role_set": "Definir {user} como {role}", "user_usage_detail": "Detalhes de uso do utilizador", "username": "Nome do utilizador", @@ -1128,6 +1269,8 @@ "validate": "Validar", "variables": "Variáveis", "version": "Versão", + "version_announcement_closing": "Seu amigo, Alex", + "version_announcement_message": "Olá amigo, há uma nova versão do aplicativo. Reserve um tempo para visitar as histórico de mudanças e garantir que suas configurações docker-compose.yml e .env estejam atualizadas para evitar qualquer configuração incorreta, especialmente se você usar o WatchTower ou qualquer mecanismo que lide com a atualização do seu aplicativo automaticamente.", "video": "Vídeo", "video_hover_setting": "Reproduzir vídeo em miniatura quando passar por cima", "video_hover_setting_description": "Reproduzir vídeo em miniatura quando o mouse está sobre o item. Mesmo quando desativado, a reprodução ainda pode ser iniciada passando sobre o ícone.", @@ -1140,7 +1283,9 @@ "view_links": "Ver links", "view_next_asset": "Ver próximo ativo", "view_previous_asset": "Ver ativo anterior", + "view_stack": "Visualizar pilha", "viewer": "Visualizar", + "visibility_changed": "Visibilidade alterada para {count, plural, one {# pessoa} other {# pessoas}}", "waiting": "Aguardando", "warning": "Aviso", "week": "Semana", diff --git a/web/src/lib/i18n/pt_BR.json b/web/src/lib/i18n/pt_BR.json index ba0698d7c5..6bf13dcee1 100644 --- a/web/src/lib/i18n/pt_BR.json +++ b/web/src/lib/i18n/pt_BR.json @@ -2,12 +2,12 @@ "about": "Sobre", "account": "Conta", "account_settings": "Configurações da Conta", - "acknowledge": "Confirmar", + "acknowledge": "Entendi", "action": "Ação", "actions": "Ações", "active": "Em execução", "activity": "Atividade", - "activity_changed": "A atividade está {enabled, select, true {enabled} other {disabled}}", + "activity_changed": "A atividade está {enabled, select, true {ativada} other {desativada}}", "add": "Adicionar", "add_a_description": "Adicionar uma descrição", "add_a_location": "Adicionar uma localização", @@ -95,7 +95,7 @@ "logging_level_description": "Quando ativado, qual nível de log usar.", "logging_settings": "Registros", "machine_learning_clip_model": "Modelo CLIP", - "machine_learning_clip_model_description": "O nome de um modelo CLIP listado aqui. Lembre-se de reexecutar a tarefa de 'Pesquisa Inteligente' para todas as imagens ao alterar o modelo.", + "machine_learning_clip_model_description": "O nome de um modelo CLIP listado aqui. Lembre-se de executar novamente a tarefa de 'Pesquisa Inteligente' para todas as imagens após alterar o modelo.", "machine_learning_duplicate_detection": "Detecção de duplicidade", "machine_learning_duplicate_detection_enabled": "Habilitar detecção de duplicidade", "machine_learning_duplicate_detection_enabled_description": "Se desativado, arquivos exatamente idênticos ainda serão desduplicados.", @@ -129,12 +129,13 @@ "map_enable_description": "Ativar recursos do mapa", "map_gps_settings": "Mapa e Configurações de GPS", "map_gps_settings_description": "Gerenciar Mapa e Configurações de GPS (Geocodificação Reversa)", + "map_implications": "O mapa depende de um serviço externo para funcionar (tiles.immich.cloud)", "map_light_style": "Tema Claro", "map_manage_reverse_geocoding_settings": "Gerenciar configurações de Geocodificação reversa", "map_reverse_geocoding": "Geocodificação reversa", "map_reverse_geocoding_enable_description": "Ativar geocodificação reversa", "map_reverse_geocoding_settings": "Configurações de geocodificação reversa", - "map_settings": "Configurações de mapa e GPS", + "map_settings": "Mapa", "map_settings_description": "Gerenciar configurações do mapa", "map_style_description": "URL para um tema de mapa style.json", "metadata_extraction_job": "Extrair metadados", @@ -249,7 +250,7 @@ "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Codecs de áudio aceitos", "transcoding_accepted_audio_codecs_description": "Selecione quais codecs de áudio não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", - "transcoding_accepted_containers": "containers aceitos", + "transcoding_accepted_containers": "Containers aceitos", "transcoding_accepted_containers_description": "Selecione quais formatos de contêiner não precisam ser remixados para MP4. Usado apenas para determinadas políticas de transcodificação.", "transcoding_accepted_video_codecs": "Codecs de vídeo aceitos", "transcoding_accepted_video_codecs_description": "Selecione quais codecs de vídeo não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", @@ -278,7 +279,7 @@ "transcoding_preferred_hardware_device": "Dispositivo de hardware preferido", "transcoding_preferred_hardware_device_description": "Aplica-se apenas a VAAPI e QSV. Define o nó dri usado para transcodificação de hardware.", "transcoding_preset_preset": "Predefinido (-preset)", - "transcoding_preset_preset_description": "Velocidade de compressão. Predefinições mais lentas produzem arquivos menores e aumentam a qualidade ao atingir uma determinada taxa de bits. VP9 ignora velocidades acima de `mais rápidas`.", + "transcoding_preset_preset_description": "Velocidade de compressão. As opções mais lentas produzem arquivos menores e aumentam a qualidade. VP9 ignora as velocidades acima de 'mais rápida'.", "transcoding_reference_frames": "Quadros de referência", "transcoding_reference_frames_description": "O número de quadros a serem referenciados ao compactar um determinado quadro. Valores mais altos melhoram a eficiência da compactação, mas retardam a codificação. 0 define esse valor automaticamente.", "transcoding_required_description": "Somente vídeos que não estejam em um formato aceito", @@ -310,7 +311,7 @@ "user_delete_delay": "A conta e os arquivos de {user} serão programados para exclusão permanente em {delay, plural, one {# dia} other {# dias}}.", "user_delete_delay_settings": "Excluir atraso", "user_delete_delay_settings_description": "Número de dias após a remoção para excluir permanentemente a conta e os arquivos de um usuário. A tarefa de exclusão de usuário é executada à meia-noite para verificar usuários que estão prontos para exclusão. As alterações nesta configuração serão avaliadas na próxima execução.", - "user_delete_immediately": "A conta e os arquivos de {user} serão postos na fila para exclusão permanente imediatamente.", + "user_delete_immediately": "A conta e os arquivos de {user} serão programados para exclusão permanente imediata.", "user_delete_immediately_checkbox": "Adicionar o usuário e seus ativos na fila para serem deletados imediatamente", "user_management": "Gerenciamento de usuários", "user_password_has_been_reset": "A senha do usuário foi redefinida:", @@ -320,7 +321,8 @@ "user_settings": "Configurações do Usuário", "user_settings_description": "Gerenciar configurações do usuário", "user_successfully_removed": "O usuário {email} foi removido com sucesso.", - "version_check_enabled_description": "Ativa verificações periódicas no GitHub para novas versões", + "version_check_enabled_description": "Ativa a verificação de versão", + "version_check_implications": "A verificação de versão depende de uma comunicação periódica com github.com", "version_check_settings": "Verificação de versão", "version_check_settings_description": "Ativar/desativar a notificação de nova versão", "video_conversion_job": "Transcodificar vídeos", @@ -344,11 +346,11 @@ "album_options": "Opções de álbum", "album_remove_user": "Remover usuário?", "album_remove_user_confirmation": "Tem certeza de que deseja remover {user}?", - "album_share_no_users": "Parece que você compartilhou este álbum com todos os usuários ou não tem nenhum usuário para compartilhar com ele.", + "album_share_no_users": "Parece que você já compartilhou este álbum com todos os usuários ou não há nenhum usuário para compartilhar.", "album_updated": "Álbum atualizado", "album_updated_setting_description": "Receba uma notificação por e-mail quando um álbum compartilhado tiver novos recursos", - "album_user_left": "Saída de {album}", - "album_user_removed": "Usuário {user} removido", + "album_user_left": "Saiu do álbum {album}", + "album_user_removed": "Usuário {user} foi removido", "album_with_link_access": "Permitir que qualquer pessoa com o link veja as fotos e as pessoas neste álbum.", "albums": "Álbuns", "albums_count": "{count, plural, one {{count, number} Álbum} other {{count, number} Álbuns}}", @@ -358,8 +360,9 @@ "all_videos": "Todos os vídeos", "allow_dark_mode": "Permitir modo escuro", "allow_edits": "Permitir edições", - "allow_public_user_to_download": "Permitir que usuários públicos façam download", - "allow_public_user_to_upload": "Permitir que usuários públicos enviem novos ativos", + "allow_public_user_to_download": "Permitir que usuários públicos baixem os arquivos", + "allow_public_user_to_upload": "Permitir que usuários públicos enviem novos arquivos", + "anti_clockwise": "Anti-horário", "api_key": "Chave de API", "api_key_description": "Este valor será mostrado apenas uma vez. Por favor, certifique-se de copiá-lo antes de fechar a janela.", "api_key_empty": "O nome da sua chave de API não deve estar vazio", @@ -368,8 +371,8 @@ "appears_in": "Aparece em", "archive": "Arquivados", "archive_or_unarchive_photo": "Arquivar ou desarquivar foto", - "archive_size": "Tamanho do Arquivo", - "archive_size_description": "Configure o tamanho do arquivo para downloads (em GiB)", + "archive_size": "Tamanho do arquivo", + "archive_size_description": "Configure o tamanho do arquivo para baixar (em GiB)", "archived": "Arquivado", "archived_count": "{count, plural, one {# Arquivado} other {# Arquivados}}", "are_these_the_same_person": "Essas pessoas são a mesma pessoa?", @@ -377,11 +380,11 @@ "asset_added_to_album": "Adicionado ao álbum", "asset_adding_to_album": "Adicionando ao álbum...", "asset_description_updated": "A descrição do ativo foi atualizada", - "asset_filename_is_offline": "O arquivo {filename} está offline", - "asset_has_unassigned_faces": "O arquivo tem rostos não atribuídos", + "asset_filename_is_offline": "O arquivo {filename} não está disponível", + "asset_has_unassigned_faces": "O arquivo tem rostos sem nomes", "asset_hashing": "Processando...", - "asset_offline": "Arquivo off-line", - "asset_offline_description": "Este arquivo está offline. O Immich não pode acessar sua localização de arquivo. Certifique-se de que o arquivo esteja disponível e depois escaneie novamente a biblioteca.", + "asset_offline": "Arquivo indisponível", + "asset_offline_description": "Este arquivo não está disponível. O Immich não pode acessar o local do arquivo. Certifique-se de que o arquivo esteja disponível e depois escaneie novamente a biblioteca.", "asset_skipped": "Ignorado", "asset_uploaded": "Carregado", "asset_uploading": "Carregando...", @@ -397,17 +400,17 @@ "assets_restore_confirmation": "Tem certeza de que deseja restaurar todos os seus arquivos na lixeira? Esta ação não pode ser desfeita!", "assets_restored_count": "{count, plural, one {# arquivo restaurado} other {# arquivos restaurados}}", "assets_trashed_count": "{count, plural, one {# arquivo movido para a lixeira} other {# arquivos movidos para a lixeira}}", - "assets_were_part_of_album_count": "{count, plural, one {O recurso estava} other {Os recursos estavam}} já fazendo parte do álbum", + "assets_were_part_of_album_count": "{count, plural, one {O arquivo já faz} other {Os arquivos já fazem}} parte do álbum", "authorized_devices": "Dispositivos Autorizados", "back": "Voltar", "back_close_deselect": "Voltar, fechar ou desmarcar", "backward": "Para trás", "birthdate_saved": "Data de nascimento salva com sucesso", - "birthdate_set_description": "A data de nascimento é usada para calcular a idade desta pessoa na época de uma foto.", + "birthdate_set_description": "A data de nascimento é usada para calcular a idade da pessoa no momento em que a foto foi tirada.", "blurred_background": "Fundo desfocado", "build": "Versão de compilação", "build_image": "Imagem de compilação", - "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja deletar {count, plural, one {# arquivo duplicado} other {em massa # arquivos duplicados}}? Esta ação mantém o maior arquivo de cada grupo e deleta permanentemente todos as outras duplicidades. Você não pode reverter esta ação!", + "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja deletar {count, plural, one {# arquivo duplicado} other {em massa # arquivos duplicados}}? Esta ação mantém o maior arquivo de cada grupo e deleta permanentemente todos as outras duplicidades. Você não pode desfazer esta ação!", "bulk_keep_duplicates_confirmation": "Tem certeza de que deseja manter {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso resolverá todos os grupos duplicados sem excluir nada.", "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a lixeira {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso manterá o maior arquivo de cada grupo e moverá para a lixeira todas as outras duplicidades.", "buy": "Comprar o Immich", @@ -441,6 +444,7 @@ "clear_all_recent_searches": "Limpar todas as buscas recentes", "clear_message": "Limpar mensagem", "clear_value": "Limpar valor", + "clockwise": "Horário", "close": "Fechar", "collapse": "Recolher", "collapse_all": "Colapsar tudo", @@ -517,6 +521,8 @@ "do_not_show_again": "Não mostrar esta mensagem novamente", "done": "Feito", "download": "Baixar", + "download_include_embedded_motion_videos": "Vídeos inclusos", + "download_include_embedded_motion_videos_description": "Baixar os vídeos inclusos de uma foto em movimento em um arquivo separado", "download_settings": "Baixar", "download_settings_description": "Gerenciar configurações relacionadas a transferência de arquivos", "downloading": "Baixando", @@ -550,6 +556,10 @@ "edit_user": "Editar usuário", "edited": "Editado", "editor": "Editar", + "editor_close_without_save_prompt": "As alterações não serão salvas", + "editor_close_without_save_title": "Fechar editor?", + "editor_crop_tool_h2_aspect_ratios": "Proporções", + "editor_crop_tool_h2_rotation": "Rotação", "email": "E-mail", "empty": "", "empty_album": "", @@ -562,16 +572,16 @@ "error_loading_image": "Erro ao carregar a página", "error_title": "Erro - Algo deu errado", "errors": { - "cannot_navigate_next_asset": "Não é possível navegar para o próximo arquivo", - "cannot_navigate_previous_asset": "Não é possível navegar para o arquivo anterior", - "cant_apply_changes": "Não é possível aplicar modificações", - "cant_change_activity": "Não é possível {enabled, select, true {disable} other {enable}} atividade", - "cant_change_asset_favorite": "Não é possível mudar favorito para o arquivo", - "cant_change_metadata_assets_count": "Não é possível alterar os metadados de {count, plural, one {# arquivo} other {# arquivos}}", + "cannot_navigate_next_asset": "Não foi possível navegar para o próximo arquivo", + "cannot_navigate_previous_asset": "Não foi possível navegar para o arquivo anterior", + "cant_apply_changes": "Não foi possível aplicar as alterações", + "cant_change_activity": "Não foi possível {enabled, select, true {desativar} other {habilitar}} a atividade", + "cant_change_asset_favorite": "Não foi possível mudar favorito para o arquivo", + "cant_change_metadata_assets_count": "Não foi possível alterar os metadados de {count, plural, one {# arquivo} other {# arquivos}}", "cant_get_faces": "Não foi possível obter os rostos", - "cant_get_number_of_comments": "Não é possível obter o número de comentários", - "cant_search_people": "Não é possível procurar pessoas", - "cant_search_places": "Não é possível procurar locais", + "cant_get_number_of_comments": "Não foi possível obter o número de comentários", + "cant_search_people": "Não foi possível procurar pessoas", + "cant_search_places": "Não foi possível procurar locais", "cleared_jobs": "Tarefas eliminadas para: {job}", "error_adding_assets_to_album": "Erro ao adicionar arquivos para o álbum", "error_adding_users_to_album": "Erro ao adicionar usuários para o álbum", @@ -605,11 +615,11 @@ "unable_to_add_import_path": "Não foi possível adicionar o caminho de importação", "unable_to_add_partners": "Não foi possível adicionar parceiros", "unable_to_add_remove_archive": "Não é possível {archived, select, true {remove asset from} other {add asset to}} arquivar", - "unable_to_add_remove_favorites": "Não é possível {favorite, select, true {add asset to} other {remove asset from}} favoritos", - "unable_to_archive_unarchive": "Não é possível {archived, select, true {archive} other {unarchive}}", + "unable_to_add_remove_favorites": "Não foi possível {favorite, select, true {adicionar o arquivo aos} other {remover o arquivo dos}} favoritos", + "unable_to_archive_unarchive": "Não foi possível {archived, select, true {arquivar} other {desarquivar}}", "unable_to_change_album_user_role": "Não foi possível alterar a permissão do usuário no álbum", "unable_to_change_date": "Não foi possível alterar a data", - "unable_to_change_favorite": "Não é possível alterar o favorito para o arquivo", + "unable_to_change_favorite": "Não foi possível alterar o favorito para o arquivo", "unable_to_change_location": "Não foi possível alterar a localização", "unable_to_change_password": "Não foi possível alterar a senha", "unable_to_change_visibility": "Não foi possível alterar a visibilidade de {count, plural, one {# pessoa} other {# pessoas}}", @@ -630,7 +640,7 @@ "unable_to_delete_import_path": "Não foi possível deletar o caminho de importação", "unable_to_delete_shared_link": "Não foi possível deletar o link compartilhado", "unable_to_delete_user": "Não foi possível deletar o usuário", - "unable_to_download_files": "Não foi possível fazer download dos arquivos", + "unable_to_download_files": "Não foi possível baixar os arquivos", "unable_to_edit_exclusion_pattern": "Não foi possível editar o padrão de exclusão", "unable_to_edit_import_path": "Não foi possível editar o caminho de importação", "unable_to_empty_trash": "Não foi possível esvaziar a lixeira", @@ -648,7 +658,7 @@ "unable_to_log_out_device": "Não foi possível sair do dispositivo", "unable_to_login_with_oauth": "Não foi possível fazer login com OAuth", "unable_to_play_video": "Não foi possível reproduzir o vídeo", - "unable_to_reassign_assets_existing_person": "Não foi possível reatribuir arquivos para {name, select, null {an existing person} other {{name}}}", + "unable_to_reassign_assets_existing_person": "Não foi possível reatribuir arquivos a {name, select, null {uma pessoa} other {{name}}}", "unable_to_reassign_assets_new_person": "Não foi possível reatribuir arquivos a uma nova pessoa", "unable_to_refresh_user": "Não foi possível atualizar o usuário", "unable_to_remove_album_users": "Não foi possível remover usuários do álbum", @@ -663,7 +673,7 @@ "unable_to_repair_items": "Não foi possível reparar os itens", "unable_to_reset_password": "Não foi possível resetar a senha", "unable_to_resolve_duplicate": "Não foi possível resolver a duplicidade", - "unable_to_restore_assets": "Não foi possível restaurar o(s) arquivo(s)", + "unable_to_restore_assets": "Não foi possível restaurar", "unable_to_restore_trash": "Não foi possível restaurar itens da lixeira", "unable_to_restore_user": "Não foi possível restaurar usuário", "unable_to_save_album": "Não foi possível salvar o álbum", @@ -699,6 +709,7 @@ "expired": "Expirou", "expires_date": "Expira em {date}", "explore": "Explorar", + "explorer": "Explorar", "export": "Exportar", "export_as_json": "Exportar como JSON", "extension": "Extensão", @@ -720,12 +731,13 @@ "filter_people": "Filtrar pessoas", "find_them_fast": "Encontre pelo nome em uma pesquisa", "fix_incorrect_match": "Corrigir correspondência incorreta", + "folders": "Pastas", "force_re-scan_library_files": "Força escanear novamente todos os arquivos da biblioteca", "forward": "Para frente", "general": "Geral", "get_help": "Obter Ajuda", "getting_started": "Primeiros passos", - "go_back": "Retornar", + "go_back": "Voltar", "go_to_search": "Ir para a pesquisa", "go_to_share_page": "Ir para a página de compartilhamento", "group_albums_by": "Agrupar álbuns por...", @@ -743,16 +755,16 @@ "host": "Host", "hour": "Hora", "image": "Imagem", - "image_alt_text_date": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {date}", - "image_alt_text_date_1_person": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} com {person1} em {date}", - "image_alt_text_date_2_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} com {person1} e {person2} em {date}", - "image_alt_text_date_3_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} com {person1}, {person2}, e {person3} em {date}", - "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} com {person1}, {person2}, e {additionalCount, number} outros em {date}", - "image_alt_text_date_place": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} em {date}", - "image_alt_text_date_place_1_person": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} com {person1} em {date}", - "image_alt_text_date_place_2_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} com {person1} e {person2} em {date}", - "image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} com {person1}, {person2}, e {person3} em {date}", - "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} com {person1}, {person2}, e {additionalCount, number} outros em {date}", + "image_alt_text_date": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1} em {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1} e {person2} em {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1}, {person2}, e {person3} em {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1}, {person2}, e outras {additionalCount, number} em {date}", + "image_alt_text_date_place": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} em {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1} em {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1} e {person2} em {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e {person3} em {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e {additionalCount, number} outros em {date}", "image_alt_text_people": "{count, plural, =1 {com {person1}} =2 {com {person1} e {person2}} =3 {com {person1}, {person2}, e {person3}} other {com {person1}, {person2} e outras {others, number} pessoas}}", "image_alt_text_place": "em {city}, {country}", "image_taken": "{isVideo, select, true {Gravado} other {Fotografado}}", @@ -912,6 +924,7 @@ "ok": "Ok", "oldest_first": "Mais antigo primeiro", "onboarding": "Integração", + "onboarding_privacy_description": "As seguintes funções opcionais dependem de serviços externos e podem ser desabilitadas a qualquer momento nas configurações de administração.", "onboarding_theme_description": "Escolha um tema de cores para sua instância. Você pode alterar isso posteriormente em suas configurações.", "onboarding_welcome_description": "Vamos configurar sua instância com algumas configurações comuns.", "onboarding_welcome_user": "Bem-vindo, {user}", @@ -985,6 +998,7 @@ "previous_memory": "Memória anterior", "previous_or_next_photo": "Foto anterior ou próxima", "primary": "Primário", + "privacy": "Privacidade", "profile_image_of_user": "Imagem do perfil de {user}", "profile_picture_set": "Foto de perfil definida.", "public_album": "Álbum público", @@ -1008,8 +1022,8 @@ "purchase_license_subtitle": "Compre o Immich para apoiar o desenvolvimento contínuo do serviço", "purchase_lifetime_description": "Compra vitalícia", "purchase_option_title": "OPÇÕES DE COMPRA", - "purchase_panel_info_1": "Construir o Immich leva muito tempo e esforço, e temos engenheiros dedicados trabalhando nele para torná-lo o melhor possível. Nossa missão é que programas de código aberto e as práticas empresariais éticas se tornem uma fonte de receita sustentável para os desenvolvedores e criar um ecossistema que respeite a privacidade, oferecendo alternativas reais aos serviços de nuvem exploratórios.", - "purchase_panel_info_2": "Como estamos comprometidos em não adicionar bloqueios de pagamento, esta compra não lhe concederá recursos adicionais no Immich. Contamos com usuários como você para apoiar o desenvolvimento contínuo do Immich.", + "purchase_panel_info_1": "Construir o Immich leva muito tempo e esforço. Temos engenheiros trabalhando em tempo integral para torná-lo o melhor possível. Nossa missão é fazer com que programas de código aberto e práticas empresariais éticas se tornem uma fonte de renda sustentável para os desenvolvedores e também criar um ecossistema que respeite a privacidade, oferecendo alternativas reais aos serviços de nuvem exploratórios.", + "purchase_panel_info_2": "Como estamos comprometidos em não adicionar funções bloqueadas por compras, esta compra não lhe concederá nenhum recurso adicional no Immich. Nós contamos com usuários como você para apoiar o desenvolvimento contínuo do Immich.", "purchase_panel_title": "Apoiar o projeto", "purchase_per_server": "Por servidor", "purchase_per_user": "Por usuário", @@ -1022,11 +1036,13 @@ "purchase_server_title": "Servidor", "purchase_settings_server_activated": "A chave do produto para servidor é gerenciada pelo administrador", "range": "", + "rating": "Estrelas", + "rating_description": "Exibir os metadados de classificação (estrelas) no painel de informações", "raw": "", "reaction_options": "Opções de reação", "read_changelog": "Ler Novidades", "reassign": "Reatribuir", - "reassigned_assets_to_existing_person": "{count, plural, one {# arquivo reatribuído} other {# arquivos reatribuídos}} a {name, select, null {an existing person} other {{name}}}", + "reassigned_assets_to_existing_person": "{count, plural, one {# arquivo reatribuído} other {# arquivos reatribuídos}} a {name, select, null {uma pessoa} other {{name}}}", "reassigned_assets_to_new_person": "{count, plural, one {# arquivo reatribuído} other {# arquivos reatribuídos}} a uma nova pessoa", "reassing_hint": "Atribuir arquivos selecionados a uma pessoa existente", "recent": "Recente", @@ -1126,8 +1142,8 @@ "send_message": "Enviar mensagem", "send_welcome_email": "Enviar E-mail de boas vindas", "server": "Servidor", - "server_offline": "Servidor Fora do Ar", - "server_online": "Servidor no Ar", + "server_offline": "Servidor Indisponível", + "server_online": "Servidor Disponível", "server_stats": "Status do servidor", "server_version": "Versão do servidor", "set": "Definir", @@ -1144,14 +1160,16 @@ "shared_by_user": "Compartilhado por {user}", "shared_by_you": "Compartilhado por você", "shared_from_partner": "Fotos de {partner}", + "shared_link_options": "Opções do link compartilhado", "shared_links": "Links compartilhados", - "shared_photos_and_videos_count": "{assetCount, plural, one {# foto e vídeo compartilhados.} other {# fotos e vídeos compartilhados.}}", + "shared_photos_and_videos_count": "{assetCount, plural, one {# arquivo compartilhado.} other {# arquivos compartilhados.}}", "shared_with_partner": "Compartilhado com {partner}", "sharing": "Compartilhar", "sharing_enter_password": "Digite a senha para visualizar esta página.", "sharing_sidebar_description": "Exibe o link Compartilhar na barra lateral", "shift_to_permanent_delete": "pressione ⇧ para excluir permanentemente o arquivo", "show_album_options": "Exibir opções do álbum", + "show_albums": "Exibir álbuns", "show_all_people": "Mostrar todas as pessoas", "show_and_hide_people": "Mostrar & ocultar pessoas", "show_file_location": "Exibir local do arquivo", @@ -1167,7 +1185,7 @@ "show_progress_bar": "Exibir barra de progresso", "show_search_options": "Exibir opções de pesquisa", "show_supporter_badge": "Insígnia de Contribuidor", - "show_supporter_badge_description": "Mostrar uma insígnia de contribuidor", + "show_supporter_badge_description": "Mostrar a insígnia de contribuidor", "shuffle": "Aleatório", "sign_out": "Sair", "sign_up": "Registrar", @@ -1184,6 +1202,8 @@ "sort_title": "Título", "source": "Fonte", "stack": "Empilhar", + "stack_duplicates": "Empilhar duplicados", + "stack_select_one_photo": "Selecione uma foto principal para a pilha", "stack_selected_photos": "Empilhar fotos selecionadas", "stacked_assets_count": "{count, plural, one {# arquivo empilhado} other {# arquivos empilhados}}", "stacktrace": "Stacktrace", @@ -1241,7 +1261,7 @@ "unnamed_share": "Compartilhamento sem nome", "unsaved_change": "Alteração não salva", "unselect_all": "Limpar seleção", - "unselect_all_duplicates": "Deselecionar todas as duplicatas", + "unselect_all_duplicates": "Desselecionar todas as duplicatas", "unstack": "Desempilhar", "unstacked_assets_count": "{count, plural, one {# arquivo não empilhado} other {# arquivos não empilhados}}", "untracked_files": "Arquivos não monitorados", @@ -1251,7 +1271,7 @@ "upload": "Carregar", "upload_concurrency": "Carregar simultâneo", "upload_errors": "Envio concluído com {count, plural, one {# erro} other {# erros}}, atualize a página para ver os novos arquivos carregados.", - "upload_progress": "{remaining, number} processando - {processed, number}/{total, number} já processados.", + "upload_progress": "{remaining, number} processando - {processed, number}/{total, number} já processados", "upload_skipped_duplicates": "{count, plural, one {# arquivo duplicado foi ignorado} other {# arquivos duplicados foram ignorados}}", "upload_status_duplicates": "Duplicados", "upload_status_errors": "Erros", @@ -1259,13 +1279,13 @@ "upload_success": "Carregado com sucesso, atualize a página para ver os novos arquivos.", "url": "URL", "usage": "Uso", - "use_custom_date_range": "Usar intervalo de datas personalizado invés", + "use_custom_date_range": "Usar intervalo de datas personalizado", "user": "Usuário", "user_id": "ID do usuário", "user_license_settings": "Licença", "user_license_settings_description": "Gerenciar sua licença", - "user_liked": "{user} curtiu {type, select, photo {this photo} video {this video} asset {this asset} other {it}}", - "user_purchase_settings": "Compra", + "user_liked": "{user} curtiu {type, select, photo {a foto} video {o vídeo} asset {o arquivo} other {isso}}", + "user_purchase_settings": "Comprar", "user_purchase_settings_description": "Gerenciar sua compra", "user_role_set": "Definir {user} como {role}", "user_usage_detail": "Detalhes de uso do usuário", @@ -1276,14 +1296,14 @@ "variables": "Variáveis", "version": "Versão", "version_announcement_closing": "De seu amigo, Alex", - "version_announcement_message": "Olá, amigo, há uma nova versão do aplicativo disponível. Por favor, visite com calma a página notas da versão e certifique-se de que a configuração do docker-compose.yml, e do .env estejam atualizadas para evitar configurações incorretas, especialmente se você usar o WatchTower ou qualquer mecanismo que lide com a atualização automática do aplicativo.", + "version_announcement_message": "Olá amigo! Uma nova versão do aplicativo está disponível. Para evitar configurações incorretas, por favor verifique com calma a página de notas da versão e certifique-se que os arquivos docker-compose.yml e .env estão configurados corretamente, principalmente se você usa o WatchTower ou qualquer outro mecanismo que faça atualizações automáticas.", "video": "Vídeo", "video_hover_setting": "Reproduzir miniatura do vídeo ao passar o mouse", "video_hover_setting_description": "Reproduzir a miniatura do vídeo ao passar o mouse sobre o item. Mesmo quando desativado, a reprodução pode ser iniciada ao passar o mouse sobre o ícone de reprodução.", "videos": "Vídeos", "videos_count": "{count, plural, one {# Vídeo} other {# Vídeos}}", "view": "Ver", - "view_album": "Exibir álbum", + "view_album": "Ver álbum", "view_all": "Ver tudo", "view_all_users": "Ver todos usuários", "view_links": "Ver links", @@ -1291,7 +1311,7 @@ "view_previous_asset": "Ver arquivo anterior", "view_stack": "Exibir Pilha", "viewer": "Visualizar", - "visibility_changed": "Visibilidade alterada para {count, plural, one {# pessoa} other {# pessoas}}", + "visibility_changed": "A visibilidade de {count, plural, one {# pessoa foi alterada} other {# pessoas foram alteradas}}", "waiting": "Aguardando", "warning": "Aviso", "week": "Semana", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 1a55ab009d..c56b3fce6f 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -129,6 +129,7 @@ "map_enable_description": "Включить функции карты", "map_gps_settings": "Настройки карты и GPS", "map_gps_settings_description": "Управление настройками карты и GPS (обратный геокодинг)", + "map_implications": "Функция отображения зависит от внешнего сервиса плиток (tiles.immich.cloud)", "map_light_style": "Светлый стиль", "map_manage_reverse_geocoding_settings": "Управление настройками Обратного геокодирования", "map_reverse_geocoding": "Обратное Геокодирование", @@ -320,7 +321,8 @@ "user_settings": "Пользовательские настройки", "user_settings_description": "Управление настройками пользователей", "user_successfully_removed": "Пользователь {email} был успешно удален.", - "version_check_enabled_description": "Включить периодические запросы к GitHub для проверки наличия новых версий", + "version_check_enabled_description": "Включить проверку наличия новых версий", + "version_check_implications": "Функция проверки версии зависит от периодического взаимодействия с github.com", "version_check_settings": "Проверка версии", "version_check_settings_description": "Включить/отключить уведомление о новой версии", "video_conversion_job": "Перекодирование видео", @@ -336,7 +338,8 @@ "album_added": "Альбом добавлен", "album_added_notification_setting_description": "Получать уведомление по электронной почте, когда вы добавлены к общему альбому", "album_cover_updated": "Обложка альбома обновлена", - "album_delete_confirmation": "Вы уверены, что хотите удалить альбом {album}?\nЕсли этот альбом общий, то другие пользователи не смогут получить к нему доступ.", + "album_delete_confirmation": "Вы уверены, что хотите удалить альбом {album}?", + "album_delete_confirmation_description": "Если альбом был общим, другие пользователи больше не смогут получить к нему доступ.", "album_info_updated": "Информация об альбоме обновлена", "album_leave": "Покинуть альбом?", "album_leave_confirmation": "Вы уверены, что хотите покинуть {album}?", @@ -519,6 +522,8 @@ "do_not_show_again": "Не показывать это сообщение в дальнейшем", "done": "Готово", "download": "Скачать", + "download_include_embedded_motion_videos": "Встроенные видео", + "download_include_embedded_motion_videos_description": "Включить видео, встроенные в живые фото, в виде отдельного файла", "download_settings": "Скачивание", "download_settings_description": "Управление настройками скачивания объектов", "downloading": "Загрузка", @@ -705,6 +710,7 @@ "expired": "Срок действия истек", "expires_date": "Срок действия до {date}", "explore": "Просмотр", + "explorer": "Проводник", "export": "Экспортировать", "export_as_json": "Экспорт в JSON", "extension": "Расширение", @@ -726,6 +732,7 @@ "filter_people": "Фильтр по людям", "find_them_fast": "Быстро найдите их по имени с помощью поиска", "fix_incorrect_match": "Исправить неправильное соответствие", + "folders": "Папки", "force_re-scan_library_files": "Принудительное повторное сканирование всех файлов библиотеки", "forward": "Переслать", "general": "Общие", @@ -918,6 +925,7 @@ "ok": "ОК", "oldest_first": "Сначала старые", "onboarding": "Начало работы", + "onboarding_privacy_description": "Следующие (необязательные) функции зависят от внешних сервисов и могут быть отключены в любое время в настройках администрирования.", "onboarding_theme_description": "Выберите цветовую тему. Вы можете изменить ее позже в настройках.", "onboarding_welcome_description": "Давайте настроим ваш экземпляр с некоторыми общими параметрами.", "onboarding_welcome_user": "Добро пожаловать, {user}", @@ -991,6 +999,7 @@ "previous_memory": "Предыдущее воспоминание", "previous_or_next_photo": "Предыдущая или следующая фотография", "primary": "Главное", + "privacy": "Конфиденциальность", "profile_image_of_user": "Изображение профиля {user}", "profile_picture_set": "Установлена картинка профиля.", "public_album": "Публичный альбом", @@ -1029,6 +1038,8 @@ "purchase_settings_server_activated": "Ключ продукта сервера управляется администратором", "range": "", "rating": "Рейтинг звёзд", + "rating_clear": "Очистить рейтинг", + "rating_count": "{count, plural, one {# звезда} other {# звезд}}", "rating_description": "Показывать рейтинг exif в панели информации", "raw": "", "reaction_options": "Опции реакций", @@ -1152,6 +1163,7 @@ "shared_by_user": "Владелец: {user}", "shared_by_you": "Вы поделились", "shared_from_partner": "Фото от {partner}", + "shared_link_options": "Параметры общих ссылок", "shared_links": "Общие ссылки", "shared_photos_and_videos_count": "{assetCount, plural, other {# поделился фото и видео.}}", "shared_with_partner": "Совместно с {partner}", @@ -1249,6 +1261,7 @@ "unlink_oauth": "Отключить OAuth", "unlinked_oauth_account": "Отключить аккаунт OAuth", "unnamed_album": "Альбом без названия", + "unnamed_album_delete_confirmation": "Вы уверены, что хотите удалить этот альбом?", "unnamed_share": "Общий доступ без названия", "unsaved_change": "Не сохраненное изменение", "unselect_all": "Снять всё", diff --git a/web/src/lib/i18n/sl.json b/web/src/lib/i18n/sl.json index bf8c55e5c4..ccd488174b 100644 --- a/web/src/lib/i18n/sl.json +++ b/web/src/lib/i18n/sl.json @@ -7,6 +7,7 @@ "actions": "Dejanja", "active": "Aktivno", "activity": "Aktivnost", + "activity_changed": "Aktivnost {enabled, select, true {omogočena} other {onemogočena}}", "add": "Dodaj", "add_a_description": "Dodaj opis", "add_a_location": "Dodaj lokacijo", @@ -29,6 +30,7 @@ "add_exclusion_pattern_description": "Dodajte vzorec izključitev. Globiranje z uporabo *, ** in ? je podprto. Če želite prezreti vse datoteke v katerem koli imeniku z imenom \"Raw\", uporabite \"**/Raw/**\". Če želite prezreti vse datoteke, ki se končajo na \".tif\", uporabite \"**/*.tif\". Če želite prezreti absolutno pot, uporabite \"/pot/za/ignoriranje/**\".", "authentication_settings": "Nastavitve preverjanja pristnosti", "authentication_settings_description": "Upravljanje gesel, OAuth in drugih nastavitev preverjanja pristnosti", + "authentication_settings_disable_all": "Ali zares želite onemogočiti vse prijavne metode? Prijava bo popolnoma onemogočena.", "authentication_settings_reenable": "Ponovno omogoči z uporabo Server Command.", "background_task_job": "Opravila v ozadju", "check_all": "Označi vse", diff --git a/web/src/lib/i18n/sr_Cyrl.json b/web/src/lib/i18n/sr_Cyrl.json index 1c7b66df01..d0c9b7d486 100644 --- a/web/src/lib/i18n/sr_Cyrl.json +++ b/web/src/lib/i18n/sr_Cyrl.json @@ -129,6 +129,7 @@ "map_enable_description": "Омогућите карактеристике мапе", "map_gps_settings": "Мап & ГПС подешавања", "map_gps_settings_description": "Управљајте поставкама мапе и ГПС-а (обрнуто геокодирање)", + "map_implications": "Функција мапе се ослања на екстерну услугу плочица (tiles.immich.cloud)", "map_light_style": "Светли стил", "map_manage_reverse_geocoding_settings": "Управљајте подешавањима Обрнуто геокодирање", "map_reverse_geocoding": "Обрнуто геокодирање", @@ -320,7 +321,8 @@ "user_settings": "Подешавања корисника", "user_settings_description": "Управљајте корисничким подешавањима", "user_successfully_removed": "Корисник {email} је успешно уклоњен.", - "version_check_enabled_description": "Омогућите периодичне захтеве GitHub-u за проверу нових издања", + "version_check_enabled_description": "Омогућите проверу нових издања", + "version_check_implications": "Функција провере верзије се ослања на периодичну комуникацију са github.com", "version_check_settings": "Провера верзије", "version_check_settings_description": "Омогућите/oneмогућите обавештење о новој верзији", "video_conversion_job": "Транскодирање видео записа", @@ -336,7 +338,8 @@ "album_added": "Албум додан", "album_added_notification_setting_description": "Прими обавештење е-поштом кад будеш додан у дељен албум", "album_cover_updated": "Омот албума ажуриран", - "album_delete_confirmation": "Да ли стварно желите да избришете албум {album}?\nАко се овај албум дели, други корисници више неће моћи да му приступе.", + "album_delete_confirmation": "Да ли стварно желите да избришете албум {album}?", + "album_delete_confirmation_description": "Ако се овај албум дели, други корисници више неће моћи да му приступе.", "album_info_updated": "Информација албума ажурирана", "album_leave": "Напустити албум?", "album_leave_confirmation": "Да ли стварно желите да напустите {album}?", @@ -360,6 +363,7 @@ "allow_edits": "Дозволи уређење", "allow_public_user_to_download": "Дозволите јавном кориснику да преузме (download-uje)", "allow_public_user_to_upload": "Дозволи јавном кориснику да отпреми (уплоад-ује)", + "anti_clockwise": "У смеру супротном од казаљке на сату", "api_key": "АПИ кључ (key)", "api_key_description": "Ова вредност ће бити приказана само једном. Обавезно копирајте пре него што затворите прозор.", "api_key_empty": "Име вашег АПИ кључа не би требало да буде празно", @@ -441,6 +445,7 @@ "clear_all_recent_searches": "Обришите све недавне претраге", "clear_message": "Обриши поруку", "clear_value": "Јасна вредност", + "clockwise": "У смеру казаљке", "close": "Затвори", "collapse": "Скупи", "collapse_all": "Скупи све", @@ -517,6 +522,8 @@ "do_not_show_again": "Не прикажи поново ову поруку", "done": "Урађено", "download": "Преузми", + "download_include_embedded_motion_videos": "Уграђени видео снимци", + "download_include_embedded_motion_videos_description": "Укључите видео записе уграђене у фотографије у покрету као засебну датотеку", "download_settings": "Преузимање", "download_settings_description": "Управљајте подешавањима везаним за преузимање датотека", "downloading": "Преузимање у току", @@ -550,6 +557,10 @@ "edit_user": "Уреди корисника", "edited": "Уређено", "editor": "Urednik", + "editor_close_without_save_prompt": "Промене неће бити сачуване", + "editor_close_without_save_title": "Затворити уређивач?", + "editor_crop_tool_h2_aspect_ratios": "Пропорције (aspect ratios)", + "editor_crop_tool_h2_rotation": "Ротација", "email": "Е-пошта", "empty": "", "empty_album": "Isprazni album", @@ -699,6 +710,7 @@ "expired": "Истекло", "expires_date": "Истиче {date}", "explore": "Истражите", + "explorer": "Претраживач (Explorer)", "export": "Извези", "export_as_json": "Извези ЈСОН", "extension": "Екстензија (Extension)", @@ -720,6 +732,7 @@ "filter_people": "Филтрирање особа", "find_them_fast": "Брзо их пронађите по имену помоћу претраге", "fix_incorrect_match": "Исправите нетачно подударање", + "folders": "Фасцикле (Folders)", "force_re-scan_library_files": "Принудно поново скенирајте све датотеке библиотеке", "forward": "Напред", "general": "Генерално", @@ -749,6 +762,10 @@ "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} снимили {person1}, {person2}, и {person3} {date}", "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} снимили {person1}, {person2}, и {additionalCount, number} осталих {date}", "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} снимљено у {city}, {country} {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} снимљено у {city}, {country} са {person1} {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} снимљено у {city}, {country} са {person1} и {person2} {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} снимљено у {city}, {country} са {person1}, {person2}, и {person3} {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} снимљено у {city}, {country} са {person1}, {person2}, и {additionalCount, number} других {date}", "image_alt_text_people": "{count, plural, =1 {са {person1}} =2 {са {person1} и {person2}} =3 {са {person1}, {person2}, и {person3}} other {са {person1}, {person2}, и {others, number} остали}}", "image_alt_text_place": "у {city}, {country}", "image_taken": "{isVideo, select, true {Видео запис снимљен} other {Фотографија усликана}}", @@ -908,6 +925,7 @@ "ok": "Ок", "oldest_first": "Најстарије прво", "onboarding": "Приступање (Онбоардинг)", + "onboarding_privacy_description": "Следеће (опционе) функције се ослањају на спољне услуге и могу се онемогућити у било ком тренутку у подешавањима администрације.", "onboarding_theme_description": "Изаберите тему боја за свој налог. Ово можете касније да промените у подешавањима.", "onboarding_welcome_description": "Хајде да подесимо вашу инстанцу са неким уобичајеним подешавањима.", "onboarding_welcome_user": "Добродошли, {user}", @@ -981,6 +999,7 @@ "previous_memory": "Prethodno сећање", "previous_or_next_photo": "Prethodna или следећа фотографија", "primary": "Примарна (Primary)", + "privacy": "Приватност", "profile_image_of_user": "Слика профила од корисника {user}", "profile_picture_set": "Профилна слика постављена.", "public_album": "Јавни албум", @@ -1019,6 +1038,8 @@ "purchase_settings_server_activated": "Кључем производа сервера управља администратор", "range": "", "rating": "Оцена звездица", + "rating_clear": "Обриши оцену", + "rating_count": "{count, plural, one {# звезда} other {# звезде}}", "rating_description": "Прикажите exif оцену у инфо панелу", "raw": "", "reaction_options": "Опције реакције", @@ -1142,6 +1163,7 @@ "shared_by_user": "Дели {user}", "shared_by_you": "Ви делите", "shared_from_partner": "Слике од {partner}", + "shared_link_options": "Опције дељене везе", "shared_links": "Дељене везе", "shared_photos_and_videos_count": "{assetCount, plural, other {# дељене фотографије и видео записе.}}", "shared_with_partner": "Дели се са {partner}", @@ -1217,7 +1239,7 @@ "to_login": "Пријава", "to_trash": "Смеће", "toggle_settings": "Намести подешавања", - "toggle_theme": "Намести теме", + "toggle_theme": "Намести тамну тему", "toggle_visibility": "Namesti vidljivost", "total_usage": "Укупна употреба", "trash": "Отпад", @@ -1239,6 +1261,7 @@ "unlink_oauth": "Прекини везу са Oauth-om", "unlinked_oauth_account": "Опозвана веза OAuth налога", "unnamed_album": "Неименовани албум", + "unnamed_album_delete_confirmation": "Да ли сте сигурни да желите да избришете овај албум?", "unnamed_share": "Неименовано делење", "unsaved_change": "Несачувана промена", "unselect_all": "Поништи све", diff --git a/web/src/lib/i18n/sr_Latn.json b/web/src/lib/i18n/sr_Latn.json index 5741354bde..63b3ae1f13 100644 --- a/web/src/lib/i18n/sr_Latn.json +++ b/web/src/lib/i18n/sr_Latn.json @@ -129,6 +129,7 @@ "map_enable_description": "Omogućite karakteristike mape", "map_gps_settings": "Map & GPS podešavanja", "map_gps_settings_description": "Upravljajte postavkama mape i GPS-a (obrnuto geokodiranje)", + "map_implications": "Funkcija mape se oslanja na eksternu uslugu pločica (tiles.immich.cloud)", "map_light_style": "Svetli stil", "map_manage_reverse_geocoding_settings": "Upravljajte podešavanjima Obrnuto geokodiranje", "map_reverse_geocoding": "Obrnuto geokodiranje", @@ -320,7 +321,8 @@ "user_settings": "Podešavanja korisnika", "user_settings_description": "Upravljajte korisničkim podešavanjima", "user_successfully_removed": "Korisnik {email} je uspešno uklonjen.", - "version_check_enabled_description": "Omogućite periodične zahteve GitHub-u za proveru novih izdanja", + "version_check_enabled_description": "Omogućite proveru novih izdanja", + "version_check_implications": "Funkcija provere verzije se oslanja na periodičnu komunikaciju sa github.com", "version_check_settings": "Provera verzije", "version_check_settings_description": "Omogućite/onemogućite obaveštenje o novoj verziji", "video_conversion_job": "Transkodiranje video zapisa", @@ -336,7 +338,8 @@ "album_added": "Album dodan", "album_added_notification_setting_description": "Primi obaveštenje e-poštom kad budeš dodan u deljen album", "album_cover_updated": "Omot albuma ažuriran", - "album_delete_confirmation": "Da li stvarno želite da izbrišete album {album}?\nAko se ovaj album deli, drugi korisnici više neće moći da mu pristupe.", + "album_delete_confirmation": "Da li stvarno želite da izbrišete album {album}?", + "album_delete_confirmation_description": "Ako se ovaj album deli, drugi korisnici više neće moći da mu pristupe.", "album_info_updated": "Informacija albuma ažurirana", "album_leave": "Napustiti album?", "album_leave_confirmation": "Da li stvarno želite da napustite {album}?", @@ -360,6 +363,7 @@ "allow_edits": "Dozvoli uređenje", "allow_public_user_to_download": "Dozvolite javnom korisniku da preuzme (download-uje)", "allow_public_user_to_upload": "Dozvoli javnom korisniku da otpremi (upload-uje)", + "anti_clockwise": "U smeru suprotnom od kazaljke na satu", "api_key": "API ključ (key)", "api_key_description": "Ova vrednost će biti prikazana samo jednom. Obavezno kopirajte pre nego što zatvorite prozor.", "api_key_empty": "Ime vašeg API ključa ne bi trebalo da bude prazno", @@ -441,6 +445,7 @@ "clear_all_recent_searches": "Obrišite sve nedavne pretrage", "clear_message": "Obriši poruku", "clear_value": "Jasna vrednost", + "clockwise": "U smeru kazaljke", "close": "Zatvori", "collapse": "Skupi", "collapse_all": "Skupi sve", @@ -517,6 +522,8 @@ "do_not_show_again": "Ne prikaži ponovo ovu poruku", "done": "Urađeno", "download": "Preuzmi", + "download_include_embedded_motion_videos": "Ugrađeni video snimci", + "download_include_embedded_motion_videos_description": "Uključite video zapise ugrađene u fotografije u pokretu kao zasebnu datoteku", "download_settings": "Preuzimanje", "download_settings_description": "Upravljajte podešavanjima vezanim za preuzimanje datoteka", "downloading": "Preuzimanje u toku", @@ -550,6 +557,10 @@ "edit_user": "Uredi korisnika", "edited": "Uređeno", "editor": "Urednik", + "editor_close_without_save_prompt": "Promene neće biti sačuvane", + "editor_close_without_save_title": "Zatvoriti uređivač?", + "editor_crop_tool_h2_aspect_ratios": "Proporcije (aspect ratios)", + "editor_crop_tool_h2_rotation": "Rotacija", "email": "E-pošta", "empty": "", "empty_album": "Isprazni album", @@ -699,6 +710,7 @@ "expired": "Isteklo", "expires_date": "Ističe {date}", "explore": "Istražite", + "explorer": "Pretraživač (Explorer)", "export": "Izvezi", "export_as_json": "Izvezi JSON", "extension": "Ekstenzija (Extension)", @@ -720,6 +732,7 @@ "filter_people": "Filtriranje osoba", "find_them_fast": "Brzo ih pronađite po imenu pomoću pretrage", "fix_incorrect_match": "Ispravite netačno podudaranje", + "folders": "Fascikle (Folders)", "force_re-scan_library_files": "Prinudno ponovo skenirajte sve datoteke biblioteke", "forward": "Napred", "general": "Generalno", @@ -912,6 +925,7 @@ "ok": "Ok", "oldest_first": "Najstarije prvo", "onboarding": "Pristupanje (Onboarding)", + "onboarding_privacy_description": "Sledeće (opcione) funkcije se oslanjaju na spoljne usluge i mogu se onemogućiti u bilo kom trenutku u podešavanjima administracije.", "onboarding_theme_description": "Izaberite temu boja za svoj nalog. Ovo možete kasnije da promenite u podešavanjima.", "onboarding_welcome_description": "Hajde da podesimo vašu instancu sa nekim uobičajenim podešavanjima.", "onboarding_welcome_user": "Dobrodošli, {user}", @@ -985,6 +999,7 @@ "previous_memory": "Prethodno sećanje", "previous_or_next_photo": "Prethodna ili sledeća fotografija", "primary": "Primarna (Primary)", + "privacy": "Privatnost", "profile_image_of_user": "Slika profila od korisnika {user}", "profile_picture_set": "Profilna slika postavljena.", "public_album": "Javni album", @@ -1023,6 +1038,8 @@ "purchase_settings_server_activated": "Ključem proizvoda servera upravlja administrator", "range": "", "rating": "Ocena zvezdica", + "rating_clear": "Obriši ocenu", + "rating_count": "{count, plural, one {# zvezda} other {# zvezde}}", "rating_description": "Prikažite exif ocenu u info panelu", "raw": "", "reaction_options": "Opcije reakcije", @@ -1146,6 +1163,7 @@ "shared_by_user": "Deli {user}", "shared_by_you": "Vi delite", "shared_from_partner": "Slike od {partner}", + "shared_link_options": "Opcije deljene veze", "shared_links": "Deljene veze", "shared_photos_and_videos_count": "{assetCount, plural, other {# deljene fotografije i video zapise.}}", "shared_with_partner": "Deli se sa {partner}", @@ -1221,7 +1239,7 @@ "to_login": "Prijava", "to_trash": "Smeće", "toggle_settings": "Namesti podešavanja", - "toggle_theme": "Namesti teme", + "toggle_theme": "Namesti tamnu temu", "toggle_visibility": "Namesti vidljivost", "total_usage": "Ukupna upotreba", "trash": "Otpad", @@ -1243,6 +1261,7 @@ "unlink_oauth": "Prekini vezu sa Oauth-om", "unlinked_oauth_account": "Opozvana veza OAuth naloga", "unnamed_album": "Neimenovani album", + "unnamed_album_delete_confirmation": "Da li ste sigurni da želite da izbrišete ovaj album?", "unnamed_share": "Neimenovano delenje", "unsaved_change": "Nesačuvana promena", "unselect_all": "Poništi sve", diff --git a/web/src/lib/i18n/sv.json b/web/src/lib/i18n/sv.json index 3eec79b615..6bd9d9b72e 100644 --- a/web/src/lib/i18n/sv.json +++ b/web/src/lib/i18n/sv.json @@ -7,7 +7,7 @@ "actions": "Händelser", "active": "Aktiva", "activity": "Aktivitet", - "activity_changed": "Aktiviteten är {aktiverad, välj, sant {aktiverad} annat {inaktiverad}}", + "activity_changed": "Aktiviteten är {enabled, select, true {aktiverad} other {inaktiverad}}", "add": "Lägg till", "add_a_description": "Lägg till en beskrivning", "add_a_location": "Lägg till en plats", @@ -129,12 +129,13 @@ "map_enable_description": "Aktivera kartfunktioner", "map_gps_settings": "Karta & GPS Inställningar", "map_gps_settings_description": "Ändra kartor & GPS (Omvänd geokodning) inställningar", + "map_implications": "Kartfunktionen är beroende av en extern kartbitstjänst (tiles.immich.cloud)", "map_light_style": "Ljus stil", "map_manage_reverse_geocoding_settings": "Hantera inställningar för Omvänd geokodning", "map_reverse_geocoding": "Omvänd Geokodning", "map_reverse_geocoding_enable_description": "Aktivera omvänd geokodning", "map_reverse_geocoding_settings": "Inställningar för omvänd geokodning", - "map_settings": "Kartinställningar", + "map_settings": "Karta", "map_settings_description": "Hantera kartinställningar", "map_style_description": "URL till en style.json-karto tema", "metadata_extraction_job": "Extrahera metadata", @@ -157,7 +158,7 @@ "notification_email_setting_description": "Inställningar för att skicka epostnotiser", "notification_email_test_email": "Skicka test-epost", "notification_email_test_email_failed": "Misslyckades med att skicka test-epost, undersök dina värden", - "notification_email_test_email_sent": "Ett test-epostmeddelande has skickats till {epost}. Kolla din inkorg.", + "notification_email_test_email_sent": "Ett testmail har skickats till {email}. Kontrollera din inkorg.", "notification_email_username_description": "Användarnamn att använda vid autentisering med epost-servern", "notification_enable_email_notifications": "Aktivera epost-notiser", "notification_settings": "Notisinställningar", @@ -181,12 +182,12 @@ "oauth_settings_description": "Hantera OAuth-logininställningar", "oauth_settings_more_details": "För ytterligare detaljer om denna funktion, se dokumentationen.", "oauth_signing_algorithm": "Signeringsalgoritm", - "oauth_storage_label_claim": "", - "oauth_storage_label_claim_description": "", - "oauth_storage_quota_claim": "", - "oauth_storage_quota_claim_description": "", + "oauth_storage_label_claim": "Användaranknuten lagringsetikett", + "oauth_storage_label_claim_description": "Sätter automatiskt angiven användares lagringsetikett.", + "oauth_storage_quota_claim": "Användaranknuten lagringskvot", + "oauth_storage_quota_claim_description": "Sätter automatiskt angiven användares lagringskvot.", "oauth_storage_quota_default": "Standardlagringskvot (GiB)", - "oauth_storage_quota_default_description": "", + "oauth_storage_quota_default_description": "Kvot i GiB som används när ingen fordran angetts (Ange 0 för obegränsad kvot).", "offline_paths": "Offline-sökvägar", "offline_paths_description": "Dessa resultat kan bero på manuell borttagning av filer som inte är en del av ett externt bibliotek.", "password_enable_description": "Logga in med epost och lösenord", @@ -197,23 +198,36 @@ "refreshing_all_libraries": "Samtliga bibliotek uppdateras", "registration": "Administratörsregistrering", "registration_description": "Du utses till administratör eftersom du är systemets första användare. Du ansvarar för administration och kan skapa ytterligare användare.", - "removing_offline_files": "Tar Bort Offline-Filer", + "removing_offline_files": "Tar bort offline-filer", "repair_all": "Reparera alla", + "repair_matched_items": "Matchade {antal, plural, ett {# föremål} övriga {# föremål}}", + "repaired_items": "Reparerade {count, plural, one {# item} other {# items}}", + "require_password_change_on_login": "Kräv av användaren att byta lösenord vid första inloggning", "reset_settings_to_default": "Återställ inställningar till standard", + "reset_settings_to_recent_saved": "Återställ inställningar till de senaste sparade", + "scanning_library_for_changed_files": "Scannar bibliotek efter ändrade filer", "scanning_library_for_new_files": "Skannar biblioteket efter nya filer", + "send_welcome_email": "Skicka välkomstmail", "server_external_domain_settings": "Extern domän", "server_external_domain_settings_description": "Domän för publikt delade länkar, inklusive http(s)://", "server_settings": "Serverinställningar", "server_settings_description": "Hantera serverinställningar", "server_welcome_message": "Välkomstmeddelande", "server_welcome_message_description": "Ett meddelande som visas på inloggningssidan.", - "sidecar_job_description": "", + "sidecar_job": "Medföljande metadata", + "sidecar_job_description": "Upptäck eller synkronisera medföljande metadata från filsystemet", "slideshow_duration_description": "Antal sekunder att visa varje bild", - "smart_search_job_description": "", - "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", + "smart_search_job_description": "Kör maskininlärning på objekt för att stödja smart sökning", + "storage_template_date_time_description": "Tidsstämpel för resursens skapande används för datum och tidsinformation", + "storage_template_date_time_sample": "Exempeltid {date}", + "storage_template_enable_description": "Aktivera mallmotor för lagring", + "storage_template_hash_verification_enabled": "Hash-verifiering aktiverat", "storage_template_hash_verification_enabled_description": "Aktiverar hash-verifiering, deaktiviera inte om du inte är säker på implikationerna", - "storage_template_migration_job": "", + "storage_template_migration_info": "Ändringar i mall gäller endast nya resurser. För att retoaktivt tillämpa mallen på tidigare uppladdade resurser kör {job}.", + "storage_template_migration_job": "Lagringsmall migreringsjobb", + "storage_template_more_details": "För mer information om den här funktionen se Lagringsmall och dess konsekvenser", + "storage_template_onboarding_description": "Vid aktivering organiserar denna funktion automatiskt filer baserat på en användardefinierad mall. På grunda av stabilitetsproblem är denna funktion avstängd som standard, för mer information se dokumentation.", + "storage_template_path_length": "Uppskattad längdbegränsning på sökväg: {length, number}/{limit, number}", "storage_template_settings": "Lagringsmall", "storage_template_settings_description": "", "system_settings": "Systeminställningar", @@ -221,22 +235,26 @@ "theme_custom_css_settings_description": "", "theme_settings": "Temainställningar", "theme_settings_description": "Hantera anpassningar av webbgränssnittet för Immich", - "thumbnail_generation_job_description": "", + "these_files_matched_by_checksum": "Dessa filer matchas av deras kontrollsummor", + "thumbnail_generation_job": "Generera Miniatyrer", + "thumbnail_generation_job_description": "Generera stora, små och suddiga miniatyrer för varje objekt, samt för varje person", "transcode_policy_description": "", - "transcoding_acceleration_api": "", - "transcoding_acceleration_api_description": "", + "transcoding_acceleration_api": "Accelerations-API", + "transcoding_acceleration_api_description": "API som kommer att interagera med din enhet för att accelerera omkodning. Inställning är 'best effort': vid fel kommer den att återgå till mjukvarubaserad omkodning. VP9 kan fungera eller inte, beroende på din hårdvara.", "transcoding_acceleration_nvenc": "NVENC (kräver NVIDIA GPU)", - "transcoding_acceleration_qsv": "", + "transcoding_acceleration_qsv": "Quick Sync (kräver 7 generationens Intel CPU eller senare)", "transcoding_acceleration_rkmpp": "RKMPP (bara med Rockchip SOCs)", "transcoding_acceleration_vaapi": "VAAPI", - "transcoding_accepted_audio_codecs": "", - "transcoding_accepted_audio_codecs_description": "", - "transcoding_accepted_video_codecs": "", - "transcoding_accepted_video_codecs_description": "", + "transcoding_accepted_audio_codecs": "Accepterade ljud-codecs", + "transcoding_accepted_audio_codecs_description": "Välj vilka ljud-codecs som inte behöver omkodas. Används endast för vissa omkodningspolicyer.", + "transcoding_accepted_containers": "Accepterade behållare", + "transcoding_accepted_video_codecs": "Accepterade video-codecs", + "transcoding_accepted_video_codecs_description": "Välj vilka video-codecs som inte behöver omkodas. Används endast för vissa omkodningspolicyer.", "transcoding_advanced_options_description": "Val som de flesta användare inte bör behöva ändra", - "transcoding_audio_codec": "", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", + "transcoding_audio_codec": "Ljud-codec", + "transcoding_audio_codec_description": "Opus är bästa kvalitetsvalet, men är inte lika kompatibelt med äldre enheter eller mjukvara.", + "transcoding_bitrate_description": "Videor som är i högre än max bithastighet eller inte i ett accepterat format", + "transcoding_codecs_learn_more": "För att läsa mer om terminologin här se FFmpeg-dokumentationen för H.264 kodek, HEVC kodek och VP9 kodek.", "transcoding_constant_quality_mode": "", "transcoding_constant_quality_mode_description": "", "transcoding_constant_rate_factor": "", @@ -246,17 +264,17 @@ "transcoding_hardware_acceleration_description": "", "transcoding_hardware_decoding": "Hårdvaruavkodning", "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", + "transcoding_hevc_codec": "HEVC-codec", "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", + "transcoding_max_b_frames_description": "Högre värden förbättrar kompressionseffektiviteten, men saktar ner kodningen. Kan vara inkompatibel med hårdvaruacceleration på äldre enheter. 0 avaktiverar B-frames, medan -1 anger detta värde automatiskt.", + "transcoding_max_bitrate": "Max bithastighet", "transcoding_max_bitrate_description": "", "transcoding_max_keyframe_interval": "Max nyckelbildruteintervall", "transcoding_max_keyframe_interval_description": "", "transcoding_optimal_description": "", "transcoding_preferred_hardware_device": "", "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", + "transcoding_preset_preset": "Förinställning (-preset)", "transcoding_preset_preset_description": "", "transcoding_reference_frames": "", "transcoding_reference_frames_description": "", @@ -831,7 +849,8 @@ "total_usage": "Total användning", "trash": "Papperskorg", "trash_all": "", - "trash_no_results_message": "", + "trash_no_results_message": "Borttagna foton och videor kommer att visas här.", + "trashed_items_will_be_permanently_deleted_after": "Borttagna objekt kommer att tas bort permanent efter {days, plural, one {# dag} other {# dagar}}.", "type": "Typ", "unarchive": "Ångra arkivering", "unarchived": "", @@ -843,35 +862,40 @@ "unlimited": "Obegränsat", "unlink_oauth": "", "unlinked_oauth_account": "", + "unsaved_change": "Osparade ändringar", "unselect_all": "", "unstack": "Stapla Av", "up_next": "", - "updated_password": "", + "updated_password": "Lösenordet har uppdaterats", "upload": "Ladda upp", "upload_concurrency": "", "upload_status_duplicates": "Dubbletter", "upload_status_errors": "Fel", - "url": "", - "usage": "", + "url": "URL", + "usage": "Användning", "user": "Användare", - "user_id": "", + "user_id": "Användar-ID", + "user_purchase_settings": "Köp", + "user_purchase_settings_description": "Hantera dina köp", "user_usage_detail": "", - "username": "", + "username": "Användarnamn", "users": "Användare", "utilities": "Verktyg", "validate": "Validera", "variables": "Variabler", "version": "Version", + "version_announcement_closing": "Din vän, Alex", "video": "Video", "video_hover_setting_description": "", "videos": "Videor", "videos_count": "{count, plural, one {# Video} other {# Videor}}", "view": "Visa", + "view_album": "Visa Album", "view_all": "Visa alla", "view_all_users": "Visa alla användare", "view_links": "Visa länkar", - "view_next_asset": "", - "view_previous_asset": "", + "view_next_asset": "Visa nästa objekt", + "view_previous_asset": "Visa föregående objekt", "viewer": "", "waiting": "Väntar", "warning": "Varning", @@ -881,5 +905,6 @@ "year": "År", "years_ago": "{years, plural, one {# år} other {# år}} sedan", "yes": "Ja", + "you_dont_have_any_shared_links": "Du har inga delade länkar", "zoom_image": "Zooma bild" } diff --git a/web/src/lib/i18n/ta.json b/web/src/lib/i18n/ta.json index 543bfda2cd..ec3f27124b 100644 --- a/web/src/lib/i18n/ta.json +++ b/web/src/lib/i18n/ta.json @@ -1,4 +1,5 @@ { + "about": "விபரம்", "account": "கணக்கு", "account_settings": "கணக்கு அமைவுகள்", "acknowledge": "ஒப்புக்கொள்கிறேன்", @@ -6,6 +7,7 @@ "actions": "செயல்கள்", "active": "செயல்பாட்டில்", "activity": "செயல்பாடுகள்", + "activity_changed": "செயல்பாடு {இயக்கப்பட்டது, தேர்ந்தெடு, சரி {இயக்கப்பட்டது} மற்றது {முடக்கப்பட்டது}}", "add": "சேர்", "add_a_description": "விவரம் சேர்", "add_a_location": "இடத்தை சேர்க்கவும்", @@ -25,11 +27,11 @@ "added_to_favorites": "விருப்பங்களில் (பேவரிட்ஸ்) சேர்க்கப்பட்டது", "added_to_favorites_count": "விருப்பங்களில் (பேவரிட்ஸ்) {count} சேர்க்கப்பட்டது", "admin": { - "add_exclusion_pattern_description": "", + "add_exclusion_pattern_description": "விலக்கு வடிவங்களைச் சேர்க்கவும். *, **, மற்றும் ? ஆதரிக்கப்படுகிறது. \"Raw\" என்ற பெயரிடப்பட்ட எந்த கோப்பகத்திலும் உள்ள எல்லா கோப்புகளையும் புறக்கணிக்க, \"**/Raw/**\" ஐப் பயன்படுத்தவும். \".tif\" இல் முடியும் எல்லா கோப்புகளையும் புறக்கணிக்க, \"**/*.tif\" ஐப் பயன்படுத்தவும். ஒரு முழுமையான பாதையை புறக்கணிக்க, \"/path/to/ignore/**\" ஐப் பயன்படுத்தவும்.", "authentication_settings": "அடையாள உறுதிப்படுத்தல் அமைப்புகள் (செட்டிங்ஸ்)", "authentication_settings_description": "கடவுச்சொல், OAuth, மற்றும் பிற அடையாள அமைப்புகள்", "authentication_settings_disable_all": "எல்லா உள்நுழைவு முறைகளையும் நிச்சயமாக முடக்க விரும்புகிறீர்களா? உள்நுழைவு முற்றிலும் முடக்கப்படும்.", - "authentication_settings_reenable": "மீண்டும் இயக்க, சர்வர் கட்டளை பயன்படுத்தவும்", + "authentication_settings_reenable": "மீண்டும் இயக்க, சர்வர் கட்டளை பயன்படுத்தவும்.", "background_task_job": "பின்னணி பணிகள்", "check_all": "அனைத்தையும் தேர்ந்தெடு", "cleared_jobs": "முடித்த வேலைகள்: {job}", @@ -47,7 +49,7 @@ "face_detection": "முகம் கண்டறிதல்", "face_detection_description": "இயந்திர கற்றலைப் பயன்படுத்தி சொத்துக்களில் உள்ள முகங்களைக் கண்டறியவும். வீடியோக்களுக்கு, சிறுபடம் மட்டுமே கருதப்படுகிறது. \"அனைத்து\" (மறு-) அனைத்து சொத்துகளையும் செயலாக்குகிறது. இதுவரை செயலாக்கப்படாத புகைப்பட சொத்துக்களை \"காணவில்லை\" வரிசைப்படுத்துகிறது. முகம் கண்டறிதல் முடிந்ததும், கண்டறியப்பட்ட முகங்கள், ஏற்கனவே இருக்கும் அல்லது புதிய நபர்களாகக் குழுவாக்கப்பட்டு, முக அடையாளத்திற்காக வரிசையில் நிறுத்தப்படும்.", "facial_recognition_job_description": "நபர்களின் முகங்களைக் குழு கண்டறிந்தது. முகம் கண்டறிதல் முடிந்ததும் இந்தப் படி இயங்கும். அனைத்து முகங்களையும் \"அனைத்து\" (மறு-) கொத்துகள். \"காணவில்லை\" என்பது நபர் நியமிக்கப்படாத முகங்களை வரிசைப்படுத்துகிறது.", - "failed_job_command": "", + "failed_job_command": "பணிக்கான கட்டளை {command} தோல்வியடைந்தது: {job}", "force_delete_user_warning": "எச்சரிக்கை: இது பயனரையும் அனைத்து புகைப்பட சொத்துகளையும் உடனடியாக அகற்றும். இதை செயல்தவிர்க்க முடியாது மற்றும் புகைப்படங்களை மீட்டெடுக்க முடியாது.", "forcing_refresh_library_files": "அனைத்து லைப்ரரி புகைப்படங்களையும் கட்டாயப்படுத்தி புதுப்பிக்கவும்", "image_format_description": "WebP, JPEG ஐ விட சிறிய கோப்புகளை உருவாக்குகிறது, ஆனால் குறியாக்கம் செய்ய மெதுவாக உள்ளது.", diff --git a/web/src/lib/i18n/th.json b/web/src/lib/i18n/th.json index d7348f37e2..19496b4238 100644 --- a/web/src/lib/i18n/th.json +++ b/web/src/lib/i18n/th.json @@ -223,7 +223,7 @@ "storage_template_migration": "การย้ายเทมเพลตที่เก็บข้อมูล", "storage_template_migration_description": "ใช้{template}ปัจจุบันกับสื่อที่อัพโหลดก่อนหน้านี้", "storage_template_migration_job": "", - "storage_template_settings": "", + "storage_template_settings": "เทมเพลตการจัดเก็บข้อมูล", "storage_template_settings_description": "", "system_settings": "การตั้งค่าระบบ", "theme_custom_css_settings": "CSS กําหนดเอง", diff --git a/web/src/lib/i18n/tr.json b/web/src/lib/i18n/tr.json index 2960af9ff5..7bf59d84db 100644 --- a/web/src/lib/i18n/tr.json +++ b/web/src/lib/i18n/tr.json @@ -27,17 +27,17 @@ "added_to_favorites": "Favorilere eklendi", "added_to_favorites_count": "{count, number} fotoğraf favorilere eklendi", "admin": { - "add_exclusion_pattern_description": "Dışlama desenleri ekleyin. *, ** ve ? kullanılarak globbing desteklenir. Herhangi bir \"Raw\" adlı dizindeki tüm dosyaları yoksaymak için \"**/Raw/**\" kullanın. \".tif\" ile biten tüm dosyaları yoksaymak için \"**/*.tif\" kullanın. Mutlak yolu yoksaymak için \"/path/to/ignore/**\" kullanın.", - "authentication_settings": "Yetkilendirme ayarları", + "add_exclusion_pattern_description": "Dışlama desenleri ekleyin. *, ** ve ? kullanılarak Globbing (temsili yer doldurucu karakter) desteklenir. Farzedelim \"Raw\" adlı bir dizininiz var, içinde ki tüm dosyaları yoksaymak için \"**/Raw/**\" şeklinde yazabilirsiniz. \".tif\" ile biten tüm dosyaları yoksaymak için \"**/*.tif\" yazabilirsiniz. Mutlak yolu yoksaymak için \"/yoksayılacak/olan/yol/**\" şeklinde yazabilirsiniz.", + "authentication_settings": "Yetkilendirme Ayarları", "authentication_settings_description": "Şifre, OAuth, ve diğer yetkilendirme ayarlarını yönet", "authentication_settings_disable_all": "Tüm giriş yöntemlerini devre dışı bırakmak istediğinize emin misiniz? Giriş yapma fonksiyonu tamamen devre dışı bırakılacak.", "authentication_settings_reenable": "Yeniden aktif etmek için Sunucu Komutu'nu kullanın.", - "background_task_job": "Arka plan görevleri", - "check_all": "Hepsini kontrol et", + "background_task_job": "Arka Plan Görevleri", + "check_all": "Hepsini Kontrol Et", "cleared_jobs": "{job} için işler temizlendi", - "config_set_by_file": "Ayarlar şuan için config dosyası tarafından ayarlandı", + "config_set_by_file": "Ayarlar şuanda config dosyası tarafından ayarlanmıştır", "confirm_delete_library": "{library} kütüphanesini silmek istediğinize emin misiniz?", - "confirm_delete_library_assets": "Bu kütüphaneyi silmek istediğinize emin misiniz? Bu işlem {count, plural, one {# contained asset} other {all # contained assets}} tane varlığı Immich'den silecek ve bu işlem geri alınamaz. Silinen dosyalar diskten silinmeyecek.", + "confirm_delete_library_assets": "Bu kütüphaneyi silmek istediğinize emin misiniz? Bu işlem {count, plural, one {# tane varlığı} other {all # tane varlığı}} Immich'den silecek ve bu işlem geri alınamaz. Silinen dosyalar diskten silinmeyecek.", "confirm_email_below": "Onaylamak için aşağıya {email} yazın", "confirm_reprocess_all_faces": "Tüm yüzleri tekrardan işlemek istediğinize emin misiniz? Bu işlem isimlendirilmiş insanları da silecek.", "confirm_user_password_reset": "{user} adlı kullanıcının şifresini sıfırlamak istediğinize emin misiniz?", @@ -46,10 +46,10 @@ "duplicate_detection_job_description": "Benzer fotoğrafları bulmak için makine öğrenmesini çalıştır. Bu işlem Akıllı Arama'ya bağlıdır", "exclusion_pattern_description": "Kütüphaneyi tararken dosya ve klasörleri görmezden gelmek için dışlama desenlerini kullanabilirsiniz. RAW dosyaları gibi bazı dosya ve klasörleri içe aktarmak istemediğinizde bu seçeneği kullanabilirsiniz.", "external_library_created_at": "Dış kütüphane ({date} tarihinde oluşturuldu.)", - "external_library_management": "Dış kütüphane yönetimi", + "external_library_management": "Dış Kütüphane Yönetimi", "face_detection": "Yüz tarama", - "face_detection_description": "Makine öğrenmesini kullanarak medyalardaki yüzleri bulun. Videolar için sadece önizleme görüntüleri kullanılacak. \"All\" tüm medyaları tekrardan işler. \"Missing\" daha önce işlenmemiş medyaları işlenmeleri için sıraya koyar. Tespit edilen yüzler yüz tarama işlemi tamamlandıktan sonra Yüz Tanıma için sıraya koyulacak ve kişiler olarak gruplandırılacak.", - "facial_recognition_job_description": "Tespit edilen yüzleri gruplandır. Bu işlem, yüz tanıma işlemi tamamlandıktan sonra çalışır. \"All\" tüm yüzleri gruplandırır. \"Missing\" ise tespit edilen fakat kişi atanmamış olan yüzleri sıraya koyar.", + "face_detection_description": "Makine öğrenmesini kullanarak içeriklerinizde ki yüzleri bulun. Videolar için sadece önizleme görüntüleri kullanılacak. \"Hepsi\" seçeneği tüm medyaları tekrardan işler. \"İşlenmemiş\" daha önceden işlenmemiş içerikleri işlenmeleri için sıraya koyar. Tespit edilen yüzler yüz tarama işlemi tamamlandıktan sonra Yüz Tanıma için sıraya koyulacak ve kişiler olarak gruplandırılacak.", + "facial_recognition_job_description": "Tespit edilen yüzleri gruplandır. Bu işlem, yüz tanıma işlemi tamamlandıktan sonra çalışır. \"Hepsi\" tüm yüzleri gruplandırır. \"İşlenmemiş\" ise tespit edilen fakat kişi atanmamış olan yüzleri sıraya koyar.", "failed_job_command": "{job} işi için {command} komutu başarısız", "force_delete_user_warning": "UYARI: Bu işlem kullanıcıyı ve bütün verilerini silecek. Bu işlem geri alınamaz ve silinen veriler geri kurtarılamaz.", "forcing_refresh_library_files": "Tüm kütüphane dosyaları yenileniyor", @@ -128,6 +128,7 @@ "map_enable_description": "Harita ayarlarını etkinleştir", "map_gps_settings": "Harita & GPS Ayarları", "map_gps_settings_description": "Harita Yönetimi & GPS (Ters Jeokodlama) Ayarları", + "map_implications": "Harita özelliği, harici bir döşeme hizmetine (tiles.immich.cloud) bağlıdır", "map_light_style": "Açık mod", "map_manage_reverse_geocoding_settings": "Coğrafi Kodlama ayarlarını yönet", "map_reverse_geocoding": "Coğrafi Kodlama", @@ -257,7 +258,7 @@ "transcoding_bitrate_description": "Videolar maksimum bir oranından yürksek ya da kabul edilir bir formatta değil", "transcoding_codecs_learn_more": "Buradaki terminolojiyi öğrenmek için FFmpeg dokümantasyonlarına bakabilirsiniz: H.264, HEVC ve VP9.", "transcoding_constant_quality_mode": "Sabit kalite modu", - "transcoding_constant_quality_mode_description": "", + "transcoding_constant_quality_mode_description": "ICQ, CQP'den daha iyidir, ancak bazı donanım hızlandırma cihazları bu modu desteklemez. Bu seçeneğin ayarlanması, kalite tabanlı kodlama kullanırken belirtilen modu tercih eder. ICQ'yu desteklemediği için NVENC tarafından göz ardı edilir.", "transcoding_constant_rate_factor": "Sabit oran faktörü (-SOF)", "transcoding_constant_rate_factor_description": "Video kalite seviyesi. Tipik değerler H.264 için 23, HEVC için 28, VP9 için 31 ve AV1 için 35'tir. Daha düşük değerler daha iyi kalite sağlar, ancak daha büyük dosyalar üretir.", "transcoding_disabled_description": "Videoları dönüştürmeyin, bazı istemcilerde oynatma bozulabilir", @@ -272,16 +273,16 @@ "transcoding_max_bitrate_description": "Maksimum bit hızı ayarlamak, kaliteye küçük bir maliyetle dosya boyutlarını daha öngörülebilir hale getirebilir.", "transcoding_max_keyframe_interval": "", "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", + "transcoding_optimal_description": "Hedef çözünürlükten yüksek veya kabul edilen formatta olmayan videolar", "transcoding_preferred_hardware_device": "Tercih edilen donanım cihazı", "transcoding_preferred_hardware_device_description": "Sadece VAAPI ve QSV için uygulanır. Donanım kod çevrimi için DRI Node ayarlar.", "transcoding_preset_preset": "", "transcoding_preset_preset_description": "Sıkıştırma hızı. Daha yavaş olan ayarlar belirli bitrate ayarları için daha küçük ve daha kaliteli dosya üretir. VP9 ayarı 'daha hızlı' ayarının üstündeki ayarları görmezden gelir.", "transcoding_reference_frames": "Referans kareler", "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", + "transcoding_required_description": "Yalnızca kabul edilen formatta olmayan videolar", + "transcoding_settings": "Video Dönüştürme Ayarları", + "transcoding_settings_description": "Video dosyalarının çözünürlük ve kodlama bilgilerini yönetir", "transcoding_target_resolution": "Hedef çözünürlük", "transcoding_target_resolution_description": "Daha yüksek çözünürlükler daha fazla detayı koruyabilir fakat işlemesi daha uzun sürer, dosya boyutu daha yüksek olur ve uygulamanın akıcılığını etkileyebilir.", "transcoding_temporal_aq": "", @@ -289,9 +290,9 @@ "transcoding_threads": "İş Parçacıkları", "transcoding_threads_description": "", "transcoding_tone_mapping": "Ton-haritalama", - "transcoding_tone_mapping_description": "", + "transcoding_tone_mapping_description": "HDR videoların SDR'ye dönüştürülürken görünümünü korumayı amaçlar. Her algoritma renk, detay ve parlaklık için farklı dengeleme yapar. Hable detayları korur, Mobius renkleri korur ve Reinhard parlaklığı korur.", "transcoding_tone_mapping_npl": "", - "transcoding_tone_mapping_npl_description": "", + "transcoding_tone_mapping_npl_description": "Renkler, bu parlaklıkta bir ekran için normal görünecek şekilde ayarlanacaktır. Karşıt olarak, daha düşük değerler videonun parlaklığını artırır ve tersi de geçerlidir çünkü ekranın parlaklığını telafi eder. 0 bu değeri otomatik olarak ayarlar.", "transcoding_transcode_policy": "", "transcoding_transcode_policy_description": "", "transcoding_two_pass_encoding": "", diff --git a/web/src/lib/i18n/uk.json b/web/src/lib/i18n/uk.json index 0b8241d89e..00137c37e4 100644 --- a/web/src/lib/i18n/uk.json +++ b/web/src/lib/i18n/uk.json @@ -1,7 +1,7 @@ { "about": "Про програму", "account": "Обліковий запис", - "account_settings": "Налаштування Облікового запису", + "account_settings": "Налаштування профілю", "acknowledge": "Прийняти", "action": "Дія", "actions": "Дії", @@ -47,7 +47,7 @@ "duplicate_detection_job_description": "Запустити машинне навчання на активах для виявлення схожих зображень. Залежить від інтелектуального пошуку", "exclusion_pattern_description": "Шаблони виключень дозволяють ігнорувати файли та папки під час сканування вашої бібліотеки. Це корисно, якщо у вас є папки, які містять файли, які ви не хочете імпортувати, наприклад, RAW-файли.", "external_library_created_at": "Зовнішня бібліотека (створена {date})", - "external_library_management": "Управління Зовнішньою Бібліотекою", + "external_library_management": "Керування зовнішніми бібліотеками", "face_detection": "Виявлення обличчя", "face_detection_description": "Виявлення обличчя на активах з використанням машинного навчання. Для відео розглядається лише ескіз. Опція \"Усі\" повторно обробляє всі активи. Опція \"Відсутні\" ставить в чергу активи, які ще не були оброблені. Виявлені обличчя будуть поставлені в чергу для визначення обличчя після завершення виявлення обличчя, групуючи їх в існуючих або нових людей.", "facial_recognition_job_description": "Групувати виявлені обличчя у людей. Цей крок виконується після завершення виявлення обличчя. Опція \"Усі\" перегруповує всі обличчя. Опція \"Відсутні\" ставить в чергу обличчя, які ще не мають призначеної особи.", @@ -129,12 +129,13 @@ "map_enable_description": "Увімкнути функції мапи", "map_gps_settings": "Налаштування карти та GPS", "map_gps_settings_description": "Керування налаштуваннями карти та GPS (зворотний геокодинг)", + "map_implications": "Функція карти використовує зовнішній сервіс плиток (tiles.immich.cloud)", "map_light_style": "Світлий стиль", "map_manage_reverse_geocoding_settings": "Керувати налаштуваннями зворотного геокодування", "map_reverse_geocoding": "Зворотне геокодування", "map_reverse_geocoding_enable_description": "Увімкнути зворотне геокодування", "map_reverse_geocoding_settings": "Налаштування зворотного геокодування", - "map_settings": "Налаштування Мапи", + "map_settings": "Мапа", "map_settings_description": "Управління налаштуваннями мапи", "map_style_description": "URL до теми мапи у форматі style.json", "metadata_extraction_job": "Витягнути метадані", @@ -209,7 +210,7 @@ "send_welcome_email": "Надіслати лист з вітанням", "server_external_domain_settings": "Зовнішній домен", "server_external_domain_settings_description": "Домен для публічних загальнодоступних посилань, включаючи http(s)://", - "server_settings": "Налаштування Серверу", + "server_settings": "Налаштування сервера", "server_settings_description": "Керування налаштуваннями сервера", "server_welcome_message": "Вітальне повідомлення", "server_welcome_message_description": "Повідомлення, яке відображається на сторінці входу.", @@ -278,11 +279,11 @@ "transcoding_preferred_hardware_device": "Переважний апаратний пристрій", "transcoding_preferred_hardware_device_description": "Застосовується тільки до VAAPI і QSV. Встановлює вузол DRI, який використовується для апаратного транскодування.", "transcoding_preset_preset": "Параметр (-preset)", - "transcoding_preset_preset_description": "Швидкість стиснення. Повільніше предустановки створюють менші файли і підвищують якість при встановленні певного бітрейту. VP9 ігнорує швидкості вище `faster`.", + "transcoding_preset_preset_description": "Швидкість стиснення. Повільніші пресети створюють менші файли і підвищують якість при певному бітрейті. VP9 ігнорує швидкості вище 'швидше'.", "transcoding_reference_frames": "Основні кадри", "transcoding_reference_frames_description": "Кількість кадрів, на які посилається при стисненні даного кадру. Вищі значення покращують ефективність стиснення, але збільшують час кодування. Значення 0 автоматично налаштовує це значення.", "transcoding_required_description": "Лише відео, що не у прийнятому форматі", - "transcoding_settings": "Налаштування Транскодування Відео", + "transcoding_settings": "Налаштування транскодування відео", "transcoding_settings_description": "Керування роздільною здатністю та кодуванням відеофайлів", "transcoding_target_resolution": "Роздільна здатність", "transcoding_target_resolution_description": "Вищі роздільні здатності можуть зберігати більше деталей, але займають більше часу на кодування, мають більші розміри файлів і можуть зменшити швидкість роботи додатку.", @@ -312,7 +313,7 @@ "user_delete_delay_settings_description": "Кількість днів після видалення для остаточного видалення акаунта користувача та його ресурсів. Задача видалення користувача запускається опівночі для перевірки користувачів, готових до видалення. Зміни цього налаштування будуть оцінені під час наступного виконання.", "user_delete_immediately": "Акаунт та ресурси користувача {user} будуть негайно поставлені в чергу на остаточне видалення.", "user_delete_immediately_checkbox": "Поставити користувача та ресурси в чергу для негайного видалення", - "user_management": "Управління користувачами", + "user_management": "Керування користувачами", "user_password_has_been_reset": "Пароль користувача було скинуто:", "user_password_reset_description": "Будь ласка, надайте користувачеві тимчасовий пароль і повідомте йому, що він повинен буде змінити пароль при наступному вході.", "user_restore_description": "Акаунт {user} буде відновлено.", @@ -320,7 +321,8 @@ "user_settings": "Налаштування користувача", "user_settings_description": "Керування налаштуваннями користувачів", "user_successfully_removed": "Користувача з електронною поштою {email} успішно видалено.", - "version_check_enabled_description": "Увімкнення періодичних запитів до GitHub для перевірки нових випусків", + "version_check_enabled_description": "Увімкнути перевірку версії", + "version_check_implications": "Функція перевірки версії залежить від періодичної комунікації з github.com", "version_check_settings": "Перевірка версії", "version_check_settings_description": "Увімкнути/вимкнути сповіщення про нову версію", "video_conversion_job": "Перекодувати відео", @@ -336,7 +338,8 @@ "album_added": "Альбом додано", "album_added_notification_setting_description": "Отримувати повідомлення по електронній пошті, коли вас додають до спільного альбому", "album_cover_updated": "Обкладинка альбому оновлена", - "album_delete_confirmation": "Ви впевнені, що хочете видалити альбом {album}?\nЯкщо цей альбом є спільним, інші користувачі більше не зможуть отримувати до нього доступ.", + "album_delete_confirmation": "Ви впевнені, що хочете видалити альбом {album}?", + "album_delete_confirmation_description": "Якщо альбом був спільним, інші користувачі не зможуть отримати доступ до нього.", "album_info_updated": "Інформація про альбом оновлена", "album_leave": "Залишити альбом?", "album_leave_confirmation": "Ви впевнені, що хочете залишити альбом {album}?", @@ -360,6 +363,7 @@ "allow_edits": "Дозволити редагування", "allow_public_user_to_download": "Дозволити публічному користувачеві завантажувати файли", "allow_public_user_to_upload": "Дозволити публічним користувачам завантажувати", + "anti_clockwise": "Проти годинникової стрілки", "api_key": "Ключ API", "api_key_description": "Це значення буде показане лише один раз. Будь ласка, обов'язково скопіюйте його перед закриттям вікна.", "api_key_empty": "Назва вашого ключа API не може бути порожньою", @@ -440,6 +444,7 @@ "clear_all_recent_searches": "Очистити всі останні пошукові запити", "clear_message": "Очистити повідомлення", "clear_value": "Очистити значення", + "clockwise": "По годинниковій стрілці", "close": "Закрити", "collapse": "Згорнути", "collapse_all": "Згорнути все", @@ -516,6 +521,8 @@ "do_not_show_again": "Не показувати це повідомлення знову", "done": "Готово", "download": "Скачати", + "download_include_embedded_motion_videos": "Вбудовані відео", + "download_include_embedded_motion_videos_description": "Включати відео, вбудовані в рухомі фотографії, як окремий файл", "download_settings": "Скачати", "download_settings_description": "Керування налаштуваннями, пов'язаними з завантаженням ресурсів", "downloading": "Скачування", @@ -548,7 +555,11 @@ "edit_title": "Редагувати заголовок", "edit_user": "Редагувати користувача", "edited": "Відредаговано", - "editor": "", + "editor": "Редактор", + "editor_close_without_save_prompt": "Зміни не будуть збережені", + "editor_close_without_save_title": "Закрити редактор?", + "editor_crop_tool_h2_aspect_ratios": "Пропорції зображення", + "editor_crop_tool_h2_rotation": "Орієнтація", "email": "Електронна пошта", "empty": "", "empty_album": "", @@ -698,6 +709,7 @@ "expired": "Закінчився термін дії", "expires_date": "Термін дії закінчується {date}", "explore": "Дослідити", + "explorer": "Провідник", "export": "Експортувати", "export_as_json": "Експорт в JSON", "extension": "Розширення", @@ -719,6 +731,7 @@ "filter_people": "Фільтр по людях", "find_them_fast": "Швидко знаходьте їх за назвою за допомогою пошуку", "fix_incorrect_match": "Виправити неправильний збіг", + "folders": "Папки", "force_re-scan_library_files": "Примусово пересканувати всі файли бібліотеки", "forward": "Переслати", "general": "Загальні", @@ -911,6 +924,7 @@ "ok": "ОК", "oldest_first": "Спочатку найстарші", "onboarding": "Введення", + "onboarding_privacy_description": "Наступні (необов'язкові) функції залежать від зовнішніх сервісів і можуть бути вимкнені в будь-який час у налаштуваннях адміністрації.", "onboarding_theme_description": "Виберіть колірну тему для свого екземпляра. Ви можете змінити її пізніше в налаштуваннях.", "onboarding_welcome_description": "Давайте налаштуємо ваш екземпляр за допомогою деяких загальних параметрів.", "onboarding_welcome_user": "Ласкаво просимо, {user}", @@ -927,7 +941,7 @@ "other": "Інше", "other_devices": "Інші пристрої", "other_variables": "Інші змінні", - "owned": "У власності", + "owned": "Власні", "owner": "Власник", "partner": "Партнер", "partner_can_access": "{partner} має доступ", @@ -983,6 +997,7 @@ "previous_memory": "Попередній спогад", "previous_or_next_photo": "Попередня або наступна фотографія", "primary": "Головне", + "privacy": "Конфіденційність", "profile_image_of_user": "Зображення профілю {user}", "profile_picture_set": "Зображення профілю встановлено.", "public_album": "Публічний альбом", @@ -1021,6 +1036,9 @@ "purchase_settings_server_activated": "Ключ продукту сервера керується адміністратором", "range": "", "rating": "Зоряний рейтинг", + "rating_clear": "Очистити рейтинг", + "rating_count": "{count, plural, one {# зірка} few {# зірки} many {# зірок} other {# зірок}}", + "rating_description": "Показувати рейтинг EXIF в інформаційній панелі", "raw": "", "reaction_options": "Опції реакції", "read_changelog": "Прочитати зміни в оновленні", @@ -1125,8 +1143,8 @@ "send_message": "Надіслати повідомлення", "send_welcome_email": "Надішліть вітальний лист", "server": "Сервер", - "server_offline": "Сервер відключено", - "server_online": "Сервер підключено", + "server_offline": "Сервер офлайн", + "server_online": "Сервер онлайн", "server_stats": "Статистика сервера", "server_version": "Версія сервера", "set": "Встановіть", @@ -1143,6 +1161,7 @@ "shared_by_user": "Спільний доступ з {user}", "shared_by_you": "Ви поділились", "shared_from_partner": "Фото від {partner}", + "shared_link_options": "Опції спільних посилань", "shared_links": "Спільні посилання", "shared_photos_and_videos_count": "{assetCount, plural, other {# спільні фотографії та відео.}}", "shared_with_partner": "Спільно з {partner}", @@ -1151,6 +1170,7 @@ "sharing_sidebar_description": "Відображати посилання на загальний доступ у бічній панелі", "shift_to_permanent_delete": "натисніть ⇧ щоб видалити об'єкт назавжди", "show_album_options": "Показати параметри альбому", + "show_albums": "Показувати альбоми", "show_all_people": "Показати всіх людей", "show_and_hide_people": "Показати та приховати людей", "show_file_location": "Показати розташування файлу", @@ -1179,10 +1199,12 @@ "sort_items": "Кількість елементів", "sort_modified": "Дата зміни", "sort_oldest": "Старі фото", - "sort_recent": "Нещодавні фото", + "sort_recent": "Нещодавні", "sort_title": "Заголовок", "source": "Джерело", "stack": "Стек", + "stack_duplicates": "Групувати дублікати", + "stack_select_one_photo": "Вибрати одне основне фото для групи", "stack_selected_photos": "Сгрупувати обрані фотографії", "stacked_assets_count": "Згруповано {count, plural, one {# ресурс} few {# ресурси} many {# ресурсів} other {# ресурсів}}", "stacktrace": "Стек викликів", @@ -1194,7 +1216,7 @@ "stop_photo_sharing": "Припинити надання ваших знімків?", "stop_photo_sharing_description": "{partner} більше не матиме доступу до ваших фотографій.", "stop_sharing_photos_with_user": "Припинити ділитися своїми фотографіями з цим користувачем", - "storage": "Місце для зберігання", + "storage": "Сховище", "storage_label": "Мітка для зберігання", "storage_usage": "{used} з {available} доступних", "submit": "Підтвердити", @@ -1237,6 +1259,7 @@ "unlink_oauth": "Від'єднайте OAuth", "unlinked_oauth_account": "Відключити акаунт OAuth", "unnamed_album": "Альбом без назви", + "unnamed_album_delete_confirmation": "Ви впевнені, що бажаєте видалити цей альбом?", "unnamed_share": "Спільний доступ без назви", "unsaved_change": "Незбережена зміна", "unselect_all": "Зняти все", diff --git a/web/src/lib/i18n/vi.json b/web/src/lib/i18n/vi.json index 58fb4a85f3..ff6fb87193 100644 --- a/web/src/lib/i18n/vi.json +++ b/web/src/lib/i18n/vi.json @@ -129,6 +129,7 @@ "map_enable_description": "Bật tính năng bản đồ", "map_gps_settings": "Bản đồ & GPS", "map_gps_settings_description": "Quản lý cài đặt Bản đồ & GPS (Mã hóa địa lý ngược)", + "map_implications": "Tính năng bản đồ phụ thuộc vào dịch vụ thẻ bản đồ bên ngoài (tiles.immich.cloud)", "map_light_style": "Giao diện sáng", "map_manage_reverse_geocoding_settings": "Quản lý cài đặt Mã hóa địa lý ngược", "map_reverse_geocoding": "Mã hoá địa lý ngược (Reverse Geocoding)", @@ -222,7 +223,7 @@ "storage_template_enable_description": "Bật công cụ mẫu lưu trữ", "storage_template_hash_verification_enabled": "Bật xác minh băm", "storage_template_hash_verification_enabled_description": "Bật xác minh băm, không tắt tính năng này trừ khi bạn chắc chắn về các rủi ro có thể xảy ra", - "storage_template_migration": "Dịch chuyển mẫu lưu trữ", + "storage_template_migration": "Di chuyển mẫu lưu trữ", "storage_template_migration_description": "Áp dụng {template} hiện tại cho các ảnh đã được tải lên trước đây", "storage_template_migration_info": "Các thay đổi mẫu chỉ áp dụng cho các ảnh mới. Để áp dụng lại mẫu cho các ảnh đã được tải lên trước đây, hãy chạy {job}.", "storage_template_migration_job": "Tác vụ di chuyển mẫu lưu trữ", @@ -320,7 +321,8 @@ "user_settings": "Người dùng", "user_settings_description": "Quản lý cài đặt người dùng", "user_successfully_removed": "Người dùng {email} đã được xóa thành công.", - "version_check_enabled_description": "Bật gửi yêu cầu định kỳ đến GitHub để kiểm tra các bản phát hành mới", + "version_check_enabled_description": "Bật kiểm tra phiên bản", + "version_check_implications": "Tính năng kiểm tra phiên bản yêu cầu kết nối thường xuyên đến github.com", "version_check_settings": "Kiểm tra phiên bản", "version_check_settings_description": "Bật/tắt thông báo phiên bản mới", "video_conversion_job": "Chuyển mã video", @@ -336,7 +338,8 @@ "album_added": "Đã thêm album", "album_added_notification_setting_description": "Nhận thông báo qua email khi bạn được thêm vào một album chia sẻ", "album_cover_updated": "Đã cập nhật ảnh bìa album", - "album_delete_confirmation": "Bạn có chắc chắn muốn xóa album {album} không?\nNếu album này đang được chia sẻ, các người dùng khác sẽ không còn truy cập được nữa.", + "album_delete_confirmation": "Bạn có chắc chắn muốn xóa album {album} không?", + "album_delete_confirmation_description": "Nếu album này được chia sẻ, các người dùng khác sẽ không còn truy cập được nữa.", "album_info_updated": "Đã cập nhật thông tin album", "album_leave": "Rời album?", "album_leave_confirmation": "Bạn có chắc chắn muốn rời khỏi {album} không?", @@ -360,6 +363,7 @@ "allow_edits": "Cho phép chỉnh sửa", "allow_public_user_to_download": "Cho phép người dùng công khai tải xuống", "allow_public_user_to_upload": "Cho phép người dùng công khai tải lên", + "anti_clockwise": "Xoay trái", "api_key": "Khóa API", "api_key_description": "Giá trị này chỉ được hiển thị một lần. Vui lòng sao chép nó trước khi đóng cửa sổ.", "api_key_empty": "Tên khóa API của bạn không được để trống", @@ -369,7 +373,7 @@ "archive": "Lưu trữ", "archive_or_unarchive_photo": "Lưu trữ hoặc huỷ lưu trữ ảnh", "archive_size": "Kích thước gói nén", - "archive_size_description": "Cấu hình kích thước cho các tập tin nén tải về (đơn vị GiB)", + "archive_size_description": "Cấu hình kích thước nén cho các tập tin tải xuống (đơn vị GiB)", "archived": "", "archived_count": "{count, plural, other {Đã lưu trữ # mục}}", "are_these_the_same_person": "Đây có phải cùng một người không?", @@ -440,10 +444,11 @@ "clear_all_recent_searches": "Xóa tất cả tìm kiếm gần đây", "clear_message": "Xóa tin nhắn", "clear_value": "Xóa giá trị", + "clockwise": "Xoay phải", "close": "Đóng", "collapse": "Thu gọn", "collapse_all": "Thu gọn tất cả", - "color_theme": "Giao diện màu", + "color_theme": "Chủ đề màu sắc", "comment_deleted": "Bình luận đã bị xóa", "comment_options": "Tùy chọn bình luận", "comments_and_likes": "Bình luận & lượt thích", @@ -480,7 +485,7 @@ "created": "Đã tạo", "current_device": "Thiết bị hiện tại", "custom_locale": "Ngôn ngữ và khu vực tùy chỉnh", - "custom_locale_description": "Định dạng ngày tháng và số dựa trên ngôn ngữ và khu vực", + "custom_locale_description": "Định dạng ngày và số dựa trên ngôn ngữ và khu vực", "dark": "Tối", "date_after": "Ngày sau", "date_and_time": "Ngày và giờ", @@ -490,7 +495,7 @@ "day": "Ngày", "deduplicate_all": "Xóa tất cả mục trùng lặp", "default_locale": "Ngôn ngữ và khu vực mặc định", - "default_locale_description": "Định dạng ngày tháng và số dựa trên ngôn ngữ của trình duyệt của bạn", + "default_locale_description": "Định dạng ngày và số dựa trên ngôn ngữ trình duyệt của bạn", "delete": "Xóa", "delete_album": "Xóa album", "delete_api_key_prompt": "Bạn có chắc chắn muốn xóa khóa API này không?", @@ -506,7 +511,7 @@ "direction": "Hướng", "disabled": "Tắt", "disallow_edits": "Không cho phép chỉnh sửa", - "discover": "Khám phá", + "discover": "Tìm", "dismiss_all_errors": "Bỏ qua tất cả lỗi", "dismiss_error": "Bỏ qua lỗi", "display_options": "Tùy chọn hiển thị", @@ -516,6 +521,8 @@ "do_not_show_again": "Không hiển thị thông báo này nữa", "done": "Xong", "download": "Tải xuống", + "download_include_embedded_motion_videos": "Các video nhúng", + "download_include_embedded_motion_videos_description": "Gồm các video được nhúng trong ảnh chuyển động thành một tập tin riêng", "download_settings": "Tải xuống", "download_settings_description": "Quản lý cài đặt liên quan đến việc tải ảnh xuống", "downloading": "Đang tải xuống", @@ -548,7 +555,11 @@ "edit_title": "Chỉnh sửa tiêu đề", "edit_user": "Chỉnh sửa người dùng", "edited": "Đã chỉnh sửa", - "editor": "", + "editor": "Trình chỉnh sửa", + "editor_close_without_save_prompt": "Những thay đổi sẽ không được lưu", + "editor_close_without_save_title": "Đóng trình chỉnh sửa?", + "editor_crop_tool_h2_aspect_ratios": "Tỷ lệ khung hình", + "editor_crop_tool_h2_rotation": "Xoay", "email": "Email", "empty": "", "empty_album": "", @@ -698,6 +709,7 @@ "expired": "Hết hạn", "expires_date": "Hết hạn vào {date}", "explore": "Khám phá", + "explorer": "Khám phá", "export": "Xuất", "export_as_json": "Xuất dưới dạng JSON", "extension": "Phần mở rộng", @@ -719,6 +731,7 @@ "filter_people": "Lọc người", "find_them_fast": "Tìm nhanh bằng tên với tìm kiếm", "fix_incorrect_match": "Sửa lỗi trùng khớp không chính xác", + "folders": "Thư mục", "force_re-scan_library_files": "Yêu cầu quét lại tất cả các tập tin thư viện", "forward": "Tiến về phía trước", "general": "Chung", @@ -883,6 +896,7 @@ "ok": "Đồng ý", "oldest_first": "Cũ nhất trước", "onboarding": "Hướng dẫn sử dụng", + "onboarding_privacy_description": "Các tính năng (tùy chọn) sau đây phụ thuộc vào các dịch vụ bên ngoài và có thể bị tắt bất kỳ lúc nào trong cài đặt quản trị.", "onboarding_theme_description": "Chọn chủ đề màu sắc cho tài khoản riêng của bạn. Bạn có thể thay đổi điều này sau trong cài đặt của bạn.", "onboarding_welcome_description": "Hãy thiết lập tài khoản riêng của bạn với một số cài đặt cơ bản.", "onboarding_welcome_user": "Chào mừng, {user}", @@ -945,7 +959,7 @@ "places": "Địa điểm", "play": "Phát", "play_memories": "Phát kỷ niệm", - "play_motion_photo": "Phát ảnh động", + "play_motion_photo": "Phát ảnh chuyển động", "play_or_pause_video": "Phát hoặc tạm dừng video", "point": "", "port": "Cổng", @@ -955,6 +969,7 @@ "previous_memory": "Kỷ niệm trước", "previous_or_next_photo": "Ảnh trước hoặc sau", "primary": "Chính", + "privacy": "Bảo mật", "profile_image_of_user": "Ảnh đại diệncủa {user}", "profile_picture_set": "Ảnh đại diện đã được đặt.", "public_album": "Album công khai", @@ -968,7 +983,7 @@ "purchase_button_buy_immich": "Mua Immich", "purchase_button_never_show_again": "Không hiển thị lại", "purchase_button_reminder": "Nhắc tôi trong 30 ngày", - "purchase_button_remove_key": "Xoá khóa", + "purchase_button_remove_key": "Xóa khóa", "purchase_button_select": "Chọn", "purchase_failed_activation": "Kích hoạt thất bại! Vui lòng kiểm tra email của bạn để biết khóa sản phẩm chính xác!", "purchase_individual_description_1": "Dành cho cá nhân", @@ -983,9 +998,9 @@ "purchase_panel_title": "Hỗ trợ dự án", "purchase_per_server": "Mỗi máy chủ", "purchase_per_user": "Mỗi người dùng", - "purchase_remove_product_key": "Xoá khóa sản phẩm", + "purchase_remove_product_key": "Xóa khóa sản phẩm", "purchase_remove_product_key_prompt": "Bạn có chắc chắn muốn xoá khóa sản phẩm?", - "purchase_remove_server_product_key": "Xoá khóa sản phẩm máy chủ", + "purchase_remove_server_product_key": "Xóa khóa sản phẩm máy chủ", "purchase_remove_server_product_key_prompt": "Bạn có chắc chắn muốn xoá khóa sản phẩm máy chủ?", "purchase_server_description_1": "Dành cho toàn bộ máy chủ", "purchase_server_description_2": "Trạng thái người hỗ trợ", @@ -993,6 +1008,8 @@ "purchase_settings_server_activated": "Khóa sản phẩm máy chủ được quản lý bởi quản trị viên", "range": "", "rating": "Xếp hạng sao", + "rating_clear": "Xóa đánh giá", + "rating_count": "{count, plural, one {# sao} other {# sao}}", "rating_description": "Hiển thị xếp hạng ảnh trong bảng thông tin", "raw": "", "reaction_options": "Tùy chọn phản ứng", @@ -1012,16 +1029,16 @@ "refreshing_encoded_video": "Đang làm mới video đã mã hóa", "refreshing_metadata": "Đang làm mới metadata", "regenerating_thumbnails": "Đang tạo lại hình thu nhỏ", - "remove": "Xoá", + "remove": "Xóa", "remove_assets_album_confirmation": "Bạn có chắc chắn muốn xoá {count, plural, one {# mục} other {# mục}} khỏi album?", "remove_assets_shared_link_confirmation": "Bạn có chắc chắn muốn xoá {count, plural, one {# mục} other {# mục}} khỏi liên kết chia sẻ này?", - "remove_assets_title": "Xoá mục?", + "remove_assets_title": "Xóa mục?", "remove_custom_date_range": "Bỏ chọn khoảng ngày tùy chỉnh", - "remove_from_album": "Xoá khỏi album", - "remove_from_favorites": "Xoá khỏi Mục yêu thích", - "remove_from_shared_link": "Xoá khỏi liên kết chia sẻ", + "remove_from_album": "Xóa khỏi album", + "remove_from_favorites": "Xóa khỏi Mục yêu thích", + "remove_from_shared_link": "Xóa khỏi liên kết chia sẻ", "remove_offline_files": "Loại bỏ tập tin ngoại tuyến", - "remove_user": "Xoá người dùng", + "remove_user": "Xóa người dùng", "removed_api_key": "Khóa API đã xóa: {name}", "removed_from_archive": "Đã xoá khỏi Kho lưu trữ", "removed_from_favorites": "Đã xoá khỏi Mục yêu thích", @@ -1163,7 +1180,7 @@ "stack_selected_photos": "Nhóm các ảnh đã chọn", "stacked_assets_count": "Đã nhóm {count, plural, one {# mục} other {# mục}}", "stacktrace": "Thông tin chi tiết lỗi", - "start": "Bắt đầu", + "start": "Chạy", "start_date": "Ngày bắt đầu", "state": "Tỉnh", "status": "Trạng thái", @@ -1180,9 +1197,9 @@ "swap_merge_direction": "Đổi hướng hợp nhất", "sync": "Đồng bộ", "template": "Mẫu", - "theme": "Giao diện", - "theme_selection": "Giao diện tổng thể", - "theme_selection_description": "Tự động đặt giao diện sáng hoặc tối dựa trên tùy chọn hệ thống của trình duyệt của bạn", + "theme": "Chủ đề", + "theme_selection": "Chủ đề tổng thể", + "theme_selection_description": "Tự động đặt chủ đề sáng hoặc tối dựa trên tùy chọn hệ thống của trình duyệt của bạn", "they_will_be_merged_together": "Chúng sẽ được hợp nhất với nhau", "time_based_memories": "Kỷ niệm dựa trên thời gian", "timezone": "Múi giờ", @@ -1190,14 +1207,14 @@ "to_change_password": "Đổi mật khẩu", "to_favorite": "Yêu thích", "to_login": "Đăng nhập", - "to_trash": "Xoá", + "to_trash": "Xóa", "toggle_settings": "Chuyển đổi cài đặt", - "toggle_theme": "Chuyển đổi giao diện", + "toggle_theme": "Chuyển đổi chủ đề tối", "toggle_visibility": "", "total_usage": "Tổng dung lượng đã sử dụng", "trash": "Thùng rác", - "trash_all": "Xoá hết", - "trash_count": "Xoá {count, number} mục", + "trash_all": "Xóa hết", + "trash_count": "Xóa {count, number} mục", "trash_delete_asset": "Chuyển vào thùng rác/Xóa vĩnh viễn", "trash_no_results_message": "Ảnh và video đã bị xoá sẽ hiển thị ở đây.", "trashed_items_will_be_permanently_deleted_after": "Các mục đã xóa sẽ bị xóa vĩnh viễn sau {days, plural, one {# ngày} other {# ngày}}.", @@ -1214,6 +1231,7 @@ "unlink_oauth": "Huỷ liên kết OAuth", "unlinked_oauth_account": "Đã huỷ liên kết tài khoản OAuth", "unnamed_album": "Album chưa đặt tên", + "unnamed_album_delete_confirmation": "Bạn có chắc chắn muốn xóa album này không?", "unnamed_share": "Chia sẻ chưa đặt tên", "unsaved_change": "Thay đổi chưa lưu", "unselect_all": "Bỏ chọn tất cả", diff --git a/web/src/lib/i18n/zh_Hant.json b/web/src/lib/i18n/zh_Hant.json index f0787bd5b3..0ef3dca88a 100644 --- a/web/src/lib/i18n/zh_Hant.json +++ b/web/src/lib/i18n/zh_Hant.json @@ -24,8 +24,8 @@ "add_to_album": "加入相簿", "add_to_shared_album": "加入共享相簿", "added_to_archive": "已加入封存", - "added_to_favorites": "新增至收藏", - "added_to_favorites_count": "已新增 {count, number} 個項目至收藏", + "added_to_favorites": "已加入收藏", + "added_to_favorites_count": "已把 {count, number} 個項目加入收藏", "admin": { "add_exclusion_pattern_description": "新增排除規則。支援使用「*」、「 **」、「?」來匹配字串。如果要排除所有名稱為「Raw」的檔案或目錄,請使用「**/Raw/**」。如果要排除所有「.tif」結尾的檔案,請使用「**/*.tif」。如果要排除某個絕對路徑,請使用「/path/to/ignore/**」。", "authentication_settings": "驗證設定", @@ -44,7 +44,7 @@ "crontab_guru": "", "disable_login": "停用登入", "disabled": "已禁用", - "duplicate_detection_job_description": "運行機器學習以檢測相似圖像。此功能仰賴智慧搜尋", + "duplicate_detection_job_description": "對檔案執行機器學習來偵測相似圖片。(此功能仰賴智慧搜尋)", "exclusion_pattern_description": "排除規則讓您在掃描資料庫時忽略特定文件和文件夾。用於當您有不想導入的文件(例如 RAW 文件)或文件夾。", "external_library_created_at": "外部圖庫(於 {date} 建立)", "external_library_management": "外部圖庫管理", @@ -61,14 +61,14 @@ "image_prefer_wide_gamut_setting_description": "使用 Display P3 來製作縮圖。這可以更好地保留廣色域圖片的鮮豔度,但在舊版瀏覽器或舊設備上,圖片可能會顯示不同。sRGB 圖片會維持 sRGB 以避免顏色變化。", "image_preview_format": "預覽格式", "image_preview_resolution": "預覽解析度", - "image_preview_resolution_description": "檢視單張照片和機器學習時用。高解析度可以保留更多細節,但會增加編碼時間、增加檔案大小、降低應用軟體的流暢度。", + "image_preview_resolution_description": "觀賞單張照片及機器學習時用。較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", "image_quality": "品質", "image_quality_description": "圖片品質從1到100,數值越高代表品質越好但檔案也越大,此選項影響預覽和縮圖圖片。", "image_settings": "圖片設定", - "image_settings_description": "管理生成圖片的品質和解析度", + "image_settings_description": "管理產生圖片的品質和解析度", "image_thumbnail_format": "縮圖格式", "image_thumbnail_resolution": "縮圖解析度", - "image_thumbnail_resolution_description": "檢視多張照片時用(時間軸、相冊等⋯)。高解析度可以保留更多細節,但會增加編碼時間、增加檔案大小、降低應用軟體的流暢度。", + "image_thumbnail_resolution_description": "觀賞多張照片時(時間軸、相簿等)用。較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", "job_concurrency": "{job}並行", "job_not_concurrency_safe": "這個任務並行並不安全。", "job_settings": "任務設定", @@ -77,9 +77,9 @@ "jobs_delayed": "{jobCount, plural, other {# 項任務延遲}}", "jobs_failed": "{jobCount, plural, other {# 項}}任務失敗", "library_created": "已建立圖庫:{library}", - "library_cron_expression": "Cron 表達式", - "library_cron_expression_description": "以 cron 格式設定掃描時段。詳細資訊請參考 Crontab Guru", - "library_cron_expression_presets": "現成的 Cron 表達式", + "library_cron_expression": "Cron 運算式", + "library_cron_expression_description": "以 Cron 格式設定掃描時段。詳細資訊請參閱 Crontab Guru", + "library_cron_expression_presets": "現成的 Cron 運算式", "library_deleted": "圖庫已刪除", "library_import_path_description": "選取要載入的資料夾。以掃描資料夾(含子資料夾)內的影像和影片。", "library_scanning": "定期掃描", @@ -96,8 +96,8 @@ "logging_settings": "記錄檔", "machine_learning_clip_model": "CLIP 模型", "machine_learning_clip_model_description": "CLIP 模型 名稱列表。更換模型後須對所有影像重新執行「智慧搜尋」。", - "machine_learning_duplicate_detection": "重複檢測", - "machine_learning_duplicate_detection_enabled": "啟用重複檢測", + "machine_learning_duplicate_detection": "重複項目偵測", + "machine_learning_duplicate_detection_enabled": "啟用重複項目偵測", "machine_learning_duplicate_detection_enabled_description": "即使停用,完全一樣的素材仍會被忽略。", "machine_learning_duplicate_detection_setting_description": "用 CLIP 向量比對潛在重複", "machine_learning_enabled": "啟用機器學習", @@ -125,16 +125,17 @@ "machine_learning_url_description": "機器學習伺服器的網址", "manage_concurrency": "管理並行", "manage_log_settings": "管理日誌設定", - "map_dark_style": "深色模式", + "map_dark_style": "深色樣式", "map_enable_description": "啟用地圖功能", "map_gps_settings": "地圖與 GPS 設定", "map_gps_settings_description": "管理地圖和 GPS(逆向地理編碼)設定", - "map_light_style": "淺色模式", + "map_implications": "地圖功能依賴外部平貼服務(tiles.immich.cloud)", + "map_light_style": "淺色樣式", "map_manage_reverse_geocoding_settings": "管理逆向地理編碼設定", "map_reverse_geocoding": "逆向地理編碼", "map_reverse_geocoding_enable_description": "啟用逆向地理編碼", "map_reverse_geocoding_settings": "逆向地理編碼設定", - "map_settings": "地圖設定", + "map_settings": "地圖", "map_settings_description": "管理地圖設定", "map_style_description": "地圖主題(style.json)的網址", "metadata_extraction_job": "擷取元資料", @@ -143,16 +144,16 @@ "migration_job_description": "將照片和人臉的縮圖遷移到最新的文件夾結構", "no_paths_added": "未添加路徑", "no_pattern_added": "未添加pattern", - "note_apply_storage_label_previous_assets": "注意:若要將存儲標籤應用於先前上傳的圖片,請運行", - "note_cannot_be_changed_later": "註:這將無法更改!", + "note_apply_storage_label_previous_assets": "註:要將存標記用於先前上傳的檔案,請執行", + "note_cannot_be_changed_later": "註:之後就無法更改嘍!", "note_unlimited_quota": "註:輸入 0 表示不限制配額", - "notification_email_from_address": "發出電郵", - "notification_email_from_address_description": "寄出人電郵,例如:\"Immich 相片伺服器 \"", - "notification_email_host_description": "電郵伺服器主機位址 (e.g. smtp.immich.app)", + "notification_email_from_address": "寄件地址", + "notification_email_from_address_description": "寄件者電子郵件地址(例:Immich Photo Server )", + "notification_email_host_description": "電子郵件伺服器主機(例:smtp.immich.app)", "notification_email_ignore_certificate_errors": "忽略憑證錯誤", "notification_email_ignore_certificate_errors_description": "忽略 TLS 憑證驗證錯誤(不建議)", "notification_email_password_description": "以電子郵件伺服器驗證身份時的密碼", - "notification_email_port_description": "電郵伺服器端口(例如 25、465 或 587)", + "notification_email_port_description": "電子郵件伺服器埠口(如: 25、465 或 587)", "notification_email_sent_test_email_button": "傳送測試電子郵件並儲存", "notification_email_setting_description": "發送電子郵件通知的設置", "notification_email_test_email": "傳送測試電子郵件", @@ -160,15 +161,15 @@ "notification_email_test_email_sent": "測試電子郵件已發送至 {email}。請檢查您的收件箱。", "notification_email_username_description": "以電子郵件伺服器驗證身份時的使用者名稱", "notification_enable_email_notifications": "啟用電子郵件通知", - "notification_settings": "通知設定", + "notification_settings": "通知", "notification_settings_description": "管理通知設置,包括電子郵件通知", "oauth_auto_launch": "自動啟動", "oauth_auto_launch_description": "導覽至登入頁面後自動進行 OAuth 登入流程", "oauth_auto_register": "自動註冊", "oauth_auto_register_description": "使用 OAuth 登錄後自動註冊新用戶", "oauth_button_text": "按鈕文字", - "oauth_client_id": "用戶端識別碼", - "oauth_client_secret": "用戶端密碼", + "oauth_client_id": "客戶端 ID", + "oauth_client_secret": "客戶端密鑰", "oauth_enable_description": "用 OAuth 登入", "oauth_issuer_url": "簽發者網址", "oauth_mobile_redirect_uri": "移動端重定向 URI", @@ -195,28 +196,28 @@ "paths_validated_successfully": "所有路徑驗證成功", "quota_size_gib": "配額(GiB)", "refreshing_all_libraries": "正在重新整理所有圖庫", - "registration": "管理員註冊", + "registration": "管理者註冊", "registration_description": "由於您是本系統的首位使用者,因此將您指派爲負責管理本系統的管理者,其他使用者須由您協助建立帳號。", "removing_offline_files": "移除離線檔案中", "repair_all": "全部糾正", "repair_matched_items": "有 {count, plural, other {# 個項目相符}}", "repaired_items": "已糾正 {count, plural, other {# 個項目}}", "require_password_change_on_login": "要求使用者在首次登入時更改密碼", - "reset_settings_to_default": "重置設置為默認值", - "reset_settings_to_recent_saved": "重置設置為最近保存的設置", + "reset_settings_to_default": "將設定重設回預設", + "reset_settings_to_recent_saved": "已設回最後儲存的設定", "scanning_library_for_changed_files": "正在掃描資料庫以檢查文件變更", "scanning_library_for_new_files": "正在掃描資料庫以檢查新文件", "send_welcome_email": "傳送歡迎電子郵件", "server_external_domain_settings": "外部網域", "server_external_domain_settings_description": "公開分享鏈結的網域(包含「http(s)://」)", - "server_settings": "伺服器設定", + "server_settings": "伺服器", "server_settings_description": "管理伺服器設定", "server_welcome_message": "歡迎訊息", "server_welcome_message_description": "在登入頁面顯示的訊息。", "sidecar_job": "側接元資料", "sidecar_job_description": "從檔案系統探索或同步側接(Sidecar)元資料", "slideshow_duration_description": "每張圖片放映的秒數", - "smart_search_job_description": "對檔案運行機器學習以用於智能搜尋", + "smart_search_job_description": "對檔案執行機器學習,以利智慧搜尋", "storage_template_date_time_description": "檔案的創建時戳會用於判斷時間資訊", "storage_template_date_time_sample": "時間樣式 {date}", "storage_template_enable_description": "啟用存儲模板引擎", @@ -230,16 +231,16 @@ "storage_template_onboarding_description": "啟用此功能後,將根據用戶自定義的模板自動組織文件。由於穩定性問題,此功能已默認關閉。欲了解更多信息,請參閱 文檔。", "storage_template_path_length": "大致路徑長度限制:{length, number}/{limit, number}", "storage_template_settings": "存儲模板", - "storage_template_settings_description": "管理上傳檔案的文件夾結構和文件名", + "storage_template_settings_description": "管理上傳檔案的資料夾結構和檔名", "storage_template_user_label": "{label} 是用戶的存儲標籤", "system_settings": "系統設定", "theme_custom_css_settings": "自訂 CSS", - "theme_custom_css_settings_description": "層疊樣式表(CSS)允許自定義 Immich 的設計。", - "theme_settings": "主題設定", - "theme_settings_description": "管理 Immich 網頁界面的自定義設置", + "theme_custom_css_settings_description": "可以用層疊樣式表(CSS)來自訂 Immich 的設計。", + "theme_settings": "主題", + "theme_settings_description": "自訂 Immich 的網頁界面", "these_files_matched_by_checksum": "這些檔案的核對和(Checksum)是相符的", - "thumbnail_generation_job": "生成縮圖", - "thumbnail_generation_job_description": "為每個資產生成大、小和模糊的縮圖,並為每個人生成縮圖", + "thumbnail_generation_job": "產生縮圖", + "thumbnail_generation_job_description": "爲每個檔案產生大、小及模糊縮圖,也爲每位人物產生縮圖", "transcode_policy_description": "", "transcoding_acceleration_api": "加速 API", "transcoding_acceleration_api_description": "該 API 將用您的設備加速轉碼。設置是“盡力而為”:如果失敗,它將退回到軟件轉碼。VP9 轉碼是否可行取決於您的硬件。", @@ -262,7 +263,7 @@ "transcoding_constant_quality_mode_description": "ICQ 比 CQP 更好,但某些硬件加速設備不支持此模式。設置此選項時,會在使用基於質量的編碼時偏好指定的模式。由於 NVENC 不支持 ICQ,此選項對其無效。", "transcoding_constant_rate_factor": "恆定速率因子(-crf)", "transcoding_constant_rate_factor_description": "視頻質量級別。典型值為 H.264 的 23、HEVC 的 28、VP9 的 31 和 AV1 的 35。數值越低,質量越高,但會產生較大的文件。", - "transcoding_disabled_description": "不要轉碼任何視頻,可能會導致某些客戶端無法播放", + "transcoding_disabled_description": "不轉碼影片,可能會讓某些客戶端無法正常播放", "transcoding_hardware_acceleration": "硬體加速", "transcoding_hardware_acceleration_description": "實驗性功能;速度更快,但在相同比特率下質量較低", "transcoding_hardware_decoding": "硬體解碼", @@ -274,7 +275,7 @@ "transcoding_max_bitrate_description": "設置最大比特率可以使文件大小更具可預測性,但會稍微降低質量。在 720p 分辨率下,典型值為 VP9 或 HEVC 的 2600k,或 H.264 的 4500k。設置為 0 則禁用此功能。", "transcoding_max_keyframe_interval": "最大關鍵幀間隔", "transcoding_max_keyframe_interval_description": "設置關鍵幀之間的最大幀距。較低的值會降低壓縮效率,但可以改善尋找時間,並可能改善快速運動場景中的質量。0 會自動設置此值。", - "transcoding_optimal_description": "分辨率高於目標或格式不被接受的視頻", + "transcoding_optimal_description": "高於目標解析度或格式不被支援的影片", "transcoding_preferred_hardware_device": "首選硬件設備", "transcoding_preferred_hardware_device_description": "僅適用於 VAAPI 和 QSV。設置用於硬件轉碼的 DRI 節點。", "transcoding_preset_preset": "預設值(-preset)", @@ -282,10 +283,10 @@ "transcoding_reference_frames": "參考幀數", "transcoding_reference_frames_description": "壓縮給定幀時參考的幀數。較高的值可以提高壓縮效率,但會降低編碼速度。0 會自動設置此值。", "transcoding_required_description": "僅限於格式不被接受的視頻", - "transcoding_settings": "影片轉碼設定", + "transcoding_settings": "影片轉碼", "transcoding_settings_description": "管理影片的解析度和編碼資訊", "transcoding_target_resolution": "目標解析度", - "transcoding_target_resolution_description": "較高的解析度可以保留更多細節,但編碼所需時間更長,文件大小也會增加,並可能降低應用程序的響應速度。", + "transcoding_target_resolution_description": "較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", "transcoding_temporal_aq": "時間自適應量化(Temporal AQ)", "transcoding_temporal_aq_description": "僅適用於 NVENC。提高高細節、低運動場景的質量。可能與舊設備不兼容。", "transcoding_threads": "線程數量", @@ -300,11 +301,11 @@ "transcoding_two_pass_encoding_setting_description": "使用雙通道編碼以產生更高質量的編碼視頻。當啟用最大比特率時(對 H.264 和 HEVC 有效),此模式使用基於最大比特率的比特率範圍,並忽略 CRF。對於 VP9,如果禁用最大比特率,可以使用 CRF。", "transcoding_video_codec": "視頻編解碼器", "transcoding_video_codec_description": "VP9 具有高效能和網頁兼容性,但轉碼時間較長。HEVC 性能相似,但網頁兼容性較低。H.264 兼容性廣泛且轉碼速度快,但生成的文件較大。AV1 是最有效的編解碼器,但在舊設備上支持度不足。", - "trash_enabled_description": "啟用垃圾箱功能", + "trash_enabled_description": "啟用垃圾桶功能", "trash_number_of_days": "日數", - "trash_number_of_days_description": "永久刪除之前,檔案於垃圾箱中保留的日數", - "trash_settings": "垃圾箱設置", - "trash_settings_description": "管理垃圾箱設置", + "trash_number_of_days_description": "永久刪除之前,將檔案保留在垃圾桶中的日數", + "trash_settings": "垃圾桶", + "trash_settings_description": "管理垃圾桶設定", "untracked_files": "未被追蹤的檔案", "untracked_files_description": "這些檔案不會被追蹤。它們可能是移動失誤、上傳中斷或遇到漏洞而遺留的產物", "user_delete_delay": "{user} 的帳戶和資產將安排在 {delay, plural, one {# 天} other {# 天}} 後進行永久刪除。", @@ -315,28 +316,30 @@ "user_management": "使用者管理", "user_password_has_been_reset": "使用者密碼已重設:", "user_password_reset_description": "請提供使用者臨時密碼,並告知下次登入時需要更改密碼。", - "user_restore_description": "{user} 的帳戶將被恢復。", - "user_restore_scheduled_removal": "恢復用戶 - 預定於 {date, date, long} 移除", - "user_settings": "使用者設定", + "user_restore_description": "{user} 的帳號將被還原。", + "user_restore_scheduled_removal": "還原使用者 - 預定於 {date, date, long} 移除", + "user_settings": "使用者", "user_settings_description": "管理使用者設定", "user_successfully_removed": "已成功移除 {email}(使用者)。", - "version_check_enabled_description": "啟用定期向 GitHub 發送請求以檢查新版本", + "version_check_enabled_description": "啟用版本檢查", + "version_check_implications": "版本檢查功能會定期與 github.com 通訊", "version_check_settings": "版本檢查", - "version_check_settings_description": "啟用/禁用新版本通知", - "video_conversion_job": "轉碼視頻", - "video_conversion_job_description": "轉碼視頻以提高瀏覽器和設備的兼容性" + "version_check_settings_description": "啟用 / 停用新版本通知", + "video_conversion_job": "轉碼影片", + "video_conversion_job_description": "對影片轉碼,相容更多瀏覽器和裝置" }, - "admin_email": "管理員電子郵件", + "admin_email": "管理者電子郵件", "admin_password": "管理者密碼", "administration": "管理", "advanced": "進階", - "age_months": "年齡 {months, plural, one {# 個月} other {# 個月}}", - "age_year_months": "年齡 1 年,{months, plural, one {# 個月} other {# 個月}}", - "age_years": "{years, plural, other {年齡 #}}", - "album_added": "已新增相簿", + "age_months": "{months, plural, other {# 個月大}}", + "age_year_months": "1 歲,{months, plural, other {# 個月}}", + "age_years": "{years, plural, other {# 歲}}", + "album_added": "加入相簿時", "album_added_notification_setting_description": "當我被加入共享相簿時,用電子郵件通知我", "album_cover_updated": "已更新相簿封面", - "album_delete_confirmation": "確定要刪除「{album}」(相簿)嗎?\n如果已分享此相簿,其他使用者就無法再存取。", + "album_delete_confirmation": "確定要刪除「{album}」(相簿)嗎?", + "album_delete_confirmation_description": "如果已分享此相簿,其他使用者就無法再存取這本相簿了。", "album_info_updated": "已更新相簿資訊", "album_leave": "離開相簿?", "album_leave_confirmation": "您確定要離開 {album} 嗎?", @@ -345,7 +348,7 @@ "album_remove_user": "移除使用者?", "album_remove_user_confirmation": "確定要移除 {user} 嗎?", "album_share_no_users": "看來您與所有使用者共享了這本相簿,或沒有其他使用者可供分享。", - "album_updated": "已更新相簿", + "album_updated": "更新相簿時", "album_updated_setting_description": "當共享相簿有新檔案時,用電子郵件通知我", "album_user_left": "已離開 {album}", "album_user_removed": "已移除 {user}", @@ -356,15 +359,16 @@ "all_albums": "所有相簿", "all_people": "所有人", "all_videos": "所有視頻", - "allow_dark_mode": "允許黑暗模式", + "allow_dark_mode": "允許深色模式", "allow_edits": "允許編輯", "allow_public_user_to_download": "開放給使用者下載", "allow_public_user_to_upload": "開放讓使用者上傳", + "anti_clockwise": "逆時針", "api_key": "API 金鑰", "api_key_description": "此值僅顯示一次。請確保在關閉窗口之前複製它。", "api_key_empty": "您的 API 金鑰名稱不能爲空", "api_keys": "API 金鑰", - "app_settings": "應用設置", + "app_settings": "應用程式設定", "appears_in": "出現在", "archive": "封存", "archive_or_unarchive_photo": "封存或取消封存照片", @@ -382,48 +386,48 @@ "asset_hashing": "Hashing中...", "asset_offline": "檔案離線", "asset_offline_description": "此檔案己離線。Immich 無法訪問其文件位置。請確保資產可用,然後重新掃描資料庫。", - "asset_skipped": "跳過", + "asset_skipped": "已略過", "asset_uploaded": "已上傳", - "asset_uploading": "上傳中...", + "asset_uploading": "上傳中…", "assets": "檔案", "assets_added_count": "已添加 {count, plural, one {# 個資產} other {# 個資產}}", "assets_added_to_album_count": "已將 {count, plural, other {# 個檔案}}加入相簿", "assets_added_to_name_count": "已將 {count, plural, other {# 個檔案}}加入{hasName, select, true {{name}} other {新相簿}}", "assets_count": "{count, plural, one {# 個檔案} other {# 個檔案}}", - "assets_moved_to_trash_count": "已將 {count, plural, one {# 個檔案} other {# 個檔案}} 移到垃圾箱", + "assets_moved_to_trash_count": "已將 {count, plural, other {# 個檔案}}丟進垃圾桶", "assets_permanently_deleted_count": "已永久刪除 {count, plural, one {# 個檔案} other {# 個檔案}}", "assets_removed_count": "已移除 {count, plural, one {# 個檔案} other {# 個檔案}}", - "assets_restore_confirmation": "您確定要恢復所有垃圾箱中的檔案嗎?此操作無法撤銷!", - "assets_restored_count": "已恢復 {count, plural, one {# 個檔案} other {# 個檔案}}", - "assets_trashed_count": "{count, plural, one {# 個檔案} other {# 個檔案}} 已放入垃圾箱", + "assets_restore_confirmation": "確定要還原所有丟掉的檔案嗎?此步驟無法取消喔!", + "assets_restored_count": "已還原 {count, plural, other {# 個檔案}}", + "assets_trashed_count": "已丟掉 {count, plural, other {# 個檔案}}", "assets_were_part_of_album_count": "{count, plural, one {檔案已} other {檔案已}} 是相冊的一部分", "authorized_devices": "授權裝置", "back": "后退", - "back_close_deselect": "返回、關閉或取消選擇", + "back_close_deselect": "返回、關閉及取消選取", "backward": "倒轉", - "birthdate_saved": "已成功保存出生日期", - "birthdate_set_description": "出生日期會用於計算此人在照片拍攝時的年齡。", + "birthdate_saved": "出生日期儲存成功", + "birthdate_set_description": "出生日期會用來計算此人拍照時的歲數。", "blurred_background": "模糊背景", "build": "建置編號", "build_image": "建置映像", "bulk_delete_duplicates_confirmation": "您確定要批量刪除 {count, plural, one {# 個重複檔案} other {# 個重複檔案}} 嗎?這將保留每組中的最大檔案,並永久刪除所有其他重複項。此操作無法撤銷!", "bulk_keep_duplicates_confirmation": "您確定要保留 {count, plural, one {# 個重複檔案} other {# 個重複檔案}} 嗎?這將解決所有重複組而不刪除任何內容。", - "bulk_trash_duplicates_confirmation": "您確定要批量將 {count, plural, one {# 個重複檔案} other {# 個重複檔案}} 移到垃圾箱嗎?這將保留每組中最大的檔案,並將所有其他重複項放入垃圾箱。", - "buy": "購買 Immich", + "bulk_trash_duplicates_confirmation": "確定要一次丟掉 {count, plural, other {# 個重複的檔案}}嗎?這樣每組重複的檔案中,最大的會留下來,其它的會被丟進垃圾桶。", + "buy": "購置 Immich", "camera": "相機", "camera_brand": "相機品牌", "camera_model": "相機型號", "cancel": "取消", "cancel_search": "取消搜尋", "cannot_merge_people": "無法合併人物", - "cannot_undo_this_action": "您無法撤銷此操作!", + "cannot_undo_this_action": "此步驟無法取消喔!", "cannot_update_the_description": "無法更新描述", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", "cant_search_places": "", "change_date": "更改日期", - "change_expiration_time": "更改有效期限", + "change_expiration_time": "更改失效期限", "change_location": "更改位置", "change_name": "改名", "change_name_successfully": "改名成功", @@ -440,6 +444,7 @@ "clear_all_recent_searches": "清除所有最近的搜尋", "clear_message": "清除訊息", "clear_value": "清除值", + "clockwise": "順時針", "close": "關閉", "collapse": "折疊", "collapse_all": "全部折疊", @@ -448,9 +453,9 @@ "comment_options": "評論選項", "comments_and_likes": "評論與讚好", "comments_are_disabled": "評論已禁用", - "confirm": "确定", + "confirm": "確認", "confirm_admin_password": "確認管理者密碼", - "confirm_delete_shared_link": "您確定要刪除這個共享鏈接嗎?", + "confirm_delete_shared_link": "確定要刪除這條分享鏈結嗎?", "confirm_password": "確認密碼", "contain": "包含", "context": "情境", @@ -458,7 +463,7 @@ "copied_image_to_clipboard": "圖片已複製到剪貼簿。", "copied_to_clipboard": "已複製到剪貼簿!", "copy_error": "複製錯誤", - "copy_file_path": "複製文件路徑", + "copy_file_path": "複製檔案路徑", "copy_image": "複製圖片", "copy_link": "複製鏈結", "copy_link_to_clipboard": "將鏈結複製到剪貼簿", @@ -467,9 +472,9 @@ "country": "國家", "cover": "封面", "covers": "封面", - "create": "创建", + "create": "建立", "create_album": "建立相簿", - "create_library": "創建圖庫", + "create_library": "建立圖庫", "create_link": "建立鏈結", "create_link_to_share": "建立分享鏈結", "create_link_to_share_description": "允許任何擁有鏈接的人查看所選的照片", @@ -479,18 +484,18 @@ "create_user": "建立使用者", "created": "建立於", "current_device": "此裝置", - "custom_locale": "自定義區域設定", - "custom_locale_description": "根據語言和地區格式化日期和數字", + "custom_locale": "自訂區域", + "custom_locale_description": "依語言和區域設定日期和數字格式", "dark": "深色", "date_after": "日期之後", - "date_and_time": "日期和时间", + "date_and_time": "日期與時間", "date_before": "日期之前", - "date_of_birth_saved": "出生日期已成功保存", + "date_of_birth_saved": "出生日期儲存成功", "date_range": "日期範圍", "day": "日", "deduplicate_all": "刪除所有重複項目", - "default_locale": "默認區域設定", - "default_locale_description": "根據您的瀏覽器區域設定格式化日期和數字", + "default_locale": "預設區域", + "default_locale_description": "依瀏覽器區域設定日期和數字格式", "delete": "删除", "delete_album": "刪除相簿", "delete_api_key_prompt": "您確定要刪除這個 API Key嗎?", @@ -512,10 +517,12 @@ "display_options": "顯示選項", "display_order": "顯示順序", "display_original_photos": "顯示原始照片", - "display_original_photos_setting_description": "當網頁兼容原始照片時,偏好查看照片時顯示原始檔案而非縮略圖。這可能會導致照片顯示速度變慢。", + "display_original_photos_setting_description": "在網頁與原始檔案相容的情況下,查看檔案時優先顯示原始檔案而非縮圖。這可能會讓照片顯示速度變慢。", "do_not_show_again": "不再顯示此訊息", "done": "完成", "download": "下載", + "download_include_embedded_motion_videos": "嵌入影片", + "download_include_embedded_motion_videos_description": "把嵌入動態照片的影片作爲單獨的檔案包含在內", "download_settings": "下載", "download_settings_description": "管理與檔案下載相關的設定", "downloading": "下載中", @@ -548,12 +555,16 @@ "edit_title": "編輯標題", "edit_user": "編輯使用者", "edited": "己編輯", - "editor": "", + "editor": "編輯器", + "editor_close_without_save_prompt": "編輯過的內容不會儲存起來", + "editor_close_without_save_title": "要關閉編輯器嗎?", + "editor_crop_tool_h2_aspect_ratios": "長寬比", + "editor_crop_tool_h2_rotation": "旋轉", "email": "電子郵件", "empty": "", "empty_album": "", - "empty_trash": "清空回收站", - "empty_trash_confirmation": "您確定要清空垃圾桶嗎?這將永久刪除 Immich 中所有垃圾桶中的檔案。\n您不能撤銷這個操作!", + "empty_trash": "清空垃圾桶", + "empty_trash_confirmation": "確定要清空垃圾桶嗎?這會永久刪除 Immich 垃圾桶中所有的檔案。\n此步驟無法取消喔!", "enable": "啟用", "enabled": "己啟用", "end_date": "結束日期", @@ -576,7 +587,7 @@ "error_adding_users_to_album": "將使用者加入相簿時出錯", "error_deleting_shared_user": "刪除共享使用者時出錯", "error_downloading": "下載 {filename} 時出錯", - "error_hiding_buy_button": "隱藏購買按鈕時出錯", + "error_hiding_buy_button": "隱藏購置按鈕時出錯", "error_removing_assets_from_album": "從相簿中移除檔案時出錯了,請到控制臺瞭解詳情", "error_selecting_all_assets": "選擇所有檔案時出錯", "exclusion_pattern_already_exists": "此排除模式已存在。", @@ -590,7 +601,7 @@ "failed_to_load_people": "無法載入人物", "failed_to_remove_product_key": "無法移除產品密鑰", "failed_to_stack_assets": "無法堆疊檔案", - "failed_to_unstack_assets": "無法解除堆疊資產", + "failed_to_unstack_assets": "無法解除堆疊檔案", "import_path_already_exists": "此匯入路徑已存在。", "incorrect_email_or_password": "電子郵件或密碼有誤", "paths_validation_failed": "{paths, plural, one {# 個路徑} other {# 個路徑}} 驗證失敗", @@ -604,7 +615,7 @@ "unable_to_add_import_path": "無法添加匯入路徑", "unable_to_add_partners": "無法添加夥伴", "unable_to_add_remove_archive": "無法{archived, select, true {從封存中移除檔案} other {將檔案加入封存}}", - "unable_to_add_remove_favorites": "無法 {favorite, select, true {將檔案添加至} other {從中移除檔案}} 收藏夾", + "unable_to_add_remove_favorites": "無法將檔案{favorite, select, true {加入收藏} other {從收藏中移除}}", "unable_to_archive_unarchive": "無法{archived, select, true {封存} other {取消封存}}", "unable_to_change_album_user_role": "無法更改相簿使用者的角色", "unable_to_change_date": "無法更改日期", @@ -618,7 +629,7 @@ "unable_to_connect": "無法連接", "unable_to_connect_to_server": "無法連接到伺服器", "unable_to_copy_to_clipboard": "無法複製到剪貼板,請確保您以 https 存取該頁面", - "unable_to_create_admin_account": "無法建立管理員帳戶", + "unable_to_create_admin_account": "無法建立管理者帳號", "unable_to_create_api_key": "無法建立新的 API 金鑰", "unable_to_create_library": "無法建立資料庫", "unable_to_create_user": "無法建立使用者", @@ -662,9 +673,9 @@ "unable_to_repair_items": "無法糾正項目", "unable_to_reset_password": "無法重設密碼", "unable_to_resolve_duplicate": "無法解決重複項", - "unable_to_restore_assets": "無法恢復檔案", - "unable_to_restore_trash": "無法恢復垃圾桶內容", - "unable_to_restore_user": "無法恢復使用者", + "unable_to_restore_assets": "無法還原檔案", + "unable_to_restore_trash": "無法還原垃圾桶中的項目", + "unable_to_restore_user": "無法還原使用者", "unable_to_save_album": "無法儲存相簿", "unable_to_save_api_key": "無法儲存 API 金鑰", "unable_to_save_date_of_birth": "無法儲存出生日期", @@ -676,7 +687,7 @@ "unable_to_set_feature_photo": "無法設置特色照片", "unable_to_set_profile_picture": "無法設置個人頭像", "unable_to_submit_job": "無法提交作業", - "unable_to_trash_asset": "無法將檔案移至垃圾桶", + "unable_to_trash_asset": "無法將檔案丟進垃圾桶", "unable_to_unlink_account": "無法對帳號取消連接", "unable_to_update_album_cover": "無法更新相簿封面", "unable_to_update_album_info": "無法更新相簿資訊", @@ -694,10 +705,11 @@ "exif": "Exif", "exit_slideshow": "退出幻燈片", "expand_all": "展開全部", - "expire_after": "有效時間", + "expire_after": "失效時間", "expired": "已過期", - "expires_date": "有效期限:{date}", + "expires_date": "失效期限:{date}", "explore": "探索", + "explorer": "探測器", "export": "匯出", "export_as_json": "匯出 JSON", "extension": "副檔名", @@ -718,6 +730,7 @@ "filter_people": "篩選人物", "find_them_fast": "搜尋名稱,快速找人", "fix_incorrect_match": "修復不相符的", + "folders": "資料夾", "force_re-scan_library_files": "強制重新掃描所有資料庫檔案", "forward": "順序", "general": "一般", @@ -746,7 +759,7 @@ "image_alt_text_date_2_people": "{isVideo, select, true {影片} other {圖片}} 與 {person1} 和 {person2} 一同於 {date} 拍攝", "image_alt_text_date_3_people": "{isVideo, select, true {影片} other {圖片}} 與 {person1}、{person2} 和 {person3} 一同於 {date} 拍攝", "image_alt_text_date_4_or_more_people": "{isVideo, select, true {影片} other {圖片}} 與 {person1}、{person2} 和其他 {additionalCount, number} 人於 {date} 拍攝", - "image_alt_text_date_place": "{isVideo, select, true {影片} other {圖片}} 於 {city}、{country},{date} 拍攝", + "image_alt_text_date_place": "{date}在 {country} - {city} 拍攝的{isVideo, select, true {影片} other {圖片}}", "image_alt_text_date_place_1_person": "{isVideo, select, true {影片} other {圖片}} 於 {city}、{country},與 {person1} 一同在 {date} 拍攝", "image_alt_text_date_place_2_people": "{isVideo, select, true {影片} other {圖片}} 在 {city}、{country},與 {person1} 和 {person2} 一同於 {date} 拍攝", "image_alt_text_date_place_3_people": "{isVideo, select, true {影片} other {圖片}} 在 {city}、{country},與 {person1}、{person2} 和 {person3} 一同於 {date} 拍攝", @@ -765,7 +778,7 @@ "info": "資訊", "interval": { "day_at_onepm": "每天下午 1 點", - "hours": "每 {hours, plural, one {小時} other {{hours, number} 小時}}", + "hours": "每 {hours, plural, other {{hours, number} 小時}}", "night_at_midnight": "每晚午夜", "night_at_twoam": "每晚凌晨 2 點" }, @@ -802,7 +815,7 @@ "login": "登入", "login_has_been_disabled": "已停用登入功能。", "logout_all_device_confirmation": "您確定要登出所有裝置嗎?", - "logout_this_device_confirmation": "您確定要登出這個裝置嗎?", + "logout_this_device_confirmation": "要登出這臺裝置嗎?", "longitude": "經度", "look": "樣貌", "loop_videos": "重播影片", @@ -811,7 +824,7 @@ "manage_shared_links": "管理分享鏈結", "manage_sharing_with_partners": "管理與夥伴的分享", "manage_the_app_settings": "管理應用程式設定", - "manage_your_account": "管理您的帳戶", + "manage_your_account": "管理您的帳號", "manage_your_api_keys": "管理您的 API 金鑰", "manage_your_devices": "管理已登入的裝置", "manage_your_oauth_connection": "管理您的 OAuth 連接", @@ -822,7 +835,7 @@ "matches": "相符", "media_type": "媒體類型", "memories": "回憶", - "memories_setting_description": "管理您在回憶中顯示的內容", + "memories_setting_description": "管理您的回憶中顯示的內容", "memory": "回憶", "memory_lane_title": "回憶長廊{title}", "menu": "選單", @@ -838,11 +851,11 @@ "model": "型號", "month": "月", "more": "更多", - "moved_to_trash": "已移至垃圾桶", + "moved_to_trash": "已丟進垃圾桶", "my_albums": "我的相簿", "name": "名稱", "name_or_nickname": "名稱或暱稱", - "never": "永遠", + "never": "永不失效", "new_album": "新相簿", "new_api_key": "新的 API 金鑰", "new_password": "新密碼", @@ -861,7 +874,7 @@ "no_duplicates_found": "沒發現重複項目。", "no_exif_info_available": "沒有可用的 Exif 資訊", "no_explore_results_message": "上傳更多照片以利探索。", - "no_favorites_message": "將最喜愛的項目添加至收藏夾,以便快速找到您的最佳照片和影片", + "no_favorites_message": "加入收藏,加速尋找影像", "no_libraries_message": "建立外部圖庫來查看您的照片和影片", "no_name": "無名", "no_places": "沒有地點", @@ -869,7 +882,7 @@ "no_results_description": "試試同義詞或更通用的關鍵字吧", "no_shared_albums_message": "建立相簿分享照片和影片", "not_in_any_album": "不在任何相簿中", - "note_apply_storage_label_to_previously_uploaded assets": "注意:要將存儲標籤應用於先前上傳的檔案,請運行", + "note_apply_storage_label_to_previously_uploaded assets": "註:要將存標記用於先前上傳的檔案,請執行", "note_unlimited_quota": "註:輸入 0 表示不限制配額", "notes": "提示", "notification_toggle_setting_description": "啟用電子郵件通知", @@ -882,6 +895,7 @@ "ok": "確定", "oldest_first": "由舊至新", "onboarding": "入門指南", + "onboarding_privacy_description": "以下(可選)功能依賴外部服務,可隨時在管理設定中停用。", "onboarding_theme_description": "選擇顏色主題。您可以稍後在設定中更改此選項。", "onboarding_welcome_description": "讓我們為您的伺服器架構一些常見的設置。", "onboarding_welcome_user": "歡迎,{user}", @@ -890,7 +904,7 @@ "only_refreshes_modified_files": "只重新整理修改過的檔案", "open_in_map_view": "開啟地圖檢視", "open_in_openstreetmap": "用 OpenStreetMap 開啟", - "open_the_search_filters": "打開搜尋過濾器", + "open_the_search_filters": "開啟搜尋篩選器", "options": "選項", "or": "或", "organize_your_library": "整理您的圖庫", @@ -943,7 +957,7 @@ "places": "地點", "play": "播放", "play_memories": "播放回憶", - "play_motion_photo": "播放動態相片", + "play_motion_photo": "播放動態照片", "play_or_pause_video": "播放或暫停影片", "point": "", "port": "埠口", @@ -951,46 +965,49 @@ "preview": "預覽", "previous": "上一張", "previous_memory": "上一張回憶", - "previous_or_next_photo": "上一張或下一張照片", + "previous_or_next_photo": "上、下一張照片", "primary": "首要", + "privacy": "隱私", "profile_image_of_user": "{user} 的個人資料圖片", "profile_picture_set": "已設定個人資料圖片。", "public_album": "公開相簿", "public_share": "公開分享", "purchase_account_info": "擁護者", - "purchase_activated_subtitle": "感謝您對 Immich 及開源軟體的支持", + "purchase_activated_subtitle": "感謝您對 Immich 及開源軟體的支援", "purchase_activated_time": "於 {date, date} 啟用", "purchase_activated_title": "金鑰成功啟用了", "purchase_button_activate": "啟用", - "purchase_button_buy": "購買", - "purchase_button_buy_immich": "購買 Immich", + "purchase_button_buy": "購置", + "purchase_button_buy_immich": "購置 Immich", "purchase_button_never_show_again": "不再顯示", - "purchase_button_reminder": "30天後提醒我", + "purchase_button_reminder": "過 30 天再提醒我", "purchase_button_remove_key": "移除金鑰", - "purchase_button_select": "選擇", + "purchase_button_select": "選這個", "purchase_failed_activation": "啟用失敗!請檢查您的電子郵件以取得正確的產品金鑰!", "purchase_individual_description_1": "針對個人", - "purchase_individual_description_2": "支持者狀態", + "purchase_individual_description_2": "擁護者狀態", "purchase_individual_title": "個人", "purchase_input_suggestion": "有產品金鑰嗎?請在下面輸入金鑰", - "purchase_license_subtitle": "購買 Immich 以支持軟件發展", - "purchase_lifetime_description": "終身購買", - "purchase_option_title": "購買選項", + "purchase_license_subtitle": "購置 Immich 來支援軟體開發", + "purchase_lifetime_description": "終身購置", + "purchase_option_title": "購置選項", "purchase_panel_info_1": "開發 Immich 可不是件容易的事,花了我們不少功夫。好在有一群全職工程師在背後默默努力,爲的就是把它做到最好。我們的目標很簡單:讓開源軟體和正當的商業模式能成爲開發者的長期飯碗,同時打造出重視隱私的生態系統,讓大家有個不被剝削的雲端服務新選擇。", - "purchase_panel_info_2": "由於我們於不設付費牆,這筆購買不會為你提供 Immich 任何額外功能。我們依賴像你這樣的用戶來支持 Immich 持續開發。", - "purchase_panel_title": "支持這個項目", - "purchase_per_server": "每台伺服器", + "purchase_panel_info_2": "我們承諾不設付費牆,所以購置 Immich 並不會讓您獲得額外的功能。我們是依賴使用者們的支援來開發 Immich 的。", + "purchase_panel_title": "支援這項專案", + "purchase_per_server": "每臺伺服器", "purchase_per_user": "每位使用者", - "purchase_remove_product_key": "移除產品密鑰", - "purchase_remove_product_key_prompt": "您確定要移除產品密鑰嗎?", - "purchase_remove_server_product_key": "移除伺服器產品密鑰", - "purchase_remove_server_product_key_prompt": "您確定要移除伺服器產品密鑰嗎?", - "purchase_server_description_1": "適用於整個伺服器", - "purchase_server_description_2": "支持者狀態", + "purchase_remove_product_key": "移除產品金鑰", + "purchase_remove_product_key_prompt": "確定要移除產品金鑰嗎?", + "purchase_remove_server_product_key": "移除伺服器產品金鑰", + "purchase_remove_server_product_key_prompt": "確定要移除伺服器產品金鑰嗎?", + "purchase_server_description_1": "給整臺伺服器", + "purchase_server_description_2": "擁護者狀態", "purchase_server_title": "伺服器", "purchase_settings_server_activated": "伺服器產品金鑰是由管理者管理的", "range": "", "rating": "評星", + "rating_clear": "清除評等", + "rating_count": "{count, plural, other {# 星}}", "rating_description": "在資訊面板中顯示 Exif 評等", "raw": "", "reaction_options": "反應選項", @@ -1023,7 +1040,7 @@ "removed_api_key": "已移除 API 金鑰:{name}", "removed_from_archive": "從封存中移除", "removed_from_favorites": "已從收藏中移除", - "removed_from_favorites_count": "已從收藏中移除 {count, plural, one {#} other {#}}", + "removed_from_favorites_count": "已移除收藏的 {count, plural, other {# 個項目}}", "rename": "改名", "repair": "糾正", "repair_no_results_message": "未被追蹤及遺失的檔案會顯示在這裏", @@ -1033,22 +1050,22 @@ "require_user_to_change_password_on_first_login": "要求使用者在首次登入時更改密碼", "reset": "重設", "reset_password": "重設密碼", - "reset_people_visibility": "重置人物可見性", + "reset_people_visibility": "重設人物可見性", "reset_settings_to_default": "", - "reset_to_default": "設爲預設", + "reset_to_default": "重設回預設", "resolve_duplicates": "解決重複項", "resolved_all_duplicates": "已解決所有重複項目", - "restore": "恢复", - "restore_all": "恢復全部", - "restore_user": "恢復使用者", - "restored_asset": "已恢復檔案", + "restore": "還原", + "restore_all": "全部還原", + "restore_user": "還原使用者", + "restored_asset": "已還原檔案", "resume": "繼續", - "retry_upload": "重試上傳", + "retry_upload": "重新上傳", "review_duplicates": "查核重複項目", "role": "角色", "role_editor": "編輯者", "role_viewer": "檢視者", - "save": "保存", + "save": "儲存", "saved_api_key": "已儲存的 API 密鑰", "saved_profile": "已儲存個人資料", "saved_settings": "已儲存設定", @@ -1069,14 +1086,14 @@ "search_country": "搜尋國家…", "search_for_existing_person": "搜尋現有的人物", "search_no_people": "沒有人找到", - "search_no_people_named": "沒有名為 \"{name}\" 的人", + "search_no_people_named": "沒有名爲「{name}」的人物", "search_people": "搜尋人物", "search_places": "搜尋地點", "search_state": "搜尋地區…", - "search_timezone": "搜尋時區...", + "search_timezone": "搜尋時區…", "search_type": "搜尋類型", "search_your_photos": "搜尋照片", - "searching_locales": "搜尋地區...", + "searching_locales": "搜尋區域…", "second": "秒", "see_all_people": "查看所有人物", "select_album_cover": "選擇相簿封面", @@ -1089,7 +1106,7 @@ "select_keep_all": "全部保留", "select_library_owner": "選擇圖庫擁有者", "select_new_face": "選擇新臉孔", - "select_photos": "選相片", + "select_photos": "選照片", "select_trash_all": "全部刪除", "selected": "已選擇", "selected_count": "{count, plural, other {選了 # 項}}", @@ -1100,10 +1117,10 @@ "server_online": "伺服器在線", "server_stats": "伺服器統計", "server_version": "目前版本", - "set": "設置", + "set": "設定", "set_as_album_cover": "設爲相簿封面", "set_as_profile_picture": "設為個人資料圖片", - "set_date_of_birth": "設置出生日期", + "set_date_of_birth": "設定出生日期", "set_profile_picture": "設置個人資料圖片", "set_slideshow_to_fullscreen": "以全螢幕放映幻燈片", "settings": "設定", @@ -1114,6 +1131,7 @@ "shared_by_user": "由 {user} 分享", "shared_by_you": "由你分享", "shared_from_partner": "來自 {partner} 的照片", + "shared_link_options": "分享鏈結選項", "shared_links": "分享鏈結", "shared_photos_and_videos_count": "{assetCount, plural, other {已分享 # 張照片及影片。}}", "shared_with_partner": "與 {partner} 共享", @@ -1137,8 +1155,8 @@ "show_person_options": "顯示人物選項", "show_progress_bar": "顯示進度條", "show_search_options": "顯示搜尋選項", - "show_supporter_badge": "支持者徽章", - "show_supporter_badge_description": "顯示支持者徽章", + "show_supporter_badge": "擁護者徽章", + "show_supporter_badge_description": "顯示擁護者徽章", "shuffle": "隨機排序", "sign_out": "登出", "sign_up": "註冊", @@ -1157,14 +1175,14 @@ "stack": "堆叠", "stack_duplicates": "堆疊重複項目", "stack_select_one_photo": "爲堆疊選一張主要照片", - "stack_selected_photos": "堆疊選定的照片", + "stack_selected_photos": "堆疊所選的照片", "stacked_assets_count": "已堆疊 {count, plural, one {# 個檔案} other {# 個檔案}}", "stacktrace": "堆疊追蹤", "start": "開始", "start_date": "開始日期", "state": "地區", "status": "狀態", - "stop_motion_photo": "停格照片", + "stop_motion_photo": "停止動態照片", "stop_photo_sharing": "要停止分享您的照片嗎?", "stop_photo_sharing_description": "{partner} 將無法再訪問你的照片。", "stop_sharing_photos_with_user": "停止與此用戶共享你的照片", @@ -1179,7 +1197,7 @@ "template": "模板", "theme": "主題", "theme_selection": "主題選項", - "theme_selection_description": "根據你的瀏覽器系統偏好自動設置主題為淺色或深色", + "theme_selection_description": "依瀏覽器系統偏好自動設定深、淺色主題", "they_will_be_merged_together": "它們將會被合併在一起", "time_based_memories": "依時間回憶", "timezone": "時區", @@ -1189,13 +1207,13 @@ "to_login": "登入", "to_trash": "垃圾桶", "toggle_settings": "切換設定", - "toggle_theme": "切換主題", + "toggle_theme": "切換深色主題", "toggle_visibility": "", "total_usage": "總用量", "trash": "垃圾桶", - "trash_all": "全丟進垃圾桶", - "trash_count": "刪除 {count, number} 檔案", - "trash_delete_asset": "刪除檔案/放入垃圾桶", + "trash_all": "全部丟掉", + "trash_count": "丟掉 {count, number} 個檔案", + "trash_delete_asset": "將檔案丟進垃圾桶 / 刪除", "trash_no_results_message": "垃圾桶中的照片和影片將顯示在這裡。", "trashed_items_will_be_permanently_deleted_after": "垃圾桶中的項目會在 {days, plural, other {# 天}}後永久刪除。", "type": "類型", @@ -1211,12 +1229,13 @@ "unlink_oauth": "取消連接 OAuth", "unlinked_oauth_account": "已解除連接 OAuth 帳號", "unnamed_album": "未命名相簿", + "unnamed_album_delete_confirmation": "確定要刪除這本相簿嗎?", "unnamed_share": "未命名分享", - "unsaved_change": "未儲存的更改", + "unsaved_change": "更改未儲存", "unselect_all": "取消全選", - "unselect_all_duplicates": "取消選擇所有重複項", + "unselect_all_duplicates": "取消選取所有的重複項目", "unstack": "取消堆叠", - "unstacked_assets_count": "已取消堆疊 {count, plural, one {# 個檔案} other {# 個檔案}}", + "unstacked_assets_count": "已解除堆疊 {count, plural, other {# 個檔案}}", "untracked_files": "未被追蹤的檔案", "untracked_files_decription": "這些檔案不會被追蹤。它們可能是移動失誤、上傳中斷或遇到漏洞而遺留的產物", "up_next": "下一個", @@ -1225,10 +1244,10 @@ "upload_concurrency": "上傳並行", "upload_errors": "上傳完成,但有 {count, plural, other {# 處出錯}},要查看新上傳的檔案請重新整理頁面。", "upload_progress": "剩餘 {remaining, number} - 已處理 {processed, number}/{total, number}", - "upload_skipped_duplicates": "跳過 {count, plural, one {# 個重複檔案} other {# 個重複檔案}}", + "upload_skipped_duplicates": "已略過 {count, plural, other {# 個重複的檔案}}", "upload_status_duplicates": "重複項目", "upload_status_errors": "錯誤", - "upload_status_uploaded": "己上載", + "upload_status_uploaded": "已上傳", "upload_success": "上傳成功,要查看新上傳的檔案請重新整理頁面。", "url": "網址", "usage": "用量", @@ -1236,7 +1255,7 @@ "user": "使用者", "user_id": "使用者 ID", "user_liked": "{user} 喜歡了 {type, select, photo {這張照片} video {這段影片} asset {這個檔案} other {它}}", - "user_purchase_settings": "購買", + "user_purchase_settings": "購置", "user_purchase_settings_description": "管理你的購買", "user_role_set": "設 {user} 爲{role}", "user_usage_detail": "使用者用量詳情", @@ -1249,7 +1268,7 @@ "version_announcement_closing": "敬祝順心,Alex", "version_announcement_message": "嗨~本應用程式可以更新了,爲防止配置出錯,請花點時間閱讀發行說明,並確保 docker-compose.yml.env 設置是最新的,特別是使用 WatchTower 等自動更新工具時。", "video": "影片", - "video_hover_setting": "在鼠標懸停時播放影片縮圖", + "video_hover_setting": "游標停留時播放影片縮圖", "video_hover_setting_description": "當滑鼠停在項目上時播放影片縮圖。即使停用,將滑鼠停在播放圖示上也可以播放。", "videos": "影片", "videos_count": "{count, plural, other {# 部影片}}", @@ -1262,7 +1281,7 @@ "view_previous_asset": "查看上一項", "view_stack": "查看堆疊", "viewer": "", - "visibility_changed": "{count, plural, one {# 人} other {# 人}} 的可見性已更改", + "visibility_changed": "已更改 {count, plural, other {# 位人物}}的可見性", "waiting": "待處理", "warning": "警告", "week": "周", diff --git a/web/src/lib/i18n/zh_SIMPLIFIED.json b/web/src/lib/i18n/zh_SIMPLIFIED.json index fd3fd5815c..b5379c27e2 100644 --- a/web/src/lib/i18n/zh_SIMPLIFIED.json +++ b/web/src/lib/i18n/zh_SIMPLIFIED.json @@ -129,12 +129,13 @@ "map_enable_description": "启用地图功能", "map_gps_settings": "地图与GPS设置", "map_gps_settings_description": "管理地图与GPS(反向地理编码)设置", + "map_implications": "地图功能依赖于外部图块服务(tiles.immich.cloud)", "map_light_style": "浅色模式", "map_manage_reverse_geocoding_settings": "管理反向地理编码设置", "map_reverse_geocoding": "反向地理编码", "map_reverse_geocoding_enable_description": "启用反向地理编码", "map_reverse_geocoding_settings": "反向地理编码设置", - "map_settings": "地图设置", + "map_settings": "地图", "map_settings_description": "管理地图设置", "map_style_description": "地图主题 style.json 的 URL", "metadata_extraction_job": "提取元数据", @@ -320,7 +321,8 @@ "user_settings": "用户设置", "user_settings_description": "管理用户设置", "user_successfully_removed": "用户{email}已被成功删除。", - "version_check_enabled_description": "启用对GitHub的定期请求以检查新版本", + "version_check_enabled_description": "启用版本检测", + "version_check_implications": "版本检查功能依赖于与 github.com 的定期通信", "version_check_settings": "版本检查", "version_check_settings_description": "启用或禁用新版本通知", "video_conversion_job": "视频转码", @@ -336,7 +338,8 @@ "album_added": "相册已添加", "album_added_notification_setting_description": "当您被添加到共享相册时,接收电子邮件通知", "album_cover_updated": "相册封面已更新", - "album_delete_confirmation": "是否确定要删除相册{album}?\n如果这是共享相册,其他用户将无法再访问它。", + "album_delete_confirmation": "是否确定要删除相册{album}?", + "album_delete_confirmation_description": "如果该相册是共享的,其他用户将无法再访问它。", "album_info_updated": "相册信息已更新", "album_leave": "退出相册?", "album_leave_confirmation": "确定要退出相册{album}?", @@ -360,6 +363,7 @@ "allow_edits": "允许编辑", "allow_public_user_to_download": "开放下载给所有人", "allow_public_user_to_upload": "允许所有用户上传", + "anti_clockwise": "逆时针", "api_key": "API Key", "api_key_description": "该应用密钥只会展示一次。请确保在关闭窗口前复制下来。", "api_key_empty": "API Key的名称不可以为空", @@ -441,6 +445,7 @@ "clear_all_recent_searches": "清除所有最近搜索", "clear_message": "清空消息", "clear_value": "清空值", + "clockwise": "顺时针", "close": "关闭", "collapse": "折叠", "collapse_all": "全部折叠", @@ -517,6 +522,8 @@ "do_not_show_again": "不再显示该信息", "done": "完成", "download": "下载", + "download_include_embedded_motion_videos": "内嵌视频", + "download_include_embedded_motion_videos_description": "将动态照片中的内嵌视频作为单独文件纳入", "download_settings": "下载", "download_settings_description": "管理项目下载相关设置", "downloading": "下载中", @@ -550,6 +557,10 @@ "edit_user": "编辑用户", "edited": "已编辑", "editor": "编辑器", + "editor_close_without_save_prompt": "此更改不会被保存", + "editor_close_without_save_title": "关闭编辑器?", + "editor_crop_tool_h2_aspect_ratios": "长宽比", + "editor_crop_tool_h2_rotation": "旋转", "email": "邮箱", "empty": "空", "empty_album": "清空相册", @@ -699,6 +710,7 @@ "expired": "已过期", "expires_date": "{date}过期", "explore": "探索", + "explorer": "浏览器", "export": "导出", "export_as_json": "导出为JSON", "extension": "扩展", @@ -720,6 +732,7 @@ "filter_people": "过滤人物", "find_them_fast": "按名称快速搜索", "fix_incorrect_match": "修复不正确的匹配", + "folders": "文件夹", "force_re-scan_library_files": "强制重新扫描所有图库文件", "forward": "向前", "general": "通用", @@ -872,7 +885,7 @@ "my_albums": "我的相册", "name": "名称", "name_or_nickname": "名称或昵称", - "never": "从不", + "never": "永不过期", "new_album": "新相册", "new_api_key": "新API Key", "new_password": "新密码", @@ -912,6 +925,7 @@ "ok": "确定", "oldest_first": "最旧优先", "onboarding": "盛大开启", + "onboarding_privacy_description": "以下(可选)功能依赖外部服务,可随时在管理设置中禁用。", "onboarding_theme_description": "选择服务的颜色主题。稍后可以在设置中进行修改。", "onboarding_welcome_description": "我们在启用服务前先做一些通用设置。", "onboarding_welcome_user": "欢迎,{user}", @@ -985,6 +999,7 @@ "previous_memory": "上一个", "previous_or_next_photo": "上一张或下一张照片", "primary": "首要", + "privacy": "隐私", "profile_image_of_user": "{user}的个人资料图片", "profile_picture_set": "个人资料图片已设置。", "public_album": "公开相册", @@ -1023,6 +1038,8 @@ "purchase_settings_server_activated": "服务器产品密钥正在由管理员管理", "range": "范围", "rating": "星级", + "rating_clear": "删除星级", + "rating_count": "{count, plural, one {#星} other {#星}}", "rating_description": "在信息面板中展示EXIF星级", "raw": "Raw", "reaction_options": "反应选项", @@ -1146,6 +1163,7 @@ "shared_by_user": "由{user}共享", "shared_by_you": "你的共享", "shared_from_partner": "来自{partner}的照片", + "shared_link_options": "共享链接选项", "shared_links": "共享链接", "shared_photos_and_videos_count": "{assetCount, plural, other {#项已共享照片&视频。}}", "shared_with_partner": "与{partner}共享", @@ -1221,7 +1239,7 @@ "to_login": "登录", "to_trash": "放入回收站", "toggle_settings": "切换设置", - "toggle_theme": "切换主题", + "toggle_theme": "切换深色主题", "toggle_visibility": "切换可见性", "total_usage": "总用量", "trash": "回收站", @@ -1243,6 +1261,7 @@ "unlink_oauth": "解绑OAuth", "unlinked_oauth_account": "解绑OAuth账户", "unnamed_album": "未命名相册", + "unnamed_album_delete_confirmation": "您确定要删除该相册吗?", "unnamed_share": "未命名共享", "unsaved_change": "未保存的修改", "unselect_all": "取消全选", From 5811025ebdbbf2be7089f73d7325e58aea4afbc5 Mon Sep 17 00:00:00 2001 From: aviv926 <51673860+aviv926@users.noreply.github.com> Date: Wed, 28 Aug 2024 19:43:51 +0300 Subject: [PATCH 193/723] docs: Documentation updates (#11516) * Documentation updates * PR feedback * PR feedback * Originally implemented using #11880 * add to FAQ * Remove mTLS --------- Co-authored-by: Jason Rasmussen --- docs/docs/FAQ.mdx | 5 +++++ docs/docs/administration/img/admin-jobs.png | Bin 1108716 -> 0 bytes docs/docs/administration/img/admin-jobs.webp | Bin 0 -> 81174 bytes docs/docs/administration/jobs-workers.md | 2 +- docs/docs/administration/system-settings.md | 18 +++++++++++++----- docs/docs/features/shared-albums.md | 4 ++-- docs/docs/guides/remote-access.md | 8 ++++++-- docs/docs/guides/remote-machine-learning.md | 4 ++++ docs/src/components/community-projects.tsx | 13 ++++++------- 9 files changed, 37 insertions(+), 17 deletions(-) delete mode 100644 docs/docs/administration/img/admin-jobs.png create mode 100644 docs/docs/administration/img/admin-jobs.webp diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index 501a67d5f2..b1a24e1788 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -67,6 +67,11 @@ No, Immich does not modify the original files. All edited metadata is saved in companion `.xmp` sidecar files and the database. However, Immich will delete original files that have been trashed when the trash is emptied in the Immich UI. +### Why do my file names appear as a random string in the file manager? + +When Storage Template is off (default) Immich saves the file names in a random string (also known as random UUIDs) to prevent duplicate file names. To retrieve the original file names, you must enable the Storage Template and then run the STORAGE TEMPLATE MIGRATION job. +It is recommended to read about [Storage Template](https://immich.app/docs/administration/storage-template) before activation. + ### Can I add my existing photo library? Yes, with an [External Library](/docs/features/libraries.md). diff --git a/docs/docs/administration/img/admin-jobs.png b/docs/docs/administration/img/admin-jobs.png deleted file mode 100644 index 096bce4354f0f0b85ab1998bc5e0d161d689e199..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1108716 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

    gqPI84}Rv1U!q!g0xV2eYd$&`1)ok6;q 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 vBeT_`1lmP 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≷+Ht%k3(B!e?8se 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 z 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%0Da2>*7s4QWQ-L+-j5 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

    ;8H*W6bo0St=p5*o=%eya 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 z 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�lQLS# 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 z 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

    ?G3uf3lw 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�`gl)5KmX9Jr*&CLj;vkG}J;o5`z$~3)4 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%0A5v9OY&#@ifrOLM{D0K(89VHwFc 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 z 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 z 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

    jVs){ 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

    E(Z*JZ+X;Ly@G>5Y;(g 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%0DGpZoyC;lZ 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%0DTFCUjF4yDh&94eA#AIGK!GNI} z@J?F(}|0P4s?2qGh;DcttBP%!S|TUt-Gh&t-tY$WStt#$dVl z3yuS0|7a8@O2;H7caK5~8PdsTQj^GCwVBszRw#=KSn}0n2}9=TPg68e*J4m>E6DxF zC0#%DEfoiqSK^bqkQ7}Pca-lVjmQNfOlc3rzm%FtRHPbB>dnt+BxEg`R|9%`x0F99^?poA6+oj`{{B zX@G*Ez<-oEM75NCB7`Rkmbfz#z($CtJBqIKRH+E8>(!la1f?MST^tr|omV)PAV&jM zGRQHe>o;YpCl#uG*KZx5K$T!7wGNgRc!uuspCA7_Y_u9ZYu_N2etIgTw9e(Yr%GA) z;Jerw7PHdQw|~T6Hj<=P-WbI2?~DXy=4cI8D1)U0A3{PS;oFag z#~$3(lc)eUVczop!{LFPE~Dt0bpsQL2}Q^6Z!gz7%Nl7L;b>k7bZH%+2eB0?4m*L~ z|D9P#FeQ~budQdfN=zXt9gpRG1!PTf$biQC$WOJn-!TMM$yA28M-66=)|GE|{;D<< z;YocbKiy_R3u^6Ycx^0dmwER&QU2@j`2RK-9&XWODn(dS&4-r%Z(uA{ zl)}D>z~ukM2}aTqX`=bdPyab={s-}Q8hEM4|1Ym0Q-j%BmepFZL}OF-xOZ7Jd~kj3 z_}3xPK$sMNd%fK(i|k4E5BO)+Os%SWGbEuXm!j9-UZVizVFI9?cq_M))81Bd2ZigJ zpZj{8x|6JrC*H!8*~(}FJJV+V+GxL1Qn3yE-i`EzvCkR(Xmk0^glIo7M~ULz#CT=) zu@WezU|Y*q&*i1IQIxet@H301T85AXX{phMb47J(wBbq2RIj%E++r$;gGyf3HN39a z*WGd`IQ?wlCZc0!GjG|Dh{rm{qrrHoh5bbdzm7R$#@05*+j7go5qOkuc+S|f%gJ+0 zb$lH5CIyT#=1uJ$opoSb^=6i@z5l%zLbIC5%R^1W>VCZDLMg!Jtdqvjc~BtR&}a9@ z`KGgoL4_+5SgqJ*OGxSJM4pPA>Y5y-9F5RH;pjaa&64Fl_=pqZH)AHg{r#P>eA#N1 zCB*&Y!ve0}+SnwnZeCqIi0R$W@4;{Q!N*4dzkj)m6}Tpo%4Q%^*Gp%Y@!0OPc`r&% z@PMli7DS|G7-mSa2!S5D|-}lsHFQS9=z>ys?S@g>`UqKU7k@LtnfRZZ?TH>MK)KwqT|kH zN@*~U_q!1~CpQF?SxXJ&YClCD(?&jxN8hjAovDtzuP@Hq;UmYd@id+mA3rNPwumZQ zIbO5w4%~y`)*qqHXYx-_$oG^AmFw<<S-ex4~&ZvzCUJ&p?G^UKx z?%aWw6k#ai#*F@rrD@$|6I_g=ChXj^;H@{p77*cF*4$aT>ER$z8ZMPqbKDu$-r&GK z>sdd{xl%q;<#7=x)oZub6{Trm*8sv9$(;M|D&to5T)J%69i=`1ihX?xQ_MEGq$u`p zve$DR;>V>vnV5kau!UBJaP9SA27O%|n>$hRn{nX^6nH+sN^GE9VG_tujlI|8{heuf ztY0T-M=ld-;}_z&9HO1Yd)Q@`ZB!hannsBzG{$A-`e&Lw>j*10CfxO>;v{p^tQh`dYX;&jNhcNi&KEvB{u()iE)J zq}qJ*s;GTDFB)U=XK&Vb)$41^?vWo4cfZK&$3`JdMJrvuxO>E(9viTtRMsGLFIK~pf6R64o(H;PKXXWBDh0fMlV+?>%r437p#e8TvbyEwq zt^EAzpU2=z`ML+og_?}Ir3N0RPD_LW2QwzuaIVYJm%F7%OeU7}a{8`0miF_B*5R0n z&vv}vbim<~9N}t}io%Ge1eo!f>s`YeIAn-*5d&NP19<07=B%Z$6{hB)={*63H**`g$MF49Pd zO6rsAjP;w`j`8_H-U#hR;&S%oC1>S%ZWbgG_dLrTyy9^BgW_VR3XX#7gHbKM`HQl; zIPq_hw~2ymZ#R$g9Bcog_OtCX?d;@ z55{Ye0@`AAA{hq{P-B=j-*V{Lt8!E+Vb(n$@(O| zYvPc0Ute#TC|T?oP>H(%=$G<-6+vD7g1RTq#H%X9eZClI=sS|P+>=OKU`!G(yiw6^ z@$o2#Fs#jK<0fTEZ=pKi+55B}=AMQ#N-;~FeL5W>43nq8Wj>A8<+lU8l#?}|?ARuA z*e9bFwd1!ruq~`{bkE6HE{~hHJ)E1sTeJ51Nw8i!H}YsdKOmFWA+VS*teQ%0nXq0X z2CO(y#`qbnjc<00QZoaO_TXmRg0*8w#+AsjjdxA$;@;R>yiTB2h_NtwFp0u9gSG3~ zp^Zq8c23-!NqI+Z4KHu;a$bfzp&Fe-Wj92LP=x!7xk=FsLEr%b2|_i!W&uBQRhxy0 zaO2hW$bFTFD8iu#UzugKc0qf2-IRB6q?rYW&WxLfpmS%E&s}U=RTL(9snOQdcS1(G zTymS)z3!CiLuL^CxTN%9-0D=)@G{#(%jhCAv|6722RX73&b~rcb_usRIR){WyIAr4 zfnLR0nJ&EucXO=BefKR7Qb;9B zaGJXF!tr{)ys9vG0z@{u3pV@7J1g_3!3EZc0Eo!T3E zROTI)$t62 zXMs8Ras}%y=3WH*o00sr;AaqR4IS7AELo?rx(Lpu29XZu;gYdsDH>Bb$v1Mx&&QKc zHL^dFZfy2_%w`vDY#f)!DR5a&L3wACDw8;+b<0HLD!?Vo&GBCuaAT#mn>=|k+;ris zGHeXHJ(YnOQ}~xZ1lCD>hY3UVzYC#%mBe7_j8w~ECobBVpti|3@4F!K?pa+!Km;go z&cd#FkPVr+So^4#N~ztpz&n95tyif`9R_7mqc|=?%hEWJB5uq?v$XdTr`QsT8xKrY zPpHt`-ro~na*LXdPSCuHcR4o>`W;v}v{0v2%~)0CAma3b63m&YCFF5|>~S7?6&;8Z zOaT7+U}x3*qgqITMcV8ny^W&bqq4I6)mz@I$f`_Kj0;IKCgAS!fUC-nmC;@7)uZ~0 z-qP8L`d$ai3(4YQ|21GWUg->AN15yGYqEy|lCw4MrjvnN#y+j=Yoh`w`zO1G<|ZBS z+ALW|MjcfMSRnQvd{tTF1W-zEOd5y0Ub-dB0#BZJpA&h`fS2F*qR~jn{YV#aFhNhp zgGh37L25@aw1>D_+qn&y7hd3LF-MZLa?-#>!woem0?%K~V(?Xct^? z?MM`n|+(@(5}cHA+je|-Y|EF;F!RtLpv7dVSFm|VBO>` z##FA|G+pT8vsPXBlWv~vVOgO%)X9~UT~5&;yN?%R+Ok?SwJVFJcS*rm!c2GKTBA68`coD|x$kj&exUCdDT0g_!@wmG_y_%AGlRT8<%HF=7HlEakz+4eLc9j<$Dcx_ZkjU5qLE18AL zPbE&Jf}HwQXvM!Z$j|g3+jNt}_|#q0Kt?mHwB$n#KRQdO*_)M6V32cBG-`K7 zkWTw|V;m=t0E3iJUi#F1f`8v05AS1_WPRgod|*vL^`UaDM)o}$R4&ZCnql|iiaJl+ z(TY`d>f+|r*-7tP~=0aK?E<`lMfBY~ADLa}+>lg-!9 z*2^3}Ym6*dTS;FE-s0M7aHx!R)hDu>so^IZJFu^$`sTzS>E>j9XZ@5~oH}WlWzTJ( z%+WwXWhqsBrUo~UPC0;hUnp?I=lm~@kixrP{n>kT=86@M0Llu-U1+pm!gK0x* zciVkprJaO*Gm>Zc!WScMRRDjae-I&C0?Vof!(54$dD3a>s@FF}8Swz&rat|{ndupKUM9rwR0ng|5# z#@|9tjU{#nM5jwFNQ5;aP0R#Y96%VdsOCAGW^c+c2f`TC5j1ZQPnbHRt_dZO{h4Nd zWS6(yYt6N~Goz0}yZf>)%C;ZWif0nNiDwWN!5Mne36T+Y#UgRTS!UWg$vlE4zk-5@ zu~4hJFBXPI#;s!5nPF6XQi@jul*b^CeUpJsiy(+#W)Nf85?}D@ZSdGCfR1-U8FY9U z)UVTF*6U-GsYADm^;oyf?Iae;c&`-$%o!OrECZv2I z7f6(f^rIFcMBOg8PzRZS;(oAXaS#5Lb>@+Vn^C=)?8Z6^>>xD~xe&LwVG>Cc->dhkjN zfZS9|PlyFLJ%9A0LH?HtO7O%xBg?6Ot~ev}bR>43GuO=P65^>FwcWiH^ZR-okJlgL zXUP8Xhcn2}!?xAxvD^3lzU;kJus1RkN8;Gs$f@qs}!i~%@Lu3 z7?8du9i*K?X2*OzF?4D_Ku=hl&GRZaCF4Z(($8NRP)n)jj6Q7{+4{UeoH4i@LsXIOf_-+_Clo;b^k4<|mkkQ;DOzEy1g#DQe%Q@ssro=q+{doG7r5O9=;g|dpc5H-c0Gc`UA}B#Pv;R>$v!7#T zsZtG|J->bo(<{(o=fHw+P%+)i5G6=)?Gh2tK8WbX*`x2&IWy$k%^_i!SZs8>Yo-bB zU$b}`0+_qJ!^BE??&r7VF+y0da);8^9*A6{=#x0}SR{IL8ybJ;V3jpAoU?3^ztyTF zKe+JP98+4t|C6P~D77XjwvcpYUa&|scS5%|+E)_I!*tQpPiDnHOTgz*v2N+ff5yGG zt2}kKVGz5M1=p;nk;&Y9(7y*)Aoeph1b|k=c<}?!8MhmZImigxIH32~=z+Kyyh<<* z{Td?Qvt-G-f=uK?R>o#P1ZCvkBdp}GifHMEnk;3r;@)+}bR+S*#L248?io~ALS zEDJ+;He7y5w7e+^fp@4_;dGc{RmbPZQo(WD5)NZ-yv1Y-N4ip}8{38q(j4bqEqFEK z`xA-Cff|Y=+h7bs=1k~lip;1` zCybK$lrq5z;u@avA%-Hj(+Ru&EufZoIUKEjvhNgH*^kEAaA1zs=J@H~FO~~$#-GZj zE3wr%;q4!pA0n57QG$#5ni=Tn?x4M`_4m^rVGI@{TwxYi4j6bP(pB!c@QW038Rhfp z_Nt}Kj`FZiOP%t$kZ0njqaIm&`r-7JSbTf&=AIva-K4`utVFeeXO=lU?!YDzUWZp* zpEz^i?EL;Ly_jN1RRw{OhbYoLd!9d&QArNTFyF@@hJ7| zD2yvncts+4E%>d!dNzhIAWx)SM9Ha0SS`qZ_ajeW3?c8@Hg8%o7;G}G?qsCWw?F5p zs~{%NoV|2z^dK|ES`B5O1_Hc|3qriD<}w1_8N-btVZZHsj)$$QXCG&s#I}g@m}Mdw zZY3Xm=3i;&$3CI798x6E?iqv&@twh5G}*0dMoGiPf;eMzijz^#^R`q>MTCw<#r^?) zc69WN!=Zy^l;;;=9-p&vhqj8lRg1Tm9Z}9r&wq5vinCyOXR;)oAk^vn(kWID6c~PpkqdvV51n1ueJz^8pFoMURyq4VcZ;kAN?AB zY2$#977---*@YU|m2xXQM#u#of~Ce*nQ90SBA7@x=ktt;aJ#vi@?L4Fw;@XbU(K+MJ2*!=TV^ch3>o+FvMx3{ zJvkOQ>{SCLVxK%H`Nq9ym`Ds~`qX?eB43)Z7xB6`^(ButkL9Ml`Q-XF+vw%r?9Y-^8{vf+;nxiA^N&j$SQBv z@UR!R9<7t{mqD!OGzBwJj?oGRsoLmmw}4C|EBSY{vk}^$Ck$!jP=`Skn(wp~^a#6h z4857&*v)E-FW8eo8gR0oFc8F+&9S9pi4^e*I&+7g!L2KZ7L5dS`uLqZMN;ic$9KeQ z(qtskPPvov`$Tt(c5EV;sMfSJHjch2fTa$FY~wu2;X^VOdt5ngOT1wZagAF_7^E2> zodD(}SfZ4XpHbMo3Oc8ckp}XPpQh!`)!^^1`}&lcd3u&tj*&&uzWY9-DWe}|9%*gq5K3ZB5qr!H&LdK@D7BXW2sQhG<7T8;OuG(Mq5Zp1IR=i zE|u#w6G5;6I1Jwa`f`~bR>%l)GXbemA$Xq;17a{Eh=p+{T;hM>of2RVaBj{})w;1~ z8iS{y^ep)m`EvI1a<70>FdT?~PfI0a3U0UB4^OziTJ9Ws$-SAr;ap<8VqEs)iW4gc z9`aA`Ls@Be{aE3y67H2UpVnG3=EeTPADDndsvj)^JlSWSVaO(klSv)vsQ`Xjq4f;_ z~9A`?G4QyeASLfonPVo`j7=S!1OS#C{ zGp-`vCZdye^VMyC+H~nN3{>U_(CPDY$wndzcbo;KHAPGx`JP((UdxgQuki)6K1~8# z{!m`}8e?E&FSmyTqb z<2A)#g5bEs1A&knWhQcp)^XEr0w>JPm3%U$y?Qnb4OJaGz`^}aMn|TYXi1A$WVFDF zo`{(CPZxKm`tKo)gyThgZxGGUZXcw(Vb?}$s4w2jW|d~$C3Ovrf&E)eUuSeueeD(o z?3wcT-9F5;X0XvnV+068C&-i^YtETymi?k`8q5V=4l?weo{Xz{tUpXsbeoz$b+Kkh z|5vkQ^X**!=Uut}P~_>*vt=bF9c{yc+G2s*c0bY6`)O6TqEQLHqK1*ERUil^G=LW^ zqde^ha+9UHp51Y$URtwRb>8jfJU)tFcDz{){ql_|Y@InL;&`Q{-yGAwPPeLR zzuy5Ln=r$RzkIyjJw43|A@n!`6;PdRzj)P3zMYZ6!z+*Sz!UqCj_}-hv9q%SByN8X z#_N6vAi4uAiW@`)V~=P>r4u6Q1i7opgJEwqj;!~`RIE8}zI~il^Si#-H1~OFo+H>7 zsJi=u*p0ko1C!sg_ElJ&5!&6_c>>Lf$7||^-(}r-QK}Lz=~dw8mFLH9vrpsdB`td% zm#C{uC32{lZPNn+8TYXCgv{}7RoDITl*het_Ul{(LnneUarCZXx9jZ^ss77~`p4D7 zNA9ey{8nb~UK3Uyv+W#S48Csb+3&F!n_Gd$IC(t)$P{$KC)N1MqmsPE*Q!;M@}elq zGT<-yBm8-fg5PCTOrHGp4?-8h_wF8*0j{;OVFHbTH-4=b?XJ7cj=jQa-;G#7;$_Wi zo_+Em5t0D|hu$sarHRi6Lc1_5Ek4}%ufXD;+m1OeI`y@mMm_F!tDp9eUx{(iqG^9n zhar!*7kpOR_#s1^X(#i#YS+Gxmh)Q2_#j*MNdN+@u@%-`l@bke~ z;8owi$J9cAk)F!cLkIyTKghQD!)Ehcm%)3moF|KZ?kVTzdyK$agg}7`*m00|)#7UP z&#`Ww^^6UEmEKRaRwPL#Z34;4N+bh}%g9(;+x;OPZ z?z!)@+H=h0J#JX|Z)ai-jUwB`WAHr|AG|^m5Y1@-QDgqHyeJiUmq9|Ewf09>TJ`hK zO?DqmSRGq~ZBUJCJde|FL_Qxsg$E4mj2oku2xM#!X8AntJMQ$oPygsY*0r>Th7uo9 zwovA4WEMJXk!OnOlvG*s%6decn-L3X_UpgQ>pvw-z0KrqhKzJx7Zv~9BP9|-HVWXh z*WtMW9DQqnpt(yNhR0w3IlTGNGb{jj9GStzFO7oI2|n7n7e*`l-C{qhg&?o)@;CzA z*~!j%z03(uq3?Z)xW4)$@Ky?HEzf`iNK=CQoS5G-Fd6_)eDF@tLdB%H70z7xHiGAv8Y9YHh1DD89T>hm(?^RlQ_c|SD7 zP3}_jCrn55tF%3zkc0-_b$GSM#3_CI>+fOxKP}!b$@?VB6NLO;M;GmvZ-2U9`$UlW zVtZg=x3j)o1p$p>Lq+k5q&@Gu4rD)`3#+~8p7UqLd*X-m0na_KmVL60u!cb9KwiG9 z9&k|Wp;cYiwfn`NccQRU(!q2V_kc;6)`pVrc;Td^;aX8SFHiC!pAe&nUzc7sok0)4 z1-~7Tr0he%od@9fO}(zzJsOG_eGp^FNZ<*}e7mhz>#xK_Gc7<@eoL%gVo?KRQ;!Q^P^MX{p`}liTg`q7^5+r?Rd$I&Ltv8 zbR}E5C(;?u6)>gyWW~x(qs!ztu_|Y!@_hN{R`=^jjC06Ue7Mf*QHX&3-Zu1~Us!=c z+(#Z?;KO1WZ)NmAO<%4$Qo2qFHu>=HXO14$t{ahkwqBAkwJd%wUh%b;rQgc?fxKlL ztyh>{0}bBs64d?ynuirLI$|=6MkycXtKIug3?A2_DZEfc6c_eU3&OVGp27ppTjWVV zT^78B)9s%gm+eF?A9`uV55#tLKHcVEjfS|!eN|i|Vy9o0+PF=97-cjhb&=O??WQe5 zvu3n(;$heAmGi(@P@CK+319P;ic+iOX65vCohJ}4eI7iK&$_X<5mE_65*@H=KLK8D z${%z+290wb`mjFwKb}{oyq;46w2aRmfd=P~JK1lkrrH`>QlglxFdfH0U0I{*>)Od7 zV&NYmq;ddKyUX5*FODTwsuh*hY&+?&+{{$V(E_n31S759c0Q{d-LFa27*Q!gLZ&cj zC5r`}Sh~Tn4S2R5&&t{N<1d%pmnlDwi!ZwmqtzVqnrr>n^?tC{QEF>zTV1w;p}oe2 z>cS#~GaiO$z{;2#k!UenGPlT+jS%YAm|u!cweE!<3OuAWultHf( zbg8VbRf7-s6*JAi`EzZ4{!VLc0F|I+?>7yhYkTysb@ z)A3xH#-YbR?R-?{3$nGf(`n_s_U8RPR_CKB3K$CP+xu3}x0Fyd1fDUUb5nJlx1E&E zgZiAe^(pUr4sUt$03U+Q_`{lao-6hr)ifIc?C~zd&jy$hZu~l`pfz$bspsSjhNXZ2*vXAw6de^KFM=>3ZxxuQ0)e0hp_4dsQk{1B`=6%|V)*_W_dO z{7ywjKQLQ6CCw+&fj7u;R48>;3^@%tKFGiN>TcSV>=Nt-`~uh7g19&E+PJENdkkt( z;Ce{CV@I|7^^bt#;bK=?T@&0Pc~KD`nthB6a#)DpN>y`%32WhdfwoyS=c`xeYA?>` zVY%|oJ@*MbJCEekMyMLq)5fu?islEsI-gsVSA;YZnqDeYTWf1U!i9{e?>B`ibAplI z4rUT+W_obKi!e1ah}~GVt(%FPfAbzY@c@pNsbJFp_F4RWxB=W=HCGK=x02i_cJDVE z;4SDa@lH%t@7C=w(d3czGOG92X?oT375zd+DpIyPb+Iz>BswTe5-_T&?)Dwp@X??L#m-g7EV&Rgc;y4&om&qeXiGCeEKxBit{ z_yCE>lM<4AvKmpoFqwHfx3`R3Z~KcDfTMS={>_fM*Riy|-VcttUlELSnfQL!6vV-F zXrTVKMkPXlu1FaZd`Az6k;q(pgJnbqSKpqOo@_7O(p~XdRw(R8jLF7Wp{wws7L=w4 z*jL7Emjv(QkiTLZN{FOzQ48iW;0Nn@1E#b$pYPSXcJ8j&_OI#(MyYfKIyqY!S|KQG z)_(LSh0uHVXLR3nzYQQq_!8L)z6>&v^iKO+?A&DAR>zci*vq#25CC!L5cCb9Q2|x= zS;tg(4}n2EXQ zEO-YhNZkcTFBK%Q#eVFFY;}eJf~o%S?~rcAJ+MC{to+~}bz$mRKsA;q9o7fcettdt zI07h8`{xAp25!3w-R`3o%*@>;QguIwWjrC0=MF$u`y~}5UeJz zq75cvND&#OC+gL@dP6TV10IG%_;#!oRInc$r9PS#b%wPo(0&VAk;w1+pMHXdN52@a zGmL5v0(=E#k(QP*CfFF zAfs--v;+`y7-$o>QB^I@s6ApzV5Vk&VMU%r?iIEtmVk*3q}ygB`gtx(^fG{~>$R-v zhF272SRm4!R`>bKpf49-oU-3Z?-Bu28C*U01%+vDYAZR6$ny>@$No(=^(bi^f4Dl) ztO+CB)z)T+UHSo)2;r-F!fy2Aex8)pS!JgL*!NSrRSS^uOiA~%Es%0 z-CNVgV^-+3U<05392BG2z%hn95*Q>%zNeMRQ*f1+6x?mTL-e9y!VsFx#zi2lBJ^^) z#hQxfchHQah>|l^&?dSFVoTIDG@uWo(kf~c+Bc1$^W{(UVS5zt$Pkx(# z6}{)>mXKW1HiH+CxJdYJOk#zp41~%3GR7IsBk5vYiN>m>z&{P{=LSX!kz>F@&P?y$ zN*0D3TOH~!u0RA<9I_hH7q9_)D!%B!Sp5JhH(B4c?T+1?b}Xb?F{`Q323%ppej+_z{k!K2&D>*utz62X8d*8~7|L{=>%A z^G{TmSIShEE2lY5TRj-q{}0GOH@`>NrD0xkgZ4{b`dVBh5cucH4145DaGdWNm5FQ( z%ts(d3_XH#Ty&QYNwk zfh!kUE|HVHiAy6&9l(e;n~Eh@8|4jhXML@6(IuDuU*G*b|MAw;W?F|r{xkO?L!Hb{u_hLRD8bav>I z7}wHNbO$b#5uQTB^ix%9Eh6#mx!?GL4}SFHtGEK1vI<2>K$9kg=r+$mE*1nQf;Mk_ z`ZJgIUy^vpzN_n?dE=9wytKHSh$*kl%m}U`DbhTk#2gbAhRfYR;xFOu(}-9LYi*p- zxXj>;!zqWqZuG_Dj!3*eo zJa6QTLV1JSLp&RL9NmJ*UbHwAzN!|fFDhT3ptuVmgwUgMmN0SuQZGwNvtYWqmn8N{DVJm>n;C` zXyZIH6?fsD@WsMbww$OtT71nE_Y}tp0*hDIk2ev^-E3{Ft>WqW`nsvUGT*S0T2+$2 zE(t9*01UU(<>lpX{x|>5jT*}b+K?RWW_#uM+QSdu2V(_c`+Jv{8jTne-bhEd>gsF1 z`<>tWBR}#(j!AAoHqqfnj>gR;*iV=T^=mNB+(VD7UwqM32(L~Ch*l3)jw7JskyfLf z^m|fy%2P*=B(j#gSaKRN2@%`h^N#mC_Uda=iP)DSKZqX~96vf347;5nA|P6=C73oo znktSiz3htjy!U+{`QUr+x${xIRFC@aW7fw_GLmgzWeUGLszxpu(SF| zt6s;!A_eHcC0Abjm@AGRJHCJa-p=ZAH2<65`u1P>*`FLB9IbO~d9jJxD$;J!p@fMc zy&Hx{t=@j-v!1IZ%URNe(|icA@`9UR_H$pm)q|aZ6c7_I?iF*@cBx00`$=!`z7Kt5 zY2QVy#eHZ57|y+ZDlUseudE+m!~|*|aTE#K5Qg0gU-)9oZU4g0ek$@rf1qt)V!?F) zyAviI$}7ZPZ3SC7zJkhRc``(0xT3U9Zh`>O6xfsO*s-reHsUdgx}gU=JjPDKU4ns> zq38X&q^WcteDJ|c${fXBR3A!~erMml1MseF^!gY{>)nmxs}IFtgutgd!u3?r-&jKo z2`oq>bY0BZOARha3Zx^nqJR1NL4RYVbSO#jEaPH=fdYpLa(^Cr;6Vu|L;gC&;n*QM)tKG4h5%n#7*Wo!6W7{=j+Z z%U+=*-z6SOkPn7-|5?v{!5{tp?I$X#Y6Ido`wdEcOmn70TWJm_yoT$!uvrNdkqT&W5rHQA&VZGVXVfJPmxZVbNp zTmJ1QKJvb1V^N9|I#P2CLMs!~TFMNd#>Ug0{>*ExyK!}`--=tcda%)5lX7!$BbBrh z1Tf0R;dF(tG+BJ9ROB-ubE$$iIU`i4w$wnGG4A>8!gBVRxA z;K4NQU$pNsMAk|f24uB28%wVGzW0ClKY#p#!}Vh+6axIxh`*H#3V8S6^g--mKI|m- z+sJ*qDbOfuTu*5b3D{oK$0+5a0D(Fg5`o`v>1eE8wqSw;1^egZKm;k(d* z2&u(!;wp}d_U*Ubw!G9Dbk!*|&OBAL>(#`;WG^vN+d9Q!N zzq;+qUpjK|9$zCcaJ>f2?oU^y*DG(=lBQYbuna$vmq=v}AJ6i$l~b z%28%#X`zmaxQJYO-v>VY2fz2bNMM#}I1e#6X&j(XOb_?;iixC(05HkB$NTXZ;WxO! zh_#U+yRm`W#@zihg(fAm140ERvXoBJ`jSg7x%P=SA#_bDjcWH#KL3SZ`lVk&$ue89 z^rjncc=May@Z={w5ix0NYb!TB^=U}7SUq?!K>4iI&QJWrPrw1-$X!#adP(y1(EZe> zerexcgfL~8Y5lPu`>}ty?+!IYPNyejOA#d9j5m%SYa)R}5<&gfzwV7sxb9k5@VJp; z#_`_!9{k6D`tl$B(QmD+Adq^Y*Bd_dDbIZ4>t6rVr#xk2?HIfS*)VzMJKpu~cYa5n z^^kO5uLUsihj0|2)GwMKYG=LS;bSX5`lCO(dh{?tPxkLw=&T*bA!806)Bo4qc++=( z&pS~kVE^)gjaB#g&%60ofAOc$EEipLv4le?Df9`Kisa@tmf+cV>QkR4fmlX@F1Bi` z#|KUnJo`D%{e_?XZ}4X5q&Hdt&8~Y>#Imn#bpMb4@PA%$Y^iPvs7xsM=^b6ZbR0`yZ=O7X#Z#E(f9#>Y0L7gl_t{~~v9qJS{ zQW4RPNzT?ry+G}SkE1iLT2;(HTHaBvaVjn3FUHDHroeoAZf}GlIg#Tf} zA(dnv$OSdPiGW!WscMVj#Ylgz;p7V8qPynWC*ouoc8>KsD=>4Rf^C&`siq}`{lb@g z)5{?mu}#EYN=;}KF-M>k>W^t8U8C3wt)73TH^&NLSMBq<%3fr)d#{5*&%<3?=Ln#YkTF`Zqq|+UqcGQOXYm z!|uQL!7u;gKmWlW|MDqaKjD*A(yY$H;@*LHDTj*-Z8sgh{*7<>)nEAU!|sYytj<)e zS(lm>F5-111?cz+0_~)(bgw&zWXUC41dsj`LI{P*Ip|TzG~61f8j0H~A~Im3AsiVo z=ZGs9N&zBSJ0inmm#1T7&#Oe1@`lG8LG^yrYAyFA`ZxBZ?5TdyYyQ12vy_-H4hEeK z+yrB}y}0JK8^}@;X2lb{j;}zS&bR*icfIdD@3{N+TX2KKt)-6QElFCP<(=plv!DSC zJKVz%+>Ct){KgH}KO>LResANz((;i9zW$3p^r67%U(pWxY6Uxc_)E9_;$46C&i8!Z z#g|_Gyz2oet&wKwVr}wi(tBz1bfDl4R2!sG3L@&0%#@!A{>=fsB6Q|oI3AU3sw&N6s z#Kca>bvIxz#WaBsLKPtaq6r~UB%!wL^eJb`eLvqhyIN6_SMER8{$(dJS*^C5GxMGA zJfG+J6c^Ow!NH2@sOphFx4Lr)dVP+TO%|q{f8`B+B3q^H9a@1qcyO zE44%2$@I%l{`9Lm9{kA1fBo{SuZIGZ?&{N$jk8yMXzPXt?Z#oZK8(6JkQo9-Ix#A| zY$|&~(T)%8onf>wsPcyf(~!|qrfXGtWAT9(xBSx=e)kW*@$U$Qd&;G-Z7n=w`L<1u z;FHAQDwQ^>^&^RNJ`S{SgIY^F_li%{jY(QtODB^14jzS7(K6gHmOl5&D;~P*_8i8u zh$5&ERg41e0ig=En0)fdD=xhXOryD4F`a+;#TUNx#V@2{!K9>9ukPte$DM)pW4FJ! z|CzOa`1?0f^K5=@+anMD{Eb&%b(|(@*TJD`EYk(W#dfJa6X4roHlL+wKKx2Iq@i}x zXH_9r@LW^;NzuYfUJFYCm9}AajHjuku>NWB|4f;onc`(+cIV5S*T5etiTM-DFy_UR zUd=0No_M_(K_2@j-@$y^)SYw!v8*jumU>Bx<+8|>VdzvtuW8pBai5e5ZCW{NT1T?F z6Isd8FlS6;iv9==4_XA_%y@nynPKwb3Y`WN;~P06jHB6gl74(bD+abdbm<2_^zh?r zGO~As=r0)CRGW=lxo@aR7ohv1i?3uTmB^RkrBa$SO=pR zrWE z!P$ID=2wYKYuFZ3Z51dE<4;o~O{(UEFVY21&+`g30|zQcwT+qHC3PTJB1bLG8LL^& zWHN=1I&Tpd?`cI)Ww89k`j z_XXnYgkf&HTC3&DT@8^K(GSV{^;%aRZ{MaSl5BAc55t@q;-Z|>gp5WP9TPr6av1W6 zn{PW4OEs|Q2GFH@FS-8LzH{@}6I!Y$Q%lIXTRi#jD)I!%a9-OlAw^)gSncW^$6KJeV5{9Q^W) ze{gXB9wg`##Ep7AnaVzP|M#EYvF?xm^v1r4llSg^<&g*OTDRt&Vji@KvW4^{!gI24 z`OsVGMrK07&iQVL-*`I7xLWt)sD(7kl!>P0YZ48V&QH4J@=xF(;bI5K$~+ zz1}brarNv9FdIb{toncATui+Hqghwl)&8S{*MIPX(&|npqkMMx@$YgW6z@EUG%8WH z8hukf-?l^QV@xJrd;I`BHQKtBmJLyzK|T+kMyvIWZ~XnIKmF-j{`r>2A6t(qI_Mk{ zF(M=fmHQ^Sf}yXhalFV?j3P+>RfQNvZ=CliROe82-!yIY>ecku`2gY;q=s z{nb!nEBV0=PEa&{nfy>W-G2M+u(^1_aNBszD_5?>{f4synFmS<#WBB{Dv)<(Ij!O0 zL0@vwe9LLjg47d?pL~p?<0+^de#5FA9UO#o2N{eL(%09=X;*Bkjy;s73GTr+2 zzucUm{s&;VtsE>ROjC8rGJp4rJHPUkKmYST`{P?~x%rh>c3F0<+*PR6F+js_z-l(D z$WEk->eHR&`g;;qprZ?K!yAPD-VT)0TBy=cAdGo3p%OL@LIwH5H^=3_S|SQ->nuHe zG?`njpt^j17omz^!MIuyRpg>68IU+egyIaw%z?tS(P%-wAziS|IrAB9 zz+GaE@>1}Gu>EKOh(~fqAEm1pOJ%{$Qjr8eO2$H=02hDxIpcxk~(@ z!kXCEE9+EVUl4RO3CE0{jyF5h-{}KoPdvhJv8*O%hJ{fscb|Rs*<7+xnG4#y>y96B zq0({4cE z;Bq=8oFjxtm~KNCgPem5qlx4NmJSxojaoV$%O?|Vqc-aM7X>Sg{`H-@8}jS82Zx6% z5nq(kzGB4+xPq)kSrJ4SDwdj%)V%gp*Ic8*Wc{dJVyG=CZbVz(+pkyWcc~zB(WvMW zj2oW2;yh+w%757#ICA)#-~9XjBZnF3D0gdlcU1P}OssO`z~6u6OJDio=l}Mv{_M%k z>${41*1AfKF+`{(>Ja%0xN@jE#GQd|q|vHp^J#cztW~7`j&H$Cb3JGK_NNk1n?nEa z;lo@$eub|bz1aNzIf=!01mLco;(U#%Nc!+YAV)6*hiWw5D>D8y-*;{dOGK-A+7lFQu1ml zM_IB>LTTK>5?Q)*8E2?1xs~?*eQ!LqWwRN22~28z5B?K=myn>CPQ_zd|G=Sp@3|A( zTxuSwTNo76r_X@)?THyNU1Ci%;c)kj5IKF4Kba(oYH~h&34!E{Y1LMx>C#khSDM2| zj~*5L6Bp;72@@w$0mGBwWODK|*gb{vdO11gp0k{5W;Gje-P*JJnTLLUXJhCnq?Sw~ zdFs6RU8OD((4wkBr9Fxnv&776u|=mW&LxW^$!B8mJAU+o!NdC*gHTPzdCFTAQY_+z zYlJr06-N&3IdbR~uTAnsSSY00Zi694;6v#lwVD#sL{!)q{MEx!a(B# zK#NLZkwi#=SaJoZn`2yoPzq?)1BR^$CD#rj7^xtou6QWET(u}#LtVS*1Nj)y3oU2A zE&ml`F<~2ymLp<^OoE;Yr4}<&UxQu7SIci>c!LWRW_1z@VvYczsHB$HrvAjDJRk7~ zQ>7dyki5K0F1dmxJ6H#S0eTDS?8qhJ@++?4+bPIY5vNjv^y3Sow;6DCcTXbV#j@MD zELiBp<0$MUULuy!n@(-G)(^dfYdkpAKXvL<8y6!zQ1~PaqT6-^rHpzcs5lKnMU0z~ z0hIa*Lr%5WzNy;*T$nI1&t(bpPVDRH?e!(~h6sx0eRut+K71&~S+biw`QoyrXCV)& zR~xE)msBLVB2Yb*&zhR2@=1vVQQ$=Golxum1Z0MCTZJR6zo;w0B&-(KZ8Lxth~&tl zHIHoo0KM7P66t~_5i5QQD?kjj+EzgptkLNtsPb3E$!q^ft;r1ve4>Jva6r#z&7Xh% z`P9oO_|by1BO&Q#B}|<*W9eDvNPtK@9syw9pUf_FT|btP5FU5k6h35_O;F;+=&lj%m|oW&_1YFYjL+}+Ks%?9zL{pVE@bQ#$ZZoo4zg4n4*I- zl@)W?nJj8GkbM9h)Y;3HV;C3{(T*7z*2|^Ql?X(T zzi+4zNnT-eHlohJD1M8&SP^;*j~#x5VMZaPC}+f(jr9^+x@1`(W@2P_%KrJ})=liX z3GP9yp@3Bl!)j(b^f&qk4yyfAl4|Mf%$a@i^l5VwiK6Ed%9-Mxk4Id}n2F2;%~HKK zj0>S-*(Xe&{=WBKC+Wn*__D7Pqa>}0u(SSU)Y!S8{yMmhZO0a@<`&sVx-60V1Tf8c7z+*gjf7BS`JA2n;Eela!ru9si5DwZLcE{R;W z{K*?WeZ!~zSI?9a+h*3G1)MI{EFarSI4voODWDKlxJyK;0!unYjzpx1aW0=!Ca0=2 z0iuZJQ>rd1hA5j-J!_@!NucuNsnhwSyg|FQWz+fud?3Evv* zZ7LEPs7zOQx-&O$>0&B(DXf0yo^(oAaiUeT@V)gNd)wwGF!FJ%!D5!8NXt+Qke>ri z#OxRrhi@6CPc)5dH@R@=5K0Az@)j}~G_!otaVTokv(o`rS+nM!vS3NGiR3yXzD9xv67x?vZRV_#9IKg1z(3;mXi>2hO$Fb1_aRrb z;W90YC3ER~8TUEVI&A?V8X7Uo^LqMvM2wL%%f-TxH{V3Pt$;@uMqLA=(>oYbPb;i4 z#@V;5AQ+#jRiUz8qCs68OxkE=$#K>+o0xEbtEHF+zTN7W5 zBJP5f7b4>h#o;iHtr;KSzu3-ZsIG|r)>h83O;TnJfHdaC+{>_1 zr%#(nG0nCB;|PU%vpLK>(sdhdtIjL|moX@(v{jg^B(-*Z=nz;VX~^i0vuFkisA~#6 z%5{?N0q3ZJ6Sf%Y!J1@{5J0`Mv^K6Kd`u`xOkTdsmhmVr6$^}Hah{NP89G5^v$pvj z4PgoB^Q76cG#5n?Slddfn!X^B`xG6(Kai$g&a$X%YA zgG51+`q)^TaOC6Bxo!vB>zyfz%&H~BRal0ec$SG#Ip%v)rk)@O17(!jKanu;Fj4!b zjWM$C)zT@HKQ;D~iGAH$wrm_89N9nH;Ten1*0q#l@uD)40PBg?OQki5Tf-v{-OH|v z(M7#dz2JhC=bv{W#tJB*uqv^A?Rbg&IDumP7(d33aiH?c1XqqjDV<5ZD&!-;(iz^9 z01;Cql{|X_;VIDyC00c!Hu!{UwHomb#y7tHx6ki<21{B*=+p_|hgM=3L_J7$5A|zKs?>F9W{6DZ+a)m`QYT>7+Wu`ZXbKd;9ji zMsJn+kLUKZsV9(kiA+{(|5YcI`hY@3(mRkufd5k2x)UVG(KC@7Kx2bzF80TZm!3l( zokC=Au%BeTr=Q-=3xhl+mc3-#lV=|zjW0Q4#>}Zxr%9#+J#uSc`;(7T1gv}HA=;|Y zzuUHZ{<-Hf&G03}JuZ_Ykv9m$IrX%Kz9cSWc=jJUa1x9nuC@Y6 zXu*Cbh#Z8YAl8U4bS!>RQJd7|kg7W>l7!1OLj6(%E$P;)&!{wehAIGQJskNX*BMah z38uy%7@Jlx!>!bsxqK1FDhf-yp)C9t;@JX`=N=!oHDi>nBHwPRU~@4>(}ah^G>^)A z@7|Xca7G}Uz4X%e81a0wt)ulPq=j^pE3SMWxhAyIiN$M;`ha8Z9PNjcVMuv z4^vYnQId-);6I9FRmd>9v!p8@-9FJLCApkFJ&;gL3j_&LwF7|D2&yqX3+hfgU7lbC zi2-s%iM~t}s>b@-KN4EM z;=Bvth}LZ!g)#XpW(r+rEM2C8MD>Gj?pMtOM~wayPcIq>yd9#9j#i*z;+Do~oit^7 zE?Y$JAc>esW9ydn=_DXQN6F7PO_lxxj4-uHbKl{Liyu0FaB1@;QizOup*uqB6POa9 z>am&DIFe(7Q7;Miahcbd33U9ae3vweV(Fo3w2f@0?p&`)P*ZA2Dd{%qidin)MRDo zN&v6sDzZI|Qor7^fMv85Q-4$sNIzzY9jLyNI)R~_OyC?}(KnI(Go|)VNs49v)P!zX zNBtu4woqqNBm1YreN&VDbN_21Y?GNwIBlBRKVxZeVABY56f83B+K3vW)%?{()5him-4=2c7*N$D^aYf4bF@B66zwpQZI5EA}sd6IE z6Z5UGZPX7+Qxyo>d#@cm#;DbJW?-2qRy7`us5HoE_i3Kxr%>7&?Z5c*&*Ks^`^+VL zFNU_t;~1M<$QV2r;yiVzt?7k9o)Ff1IX&DOqGzvi^RzK9=~%&`W*VkEJ?YWO7)Pq5 zty>=LE@v*j`0B}%W-zfq!b^)3fyM_vGW%m6|E=v?w>-XP&DO`)_H`3-lE58H;vD43 zin5r2zhtmUh=e>_1wc{Nh-H8j4isCWFc$Y_!B_F%=3Su-RvOTW9y7&1bhxReW+WG9$wMre&E5KO3*nQ^WvvxeSvCVi417pzw z<8?W(@~Y0?WI*CSH5pN8W$!wK+7~(8H)yn0v&`=bqj158wDI22eCp zrT$cERs|YW9At-@(J>>7`nW3lJ?J17(FzHO)<^1aU0p?87-H&2$PYB7g)5*G{Q$Hd z)}>%Ay1#&|88c>l``iDP$7Yu61wvr;dadg4(OdrcA9lU)tN>KGoa!g5U#)DsfrO?k zMZVUzfeI%~?Mvr5rTWRIE}l4L)}cc$2dL~W#`RvlTSNNv5iQMA+n<~Ghu!%Zle&OrWrkxnWGz^YryFGCD2ptO6 zeA}squ0>tWNQbMbXM6iwO_WmN@@u4hkKx$_>Tz}qnkh12>d5eb+#WI~=1WZKOfwC9 z;`11v=-_IcDzx}XC!M}%(b<{zOF3!&so7jf>@6T;dhNIjGiljKIHG|6Xpj{i z+mqG3HZtTMonMV`EY&B2h!19rwwaiGV;XJ5CQY2e3F9JSWyUMPgDdXYxs+Tg_=7mL zBV6NEiAIb4MfE1$Jov_f)0faW;zg9Z3p{MR&`}YqDv3#cF-gA`V z%`;5dFHtN&?cqtyM{T#KTzvV3e!tx)7ZP8->2sg?+!sz=a5@G)VcW*Al#;k!cXFAe zC_Nw`LucSwDyb(ZAW83Xp!C0O#uXet;Sniw9-H`GF~}%1;3%Bh{iVOn#wZi4H;WyF@VG zNpesW2wLdyy#2Pwg(>OCN*93#a11g@tIjKwP-!Uej%}Ffn7G>j!|=L-Ih}~-(np4@ zMx#odiiIwP9UWpq?_Ara_7Z)yCZ;%T@!&P5&zN&F7Rd#Cwlo4jh^I_u(}T641pYY* zlr|6;@W*PV6B>p#^dJklbfY?u&cr1+B~z&M*YaIGr!PJm83u`aXtE!Aza$op_wHK%s;TmSSmRMDKTd@j{C@JGnv!s9w2kq8i8 zc=e%=F)=ckM5N(Ci%twZ1y+CtMvH3bh|Q&`s<0i^vTB^CkDaPTf0Mz2TqXV3+7OFb zso4d3n;j`eG!UeACeV@mPvR!vycG(4obG0=P8J6SN&;M1zED!kTyGbIJ<2mv_2{F) zny=C`j;YfTH!Eo1w;J`$8#i5X@fCb4c;eg(R{!j-!`vNB+xp~H@1fYG#IINT*FUy) z;hD>`5RfEC!lPPa`V)JnfOvk%iu2E#KYt#V6*6I{%((elf-La@5W8?f%Wa2d@y5s3 zHW~wVb0D269;czum1bQb<`FeWpbWv~EE>YXag{%ram<`rMjHlPC8w>QeeQ+*!*v2N zQ<>!2wQD(x^!*wA-S_i*7M!-ESm-)<;L!4OS3daAeT~{dniA?;H3cGlNih-fnR#sf z86K-&MamL{V_s)RoQ*i9jb((133ak*`^B!ldTThaMPw0|?8q84~$Jos*1_Qw5P= zSSeVDaXCXM&6g})dcifn!X+f+5tJl@8>@+5GX{YF{11QE_J;*88lroN@DV485YL6D zpY@%8|3M*>uo}YwX-MePxHsY%a7eM#%`lUhWUDpAFZtP>w?PIBWS;`7q;POxa1A0j z`IOTpO`0ZKcNo;`)lHi=3YRtTG5L7&%|jRF!MKg$eP(nbD47FtcOzNHe6Vve|9srhXWI~%2jf$i|w(6(^r%Wt8 zx#fw6=FI!VM}Mn^MWd#jy6}vZ7hSe~-5Rtp(#cR9=23=Z2U+QFwPMi6bt~h?_%VL` zLLTF!Rc-9{tsXYo=wMt%C<);))5nzfZXVAnkSrFuDWZzKMmiBRtA}%`FrnG5HDrb@ z>eqdHo==&C20P+&4AH=9!G+-GIMPa$q&8`6N+KcYCGnBp^wtXzbwvvlm)-{M}Kn8%GIeZESNA; zPK2EVKqGpaB9I7FBVTwl)Fqm<_9Du)7IDD*=$C?D+O8KtCdMf=FJ-n96Dk_rLCXE8 zUoLK2Dn@`OJuDj7)taJl!`c!_C_Qlrz7|yMONfZ1F;E;r1wVSUmM`|!>+rMmo}THk zMAy+lt5lpukVdUWt20UVL_QPy>5sp^b<^YEoJM_EDCv{|h6$evw%C$o=cfx@@b4S8 z^TLZSZ-4e#w6u+ugoZ!9ev>abjApHAopkbPXiO78oMeJtFBuw~(X2$*`Fy?AcGHC% zfsKLC^h{e4ov9eg(`L+@KI7C`^A=4#VZnqca~3W>?}XV4=A67}?kS6jpGRrZBt=c6 z)H-2>_-4gOdP_(SInjSs!=mh<5026m8VP(KmmCRD8f*_6Pf@*t~JHN08-Bn7Q`;Qm1D zvsYxYc@m0h)uE?%Y~yQOCI{eHLo_+!AUD$4eapTxFg(!R+r=8947Kebo$fXa!aqxT zm>M2x$S)7Vg9qODk6XXfe`J5(gpx6mqHSm+|BXH)z)!3NR8oyZYElUDabQFzIna)^ zkt5xY)-_?$tl9JO`5qv#)oO3tuz?@NXr)%Kzr5#lSVjj94eFRr_fDQTWkx6iK;#)6 z(U*8QfVNQzMJo4E5?1Gu9ot}yj)Y<4MvrBhZUA2nvXq%fAh_aXGu=vv<$1@BFTsFC zQ&J+8A~NQu1s%!6+wjA&36<60(s!53w$pla&BJiO;1V%iz3j@XZHXx_Oqnrz-uwj; zl-X*$yz9l+Ufav<%xlqZWZv~`JA4~C(Su!iJ8gsv)Y=J`;ovSvcF^25vN#> zWh;aAWW1P+cg6HlD&7-#@ZtIfhiq;jJO1xU3}Sa-6H1`-TBBx3zYd+5Y0Ek zG4Fk}a}*tb4pSWwD{thVI!cvM4@quSTSDqb_fLt4Rg-od-#PY4rDx%ErFCUaz!Fyb zXHwj!F^QHvn0pWxNn7Hr80aZ4FGD7sGWtoyjs7DCCv@k3`ja1S-?o{_65orz-v>VY zv7U)jF=tn1U&iQOacurGevBXE$M`WmQTgRMMjt|B_i!n`dCI}|p&}=~!w7M{fANK# zIEzY>oe9UGA&t6MUQ$YOi<-=~Du4bbzx$V8`No`+7KX7Dx!hAGl^Lo~;`4l>1uMzW z_<*4lr{5O5>jQ(wJjzR+w9!L_k1YWPsa%qu~Q1s7es z^sMDbAbeqwrwYAOKK|>UIW%0^`_yKh`xtQ+vMEtFNN$Xq6h|hEh2W^f!9Ged;dZp3 z;`b#32Nj1WIcs=`p^f2*n@MLVdYD2$U!l&5h|1B?;a7ROxHh+#&| zPl(}PL|rb@Y5_BpqNsYPD1ZtSu*A?OLri!`s*8c>>ON=K$Y#4BbPe`5CQq8^v^3%l z{^Qnf@7T7fx0GvJwYUVROAnUXYoKb;YF@nTTnaG29C^$;);yNUmxvk!FT_&mmtJ{w z|A9j@XU))!EY%Nn$(^@(ye-D) zfzPC~WT@NCmTO4ukV2Bd8z)$ih=$0pfNQJFNJT?)_{{@k7bIi;!F^R+ZYA%;bNdhP z$AAc(a52YO;>tSIqfOS2DJH>?{P6||Lgn5NnWIX{YA;;yzZczVs(Qz4asSxembtbyzBu{OOPG z6zUAxCoJINslc(vYQe`AzESO-&to-#{Mtd^MT#H&a>?&-#aWMV)ynX|+DGnr`2L?D zGM>;~=s)^qIx$JgL(z`K#GAmtLYjpwGY4H&Vn#Gz>B!B42vPvJh0RKdw8G%5K+S%ZMarjw_)@3ANX*s)?%$+dgXg}JhK_1W-^gtpqB%a z;Q~BVPbRX${@0WSAXGp}YEq$BDO+z-nMWJyWvJ^Ss1oT!CiDm}g81ZBjvR8`3b>xp z5vVGU$V0OazPXR{jq+MvtQU?N_l1#PA2qk&MwzteMrY`Xe}`1%Y6Igc)Em`YCcSm@ z6Cb|*eP}tjSTkqOop;JgeWK%oe>dT`+{1|+f)<B04Gx-L9Njfz!Vn;AxyIAhl(saf;*%0Ken+IjZxla z!6Iol%p28@qrv5|TC4{48^i|}qy1By*>G!+{j(+x*NWK-j!0m;f+|ILy3zh=(HxVE z%C^(lKUp)H#G|&C+z6U`41bQxRK}0-WBeEgD!X|lVLPG8PNblBa|S@BzTvn1yAe9 zn4hAIho9@%QJ%ePwh5loHS!{thh=ZZ% zLq;RinWPemSi(zQd}ezon~uR?OJ;}bjZ;rwNFD<52t$L;Q`?@s^_Kq?)BJKi9lNTbq2R{r~Dvzvvw11dbx02%;^@g}@J^SMGxpHyJ1h|M7JXK6vj>HNTZlC)!Spc?BqzWNBF>Y_zwX7at2TJ^jOS8sgsi6u+V1v5}#;gtH+wyn)ZJGF=@jrCzz(LyoSjb6z%{4_fjb9%eHbf7}(xNk#f;`iiF8nQ?d-i!B zD=Yp_Il{p-dgl2TmMl9|E_DMW=be1=wCOXC9C|qsnE7G}!7~#ekdmedV58%PV;89N z@mprsa-+2gpm9M0xS1yo9XNE;pZuQZ4#5e9qZ2n-ElohQr0{883#*27RymY0GgJM0 z5xYl5>W21iMrwF};HZtCm)k;6b?@DG{pzp&s+d@4;YAl;5?{aJ^hJxQTmd=HKlkk3 z-Mg^4Gr|$dTr{4FesVZ=kkY{_n$pgJ+dXB@arp2-<$oxyUtL{2lP67S47`DIES^mE z4^+Ck%3S++h*7~{Pv1=7Nj4X+3_7%0Tyk6l8#Dh96GHri#YZ~;(f~`EOjqB0?X`r2 z3gI9I*C~0*X^RgI4E0RxvK;4MZ@GEL*0oxDIGe?e{BR<)3+2r4a6>aO1k1UuzI^$L zY`#olX47iF^6G1~mV}Bh(WCbOgxkOW_1(L7&7Bv^=Zb^XqwJP%zPZ=%n`qeuzR69b zc#q<*r)#?LpOO)Rx3LoBF`-9rSoA@^$SMwG|Fmpq zqwp|k7sSjM%^fyE**~XDmmDO{P%QE4-hKR+rWnr6ZsgN|zEI6c^l0OzG0<)d443kwZq?W*x4V<6lQGZMHuj!(vq@nifL2P*%+ICyYS zs!_2)&oHwYMiH>TKq>UOG@o{0%xcy6CvpZ=mOMcihQxJi+FyS03xD>-ufY+jHAT|R zqvQC8+`Ao}=ZCbxVlhRjl4dy)befkm?Fbd)J(HM^Ecry7lBPfy>E zfBYZ&4j%cf-}wW`PWjU0UM<@Zy@ZKKB)92nW9h=hyhk)?4(JJq)J=$RHjT+ z&SrXqSSVhx!aRI%AE2tMy8yQ*pG_@Za@M+sjzD9#TO?1XZBf>0SQyuuQqYt6-D;aN zPMpJo7G{fh^-`T#Ei<&^n1e~sgePIjVt`}RDg$5p+LwY>6Tv*GaR~Q+=d+((a`ri} zlrq`OigTC#^!vFML2vl@00m7?jAq60E1u1C9UgX8uDYaH>Nz@8MRH6J@^An4Ybosz z0$&2SO(C^Q)&)3fXc)p+IA`9;vu4kKW!JL_P=!#?0_xz^siak_Ws}L$^qHk>ssHdB z7?qLU7?(bI@U4ITyYGDGJ1v)_k?cjQFT3H_e;uEzayhr>;I1SpVlAr0;n~wvZel7+ ziIYlUip6Neim`UA2L4eLjeuFE44F)VPodf1G)FTlN;KD&IG0p14K0xIUBVa(OH@BT zs+K}yggAA)t5qmHb^*O%hg=^C^ChA*Fj#e1q2a35uvuACOQFm$`&tUT4imvSPEMW^oOX(sU4lj4yOi)}U<=?K+jC z8fh@kq&mzb5+^TOHlc4C_G~TNf~xeP54``AAHFsgCo{asG$@fQ)*J0oX#y-7h+~OV zhBbNjPxrXWiHo##Vc~?1e(Z`U)z`>H86|&($1Y;$Gf=#c^58^^L0ti@id!#bO*-pB zyK0kd9=HuFn@_cD?g$x8LEQ`@p|&!&p!3ct5nm0@f2Y!!RS@DFB@W;iMzpQX8y9TaLC>?dW_-+63M;}fZN@p?hdW|UVa11;S$6heC!1z~5$dQ3Vd+)2S z4D=r|v!r1%M9EF)n_^e{Nk3(S$>xf+ma~4-=DQzRHEHt9gNGVD6XyQi-`~=e&5-h% zP3f*x(_|Cy#9m9bpcog^hZvW3HaxlcU%&Ye3C^7l6tN~vJ@K=D__;X?PCq&{%v|Qm zt1j8N{!Uz^*={rGFdm>8v#0>AKvKWhGwFPT#U92lx%?_zMpBs^XKU_B^S<`AuOXJ> zX2+lpA{=iaoyFq3EeJM|I&NIDQQB7e%ITPH-~N;T@Vo}jo3>rk_4da;`70{_L@01cm8L=! zOMkfm3h~3mOO_(R$AcB9HZ*vYc?Co@G8jZ2V;ZEG(5R(A7m2In#w5=_e>E1mP&;^9 zQxosm^9uAYk{~e)ZMWJNoxi%LJSkzOn3V8~a4V)7!)~>^ly2MA-tL^;9Ku`x4pn9N zi1dRE3CBPM5y)VJ(jW2_%rD1nlMjd&92uRKM0o42jMGFGBtmE$nPZ7GmbmyA(?!;e z6uo1is)rJf^IzSb#(x5Cj|qNtftTP7ums~2Qxde}bfc-*%H3Em@M;MN#f^@!Sc_U! zC5!uhBna2U%MK=<6#1~K#8phxG?;Ry5rSLjvh+tDxd%J11aaD~bK!*-du>rEI9C1f zbq{6JtanN)C%P%*#&PYDoXwG%E*yO)K_Q>TXqE9Gh>k@2WDH?XGp1Kkv3e%uB;$7I z)ul{pV>@9KvL&UthMwevYRC{xCKg92Hf`rDKNl_&Os2M^m;kXwrxj%& zj=gomcX3e~)&GlF$a9c{#7Qe6!6tND)dAgWrLlc#w;)D#my5R51c1?)U~0srVf}mJ zlO0B}>--d9{u~ch$Nb_z%+rmKs{o+D|KQO_9*(|?ap>u%pN<5A694trcE9k2W{dcusEbC3ptKiPE&En%=D znz|?)h}MGPRfh+}CCm@=xe~X?$@5Ro=gX14D~fMF?lg>^htN3w<9B+pbWWEH!AxZb z$Nqx>v(>7+_S&9GbvTifT-Y<0uzwPGg@bt-bsUDuzS>IS=^PW3;YvgH&v?2~x7GgH zy>`voakQJVn zpj%&d@_EFALGY3Q2C{JQ zg$S&biG7m?hK58hZ^U=KvOBV{SN|AAI!Na-N)XrIT5BGAa!_LOw4>aN80t#58DG76 z^{@W=@BjTj-g?uQzQMrcNPjb1=q1FB4kD(pH1janp}skC_z=7ligXA8i8QKLa8S4no;ddF0gH6g~VQN;#dTrlU>|=ALx&>PxRWGSo0rg

    ` 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�C@R6~ZR+@L+$nj}7C?Yer3z4wY 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 literal 0 HcmV?d00001 diff --git a/docs/docs/administration/jobs-workers.md b/docs/docs/administration/jobs-workers.md index ff74ea4673..fb5ca7c059 100644 --- a/docs/docs/administration/jobs-workers.md +++ b/docs/docs/administration/jobs-workers.md @@ -52,4 +52,4 @@ Additionally, some jobs run on a schedule, which is every night at midnight. Thi Storage Migration job can be run after changing the [Storage Template](/docs/administration/storage-template.mdx), in order to apply the change to the existing library. ::: - + diff --git a/docs/docs/administration/system-settings.md b/docs/docs/administration/system-settings.md index f92bab4938..9f35ed1010 100644 --- a/docs/docs/administration/system-settings.md +++ b/docs/docs/administration/system-settings.md @@ -104,7 +104,7 @@ You can choose to disable a certain type of machine learning, for example smart ### Smart Search -The smart search settings are designed to allow the search tool to be used using [CLIP](https://openai.com/research/clip) models that [can be changed](/docs/FAQ#can-i-use-a-custom-clip-model), different models will necessarily give better results but may consume more processing power, when changing a model it is mandatory to re-run the +The [smart search](/docs/features/smart-search) settings are designed to allow the search tool to be used using [CLIP](https://openai.com/research/clip) models that [can be changed](/docs/FAQ#can-i-use-a-custom-clip-model), different models will necessarily give better results but may consume more processing power, when changing a model it is mandatory to re-run the Smart Search job on all images to fully apply the change. :::info Internet connection @@ -113,15 +113,23 @@ After downloading, there is no need for Immich to connect to the network Unless version checking has been enabled in the settings. ::: +### Duplicate Detection + +Use CLIP embeddings to find likely duplicates. The maximum detection distance can be configured in order to improve / reduce the level of accuracy. + +- **Maximum detection distance -** Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives. + ### Facial Recognition Under these settings, you can change the facial recognition settings Editable settings: -- **Facial Recognition Model -** Models are listed in descending order of size. Larger models are slower and use more memory, but produce better results. Note that you must re-run the Face Detection job for all images upon changing a model. -- **Min Detection Score -** Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives. -- **Max Recognition Distance -** Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible. -- **Min Recognized Faces -** The minimum number of recognized faces for a person to be created (AKA: Core face). Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person. +- **Facial Recognition Model** +- **Min Detection Score** +- **Max Recognition Distance** +- **Min Recognized Faces** + +You can learn more about these options on the [Facial Recognition page](/docs/features/facial-recognition#how-face-detection-works) :::info When changing the values in Min Detection Score, Max Recognition Distance, and Min Recognized Faces. diff --git a/docs/docs/features/shared-albums.md b/docs/docs/features/shared-albums.md index 2684acfd9c..dcf884bc9b 100644 --- a/docs/docs/features/shared-albums.md +++ b/docs/docs/features/shared-albums.md @@ -16,7 +16,7 @@ When sharing shared albums, whats shared is: - Download all assets as zip file (Web only). :::info Archive size limited. - If the size of the album exceeds 4GB, the archive files will be divided into 4GB each. + If the size of the album exceeds 4GB, the archive files will by default be divided into 4GB each. This can be changed on the user settings page. ::: - Add a description to the album (Web only). - Slideshow view (Web only). @@ -152,7 +152,7 @@ Some of the features are not available on mobile, to understand what the full fe ## Sharing Between Users -#### Add or remove users from the album. +#### Add or remove users from the album :::info remove user(s) When a user is removed from the album, the photos he uploaded will still appear in the album. diff --git a/docs/docs/guides/remote-access.md b/docs/docs/guides/remote-access.md index cd8bf66f14..326ac6c93d 100644 --- a/docs/docs/guides/remote-access.md +++ b/docs/docs/guides/remote-access.md @@ -11,13 +11,13 @@ Never forward port 2283 directly to the internet without additional configuratio You may use a VPN service to open an encrypted connection to your Immich instance. OpenVPN and Wireguard are two popular VPN solutions. Here is a guide on setting up VPN access to your server - [Pihole documentation](https://docs.pi-hole.net/guides/vpn/wireguard/overview/) -### Pros: +### Pros - Simple to set up and very secure. - Single point of potential failure, i.e., the VPN software itself. Even if there is a zero-day vulnerability on Immich, you will not be at risk. - Both Wireguard and OpenVPN are independently security-audited, so the risk of serious zero-day exploits are minimal. -### Cons: +### Cons - If you don't have a static IP address, you would need to set up a [Dynamic DNS](https://www.cloudflare.com/learning/dns/glossary/dynamic-dns/). [DuckDNS](https://www.duckdns.org/) is a free DDNS provider. - VPN software needs to be installed and active on both server-side and client-side. @@ -27,6 +27,10 @@ You may use a VPN service to open an encrypted connection to your Immich instanc If you are unable to open a port on your router for Wireguard or OpenVPN to your server, [Tailscale](https://tailscale.com/) is a good option. Tailscale mediates a peer-to-peer wireguard tunnel between your server and remote device, even if one or both of them are behind a [NAT firewall](https://en.wikipedia.org/wiki/Network_address_translation). +:::tip Video toturial +You can learn how to set up Tailscale together with Immich with the [tutorial video](https://www.youtube.com/watch?v=Vt4PDUXB_fg) they created. +::: + ### Pros - Minimal configuration needed on server and client sides. diff --git a/docs/docs/guides/remote-machine-learning.md b/docs/docs/guides/remote-machine-learning.md index e4186e1697..4dbb72a408 100644 --- a/docs/docs/guides/remote-machine-learning.md +++ b/docs/docs/guides/remote-machine-learning.md @@ -11,6 +11,10 @@ To alleviate [performance issues on low-memory systems](/docs/FAQ.mdx#why-is-imm Smart Search and Face Detection will use this feature, but Facial Recognition is handled in the server. ::: +:::danger +When using remote machine learning, the thumbnails are sent to the remote machine learning container. Use this option carefully when running this on a public computer or a paid processing cloud. +::: + ```yaml name: immich_remote_ml diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx index 0f30bac60f..23a55ca9ce 100644 --- a/docs/src/components/community-projects.tsx +++ b/docs/src/components/community-projects.tsx @@ -28,11 +28,6 @@ const projects: CommunityProjectProps[] = [ description: 'A simple way to remove orphaned offline assets from the Immich database', url: 'https://github.com/Thoroslives/immich_remove_offline_files', }, - { - title: 'Create albums from folders', - description: 'A Python script to create albums based on the folder structure of an external library.', - url: 'https://github.com/Salvoxia/immich-folder-album-creator', - }, { title: 'Immich-Tools', description: 'Provides scripts for handling problems on the repair page.', @@ -58,6 +53,11 @@ const projects: CommunityProjectProps[] = [ description: 'Unofficial Immich Android TV app.', url: 'https://github.com/giejay/Immich-Android-TV', }, + { + title: 'Create albums from folders', + description: 'A Python script to create albums based on the folder structure of an external library.', + url: 'https://github.com/Salvoxia/immich-folder-album-creator', + }, { title: 'Powershell Module PSImmich', description: 'Powershell Module for the Immich API', @@ -75,8 +75,7 @@ const projects: CommunityProjectProps[] = [ }, { title: 'Immich Power Tools', - description: - 'An unofficial immich client providing tools to speed up your workflows in Immich to organize your people and albums.', + description: 'Power tools for organizing your immich library.', url: 'https://github.com/varun-raj/immich-power-tools', }, ]; From 363c558db7164d427517f4419f4c933f96a29426 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Wed, 28 Aug 2024 19:05:48 +0200 Subject: [PATCH 194/723] fix(server): don't crash when refreshing large libraries (#7934) * add job to check for offline files * fix lint * only check for offline when using checkForOffline * improve tests * remove old test * wip * remove trie * refactor batches * also check offline status * fix spelling * don't do offline scan * rename scan to check * fix job statuses * fix lint * cleanup * add test * open-api * fix test * fix spinner * reset text * don't double batch * fix comments from mert * remove tries * fix tests * fix e2e * fix test * fix test * add tests * fix lint * fix e2e * interweave scans * fix errors * fix messages * fix test * add mock * fix sql * fix e2e * use library batch size * save -> update * add file extensions * update specs * test for import paths * check import paths when testing offline * fix lint * normalize import path * remove console logs * decrease batch size to 1000 * add test for import path * add test for already-online assets * fix merge * fix lint * add library job back * add offline job to correct queue * library spec compiles now * move one test to new e2e * fix comments * fix comments * fix lint * refactor path validation * fix loop bug * remove logging * expect responses * fix asset mock * take the straightforward approach * use generator correctly * fix vitest on file edit * bump vitest to 1.6.0 * test for offline check * add e2e tests for offlining assets depending on import path * cleanup e2e test after finish * cleanup library service * paginate the walk generator * fix tests * fix typo * refactoring handleOfflineCheck * better testing of handleOfflineCheck * fix lint * handle large library deletions * dont check if library is deleted * fix mock * add a 100k page size to library * fix loading animation * better log messages * Better logging for offline asset removal * fix sql and tests * fix number format * Remove submodule * fix format * chore: cleanup * chore: fix tests --------- Co-authored-by: Alex Co-authored-by: Jason Rasmussen --- e2e/src/api/specs/library.e2e-spec.ts | 39 ++- server/package-lock.json | 27 -- server/package.json | 1 - server/src/dtos/library.dto.ts | 10 +- server/src/interfaces/asset.interface.ts | 1 + server/src/interfaces/job.interface.ts | 7 + server/src/interfaces/library.interface.ts | 1 - server/src/interfaces/storage.interface.ts | 6 +- server/src/queries/library.repository.sql | 11 - server/src/repositories/asset.repository.ts | 11 +- server/src/repositories/job.repository.ts | 1 + server/src/repositories/library.repository.ts | 24 -- server/src/repositories/storage.repository.ts | 17 +- server/src/services/library.service.spec.ts | 165 +++++++--- server/src/services/library.service.ts | 299 +++++++++--------- server/src/services/microservices.service.ts | 3 +- .../repositories/library.repository.mock.ts | 1 - .../admin/library-management/+page.svelte | 22 +- 18 files changed, 364 insertions(+), 282 deletions(-) diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 59968f3b79..013e1364ca 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -364,7 +364,7 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); }); - it('should offline missing files', async () => { + it('should offline a file missing from disk', async () => { utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -391,6 +391,43 @@ describe('/libraries', () => { ); }); + it('should offline a file outside of import paths', async () => { + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); + utils.createImageFile(`${testAssetDir}/temp/directoryB/assetC.png`); + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp`], + }); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + await request(app) + .put(`/libraries/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ importPaths: [`${testAssetDirInternal}/temp/directoryA`] }); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(assets.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + isOffline: false, + originalFileName: 'assetB.png', + }), + expect.objectContaining({ + isOffline: true, + originalFileName: 'assetC.png', + }), + ]), + ); + + utils.removeImageFile(`${testAssetDir}/temp/directoryB/assetC.png`); + }); + it('should not try to delete offline files', async () => { utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`); diff --git a/server/package-lock.json b/server/package-lock.json index 972d116463..1ec4fe0fb0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -45,7 +45,6 @@ "js-yaml": "^4.1.0", "lodash": "^4.17.21", "luxon": "^3.4.2", - "mnemonist": "^0.39.8", "nest-commander": "^3.11.1", "nestjs-cls": "^4.3.0", "nestjs-otel": "^6.0.0", @@ -10434,14 +10433,6 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, - "node_modules/mnemonist": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", - "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", - "dependencies": { - "obliterator": "^2.0.1" - } - }, "node_modules/mock-fs": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", @@ -10955,11 +10946,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obliterator": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz", - "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==" - }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -22483,14 +22469,6 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, - "mnemonist": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", - "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", - "requires": { - "obliterator": "^2.0.1" - } - }, "mock-fs": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", @@ -22855,11 +22833,6 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" }, - "obliterator": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz", - "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==" - }, "obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", diff --git a/server/package.json b/server/package.json index f58ad98b08..9b42922278 100644 --- a/server/package.json +++ b/server/package.json @@ -71,7 +71,6 @@ "js-yaml": "^4.1.0", "lodash": "^4.17.21", "luxon": "^3.4.2", - "mnemonist": "^0.39.8", "nest-commander": "^3.11.1", "nestjs-cls": "^4.3.0", "nestjs-otel": "^6.0.0", diff --git a/server/src/dtos/library.dto.ts b/server/src/dtos/library.dto.ts index b9578a2c37..c2c3ac9d27 100644 --- a/server/src/dtos/library.dto.ts +++ b/server/src/dtos/library.dto.ts @@ -48,12 +48,16 @@ export class UpdateLibraryDto { exclusionPatterns?: string[]; } -export class CrawlOptionsDto { - pathsToCrawl!: string[]; - includeHidden? = false; +export interface CrawlOptionsDto { + pathsToCrawl: string[]; + includeHidden?: boolean; exclusionPatterns?: string[]; } +export interface WalkOptionsDto extends CrawlOptionsDto { + take: number; +} + export class ValidateLibraryDto { @Optional() @IsString({ each: true }) diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 666c6d3f7e..9f9218a3e3 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -36,6 +36,7 @@ export enum WithoutProperty { export enum WithProperty { SIDECAR = 'sidecar', + IS_ONLINE = 'isOnline', IS_OFFLINE = 'isOffline', } diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 7776d2bd37..fab959936f 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -76,6 +76,7 @@ export enum JobName { LIBRARY_SCAN = 'library-refresh', LIBRARY_SCAN_ASSET = 'library-refresh-asset', LIBRARY_REMOVE_OFFLINE = 'library-remove-offline', + LIBRARY_CHECK_OFFLINE = 'library-check-offline', LIBRARY_DELETE = 'library-delete', LIBRARY_QUEUE_SCAN_ALL = 'library-queue-all-refresh', LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', @@ -110,6 +111,7 @@ export enum JobName { } export const JOBS_ASSET_PAGINATION_SIZE = 1000; +export const JOBS_LIBRARY_PAGINATION_SIZE = 100_000; export interface IBaseJob { force?: boolean; @@ -129,6 +131,10 @@ export interface ILibraryFileJob extends IEntityJob { assetPath: string; } +export interface ILibraryOfflineJob extends IEntityJob { + importPaths: string[]; +} + export interface ILibraryRefreshJob extends IEntityJob { refreshModifiedFiles: boolean; refreshAllFiles: boolean; @@ -264,6 +270,7 @@ export type JobItem = | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob } | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob } + | { name: JobName.LIBRARY_CHECK_OFFLINE; data: IEntityJob } | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } // Notification diff --git a/server/src/interfaces/library.interface.ts b/server/src/interfaces/library.interface.ts index 6468977df4..d8f1a13031 100644 --- a/server/src/interfaces/library.interface.ts +++ b/server/src/interfaces/library.interface.ts @@ -12,5 +12,4 @@ export interface ILibraryRepository { softDelete(id: string): Promise; update(library: Partial): Promise; getStatistics(id: string): Promise; - getAssetIds(id: string, withDeleted?: boolean): Promise; } diff --git a/server/src/interfaces/storage.interface.ts b/server/src/interfaces/storage.interface.ts index f27edaccc9..fec3d66dd5 100644 --- a/server/src/interfaces/storage.interface.ts +++ b/server/src/interfaces/storage.interface.ts @@ -2,7 +2,7 @@ import { WatchOptions } from 'chokidar'; import { Stats } from 'node:fs'; import { FileReadOptions } from 'node:fs/promises'; import { Readable } from 'node:stream'; -import { CrawlOptionsDto } from 'src/dtos/library.dto'; +import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto'; export interface ImmichReadStream { stream: Readable; @@ -45,8 +45,8 @@ export interface IStorageRepository { checkDiskUsage(folder: string): Promise; readdir(folder: string): Promise; stat(filepath: string): Promise; - crawl(crawlOptions: CrawlOptionsDto): Promise; - walk(crawlOptions: CrawlOptionsDto): AsyncGenerator; + crawl(options: CrawlOptionsDto): Promise; + walk(options: WalkOptionsDto): AsyncGenerator; copyFile(source: string, target: string): Promise; rename(source: string, target: string): Promise; watch(paths: string[], options: WatchOptions, events: Partial): () => Promise; diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index bc20bf4bd3..5dd32ce365 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -145,14 +145,3 @@ WHERE AND ("libraries"."deletedAt" IS NULL) GROUP BY "libraries"."id" - --- LibraryRepository.getAssetIds -SELECT - "assets"."id" AS "assets_id" -FROM - "libraries" "library" - INNER JOIN "assets" "assets" ON "assets"."libraryId" = "library"."id" - AND ("assets"."deletedAt" IS NULL) -WHERE - ("library"."id" = $1) - AND ("library"."deletedAt" IS NULL) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index b95db5f3a8..1a2a0474a1 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -383,7 +383,7 @@ export class AssetRepository implements IAssetRepository { @GenerateSql( ...Object.values(WithProperty) - .filter((property) => property !== WithProperty.IS_OFFLINE) + .filter((property) => property !== WithProperty.IS_OFFLINE && property !== WithProperty.IS_ONLINE) .map((property) => ({ name: property, params: [DummyValue.PAGINATION, property], @@ -539,7 +539,14 @@ export class AssetRepository implements IAssetRepository { if (!libraryId) { throw new Error('Library id is required when finding offline assets'); } - where = [{ isOffline: true, libraryId: libraryId }]; + where = [{ isOffline: true, libraryId }]; + break; + } + case WithProperty.IS_ONLINE: { + if (!libraryId) { + throw new Error('Library id is required when finding online assets'); + } + where = [{ isOffline: false, libraryId }]; break; } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 88834afc00..f64e5175e5 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -79,6 +79,7 @@ export const JOBS_TO_QUEUE: Record = { [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, [JobName.LIBRARY_SCAN]: QueueName.LIBRARY, [JobName.LIBRARY_DELETE]: QueueName.LIBRARY, + [JobName.LIBRARY_CHECK_OFFLINE]: QueueName.LIBRARY, [JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY, [JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY, [JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY, diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index 963b0aaf73..36fb4b9217 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -94,30 +94,6 @@ export class LibraryRepository implements ILibraryRepository { }; } - @GenerateSql({ params: [DummyValue.UUID] }) - async getAssetIds(libraryId: string, withDeleted = false): Promise { - const builder = this.repository - .createQueryBuilder('library') - .innerJoinAndSelect('library.assets', 'assets') - .where('library.id = :id', { id: libraryId }) - .select('assets.id'); - - if (withDeleted) { - builder.withDeleted(); - } - - // Return all asset paths for a given library - const rawResults = await builder.getRawMany(); - - const results: string[] = []; - - for (const rawPath of rawResults) { - results.push(rawPath.assets_id); - } - - return results; - } - private async save(library: Partial) { const { id } = await this.repository.save(library); return this.repository.findOneByOrFail({ id }); diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index b310f2e110..c699047ce1 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -5,7 +5,7 @@ import { escapePath, glob, globStream } from 'fast-glob'; import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { CrawlOptionsDto } from 'src/dtos/library.dto'; +import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { DiskUsage, @@ -157,8 +157,8 @@ export class StorageRepository implements IStorageRepository { }); } - async *walk(crawlOptions: CrawlOptionsDto): AsyncGenerator { - const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions; + async *walk(walkOptions: WalkOptionsDto): AsyncGenerator { + const { pathsToCrawl, exclusionPatterns, includeHidden } = walkOptions; if (pathsToCrawl.length === 0) { async function* emptyGenerator() {} return emptyGenerator(); @@ -172,8 +172,17 @@ export class StorageRepository implements IStorageRepository { ignore: exclusionPatterns, }); + let batch: string[] = []; for await (const value of stream) { - yield value as string; + batch.push(value.toString()); + if (batch.length === walkOptions.take) { + yield batch; + batch = []; + } + } + + if (batch.length > 0) { + yield batch; } } diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 8a74ec9189..9e260e98ef 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -8,7 +8,15 @@ import { AssetType } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { IJobRepository, ILibraryFileJob, ILibraryRefreshJob, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { + IJobRepository, + ILibraryFileJob, + ILibraryOfflineJob, + ILibraryRefreshJob, + JobName, + JOBS_LIBRARY_PAGINATION_SIZE, + JobStatus, +} from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -154,17 +162,19 @@ describe(LibraryService.name, () => { }); describe('handleQueueAssetRefresh', () => { - it('should queue new assets', async () => { + it('should queue refresh of a new asset', async () => { const mockLibraryJob: ILibraryRefreshJob = { id: libraryStub.externalLibrary1.id, refreshModifiedFiles: false, refreshAllFiles: false, }; + assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); // eslint-disable-next-line @typescript-eslint/require-await storageMock.walk.mockImplementation(async function* generator() { - yield '/data/user1/photo.jpg'; + yield ['/data/user1/photo.jpg']; }); assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); @@ -183,6 +193,44 @@ describe(LibraryService.name, () => { ]); }); + it('should queue offline check of existing online assets', async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }; + + assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.walk.mockImplementation(async function* generator() {}); + assetMock.getWith.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); + + await sut.handleQueueAssetRefresh(mockLibraryJob); + + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.LIBRARY_CHECK_OFFLINE, + data: { + id: assetStub.external.id, + importPaths: libraryStub.externalLibrary1.importPaths, + exclusionPatterns: [], + }, + }, + ]); + }); + + it("should fail when library can't be found", async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }; + + libraryMock.get.mockResolvedValue(null); + + await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); + }); + it('should force queue new assets', async () => { const mockLibraryJob: ILibraryRefreshJob = { id: libraryStub.externalLibrary1.id, @@ -190,10 +238,11 @@ describe(LibraryService.name, () => { refreshAllFiles: true, }; + assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); // eslint-disable-next-line @typescript-eslint/require-await storageMock.walk.mockImplementation(async function* generator() { - yield '/data/user1/photo.jpg'; + yield ['/data/user1/photo.jpg']; }); assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); @@ -225,6 +274,8 @@ describe(LibraryService.name, () => { storageMock.checkFileExists.mockResolvedValue(true); + assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); + const mockLibraryJob: ILibraryRefreshJob = { id: libraryStub.externalLibraryWithImportPaths1.id, refreshModifiedFiles: false, @@ -239,51 +290,78 @@ describe(LibraryService.name, () => { expect(storageMock.walk).toHaveBeenCalledWith({ pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]], exclusionPatterns: [], + includeHidden: false, + take: JOBS_LIBRARY_PAGINATION_SIZE, }); }); + }); - it('should set missing assets offline', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, + describe('handleOfflineCheck', () => { + it('should skip missing assets', async () => { + const mockAssetJob: ILibraryOfflineJob = { + id: assetStub.external.id, + importPaths: ['/'], }; - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ - items: [assetStub.external], - hasNextPage: false, - }); + assetMock.getById.mockResolvedValue(null); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], { isOffline: true }); - expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: false }); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }); - it('should set crawled assets that were previously offline back online', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, + it('should do nothing with already-offline assets', async () => { + const mockAssetJob: ILibraryOfflineJob = { + id: assetStub.external.id, + importPaths: ['/'], }; - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - // eslint-disable-next-line @typescript-eslint/require-await - storageMock.walk.mockImplementation(async function* generator() { - yield assetStub.externalOffline.originalPath; - }); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ - items: [assetStub.externalOffline], - hasNextPage: false, - }); + assetMock.getById.mockResolvedValue(assetStub.offline); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.externalOffline.id], { isOffline: false }); - expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: true }); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); + }); + + it('should offline assets no longer on disk or matching exclusion pattern', async () => { + const mockAssetJob: ILibraryOfflineJob = { + id: assetStub.external.id, + importPaths: ['/'], + }; + + assetMock.getById.mockResolvedValue(assetStub.external); + + await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + }); + + it('should set assets outside of import paths as offline', async () => { + const mockAssetJob: ILibraryOfflineJob = { + id: assetStub.external.id, + importPaths: ['/data/user2'], + }; + + assetMock.getById.mockResolvedValue(assetStub.external); + storageMock.checkFileExists.mockResolvedValue(true); + + await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + }); + + it('should do nothing with online assets', async () => { + const mockAssetJob: ILibraryOfflineJob = { + id: assetStub.external.id, + importPaths: ['/'], + }; + + assetMock.getById.mockResolvedValue(assetStub.external); + storageMock.checkFileExists.mockResolvedValue(true); + + await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.update).not.toHaveBeenCalled(); }); }); @@ -1115,18 +1193,9 @@ describe(LibraryService.name, () => { }); describe('handleDeleteLibrary', () => { - it('should not delete a nonexistent library', async () => { - libraryMock.get.mockResolvedValue(null); - - libraryMock.getAssetIds.mockResolvedValue([]); - libraryMock.delete.mockImplementation(async () => {}); - - await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.FAILED); - }); - it('should delete an empty library', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.getAssetIds.mockResolvedValue([]); + assetMock.getAll.mockResolvedValue({ items: [], hasNextPage: false }); libraryMock.delete.mockImplementation(async () => {}); await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); @@ -1134,7 +1203,7 @@ describe(LibraryService.name, () => { it('should delete a library with assets', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.getAssetIds.mockResolvedValue([assetStub.image1.id]); + assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); libraryMock.delete.mockImplementation(async () => {}); assetMock.getById.mockResolvedValue(assetStub.image1); @@ -1273,7 +1342,7 @@ describe(LibraryService.name, () => { assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); assetMock.getById.mockResolvedValue(assetStub.image1); - await expect(sut.handleOfflineRemoval({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleRemoveOffline({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.ASSET_DELETION, data: { id: assetStub.image1.id, deleteOnDisk: false } }, diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 4b82c9811d..9e31107027 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { Trie } from 'mnemonist'; import { R_OK } from 'node:constants'; import { Stats } from 'node:fs'; import path, { basename, parse } from 'node:path'; @@ -18,7 +17,6 @@ import { ValidateLibraryResponseDto, mapLibrary, } from 'src/dtos/library.dto'; -import { LibraryEntity } from 'src/entities/library.entity'; import { AssetType } from 'src/enum'; import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -29,8 +27,9 @@ import { IEntityJob, IJobRepository, ILibraryFileJob, + ILibraryOfflineJob, ILibraryRefreshJob, - JOBS_ASSET_PAGINATION_SIZE, + JOBS_LIBRARY_PAGINATION_SIZE, JobName, JobStatus, } from 'src/interfaces/job.interface'; @@ -43,8 +42,6 @@ import { handlePromiseError } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { validateCronExpression } from 'src/validation'; -const LIBRARY_SCAN_BATCH_SIZE = 5000; - @Injectable() export class LibraryService { private configCore: SystemConfigCore; @@ -254,26 +251,17 @@ export class LibraryService { } private async scanAssets(libraryId: string, assetPaths: string[], ownerId: string, force = false) { - this.logger.verbose(`Queuing refresh of ${assetPaths.length} asset(s)`); - - // We perform this in batches to save on memory when performing large refreshes (greater than 1M assets) - const batchSize = 5000; - for (let i = 0; i < assetPaths.length; i += batchSize) { - const batch = assetPaths.slice(i, i + batchSize); - await this.jobRepository.queueAll( - batch.map((assetPath) => ({ - name: JobName.LIBRARY_SCAN_ASSET, - data: { - id: libraryId, - assetPath: assetPath, - ownerId, - force, - }, - })), - ); - } - - this.logger.debug('Asset refresh queue completed'); + await this.jobRepository.queueAll( + assetPaths.map((assetPath) => ({ + name: JobName.LIBRARY_SCAN_ASSET, + data: { + id: libraryId, + assetPath, + ownerId, + force, + }, + })), + ); } private async validateImportPath(importPath: string): Promise { @@ -348,27 +336,32 @@ export class LibraryService { } async handleDeleteLibrary(job: IEntityJob): Promise { - const library = await this.repository.get(job.id, true); - if (!library) { - return JobStatus.FAILED; - } + const libraryId = job.id; - // TODO use pagination - const assetIds = await this.repository.getAssetIds(job.id, true); - this.logger.debug(`Will delete ${assetIds.length} asset(s) in library ${job.id}`); - await this.jobRepository.queueAll( - assetIds.map((assetId) => ({ - name: JobName.ASSET_DELETION, - data: { - id: assetId, - deleteOnDisk: false, - }, - })), + const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => + this.assetRepository.getAll(pagination, { libraryId: libraryId, withDeleted: true }), ); - if (assetIds.length === 0) { - this.logger.log(`Deleting library ${job.id}`); - await this.repository.delete(job.id); + let assetsFound = false; + + this.logger.debug(`Will delete all assets in library ${libraryId}`); + for await (const assets of assetPagination) { + assetsFound = true; + this.logger.debug(`Queueing deletion of ${assets.length} asset(s) in library ${libraryId}`); + await this.jobRepository.queueAll( + assets.map((asset) => ({ + name: JobName.ASSET_DELETION, + data: { + id: asset.id, + deleteOnDisk: false, + }, + })), + ); + } + + if (!assetsFound) { + this.logger.log(`Deleting library ${libraryId}`); + await this.repository.delete(libraryId); } return JobStatus.SUCCESS; } @@ -453,6 +446,7 @@ export class LibraryService { sidecarPath = `${assetPath}.xmp`; } + // TODO: device asset id is deprecated, remove it const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); let assetId; @@ -494,7 +488,7 @@ export class LibraryService { return JobStatus.SKIPPED; } - this.logger.debug(`Queuing metadata extraction for: ${assetPath}`); + this.logger.debug(`Queueing metadata extraction for: ${assetPath}`); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: assetId, source: 'upload' } }); @@ -519,17 +513,15 @@ export class LibraryService { } async queueRemoveOffline(id: string) { - this.logger.verbose(`Removing offline files from library: ${id}`); + this.logger.verbose(`Queueing offline file removal from library ${id}`); await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } }); } async handleQueueAllScan(job: IBaseJob): Promise { this.logger.debug(`Refreshing all external libraries: force=${job.force}`); - // Queue cleanup await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); - // Queue all library refresh const libraries = await this.repository.getAll(true); await this.jobRepository.queueAll( libraries.map((library) => ({ @@ -544,22 +536,71 @@ export class LibraryService { return JobStatus.SUCCESS; } - async handleOfflineRemoval(job: IEntityJob): Promise { - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => + async handleOfflineCheck(job: ILibraryOfflineJob): Promise { + const asset = await this.assetRepository.getById(job.id); + + if (!asset) { + // Asset is no longer in the database, skip + return JobStatus.SKIPPED; + } + + if (asset.isOffline) { + this.logger.verbose(`Asset is already offline: ${asset.originalPath}`); + return JobStatus.SUCCESS; + } + + const isInPath = job.importPaths.find((path) => asset.originalPath.startsWith(path)); + if (!isInPath) { + this.logger.debug(`Asset is no longer in an import path, marking offline: ${asset.originalPath}`); + await this.assetRepository.update({ id: asset.id, isOffline: true }); + return JobStatus.SUCCESS; + } + + const fileExists = await this.storageRepository.checkFileExists(asset.originalPath, R_OK); + if (!fileExists) { + this.logger.debug( + `Asset is no longer found on disk or is covered by exclusion pattern, marking offline: ${asset.originalPath}`, + ); + await this.assetRepository.update({ id: asset.id, isOffline: true }); + return JobStatus.SUCCESS; + } + + this.logger.verbose( + `Asset is found on disk, not covered by an exclusion pattern, and is in an import path, keeping online: ${asset.originalPath}`, + ); + + return JobStatus.SUCCESS; + } + + async handleRemoveOffline(job: IEntityJob): Promise { + this.logger.debug(`Removing offline assets for library ${job.id}`); + + const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id), ); + let offlineAssets = 0; for await (const assets of assetPagination) { - this.logger.debug(`Removing ${assets.length} offline assets`); - await this.jobRepository.queueAll( - assets.map((asset) => ({ - name: JobName.ASSET_DELETION, - data: { - id: asset.id, - deleteOnDisk: false, - }, - })), - ); + offlineAssets += assets.length; + if (assets.length > 0) { + this.logger.debug(`Discovered ${offlineAssets} offline assets in library ${job.id}`); + await this.jobRepository.queueAll( + assets.map((asset) => ({ + name: JobName.ASSET_DELETION, + data: { + id: asset.id, + deleteOnDisk: false, + }, + })), + ); + this.logger.verbose(`Queued deletion of ${assets.length} offline assets in library ${job.id}`); + } + } + + if (offlineAssets) { + this.logger.debug(`Finished queueing deletion of ${offlineAssets} offline assets for library ${job.id}`); + } else { + this.logger.debug(`Found no offline assets to delete from library ${job.id}`); } return JobStatus.SUCCESS; @@ -568,73 +609,67 @@ export class LibraryService { async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise { const library = await this.repository.get(job.id); if (!library) { - this.logger.warn('Library not found'); - return JobStatus.FAILED; + return JobStatus.SKIPPED; } - this.logger.log(`Refreshing library: ${job.id}`); + this.logger.log(`Refreshing library ${library.id}`); - const crawledAssetPaths = await this.getPathTrie(library); - this.logger.debug(`Found ${crawledAssetPaths.size} asset(s) when crawling import paths ${library.importPaths}`); + const validImportPaths: string[] = []; - const assetIdsToMarkOffline = []; - const assetIdsToMarkOnline = []; - const pagination = usePagination(LIBRARY_SCAN_BATCH_SIZE, (pagination) => - this.assetRepository.getExternalLibraryAssetPaths(pagination, library.id), + for (const importPath of library.importPaths) { + const validation = await this.validateImportPath(importPath); + if (validation.isValid) { + validImportPaths.push(path.normalize(importPath)); + } else { + this.logger.warn(`Skipping invalid import path: ${importPath}. Reason: ${validation.message}`); + } + } + + if (validImportPaths.length === 0) { + this.logger.warn(`No valid import paths found for library ${library.id}`); + } + + const assetsOnDisk = this.storageRepository.walk({ + pathsToCrawl: validImportPaths, + includeHidden: false, + exclusionPatterns: library.exclusionPatterns, + take: JOBS_LIBRARY_PAGINATION_SIZE, + }); + + let crawledAssets = 0; + + for await (const assetBatch of assetsOnDisk) { + crawledAssets += assetBatch.length; + this.logger.debug(`Discovered ${crawledAssets} asset(s) on disk for library ${library.id}...`); + await this.scanAssets(job.id, assetBatch, library.ownerId, job.refreshAllFiles ?? false); + this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`); + } + + if (crawledAssets) { + this.logger.debug(`Finished queueing scan of ${crawledAssets} assets on disk for library ${library.id}`); + } else { + this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); + } + + const onlineAssets = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => + this.assetRepository.getWith(pagination, WithProperty.IS_ONLINE, job.id), ); - this.logger.verbose(`Crawled asset paths paginated`); - - const shouldScanAll = job.refreshAllFiles || job.refreshModifiedFiles; - for await (const page of pagination) { - for (const asset of page) { - const isOffline = !crawledAssetPaths.has(asset.originalPath); - if (isOffline && !asset.isOffline) { - assetIdsToMarkOffline.push(asset.id); - this.logger.verbose(`Added to mark-offline list: ${asset.originalPath}`); - } - - if (!isOffline && asset.isOffline) { - assetIdsToMarkOnline.push(asset.id); - this.logger.verbose(`Added to mark-online list: ${asset.originalPath}`); - } - - if (!shouldScanAll) { - crawledAssetPaths.delete(asset.originalPath); - } - } + let onlineAssetCount = 0; + for await (const assets of onlineAssets) { + onlineAssetCount += assets.length; + this.logger.debug(`Discovered ${onlineAssetCount} asset(s) in library ${library.id}...`); + await this.jobRepository.queueAll( + assets.map((asset) => ({ + name: JobName.LIBRARY_CHECK_OFFLINE, + data: { id: asset.id, importPaths: validImportPaths, exclusionPatterns: library.exclusionPatterns }, + })), + ); + this.logger.debug(`Queued online check of ${assets.length} asset(s) in library ${library.id}...`); } - this.logger.verbose(`Crawled assets have been checked for online/offline status`); - - if (assetIdsToMarkOffline.length > 0) { - this.logger.debug(`Found ${assetIdsToMarkOffline.length} offline asset(s) previously marked as online`); - await this.assetRepository.updateAll(assetIdsToMarkOffline, { isOffline: true }); - } - - if (assetIdsToMarkOnline.length > 0) { - this.logger.debug(`Found ${assetIdsToMarkOnline.length} online asset(s) previously marked as offline`); - await this.assetRepository.updateAll(assetIdsToMarkOnline, { isOffline: false }); - } - - if (crawledAssetPaths.size > 0) { - if (!shouldScanAll) { - this.logger.debug(`Will import ${crawledAssetPaths.size} new asset(s)`); - } - - let batch = []; - for (const assetPath of crawledAssetPaths) { - batch.push(assetPath); - - if (batch.length >= LIBRARY_SCAN_BATCH_SIZE) { - await this.scanAssets(job.id, batch, library.ownerId, job.refreshAllFiles ?? false); - batch = []; - } - } - - if (batch.length > 0) { - await this.scanAssets(job.id, batch, library.ownerId, job.refreshAllFiles ?? false); - } + if (onlineAssetCount) { + this.logger.log(`Finished queueing online check of ${onlineAssetCount} assets for library ${library.id}`); } await this.repository.update({ id: job.id, refreshedAt: new Date() }); @@ -642,34 +677,6 @@ export class LibraryService { return JobStatus.SUCCESS; } - private async getPathTrie(library: LibraryEntity): Promise> { - const pathValidation = await Promise.all( - library.importPaths.map(async (importPath) => await this.validateImportPath(importPath)), - ); - - const validImportPaths = pathValidation - .map((validation) => { - if (!validation.isValid) { - this.logger.error(`Skipping invalid import path: ${validation.importPath}. Reason: ${validation.message}`); - } - return validation; - }) - .filter((validation) => validation.isValid) - .map((validation) => validation.importPath); - - const generator = this.storageRepository.walk({ - pathsToCrawl: validImportPaths, - exclusionPatterns: library.exclusionPatterns, - }); - - const trie = new Trie(); - for await (const filePath of generator) { - trie.add(filePath); - } - - return trie; - } - private async findOrFail(id: string) { const library = await this.repository.get(id); if (!library) { diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 5b28e6a00a..025400cc9b 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -85,7 +85,8 @@ export class MicroservicesService { [JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data), [JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data), [JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data), - [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data), + [JobName.LIBRARY_CHECK_OFFLINE]: (data) => this.libraryService.handleOfflineCheck(data), + [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleRemoveOffline(data), [JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data), [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), [JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data), diff --git a/server/test/repositories/library.repository.mock.ts b/server/test/repositories/library.repository.mock.ts index e5b8e5c763..83e97c7ffa 100644 --- a/server/test/repositories/library.repository.mock.ts +++ b/server/test/repositories/library.repository.mock.ts @@ -9,7 +9,6 @@ export const newLibraryRepositoryMock = (): Mocked => { softDelete: vitest.fn(), update: vitest.fn(), getStatistics: vitest.fn(), - getAssetIds: vitest.fn(), getAllDeleted: vitest.fn(), getAll: vitest.fn(), }; diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 64b104624b..74db5628ba 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -329,17 +329,21 @@ {:else}{owner[index].name}{/if} - - {#if totalCount[index] == undefined} - + + {#if totalCount[index] == undefined} - - {:else} - + {:else} {totalCount[index].toLocaleString($locale)} - - {diskUsage[index]} {diskUsageUnit[index]} - {/if} + {/if} + + + {#if diskUsage[index] == undefined} + + {:else} + {diskUsage[index]} + {diskUsageUnit[index]} + {/if} + Date: Wed, 28 Aug 2024 21:59:09 +0200 Subject: [PATCH 195/723] feat(server): sort images in duplicate groups by date (#12094) * feat(server): sort images in duplicate groups by date * Update server/src/dtos/duplicate.dto.ts Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --------- Co-authored-by: Alex Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- server/src/dtos/duplicate.dto.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/dtos/duplicate.dto.ts b/server/src/dtos/duplicate.dto.ts index 73863fa95d..09976b3213 100644 --- a/server/src/dtos/duplicate.dto.ts +++ b/server/src/dtos/duplicate.dto.ts @@ -1,5 +1,5 @@ import { IsNotEmpty } from 'class-validator'; -import { groupBy } from 'lodash'; +import { groupBy, sortBy } from 'lodash'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { ValidateUUID } from 'src/validation'; @@ -19,7 +19,8 @@ export function mapDuplicateResponse(assets: AssetResponseDto[]): DuplicateRespo const grouped = groupBy(assets, (a) => a.duplicateId); - for (const [duplicateId, assets] of Object.entries(grouped)) { + for (const [duplicateId, unsortedAssets] of Object.entries(grouped)) { + const assets = sortBy(unsortedAssets, (asset) => asset.localDateTime); result.push({ duplicateId, assets }); } From f0c86846e09bbb15fb2dd2128cfd0d01cdda11b8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 Aug 2024 17:59:57 -0400 Subject: [PATCH 196/723] fix(deps): update machine-learning (major) (#11928) --- machine-learning/poetry.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 31949aee84..d52e22dafb 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -1111,13 +1111,13 @@ test = ["objgraph", "psutil"] [[package]] name = "gunicorn" -version = "22.0.0" +version = "23.0.0" description = "WSGI HTTP Server for UNIX" optional = false python-versions = ">=3.7" files = [ - {file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"}, - {file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"}, + {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, + {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, ] [package.dependencies] @@ -2512,13 +2512,13 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "4.1.0" +version = "5.0.0" description = "Pytest plugin for measuring coverage." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, - {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, ] [package.dependencies] @@ -2526,7 +2526,7 @@ coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-mock" From c6c7c54fa5d75f9345c81271459cfb9cc627e6bf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 Aug 2024 18:00:47 -0400 Subject: [PATCH 197/723] chore(deps): update machine-learning (#12062) --- machine-learning/Dockerfile | 4 +- machine-learning/export/Dockerfile | 2 +- machine-learning/poetry.lock | 96 +++++++++++++++--------------- 3 files changed, 51 insertions(+), 51 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index c06b4900e6..8fc72b308f 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:add76c758e402c3acf53b8251da50d8ae67989a81ca96ff4331e296773df853d AS builder-cpu +FROM python:3.11-bookworm@sha256:f7543d9969bdc112dd9819ca642e14433fdacfe857f170f6b803392fc7e451ad AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:1c0c54195c7c7b46e61a2f3b906e9b55a8165f20388a0eeb4af4c6f8579988ac AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:ad5dadd957a398226996bc4846e522c39f2a77340b531b28aaab85b2d361210b AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index 94082ae957..d458d92d15 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:e37ec9f3f7dea01ef9958d3d924d46077911f7e29c4faed40cd6b37a9ac239fc AS builder +FROM mambaorg/micromamba:bookworm-slim@sha256:475730daef12ff9c0733e70092aeeefdf4c373a584c952dac3f7bdb739601990 AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index d52e22dafb..7385d1269d 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -2373,54 +2373,54 @@ files = [ [[package]] name = "pydantic" -version = "1.10.17" +version = "1.10.18" description = "Data validation and settings management using python type hints" optional = false python-versions = ">=3.7" files = [ - {file = "pydantic-1.10.17-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fa51175313cc30097660b10eec8ca55ed08bfa07acbfe02f7a42f6c242e9a4b"}, - {file = "pydantic-1.10.17-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7e8988bb16988890c985bd2093df9dd731bfb9d5e0860db054c23034fab8f7a"}, - {file = "pydantic-1.10.17-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:371dcf1831f87c9e217e2b6a0c66842879a14873114ebb9d0861ab22e3b5bb1e"}, - {file = "pydantic-1.10.17-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4866a1579c0c3ca2c40575398a24d805d4db6cb353ee74df75ddeee3c657f9a7"}, - {file = "pydantic-1.10.17-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:543da3c6914795b37785703ffc74ba4d660418620cc273490d42c53949eeeca6"}, - {file = "pydantic-1.10.17-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7623b59876f49e61c2e283551cc3647616d2fbdc0b4d36d3d638aae8547ea681"}, - {file = "pydantic-1.10.17-cp310-cp310-win_amd64.whl", hash = "sha256:409b2b36d7d7d19cd8310b97a4ce6b1755ef8bd45b9a2ec5ec2b124db0a0d8f3"}, - {file = "pydantic-1.10.17-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fa43f362b46741df8f201bf3e7dff3569fa92069bcc7b4a740dea3602e27ab7a"}, - {file = "pydantic-1.10.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2a72d2a5ff86a3075ed81ca031eac86923d44bc5d42e719d585a8eb547bf0c9b"}, - {file = "pydantic-1.10.17-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4ad32aed3bf5eea5ca5decc3d1bbc3d0ec5d4fbcd72a03cdad849458decbc63"}, - {file = "pydantic-1.10.17-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb4e741782e236ee7dc1fb11ad94dc56aabaf02d21df0e79e0c21fe07c95741"}, - {file = "pydantic-1.10.17-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d2f89a719411cb234105735a520b7c077158a81e0fe1cb05a79c01fc5eb59d3c"}, - {file = "pydantic-1.10.17-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db3b48d9283d80a314f7a682f7acae8422386de659fffaba454b77a083c3937d"}, - {file = "pydantic-1.10.17-cp311-cp311-win_amd64.whl", hash = "sha256:9c803a5113cfab7bbb912f75faa4fc1e4acff43e452c82560349fff64f852e1b"}, - {file = "pydantic-1.10.17-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:820ae12a390c9cbb26bb44913c87fa2ff431a029a785642c1ff11fed0a095fcb"}, - {file = "pydantic-1.10.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1e51d1af306641b7d1574d6d3307eaa10a4991542ca324f0feb134fee259815"}, - {file = "pydantic-1.10.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e53fb834aae96e7b0dadd6e92c66e7dd9cdf08965340ed04c16813102a47fab"}, - {file = "pydantic-1.10.17-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e2495309b1266e81d259a570dd199916ff34f7f51f1b549a0d37a6d9b17b4dc"}, - {file = "pydantic-1.10.17-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:098ad8de840c92ea586bf8efd9e2e90c6339d33ab5c1cfbb85be66e4ecf8213f"}, - {file = "pydantic-1.10.17-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:525bbef620dac93c430d5d6bdbc91bdb5521698d434adf4434a7ef6ffd5c4b7f"}, - {file = "pydantic-1.10.17-cp312-cp312-win_amd64.whl", hash = "sha256:6654028d1144df451e1da69a670083c27117d493f16cf83da81e1e50edce72ad"}, - {file = "pydantic-1.10.17-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c87cedb4680d1614f1d59d13fea353faf3afd41ba5c906a266f3f2e8c245d655"}, - {file = "pydantic-1.10.17-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11289fa895bcbc8f18704efa1d8020bb9a86314da435348f59745473eb042e6b"}, - {file = "pydantic-1.10.17-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94833612d6fd18b57c359a127cbfd932d9150c1b72fea7c86ab58c2a77edd7c7"}, - {file = "pydantic-1.10.17-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d4ecb515fa7cb0e46e163ecd9d52f9147ba57bc3633dca0e586cdb7a232db9e3"}, - {file = "pydantic-1.10.17-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7017971ffa7fd7808146880aa41b266e06c1e6e12261768a28b8b41ba55c8076"}, - {file = "pydantic-1.10.17-cp37-cp37m-win_amd64.whl", hash = "sha256:e840e6b2026920fc3f250ea8ebfdedf6ea7a25b77bf04c6576178e681942ae0f"}, - {file = "pydantic-1.10.17-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bfbb18b616abc4df70591b8c1ff1b3eabd234ddcddb86b7cac82657ab9017e33"}, - {file = "pydantic-1.10.17-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ebb249096d873593e014535ab07145498957091aa6ae92759a32d40cb9998e2e"}, - {file = "pydantic-1.10.17-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8c209af63ccd7b22fba94b9024e8b7fd07feffee0001efae50dd99316b27768"}, - {file = "pydantic-1.10.17-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b40c9e13a0b61583e5599e7950490c700297b4a375b55b2b592774332798b7"}, - {file = "pydantic-1.10.17-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c31d281c7485223caf6474fc2b7cf21456289dbaa31401844069b77160cab9c7"}, - {file = "pydantic-1.10.17-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ae5184e99a060a5c80010a2d53c99aee76a3b0ad683d493e5f0620b5d86eeb75"}, - {file = "pydantic-1.10.17-cp38-cp38-win_amd64.whl", hash = "sha256:ad1e33dc6b9787a6f0f3fd132859aa75626528b49cc1f9e429cdacb2608ad5f0"}, - {file = "pydantic-1.10.17-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e17c0ee7192e54a10943f245dc79e36d9fe282418ea05b886e1c666063a7b54"}, - {file = "pydantic-1.10.17-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cafb9c938f61d1b182dfc7d44a7021326547b7b9cf695db5b68ec7b590214773"}, - {file = "pydantic-1.10.17-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95ef534e3c22e5abbdbdd6f66b6ea9dac3ca3e34c5c632894f8625d13d084cbe"}, - {file = "pydantic-1.10.17-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d96b8799ae3d782df7ec9615cb59fc32c32e1ed6afa1b231b0595f6516e8ab"}, - {file = "pydantic-1.10.17-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ab2f976336808fd5d539fdc26eb51f9aafc1f4b638e212ef6b6f05e753c8011d"}, - {file = "pydantic-1.10.17-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8ad363330557beac73159acfbeed220d5f1bfcd6b930302a987a375e02f74fd"}, - {file = "pydantic-1.10.17-cp39-cp39-win_amd64.whl", hash = "sha256:48db882e48575ce4b39659558b2f9f37c25b8d348e37a2b4e32971dd5a7d6227"}, - {file = "pydantic-1.10.17-py3-none-any.whl", hash = "sha256:e41b5b973e5c64f674b3b4720286ded184dcc26a691dd55f34391c62c6934688"}, - {file = "pydantic-1.10.17.tar.gz", hash = "sha256:f434160fb14b353caf634149baaf847206406471ba70e64657c1e8330277a991"}, + {file = "pydantic-1.10.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e405ffcc1254d76bb0e760db101ee8916b620893e6edfbfee563b3c6f7a67c02"}, + {file = "pydantic-1.10.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e306e280ebebc65040034bff1a0a81fd86b2f4f05daac0131f29541cafd80b80"}, + {file = "pydantic-1.10.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11d9d9b87b50338b1b7de4ebf34fd29fdb0d219dc07ade29effc74d3d2609c62"}, + {file = "pydantic-1.10.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b661ce52c7b5e5f600c0c3c5839e71918346af2ef20062705ae76b5c16914cab"}, + {file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c20f682defc9ef81cd7eaa485879ab29a86a0ba58acf669a78ed868e72bb89e0"}, + {file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c5ae6b7c8483b1e0bf59e5f1843e4fd8fd405e11df7de217ee65b98eb5462861"}, + {file = "pydantic-1.10.18-cp310-cp310-win_amd64.whl", hash = "sha256:74fe19dda960b193b0eb82c1f4d2c8e5e26918d9cda858cbf3f41dd28549cb70"}, + {file = "pydantic-1.10.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72fa46abace0a7743cc697dbb830a41ee84c9db8456e8d77a46d79b537efd7ec"}, + {file = "pydantic-1.10.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef0fe7ad7cbdb5f372463d42e6ed4ca9c443a52ce544472d8842a0576d830da5"}, + {file = "pydantic-1.10.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a00e63104346145389b8e8f500bc6a241e729feaf0559b88b8aa513dd2065481"}, + {file = "pydantic-1.10.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae6fa2008e1443c46b7b3a5eb03800121868d5ab6bc7cda20b5df3e133cde8b3"}, + {file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9f463abafdc92635da4b38807f5b9972276be7c8c5121989768549fceb8d2588"}, + {file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3445426da503c7e40baccefb2b2989a0c5ce6b163679dd75f55493b460f05a8f"}, + {file = "pydantic-1.10.18-cp311-cp311-win_amd64.whl", hash = "sha256:467a14ee2183bc9c902579bb2f04c3d3dac00eff52e252850509a562255b2a33"}, + {file = "pydantic-1.10.18-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:efbc8a7f9cb5fe26122acba1852d8dcd1e125e723727c59dcd244da7bdaa54f2"}, + {file = "pydantic-1.10.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24a4a159d0f7a8e26bf6463b0d3d60871d6a52eac5bb6a07a7df85c806f4c048"}, + {file = "pydantic-1.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b74be007703547dc52e3c37344d130a7bfacca7df112a9e5ceeb840a9ce195c7"}, + {file = "pydantic-1.10.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcb20d4cb355195c75000a49bb4a31d75e4295200df620f454bbc6bdf60ca890"}, + {file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:46f379b8cb8a3585e3f61bf9ae7d606c70d133943f339d38b76e041ec234953f"}, + {file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cbfbca662ed3729204090c4d09ee4beeecc1a7ecba5a159a94b5a4eb24e3759a"}, + {file = "pydantic-1.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:c6d0a9f9eccaf7f438671a64acf654ef0d045466e63f9f68a579e2383b63f357"}, + {file = "pydantic-1.10.18-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3d5492dbf953d7d849751917e3b2433fb26010d977aa7a0765c37425a4026ff1"}, + {file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe734914977eed33033b70bfc097e1baaffb589517863955430bf2e0846ac30f"}, + {file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15fdbe568beaca9aacfccd5ceadfb5f1a235087a127e8af5e48df9d8a45ae85c"}, + {file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c3e742f62198c9eb9201781fbebe64533a3bbf6a76a91b8d438d62b813079dbc"}, + {file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:19a3bd00b9dafc2cd7250d94d5b578edf7a0bd7daf102617153ff9a8fa37871c"}, + {file = "pydantic-1.10.18-cp37-cp37m-win_amd64.whl", hash = "sha256:2ce3fcf75b2bae99aa31bd4968de0474ebe8c8258a0110903478bd83dfee4e3b"}, + {file = "pydantic-1.10.18-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:335a32d72c51a313b33fa3a9b0fe283503272ef6467910338e123f90925f0f03"}, + {file = "pydantic-1.10.18-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:34a3613c7edb8c6fa578e58e9abe3c0f5e7430e0fc34a65a415a1683b9c32d9a"}, + {file = "pydantic-1.10.18-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9ee4e6ca1d9616797fa2e9c0bfb8815912c7d67aca96f77428e316741082a1b"}, + {file = "pydantic-1.10.18-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23e8ec1ce4e57b4f441fc91e3c12adba023fedd06868445a5b5f1d48f0ab3682"}, + {file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:44ae8a3e35a54d2e8fa88ed65e1b08967a9ef8c320819a969bfa09ce5528fafe"}, + {file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5389eb3b48a72da28c6e061a247ab224381435256eb541e175798483368fdd3"}, + {file = "pydantic-1.10.18-cp38-cp38-win_amd64.whl", hash = "sha256:069b9c9fc645474d5ea3653788b544a9e0ccd3dca3ad8c900c4c6eac844b4620"}, + {file = "pydantic-1.10.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:80b982d42515632eb51f60fa1d217dfe0729f008e81a82d1544cc392e0a50ddf"}, + {file = "pydantic-1.10.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aad8771ec8dbf9139b01b56f66386537c6fe4e76c8f7a47c10261b69ad25c2c9"}, + {file = "pydantic-1.10.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941a2eb0a1509bd7f31e355912eb33b698eb0051730b2eaf9e70e2e1589cae1d"}, + {file = "pydantic-1.10.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65f7361a09b07915a98efd17fdec23103307a54db2000bb92095457ca758d485"}, + {file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6951f3f47cb5ca4da536ab161ac0163cab31417d20c54c6de5ddcab8bc813c3f"}, + {file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7a4c5eec138a9b52c67f664c7d51d4c7234c5ad65dd8aacd919fb47445a62c86"}, + {file = "pydantic-1.10.18-cp39-cp39-win_amd64.whl", hash = "sha256:49e26c51ca854286bffc22b69787a8d4063a62bf7d83dc21d44d2ff426108518"}, + {file = "pydantic-1.10.18-py3-none-any.whl", hash = "sha256:06a189b81ffc52746ec9c8c007f16e5167c8b0a696e1a726369327e3db7b2a82"}, + {file = "pydantic-1.10.18.tar.gz", hash = "sha256:baebdff1907d1d96a139c25136a9bb7d17e118f133a76a2ef3b845e831e3403a"}, ] [package.dependencies] @@ -2494,17 +2494,17 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.23.8" +version = "0.24.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" files = [ - {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, - {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, + {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, + {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, ] [package.dependencies] -pytest = ">=7.0.0,<9" +pytest = ">=8.2,<9" [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] From bab5ad7ebd34cb792054c090068e571e8c92ef7e Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Thu, 29 Aug 2024 01:51:25 +0200 Subject: [PATCH 198/723] fix(server): ensure new exclusion patterns work (#12102) * add test for bug * find excluded paths when checking offline * fix filename * fix unit tests * bump picomatch * fix e2e paths * improve e2e * add unit tests * cleanup e2e * set correct asset count * fix e2e test * fix lint --- e2e/src/api/specs/library.e2e-spec.ts | 67 ++++++++++++++++----- server/package-lock.json | 3 +- server/package.json | 2 +- server/src/interfaces/job.interface.ts | 1 + server/src/services/library.service.spec.ts | 21 ++++++- server/src/services/library.service.ts | 11 +++- 6 files changed, 85 insertions(+), 20 deletions(-) diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 013e1364ca..ec42cbe4fa 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -353,7 +353,7 @@ describe('/libraries', () => { expect(assets.count).toBe(2); - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); await scan(admin.accessToken, library.id); await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 3 }); @@ -361,11 +361,11 @@ describe('/libraries', () => { const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); expect(newAssets.count).toBe(3); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); }); it('should offline a file missing from disk', async () => { - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], @@ -374,26 +374,28 @@ describe('/libraries', () => { await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(3); + + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(newAssets.count).toBe(3); - expect(assets.items).toEqual( + expect(newAssets.items).toEqual( expect.arrayContaining([ expect.objectContaining({ isOffline: true, - originalFileName: 'assetB.png', + originalFileName: 'assetC.png', }), ]), ); }); it('should offline a file outside of import paths', async () => { - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); - utils.createImageFile(`${testAssetDir}/temp/directoryB/assetC.png`); const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], @@ -416,16 +418,49 @@ describe('/libraries', () => { expect.arrayContaining([ expect.objectContaining({ isOffline: false, - originalFileName: 'assetB.png', + originalFileName: 'assetA.png', }), expect.objectContaining({ isOffline: true, - originalFileName: 'assetC.png', + originalFileName: 'assetB.png', }), ]), ); + }); - utils.removeImageFile(`${testAssetDir}/temp/directoryB/assetC.png`); + it('should offline a file covered by an exclusion pattern', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp`], + }); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + await request(app) + .put(`/libraries/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ exclusionPatterns: ['**/directoryB/**'] }); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(assets.count).toBe(2); + + expect(assets.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + isOffline: false, + originalFileName: 'assetA.png', + }), + expect.objectContaining({ + isOffline: true, + originalFileName: 'assetB.png', + }), + ]), + ); }); it('should not try to delete offline files', async () => { @@ -471,6 +506,8 @@ describe('/libraries', () => { await utils.waitForWebsocketEvent({ event: 'assetDelete', total: 1 }); expect(existsSync(`${testAssetDir}/temp/offline1/assetA.png`)).toBe(true); + + utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`); }); it('should scan new files', async () => { @@ -482,14 +519,14 @@ describe('/libraries', () => { await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + utils.createImageFile(`${testAssetDir}/temp/directoryC/assetC.png`); await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(3); expect(assets.items).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -497,6 +534,8 @@ describe('/libraries', () => { }), ]), ); + + utils.removeImageFile(`${testAssetDir}/temp/directoryC/assetC.png`); }); describe('with refreshModifiedFiles=true', () => { diff --git a/server/package-lock.json b/server/package-lock.json index 1ec4fe0fb0..e90256e29b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -51,7 +51,7 @@ "nodemailer": "^6.9.13", "openid-client": "^5.4.3", "pg": "^8.11.3", - "picomatch": "^4.0.0", + "picomatch": "^4.0.2", "react": "^18.3.1", "react-email": "^3.0.0", "reflect-metadata": "^0.2.0", @@ -11403,6 +11403,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "license": "MIT", "engines": { "node": ">=12" }, diff --git a/server/package.json b/server/package.json index 9b42922278..42552f20b7 100644 --- a/server/package.json +++ b/server/package.json @@ -77,7 +77,7 @@ "nodemailer": "^6.9.13", "openid-client": "^5.4.3", "pg": "^8.11.3", - "picomatch": "^4.0.0", + "picomatch": "^4.0.2", "react": "^18.3.1", "react-email": "^3.0.0", "reflect-metadata": "^0.2.0", diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index fab959936f..b2ac5ec6f1 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -133,6 +133,7 @@ export interface ILibraryFileJob extends IEntityJob { export interface ILibraryOfflineJob extends IEntityJob { importPaths: string[]; + exclusionPatterns: string[]; } export interface ILibraryRefreshJob extends IEntityJob { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 9e260e98ef..2d4e1d5776 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -301,6 +301,7 @@ describe(LibraryService.name, () => { const mockAssetJob: ILibraryOfflineJob = { id: assetStub.external.id, importPaths: ['/'], + exclusionPatterns: [], }; assetMock.getById.mockResolvedValue(null); @@ -314,6 +315,7 @@ describe(LibraryService.name, () => { const mockAssetJob: ILibraryOfflineJob = { id: assetStub.external.id, importPaths: ['/'], + exclusionPatterns: [], }; assetMock.getById.mockResolvedValue(assetStub.offline); @@ -323,10 +325,25 @@ describe(LibraryService.name, () => { expect(assetMock.update).not.toHaveBeenCalled(); }); - it('should offline assets no longer on disk or matching exclusion pattern', async () => { + it('should offline assets no longer on disk', async () => { const mockAssetJob: ILibraryOfflineJob = { id: assetStub.external.id, importPaths: ['/'], + exclusionPatterns: [], + }; + + assetMock.getById.mockResolvedValue(assetStub.external); + + await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + }); + + it('should offline assets matching an exclusion pattern', async () => { + const mockAssetJob: ILibraryOfflineJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: ['**/user1/**'], }; assetMock.getById.mockResolvedValue(assetStub.external); @@ -340,6 +357,7 @@ describe(LibraryService.name, () => { const mockAssetJob: ILibraryOfflineJob = { id: assetStub.external.id, importPaths: ['/data/user2'], + exclusionPatterns: [], }; assetMock.getById.mockResolvedValue(assetStub.external); @@ -354,6 +372,7 @@ describe(LibraryService.name, () => { const mockAssetJob: ILibraryOfflineJob = { id: assetStub.external.id, importPaths: ['/'], + exclusionPatterns: [], }; assetMock.getById.mockResolvedValue(assetStub.external); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 9e31107027..c7f82eddea 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -556,11 +556,16 @@ export class LibraryService { return JobStatus.SUCCESS; } + const isExcluded = job.exclusionPatterns.some((pattern) => picomatch.isMatch(asset.originalPath, pattern)); + if (isExcluded) { + this.logger.debug(`Asset is covered by an exclusion pattern, marking offline: ${asset.originalPath}`); + await this.assetRepository.update({ id: asset.id, isOffline: true }); + return JobStatus.SUCCESS; + } + const fileExists = await this.storageRepository.checkFileExists(asset.originalPath, R_OK); if (!fileExists) { - this.logger.debug( - `Asset is no longer found on disk or is covered by exclusion pattern, marking offline: ${asset.originalPath}`, - ); + this.logger.debug(`Asset is no longer found on disk, marking offline: ${asset.originalPath}`); await this.assetRepository.update({ id: asset.id, isOffline: true }); return JobStatus.SUCCESS; } From 9f5a3f1e84b6cb6f0144eec1b0a3b248784a6da1 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 29 Aug 2024 14:41:39 +0200 Subject: [PATCH 199/723] chore(web): enforce valid translation keys using typescript (#12106) --- web/src/app.d.ts | 28 +++++++++++++++++ .../i18n/__test__/format-message.spec.ts | 24 +++++++------- .../i18n/__test__/format-tag-b.svelte | 3 +- .../i18n/format-bold-message.svelte | 3 +- .../lib/components/i18n/format-message.svelte | 4 +-- web/src/lib/i18n.spec.ts | 31 ------------------- web/src/routes/+page.ts | 4 +-- 7 files changed, 48 insertions(+), 49 deletions(-) diff --git a/web/src/app.d.ts b/web/src/app.d.ts index 4fcb901892..b13a0c97d5 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -27,3 +27,31 @@ interface Element { // Make optional, because it's unavailable on iPhones. requestFullscreen?(options?: FullscreenOptions): Promise; } + +import type en from '$lib/i18n/en.json'; +import 'svelte-i18n'; + +type NestedKeys = K extends keyof T & string + ? `${K}` | (T[K] extends object ? `${K}.${NestedKeys}` : never) + : never; + +declare module 'svelte-i18n' { + import type { InterpolationValues } from '$lib/components/i18n/format-message.svelte'; + import type { Readable } from 'svelte/store'; + + type Translations = NestedKeys; + + interface MessageObject { + id: Translations; + locale?: string; + format?: string; + default?: string; + values?: InterpolationValues; + } + + type MessageFormatter = (id: Translations | MessageObject, options?: Omit) => string; + + const format: Readable; + const t: Readable; + const _: Readable; +} diff --git a/web/src/lib/components/i18n/__test__/format-message.spec.ts b/web/src/lib/components/i18n/__test__/format-message.spec.ts index 589d9024e7..52eb77c80b 100644 --- a/web/src/lib/components/i18n/__test__/format-message.spec.ts +++ b/web/src/lib/components/i18n/__test__/format-message.spec.ts @@ -2,7 +2,7 @@ import FormatTagB from '$lib/components/i18n/__test__/format-tag-b.svelte'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/svelte'; -import { init, locale, register, waitLocale } from 'svelte-i18n'; +import { init, locale, register, waitLocale, type Translations } from 'svelte-i18n'; import { describe } from 'vitest'; describe('FormatMessage component', () => { @@ -25,7 +25,7 @@ describe('FormatMessage component', () => { it('formats a plain text message', () => { render(FormatMessage, { - key: 'hello', + key: 'hello' as Translations, values: { name: 'test' }, }); expect(screen.getByText('Hello test')).toBeInTheDocument(); @@ -33,20 +33,20 @@ describe('FormatMessage component', () => { it('throws an error when locale is empty', async () => { await locale.set(undefined); - expect(() => render(FormatMessage, { key: '' })).toThrowError(); + expect(() => render(FormatMessage, { key: '' as Translations })).toThrowError(); await locale.set('en'); }); it('shows raw message when value is empty', () => { render(FormatMessage, { - key: 'hello', + key: 'hello' as Translations, }); expect(screen.getByText('Hello {name}')).toBeInTheDocument(); }); it('shows message when slot is empty', () => { render(FormatMessage, { - key: 'html', + key: 'html' as Translations, values: { name: 'test' }, }); expect(screen.getByText('Hello test')).toBeInTheDocument(); @@ -54,7 +54,7 @@ describe('FormatMessage component', () => { it('renders a message with html', () => { const { container } = render(FormatTagB, { - key: 'html', + key: 'html' as Translations, values: { name: 'test' }, }); expect(container.innerHTML).toBe('Hello test'); @@ -62,7 +62,7 @@ describe('FormatMessage component', () => { it('renders a message with html and plural', () => { const { container } = render(FormatTagB, { - key: 'plural', + key: 'plural' as Translations, values: { count: 1 }, }); expect(container.innerHTML).toBe('You have 1 item'); @@ -70,19 +70,19 @@ describe('FormatMessage component', () => { it('protects agains XSS injection', () => { render(FormatMessage, { - key: 'xss', + key: 'xss' as Translations, }); expect(screen.getByText('')).toBeInTheDocument(); }); it('displays the message key when not found', () => { - render(FormatMessage, { key: 'invalid.key' }); + render(FormatMessage, { key: 'invalid.key' as Translations }); expect(screen.getByText('invalid.key')).toBeInTheDocument(); }); it('supports html tags inside plurals', () => { const { container } = render(FormatTagB, { - key: 'plural_with_html', + key: 'plural_with_html' as Translations, values: { count: 10 }, }); expect(container.innerHTML).toBe('You have 10 items'); @@ -90,7 +90,7 @@ describe('FormatMessage component', () => { it('supports html tags inside select', () => { const { container } = render(FormatTagB, { - key: 'select_with_html', + key: 'select_with_html' as Translations, values: { status: true }, }); expect(container.innerHTML).toBe('Item is disabled'); @@ -98,7 +98,7 @@ describe('FormatMessage component', () => { it('supports html tags inside selectordinal', () => { const { container } = render(FormatTagB, { - key: 'ordinal_with_html', + key: 'ordinal_with_html' as Translations, values: { count: 4 }, }); expect(container.innerHTML).toBe('4th item'); diff --git a/web/src/lib/components/i18n/__test__/format-tag-b.svelte b/web/src/lib/components/i18n/__test__/format-tag-b.svelte index f06a54a1e0..122358c6b7 100644 --- a/web/src/lib/components/i18n/__test__/format-tag-b.svelte +++ b/web/src/lib/components/i18n/__test__/format-tag-b.svelte @@ -1,8 +1,9 @@ diff --git a/web/src/lib/components/i18n/format-bold-message.svelte b/web/src/lib/components/i18n/format-bold-message.svelte index 6a449e8808..052b220edc 100644 --- a/web/src/lib/components/i18n/format-bold-message.svelte +++ b/web/src/lib/components/i18n/format-bold-message.svelte @@ -1,8 +1,9 @@ diff --git a/web/src/lib/components/i18n/format-message.svelte b/web/src/lib/components/i18n/format-message.svelte index d6ff09ed1c..48c59478c6 100644 --- a/web/src/lib/components/i18n/format-message.svelte +++ b/web/src/lib/components/i18n/format-message.svelte @@ -11,14 +11,14 @@ type PluralElement, type SelectElement, } from '@formatjs/icu-messageformat-parser'; - import { locale as i18nLocale, json } from 'svelte-i18n'; + import { locale as i18nLocale, json, type Translations } from 'svelte-i18n'; type MessagePart = { message: string; tag?: string; }; - export let key: string; + export let key: Translations; export let values: InterpolationValues = {}; const getLocale = (locale?: string | null) => { diff --git a/web/src/lib/i18n.spec.ts b/web/src/lib/i18n.spec.ts index c9261dcec5..13d926e647 100644 --- a/web/src/lib/i18n.spec.ts +++ b/web/src/lib/i18n.spec.ts @@ -1,39 +1,8 @@ import { langs } from '$lib/constants'; -import messages from '$lib/i18n/en.json'; import { getClosestAvailableLocale } from '$lib/utils/i18n'; -import { exec as execCallback } from 'node:child_process'; import { readFileSync, readdirSync } from 'node:fs'; -import { promisify } from 'node:util'; - -type Messages = { [key: string]: string | Messages }; - -const exec = promisify(execCallback); - -function setEmptyMessages(messages: Messages) { - const copy = { ...messages }; - - for (const key in copy) { - const message = copy[key]; - if (typeof message === 'string') { - copy[key] = ''; - } else if (typeof message === 'object') { - setEmptyMessages(message); - } - } - - return copy; -} describe('i18n', () => { - test('no missing messages', async () => { - const { stdout } = await exec('npx svelte-i18n extract -c svelte.config.js "src/**/*"'); - const extractedMessages: Messages = JSON.parse(stdout); - const existingMessages = setEmptyMessages(messages); - - // Only translations directly using the store seem to get extracted - expect({ ...extractedMessages, ...existingMessages }).toEqual(existingMessages); - }); - describe('loaders', () => { const languageFiles = readdirSync('src/lib/i18n').sort(); for (const filename of languageFiles) { diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts index bcc854cc3c..0f3a7377d2 100644 --- a/web/src/routes/+page.ts +++ b/web/src/routes/+page.ts @@ -12,7 +12,6 @@ export const ssr = false; export const csr = true; export const load = (async ({ fetch }) => { - let $t = (arg: string) => arg; try { await init(fetch); const authenticated = await loadUser(); @@ -26,7 +25,6 @@ export const load = (async ({ fetch }) => { redirect(302, AppRoute.AUTH_LOGIN); } - $t = await getFormatter(); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (redirectError: any) { if (redirectError?.status === 302) { @@ -34,6 +32,8 @@ export const load = (async ({ fetch }) => { } } + const $t = await getFormatter(); + return { meta: { title: $t('welcome') + ' 🎉', From f3e176e192fe63daba827f61e4bd739c004f00b7 Mon Sep 17 00:00:00 2001 From: Richard Kojedzinszky Date: Thu, 29 Aug 2024 17:11:49 +0200 Subject: [PATCH 200/723] feat(ml): support dynamic scaling (#12065) feat(ml): make http keep-alive configurable Closes #12064 --- docs/docs/install/environment-variables.md | 33 ++++++++++++---------- machine-learning/start.sh | 2 ++ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 78cd16cf1b..9a4b0b9360 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -159,26 +159,29 @@ Redis (Sentinel) URL example JSON before encoding: ## Machine Learning -| Variable | Description | Default | Containers | -| :----------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------------: | :--------------- | -| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning | -| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning | -| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning | -| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning | -| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning | -| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning | -| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning | -| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO image) | machine learning | -| `MACHINE_LEARNING_PRELOAD__CLIP` | Name of a CLIP model to be preloaded and kept in cache | | machine learning | -| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION` | Name of a facial recognition model to be preloaded and kept in cache | | machine learning | -| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | -| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | -| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | +| Variable | Description | Default | Containers | +| :-------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------------: | :--------------- | +| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning | +| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning | +| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning | +| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning | +| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning | +| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning | +| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning | +| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`\*3 | HTTP Keep-alive time in seconds | `2` | machine learning | +| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO image) | machine learning | +| `MACHINE_LEARNING_PRELOAD__CLIP` | Name of a CLIP model to be preloaded and kept in cache | | machine learning | +| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION` | Name of a facial recognition model to be preloaded and kept in cache | | machine learning | +| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | +| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | +| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | \*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones. \*2: Since each process duplicates models in memory, changing this is not recommended unless you have abundant memory to go around. +\*3: For scenarios like HPA in K8S. https://github.com/immich-app/immich/discussions/12064 + :::info Other machine learning parameters can be tuned from the admin UI. diff --git a/machine-learning/start.sh b/machine-learning/start.sh index 6b8e55a236..c3fda523df 100755 --- a/machine-learning/start.sh +++ b/machine-learning/start.sh @@ -13,6 +13,7 @@ fi : "${IMMICH_HOST:=[::]}" : "${IMMICH_PORT:=3003}" : "${MACHINE_LEARNING_WORKERS:=1}" +: "${MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S:=2}" gunicorn app.main:app \ -k app.config.CustomUvicornWorker \ @@ -20,4 +21,5 @@ gunicorn app.main:app \ -w "$MACHINE_LEARNING_WORKERS" \ -t "$MACHINE_LEARNING_WORKER_TIMEOUT" \ --log-config-json log_conf.json \ + --keep-alive "$MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S" \ --graceful-timeout 0 From c008feca6306556e0a704b27b3a056f6c82e4197 Mon Sep 17 00:00:00 2001 From: kaziu687 Date: Thu, 29 Aug 2024 17:40:17 +0200 Subject: [PATCH 201/723] feat(web): navigate assets with gestures (next/prev) (#11888) Co-authored-by: Alex Tran --- web/package-lock.json | 7 +++++++ web/package.json | 1 + .../asset-viewer/asset-viewer.svelte | 19 ++++++++++++++++++- .../asset-viewer/photo-viewer.svelte | 17 +++++++++++++++++ .../asset-viewer/video-native-viewer.svelte | 15 +++++++++++++++ .../asset-viewer/video-wrapper-viewer.svelte | 12 +++++++++++- 6 files changed, 69 insertions(+), 2 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 5670cf2cc9..d5a2747893 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -24,6 +24,7 @@ "lodash-es": "^4.17.21", "luxon": "^3.4.4", "socket.io-client": "^4.7.4", + "svelte-gestures": "^5.0.4", "svelte-i18n": "^4.0.0", "svelte-local-storage-store": "^0.6.4", "svelte-maplibre": "^0.9.0", @@ -7791,6 +7792,12 @@ } } }, + "node_modules/svelte-gestures": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/svelte-gestures/-/svelte-gestures-5.0.4.tgz", + "integrity": "sha512-a6cnR46AfFZ8zZyvA38A1wBLBFI7rYuAWQnmv3yYgSdbaJK/U7JG34rSkjMCePRvf4BETJSDfMNngLs5zEAfbw==", + "license": "MIT" + }, "node_modules/svelte-hmr": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz", diff --git a/web/package.json b/web/package.json index 7163b04788..d87b6e6c08 100644 --- a/web/package.json +++ b/web/package.json @@ -80,6 +80,7 @@ "lodash-es": "^4.17.21", "luxon": "^3.4.4", "socket.io-client": "^4.7.4", + "svelte-gestures": "^5.0.4", "svelte-i18n": "^4.0.0", "svelte-local-storage-store": "^0.6.4", "svelte-maplibre": "^0.9.0", diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 4e98546069..69d35b9aa4 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -462,6 +462,8 @@ bind:copyImage asset={previewStackedAsset} {preloadAssets} + onPreviousAsset={() => navigateAsset('previous')} + onNextAsset={() => navigateAsset('next')} on:close={closeViewer} haveFadeTransition={false} {sharedLink} @@ -472,6 +474,8 @@ checksum={previewStackedAsset.checksum} projectionType={previewStackedAsset.exifInfo?.projectionType} loopVideo={true} + onPreviousAsset={() => navigateAsset('previous')} + onNextAsset={() => navigateAsset('next')} on:close={closeViewer} on:onVideoEnded={() => navigateAsset()} on:onVideoStarted={handleVideoStarted} @@ -487,6 +491,8 @@ checksum={asset.checksum} projectionType={asset.exifInfo?.projectionType} loopVideo={$slideshowState !== SlideshowState.PlaySlideshow} + onPreviousAsset={() => navigateAsset('previous')} + onNextAsset={() => navigateAsset('next')} on:close={closeViewer} on:onVideoEnded={() => (shouldPlayMotionPhoto = false)} /> @@ -497,7 +503,16 @@ {:else if isShowEditor && selectedEditType === 'crop'} {:else} - + navigateAsset('previous')} + onNextAsset={() => navigateAsset('next')} + on:close={closeViewer} + {sharedLink} + /> {/if} {:else} navigateAsset('previous')} + onNextAsset={() => navigateAsset('next')} on:close={closeViewer} on:onVideoEnded={() => navigateAsset()} on:onVideoStarted={handleVideoStarted} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 7589ce130a..6f6af652b9 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -15,6 +15,7 @@ import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard'; import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; + import { type SwipeCustomEvent, swipe } from 'svelte-gestures'; import { fade } from 'svelte/transition'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; @@ -24,6 +25,8 @@ export let element: HTMLDivElement | undefined = undefined; export let haveFadeTransition = true; export let sharedLink: SharedLinkResponseDto | undefined = undefined; + export let onPreviousAsset: (() => void) | null = null; + export let onNextAsset: (() => void) | null = null; export let copyImage: (() => Promise) | null = null; export let zoomToggle: (() => void) | null = null; @@ -110,6 +113,18 @@ handlePromiseError(copyImage()); }; + const onSwipe = (event: SwipeCustomEvent) => { + if ($photoZoomState.currentZoom > 1) { + return; + } + if (onNextAsset && event.detail.direction === 'left') { + onNextAsset(); + } + if (onPreviousAsset && event.detail.direction === 'right') { + onPreviousAsset(); + } + }; + onMount(() => { const onload = () => { imageLoaded = true; @@ -166,6 +181,8 @@ {$getAltText(asset)} @@ -59,6 +72,8 @@ playsinline controls class="h-full object-contain" + use:swipe + on:swipe={onSwipe} on:canplay={(e) => handleCanPlay(e.currentTarget)} on:ended={() => dispatch('onVideoEnded')} on:volumechange={(e) => { diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte index 129b6c8be7..5f03784c42 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -8,10 +8,20 @@ export let projectionType: string | null | undefined; export let checksum: string; export let loopVideo: boolean; + export let onPreviousAsset: () => void; + export let onNextAsset: () => void; {#if projectionType === ProjectionType.EQUIRECTANGULAR} {:else} - + {/if} From 682adaa334568eefae1fccb6b83d5e274e2e7b97 Mon Sep 17 00:00:00 2001 From: src Date: Thu, 29 Aug 2024 15:57:42 +0000 Subject: [PATCH 202/723] fix(mobile): allow create empty non-shared albums, add proper button colors (#12103) * Add proper colors to create album button Allow creation of empty albums with names, or non-empty albums without names * Add proper colors to create album button Allow creation of empty albums with names, or non-empty albums without names * Small changes * Revert change * Simplify logic * lint --------- Co-authored-by: Alex --- mobile/lib/pages/common/create_album.page.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/mobile/lib/pages/common/create_album.page.dart b/mobile/lib/pages/common/create_album.page.dart index 51282d8dd6..1fd860520d 100644 --- a/mobile/lib/pages/common/create_album.page.dart +++ b/mobile/lib/pages/common/create_album.page.dart @@ -52,6 +52,7 @@ class CreateAlbumPage extends HookConsumerWidget { if (albumTitleController.text.isEmpty) { albumTitleController.text = 'create_album_page_untitled'.tr(); + isAlbumTitleEmpty.value = false; ref .watch(albumTitleProvider.notifier) .setAlbumTitle('create_album_page_untitled'.tr()); @@ -191,6 +192,7 @@ class CreateAlbumPage extends HookConsumerWidget { } createNonSharedAlbum() async { + onBackgroundTapped(); var newAlbum = await ref.watch(albumProvider.notifier).createAlbum( ref.watch(albumTitleProvider), selectedAssets.value, @@ -238,15 +240,16 @@ class CreateAlbumPage extends HookConsumerWidget { ), if (!isSharedAlbum) TextButton( - onPressed: albumTitleController.text.isNotEmpty && - selectedAssets.value.isNotEmpty + onPressed: albumTitleController.text.isNotEmpty ? createNonSharedAlbum : null, child: Text( 'create_shared_album_page_create'.tr(), style: TextStyle( fontWeight: FontWeight.bold, - color: context.primaryColor, + color: albumTitleController.text.isNotEmpty + ? context.primaryColor + : context.themeData.disabledColor, ), ), ), From d08a20bd5708670f21fc7a65bc29c65f17111446 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 29 Aug 2024 12:14:03 -0400 Subject: [PATCH 203/723] feat: tags (#11980) * feat: tags * fix: folder tree icons * navigate to tag from detail panel * delete tag * Tag position and add tag button * Tag asset in detail panel * refactor form * feat: navigate to tag page from clicking on a tag * feat: delete tags from the tag page * refactor: moving tag section in detail panel and add + tag button * feat: tag asset action in detail panel * refactor add tag form * fdisable add tag button when there is no selection * feat: tag bulk endpoint * feat: tag colors * chore: clean up * chore: unit tests * feat: write tags to sidecar * Remove tag and auto focus on tag creation form opened * chore: regenerate migration * chore: linting * add color picker to tag edit form * fix: force render tags timeline on navigating back from asset viewer * feat: read tags from keywords * chore: clean up --------- Co-authored-by: Alex Tran --- e2e/src/api/specs/tag.e2e-spec.ts | 559 ++++++++++++++++++ e2e/src/utils.ts | 1 + mobile/openapi/README.md | 13 +- mobile/openapi/lib/api.dart | 8 +- mobile/openapi/lib/api/tags_api.dart | 208 ++++--- mobile/openapi/lib/api/timeline_api.dart | 26 +- mobile/openapi/lib/api_client.dart | 16 +- mobile/openapi/lib/api_helper.dart | 3 - mobile/openapi/lib/model/permission.dart | 3 + .../lib/model/tag_bulk_assets_dto.dart | 110 ++++ .../model/tag_bulk_assets_response_dto.dart | 98 +++ ...pdate_tag_dto.dart => tag_create_dto.dart} | 71 ++- .../openapi/lib/model/tag_response_dto.dart | 55 +- mobile/openapi/lib/model/tag_type_enum.dart | 88 --- ...reate_tag_dto.dart => tag_update_dto.dart} | 61 +- mobile/openapi/lib/model/tag_upsert_dto.dart | 100 ++++ open-api/immich-openapi-specs.json | 285 ++++++--- open-api/typescript-sdk/src/fetch-client.ts | 103 ++-- server/src/controllers/tag.controller.ts | 54 +- server/src/dtos/asset-response.dto.ts | 2 +- server/src/dtos/tag.dto.ts | 62 +- server/src/dtos/time-bucket.dto.ts | 3 + server/src/entities/tag.entity.ts | 63 +- server/src/enum.ts | 1 + server/src/interfaces/access.interface.ts | 4 + server/src/interfaces/asset.interface.ts | 1 + server/src/interfaces/event.interface.ts | 4 + server/src/interfaces/job.interface.ts | 1 + server/src/interfaces/tag.interface.ts | 22 +- .../1724790460210-NestedTagTable.ts | 57 ++ server/src/queries/access.repository.sql | 11 + server/src/queries/asset.repository.sql | 8 +- server/src/queries/tag.repository.sql | 30 + server/src/repositories/access.repository.ts | 27 + server/src/repositories/asset.repository.ts | 9 + server/src/repositories/tag.repository.ts | 193 +++--- server/src/services/metadata.service.spec.ts | 72 +++ server/src/services/metadata.service.ts | 113 ++-- server/src/services/tag.service.spec.ts | 233 +++++--- server/src/services/tag.service.ts | 159 +++-- server/src/services/timeline.service.ts | 4 + server/src/utils/access.ts | 19 +- server/src/utils/request.ts | 2 +- server/src/utils/tag.ts | 30 + server/test/fixtures/tag.stub.ts | 55 +- .../repositories/access.repository.mock.ts | 5 + .../test/repositories/tag.repository.mock.ts | 17 +- .../asset-viewer/detail-panel-tags.svelte | 80 +++ .../asset-viewer/detail-panel.svelte | 11 +- .../assets/thumbnail/thumbnail.svelte | 2 +- .../components/forms/tag-asset-form.svelte | 82 +++ .../layouts/user-page-layout.svelte | 8 +- .../photos-page/actions/tag-action.svelte | 47 ++ .../photos-page/asset-date-group.svelte | 8 +- .../components/photos-page/asset-grid.svelte | 16 +- .../shared-components/combobox.svelte | 5 +- .../settings/setting-input-field.svelte | 94 ++- .../side-bar/side-bar.svelte | 3 + .../shared-components/tree/tree-items.svelte | 16 +- .../shared-components/tree/tree.svelte | 16 +- web/src/lib/constants.ts | 1 + web/src/lib/i18n/en.json | 15 + web/src/lib/utils/asset-store-task-manager.ts | 26 +- web/src/lib/utils/asset-utils.ts | 51 ++ .../[[assetId=id]]/+page.svelte | 11 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 2 + .../[[assetId=id]]/+page.svelte | 251 ++++++++ .../[[photos=photos]]/[[assetId=id]]/+page.ts | 32 + 68 files changed, 3032 insertions(+), 814 deletions(-) create mode 100644 e2e/src/api/specs/tag.e2e-spec.ts create mode 100644 mobile/openapi/lib/model/tag_bulk_assets_dto.dart create mode 100644 mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart rename mobile/openapi/lib/model/{update_tag_dto.dart => tag_create_dto.dart} (56%) delete mode 100644 mobile/openapi/lib/model/tag_type_enum.dart rename mobile/openapi/lib/model/{create_tag_dto.dart => tag_update_dto.dart} (57%) create mode 100644 mobile/openapi/lib/model/tag_upsert_dto.dart create mode 100644 server/src/migrations/1724790460210-NestedTagTable.ts create mode 100644 server/src/queries/tag.repository.sql create mode 100644 server/src/utils/tag.ts create mode 100644 web/src/lib/components/asset-viewer/detail-panel-tags.svelte create mode 100644 web/src/lib/components/forms/tag-asset-form.svelte create mode 100644 web/src/lib/components/photos-page/actions/tag-action.svelte create mode 100644 web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte create mode 100644 web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts diff --git a/e2e/src/api/specs/tag.e2e-spec.ts b/e2e/src/api/specs/tag.e2e-spec.ts new file mode 100644 index 0000000000..0a26ccef0e --- /dev/null +++ b/e2e/src/api/specs/tag.e2e-spec.ts @@ -0,0 +1,559 @@ +import { + AssetMediaResponseDto, + LoginResponseDto, + Permission, + TagCreateDto, + createTag, + getAllTags, + tagAssets, + upsertTags, +} from '@immich/sdk'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const create = (accessToken: string, dto: TagCreateDto) => + createTag({ tagCreateDto: dto }, { headers: asBearerAuth(accessToken) }); + +const upsert = (accessToken: string, tags: string[]) => + upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }); + +describe('/tags', () => { + let admin: LoginResponseDto; + let user: LoginResponseDto; + let userAsset: AssetMediaResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + + admin = await utils.adminSetup(); + user = await utils.userSetup(admin.accessToken, createUserDto.user1); + userAsset = await utils.createAsset(user.accessToken); + }); + + beforeEach(async () => { + // tagging assets eventually triggers metadata extraction which can impact other tests + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.resetDatabase(['tags']); + }); + + describe('POST /tags', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/tags').send({ name: 'TagA' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.create')); + }); + + it('should work with tag.create', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.TagCreate]); + const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + + it('should create a tag', async () => { + const { status, body } = await request(app) + .post('/tags') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ name: 'TagA' }); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + + it('should create a nested tag', async () => { + const parent = await create(admin.accessToken, { name: 'TagA' }); + + const { status, body } = await request(app) + .post('/tags') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ name: 'TagB', parentId: parent.id }); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagB', + value: 'TagA/TagB', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + }); + + describe('GET /tags', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/tags'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).get('/tags').set('x-api-key', secret); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.read')); + }); + + it('should start off empty', async () => { + const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([]); + expect(status).toEqual(200); + }); + + it('should return a list of tags', async () => { + const [tagA, tagB, tagC] = await Promise.all([ + create(admin.accessToken, { name: 'TagA' }), + create(admin.accessToken, { name: 'TagB' }), + create(admin.accessToken, { name: 'TagC' }), + ]); + const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toHaveLength(3); + expect(body).toEqual([tagA, tagB, tagC]); + expect(status).toEqual(200); + }); + + it('should return a nested tags', async () => { + await upsert(admin.accessToken, ['TagA/TagB/TagC', 'TagD']); + const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toHaveLength(4); + expect(body).toEqual([ + expect.objectContaining({ name: 'TagA', value: 'TagA' }), + expect.objectContaining({ name: 'TagB', value: 'TagA/TagB' }), + expect.objectContaining({ name: 'TagC', value: 'TagA/TagB/TagC' }), + expect.objectContaining({ name: 'TagD', value: 'TagD' }), + ]); + expect(status).toEqual(200); + }); + }); + + describe('PUT /tags', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/tags`).send({ name: 'TagA/TagB' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.create')); + }); + + it('should upsert tags', async () => { + const { status, body } = await request(app) + .put(`/tags`) + .send({ tags: ['TagA/TagB/TagC/TagD'] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ name: 'TagD', value: 'TagA/TagB/TagC/TagD' })]); + }); + }); + + describe('PUT /tags/assets', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/tags/assets`).send({ tagIds: [], assetIds: [] }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .put('/tags/assets') + .set('x-api-key', secret) + .send({ assetIds: [], tagIds: [] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.asset')); + }); + + it('should skip assets that are not owned by the user', async () => { + const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ + create(user.accessToken, { name: 'TagA' }), + create(user.accessToken, { name: 'TagB' }), + create(user.accessToken, { name: 'TagC' }), + utils.createAsset(user.accessToken), + utils.createAsset(admin.accessToken), + ]); + const { status, body } = await request(app) + .put(`/tags/assets`) + .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 3 }); + }); + + it('should skip tags that are not owned by the user', async () => { + const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ + create(user.accessToken, { name: 'TagA' }), + create(user.accessToken, { name: 'TagB' }), + create(admin.accessToken, { name: 'TagC' }), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + ]); + const { status, body } = await request(app) + .put(`/tags/assets`) + .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 4 }); + }); + + it('should bulk tag assets', async () => { + const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ + create(user.accessToken, { name: 'TagA' }), + create(user.accessToken, { name: 'TagB' }), + create(user.accessToken, { name: 'TagC' }), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + ]); + const { status, body } = await request(app) + .put(`/tags/assets`) + .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 6 }); + }); + }); + + describe('GET /tags/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get(`/tags/${uuidDto.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .get(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .get(`/tags/${uuidDto.notFound}`) + .set('x-api-key', secret) + .send({ assetIds: [], tagIds: [] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.read')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .get(`/tags/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should get tag details', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .get(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + + it('should get nested tag details', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id }); + const tagC = await create(user.accessToken, { name: 'TagC', parentId: tagB.id }); + const tagD = await create(user.accessToken, { name: 'TagD', parentId: tagC.id }); + + const { status, body } = await request(app) + .get(`/tags/${tagD.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagD', + value: 'TagA/TagB/TagC/TagD', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + }); + + describe('PUT /tags/:id', () => { + it('should require authentication', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app).put(`/tags/${tag.id}`).send({ color: '#000000' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(admin.accessToken, { name: 'tagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .send({ color: '#000000' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .set('x-api-key', secret) + .send({ color: '#000000' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.update')); + }); + + it('should update a tag', async () => { + const tag = await create(user.accessToken, { name: 'tagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .send({ color: '#000000' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ color: `#000000` })); + }); + + it('should update a tag color without a # prefix', async () => { + const tag = await create(user.accessToken, { name: 'tagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .send({ color: '000000' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ color: `#000000` })); + }); + }); + + describe('DELETE /tags/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).delete(`/tags/${uuidDto.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .delete(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).delete(`/tags/${tag.id}`).set('x-api-key', secret); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.delete')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .delete(`/tags/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should delete a tag', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status } = await request(app) + .delete(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + }); + + it('should delete a nested tag (root)', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await create(user.accessToken, { name: 'TagB', parentId: tagA.id }); + const { status } = await request(app) + .delete(`/tags/${tagA.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) }); + expect(tags.length).toBe(0); + }); + + it('should delete a nested tag (leaf)', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id }); + const { status } = await request(app) + .delete(`/tags/${tagB.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) }); + expect(tags.length).toBe(1); + expect(tags[0]).toEqual(tagA); + }); + }); + + describe('PUT /tags/:id/assets', () => { + it('should require authentication', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .send({ ids: [userAsset.id] }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}/assets`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [userAsset.id] }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .put(`/tags/${tag.id}/assets`) + .set('x-api-key', secret) + .send({ ids: [userAsset.id] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.asset')); + }); + + it('should be able to tag own asset', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]); + }); + + it("should not be able to add assets to another user's tag", async () => { + const tagA = await create(admin.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Not found or no tag.asset access')); + }); + + it('should add duplicate assets only once', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id, userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: userAsset.id, success: true }), + expect.objectContaining({ id: userAsset.id, success: false, error: 'duplicate' }), + ]); + }); + }); + + describe('DELETE /tags/:id/assets', () => { + it('should require authentication', async () => { + const tagA = await create(admin.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .delete(`/tags/${tagA}/assets`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await tagAssets( + { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, + { headers: asBearerAuth(user.accessToken) }, + ); + const { status, body } = await request(app) + .delete(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .delete(`/tags/${tag.id}/assets`) + .set('x-api-key', secret) + .send({ ids: [userAsset.id] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.asset')); + }); + + it('should be able to remove own asset from own tag', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await tagAssets( + { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, + { headers: asBearerAuth(user.accessToken) }, + ); + const { status, body } = await request(app) + .delete(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]); + }); + + it('should remove duplicate assets only once', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await tagAssets( + { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, + { headers: asBearerAuth(user.accessToken) }, + ); + const { status, body } = await request(app) + .delete(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id, userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: userAsset.id, success: true }), + expect.objectContaining({ id: userAsset.id, success: false, error: 'not_found' }), + ]); + }); + }); +}); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 30e2497b51..a53a3ddd25 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -148,6 +148,7 @@ export const utils = { 'sessions', 'users', 'system_metadata', + 'tags', ]; const sql: string[] = []; diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1da4463a12..1f8958dd95 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -210,14 +210,15 @@ Class | Method | HTTP request | Description *SystemMetadataApi* | [**getAdminOnboarding**](doc//SystemMetadataApi.md#getadminonboarding) | **GET** /system-metadata/admin-onboarding | *SystemMetadataApi* | [**getReverseGeocodingState**](doc//SystemMetadataApi.md#getreversegeocodingstate) | **GET** /system-metadata/reverse-geocoding-state | *SystemMetadataApi* | [**updateAdminOnboarding**](doc//SystemMetadataApi.md#updateadminonboarding) | **POST** /system-metadata/admin-onboarding | +*TagsApi* | [**bulkTagAssets**](doc//TagsApi.md#bulktagassets) | **PUT** /tags/assets | *TagsApi* | [**createTag**](doc//TagsApi.md#createtag) | **POST** /tags | *TagsApi* | [**deleteTag**](doc//TagsApi.md#deletetag) | **DELETE** /tags/{id} | *TagsApi* | [**getAllTags**](doc//TagsApi.md#getalltags) | **GET** /tags | -*TagsApi* | [**getTagAssets**](doc//TagsApi.md#gettagassets) | **GET** /tags/{id}/assets | *TagsApi* | [**getTagById**](doc//TagsApi.md#gettagbyid) | **GET** /tags/{id} | *TagsApi* | [**tagAssets**](doc//TagsApi.md#tagassets) | **PUT** /tags/{id}/assets | *TagsApi* | [**untagAssets**](doc//TagsApi.md#untagassets) | **DELETE** /tags/{id}/assets | -*TagsApi* | [**updateTag**](doc//TagsApi.md#updatetag) | **PATCH** /tags/{id} | +*TagsApi* | [**updateTag**](doc//TagsApi.md#updatetag) | **PUT** /tags/{id} | +*TagsApi* | [**upsertTags**](doc//TagsApi.md#upserttags) | **PUT** /tags | *TimelineApi* | [**getTimeBucket**](doc//TimelineApi.md#gettimebucket) | **GET** /timeline/bucket | *TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets | *TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | @@ -305,7 +306,6 @@ Class | Method | HTTP request | Description - [CreateAlbumDto](doc//CreateAlbumDto.md) - [CreateLibraryDto](doc//CreateLibraryDto.md) - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md) - - [CreateTagDto](doc//CreateTagDto.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponse](doc//DownloadResponse.md) @@ -429,8 +429,12 @@ Class | Method | HTTP request | Description - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md) - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md) - [SystemConfigUserDto](doc//SystemConfigUserDto.md) + - [TagBulkAssetsDto](doc//TagBulkAssetsDto.md) + - [TagBulkAssetsResponseDto](doc//TagBulkAssetsResponseDto.md) + - [TagCreateDto](doc//TagCreateDto.md) - [TagResponseDto](doc//TagResponseDto.md) - - [TagTypeEnum](doc//TagTypeEnum.md) + - [TagUpdateDto](doc//TagUpdateDto.md) + - [TagUpsertDto](doc//TagUpsertDto.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketSize](doc//TimeBucketSize.md) - [ToneMapping](doc//ToneMapping.md) @@ -441,7 +445,6 @@ Class | Method | HTTP request | Description - [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateLibraryDto](doc//UpdateLibraryDto.md) - [UpdatePartnerDto](doc//UpdatePartnerDto.md) - - [UpdateTagDto](doc//UpdateTagDto.md) - [UsageByUserDto](doc//UsageByUserDto.md) - [UserAdminCreateDto](doc//UserAdminCreateDto.md) - [UserAdminDeleteDto](doc//UserAdminDeleteDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 05a43c8af7..532d7e22cd 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -120,7 +120,6 @@ part 'model/colorspace.dart'; part 'model/create_album_dto.dart'; part 'model/create_library_dto.dart'; part 'model/create_profile_image_response_dto.dart'; -part 'model/create_tag_dto.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response.dart'; @@ -244,8 +243,12 @@ part 'model/system_config_template_storage_option_dto.dart'; part 'model/system_config_theme_dto.dart'; part 'model/system_config_trash_dto.dart'; part 'model/system_config_user_dto.dart'; +part 'model/tag_bulk_assets_dto.dart'; +part 'model/tag_bulk_assets_response_dto.dart'; +part 'model/tag_create_dto.dart'; part 'model/tag_response_dto.dart'; -part 'model/tag_type_enum.dart'; +part 'model/tag_update_dto.dart'; +part 'model/tag_upsert_dto.dart'; part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_size.dart'; part 'model/tone_mapping.dart'; @@ -256,7 +259,6 @@ part 'model/update_album_user_dto.dart'; part 'model/update_asset_dto.dart'; part 'model/update_library_dto.dart'; part 'model/update_partner_dto.dart'; -part 'model/update_tag_dto.dart'; part 'model/usage_by_user_dto.dart'; part 'model/user_admin_create_dto.dart'; part 'model/user_admin_delete_dto.dart'; diff --git a/mobile/openapi/lib/api/tags_api.dart b/mobile/openapi/lib/api/tags_api.dart index e5d1e9c650..87c9001a3c 100644 --- a/mobile/openapi/lib/api/tags_api.dart +++ b/mobile/openapi/lib/api/tags_api.dart @@ -16,16 +16,63 @@ class TagsApi { final ApiClient apiClient; + /// Performs an HTTP 'PUT /tags/assets' operation and returns the [Response]. + /// Parameters: + /// + /// * [TagBulkAssetsDto] tagBulkAssetsDto (required): + Future bulkTagAssetsWithHttpInfo(TagBulkAssetsDto tagBulkAssetsDto,) async { + // ignore: prefer_const_declarations + final path = r'/tags/assets'; + + // ignore: prefer_final_locals + Object? postBody = tagBulkAssetsDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [TagBulkAssetsDto] tagBulkAssetsDto (required): + Future bulkTagAssets(TagBulkAssetsDto tagBulkAssetsDto,) async { + final response = await bulkTagAssetsWithHttpInfo(tagBulkAssetsDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TagBulkAssetsResponseDto',) as TagBulkAssetsResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /tags' operation and returns the [Response]. /// Parameters: /// - /// * [CreateTagDto] createTagDto (required): - Future createTagWithHttpInfo(CreateTagDto createTagDto,) async { + /// * [TagCreateDto] tagCreateDto (required): + Future createTagWithHttpInfo(TagCreateDto tagCreateDto,) async { // ignore: prefer_const_declarations final path = r'/tags'; // ignore: prefer_final_locals - Object? postBody = createTagDto; + Object? postBody = tagCreateDto; final queryParams = []; final headerParams = {}; @@ -47,9 +94,9 @@ class TagsApi { /// Parameters: /// - /// * [CreateTagDto] createTagDto (required): - Future createTag(CreateTagDto createTagDto,) async { - final response = await createTagWithHttpInfo(createTagDto,); + /// * [TagCreateDto] tagCreateDto (required): + Future createTag(TagCreateDto tagCreateDto,) async { + final response = await createTagWithHttpInfo(tagCreateDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -147,57 +194,6 @@ class TagsApi { return null; } - /// Performs an HTTP 'GET /tags/{id}/assets' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - Future getTagAssetsWithHttpInfo(String id,) async { - // ignore: prefer_const_declarations - final path = r'/tags/{id}/assets' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - Future?> getTagAssets(String id,) async { - final response = await getTagAssetsWithHttpInfo(id,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() - .toList(growable: false); - - } - return null; - } - /// Performs an HTTP 'GET /tags/{id}' operation and returns the [Response]. /// Parameters: /// @@ -251,14 +247,14 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future tagAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { + /// * [BulkIdsDto] bulkIdsDto (required): + Future tagAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations final path = r'/tags/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = assetIdsDto; + Object? postBody = bulkIdsDto; final queryParams = []; final headerParams = {}; @@ -282,9 +278,9 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future?> tagAssets(String id, AssetIdsDto assetIdsDto,) async { - final response = await tagAssetsWithHttpInfo(id, assetIdsDto,); + /// * [BulkIdsDto] bulkIdsDto (required): + Future?> tagAssets(String id, BulkIdsDto bulkIdsDto,) async { + final response = await tagAssetsWithHttpInfo(id, bulkIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -293,8 +289,8 @@ class TagsApi { // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() .toList(growable: false); } @@ -306,14 +302,14 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future untagAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { + /// * [BulkIdsDto] bulkIdsDto (required): + Future untagAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations final path = r'/tags/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = assetIdsDto; + Object? postBody = bulkIdsDto; final queryParams = []; final headerParams = {}; @@ -337,9 +333,9 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future?> untagAssets(String id, AssetIdsDto assetIdsDto,) async { - final response = await untagAssetsWithHttpInfo(id, assetIdsDto,); + /// * [BulkIdsDto] bulkIdsDto (required): + Future?> untagAssets(String id, BulkIdsDto bulkIdsDto,) async { + final response = await untagAssetsWithHttpInfo(id, bulkIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -348,27 +344,27 @@ class TagsApi { // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() .toList(growable: false); } return null; } - /// Performs an HTTP 'PATCH /tags/{id}' operation and returns the [Response]. + /// Performs an HTTP 'PUT /tags/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): /// - /// * [UpdateTagDto] updateTagDto (required): - Future updateTagWithHttpInfo(String id, UpdateTagDto updateTagDto,) async { + /// * [TagUpdateDto] tagUpdateDto (required): + Future updateTagWithHttpInfo(String id, TagUpdateDto tagUpdateDto,) async { // ignore: prefer_const_declarations final path = r'/tags/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = updateTagDto; + Object? postBody = tagUpdateDto; final queryParams = []; final headerParams = {}; @@ -379,7 +375,7 @@ class TagsApi { return apiClient.invokeAPI( path, - 'PATCH', + 'PUT', queryParams, postBody, headerParams, @@ -392,9 +388,9 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [UpdateTagDto] updateTagDto (required): - Future updateTag(String id, UpdateTagDto updateTagDto,) async { - final response = await updateTagWithHttpInfo(id, updateTagDto,); + /// * [TagUpdateDto] tagUpdateDto (required): + Future updateTag(String id, TagUpdateDto tagUpdateDto,) async { + final response = await updateTagWithHttpInfo(id, tagUpdateDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -407,4 +403,54 @@ class TagsApi { } return null; } + + /// Performs an HTTP 'PUT /tags' operation and returns the [Response]. + /// Parameters: + /// + /// * [TagUpsertDto] tagUpsertDto (required): + Future upsertTagsWithHttpInfo(TagUpsertDto tagUpsertDto,) async { + // ignore: prefer_const_declarations + final path = r'/tags'; + + // ignore: prefer_final_locals + Object? postBody = tagUpsertDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [TagUpsertDto] tagUpsertDto (required): + Future?> upsertTags(TagUpsertDto tagUpsertDto,) async { + final response = await upsertTagsWithHttpInfo(tagUpsertDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } } diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 4acb98bdf2..8c94e09bf5 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -37,12 +37,14 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final path = r'/timeline/bucket'; @@ -75,6 +77,9 @@ class TimelineApi { queryParams.addAll(_queryParams('', 'personId', personId)); } queryParams.addAll(_queryParams('', 'size', size)); + if (tagId != null) { + queryParams.addAll(_queryParams('', 'tagId', tagId)); + } queryParams.addAll(_queryParams('', 'timeBucket', timeBucket)); if (userId != null) { queryParams.addAll(_queryParams('', 'userId', userId)); @@ -120,13 +125,15 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -162,12 +169,14 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final path = r'/timeline/buckets'; @@ -200,6 +209,9 @@ class TimelineApi { queryParams.addAll(_queryParams('', 'personId', personId)); } queryParams.addAll(_queryParams('', 'size', size)); + if (tagId != null) { + queryParams.addAll(_queryParams('', 'tagId', tagId)); + } if (userId != null) { queryParams.addAll(_queryParams('', 'userId', userId)); } @@ -242,13 +254,15 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c9ed2a508d..54873a5955 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -295,8 +295,6 @@ class ApiClient { return CreateLibraryDto.fromJson(value); case 'CreateProfileImageResponseDto': return CreateProfileImageResponseDto.fromJson(value); - case 'CreateTagDto': - return CreateTagDto.fromJson(value); case 'DownloadArchiveInfo': return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': @@ -543,10 +541,18 @@ class ApiClient { return SystemConfigTrashDto.fromJson(value); case 'SystemConfigUserDto': return SystemConfigUserDto.fromJson(value); + case 'TagBulkAssetsDto': + return TagBulkAssetsDto.fromJson(value); + case 'TagBulkAssetsResponseDto': + return TagBulkAssetsResponseDto.fromJson(value); + case 'TagCreateDto': + return TagCreateDto.fromJson(value); case 'TagResponseDto': return TagResponseDto.fromJson(value); - case 'TagTypeEnum': - return TagTypeEnumTypeTransformer().decode(value); + case 'TagUpdateDto': + return TagUpdateDto.fromJson(value); + case 'TagUpsertDto': + return TagUpsertDto.fromJson(value); case 'TimeBucketResponseDto': return TimeBucketResponseDto.fromJson(value); case 'TimeBucketSize': @@ -567,8 +573,6 @@ class ApiClient { return UpdateLibraryDto.fromJson(value); case 'UpdatePartnerDto': return UpdatePartnerDto.fromJson(value); - case 'UpdateTagDto': - return UpdateTagDto.fromJson(value); case 'UsageByUserDto': return UsageByUserDto.fromJson(value); case 'UserAdminCreateDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 7f46e145b1..a486551cc5 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -127,9 +127,6 @@ String parameterToString(dynamic value) { if (value is SharedLinkType) { return SharedLinkTypeTypeTransformer().encode(value).toString(); } - if (value is TagTypeEnum) { - return TagTypeEnumTypeTransformer().encode(value).toString(); - } if (value is TimeBucketSize) { return TimeBucketSizeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 3f89c9826d..1244a434b6 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -96,6 +96,7 @@ class Permission { static const tagPeriodRead = Permission._(r'tag.read'); static const tagPeriodUpdate = Permission._(r'tag.update'); static const tagPeriodDelete = Permission._(r'tag.delete'); + static const tagPeriodAsset = Permission._(r'tag.asset'); static const adminPeriodUserPeriodCreate = Permission._(r'admin.user.create'); static const adminPeriodUserPeriodRead = Permission._(r'admin.user.read'); static const adminPeriodUserPeriodUpdate = Permission._(r'admin.user.update'); @@ -176,6 +177,7 @@ class Permission { tagPeriodRead, tagPeriodUpdate, tagPeriodDelete, + tagPeriodAsset, adminPeriodUserPeriodCreate, adminPeriodUserPeriodRead, adminPeriodUserPeriodUpdate, @@ -291,6 +293,7 @@ class PermissionTypeTransformer { case r'tag.read': return Permission.tagPeriodRead; case r'tag.update': return Permission.tagPeriodUpdate; case r'tag.delete': return Permission.tagPeriodDelete; + case r'tag.asset': return Permission.tagPeriodAsset; case r'admin.user.create': return Permission.adminPeriodUserPeriodCreate; case r'admin.user.read': return Permission.adminPeriodUserPeriodRead; case r'admin.user.update': return Permission.adminPeriodUserPeriodUpdate; diff --git a/mobile/openapi/lib/model/tag_bulk_assets_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart new file mode 100644 index 0000000000..c11cb66ce0 --- /dev/null +++ b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart @@ -0,0 +1,110 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagBulkAssetsDto { + /// Returns a new [TagBulkAssetsDto] instance. + TagBulkAssetsDto({ + this.assetIds = const [], + this.tagIds = const [], + }); + + List assetIds; + + List tagIds; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagBulkAssetsDto && + _deepEquality.equals(other.assetIds, assetIds) && + _deepEquality.equals(other.tagIds, tagIds); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetIds.hashCode) + + (tagIds.hashCode); + + @override + String toString() => 'TagBulkAssetsDto[assetIds=$assetIds, tagIds=$tagIds]'; + + Map toJson() { + final json = {}; + json[r'assetIds'] = this.assetIds; + json[r'tagIds'] = this.tagIds; + return json; + } + + /// Returns a new [TagBulkAssetsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagBulkAssetsDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagBulkAssetsDto( + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + : const [], + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagBulkAssetsDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagBulkAssetsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagBulkAssetsDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagBulkAssetsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetIds', + 'tagIds', + }; +} + diff --git a/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart new file mode 100644 index 0000000000..d4dcb91d8c --- /dev/null +++ b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagBulkAssetsResponseDto { + /// Returns a new [TagBulkAssetsResponseDto] instance. + TagBulkAssetsResponseDto({ + required this.count, + }); + + int count; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagBulkAssetsResponseDto && + other.count == count; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (count.hashCode); + + @override + String toString() => 'TagBulkAssetsResponseDto[count=$count]'; + + Map toJson() { + final json = {}; + json[r'count'] = this.count; + return json; + } + + /// Returns a new [TagBulkAssetsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagBulkAssetsResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagBulkAssetsResponseDto( + count: mapValueOfType(json, r'count')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagBulkAssetsResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagBulkAssetsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagBulkAssetsResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagBulkAssetsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'count', + }; +} + diff --git a/mobile/openapi/lib/model/update_tag_dto.dart b/mobile/openapi/lib/model/tag_create_dto.dart similarity index 56% rename from mobile/openapi/lib/model/update_tag_dto.dart rename to mobile/openapi/lib/model/tag_create_dto.dart index dfa9b8cfc0..dd7e537a0a 100644 --- a/mobile/openapi/lib/model/update_tag_dto.dart +++ b/mobile/openapi/lib/model/tag_create_dto.dart @@ -10,10 +10,12 @@ part of openapi.api; -class UpdateTagDto { - /// Returns a new [UpdateTagDto] instance. - UpdateTagDto({ - this.name, +class TagCreateDto { + /// Returns a new [TagCreateDto] instance. + TagCreateDto({ + this.color, + required this.name, + this.parentId, }); /// @@ -22,49 +24,65 @@ class UpdateTagDto { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - String? name; + String? color; + + String name; + + String? parentId; @override - bool operator ==(Object other) => identical(this, other) || other is UpdateTagDto && - other.name == name; + bool operator ==(Object other) => identical(this, other) || other is TagCreateDto && + other.color == color && + other.name == name && + other.parentId == parentId; @override int get hashCode => // ignore: unnecessary_parenthesis - (name == null ? 0 : name!.hashCode); + (color == null ? 0 : color!.hashCode) + + (name.hashCode) + + (parentId == null ? 0 : parentId!.hashCode); @override - String toString() => 'UpdateTagDto[name=$name]'; + String toString() => 'TagCreateDto[color=$color, name=$name, parentId=$parentId]'; Map toJson() { final json = {}; - if (this.name != null) { - json[r'name'] = this.name; + if (this.color != null) { + json[r'color'] = this.color; } else { - // json[r'name'] = null; + // json[r'color'] = null; + } + json[r'name'] = this.name; + if (this.parentId != null) { + json[r'parentId'] = this.parentId; + } else { + // json[r'parentId'] = null; } return json; } - /// Returns a new [UpdateTagDto] instance and imports its values from + /// Returns a new [TagCreateDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static UpdateTagDto? fromJson(dynamic value) { + static TagCreateDto? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return UpdateTagDto( - name: mapValueOfType(json, r'name'), + return TagCreateDto( + color: mapValueOfType(json, r'color'), + name: mapValueOfType(json, r'name')!, + parentId: mapValueOfType(json, r'parentId'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = UpdateTagDto.fromJson(row); + final value = TagCreateDto.fromJson(row); if (value != null) { result.add(value); } @@ -73,12 +91,12 @@ class UpdateTagDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = UpdateTagDto.fromJson(entry.value); + final value = TagCreateDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -87,14 +105,14 @@ class UpdateTagDto { return map; } - // maps a json object with a list of UpdateTagDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of TagCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = UpdateTagDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = TagCreateDto.listFromJson(entry.value, growable: growable,); } } return map; @@ -102,6 +120,7 @@ class UpdateTagDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'name', }; } diff --git a/mobile/openapi/lib/model/tag_response_dto.dart b/mobile/openapi/lib/model/tag_response_dto.dart index d371bd1c04..4f0a62a8b9 100644 --- a/mobile/openapi/lib/model/tag_response_dto.dart +++ b/mobile/openapi/lib/model/tag_response_dto.dart @@ -13,44 +13,66 @@ part of openapi.api; class TagResponseDto { /// Returns a new [TagResponseDto] instance. TagResponseDto({ + this.color, + required this.createdAt, required this.id, required this.name, - required this.type, - required this.userId, + required this.updatedAt, + required this.value, }); + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? color; + + DateTime createdAt; + String id; String name; - TagTypeEnum type; + DateTime updatedAt; - String userId; + String value; @override bool operator ==(Object other) => identical(this, other) || other is TagResponseDto && + other.color == color && + other.createdAt == createdAt && other.id == id && other.name == name && - other.type == type && - other.userId == userId; + other.updatedAt == updatedAt && + other.value == value; @override int get hashCode => // ignore: unnecessary_parenthesis + (color == null ? 0 : color!.hashCode) + + (createdAt.hashCode) + (id.hashCode) + (name.hashCode) + - (type.hashCode) + - (userId.hashCode); + (updatedAt.hashCode) + + (value.hashCode); @override - String toString() => 'TagResponseDto[id=$id, name=$name, type=$type, userId=$userId]'; + String toString() => 'TagResponseDto[color=$color, createdAt=$createdAt, id=$id, name=$name, updatedAt=$updatedAt, value=$value]'; Map toJson() { final json = {}; + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); json[r'id'] = this.id; json[r'name'] = this.name; - json[r'type'] = this.type; - json[r'userId'] = this.userId; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'value'] = this.value; return json; } @@ -62,10 +84,12 @@ class TagResponseDto { final json = value.cast(); return TagResponseDto( + color: mapValueOfType(json, r'color'), + createdAt: mapDateTime(json, r'createdAt', r'')!, id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, - type: TagTypeEnum.fromJson(json[r'type'])!, - userId: mapValueOfType(json, r'userId')!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + value: mapValueOfType(json, r'value')!, ); } return null; @@ -113,10 +137,11 @@ class TagResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'createdAt', 'id', 'name', - 'type', - 'userId', + 'updatedAt', + 'value', }; } diff --git a/mobile/openapi/lib/model/tag_type_enum.dart b/mobile/openapi/lib/model/tag_type_enum.dart deleted file mode 100644 index 3f2e723796..0000000000 --- a/mobile/openapi/lib/model/tag_type_enum.dart +++ /dev/null @@ -1,88 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class TagTypeEnum { - /// Instantiate a new enum with the provided [value]. - const TagTypeEnum._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const OBJECT = TagTypeEnum._(r'OBJECT'); - static const FACE = TagTypeEnum._(r'FACE'); - static const CUSTOM = TagTypeEnum._(r'CUSTOM'); - - /// List of all possible values in this [enum][TagTypeEnum]. - static const values = [ - OBJECT, - FACE, - CUSTOM, - ]; - - static TagTypeEnum? fromJson(dynamic value) => TagTypeEnumTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = TagTypeEnum.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [TagTypeEnum] to String, -/// and [decode] dynamic data back to [TagTypeEnum]. -class TagTypeEnumTypeTransformer { - factory TagTypeEnumTypeTransformer() => _instance ??= const TagTypeEnumTypeTransformer._(); - - const TagTypeEnumTypeTransformer._(); - - String encode(TagTypeEnum data) => data.value; - - /// Decodes a [dynamic value][data] to a TagTypeEnum. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - TagTypeEnum? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'OBJECT': return TagTypeEnum.OBJECT; - case r'FACE': return TagTypeEnum.FACE; - case r'CUSTOM': return TagTypeEnum.CUSTOM; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [TagTypeEnumTypeTransformer] instance. - static TagTypeEnumTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/create_tag_dto.dart b/mobile/openapi/lib/model/tag_update_dto.dart similarity index 57% rename from mobile/openapi/lib/model/create_tag_dto.dart rename to mobile/openapi/lib/model/tag_update_dto.dart index 31b194993d..661f65896e 100644 --- a/mobile/openapi/lib/model/create_tag_dto.dart +++ b/mobile/openapi/lib/model/tag_update_dto.dart @@ -10,58 +10,55 @@ part of openapi.api; -class CreateTagDto { - /// Returns a new [CreateTagDto] instance. - CreateTagDto({ - required this.name, - required this.type, +class TagUpdateDto { + /// Returns a new [TagUpdateDto] instance. + TagUpdateDto({ + this.color, }); - String name; - - TagTypeEnum type; + String? color; @override - bool operator ==(Object other) => identical(this, other) || other is CreateTagDto && - other.name == name && - other.type == type; + bool operator ==(Object other) => identical(this, other) || other is TagUpdateDto && + other.color == color; @override int get hashCode => // ignore: unnecessary_parenthesis - (name.hashCode) + - (type.hashCode); + (color == null ? 0 : color!.hashCode); @override - String toString() => 'CreateTagDto[name=$name, type=$type]'; + String toString() => 'TagUpdateDto[color=$color]'; Map toJson() { final json = {}; - json[r'name'] = this.name; - json[r'type'] = this.type; + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } return json; } - /// Returns a new [CreateTagDto] instance and imports its values from + /// Returns a new [TagUpdateDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static CreateTagDto? fromJson(dynamic value) { + static TagUpdateDto? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return CreateTagDto( - name: mapValueOfType(json, r'name')!, - type: TagTypeEnum.fromJson(json[r'type'])!, + return TagUpdateDto( + color: mapValueOfType(json, r'color'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = CreateTagDto.fromJson(row); + final value = TagUpdateDto.fromJson(row); if (value != null) { result.add(value); } @@ -70,12 +67,12 @@ class CreateTagDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = CreateTagDto.fromJson(entry.value); + final value = TagUpdateDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -84,14 +81,14 @@ class CreateTagDto { return map; } - // maps a json object with a list of CreateTagDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of TagUpdateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = CreateTagDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = TagUpdateDto.listFromJson(entry.value, growable: growable,); } } return map; @@ -99,8 +96,6 @@ class CreateTagDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'name', - 'type', }; } diff --git a/mobile/openapi/lib/model/tag_upsert_dto.dart b/mobile/openapi/lib/model/tag_upsert_dto.dart new file mode 100644 index 0000000000..941d25b6ae --- /dev/null +++ b/mobile/openapi/lib/model/tag_upsert_dto.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagUpsertDto { + /// Returns a new [TagUpsertDto] instance. + TagUpsertDto({ + this.tags = const [], + }); + + List tags; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagUpsertDto && + _deepEquality.equals(other.tags, tags); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (tags.hashCode); + + @override + String toString() => 'TagUpsertDto[tags=$tags]'; + + Map toJson() { + final json = {}; + json[r'tags'] = this.tags; + return json; + } + + /// Returns a new [TagUpsertDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagUpsertDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagUpsertDto( + tags: json[r'tags'] is Iterable + ? (json[r'tags'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagUpsertDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagUpsertDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagUpsertDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagUpsertDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'tags', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2137bf7b11..4d80353177 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6169,7 +6169,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTagDto" + "$ref": "#/components/schemas/TagCreateDto" } } }, @@ -6201,6 +6201,91 @@ "tags": [ "Tags" ] + }, + "put": { + "operationId": "upsertTags", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagUpsertDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/TagResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Tags" + ] + } + }, + "/tags/assets": { + "put": { + "operationId": "bulkTagAssets", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagBulkAssetsDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagBulkAssetsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Tags" + ] } }, "/tags/{id}": { @@ -6218,7 +6303,7 @@ } ], "responses": { - "200": { + "204": { "description": "" } }, @@ -6277,7 +6362,7 @@ "Tags" ] }, - "patch": { + "put": { "operationId": "updateTag", "parameters": [ { @@ -6294,7 +6379,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateTagDto" + "$ref": "#/components/schemas/TagUpdateDto" } } }, @@ -6346,7 +6431,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetIdsDto" + "$ref": "#/components/schemas/BulkIdsDto" } } }, @@ -6358,50 +6443,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/AssetIdsResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Tags" - ] - }, - "get": { - "operationId": "getTagAssets", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" + "$ref": "#/components/schemas/BulkIdResponseDto" }, "type": "array" } @@ -6442,7 +6484,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetIdsDto" + "$ref": "#/components/schemas/BulkIdsDto" } } }, @@ -6454,7 +6496,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/AssetIdsResponseDto" + "$ref": "#/components/schemas/BulkIdResponseDto" }, "type": "array" } @@ -6549,6 +6591,15 @@ "$ref": "#/components/schemas/TimeBucketSize" } }, + { + "name": "tagId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "timeBucket", "required": true, @@ -6684,6 +6735,15 @@ "$ref": "#/components/schemas/TimeBucketSize" } }, + { + "name": "tagId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "userId", "required": false, @@ -8685,21 +8745,6 @@ ], "type": "object" }, - "CreateTagDto": { - "properties": { - "name": { - "type": "string" - }, - "type": { - "$ref": "#/components/schemas/TagTypeEnum" - } - }, - "required": [ - "name", - "type" - ], - "type": "object" - }, "DownloadArchiveInfo": { "properties": { "assetIds": { @@ -10053,6 +10098,7 @@ "tag.read", "tag.update", "tag.delete", + "tag.asset", "admin.user.create", "admin.user.read", "admin.user.update", @@ -11848,36 +11894,113 @@ ], "type": "object" }, + "TagBulkAssetsDto": { + "properties": { + "assetIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "assetIds", + "tagIds" + ], + "type": "object" + }, + "TagBulkAssetsResponseDto": { + "properties": { + "count": { + "type": "integer" + } + }, + "required": [ + "count" + ], + "type": "object" + }, + "TagCreateDto": { + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parentId": { + "format": "uuid", + "nullable": true, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "TagResponseDto": { "properties": { + "color": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, "id": { "type": "string" }, "name": { "type": "string" }, - "type": { - "$ref": "#/components/schemas/TagTypeEnum" + "updatedAt": { + "format": "date-time", + "type": "string" }, - "userId": { + "value": { "type": "string" } }, "required": [ + "createdAt", "id", "name", - "type", - "userId" + "updatedAt", + "value" ], "type": "object" }, - "TagTypeEnum": { - "enum": [ - "OBJECT", - "FACE", - "CUSTOM" + "TagUpdateDto": { + "properties": { + "color": { + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "TagUpsertDto": { + "properties": { + "tags": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tags" ], - "type": "string" + "type": "object" }, "TimeBucketResponseDto": { "properties": { @@ -12021,14 +12144,6 @@ ], "type": "object" }, - "UpdateTagDto": { - "properties": { - "name": { - "type": "string" - } - }, - "type": "object" - }, "UsageByUserDto": { "properties": { "photos": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index bf0c63c2b8..3fdcf33757 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -198,10 +198,12 @@ export type AssetStackResponseDto = { primaryAssetId: string; }; export type TagResponseDto = { + color?: string; + createdAt: string; id: string; name: string; - "type": TagTypeEnum; - userId: string; + updatedAt: string; + value: string; }; export type AssetResponseDto = { /** base64 encoded sha1 hash */ @@ -1171,12 +1173,23 @@ export type ReverseGeocodingStateResponseDto = { lastImportFileName: string | null; lastUpdate: string | null; }; -export type CreateTagDto = { +export type TagCreateDto = { + color?: string; name: string; - "type": TagTypeEnum; + parentId?: string | null; }; -export type UpdateTagDto = { - name?: string; +export type TagUpsertDto = { + tags: string[]; +}; +export type TagBulkAssetsDto = { + assetIds: string[]; + tagIds: string[]; +}; +export type TagBulkAssetsResponseDto = { + count: number; +}; +export type TagUpdateDto = { + color?: string | null; }; export type TimeBucketResponseDto = { count: number; @@ -2835,8 +2848,8 @@ export function getAllTags(opts?: Oazapfts.RequestOpts) { ...opts })); } -export function createTag({ createTagDto }: { - createTagDto: CreateTagDto; +export function createTag({ tagCreateDto }: { + tagCreateDto: TagCreateDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; @@ -2844,7 +2857,31 @@ export function createTag({ createTagDto }: { }>("/tags", oazapfts.json({ ...opts, method: "POST", - body: createTagDto + body: tagCreateDto + }))); +} +export function upsertTags({ tagUpsertDto }: { + tagUpsertDto: TagUpsertDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TagResponseDto[]; + }>("/tags", oazapfts.json({ + ...opts, + method: "PUT", + body: tagUpsertDto + }))); +} +export function bulkTagAssets({ tagBulkAssetsDto }: { + tagBulkAssetsDto: TagBulkAssetsDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TagBulkAssetsResponseDto; + }>("/tags/assets", oazapfts.json({ + ...opts, + method: "PUT", + body: tagBulkAssetsDto }))); } export function deleteTag({ id }: { @@ -2865,56 +2902,46 @@ export function getTagById({ id }: { ...opts })); } -export function updateTag({ id, updateTagDto }: { +export function updateTag({ id, tagUpdateDto }: { id: string; - updateTagDto: UpdateTagDto; + tagUpdateDto: TagUpdateDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: TagResponseDto; }>(`/tags/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, - method: "PATCH", - body: updateTagDto + method: "PUT", + body: tagUpdateDto }))); } -export function untagAssets({ id, assetIdsDto }: { +export function untagAssets({ id, bulkIdsDto }: { id: string; - assetIdsDto: AssetIdsDto; + bulkIdsDto: BulkIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetIdsResponseDto[]; + data: BulkIdResponseDto[]; }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "DELETE", - body: assetIdsDto + body: bulkIdsDto }))); } -export function getTagAssets({ id }: { +export function tagAssets({ id, bulkIdsDto }: { id: string; + bulkIdsDto: BulkIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetResponseDto[]; - }>(`/tags/${encodeURIComponent(id)}/assets`, { - ...opts - })); -} -export function tagAssets({ id, assetIdsDto }: { - id: string; - assetIdsDto: AssetIdsDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AssetIdsResponseDto[]; + data: BulkIdResponseDto[]; }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "PUT", - body: assetIdsDto + body: bulkIdsDto }))); } -export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; @@ -2923,6 +2950,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order?: AssetOrder; personId?: string; size: TimeBucketSize; + tagId?: string; timeBucket: string; userId?: string; withPartners?: boolean; @@ -2940,6 +2968,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, + tagId, timeBucket, userId, withPartners, @@ -2948,7 +2977,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, ...opts })); } -export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; @@ -2957,6 +2986,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key order?: AssetOrder; personId?: string; size: TimeBucketSize; + tagId?: string; userId?: string; withPartners?: boolean; withStacked?: boolean; @@ -2973,6 +3003,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key order, personId, size, + tagId, userId, withPartners, withStacked @@ -3162,11 +3193,6 @@ export enum AlbumUserRole { Editor = "editor", Viewer = "viewer" } -export enum TagTypeEnum { - Object = "OBJECT", - Face = "FACE", - Custom = "CUSTOM" -} export enum AssetTypeEnum { Image = "IMAGE", Video = "VIDEO", @@ -3257,6 +3283,7 @@ export enum Permission { TagRead = "tag.read", TagUpdate = "tag.update", TagDelete = "tag.delete", + TagAsset = "tag.asset", AdminUserCreate = "admin.user.create", AdminUserRead = "admin.user.read", AdminUserUpdate = "admin.user.update", diff --git a/server/src/controllers/tag.controller.ts b/server/src/controllers/tag.controller.ts index 8b646400cc..cf6b8ac695 100644 --- a/server/src/controllers/tag.controller.ts +++ b/server/src/controllers/tag.controller.ts @@ -1,10 +1,15 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { AssetIdsDto } from 'src/dtos/asset.dto'; +import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { CreateTagDto, TagResponseDto, UpdateTagDto } from 'src/dtos/tag.dto'; +import { + TagBulkAssetsDto, + TagBulkAssetsResponseDto, + TagCreateDto, + TagResponseDto, + TagUpdateDto, + TagUpsertDto, +} from 'src/dtos/tag.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TagService } from 'src/services/tag.service'; @@ -17,7 +22,7 @@ export class TagController { @Post() @Authenticated({ permission: Permission.TAG_CREATE }) - createTag(@Auth() auth: AuthDto, @Body() dto: CreateTagDto): Promise { + createTag(@Auth() auth: AuthDto, @Body() dto: TagCreateDto): Promise { return this.service.create(auth, dto); } @@ -27,47 +32,54 @@ export class TagController { return this.service.getAll(auth); } + @Put() + @Authenticated({ permission: Permission.TAG_CREATE }) + upsertTags(@Auth() auth: AuthDto, @Body() dto: TagUpsertDto): Promise { + return this.service.upsert(auth, dto); + } + + @Put('assets') + @Authenticated({ permission: Permission.TAG_ASSET }) + bulkTagAssets(@Auth() auth: AuthDto, @Body() dto: TagBulkAssetsDto): Promise { + return this.service.bulkTagAssets(auth, dto); + } + @Get(':id') @Authenticated({ permission: Permission.TAG_READ }) getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.getById(auth, id); + return this.service.get(auth, id); } - @Patch(':id') + @Put(':id') @Authenticated({ permission: Permission.TAG_UPDATE }) - updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateTagDto): Promise { + updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: TagUpdateDto): Promise { return this.service.update(auth, id, dto); } @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) @Authenticated({ permission: Permission.TAG_DELETE }) deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } - @Get(':id/assets') - @Authenticated() - getTagAssets(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.getAssets(auth, id); - } - @Put(':id/assets') - @Authenticated() + @Authenticated({ permission: Permission.TAG_ASSET }) tagAssets( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, - @Body() dto: AssetIdsDto, - ): Promise { + @Body() dto: BulkIdsDto, + ): Promise { return this.service.addAssets(auth, id, dto); } @Delete(':id/assets') - @Authenticated() + @Authenticated({ permission: Permission.TAG_ASSET }) untagAssets( @Auth() auth: AuthDto, - @Body() dto: AssetIdsDto, + @Body() dto: BulkIdsDto, @Param() { id }: UUIDParamDto, - ): Promise { + ): Promise { return this.service.removeAssets(auth, id, dto); } } diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index caeae2971a..463ab119a6 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -140,7 +140,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, - tags: entity.tags?.map(mapTag), + tags: entity.tags?.map((tag) => mapTag(tag)), people: peopleWithFaces(entity.faces), unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), checksum: entity.checksum.toString('base64'), diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index 1094d70df3..40c5b176ff 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -1,38 +1,64 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { TagEntity, TagType } from 'src/entities/tag.entity'; -import { Optional } from 'src/validation'; +import { Transform } from 'class-transformer'; +import { IsHexColor, IsNotEmpty, IsString } from 'class-validator'; +import { TagEntity } from 'src/entities/tag.entity'; +import { Optional, ValidateUUID } from 'src/validation'; -export class CreateTagDto { +export class TagCreateDto { @IsString() @IsNotEmpty() name!: string; - @IsEnum(TagType) - @IsNotEmpty() - @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType }) - type!: TagType; + @ValidateUUID({ optional: true, nullable: true }) + parentId?: string | null; + + @IsHexColor() + @Optional({ nullable: true, emptyToNull: true }) + color?: string; } -export class UpdateTagDto { - @IsString() - @Optional() - name?: string; +export class TagUpdateDto { + @Optional({ nullable: true, emptyToNull: true }) + @IsHexColor() + @Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value)) + color?: string | null; +} + +export class TagUpsertDto { + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + tags!: string[]; +} + +export class TagBulkAssetsDto { + @ValidateUUID({ each: true }) + tagIds!: string[]; + + @ValidateUUID({ each: true }) + assetIds!: string[]; +} + +export class TagBulkAssetsResponseDto { + @ApiProperty({ type: 'integer' }) + count!: number; } export class TagResponseDto { id!: string; - @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType }) - type!: string; name!: string; - userId!: string; + value!: string; + createdAt!: Date; + updatedAt!: Date; + color?: string; } export function mapTag(entity: TagEntity): TagResponseDto { return { id: entity.id, - type: entity.type, - name: entity.name, - userId: entity.userId, + name: entity.value.split('/').at(-1) as string, + value: entity.value, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + color: entity.color ?? undefined, }; } diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index 8803f24fc4..dd7a01df35 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -19,6 +19,9 @@ export class TimeBucketDto { @ValidateUUID({ optional: true }) personId?: string; + @ValidateUUID({ optional: true }) + tagId?: string; + @ValidateBoolean({ optional: true }) isArchived?: boolean; diff --git a/server/src/entities/tag.entity.ts b/server/src/entities/tag.entity.ts index 93edcb0555..940b446aea 100644 --- a/server/src/entities/tag.entity.ts +++ b/server/src/entities/tag.entity.ts @@ -1,45 +1,48 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { Column, Entity, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + ManyToMany, + ManyToOne, + PrimaryGeneratedColumn, + Tree, + TreeChildren, + TreeParent, + UpdateDateColumn, +} from 'typeorm'; @Entity('tags') -@Unique('UQ_tag_name_userId', ['name', 'userId']) +@Tree('closure-table') export class TagEntity { @PrimaryGeneratedColumn('uuid') id!: string; - @Column() - type!: TagType; + @Column({ unique: true }) + value!: string; - @Column() - name!: string; + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; - @ManyToOne(() => UserEntity, (user) => user.tags) - user!: UserEntity; + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ type: 'varchar', nullable: true, default: null }) + color!: string | null; + + @TreeParent({ onDelete: 'CASCADE' }) + parent?: TagEntity; + + @TreeChildren() + children?: TagEntity[]; + + @ManyToOne(() => UserEntity, (user) => user.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + user?: UserEntity; @Column() userId!: string; - @Column({ type: 'uuid', comment: 'The new renamed tagId', nullable: true }) - renameTagId!: string | null; - - @ManyToMany(() => AssetEntity, (asset) => asset.tags) - assets!: AssetEntity[]; -} - -export enum TagType { - /** - * Tag that is detected by the ML model for object detection will use this type - */ - OBJECT = 'OBJECT', - - /** - * Face that is detected by the ML model for facial detection (TBD/NOT YET IMPLEMENTED) will use this type - */ - FACE = 'FACE', - - /** - * Tag that is created by the user will use this type - */ - CUSTOM = 'CUSTOM', + @ManyToMany(() => AssetEntity, (asset) => asset.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + assets?: AssetEntity[]; } diff --git a/server/src/enum.ts b/server/src/enum.ts index 25ccbf961e..9cd5c189e8 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -130,6 +130,7 @@ export enum Permission { TAG_READ = 'tag.read', TAG_UPDATE = 'tag.update', TAG_DELETE = 'tag.delete', + TAG_ASSET = 'tag.asset', ADMIN_USER_CREATE = 'admin.user.create', ADMIN_USER_READ = 'admin.user.read', diff --git a/server/src/interfaces/access.interface.ts b/server/src/interfaces/access.interface.ts index 2dcf9d6b94..d8d7b4e807 100644 --- a/server/src/interfaces/access.interface.ts +++ b/server/src/interfaces/access.interface.ts @@ -46,4 +46,8 @@ export interface IAccessRepository { stack: { checkOwnerAccess(userId: string, stackIds: Set): Promise>; }; + + tag: { + checkOwnerAccess(userId: string, tagIds: Set): Promise>; + }; } diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 9f9218a3e3..e323d98640 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -51,6 +51,7 @@ export interface AssetBuilderOptions { isTrashed?: boolean; isDuplicate?: boolean; albumId?: string; + tagId?: string; personId?: string; userIds?: string[]; withStacked?: boolean; diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 609f42cc32..bb2b0d9ab4 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -17,6 +17,10 @@ type EmitEventMap = { 'album.update': [{ id: string; updatedBy: string }]; 'album.invite': [{ id: string; userId: string }]; + // tag events + 'asset.tag': [{ assetId: string }]; + 'asset.untag': [{ assetId: string }]; + // user events 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; }; diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index b2ac5ec6f1..bc780398ea 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -155,6 +155,7 @@ export interface ISidecarWriteJob extends IEntityJob { latitude?: number; longitude?: number; rating?: number; + tags?: true; } export interface IDeferrableJob extends IEntityJob { diff --git a/server/src/interfaces/tag.interface.ts b/server/src/interfaces/tag.interface.ts index 8071461dfc..f9f3784f06 100644 --- a/server/src/interfaces/tag.interface.ts +++ b/server/src/interfaces/tag.interface.ts @@ -1,17 +1,19 @@ -import { AssetEntity } from 'src/entities/asset.entity'; import { TagEntity } from 'src/entities/tag.entity'; +import { IBulkAsset } from 'src/utils/asset.util'; export const ITagRepository = 'ITagRepository'; -export interface ITagRepository { - getById(userId: string, tagId: string): Promise; +export type AssetTagItem = { assetId: string; tagId: string }; + +export interface ITagRepository extends IBulkAsset { getAll(userId: string): Promise; + getByValue(userId: string, value: string): Promise; + create(tag: Partial): Promise; - update(tag: Partial): Promise; - remove(tag: TagEntity): Promise; - hasName(userId: string, name: string): Promise; - hasAsset(userId: string, tagId: string, assetId: string): Promise; - getAssets(userId: string, tagId: string): Promise; - addAssets(userId: string, tagId: string, assetIds: string[]): Promise; - removeAssets(userId: string, tagId: string, assetIds: string[]): Promise; + get(id: string): Promise; + update(tag: { id: string } & Partial): Promise; + delete(id: string): Promise; + + upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise; + upsertAssetIds(items: AssetTagItem[]): Promise; } diff --git a/server/src/migrations/1724790460210-NestedTagTable.ts b/server/src/migrations/1724790460210-NestedTagTable.ts new file mode 100644 index 0000000000..dfda9a6d7a --- /dev/null +++ b/server/src/migrations/1724790460210-NestedTagTable.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class NestedTagTable1724790460210 implements MigrationInterface { + name = 'NestedTagTable1724790460210' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('TRUNCATE TABLE "tags" CASCADE'); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_tag_name_userId"`); + await queryRunner.query(`CREATE TABLE "tags_closure" ("id_ancestor" uuid NOT NULL, "id_descendant" uuid NOT NULL, CONSTRAINT "PK_eab38eb12a3ec6df8376c95477c" PRIMARY KEY ("id_ancestor", "id_descendant"))`); + await queryRunner.query(`CREATE INDEX "IDX_15fbcbc67663c6bfc07b354c22" ON "tags_closure" ("id_ancestor") `); + await queryRunner.query(`CREATE INDEX "IDX_b1a2a7ed45c29179b5ad51548a" ON "tags_closure" ("id_descendant") `); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "renameTagId"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "type"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "name"`); + await queryRunner.query(`ALTER TABLE "tags" ADD "value" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451" UNIQUE ("value")`); + await queryRunner.query(`ALTER TABLE "tags" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "tags" ADD "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "tags" ADD "color" character varying`); + await queryRunner.query(`ALTER TABLE "tags" ADD "parentId" uuid`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99" FOREIGN KEY ("parentId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c" FOREIGN KEY ("id_ancestor") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1" FOREIGN KEY ("id_descendant") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`); + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`); + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tags_closure" DROP CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1"`); + await queryRunner.query(`ALTER TABLE "tags_closure" DROP CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "parentId"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "color"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "updatedAt"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "createdAt"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "value"`); + await queryRunner.query(`ALTER TABLE "tags" ADD "name" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "tags" ADD "type" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "tags" ADD "renameTagId" uuid`); + await queryRunner.query(`DROP INDEX "public"."IDX_b1a2a7ed45c29179b5ad51548a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_15fbcbc67663c6bfc07b354c22"`); + await queryRunner.query(`DROP TABLE "tags_closure"`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_tag_name_userId" UNIQUE ("name", "userId")`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 48a93f546b..ad57eac0ad 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -259,6 +259,17 @@ WHERE AND ("StackEntity"."ownerId" = $2) ) +-- AccessRepository.tag.checkOwnerAccess +SELECT + "TagEntity"."id" AS "TagEntity_id" +FROM + "tags" "TagEntity" +WHERE + ( + ("TagEntity"."id" IN ($1)) + AND ("TagEntity"."userId" = $2) + ) + -- AccessRepository.timeline.checkPartnerAccess SELECT "partner"."sharedById" AS "partner_sharedById", diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index b08130b183..ba52f7d148 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -184,10 +184,12 @@ SELECT "AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags", "AssetEntity__AssetEntity_smartInfo"."objects" AS "AssetEntity__AssetEntity_smartInfo_objects", "AssetEntity__AssetEntity_tags"."id" AS "AssetEntity__AssetEntity_tags_id", - "AssetEntity__AssetEntity_tags"."type" AS "AssetEntity__AssetEntity_tags_type", - "AssetEntity__AssetEntity_tags"."name" AS "AssetEntity__AssetEntity_tags_name", + "AssetEntity__AssetEntity_tags"."value" AS "AssetEntity__AssetEntity_tags_value", + "AssetEntity__AssetEntity_tags"."createdAt" AS "AssetEntity__AssetEntity_tags_createdAt", + "AssetEntity__AssetEntity_tags"."updatedAt" AS "AssetEntity__AssetEntity_tags_updatedAt", + "AssetEntity__AssetEntity_tags"."color" AS "AssetEntity__AssetEntity_tags_color", "AssetEntity__AssetEntity_tags"."userId" AS "AssetEntity__AssetEntity_tags_userId", - "AssetEntity__AssetEntity_tags"."renameTagId" AS "AssetEntity__AssetEntity_tags_renameTagId", + "AssetEntity__AssetEntity_tags"."parentId" AS "AssetEntity__AssetEntity_tags_parentId", "AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id", "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId", "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId", diff --git a/server/src/queries/tag.repository.sql b/server/src/queries/tag.repository.sql new file mode 100644 index 0000000000..ba1aac82b3 --- /dev/null +++ b/server/src/queries/tag.repository.sql @@ -0,0 +1,30 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- TagRepository.getAssetIds +SELECT + "tag_asset"."assetsId" AS "assetId" +FROM + "tag_asset" "tag_asset" +WHERE + "tag_asset"."tagsId" = $1 + AND "tag_asset"."assetsId" IN ($2) + +-- TagRepository.addAssetIds +INSERT INTO + "tag_asset" ("assetsId", "tagsId") +VALUES + ($1, $2) + +-- TagRepository.removeAssetIds +DELETE FROM "tag_asset" +WHERE + ( + "tagsId" = $1 + AND "assetsId" IN ($2) + ) + +-- TagRepository.upsertAssetIds +INSERT INTO + "tag_asset" ("assetsId", "tagsId") +VALUES + ($1, $2) diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 6dd6d47a46..f6921ffe27 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -12,6 +12,7 @@ import { PersonEntity } from 'src/entities/person.entity'; import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { StackEntity } from 'src/entities/stack.entity'; +import { TagEntity } from 'src/entities/tag.entity'; import { AlbumUserRole } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { Instrumentation } from 'src/utils/instrumentation'; @@ -25,6 +26,7 @@ type IMemoryAccess = IAccessRepository['memory']; type IPersonAccess = IAccessRepository['person']; type IPartnerAccess = IAccessRepository['partner']; type IStackAccess = IAccessRepository['stack']; +type ITagAccess = IAccessRepository['tag']; type ITimelineAccess = IAccessRepository['timeline']; @Instrumentation() @@ -444,6 +446,28 @@ class PartnerAccess implements IPartnerAccess { } } +class TagAccess implements ITagAccess { + constructor(private tagRepository: Repository) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, tagIds: Set): Promise> { + if (tagIds.size === 0) { + return new Set(); + } + + return this.tagRepository + .find({ + select: { id: true }, + where: { + id: In([...tagIds]), + userId, + }, + }) + .then((tags) => new Set(tags.map((tag) => tag.id))); + } +} + export class AccessRepository implements IAccessRepository { activity: IActivityAccess; album: IAlbumAccess; @@ -453,6 +477,7 @@ export class AccessRepository implements IAccessRepository { person: IPersonAccess; partner: IPartnerAccess; stack: IStackAccess; + tag: ITagAccess; timeline: ITimelineAccess; constructor( @@ -467,6 +492,7 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository, @InjectRepository(SessionEntity) sessionRepository: Repository, @InjectRepository(StackEntity) stackRepository: Repository, + @InjectRepository(TagEntity) tagRepository: Repository, ) { this.activity = new ActivityAccess(activityRepository, albumRepository); this.album = new AlbumAccess(albumRepository, sharedLinkRepository); @@ -476,6 +502,7 @@ export class AccessRepository implements IAccessRepository { this.person = new PersonAccess(assetFaceRepository, personRepository); this.partner = new PartnerAccess(partnerRepository); this.stack = new StackAccess(stackRepository); + this.tag = new TagAccess(tagRepository); this.timeline = new TimelineAccess(partnerRepository); } } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 1a2a0474a1..dd526dd664 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -723,6 +723,15 @@ export class AssetRepository implements IAssetRepository { builder.andWhere('asset.type = :assetType', { assetType: options.assetType }); } + if (options.tagId) { + builder.innerJoin( + 'asset.tags', + 'asset_tags', + 'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)', + { tagId: options.tagId }, + ); + } + let stackJoined = false; if (options.exifInfo !== false) { diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 788b976357..7699d5897a 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -1,33 +1,36 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { AssetEntity } from 'src/entities/asset.entity'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { TagEntity } from 'src/entities/tag.entity'; -import { ITagRepository } from 'src/interfaces/tag.interface'; +import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Repository } from 'typeorm'; +import { DataSource, In, Repository } from 'typeorm'; @Instrumentation() @Injectable() export class TagRepository implements ITagRepository { constructor( - @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectDataSource() private dataSource: DataSource, @InjectRepository(TagEntity) private repository: Repository, ) {} - getById(userId: string, id: string): Promise { - return this.repository.findOne({ - where: { - id, - userId, - }, - relations: { - user: true, - }, - }); + get(id: string): Promise { + return this.repository.findOne({ where: { id } }); } - getAll(userId: string): Promise { - return this.repository.find({ where: { userId } }); + getByValue(userId: string, value: string): Promise { + return this.repository.findOne({ where: { userId, value } }); + } + + async getAll(userId: string): Promise { + const tags = await this.repository.find({ + where: { userId }, + order: { + value: 'ASC', + }, + }); + + return tags; } create(tag: Partial): Promise { @@ -38,89 +41,99 @@ export class TagRepository implements ITagRepository { return this.save(tag); } - async remove(tag: TagEntity): Promise { - await this.repository.remove(tag); + async delete(id: string): Promise { + await this.repository.delete(id); } - async getAssets(userId: string, tagId: string): Promise { - return this.assetRepository.find({ - where: { - tags: { - userId, - id: tagId, - }, - }, - relations: { - exifInfo: true, - tags: true, - faces: { - person: true, - }, - }, - order: { - createdAt: 'ASC', - }, - }); - } - - async addAssets(userId: string, id: string, assetIds: string[]): Promise { - for (const assetId of assetIds) { - const asset = await this.assetRepository.findOneOrFail({ - where: { - ownerId: userId, - id: assetId, - }, - relations: { - tags: true, - }, - }); - asset.tags.push({ id } as TagEntity); - await this.assetRepository.save(asset); + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @ChunkedSet({ paramIndex: 1 }) + async getAssetIds(tagId: string, assetIds: string[]): Promise> { + if (assetIds.length === 0) { + return new Set(); } + + const results = await this.dataSource + .createQueryBuilder() + .select('tag_asset.assetsId', 'assetId') + .from('tag_asset', 'tag_asset') + .where('"tag_asset"."tagsId" = :tagId', { tagId }) + .andWhere('"tag_asset"."assetsId" IN (:...assetIds)', { assetIds }) + .getRawMany<{ assetId: string }>(); + + return new Set(results.map(({ assetId }) => assetId)); } - async removeAssets(userId: string, id: string, assetIds: string[]): Promise { - for (const assetId of assetIds) { - const asset = await this.assetRepository.findOneOrFail({ - where: { - ownerId: userId, - id: assetId, - }, - relations: { - tags: true, - }, - }); - asset.tags = asset.tags.filter((tag) => tag.id !== id); - await this.assetRepository.save(asset); + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + async addAssetIds(tagId: string, assetIds: string[]): Promise { + if (assetIds.length === 0) { + return; } + + await this.dataSource.manager + .createQueryBuilder() + .insert() + .into('tag_asset', ['tagsId', 'assetsId']) + .values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId }))) + .execute(); } - hasAsset(userId: string, tagId: string, assetId: string): Promise { - return this.repository.exists({ - where: { - id: tagId, - userId, - assets: { - id: assetId, - }, - }, - relations: { - assets: true, - }, + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @Chunked({ paramIndex: 1 }) + async removeAssetIds(tagId: string, assetIds: string[]): Promise { + if (assetIds.length === 0) { + return; + } + + await this.dataSource + .createQueryBuilder() + .delete() + .from('tag_asset') + .where({ + tagsId: tagId, + assetsId: In(assetIds), + }) + .execute(); + } + + @GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagId: DummyValue.UUID }]] }) + @Chunked() + async upsertAssetIds(items: AssetTagItem[]): Promise { + if (items.length === 0) { + return []; + } + + const { identifiers } = await this.dataSource + .createQueryBuilder() + .insert() + .into('tag_asset', ['assetsId', 'tagsId']) + .values(items.map(({ assetId, tagId }) => ({ assetsId: assetId, tagsId: tagId }))) + .execute(); + + return (identifiers as Array<{ assetsId: string; tagsId: string }>).map(({ assetsId, tagsId }) => ({ + assetId: assetsId, + tagId: tagsId, + })); + } + + async upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }) { + await this.dataSource.transaction(async (manager) => { + await manager.createQueryBuilder().delete().from('tag_asset').where({ assetsId: assetId }).execute(); + + if (tagIds.length === 0) { + return; + } + + await manager + .createQueryBuilder() + .insert() + .into('tag_asset', ['tagsId', 'assetsId']) + .values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId }))) + .execute(); }); } - hasName(userId: string, name: string): Promise { - return this.repository.exists({ - where: { - name, - userId, - }, - }); - } - - private async save(tag: Partial): Promise { - const { id } = await this.repository.save(tag); - return this.repository.findOneOrFail({ where: { id }, relations: { user: true } }); + private async save(partial: Partial): Promise { + const { id } = await this.repository.save(partial); + return this.repository.findOneOrFail({ where: { id } }); } } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 6585b8c2ee..cb89de184a 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -18,11 +18,13 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { MetadataService, Orientation } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; +import { tagStub } from 'test/fixtures/tag.stub'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; @@ -37,6 +39,7 @@ import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked } from 'vitest'; @@ -56,6 +59,7 @@ describe(MetadataService.name, () => { let databaseMock: Mocked; let userMock: Mocked; let loggerMock: Mocked; + let tagMock: Mocked; let sut: MetadataService; beforeEach(() => { @@ -74,6 +78,7 @@ describe(MetadataService.name, () => { databaseMock = newDatabaseRepositoryMock(); userMock = newUserRepositoryMock(); loggerMock = newLoggerRepositoryMock(); + tagMock = newTagRepositoryMock(); sut = new MetadataService( albumMock, @@ -89,6 +94,7 @@ describe(MetadataService.name, () => { personMock, storageMock, systemMock, + tagMock, userMock, loggerMock, ); @@ -356,6 +362,72 @@ describe(MetadataService.name, () => { expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null })); }); + it('should extract tags from TagsList', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent'] }); + tagMock.getByValue.mockResolvedValue(null); + tagMock.create.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + }); + + it('should extract hierarchy from TagsList', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent/Child'] }); + tagMock.getByValue.mockResolvedValue(null); + tagMock.create.mockResolvedValueOnce(tagStub.parent); + tagMock.create.mockResolvedValueOnce(tagStub.child); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.create).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.create).toHaveBeenNthCalledWith(2, { + userId: 'user-id', + value: 'Parent/Child', + parent: tagStub.parent, + }); + }); + + it('should extract tags from Keywords as a string', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent' }); + tagMock.getByValue.mockResolvedValue(null); + tagMock.create.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + }); + + it('should extract tags from Keywords as a list', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent'] }); + tagMock.getByValue.mockResolvedValue(null); + tagMock.create.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + }); + + it('should extract hierarchal tags from Keywords', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent/Child' }); + tagMock.getByValue.mockResolvedValue(null); + tagMock.create.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.create).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.create).toHaveBeenNthCalledWith(2, { + userId: 'user-id', + value: 'Parent/Child', + parent: tagStub.parent, + }); + }); + it('should not apply motion photos if asset is video', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 3c938a4e59..875414d84d 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -22,8 +22,8 @@ import { IEntityJob, IJobRepository, ISidecarWriteJob, - JOBS_ASSET_PAGINATION_SIZE, JobName, + JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName, } from 'src/interfaces/job.interface'; @@ -35,8 +35,10 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { usePagination } from 'src/utils/pagination'; +import { upsertTags } from 'src/utils/tag'; /** look for a date from these tags (in order) */ const EXIF_DATE_TAGS: Array = [ @@ -105,6 +107,7 @@ export class MetadataService { @Inject(IPersonRepository) personRepository: IPersonRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, + @Inject(ITagRepository) private tagRepository: ITagRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { @@ -217,24 +220,27 @@ export class MetadataService { return JobStatus.FAILED; } - const { exifData, tags } = await this.exifData(asset); + const { exifData, exifTags } = await this.exifData(asset); if (asset.type === AssetType.VIDEO) { await this.applyVideoMetadata(asset, exifData); } - await this.applyMotionPhotos(asset, tags); + await this.applyMotionPhotos(asset, exifTags); await this.applyReverseGeocoding(asset, exifData); + await this.applyTagList(asset, exifTags); + await this.assetRepository.upsertExif(exifData); const dateTimeOriginal = exifData.dateTimeOriginal; let localDateTime = dateTimeOriginal ?? undefined; - const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0; + const timeZoneOffset = tzOffset(firstDateTime(exifTags as Tags)) ?? 0; if (dateTimeOriginal && timeZoneOffset) { localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000); } + await this.assetRepository.update({ id: asset.id, duration: asset.duration, @@ -278,22 +284,35 @@ export class MetadataService { return this.processSidecar(id, false); } + @OnEmit({ event: 'asset.tag' }) + async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) { + await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); + } + + @OnEmit({ event: 'asset.untag' }) + async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) { + await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); + } + async handleSidecarWrite(job: ISidecarWriteJob): Promise { - const { id, description, dateTimeOriginal, latitude, longitude, rating } = job; - const [asset] = await this.assetRepository.getByIds([id]); + const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job; + const [asset] = await this.assetRepository.getByIds([id], { tags: true }); if (!asset) { return JobStatus.FAILED; } + const tagsList = (asset.tags || []).map((tag) => tag.value); + const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`; - const exif = _.omitBy( - { + const exif = _.omitBy( + { Description: description, ImageDescription: description, DateTimeOriginal: dateTimeOriginal, GPSLatitude: latitude, GPSLongitude: longitude, Rating: rating, + TagsList: tags ? tagsList : undefined, }, _.isUndefined, ); @@ -332,6 +351,28 @@ export class MetadataService { } } + private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) { + const tags: string[] = []; + + if (exifTags.TagsList) { + tags.push(...exifTags.TagsList); + } + + if (exifTags.Keywords) { + let keywords = exifTags.Keywords; + if (typeof keywords === 'string') { + keywords = [keywords]; + } + tags.push(...keywords); + } + + if (tags.length > 0) { + const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags }); + const tagIds = results.map((tag) => tag.id); + await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds }); + } + } + private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) { if (asset.type !== AssetType.IMAGE) { return; @@ -466,7 +507,7 @@ export class MetadataService { private async exifData( asset: AssetEntity, - ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; tags: ImmichTags }> { + ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> { const stats = await this.storageRepository.stat(asset.originalPath); const mediaTags = await this.repository.readTags(asset.originalPath); const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null; @@ -479,38 +520,38 @@ export class MetadataService { } } - const tags = { ...mediaTags, ...sidecarTags }; + const exifTags = { ...mediaTags, ...sidecarTags }; - this.logger.verbose('Exif Tags', tags); + this.logger.verbose('Exif Tags', exifTags); const exifData = { // altitude: tags.GPSAltitude ?? null, assetId: asset.id, - bitsPerSample: this.getBitsPerSample(tags), - colorspace: tags.ColorSpace ?? null, - dateTimeOriginal: this.getDateTimeOriginal(tags) ?? asset.fileCreatedAt, - description: String(tags.ImageDescription || tags.Description || '').trim(), - exifImageHeight: validate(tags.ImageHeight), - exifImageWidth: validate(tags.ImageWidth), - exposureTime: tags.ExposureTime ?? null, + bitsPerSample: this.getBitsPerSample(exifTags), + colorspace: exifTags.ColorSpace ?? null, + dateTimeOriginal: this.getDateTimeOriginal(exifTags) ?? asset.fileCreatedAt, + description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), + exifImageHeight: validate(exifTags.ImageHeight), + exifImageWidth: validate(exifTags.ImageWidth), + exposureTime: exifTags.ExposureTime ?? null, fileSizeInByte: stats.size, - fNumber: validate(tags.FNumber), - focalLength: validate(tags.FocalLength), - fps: validate(Number.parseFloat(tags.VideoFrameRate!)), - iso: validate(tags.ISO), - latitude: validate(tags.GPSLatitude), - lensModel: tags.LensModel ?? null, - livePhotoCID: (tags.ContentIdentifier || tags.MediaGroupUUID) ?? null, - autoStackId: this.getAutoStackId(tags), - longitude: validate(tags.GPSLongitude), - make: tags.Make ?? null, - model: tags.Model ?? null, - modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt, - orientation: validate(tags.Orientation)?.toString() ?? null, - profileDescription: tags.ProfileDescription || null, - projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null, - timeZone: tags.tz ?? null, - rating: tags.Rating ?? null, + fNumber: validate(exifTags.FNumber), + focalLength: validate(exifTags.FocalLength), + fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), + iso: validate(exifTags.ISO), + latitude: validate(exifTags.GPSLatitude), + lensModel: exifTags.LensModel ?? null, + livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, + autoStackId: this.getAutoStackId(exifTags), + longitude: validate(exifTags.GPSLongitude), + make: exifTags.Make ?? null, + model: exifTags.Model ?? null, + modifyDate: exifDate(exifTags.ModifyDate) ?? asset.fileModifiedAt, + orientation: validate(exifTags.Orientation)?.toString() ?? null, + profileDescription: exifTags.ProfileDescription || null, + projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, + timeZone: exifTags.tz ?? null, + rating: exifTags.Rating ?? null, }; if (exifData.latitude === 0 && exifData.longitude === 0) { @@ -519,7 +560,7 @@ export class MetadataService { exifData.longitude = null; } - return { exifData, tags }; + return { exifData, exifTags }; } private getAutoStackId(tags: ImmichTags | null): string | null { diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 4323c061e1..ffa7895cb4 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -1,21 +1,28 @@ import { BadRequestException } from '@nestjs/common'; -import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { TagType } from 'src/entities/tag.entity'; +import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { TagService } from 'src/services/tag.service'; -import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock'; import { Mocked } from 'vitest'; describe(TagService.name, () => { let sut: TagService; + let accessMock: IAccessRepositoryMock; + let eventMock: Mocked; let tagMock: Mocked; beforeEach(() => { + accessMock = newAccessRepositoryMock(); + eventMock = newEventRepositoryMock(); tagMock = newTagRepositoryMock(); - sut = new TagService(tagMock); + sut = new TagService(accessMock, eventMock, tagMock); + + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); }); it('should work', () => { @@ -30,148 +37,216 @@ describe(TagService.name, () => { }); }); - describe('getById', () => { + describe('get', () => { it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.getById(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + tagMock.get.mockResolvedValue(null); + await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); + expect(tagMock.get).toHaveBeenCalledWith('tag-1'); }); it('should return a tag for a user', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - await expect(sut.getById(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + tagMock.get.mockResolvedValue(tagStub.tag1); + await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1); + expect(tagMock.get).toHaveBeenCalledWith('tag-1'); + }); + }); + + describe('create', () => { + it('should throw an error for no parent tag access', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + await expect(sut.create(authStub.admin, { name: 'tag', parentId: 'tag-parent' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(tagMock.create).not.toHaveBeenCalled(); + }); + + it('should create a tag with a parent', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); + tagMock.create.mockResolvedValue(tagStub.tag1); + tagMock.get.mockResolvedValueOnce(tagStub.parent); + tagMock.get.mockResolvedValueOnce(tagStub.child); + await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined(); + expect(tagMock.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' })); + }); + + it('should handle invalid parent ids', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); + await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(tagMock.create).not.toHaveBeenCalled(); }); }); describe('create', () => { it('should throw an error for a duplicate tag', async () => { - tagMock.hasName.mockResolvedValue(true); - await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).rejects.toBeInstanceOf( - BadRequestException, - ); - expect(tagMock.hasName).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + tagMock.getByValue.mockResolvedValue(tagStub.tag1); + await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException); + expect(tagMock.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); expect(tagMock.create).not.toHaveBeenCalled(); }); it('should create a new tag', async () => { tagMock.create.mockResolvedValue(tagStub.tag1); - await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).resolves.toEqual( - tagResponseStub.tag1, - ); + await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1); expect(tagMock.create).toHaveBeenCalledWith({ userId: authStub.admin.user.id, - name: 'tag-1', - type: TagType.CUSTOM, + value: 'tag-1', }); }); }); describe('update', () => { - it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.remove).not.toHaveBeenCalled(); + it('should throw an error for no update permission', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(tagMock.update).not.toHaveBeenCalled(); }); it('should update a tag', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - tagMock.update.mockResolvedValue(tagStub.tag1); - await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).resolves.toEqual(tagResponseStub.tag1); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', name: 'tag-2' }); + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); + tagMock.update.mockResolvedValue(tagStub.color1); + await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1); + expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' }); + }); + }); + + describe('upsert', () => { + it('should upsert a new tag', async () => { + tagMock.create.mockResolvedValue(tagStub.parent); + await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined(); + expect(tagMock.create).toHaveBeenCalledWith({ + value: 'Parent', + userId: 'admin_id', + parentId: undefined, + }); + }); + + it('should upsert a nested tag', async () => { + tagMock.getByValue.mockResolvedValueOnce(null); + tagMock.create.mockResolvedValueOnce(tagStub.parent); + tagMock.create.mockResolvedValueOnce(tagStub.child); + await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined(); + expect(tagMock.create).toHaveBeenNthCalledWith(1, { + value: 'Parent', + userId: 'admin_id', + parentId: undefined, + }); + expect(tagMock.create).toHaveBeenNthCalledWith(2, { + value: 'Parent/Child', + userId: 'admin_id', + parent: expect.objectContaining({ id: 'tag-parent' }), + }); }); }); describe('remove', () => { it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.remove).not.toHaveBeenCalled(); + expect(tagMock.delete).not.toHaveBeenCalled(); }); it('should remove a tag', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); + tagMock.get.mockResolvedValue(tagStub.tag1); await sut.remove(authStub.admin, 'tag-1'); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.remove).toHaveBeenCalledWith(tagStub.tag1); + expect(tagMock.delete).toHaveBeenCalledWith('tag-1'); }); }); - describe('getAssets', () => { - it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.remove).not.toHaveBeenCalled(); + describe('bulkTagAssets', () => { + it('should handle invalid requests', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + tagMock.upsertAssetIds.mockResolvedValue([]); + await expect(sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({ + count: 0, + }); + expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([]); }); - it('should get the assets for a tag', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - tagMock.getAssets.mockResolvedValue([assetStub.image]); - await sut.getAssets(authStub.admin, 'tag-1'); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.getAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + it('should upsert records', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + tagMock.upsertAssetIds.mockResolvedValue([ + { tagId: 'tag-1', assetId: 'asset-1' }, + { tagId: 'tag-1', assetId: 'asset-2' }, + { tagId: 'tag-1', assetId: 'asset-3' }, + { tagId: 'tag-2', assetId: 'asset-1' }, + { tagId: 'tag-2', assetId: 'asset-2' }, + { tagId: 'tag-2', assetId: 'asset-3' }, + ]); + await expect( + sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1', 'tag-2'], assetIds: ['asset-1', 'asset-2', 'asset-3'] }), + ).resolves.toEqual({ + count: 6, + }); + expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([ + { tagId: 'tag-1', assetId: 'asset-1' }, + { tagId: 'tag-1', assetId: 'asset-2' }, + { tagId: 'tag-1', assetId: 'asset-3' }, + { tagId: 'tag-2', assetId: 'asset-1' }, + { tagId: 'tag-2', assetId: 'asset-2' }, + { tagId: 'tag-2', assetId: 'asset-3' }, + ]); }); }); describe('addAssets', () => { - it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.addAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( - BadRequestException, - ); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.addAssets).not.toHaveBeenCalled(); + it('should handle invalid ids', async () => { + tagMock.get.mockResolvedValue(null); + tagMock.getAssetIds.mockResolvedValue(new Set([])); + await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ + { id: 'asset-1', success: false, error: 'no_permission' }, + ]); + expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); + expect(tagMock.addAssetIds).not.toHaveBeenCalled(); }); - it('should reject duplicate asset ids and accept new ones', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1')); + it('should accept accept ids that are new and reject the rest', async () => { + tagMock.get.mockResolvedValue(tagStub.tag1); + tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1'])); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); await expect( sut.addAssets(authStub.admin, 'tag-1', { - assetIds: ['asset-1', 'asset-2'], + ids: ['asset-1', 'asset-2'], }), ).resolves.toEqual([ - { assetId: 'asset-1', success: false, error: AssetIdErrorReason.DUPLICATE }, - { assetId: 'asset-2', success: true }, + { id: 'asset-1', success: false, error: BulkIdErrorReason.DUPLICATE }, + { id: 'asset-2', success: true }, ]); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.hasAsset).toHaveBeenCalledTimes(2); - expect(tagMock.addAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-2']); + expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); + expect(tagMock.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']); }); }); describe('removeAssets', () => { it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.removeAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( - BadRequestException, - ); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.removeAssets).not.toHaveBeenCalled(); + tagMock.get.mockResolvedValue(null); + tagMock.getAssetIds.mockResolvedValue(new Set()); + await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ + { id: 'asset-1', success: false, error: 'not_found' }, + ]); }); it('should accept accept ids that are tagged and reject the rest', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1')); + tagMock.get.mockResolvedValue(tagStub.tag1); + tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1'])); await expect( sut.removeAssets(authStub.admin, 'tag-1', { - assetIds: ['asset-1', 'asset-2'], + ids: ['asset-1', 'asset-2'], }), ).resolves.toEqual([ - { assetId: 'asset-1', success: true }, - { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, + { id: 'asset-1', success: true }, + { id: 'asset-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.hasAsset).toHaveBeenCalledTimes(2); - expect(tagMock.removeAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-1']); + expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); + expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); }); }); }); diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index c04f9b14c4..97b0ef1be6 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -1,102 +1,145 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; -import { AssetIdsDto } from 'src/dtos/asset.dto'; +import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { CreateTagDto, TagResponseDto, UpdateTagDto, mapTag } from 'src/dtos/tag.dto'; -import { ITagRepository } from 'src/interfaces/tag.interface'; +import { + TagBulkAssetsDto, + TagBulkAssetsResponseDto, + TagCreateDto, + TagResponseDto, + TagUpdateDto, + TagUpsertDto, + mapTag, +} from 'src/dtos/tag.dto'; +import { TagEntity } from 'src/entities/tag.entity'; +import { Permission } from 'src/enum'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; +import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; +import { checkAccess, requireAccess } from 'src/utils/access'; +import { addAssets, removeAssets } from 'src/utils/asset.util'; +import { upsertTags } from 'src/utils/tag'; @Injectable() export class TagService { - constructor(@Inject(ITagRepository) private repository: ITagRepository) {} + constructor( + @Inject(IAccessRepository) private access: IAccessRepository, + @Inject(IEventRepository) private eventRepository: IEventRepository, + @Inject(ITagRepository) private repository: ITagRepository, + ) {} - getAll(auth: AuthDto) { - return this.repository.getAll(auth.user.id).then((tags) => tags.map((tag) => mapTag(tag))); + async getAll(auth: AuthDto) { + const tags = await this.repository.getAll(auth.user.id); + return tags.map((tag) => mapTag(tag)); } - async getById(auth: AuthDto, id: string): Promise { - const tag = await this.findOrFail(auth, id); + async get(auth: AuthDto, id: string): Promise { + await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [id] }); + const tag = await this.findOrFail(id); return mapTag(tag); } - async create(auth: AuthDto, dto: CreateTagDto) { - const duplicate = await this.repository.hasName(auth.user.id, dto.name); + async create(auth: AuthDto, dto: TagCreateDto) { + let parent: TagEntity | undefined; + if (dto.parentId) { + await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] }); + parent = (await this.repository.get(dto.parentId)) || undefined; + if (!parent) { + throw new BadRequestException('Tag not found'); + } + } + + const userId = auth.user.id; + const value = parent ? `${parent.value}/${dto.name}` : dto.name; + const duplicate = await this.repository.getByValue(userId, value); if (duplicate) { throw new BadRequestException(`A tag with that name already exists`); } - const tag = await this.repository.create({ - userId: auth.user.id, - name: dto.name, - type: dto.type, - }); + const tag = await this.repository.create({ userId, value, parent }); return mapTag(tag); } - async update(auth: AuthDto, id: string, dto: UpdateTagDto): Promise { - await this.findOrFail(auth, id); - const tag = await this.repository.update({ id, name: dto.name }); + async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise { + await requireAccess(this.access, { auth, permission: Permission.TAG_UPDATE, ids: [id] }); + + const { color } = dto; + const tag = await this.repository.update({ id, color }); return mapTag(tag); } + async upsert(auth: AuthDto, dto: TagUpsertDto) { + const tags = await upsertTags(this.repository, { userId: auth.user.id, tags: dto.tags }); + return tags.map((tag) => mapTag(tag)); + } + async remove(auth: AuthDto, id: string): Promise { - const tag = await this.findOrFail(auth, id); - await this.repository.remove(tag); + await requireAccess(this.access, { auth, permission: Permission.TAG_DELETE, ids: [id] }); + + // TODO sync tag changes for affected assets + + await this.repository.delete(id); } - async getAssets(auth: AuthDto, id: string): Promise { - await this.findOrFail(auth, id); - const assets = await this.repository.getAssets(auth.user.id, id); - return assets.map((asset) => mapAsset(asset)); - } + async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise { + const [tagIds, assetIds] = await Promise.all([ + checkAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }), + checkAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }), + ]); - async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { - await this.findOrFail(auth, id); - - const results: AssetIdsResponseDto[] = []; - for (const assetId of dto.assetIds) { - const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId); - if (hasAsset) { - results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE }); - } else { - results.push({ assetId, success: true }); + const items: AssetTagItem[] = []; + for (const tagId of tagIds) { + for (const assetId of assetIds) { + items.push({ tagId, assetId }); } } - await this.repository.addAssets( - auth.user.id, - id, - results.filter((result) => result.success).map((result) => result.assetId), + const results = await this.repository.upsertAssetIds(items); + for (const assetId of new Set(results.map((item) => item.assetId))) { + await this.eventRepository.emit('asset.tag', { assetId }); + } + + return { count: results.length }; + } + + async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { + await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] }); + + const results = await addAssets( + auth, + { access: this.access, bulk: this.repository }, + { parentId: id, assetIds: dto.ids }, ); + for (const { id: assetId, success } of results) { + if (success) { + await this.eventRepository.emit('asset.tag', { assetId }); + } + } + return results; } - async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { - await this.findOrFail(auth, id); + async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { + await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] }); - const results: AssetIdsResponseDto[] = []; - for (const assetId of dto.assetIds) { - const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId); - if (hasAsset) { - results.push({ assetId, success: true }); - } else { - results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND }); + const results = await removeAssets( + auth, + { access: this.access, bulk: this.repository }, + { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TAG_DELETE }, + ); + + for (const { id: assetId, success } of results) { + if (success) { + await this.eventRepository.emit('asset.untag', { assetId }); } } - await this.repository.removeAssets( - auth.user.id, - id, - results.filter((result) => result.success).map((result) => result.assetId), - ); - return results; } - private async findOrFail(auth: AuthDto, id: string) { - const tag = await this.repository.getById(auth.user.id, id); + private async findOrFail(id: string) { + const tag = await this.repository.get(id); if (!tag) { throw new BadRequestException('Tag not found'); } diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 052565fca9..bc08505b94 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -68,6 +68,10 @@ export class TimelineService { } } + if (dto.tagId) { + await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] }); + } + if (dto.withPartners) { const requestedArchived = dto.isArchived === true || dto.isArchived === undefined; const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 45badeec73..d3219a1a6c 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -41,7 +41,10 @@ export const requireAccess = async (access: IAccessRepository, request: AccessRe } }; -export const checkAccess = async (access: IAccessRepository, { ids, auth, permission }: AccessRequest) => { +export const checkAccess = async ( + access: IAccessRepository, + { ids, auth, permission }: AccessRequest, +): Promise> => { const idSet = Array.isArray(ids) ? new Set(ids) : ids; if (idSet.size === 0) { return new Set(); @@ -52,7 +55,10 @@ export const checkAccess = async (access: IAccessRepository, { ids, auth, permis : checkOtherAccess(access, { auth, permission, ids: idSet }); }; -const checkSharedLinkAccess = async (access: IAccessRepository, request: SharedLinkAccessRequest) => { +const checkSharedLinkAccess = async ( + access: IAccessRepository, + request: SharedLinkAccessRequest, +): Promise> => { const { sharedLink, permission, ids } = request; const sharedLinkId = sharedLink.id; @@ -96,7 +102,7 @@ const checkSharedLinkAccess = async (access: IAccessRepository, request: SharedL } }; -const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest) => { +const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest): Promise> => { const { auth, permission, ids } = request; switch (permission) { @@ -211,6 +217,13 @@ const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessR return await access.authDevice.checkOwnerAccess(auth.user.id, ids); } + case Permission.TAG_ASSET: + case Permission.TAG_READ: + case Permission.TAG_UPDATE: + case Permission.TAG_DELETE: { + return await access.tag.checkOwnerAccess(auth.user.id, ids); + } + case Permission.TIMELINE_READ: { const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); const isPartner = await access.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); diff --git a/server/src/utils/request.ts b/server/src/utils/request.ts index f6edb2f8b3..19d3cac661 100644 --- a/server/src/utils/request.ts +++ b/server/src/utils/request.ts @@ -2,4 +2,4 @@ export const fromChecksum = (checksum: string): Buffer => { return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex'); }; -export const fromMaybeArray = (param: string | string[] | undefined) => (Array.isArray(param) ? param[0] : param); +export const fromMaybeArray = (param: T | T[]) => (Array.isArray(param) ? param[0] : param); diff --git a/server/src/utils/tag.ts b/server/src/utils/tag.ts new file mode 100644 index 0000000000..12c46d2440 --- /dev/null +++ b/server/src/utils/tag.ts @@ -0,0 +1,30 @@ +import { TagEntity } from 'src/entities/tag.entity'; +import { ITagRepository } from 'src/interfaces/tag.interface'; + +type UpsertRequest = { userId: string; tags: string[] }; +export const upsertTags = async (repository: ITagRepository, { userId, tags }: UpsertRequest) => { + tags = [...new Set(tags)]; + + const results: TagEntity[] = []; + + for (const tag of tags) { + const parts = tag.split('/'); + let parent: TagEntity | undefined; + + for (const part of parts) { + const value = parent ? `${parent.value}/${part}` : part; + let tag = await repository.getByValue(userId, value); + if (!tag) { + tag = await repository.create({ userId, value, parent }); + } + + parent = tag; + } + + if (parent) { + results.push(parent); + } + } + + return results; +}; diff --git a/server/test/fixtures/tag.stub.ts b/server/test/fixtures/tag.stub.ts index 537c65db47..b245bfe9e5 100644 --- a/server/test/fixtures/tag.stub.ts +++ b/server/test/fixtures/tag.stub.ts @@ -1,24 +1,65 @@ import { TagResponseDto } from 'src/dtos/tag.dto'; -import { TagEntity, TagType } from 'src/entities/tag.entity'; +import { TagEntity } from 'src/entities/tag.entity'; import { userStub } from 'test/fixtures/user.stub'; +const parent = Object.freeze({ + id: 'tag-parent', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Parent', + color: null, + userId: userStub.admin.id, + user: userStub.admin, +}); + +const child = Object.freeze({ + id: 'tag-child', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Parent/Child', + color: null, + parent, + userId: userStub.admin.id, + user: userStub.admin, +}); + export const tagStub = { tag1: Object.freeze({ id: 'tag-1', - name: 'Tag1', - type: TagType.CUSTOM, + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Tag1', + color: null, + userId: userStub.admin.id, + user: userStub.admin, + }), + parent, + child, + color1: Object.freeze({ + id: 'tag-1', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Tag1', + color: '#000000', userId: userStub.admin.id, user: userStub.admin, - renameTagId: null, - assets: [], }), }; export const tagResponseStub = { tag1: Object.freeze({ id: 'tag-1', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), name: 'Tag1', - type: 'CUSTOM', - userId: 'admin_id', + value: 'Tag1', + }), + color1: Object.freeze({ + id: 'tag-1', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + color: '#000000', + name: 'Tag1', + value: 'Tag1', }), }; diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index c9db8cd76a..9e9bf5406b 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -11,6 +11,7 @@ export interface IAccessRepositoryMock { partner: Mocked; stack: Mocked; timeline: Mocked; + tag: Mocked; } export const newAccessRepositoryMock = (): IAccessRepositoryMock => { @@ -58,5 +59,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { timeline: { checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), }, + + tag: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, }; }; diff --git a/server/test/repositories/tag.repository.mock.ts b/server/test/repositories/tag.repository.mock.ts index a5123e0f36..35b3de1576 100644 --- a/server/test/repositories/tag.repository.mock.ts +++ b/server/test/repositories/tag.repository.mock.ts @@ -4,14 +4,17 @@ import { Mocked, vitest } from 'vitest'; export const newTagRepositoryMock = (): Mocked => { return { getAll: vitest.fn(), - getById: vitest.fn(), + getByValue: vitest.fn(), + upsertAssetTags: vitest.fn(), + + get: vitest.fn(), create: vitest.fn(), update: vitest.fn(), - remove: vitest.fn(), - hasAsset: vitest.fn(), - hasName: vitest.fn(), - getAssets: vitest.fn(), - addAssets: vitest.fn(), - removeAssets: vitest.fn(), + delete: vitest.fn(), + + getAssetIds: vitest.fn(), + addAssetIds: vitest.fn(), + removeAssetIds: vitest.fn(), + upsertAssetIds: vitest.fn(), }; }; diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte new file mode 100644 index 0000000000..434682f73e --- /dev/null +++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte @@ -0,0 +1,80 @@ + + +{#if isOwner && !isSharedLink()} +

    +
    +

    {$t('tags').toUpperCase()}

    +
    +
    + {#each tags as tag (tag.id)} +
    + +

    + {tag.value} +

    +
    + + +
    + {/each} + +
    +
    +{/if} + +{#if isOpen} + handleTag(tagsIds)} onCancel={handleCancel} /> +{/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 88417f248f..0a105430cc 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -43,6 +43,7 @@ import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte'; import { t } from 'svelte-i18n'; import { goto } from '$app/navigation'; + import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte'; export let asset: AssetResponseDto; export let albums: AlbumResponseDto[] = []; @@ -157,7 +158,7 @@ {#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0} -
    +

    {$t('people').toUpperCase()}

    @@ -472,11 +473,11 @@ {/if} {#if albums.length > 0} -
    +

    {$t('appears_in').toUpperCase()}

    {#each albums as album} -
    +
    {album.albumName} {/if} +
    + +
    + {#if showEditFaces} (intersecting = false)); + assetStore.taskManager.separatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false)); } else { intersecting = false; } diff --git a/web/src/lib/components/forms/tag-asset-form.svelte b/web/src/lib/components/forms/tag-asset-form.svelte new file mode 100644 index 0000000000..306d25d3f1 --- /dev/null +++ b/web/src/lib/components/forms/tag-asset-form.svelte @@ -0,0 +1,82 @@ + + + + +
    + handleSelect(option)} + label={$t('tag')} + options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))} + placeholder={$t('search_tags')} + /> +
    + + +
    + {#each selectedIds as tagId (tagId)} + {@const tag = tagMap[tagId]} + {#if tag} +
    + +

    + {tag.value} +

    +
    + + +
    + {/if} + {/each} +
    + + + + + +
    diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 8222007d57..6511a9deba 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -35,12 +35,16 @@
    - {#if title} + {#if title || $$slots.title || $$slots.buttons}
    -
    {title}
    + + {#if title} +
    {title}
    + {/if} +
    {#if description}

    {description}

    {/if} diff --git a/web/src/lib/components/photos-page/actions/tag-action.svelte b/web/src/lib/components/photos-page/actions/tag-action.svelte new file mode 100644 index 0000000000..77e91d7235 --- /dev/null +++ b/web/src/lib/components/photos-page/actions/tag-action.svelte @@ -0,0 +1,47 @@ + + +{#if menuItem} + +{/if} + +{#if !menuItem} + {#if loading} + + {:else} + + {/if} +{/if} + +{#if isOpen} + handleTag(tagIds)} onCancel={handleCancel} /> +{/if} diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index 5ca29967fe..5cbc2e7dca 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -109,7 +109,7 @@ ); }, onSeparate: () => { - $assetStore.taskManager.seperatedDateGroup(componentId, dateGroup, () => + $assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () => assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }), ); }, @@ -186,9 +186,9 @@
    onAssetInGrid?.(asset), - top: `-${TITLE_HEIGHT}px`, - bottom: `-${viewport.height - TITLE_HEIGHT - 1}px`, - right: `-${viewport.width - 1}px`, + top: `${-TITLE_HEIGHT}px`, + bottom: `${-(viewport.height - TITLE_HEIGHT - 1)}px`, + right: `${-(viewport.width - 1)}px`, root: assetGridElement, }} data-asset-id={asset.id} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index db030ed14c..40dc79c4f2 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -498,21 +498,21 @@ } }; - function intersectedHandler(bucket: AssetBucket) { + function handleIntersect(bucket: AssetBucket) { updateLastIntersectedBucketDate(); - const intersectedTask = () => { + const task = () => { $assetStore.updateBucket(bucket.bucketDate, { intersecting: true }); void $assetStore.loadBucket(bucket.bucketDate); }; - $assetStore.taskManager.intersectedBucket(componentId, bucket, intersectedTask); + $assetStore.taskManager.intersectedBucket(componentId, bucket, task); } - function seperatedHandler(bucket: AssetBucket) { - const seperatedTask = () => { + function handleSeparate(bucket: AssetBucket) { + const task = () => { $assetStore.updateBucket(bucket.bucketDate, { intersecting: false }); bucket.cancel(); }; - $assetStore.taskManager.seperatedBucket(componentId, bucket, seperatedTask); + $assetStore.taskManager.separatedBucket(componentId, bucket, task); } const handlePrevious = async () => { @@ -809,8 +809,8 @@
    intersectedHandler(bucket), - onSeparate: () => seperatedHandler(bucket), + onIntersect: () => handleIntersect(bucket), + onSeparate: () => handleSeparate(bucket), top: BUCKET_INTERSECTION_ROOT_TOP, bottom: BUCKET_INTERSECTION_ROOT_BOTTOM, root: element, diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 64ec16fda6..d3e022a759 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -1,5 +1,6 @@ @@ -13,6 +14,7 @@ import { fly } from 'svelte/transition'; import PasswordField from '../password-field.svelte'; import { t } from 'svelte-i18n'; + import { onMount, tick } from 'svelte'; export let inputType: SettingInputFieldType; export let value: string | number; @@ -25,8 +27,11 @@ export let required = false; export let disabled = false; export let isEdited = false; + export let autofocus = false; export let passwordAutocomplete: string = 'current-password'; + let input: HTMLInputElement; + const handleChange: FormEventHandler = (e) => { value = e.currentTarget.value; @@ -41,6 +46,14 @@ value = newValue; } }; + + onMount(() => { + if (autofocus) { + tick() + .then(() => input?.focus()) + .catch((_) => {}); + } + });
    @@ -69,22 +82,46 @@ {/if} {#if inputType !== SettingInputFieldType.PASSWORD} - +
    + {#if inputType === SettingInputFieldType.COLOR} + + {/if} + + +
    {:else} {/if}
    + + diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index 1985160b27..dd777d1259 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -21,6 +21,7 @@ mdiToolbox, mdiToolboxOutline, mdiFolderOutline, + mdiTagMultipleOutline, } from '@mdi/js'; import SideBarSection from './side-bar-section.svelte'; import SideBarLink from './side-bar-link.svelte'; @@ -105,6 +106,8 @@ + + import Tree from '$lib/components/shared-components/tree/tree.svelte'; - import type { RecursiveObject } from '$lib/utils/tree-utils'; + import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils'; export let items: RecursiveObject; export let parent = ''; export let active = ''; + export let icons: { default: string; active: string }; export let getLink: (path: string) => string; + export let getColor: (path: string) => string | undefined = () => undefined;
      - {#each Object.entries(items) as [path, tree], index (index)} -
    • - -
    • + {#each Object.entries(items) as [path, tree]} + {@const value = normalizeTreePath(`${parent}/${path}`)} + {@const key = value + getColor(value)} + {#key key} +
    • + +
    • + {/key} {/each}
    diff --git a/web/src/lib/components/shared-components/tree/tree.svelte b/web/src/lib/components/shared-components/tree/tree.svelte index 7975825c5e..99928f5bbd 100644 --- a/web/src/lib/components/shared-components/tree/tree.svelte +++ b/web/src/lib/components/shared-components/tree/tree.svelte @@ -2,18 +2,21 @@ import Icon from '$lib/components/elements/icon.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils'; - import { mdiChevronDown, mdiChevronRight, mdiFolder, mdiFolderOutline } from '@mdi/js'; + import { mdiChevronDown, mdiChevronRight } from '@mdi/js'; export let tree: RecursiveObject; export let parent: string; export let value: string; export let active = ''; + export let icons: { default: string; active: string }; export let getLink: (path: string) => string; + export let getColor: (path: string) => string | undefined; $: path = normalizeTreePath(`${parent}/${value}`); $: isActive = active.startsWith(path); $: isOpen = isActive; $: isTarget = active === path; + $: color = getColor(path); -
    @@ -35,5 +43,5 @@
    {#if isOpen} - + {/if} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 34d6409848..ce5cefd815 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -47,6 +47,7 @@ export enum AppRoute { DUPLICATES = '/utilities/duplicates', FOLDERS = '/folders', + TAGS = '/tags', } export enum ProjectionType { diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 8b1ec452d7..684cb0e319 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -440,6 +440,7 @@ "close": "Close", "collapse": "Collapse", "collapse_all": "Collapse all", + "color": "Color", "color_theme": "Color theme", "comment_deleted": "Comment deleted", "comment_options": "Comment options", @@ -473,6 +474,8 @@ "create_new_person": "Create new person", "create_new_person_hint": "Assign selected assets to a new person", "create_new_user": "Create new user", + "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_user": "Create user", "created": "Created", "current_device": "Current device", @@ -496,6 +499,8 @@ "delete_library": "Delete library", "delete_link": "Delete link", "delete_shared_link": "Delete shared link", + "delete_tag": "Delete tag", + "delete_tag_confirmation_prompt": "Are you sure you want to delete {tagName} tag?", "delete_user": "Delete user", "deleted_shared_link": "Deleted shared link", "description": "Description", @@ -537,6 +542,7 @@ "edit_location": "Edit location", "edit_name": "Edit name", "edit_people": "Edit people", + "edit_tag": "Edit tag", "edit_title": "Edit Title", "edit_user": "Edit user", "edited": "Edited", @@ -1007,6 +1013,7 @@ "removed_from_archive": "Removed from archive", "removed_from_favorites": "Removed from favorites", "removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites", + "removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}", "rename": "Rename", "repair": "Repair", "repair_no_results_message": "Untracked and missing files will show up here", @@ -1055,6 +1062,7 @@ "search_people": "Search people", "search_places": "Search places", "search_state": "Search state...", + "search_tags": "Search tags...", "search_timezone": "Search timezone...", "search_type": "Search type", "search_your_photos": "Search your photos", @@ -1158,6 +1166,12 @@ "sunrise_on_the_beach": "Sunrise on the beach", "swap_merge_direction": "Swap merge direction", "sync": "Sync", + "tag": "Tag", + "tag_assets": "Tag assets", + "tag_created": "Created tag: {tag}", + "tag_updated": "Updated tag: {tag}", + "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}", + "tags": "Tags", "template": "Template", "theme": "Theme", "theme_selection": "Theme selection", @@ -1169,6 +1183,7 @@ "to_change_password": "Change password", "to_favorite": "Favorite", "to_login": "Login", + "to_root": "To root", "to_trash": "Trash", "toggle_settings": "Toggle settings", "toggle_theme": "Toggle dark theme", diff --git a/web/src/lib/utils/asset-store-task-manager.ts b/web/src/lib/utils/asset-store-task-manager.ts index 6ece1327c4..6ca4f057bd 100644 --- a/web/src/lib/utils/asset-store-task-manager.ts +++ b/web/src/lib/utils/asset-store-task-manager.ts @@ -256,9 +256,9 @@ export class AssetGridTaskManager { bucketTask.scheduleIntersected(componentId, task); } - seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) { + separatedBucket(componentId: string, bucket: AssetBucket, separated: Task) { const bucketTask = this.getOrCreateBucketTask(bucket); - bucketTask.scheduleSeparated(componentId, seperated); + bucketTask.scheduleSeparated(componentId, separated); } intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) { @@ -266,9 +266,9 @@ export class AssetGridTaskManager { bucketTask.intersectedDateGroup(componentId, dateGroup, intersected); } - seperatedDateGroup(componentId: string, dateGroup: DateGroup, seperated: Task) { + separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) { const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); - bucketTask.separatedDateGroup(componentId, dateGroup, seperated); + bucketTask.separatedDateGroup(componentId, dateGroup, separated); } intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) { @@ -277,16 +277,16 @@ export class AssetGridTaskManager { dateGroupTask.intersectedThumbnail(componentId, asset, intersected); } - seperatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, seperated: Task) { + separatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, separated: Task) { const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup); - dateGroupTask.separatedThumbnail(componentId, asset, seperated); + dateGroupTask.separatedThumbnail(componentId, asset, separated); } } class IntersectionTask { internalTaskManager: InternalTaskManager; - seperatedKey; + separatedKey; intersectedKey; priority; @@ -295,7 +295,7 @@ class IntersectionTask { constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) { this.internalTaskManager = internalTaskManager; - this.seperatedKey = keyPrefix + ':s:' + key; + this.separatedKey = keyPrefix + ':s:' + key; this.intersectedKey = keyPrefix + ':i:' + key; this.priority = priority; } @@ -325,14 +325,14 @@ class IntersectionTask { this.separated = execTask; const cleanup = () => { this.separated = undefined; - this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey); + this.internalTaskManager.deleteFromComponentTasks(componentId, this.separatedKey); }; return { task: execTask, cleanup }; } removePendingSeparated() { if (this.separated) { - this.internalTaskManager.removeSeparateTask(this.seperatedKey); + this.internalTaskManager.removeSeparateTask(this.separatedKey); } } removePendingIntersected() { @@ -368,7 +368,7 @@ class IntersectionTask { task, cleanup, componentId: componentId, - taskId: this.seperatedKey, + taskId: this.separatedKey, }); } } @@ -448,9 +448,9 @@ class DateGroupTask extends IntersectionTask { thumbnailTask.scheduleIntersected(componentId, intersected); } - separatedThumbnail(componentId: string, asset: AssetResponseDto, seperated: Task) { + separatedThumbnail(componentId: string, asset: AssetResponseDto, separated: Task) { const thumbnailTask = this.getOrCreateThumbnailTask(asset); - thumbnailTask.scheduleSeparated(componentId, seperated); + thumbnailTask.scheduleSeparated(componentId, separated); } } class ThumbnailTask extends IntersectionTask { diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 576b14b201..ce7944b9c9 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -10,6 +10,7 @@ import { preferences } from '$lib/stores/user.store'; import { downloadRequest, getKey, withError } from '$lib/utils'; import { createAlbum } from '$lib/utils/album-utils'; import { getByteUnitString } from '$lib/utils/byte-units'; +import { getFormatter } from '$lib/utils/i18n'; import { addAssetsToAlbum as addAssets, createStack, @@ -18,6 +19,8 @@ import { getBaseUrl, getDownloadInfo, getStack, + tagAssets as tagAllAssets, + untagAssets, updateAsset, updateAssets, type AlbumResponseDto, @@ -61,6 +64,54 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show } }; +export const tagAssets = async ({ + assetIds, + tagIds, + showNotification = true, +}: { + assetIds: string[]; + tagIds: string[]; + showNotification?: boolean; +}) => { + for (const tagId of tagIds) { + await tagAllAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }); + } + + if (showNotification) { + const $t = await getFormatter(); + notificationController.show({ + message: $t('tagged_assets', { values: { count: assetIds.length } }), + type: NotificationType.Info, + }); + } + + return assetIds; +}; + +export const removeTag = async ({ + assetIds, + tagIds, + showNotification = true, +}: { + assetIds: string[]; + tagIds: string[]; + showNotification?: boolean; +}) => { + for (const tagId of tagIds) { + await untagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }); + } + + if (showNotification) { + const $t = await getFormatter(); + notificationController.show({ + message: $t('removed_tagged_assets', { values: { count: assetIds.length } }), + type: NotificationType.Info, + }); + } + + return assetIds; +}; + export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[]) => { const album = await createAlbum(albumName, assetIds); if (!album) { diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index b530184342..a8b8602c02 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -13,7 +13,7 @@ import { foldersStore } from '$lib/stores/folders.store'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import { type AssetResponseDto } from '@immich/sdk'; - import { mdiArrowUpLeft, mdiChevronRight, mdiFolder, mdiFolderHome } from '@mdi/js'; + import { mdiArrowUpLeft, mdiChevronRight, mdiFolder, mdiFolderHome, mdiFolderOutline } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -60,7 +60,12 @@
    {$t('explorer').toUpperCase()}
    - +
    @@ -73,7 +78,7 @@
    - + {#each pathSegments as segment, index} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 9bcbdbeea0..e15c20cbbe 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -25,6 +25,7 @@ import { preferences, user } from '$lib/stores/user.store'; import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; + import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; let { isViewing: showAssetViewer } = assetViewingStore; const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true }); @@ -80,6 +81,7 @@ assetStore.removeAssets(assetIds)} /> + assetStore.removeAssets(assetIds)} />
    diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000..7335bf83c1 --- /dev/null +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,251 @@ + + + + +
    +
    {$t('explorer').toUpperCase()}
    +
    + +
    +
    +
    + +
    + +
    + + +
    +
    + + +
    + + +
    +
    + + {#if pathSegments.length > 0 && tag} + +
    + + +
    +
    + {/if} +
    + +
    + + + + {#each pathSegments as segment, index} + +

    + {#if index < pathSegments.length - 1} + + {/if} +

    + {/each} +
    + +
    + {#key $page.url.href} + {#if tag} + + + + {:else} + + {/if} + {/key} +
    +
    + +{#if isNewOpen} + +
    +

    + {$t('create_tag_description')} +

    +
    + +
    +
    + +
    +
    + + + + +
    +{/if} + +{#if isEditOpen} + +
    +
    + +
    +
    + + + + +
    +{/if} diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000..23846e57c4 --- /dev/null +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,32 @@ +import { QueryParameter } from '$lib/constants'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; +import { getAllTags } from '@immich/sdk'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(); + const asset = await getAssetInfoFromParam(params); + const $t = await getFormatter(); + + const path = url.searchParams.get(QueryParameter.PATH); + const tags = await getAllTags(); + const tree = buildTree(tags.map((tag) => tag.value)); + let currentTree = tree; + const parts = normalizeTreePath(path || '').split('/'); + for (const part of parts) { + currentTree = currentTree?.[part]; + } + + return { + tags, + asset, + path, + children: Object.keys(currentTree || {}), + meta: { + title: $t('tags'), + }, + }; +}) satisfies PageLoad; From 74f18a45235845b7dd16b4c4088d084ec3ea20b7 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 29 Aug 2024 20:10:09 +0200 Subject: [PATCH 204/723] fix(server): skip smtp validation if unchanged (#12111) * fix(server): skip smtp validation if unchanged * update comparison + convert config to plain object --- server/src/services/notification.service.spec.ts | 10 ++++++++++ server/src/services/notification.service.ts | 4 ++-- server/src/services/system-config.service.ts | 3 ++- server/src/utils/object.ts | 15 +++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 server/src/utils/object.ts diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index bcce902e91..5bcead0ff3 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -1,4 +1,6 @@ +import { plainToInstance } from 'class-transformer'; import { defaults, SystemConfig } from 'src/config'; +import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, UserMetadataKey } from 'src/enum'; @@ -112,6 +114,14 @@ describe(NotificationService.name, () => { expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); }); + it('skips smtp validation with DTO when there are no changes', async () => { + const oldConfig = { ...configs.smtpEnabled }; + const newConfig = plainToInstance(SystemConfigDto, configs.smtpEnabled); + + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); + expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); + }); + it('skips smtp validation when smtp is disabled', async () => { const oldConfig = { ...configs.smtpEnabled }; const newConfig = { ...configs.smtpDisabled }; diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index ace8240b39..274c91661c 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,5 +1,4 @@ import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { isEqual } from 'lodash'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEmit } from 'src/decorators'; @@ -23,6 +22,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf import { IUserRepository } from 'src/interfaces/user.interface'; import { getAssetFiles } from 'src/utils/asset.util'; import { getFilenameExtension } from 'src/utils/file'; +import { isEqualObject } from 'src/utils/object'; import { getPreferences } from 'src/utils/preferences'; @Injectable() @@ -47,7 +47,7 @@ export class NotificationService { try { if ( newConfig.notifications.smtp.enabled && - !isEqual(oldConfig.notifications.smtp, newConfig.notifications.smtp) + !isEqualObject(oldConfig.notifications.smtp, newConfig.notifications.smtp) ) { await this.notificationRepository.verifySmtp(newConfig.notifications.smtp.transport); } diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 26a91f1d09..5ec9ab7a5d 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -18,6 +18,7 @@ import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { toPlainObject } from 'src/utils/object'; @Injectable() export class SystemConfigService { @@ -63,7 +64,7 @@ export class SystemConfigService { const oldConfig = await this.core.getConfig({ withCache: false }); try { - await this.eventRepository.emit('config.validate', { newConfig: dto, oldConfig }); + await this.eventRepository.emit('config.validate', { newConfig: toPlainObject(dto), oldConfig }); } catch (error) { this.logger.warn(`Unable to save system config due to a validation error: ${error}`); throw new BadRequestException(error instanceof Error ? error.message : error); diff --git a/server/src/utils/object.ts b/server/src/utils/object.ts new file mode 100644 index 0000000000..25ae42cba8 --- /dev/null +++ b/server/src/utils/object.ts @@ -0,0 +1,15 @@ +import { isEqual, isPlainObject } from 'lodash'; + +/** + * Deeply clones and converts a class instance to a plain object. + */ +export function toPlainObject(obj: T): T { + return isPlainObject(obj) ? obj : structuredClone(obj); +} + +/** + * Performs a deep comparison between objects, converting them to plain objects first if needed. + */ +export function isEqualObject(value: object, other: object): boolean { + return isEqual(toPlainObject(value), toPlainObject(other)); +} From 9bfaa525db29d0f591750582415894ebfeced0f6 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 29 Aug 2024 13:46:47 -0500 Subject: [PATCH 205/723] fix(mobile): long waiting time for login request when server is unreachable (#12100) * fix(mobile): long waiting time for login request when server is unreachable * lint * increase timeout duration --- mobile/lib/providers/authentication.provider.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart index 5d3ae5bc22..b56e71b11b 100644 --- a/mobile/lib/providers/authentication.provider.dart +++ b/mobile/lib/providers/authentication.provider.dart @@ -170,8 +170,10 @@ class AuthenticationNotifier extends StateNotifier { UserPreferencesResponseDto? userPreferences; try { final responses = await Future.wait([ - _apiService.usersApi.getMyUser(), - _apiService.usersApi.getMyPreferences(), + _apiService.usersApi.getMyUser().timeout(const Duration(seconds: 7)), + _apiService.usersApi + .getMyPreferences() + .timeout(const Duration(seconds: 7)), ]); userResponse = responses[0] as UserAdminResponseDto; userPreferences = responses[1] as UserPreferencesResponseDto; From ebecb60f39819e8b56cfe29dd8b118b065ef487d Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 29 Aug 2024 14:29:04 -0500 Subject: [PATCH 206/723] feat: user's features preferences (#12099) * feat: metadata in UserPreference * feat: web metadata settings * feat: web metadata settings * fix: typo * patch openapi * fix: missing translation key * new organization of preference strucutre * feature settings on web * localization * added and used feature settings * add default value to response dto * patch openapi * format en.json file * implement helper method * use tags preference logic * Fix logic bug and add tests * fix preference can be null in detail panel --- mobile/lib/utils/openapi_patching.dart | 30 +++- mobile/openapi/README.md | 14 +- mobile/openapi/lib/api.dart | 14 +- mobile/openapi/lib/api_client.dart | 28 ++- .../openapi/lib/model/folders_response.dart | 106 ++++++++++++ mobile/openapi/lib/model/folders_update.dart | 124 ++++++++++++++ ...y_response.dart => memories_response.dart} | 38 ++-- ...ating_update.dart => memories_update.dart} | 36 ++-- mobile/openapi/lib/model/people_response.dart | 106 ++++++++++++ mobile/openapi/lib/model/people_update.dart | 124 ++++++++++++++ ...ng_response.dart => ratings_response.dart} | 36 ++-- ...memory_update.dart => ratings_update.dart} | 36 ++-- mobile/openapi/lib/model/tags_response.dart | 106 ++++++++++++ mobile/openapi/lib/model/tags_update.dart | 124 ++++++++++++++ .../model/user_preferences_response_dto.dart | 44 +++-- .../model/user_preferences_update_dto.dart | 73 ++++++-- .../modules/utils/openapi_patching_test.dart | 49 ++++++ open-api/immich-openapi-specs.json | 162 +++++++++++++++--- open-api/typescript-sdk/src/fetch-client.ts | 46 ++++- server/src/dtos/user-preferences.dto.ts | 85 +++++++-- server/src/entities/user-metadata.entity.ts | 28 ++- .../detail-panel-star-rating.svelte | 2 +- .../asset-viewer/detail-panel.svelte | 10 +- .../settings/setting-accordion.svelte | 17 +- .../side-bar/side-bar.svelte | 38 ++-- .../user-settings-page/app-settings.svelte | 43 ----- .../feature-settings.svelte | 124 ++++++++++++++ .../memories-settings.svelte | 46 ----- .../user-settings-list.svelte | 7 +- web/src/lib/i18n/en.json | 9 +- web/src/lib/stores/preferences.store.ts | 5 - .../(user)/photos/[[assetId=id]]/+page.svelte | 4 +- 32 files changed, 1418 insertions(+), 296 deletions(-) create mode 100644 mobile/openapi/lib/model/folders_response.dart create mode 100644 mobile/openapi/lib/model/folders_update.dart rename mobile/openapi/lib/model/{memory_response.dart => memories_response.dart} (62%) rename mobile/openapi/lib/model/{rating_update.dart => memories_update.dart} (68%) create mode 100644 mobile/openapi/lib/model/people_response.dart create mode 100644 mobile/openapi/lib/model/people_update.dart rename mobile/openapi/lib/model/{rating_response.dart => ratings_response.dart} (63%) rename mobile/openapi/lib/model/{memory_update.dart => ratings_update.dart} (69%) create mode 100644 mobile/openapi/lib/model/tags_response.dart create mode 100644 mobile/openapi/lib/model/tags_update.dart create mode 100644 mobile/test/modules/utils/openapi_patching_test.dart create mode 100644 web/src/lib/components/user-settings-page/feature-settings.svelte delete mode 100644 web/src/lib/components/user-settings-page/memories-settings.svelte diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 7a2f7396eb..349b2322af 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -4,14 +4,30 @@ dynamic upgradeDto(dynamic value, String targetType) { switch (targetType) { case 'UserPreferencesResponseDto': if (value is Map) { - if (value['rating'] == null) { - value['rating'] = RatingResponse().toJson(); - } - - if (value['download']['includeEmbeddedVideos'] == null) { - value['download']['includeEmbeddedVideos'] = false; - } + addDefault(value, 'download.includeEmbeddedVideos', false); + addDefault(value, 'folders', FoldersResponse().toJson()); + addDefault(value, 'memories', MemoriesResponse().toJson()); + addDefault(value, 'ratings', RatingsResponse().toJson()); + addDefault(value, 'people', PeopleResponse().toJson()); + addDefault(value, 'tags', TagsResponse().toJson()); } break; } } + +addDefault(dynamic value, String keys, dynamic defaultValue) { + // Loop through the keys and assign the default value if the key is not present + List keyList = keys.split('.'); + dynamic current = value; + + for (int i = 0; i < keyList.length - 1; i++) { + if (current[keyList[i]] == null) { + current[keyList[i]] = {}; + } + current = current[keyList[i]]; + } + + if (current[keyList.last] == null) { + current[keyList.last] = defaultValue; + } +} diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1f8958dd95..b831f60b9a 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -324,6 +324,8 @@ Class | Method | HTTP request | Description - [FileReportDto](doc//FileReportDto.md) - [FileReportFixDto](doc//FileReportFixDto.md) - [FileReportItemDto](doc//FileReportItemDto.md) + - [FoldersResponse](doc//FoldersResponse.md) + - [FoldersUpdate](doc//FoldersUpdate.md) - [ImageFormat](doc//ImageFormat.md) - [JobCommand](doc//JobCommand.md) - [JobCommandDto](doc//JobCommandDto.md) @@ -342,12 +344,12 @@ Class | Method | HTTP request | Description - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - [MapTheme](doc//MapTheme.md) + - [MemoriesResponse](doc//MemoriesResponse.md) + - [MemoriesUpdate](doc//MemoriesUpdate.md) - [MemoryCreateDto](doc//MemoryCreateDto.md) - [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md) - - [MemoryResponse](doc//MemoryResponse.md) - [MemoryResponseDto](doc//MemoryResponseDto.md) - [MemoryType](doc//MemoryType.md) - - [MemoryUpdate](doc//MemoryUpdate.md) - [MemoryUpdateDto](doc//MemoryUpdateDto.md) - [MergePersonDto](doc//MergePersonDto.md) - [MetadataSearchDto](doc//MetadataSearchDto.md) @@ -359,7 +361,9 @@ Class | Method | HTTP request | Description - [PartnerResponseDto](doc//PartnerResponseDto.md) - [PathEntityType](doc//PathEntityType.md) - [PathType](doc//PathType.md) + - [PeopleResponse](doc//PeopleResponse.md) - [PeopleResponseDto](doc//PeopleResponseDto.md) + - [PeopleUpdate](doc//PeopleUpdate.md) - [PeopleUpdateDto](doc//PeopleUpdateDto.md) - [PeopleUpdateItem](doc//PeopleUpdateItem.md) - [Permission](doc//Permission.md) @@ -372,8 +376,8 @@ Class | Method | HTTP request | Description - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueStatusDto](doc//QueueStatusDto.md) - - [RatingResponse](doc//RatingResponse.md) - - [RatingUpdate](doc//RatingUpdate.md) + - [RatingsResponse](doc//RatingsResponse.md) + - [RatingsUpdate](doc//RatingsUpdate.md) - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) @@ -435,6 +439,8 @@ Class | Method | HTTP request | Description - [TagResponseDto](doc//TagResponseDto.md) - [TagUpdateDto](doc//TagUpdateDto.md) - [TagUpsertDto](doc//TagUpsertDto.md) + - [TagsResponse](doc//TagsResponse.md) + - [TagsUpdate](doc//TagsUpdate.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketSize](doc//TimeBucketSize.md) - [ToneMapping](doc//ToneMapping.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 532d7e22cd..d6ce89624c 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -138,6 +138,8 @@ part 'model/file_checksum_response_dto.dart'; part 'model/file_report_dto.dart'; part 'model/file_report_fix_dto.dart'; part 'model/file_report_item_dto.dart'; +part 'model/folders_response.dart'; +part 'model/folders_update.dart'; part 'model/image_format.dart'; part 'model/job_command.dart'; part 'model/job_command_dto.dart'; @@ -156,12 +158,12 @@ part 'model/logout_response_dto.dart'; part 'model/map_marker_response_dto.dart'; part 'model/map_reverse_geocode_response_dto.dart'; part 'model/map_theme.dart'; +part 'model/memories_response.dart'; +part 'model/memories_update.dart'; part 'model/memory_create_dto.dart'; part 'model/memory_lane_response_dto.dart'; -part 'model/memory_response.dart'; part 'model/memory_response_dto.dart'; part 'model/memory_type.dart'; -part 'model/memory_update.dart'; part 'model/memory_update_dto.dart'; part 'model/merge_person_dto.dart'; part 'model/metadata_search_dto.dart'; @@ -173,7 +175,9 @@ part 'model/partner_direction.dart'; part 'model/partner_response_dto.dart'; part 'model/path_entity_type.dart'; part 'model/path_type.dart'; +part 'model/people_response.dart'; part 'model/people_response_dto.dart'; +part 'model/people_update.dart'; part 'model/people_update_dto.dart'; part 'model/people_update_item.dart'; part 'model/permission.dart'; @@ -186,8 +190,8 @@ part 'model/places_response_dto.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; part 'model/queue_status_dto.dart'; -part 'model/rating_response.dart'; -part 'model/rating_update.dart'; +part 'model/ratings_response.dart'; +part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; part 'model/reverse_geocoding_state_response_dto.dart'; @@ -249,6 +253,8 @@ part 'model/tag_create_dto.dart'; part 'model/tag_response_dto.dart'; part 'model/tag_update_dto.dart'; part 'model/tag_upsert_dto.dart'; +part 'model/tags_response.dart'; +part 'model/tags_update.dart'; part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_size.dart'; part 'model/tone_mapping.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 54873a5955..47375f0b50 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -331,6 +331,10 @@ class ApiClient { return FileReportFixDto.fromJson(value); case 'FileReportItemDto': return FileReportItemDto.fromJson(value); + case 'FoldersResponse': + return FoldersResponse.fromJson(value); + case 'FoldersUpdate': + return FoldersUpdate.fromJson(value); case 'ImageFormat': return ImageFormatTypeTransformer().decode(value); case 'JobCommand': @@ -367,18 +371,18 @@ class ApiClient { return MapReverseGeocodeResponseDto.fromJson(value); case 'MapTheme': return MapThemeTypeTransformer().decode(value); + case 'MemoriesResponse': + return MemoriesResponse.fromJson(value); + case 'MemoriesUpdate': + return MemoriesUpdate.fromJson(value); case 'MemoryCreateDto': return MemoryCreateDto.fromJson(value); case 'MemoryLaneResponseDto': return MemoryLaneResponseDto.fromJson(value); - case 'MemoryResponse': - return MemoryResponse.fromJson(value); case 'MemoryResponseDto': return MemoryResponseDto.fromJson(value); case 'MemoryType': return MemoryTypeTypeTransformer().decode(value); - case 'MemoryUpdate': - return MemoryUpdate.fromJson(value); case 'MemoryUpdateDto': return MemoryUpdateDto.fromJson(value); case 'MergePersonDto': @@ -401,8 +405,12 @@ class ApiClient { return PathEntityTypeTypeTransformer().decode(value); case 'PathType': return PathTypeTypeTransformer().decode(value); + case 'PeopleResponse': + return PeopleResponse.fromJson(value); case 'PeopleResponseDto': return PeopleResponseDto.fromJson(value); + case 'PeopleUpdate': + return PeopleUpdate.fromJson(value); case 'PeopleUpdateDto': return PeopleUpdateDto.fromJson(value); case 'PeopleUpdateItem': @@ -427,10 +435,10 @@ class ApiClient { return PurchaseUpdate.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); - case 'RatingResponse': - return RatingResponse.fromJson(value); - case 'RatingUpdate': - return RatingUpdate.fromJson(value); + case 'RatingsResponse': + return RatingsResponse.fromJson(value); + case 'RatingsUpdate': + return RatingsUpdate.fromJson(value); case 'ReactionLevel': return ReactionLevelTypeTransformer().decode(value); case 'ReactionType': @@ -553,6 +561,10 @@ class ApiClient { return TagUpdateDto.fromJson(value); case 'TagUpsertDto': return TagUpsertDto.fromJson(value); + case 'TagsResponse': + return TagsResponse.fromJson(value); + case 'TagsUpdate': + return TagsUpdate.fromJson(value); case 'TimeBucketResponseDto': return TimeBucketResponseDto.fromJson(value); case 'TimeBucketSize': diff --git a/mobile/openapi/lib/model/folders_response.dart b/mobile/openapi/lib/model/folders_response.dart new file mode 100644 index 0000000000..5bfc4c793d --- /dev/null +++ b/mobile/openapi/lib/model/folders_response.dart @@ -0,0 +1,106 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class FoldersResponse { + /// Returns a new [FoldersResponse] instance. + FoldersResponse({ + this.enabled = false, + this.sidebarWeb = false, + }); + + bool enabled; + + bool sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is FoldersResponse && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode) + + (sidebarWeb.hashCode); + + @override + String toString() => 'FoldersResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + json[r'sidebarWeb'] = this.sidebarWeb; + return json; + } + + /// Returns a new [FoldersResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static FoldersResponse? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return FoldersResponse( + enabled: mapValueOfType(json, r'enabled')!, + sidebarWeb: mapValueOfType(json, r'sidebarWeb')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = FoldersResponse.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = FoldersResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of FoldersResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = FoldersResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + 'sidebarWeb', + }; +} + diff --git a/mobile/openapi/lib/model/folders_update.dart b/mobile/openapi/lib/model/folders_update.dart new file mode 100644 index 0000000000..088c98a4d8 --- /dev/null +++ b/mobile/openapi/lib/model/folders_update.dart @@ -0,0 +1,124 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class FoldersUpdate { + /// Returns a new [FoldersUpdate] instance. + FoldersUpdate({ + this.enabled, + this.sidebarWeb, + }); + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is FoldersUpdate && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled == null ? 0 : enabled!.hashCode) + + (sidebarWeb == null ? 0 : sidebarWeb!.hashCode); + + @override + String toString() => 'FoldersUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + if (this.sidebarWeb != null) { + json[r'sidebarWeb'] = this.sidebarWeb; + } else { + // json[r'sidebarWeb'] = null; + } + return json; + } + + /// Returns a new [FoldersUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static FoldersUpdate? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return FoldersUpdate( + enabled: mapValueOfType(json, r'enabled'), + sidebarWeb: mapValueOfType(json, r'sidebarWeb'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = FoldersUpdate.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = FoldersUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of FoldersUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = FoldersUpdate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/memory_response.dart b/mobile/openapi/lib/model/memories_response.dart similarity index 62% rename from mobile/openapi/lib/model/memory_response.dart rename to mobile/openapi/lib/model/memories_response.dart index fb34bc1518..e215a66a03 100644 --- a/mobile/openapi/lib/model/memory_response.dart +++ b/mobile/openapi/lib/model/memories_response.dart @@ -10,16 +10,16 @@ part of openapi.api; -class MemoryResponse { - /// Returns a new [MemoryResponse] instance. - MemoryResponse({ - required this.enabled, +class MemoriesResponse { + /// Returns a new [MemoriesResponse] instance. + MemoriesResponse({ + this.enabled = true, }); bool enabled; @override - bool operator ==(Object other) => identical(this, other) || other is MemoryResponse && + bool operator ==(Object other) => identical(this, other) || other is MemoriesResponse && other.enabled == enabled; @override @@ -28,7 +28,7 @@ class MemoryResponse { (enabled.hashCode); @override - String toString() => 'MemoryResponse[enabled=$enabled]'; + String toString() => 'MemoriesResponse[enabled=$enabled]'; Map toJson() { final json = {}; @@ -36,25 +36,25 @@ class MemoryResponse { return json; } - /// Returns a new [MemoryResponse] instance and imports its values from + /// Returns a new [MemoriesResponse] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static MemoryResponse? fromJson(dynamic value) { + static MemoriesResponse? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return MemoryResponse( + return MemoriesResponse( enabled: mapValueOfType(json, r'enabled')!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = MemoryResponse.fromJson(row); + final value = MemoriesResponse.fromJson(row); if (value != null) { result.add(value); } @@ -63,12 +63,12 @@ class MemoryResponse { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = MemoryResponse.fromJson(entry.value); + final value = MemoriesResponse.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -77,14 +77,14 @@ class MemoryResponse { return map; } - // maps a json object with a list of MemoryResponse-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of MemoriesResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = MemoryResponse.listFromJson(entry.value, growable: growable,); + map[entry.key] = MemoriesResponse.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/rating_update.dart b/mobile/openapi/lib/model/memories_update.dart similarity index 68% rename from mobile/openapi/lib/model/rating_update.dart rename to mobile/openapi/lib/model/memories_update.dart index bb8f7eadc2..d309491361 100644 --- a/mobile/openapi/lib/model/rating_update.dart +++ b/mobile/openapi/lib/model/memories_update.dart @@ -10,9 +10,9 @@ part of openapi.api; -class RatingUpdate { - /// Returns a new [RatingUpdate] instance. - RatingUpdate({ +class MemoriesUpdate { + /// Returns a new [MemoriesUpdate] instance. + MemoriesUpdate({ this.enabled, }); @@ -25,7 +25,7 @@ class RatingUpdate { bool? enabled; @override - bool operator ==(Object other) => identical(this, other) || other is RatingUpdate && + bool operator ==(Object other) => identical(this, other) || other is MemoriesUpdate && other.enabled == enabled; @override @@ -34,7 +34,7 @@ class RatingUpdate { (enabled == null ? 0 : enabled!.hashCode); @override - String toString() => 'RatingUpdate[enabled=$enabled]'; + String toString() => 'MemoriesUpdate[enabled=$enabled]'; Map toJson() { final json = {}; @@ -46,25 +46,25 @@ class RatingUpdate { return json; } - /// Returns a new [RatingUpdate] instance and imports its values from + /// Returns a new [MemoriesUpdate] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static RatingUpdate? fromJson(dynamic value) { + static MemoriesUpdate? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return RatingUpdate( + return MemoriesUpdate( enabled: mapValueOfType(json, r'enabled'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = RatingUpdate.fromJson(row); + final value = MemoriesUpdate.fromJson(row); if (value != null) { result.add(value); } @@ -73,12 +73,12 @@ class RatingUpdate { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = RatingUpdate.fromJson(entry.value); + final value = MemoriesUpdate.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -87,14 +87,14 @@ class RatingUpdate { return map; } - // maps a json object with a list of RatingUpdate-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of MemoriesUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = RatingUpdate.listFromJson(entry.value, growable: growable,); + map[entry.key] = MemoriesUpdate.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/people_response.dart b/mobile/openapi/lib/model/people_response.dart new file mode 100644 index 0000000000..e12f86eeab --- /dev/null +++ b/mobile/openapi/lib/model/people_response.dart @@ -0,0 +1,106 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PeopleResponse { + /// Returns a new [PeopleResponse] instance. + PeopleResponse({ + this.enabled = true, + this.sidebarWeb = false, + }); + + bool enabled; + + bool sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is PeopleResponse && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode) + + (sidebarWeb.hashCode); + + @override + String toString() => 'PeopleResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + json[r'sidebarWeb'] = this.sidebarWeb; + return json; + } + + /// Returns a new [PeopleResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PeopleResponse? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return PeopleResponse( + enabled: mapValueOfType(json, r'enabled')!, + sidebarWeb: mapValueOfType(json, r'sidebarWeb')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PeopleResponse.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PeopleResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PeopleResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PeopleResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + 'sidebarWeb', + }; +} + diff --git a/mobile/openapi/lib/model/people_update.dart b/mobile/openapi/lib/model/people_update.dart new file mode 100644 index 0000000000..7803e62970 --- /dev/null +++ b/mobile/openapi/lib/model/people_update.dart @@ -0,0 +1,124 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PeopleUpdate { + /// Returns a new [PeopleUpdate] instance. + PeopleUpdate({ + this.enabled, + this.sidebarWeb, + }); + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is PeopleUpdate && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled == null ? 0 : enabled!.hashCode) + + (sidebarWeb == null ? 0 : sidebarWeb!.hashCode); + + @override + String toString() => 'PeopleUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + if (this.sidebarWeb != null) { + json[r'sidebarWeb'] = this.sidebarWeb; + } else { + // json[r'sidebarWeb'] = null; + } + return json; + } + + /// Returns a new [PeopleUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PeopleUpdate? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return PeopleUpdate( + enabled: mapValueOfType(json, r'enabled'), + sidebarWeb: mapValueOfType(json, r'sidebarWeb'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PeopleUpdate.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PeopleUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PeopleUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PeopleUpdate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/rating_response.dart b/mobile/openapi/lib/model/ratings_response.dart similarity index 63% rename from mobile/openapi/lib/model/rating_response.dart rename to mobile/openapi/lib/model/ratings_response.dart index 31505550ef..c8791aa91a 100644 --- a/mobile/openapi/lib/model/rating_response.dart +++ b/mobile/openapi/lib/model/ratings_response.dart @@ -10,16 +10,16 @@ part of openapi.api; -class RatingResponse { - /// Returns a new [RatingResponse] instance. - RatingResponse({ +class RatingsResponse { + /// Returns a new [RatingsResponse] instance. + RatingsResponse({ this.enabled = false, }); bool enabled; @override - bool operator ==(Object other) => identical(this, other) || other is RatingResponse && + bool operator ==(Object other) => identical(this, other) || other is RatingsResponse && other.enabled == enabled; @override @@ -28,7 +28,7 @@ class RatingResponse { (enabled.hashCode); @override - String toString() => 'RatingResponse[enabled=$enabled]'; + String toString() => 'RatingsResponse[enabled=$enabled]'; Map toJson() { final json = {}; @@ -36,25 +36,25 @@ class RatingResponse { return json; } - /// Returns a new [RatingResponse] instance and imports its values from + /// Returns a new [RatingsResponse] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static RatingResponse? fromJson(dynamic value) { + static RatingsResponse? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return RatingResponse( + return RatingsResponse( enabled: mapValueOfType(json, r'enabled')!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = RatingResponse.fromJson(row); + final value = RatingsResponse.fromJson(row); if (value != null) { result.add(value); } @@ -63,12 +63,12 @@ class RatingResponse { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = RatingResponse.fromJson(entry.value); + final value = RatingsResponse.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -77,14 +77,14 @@ class RatingResponse { return map; } - // maps a json object with a list of RatingResponse-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of RatingsResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = RatingResponse.listFromJson(entry.value, growable: growable,); + map[entry.key] = RatingsResponse.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/memory_update.dart b/mobile/openapi/lib/model/ratings_update.dart similarity index 69% rename from mobile/openapi/lib/model/memory_update.dart rename to mobile/openapi/lib/model/ratings_update.dart index f2529186c0..bde51bad1b 100644 --- a/mobile/openapi/lib/model/memory_update.dart +++ b/mobile/openapi/lib/model/ratings_update.dart @@ -10,9 +10,9 @@ part of openapi.api; -class MemoryUpdate { - /// Returns a new [MemoryUpdate] instance. - MemoryUpdate({ +class RatingsUpdate { + /// Returns a new [RatingsUpdate] instance. + RatingsUpdate({ this.enabled, }); @@ -25,7 +25,7 @@ class MemoryUpdate { bool? enabled; @override - bool operator ==(Object other) => identical(this, other) || other is MemoryUpdate && + bool operator ==(Object other) => identical(this, other) || other is RatingsUpdate && other.enabled == enabled; @override @@ -34,7 +34,7 @@ class MemoryUpdate { (enabled == null ? 0 : enabled!.hashCode); @override - String toString() => 'MemoryUpdate[enabled=$enabled]'; + String toString() => 'RatingsUpdate[enabled=$enabled]'; Map toJson() { final json = {}; @@ -46,25 +46,25 @@ class MemoryUpdate { return json; } - /// Returns a new [MemoryUpdate] instance and imports its values from + /// Returns a new [RatingsUpdate] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static MemoryUpdate? fromJson(dynamic value) { + static RatingsUpdate? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return MemoryUpdate( + return RatingsUpdate( enabled: mapValueOfType(json, r'enabled'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = MemoryUpdate.fromJson(row); + final value = RatingsUpdate.fromJson(row); if (value != null) { result.add(value); } @@ -73,12 +73,12 @@ class MemoryUpdate { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = MemoryUpdate.fromJson(entry.value); + final value = RatingsUpdate.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -87,14 +87,14 @@ class MemoryUpdate { return map; } - // maps a json object with a list of MemoryUpdate-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of RatingsUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = MemoryUpdate.listFromJson(entry.value, growable: growable,); + map[entry.key] = RatingsUpdate.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/tags_response.dart b/mobile/openapi/lib/model/tags_response.dart new file mode 100644 index 0000000000..3a5ea3b20b --- /dev/null +++ b/mobile/openapi/lib/model/tags_response.dart @@ -0,0 +1,106 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagsResponse { + /// Returns a new [TagsResponse] instance. + TagsResponse({ + this.enabled = true, + this.sidebarWeb = true, + }); + + bool enabled; + + bool sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagsResponse && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode) + + (sidebarWeb.hashCode); + + @override + String toString() => 'TagsResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + json[r'sidebarWeb'] = this.sidebarWeb; + return json; + } + + /// Returns a new [TagsResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagsResponse? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagsResponse( + enabled: mapValueOfType(json, r'enabled')!, + sidebarWeb: mapValueOfType(json, r'sidebarWeb')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagsResponse.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagsResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagsResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagsResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + 'sidebarWeb', + }; +} + diff --git a/mobile/openapi/lib/model/tags_update.dart b/mobile/openapi/lib/model/tags_update.dart new file mode 100644 index 0000000000..8355b00a00 --- /dev/null +++ b/mobile/openapi/lib/model/tags_update.dart @@ -0,0 +1,124 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagsUpdate { + /// Returns a new [TagsUpdate] instance. + TagsUpdate({ + this.enabled, + this.sidebarWeb, + }); + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagsUpdate && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled == null ? 0 : enabled!.hashCode) + + (sidebarWeb == null ? 0 : sidebarWeb!.hashCode); + + @override + String toString() => 'TagsUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + if (this.sidebarWeb != null) { + json[r'sidebarWeb'] = this.sidebarWeb; + } else { + // json[r'sidebarWeb'] = null; + } + return json; + } + + /// Returns a new [TagsUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagsUpdate? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagsUpdate( + enabled: mapValueOfType(json, r'enabled'), + sidebarWeb: mapValueOfType(json, r'sidebarWeb'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagsUpdate.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagsUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagsUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagsUpdate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index 6401a36f9f..d3927df8d7 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -16,9 +16,12 @@ class UserPreferencesResponseDto { required this.avatar, required this.download, required this.emailNotifications, + required this.folders, required this.memories, + required this.people, required this.purchase, - required this.rating, + required this.ratings, + required this.tags, }); AvatarResponse avatar; @@ -27,20 +30,29 @@ class UserPreferencesResponseDto { EmailNotificationsResponse emailNotifications; - MemoryResponse memories; + FoldersResponse folders; + + MemoriesResponse memories; + + PeopleResponse people; PurchaseResponse purchase; - RatingResponse rating; + RatingsResponse ratings; + + TagsResponse tags; @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto && other.avatar == avatar && other.download == download && other.emailNotifications == emailNotifications && + other.folders == folders && other.memories == memories && + other.people == people && other.purchase == purchase && - other.rating == rating; + other.ratings == ratings && + other.tags == tags; @override int get hashCode => @@ -48,21 +60,27 @@ class UserPreferencesResponseDto { (avatar.hashCode) + (download.hashCode) + (emailNotifications.hashCode) + + (folders.hashCode) + (memories.hashCode) + + (people.hashCode) + (purchase.hashCode) + - (rating.hashCode); + (ratings.hashCode) + + (tags.hashCode); @override - String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, memories=$memories, purchase=$purchase, rating=$rating]'; + String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, tags=$tags]'; Map toJson() { final json = {}; json[r'avatar'] = this.avatar; json[r'download'] = this.download; json[r'emailNotifications'] = this.emailNotifications; + json[r'folders'] = this.folders; json[r'memories'] = this.memories; + json[r'people'] = this.people; json[r'purchase'] = this.purchase; - json[r'rating'] = this.rating; + json[r'ratings'] = this.ratings; + json[r'tags'] = this.tags; return json; } @@ -77,9 +95,12 @@ class UserPreferencesResponseDto { avatar: AvatarResponse.fromJson(json[r'avatar'])!, download: DownloadResponse.fromJson(json[r'download'])!, emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!, - memories: MemoryResponse.fromJson(json[r'memories'])!, + folders: FoldersResponse.fromJson(json[r'folders'])!, + memories: MemoriesResponse.fromJson(json[r'memories'])!, + people: PeopleResponse.fromJson(json[r'people'])!, purchase: PurchaseResponse.fromJson(json[r'purchase'])!, - rating: RatingResponse.fromJson(json[r'rating'])!, + ratings: RatingsResponse.fromJson(json[r'ratings'])!, + tags: TagsResponse.fromJson(json[r'tags'])!, ); } return null; @@ -130,9 +151,12 @@ class UserPreferencesResponseDto { 'avatar', 'download', 'emailNotifications', + 'folders', 'memories', + 'people', 'purchase', - 'rating', + 'ratings', + 'tags', }; } diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index cf55aebf97..2841c2f572 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -16,9 +16,12 @@ class UserPreferencesUpdateDto { this.avatar, this.download, this.emailNotifications, + this.folders, this.memories, + this.people, this.purchase, - this.rating, + this.ratings, + this.tags, }); /// @@ -51,7 +54,23 @@ class UserPreferencesUpdateDto { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - MemoryUpdate? memories; + FoldersUpdate? folders; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + MemoriesUpdate? memories; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + PeopleUpdate? people; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -67,16 +86,27 @@ class UserPreferencesUpdateDto { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - RatingUpdate? rating; + RatingsUpdate? ratings; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + TagsUpdate? tags; @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesUpdateDto && other.avatar == avatar && other.download == download && other.emailNotifications == emailNotifications && + other.folders == folders && other.memories == memories && + other.people == people && other.purchase == purchase && - other.rating == rating; + other.ratings == ratings && + other.tags == tags; @override int get hashCode => @@ -84,12 +114,15 @@ class UserPreferencesUpdateDto { (avatar == null ? 0 : avatar!.hashCode) + (download == null ? 0 : download!.hashCode) + (emailNotifications == null ? 0 : emailNotifications!.hashCode) + + (folders == null ? 0 : folders!.hashCode) + (memories == null ? 0 : memories!.hashCode) + + (people == null ? 0 : people!.hashCode) + (purchase == null ? 0 : purchase!.hashCode) + - (rating == null ? 0 : rating!.hashCode); + (ratings == null ? 0 : ratings!.hashCode) + + (tags == null ? 0 : tags!.hashCode); @override - String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, memories=$memories, purchase=$purchase, rating=$rating]'; + String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, tags=$tags]'; Map toJson() { final json = {}; @@ -108,20 +141,35 @@ class UserPreferencesUpdateDto { } else { // json[r'emailNotifications'] = null; } + if (this.folders != null) { + json[r'folders'] = this.folders; + } else { + // json[r'folders'] = null; + } if (this.memories != null) { json[r'memories'] = this.memories; } else { // json[r'memories'] = null; } + if (this.people != null) { + json[r'people'] = this.people; + } else { + // json[r'people'] = null; + } if (this.purchase != null) { json[r'purchase'] = this.purchase; } else { // json[r'purchase'] = null; } - if (this.rating != null) { - json[r'rating'] = this.rating; + if (this.ratings != null) { + json[r'ratings'] = this.ratings; } else { - // json[r'rating'] = null; + // json[r'ratings'] = null; + } + if (this.tags != null) { + json[r'tags'] = this.tags; + } else { + // json[r'tags'] = null; } return json; } @@ -137,9 +185,12 @@ class UserPreferencesUpdateDto { avatar: AvatarUpdate.fromJson(json[r'avatar']), download: DownloadUpdate.fromJson(json[r'download']), emailNotifications: EmailNotificationsUpdate.fromJson(json[r'emailNotifications']), - memories: MemoryUpdate.fromJson(json[r'memories']), + folders: FoldersUpdate.fromJson(json[r'folders']), + memories: MemoriesUpdate.fromJson(json[r'memories']), + people: PeopleUpdate.fromJson(json[r'people']), purchase: PurchaseUpdate.fromJson(json[r'purchase']), - rating: RatingUpdate.fromJson(json[r'rating']), + ratings: RatingsUpdate.fromJson(json[r'ratings']), + tags: TagsUpdate.fromJson(json[r'tags']), ); } return null; diff --git a/mobile/test/modules/utils/openapi_patching_test.dart b/mobile/test/modules/utils/openapi_patching_test.dart new file mode 100644 index 0000000000..b956c4bfb9 --- /dev/null +++ b/mobile/test/modules/utils/openapi_patching_test.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openapi/api.dart'; +import 'package:immich_mobile/utils/openapi_patching.dart'; + +void main() { + group('Test OpenApi Patching', () { + test('upgradeDto', () { + dynamic value; + String targetType; + + targetType = 'UserPreferencesResponseDto'; + value = jsonDecode(""" +{ + "download": { + "archiveSize": 4294967296, + "includeEmbeddedVideos": false + } +} +"""); + + upgradeDto(value, targetType); + expect(value['tags'], TagsResponse().toJson()); + expect(value['download']['includeEmbeddedVideos'], false); + }); + + test('addDefault', () { + dynamic value = jsonDecode(""" +{ + "download": { + "archiveSize": 4294967296, + "includeEmbeddedVideos": false + } +} +"""); + String keys = 'download.unknownKey'; + dynamic defaultValue = 69420; + + addDefault(value, keys, defaultValue); + expect(value['download']['unknownKey'], 69420); + + keys = 'alpha.beta'; + defaultValue = 'gamma'; + addDefault(value, keys, defaultValue); + expect(value['alpha']['beta'], 'gamma'); + }); + }); +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4d80353177..1ca112bf26 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9164,6 +9164,34 @@ ], "type": "object" }, + "FoldersResponse": { + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "sidebarWeb": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "enabled", + "sidebarWeb" + ], + "type": "object" + }, + "FoldersUpdate": { + "properties": { + "enabled": { + "type": "boolean" + }, + "sidebarWeb": { + "type": "boolean" + } + }, + "type": "object" + }, "ImageFormat": { "enum": [ "jpeg", @@ -9534,6 +9562,26 @@ ], "type": "string" }, + "MemoriesResponse": { + "properties": { + "enabled": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "MemoriesUpdate": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object" + }, "MemoryCreateDto": { "properties": { "assetIds": { @@ -9586,17 +9634,6 @@ ], "type": "object" }, - "MemoryResponse": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "MemoryResponseDto": { "properties": { "assets": { @@ -9660,14 +9697,6 @@ ], "type": "string" }, - "MemoryUpdate": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "type": "object" - }, "MemoryUpdateDto": { "properties": { "isSaved": { @@ -9953,6 +9982,23 @@ ], "type": "string" }, + "PeopleResponse": { + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "sidebarWeb": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "enabled", + "sidebarWeb" + ], + "type": "object" + }, "PeopleResponseDto": { "properties": { "hasNextPage": { @@ -9979,6 +10025,17 @@ ], "type": "object" }, + "PeopleUpdate": { + "properties": { + "enabled": { + "type": "boolean" + }, + "sidebarWeb": { + "type": "boolean" + } + }, + "type": "object" + }, "PeopleUpdateDto": { "properties": { "people": { @@ -10300,7 +10357,7 @@ ], "type": "object" }, - "RatingResponse": { + "RatingsResponse": { "properties": { "enabled": { "default": false, @@ -10312,7 +10369,7 @@ ], "type": "object" }, - "RatingUpdate": { + "RatingsUpdate": { "properties": { "enabled": { "type": "boolean" @@ -12002,6 +12059,34 @@ ], "type": "object" }, + "TagsResponse": { + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "sidebarWeb": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "enabled", + "sidebarWeb" + ], + "type": "object" + }, + "TagsUpdate": { + "properties": { + "enabled": { + "type": "boolean" + }, + "sidebarWeb": { + "type": "boolean" + } + }, + "type": "object" + }, "TimeBucketResponseDto": { "properties": { "count": { @@ -12379,23 +12464,35 @@ "emailNotifications": { "$ref": "#/components/schemas/EmailNotificationsResponse" }, + "folders": { + "$ref": "#/components/schemas/FoldersResponse" + }, "memories": { - "$ref": "#/components/schemas/MemoryResponse" + "$ref": "#/components/schemas/MemoriesResponse" + }, + "people": { + "$ref": "#/components/schemas/PeopleResponse" }, "purchase": { "$ref": "#/components/schemas/PurchaseResponse" }, - "rating": { - "$ref": "#/components/schemas/RatingResponse" + "ratings": { + "$ref": "#/components/schemas/RatingsResponse" + }, + "tags": { + "$ref": "#/components/schemas/TagsResponse" } }, "required": [ "avatar", "download", "emailNotifications", + "folders", "memories", + "people", "purchase", - "rating" + "ratings", + "tags" ], "type": "object" }, @@ -12410,14 +12507,23 @@ "emailNotifications": { "$ref": "#/components/schemas/EmailNotificationsUpdate" }, + "folders": { + "$ref": "#/components/schemas/FoldersUpdate" + }, "memories": { - "$ref": "#/components/schemas/MemoryUpdate" + "$ref": "#/components/schemas/MemoriesUpdate" + }, + "people": { + "$ref": "#/components/schemas/PeopleUpdate" }, "purchase": { "$ref": "#/components/schemas/PurchaseUpdate" }, - "rating": { - "$ref": "#/components/schemas/RatingUpdate" + "ratings": { + "$ref": "#/components/schemas/RatingsUpdate" + }, + "tags": { + "$ref": "#/components/schemas/TagsUpdate" } }, "type": "object" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 3fdcf33757..bad370ecfe 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -93,23 +93,38 @@ export type EmailNotificationsResponse = { albumUpdate: boolean; enabled: boolean; }; -export type MemoryResponse = { +export type FoldersResponse = { enabled: boolean; + sidebarWeb: boolean; +}; +export type MemoriesResponse = { + enabled: boolean; +}; +export type PeopleResponse = { + enabled: boolean; + sidebarWeb: boolean; }; export type PurchaseResponse = { hideBuyButtonUntil: string; showSupportBadge: boolean; }; -export type RatingResponse = { +export type RatingsResponse = { enabled: boolean; }; +export type TagsResponse = { + enabled: boolean; + sidebarWeb: boolean; +}; export type UserPreferencesResponseDto = { avatar: AvatarResponse; download: DownloadResponse; emailNotifications: EmailNotificationsResponse; - memories: MemoryResponse; + folders: FoldersResponse; + memories: MemoriesResponse; + people: PeopleResponse; purchase: PurchaseResponse; - rating: RatingResponse; + ratings: RatingsResponse; + tags: TagsResponse; }; export type AvatarUpdate = { color?: UserAvatarColor; @@ -123,23 +138,38 @@ export type EmailNotificationsUpdate = { albumUpdate?: boolean; enabled?: boolean; }; -export type MemoryUpdate = { +export type FoldersUpdate = { enabled?: boolean; + sidebarWeb?: boolean; +}; +export type MemoriesUpdate = { + enabled?: boolean; +}; +export type PeopleUpdate = { + enabled?: boolean; + sidebarWeb?: boolean; }; export type PurchaseUpdate = { hideBuyButtonUntil?: string; showSupportBadge?: boolean; }; -export type RatingUpdate = { +export type RatingsUpdate = { enabled?: boolean; }; +export type TagsUpdate = { + enabled?: boolean; + sidebarWeb?: boolean; +}; export type UserPreferencesUpdateDto = { avatar?: AvatarUpdate; download?: DownloadUpdate; emailNotifications?: EmailNotificationsUpdate; - memories?: MemoryUpdate; + folders?: FoldersUpdate; + memories?: MemoriesUpdate; + people?: PeopleUpdate; purchase?: PurchaseUpdate; - rating?: RatingUpdate; + ratings?: RatingsUpdate; + tags?: TagsUpdate; }; export type AlbumUserResponseDto = { role: AlbumUserRole; diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 7ccf6cd78b..8de7021eaf 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -12,16 +12,40 @@ class AvatarUpdate { color?: UserAvatarColor; } -class MemoryUpdate { +class MemoriesUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; } -class RatingUpdate { +class RatingsUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; } +class FoldersUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateBoolean({ optional: true }) + sidebarWeb?: boolean; +} + +class PeopleUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateBoolean({ optional: true }) + sidebarWeb?: boolean; +} + +class TagsUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateBoolean({ optional: true }) + sidebarWeb?: boolean; +} + class EmailNotificationsUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; @@ -56,19 +80,34 @@ class PurchaseUpdate { export class UserPreferencesUpdateDto { @Optional() @ValidateNested() - @Type(() => RatingUpdate) - rating?: RatingUpdate; + @Type(() => FoldersUpdate) + folders?: FoldersUpdate; + + @Optional() + @ValidateNested() + @Type(() => MemoriesUpdate) + memories?: MemoriesUpdate; + + @Optional() + @ValidateNested() + @Type(() => PeopleUpdate) + people?: PeopleUpdate; + + @Optional() + @ValidateNested() + @Type(() => RatingsUpdate) + ratings?: RatingsUpdate; + + @Optional() + @ValidateNested() + @Type(() => TagsUpdate) + tags?: TagsUpdate; @Optional() @ValidateNested() @Type(() => AvatarUpdate) avatar?: AvatarUpdate; - @Optional() - @ValidateNested() - @Type(() => MemoryUpdate) - memories?: MemoryUpdate; - @Optional() @ValidateNested() @Type(() => EmailNotificationsUpdate) @@ -90,12 +129,27 @@ class AvatarResponse { color!: UserAvatarColor; } -class RatingResponse { +class RatingsResponse { enabled: boolean = false; } -class MemoryResponse { - enabled!: boolean; +class MemoriesResponse { + enabled: boolean = true; +} + +class FoldersResponse { + enabled: boolean = false; + sidebarWeb: boolean = false; +} + +class PeopleResponse { + enabled: boolean = true; + sidebarWeb: boolean = false; +} + +class TagsResponse { + enabled: boolean = true; + sidebarWeb: boolean = true; } class EmailNotificationsResponse { @@ -117,8 +171,11 @@ class PurchaseResponse { } export class UserPreferencesResponseDto implements UserPreferences { - rating!: RatingResponse; - memories!: MemoryResponse; + folders!: FoldersResponse; + memories!: MemoriesResponse; + people!: PeopleResponse; + ratings!: RatingsResponse; + tags!: TagsResponse; avatar!: AvatarResponse; emailNotifications!: EmailNotificationsResponse; download!: DownloadResponse; diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index eadcdeec57..c342cb71f8 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -19,12 +19,24 @@ export class UserMetadataEntity } export interface UserPreferences { - rating: { + folders: { enabled: boolean; + sidebarWeb: boolean; }; memories: { enabled: boolean; }; + people: { + enabled: boolean; + sidebarWeb: boolean; + }; + ratings: { + enabled: boolean; + }; + tags: { + enabled: boolean; + sidebarWeb: boolean; + }; avatar: { color: UserAvatarColor; }; @@ -50,12 +62,24 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences ); return { - rating: { + folders: { enabled: false, + sidebarWeb: false, }, memories: { enabled: true, }, + people: { + enabled: true, + sidebarWeb: false, + }, + ratings: { + enabled: false, + }, + tags: { + enabled: false, + sidebarWeb: false, + }, avatar: { color: values[randomIndex], }, diff --git a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte index 8b18d14f03..b73fe71716 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte @@ -20,7 +20,7 @@ }; -{#if !isSharedLink() && $preferences?.rating?.enabled} +{#if !isSharedLink() && $preferences?.ratings.enabled}
    handlePromiseError(handleChangeRating(rating))} />
    diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 0a105430cc..5ffc5120b6 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -6,7 +6,7 @@ import { boundingBoxesArray } from '$lib/stores/people.store'; import { locale } from '$lib/stores/preferences.store'; import { featureFlags } from '$lib/stores/server-config.store'; - import { user } from '$lib/stores/user.store'; + import { preferences, user } from '$lib/stores/user.store'; import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError, isSharedLink } from '$lib/utils'; import { delay, isFlipped } from '$lib/utils/asset-utils'; import { @@ -502,9 +502,11 @@
    {/if} -
    - -
    +{#if $preferences?.tags?.enabled} +
    + +
    +{/if} {#if showEditFaces} { - accordionElement.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - }, 200); + if (autoScrollTo) { + setTimeout(() => { + accordionElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + }, 200); + } } else { $accordionState.delete(key); $accordionState = $accordionState; @@ -72,7 +75,7 @@ {#if isOpen} -
      +
      {/if} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index dd777d1259..fab7c6ed6d 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -1,5 +1,4 @@
      @@ -189,29 +169,6 @@ bind:checked={$showDeleteModal} />
    - -
    - -
    -
    - -
    -
    - handleRatingChange(enabled)} - /> -
    diff --git a/web/src/lib/components/user-settings-page/feature-settings.svelte b/web/src/lib/components/user-settings-page/feature-settings.svelte new file mode 100644 index 0000000000..dc11dab15e --- /dev/null +++ b/web/src/lib/components/user-settings-page/feature-settings.svelte @@ -0,0 +1,124 @@ + + +
    +
    +
    +
    + +
    + +
    + + {#if foldersEnabled} +
    + +
    + {/if} +
    + + +
    + +
    +
    + + +
    + +
    + + {#if peopleEnabled} +
    + +
    + {/if} +
    + + +
    + +
    +
    + + +
    + +
    + {#if tagsEnabled} +
    + +
    + {/if} +
    + +
    + +
    +
    +
    +
    +
    diff --git a/web/src/lib/components/user-settings-page/memories-settings.svelte b/web/src/lib/components/user-settings-page/memories-settings.svelte deleted file mode 100644 index e8a58bf016..0000000000 --- a/web/src/lib/components/user-settings-page/memories-settings.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - -
    -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    -
    diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index df32126a2d..596efaedef 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -10,7 +10,6 @@ import AppSettings from './app-settings.svelte'; import ChangePasswordSettings from './change-password-settings.svelte'; import DeviceList from './device-list.svelte'; - import MemoriesSettings from './memories-settings.svelte'; import OAuthSettings from './oauth-settings.svelte'; import PartnerSettings from './partner-settings.svelte'; import UserAPIKeyList from './user-api-key-list.svelte'; @@ -19,6 +18,7 @@ import { t } from 'svelte-i18n'; import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte'; import UserPurchaseSettings from '$lib/components/user-settings-page/user-purchase-settings.svelte'; + import FeatureSettings from '$lib/components/user-settings-page/feature-settings.svelte'; export let keys: ApiKeyResponseDto[] = []; export let sessions: SessionResponseDto[] = []; @@ -53,8 +53,8 @@ - - + + @@ -84,6 +84,7 @@ key="user-purchase-settings" title={$t('user_purchase_settings')} subtitle={$t('user_purchase_settings_description')} + autoScrollTo={true} > diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 684cb0e319..dcefccf2ef 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -701,6 +701,8 @@ "favorite_or_unfavorite_photo": "Favorite or unfavorite photo", "favorites": "Favorites", "feature_photo_updated": "Feature photo updated", + "features": "Features", + "features_setting_description": "Manage the app features", "file_name": "File name", "file_name_or_extension": "File name or extension", "filename": "Filename", @@ -709,6 +711,7 @@ "find_them_fast": "Find them fast by name with search", "fix_incorrect_match": "Fix incorrect match", "folders": "Folders", + "folders_feature_description": "Browsing the folder view for the photos and videos on the file system", "force_re-scan_library_files": "Force Re-scan All Library Files", "forward": "Forward", "general": "General", @@ -912,6 +915,7 @@ "pending": "Pending", "people": "People", "people_edits_count": "Edited {count, plural, one {# person} other {# people}}", + "people_feature_description": "Browsing photos and videos grouped by people", "people_sidebar_description": "Display a link to People in the sidebar", "permanent_deletion_warning": "Permanent deletion warning", "permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets", @@ -981,7 +985,7 @@ "rating": "Star rating", "rating_clear": "Clear rating", "rating_count": "{count, plural, one {# star} other {# stars}}", - "rating_description": "Display the exif rating in the info panel", + "rating_description": "Display the EXIF rating in the info panel", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", "reassign": "Reassign", @@ -1130,6 +1134,8 @@ "show_supporter_badge": "Supporter badge", "show_supporter_badge_description": "Show a supporter badge", "shuffle": "Shuffle", + "sidebar": "Sidebar", + "sidebar_display_description": "Display a link to the view in the sidebar", "sign_out": "Sign Out", "sign_up": "Sign up", "size": "Size", @@ -1169,6 +1175,7 @@ "tag": "Tag", "tag_assets": "Tag assets", "tag_created": "Created tag: {tag}", + "tag_feature_description": "Browsing photos and videos grouped by logical tag topics", "tag_updated": "Updated tag: {tag}", "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}", "tags": "Tags", diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 11473f8061..de80702b95 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -96,11 +96,6 @@ export interface SidebarSettings { sharing: boolean; } -export const sidebarSettings = persisted('sidebar-settings-1', { - people: false, - sharing: true, -}); - export enum SortOrder { Asc = 'asc', Desc = 'desc', diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index e15c20cbbe..70e74f84f1 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -81,7 +81,9 @@ assetStore.removeAssets(assetIds)} /> - + {#if $preferences.tags.enabled} + + {/if} assetStore.removeAssets(assetIds)} />
    From 6fe011e2d791c9e4c37c6ff4b86f71068bdac577 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 29 Aug 2024 16:14:52 -0500 Subject: [PATCH 207/723] feat(web): jump to timeline (#12117) * feat(web): jump to timeline * Update web/src/lib/components/memory-page/memory-viewer.svelte Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> * wording and open in new tab * Use correct wording and icon * fix: hide on archived and trashed assets --------- Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Co-authored-by: Jason Rasmussen --- .../asset-viewer/asset-viewer-nav-bar.svelte | 10 ++++++++++ .../components/memory-page/memory-viewer.svelte | 14 ++++++++++++++ web/src/lib/i18n/en.json | 1 + 3 files changed, 25 insertions(+) diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index a57a7faef8..0f75f9bb83 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -1,4 +1,5 @@ +
    +

    + + + {message} + + +

    +
    here", "tag_updated": "Updated tag: {tag}", "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}", "tags": "Tags", From b9e5e40ced02b74784dc45c0101e1f21cf0f7a7c Mon Sep 17 00:00:00 2001 From: Pierre Couy Date: Fri, 30 Aug 2024 18:26:31 +0200 Subject: [PATCH 221/723] docs(guide): nginx caching proxy (#12140) * docs:Add link to nginx caching proxy guide Following comments on https://github.com/immich-app/immich/pull/11350 * docs:Fix typo * docs:Fix typo * docs:Switch to GitHub link --- docs/src/components/community-guides.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/src/components/community-guides.tsx b/docs/src/components/community-guides.tsx index 1c1ad7cabd..6982853fad 100644 --- a/docs/src/components/community-guides.tsx +++ b/docs/src/components/community-guides.tsx @@ -43,6 +43,11 @@ const guides: CommunityGuidesProps[] = [ description: 'Access your local Immich installation over the internet using your own domain', url: 'https://github.com/ppr88/immich-guides/blob/main/open-immich-custom-domain.md', }, + { + title: 'Nginx caching map server', + description: 'Increase privacy by using nginx as a caching proxy in front of a map tile server', + url: 'https://github.com/pcouy/pcouy.github.io/blob/main/_posts/2024-08-30-proxying-a-map-tile-server-for-increased-privacy.md', + }, ]; function CommunityGuide({ title, description, url }: CommunityGuidesProps): JSX.Element { From 9b1a985d29fe6866b06a7c06e2ccf0746953d2d5 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 30 Aug 2024 12:44:24 -0400 Subject: [PATCH 222/723] fix(server): tag upsert (#12141) --- e2e/src/api/specs/tag.e2e-spec.ts | 58 ++++++++++++++++--- .../openapi/lib/model/tag_response_dto.dart | 19 +++++- open-api/immich-openapi-specs.json | 3 + open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/dtos/tag.dto.ts | 2 + server/src/entities/tag.entity.ts | 7 ++- server/src/interfaces/tag.interface.ts | 1 + .../1725023079109-FixTagUniqueness.ts | 16 +++++ server/src/queries/asset.repository.sql | 2 +- server/src/repositories/tag.repository.ts | 42 ++++++++++++++ server/src/services/metadata.service.spec.ts | 31 +++++----- server/src/services/tag.service.spec.ts | 14 ++--- server/src/utils/tag.ts | 7 +-- .../test/repositories/tag.repository.mock.ts | 1 + 14 files changed, 163 insertions(+), 41 deletions(-) create mode 100644 server/src/migrations/1725023079109-FixTagUniqueness.ts diff --git a/e2e/src/api/specs/tag.e2e-spec.ts b/e2e/src/api/specs/tag.e2e-spec.ts index 0a26ccef0e..a4cbc99ed3 100644 --- a/e2e/src/api/specs/tag.e2e-spec.ts +++ b/e2e/src/api/specs/tag.e2e-spec.ts @@ -3,6 +3,7 @@ import { LoginResponseDto, Permission, TagCreateDto, + TagResponseDto, createTag, getAllTags, tagAssets, @@ -81,15 +82,31 @@ describe('/tags', () => { expect(status).toBe(201); }); + it('should allow multiple users to create tags with the same value', async () => { + await create(admin.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .post('/tags') + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ name: 'TagA' }); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + it('should create a nested tag', async () => { const parent = await create(admin.accessToken, { name: 'TagA' }); - const { status, body } = await request(app) .post('/tags') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ name: 'TagB', parentId: parent.id }); expect(body).toEqual({ id: expect.any(String), + parentId: parent.id, name: 'TagB', value: 'TagA/TagB', createdAt: expect.any(String), @@ -134,14 +151,20 @@ describe('/tags', () => { it('should return a nested tags', async () => { await upsert(admin.accessToken, ['TagA/TagB/TagC', 'TagD']); const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toHaveLength(4); - expect(body).toEqual([ - expect.objectContaining({ name: 'TagA', value: 'TagA' }), - expect.objectContaining({ name: 'TagB', value: 'TagA/TagB' }), - expect.objectContaining({ name: 'TagC', value: 'TagA/TagB/TagC' }), - expect.objectContaining({ name: 'TagD', value: 'TagD' }), - ]); expect(status).toEqual(200); + + const tags = body as TagResponseDto[]; + const tagA = tags.find((tag) => tag.value === 'TagA') as TagResponseDto; + const tagB = tags.find((tag) => tag.value === 'TagA/TagB') as TagResponseDto; + const tagC = tags.find((tag) => tag.value === 'TagA/TagB/TagC') as TagResponseDto; + const tagD = tags.find((tag) => tag.value === 'TagD') as TagResponseDto; + + expect(tagA).toEqual(expect.objectContaining({ name: 'TagA', value: 'TagA' })); + expect(tagB).toEqual(expect.objectContaining({ name: 'TagB', value: 'TagA/TagB', parentId: tagA.id })); + expect(tagC).toEqual(expect.objectContaining({ name: 'TagC', value: 'TagA/TagB/TagC', parentId: tagB.id })); + expect(tagD).toEqual(expect.objectContaining({ name: 'TagD', value: 'TagD' })); }); }); @@ -167,6 +190,26 @@ describe('/tags', () => { expect(status).toBe(200); expect(body).toEqual([expect.objectContaining({ name: 'TagD', value: 'TagA/TagB/TagC/TagD' })]); }); + + it('should upsert tags in parallel without conflicts', async () => { + const [[tag1], [tag2], [tag3], [tag4]] = await Promise.all([ + upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']), + upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']), + upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']), + upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']), + ]); + + const { id, parentId, createdAt } = tag1; + for (const tag of [tag1, tag2, tag3, tag4]) { + expect(tag).toMatchObject({ + id, + parentId, + createdAt, + name: 'TagD', + value: 'TagA/TagB/TagC/TagD', + }); + } + }); }); describe('PUT /tags/assets', () => { @@ -296,6 +339,7 @@ describe('/tags', () => { expect(status).toBe(200); expect(body).toEqual({ id: expect.any(String), + parentId: tagC.id, name: 'TagD', value: 'TagA/TagB/TagC/TagD', createdAt: expect.any(String), diff --git a/mobile/openapi/lib/model/tag_response_dto.dart b/mobile/openapi/lib/model/tag_response_dto.dart index 4f0a62a8b9..1d1a88c3cf 100644 --- a/mobile/openapi/lib/model/tag_response_dto.dart +++ b/mobile/openapi/lib/model/tag_response_dto.dart @@ -17,6 +17,7 @@ class TagResponseDto { required this.createdAt, required this.id, required this.name, + this.parentId, required this.updatedAt, required this.value, }); @@ -35,6 +36,14 @@ class TagResponseDto { String name; + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? parentId; + DateTime updatedAt; String value; @@ -45,6 +54,7 @@ class TagResponseDto { other.createdAt == createdAt && other.id == id && other.name == name && + other.parentId == parentId && other.updatedAt == updatedAt && other.value == value; @@ -55,11 +65,12 @@ class TagResponseDto { (createdAt.hashCode) + (id.hashCode) + (name.hashCode) + + (parentId == null ? 0 : parentId!.hashCode) + (updatedAt.hashCode) + (value.hashCode); @override - String toString() => 'TagResponseDto[color=$color, createdAt=$createdAt, id=$id, name=$name, updatedAt=$updatedAt, value=$value]'; + String toString() => 'TagResponseDto[color=$color, createdAt=$createdAt, id=$id, name=$name, parentId=$parentId, updatedAt=$updatedAt, value=$value]'; Map toJson() { final json = {}; @@ -71,6 +82,11 @@ class TagResponseDto { json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); json[r'id'] = this.id; json[r'name'] = this.name; + if (this.parentId != null) { + json[r'parentId'] = this.parentId; + } else { + // json[r'parentId'] = null; + } json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); json[r'value'] = this.value; return json; @@ -88,6 +104,7 @@ class TagResponseDto { createdAt: mapDateTime(json, r'createdAt', r'')!, id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, + parentId: mapValueOfType(json, r'parentId'), updatedAt: mapDateTime(json, r'updatedAt', r'')!, value: mapValueOfType(json, r'value')!, ); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 50bd57b527..97a31ead26 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12024,6 +12024,9 @@ "name": { "type": "string" }, + "parentId": { + "type": "string" + }, "updatedAt": { "format": "date-time", "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 0bd67c231e..2c336f98be 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -232,6 +232,7 @@ export type TagResponseDto = { createdAt: string; id: string; name: string; + parentId?: string; updatedAt: string; value: string; }; diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index 40c5b176ff..cff11962d7 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -45,6 +45,7 @@ export class TagBulkAssetsResponseDto { export class TagResponseDto { id!: string; + parentId?: string; name!: string; value!: string; createdAt!: Date; @@ -55,6 +56,7 @@ export class TagResponseDto { export function mapTag(entity: TagEntity): TagResponseDto { return { id: entity.id, + parentId: entity.parentId ?? undefined, name: entity.value.split('/').at(-1) as string, value: entity.value, createdAt: entity.createdAt, diff --git a/server/src/entities/tag.entity.ts b/server/src/entities/tag.entity.ts index 940b446aea..ebcc6853c9 100644 --- a/server/src/entities/tag.entity.ts +++ b/server/src/entities/tag.entity.ts @@ -10,16 +10,18 @@ import { Tree, TreeChildren, TreeParent, + Unique, UpdateDateColumn, } from 'typeorm'; @Entity('tags') +@Unique(['userId', 'value']) @Tree('closure-table') export class TagEntity { @PrimaryGeneratedColumn('uuid') id!: string; - @Column({ unique: true }) + @Column() value!: string; @CreateDateColumn({ type: 'timestamptz' }) @@ -31,6 +33,9 @@ export class TagEntity { @Column({ type: 'varchar', nullable: true, default: null }) color!: string | null; + @Column({ nullable: true }) + parentId?: string; + @TreeParent({ onDelete: 'CASCADE' }) parent?: TagEntity; diff --git a/server/src/interfaces/tag.interface.ts b/server/src/interfaces/tag.interface.ts index f9f3784f06..aca9c223d5 100644 --- a/server/src/interfaces/tag.interface.ts +++ b/server/src/interfaces/tag.interface.ts @@ -8,6 +8,7 @@ export type AssetTagItem = { assetId: string; tagId: string }; export interface ITagRepository extends IBulkAsset { getAll(userId: string): Promise; getByValue(userId: string, value: string): Promise; + upsertValue(request: { userId: string; value: string; parent?: TagEntity }): Promise; create(tag: Partial): Promise; get(id: string): Promise; diff --git a/server/src/migrations/1725023079109-FixTagUniqueness.ts b/server/src/migrations/1725023079109-FixTagUniqueness.ts new file mode 100644 index 0000000000..859712621c --- /dev/null +++ b/server/src/migrations/1725023079109-FixTagUniqueness.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class FixTagUniqueness1725023079109 implements MigrationInterface { + name = 'FixTagUniqueness1725023079109' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451"`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_79d6f16e52bb2c7130375246793" UNIQUE ("userId", "value")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_79d6f16e52bb2c7130375246793"`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451" UNIQUE ("value")`); + } + +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index ba52f7d148..3852439936 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -188,8 +188,8 @@ SELECT "AssetEntity__AssetEntity_tags"."createdAt" AS "AssetEntity__AssetEntity_tags_createdAt", "AssetEntity__AssetEntity_tags"."updatedAt" AS "AssetEntity__AssetEntity_tags_updatedAt", "AssetEntity__AssetEntity_tags"."color" AS "AssetEntity__AssetEntity_tags_color", - "AssetEntity__AssetEntity_tags"."userId" AS "AssetEntity__AssetEntity_tags_userId", "AssetEntity__AssetEntity_tags"."parentId" AS "AssetEntity__AssetEntity_tags_parentId", + "AssetEntity__AssetEntity_tags"."userId" AS "AssetEntity__AssetEntity_tags_userId", "AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id", "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId", "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId", diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 7699d5897a..9389aeb13b 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -22,6 +22,48 @@ export class TagRepository implements ITagRepository { return this.repository.findOne({ where: { userId, value } }); } + async upsertValue({ + userId, + value, + parent, + }: { + userId: string; + value: string; + parent?: TagEntity; + }): Promise { + return this.dataSource.transaction(async (manager) => { + // upsert tag + const { identifiers } = await manager.upsert( + TagEntity, + { userId, value, parentId: parent?.id }, + { conflictPaths: { userId: true, value: true } }, + ); + const id = identifiers[0]?.id; + if (!id) { + throw new Error('Failed to upsert tag'); + } + + // update closure table + await manager.query( + `INSERT INTO tags_closure (id_ancestor, id_descendant) + VALUES ($1, $1) + ON CONFLICT DO NOTHING;`, + [id], + ); + + if (parent) { + await manager.query( + `INSERT INTO tags_closure (id_ancestor, id_descendant) + SELECT id_ancestor, '${id}' as id_descendant FROM tags_closure WHERE id_descendant = $1 + ON CONFLICT DO NOTHING`, + [parent.id], + ); + } + + return manager.findOneOrFail(TagEntity, { where: { id } }); + }); + } + async getAll(userId: string): Promise { const tags = await this.repository.find({ where: { userId }, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index cb89de184a..8f44962279 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -365,25 +365,23 @@ describe(MetadataService.name, () => { it('should extract tags from TagsList', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent'] }); - tagMock.getByValue.mockResolvedValue(null); - tagMock.create.mockResolvedValue(tagStub.parent); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); }); it('should extract hierarchy from TagsList', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent/Child'] }); - tagMock.getByValue.mockResolvedValue(null); - tagMock.create.mockResolvedValueOnce(tagStub.parent); - tagMock.create.mockResolvedValueOnce(tagStub.child); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.create).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.create).toHaveBeenNthCalledWith(2, { + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', parent: tagStub.parent, @@ -393,35 +391,32 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a string', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent' }); - tagMock.getByValue.mockResolvedValue(null); - tagMock.create.mockResolvedValue(tagStub.parent); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); }); it('should extract tags from Keywords as a list', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent'] }); - tagMock.getByValue.mockResolvedValue(null); - tagMock.create.mockResolvedValue(tagStub.parent); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); }); it('should extract hierarchal tags from Keywords', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent/Child' }); - tagMock.getByValue.mockResolvedValue(null); - tagMock.create.mockResolvedValue(tagStub.parent); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.create).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.create).toHaveBeenNthCalledWith(2, { + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', parent: tagStub.parent, diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index ffa7895cb4..de270777b0 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -115,9 +115,9 @@ describe(TagService.name, () => { describe('upsert', () => { it('should upsert a new tag', async () => { - tagMock.create.mockResolvedValue(tagStub.parent); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined(); - expect(tagMock.create).toHaveBeenCalledWith({ + expect(tagMock.upsertValue).toHaveBeenCalledWith({ value: 'Parent', userId: 'admin_id', parentId: undefined, @@ -126,15 +126,15 @@ describe(TagService.name, () => { it('should upsert a nested tag', async () => { tagMock.getByValue.mockResolvedValueOnce(null); - tagMock.create.mockResolvedValueOnce(tagStub.parent); - tagMock.create.mockResolvedValueOnce(tagStub.child); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined(); - expect(tagMock.create).toHaveBeenNthCalledWith(1, { + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { value: 'Parent', userId: 'admin_id', - parentId: undefined, + parent: undefined, }); - expect(tagMock.create).toHaveBeenNthCalledWith(2, { + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { value: 'Parent/Child', userId: 'admin_id', parent: expect.objectContaining({ id: 'tag-parent' }), diff --git a/server/src/utils/tag.ts b/server/src/utils/tag.ts index 12c46d2440..6d6c70f1d7 100644 --- a/server/src/utils/tag.ts +++ b/server/src/utils/tag.ts @@ -13,12 +13,7 @@ export const upsertTags = async (repository: ITagRepository, { userId, tags }: U for (const part of parts) { const value = parent ? `${parent.value}/${part}` : part; - let tag = await repository.getByValue(userId, value); - if (!tag) { - tag = await repository.create({ userId, value, parent }); - } - - parent = tag; + parent = await repository.upsertValue({ userId, value, parent }); } if (parent) { diff --git a/server/test/repositories/tag.repository.mock.ts b/server/test/repositories/tag.repository.mock.ts index 35b3de1576..a3fc0e77e0 100644 --- a/server/test/repositories/tag.repository.mock.ts +++ b/server/test/repositories/tag.repository.mock.ts @@ -5,6 +5,7 @@ export const newTagRepositoryMock = (): Mocked => { return { getAll: vitest.fn(), getByValue: vitest.fn(), + upsertValue: vitest.fn(), upsertAssetTags: vitest.fn(), get: vitest.fn(), From 860ba78650693a371d2a3f7b8b4cd8502ca59dea Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 30 Aug 2024 18:07:02 +0100 Subject: [PATCH 223/723] ci: fix release script (#12146) --- .github/workflows/prepare-release.yml | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 6668976bcf..fc03b24d08 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -29,17 +29,6 @@ jobs: ref: ${{ steps.push-tag.outputs.commit_long_sha }} steps: - - name: Checkout - uses: actions/checkout@v4 - with: - token: ${{ secrets.ORG_RELEASE_TOKEN }} - - - name: Install Poetry - run: pipx install poetry - - - name: Bump version - run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}" - - name: Generate a token id: generate-token uses: actions/create-github-app-token@v1 @@ -47,6 +36,17 @@ jobs: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ steps.generate-token.outputs.token }} + + - name: Install Poetry + run: pipx install poetry + + - name: Bump version + run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}" + - name: Commit and tag id: push-tag uses: EndBug/add-and-commit@v9 @@ -55,7 +55,6 @@ jobs: message: 'chore: version ${{ env.IMMICH_VERSION }}' tag: ${{ env.IMMICH_VERSION }} push: true - github-token: ${{ steps.generate-token.outputs.token }} build_mobile: uses: ./.github/workflows/build-mobile.yml From cc88cbb456e6f3e1c77680cebde4806ed44a8915 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 30 Aug 2024 17:16:21 +0000 Subject: [PATCH 224/723] chore: version v1.113.0 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index fa38bd275e..a8a636b3b6 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.15", + "version": "2.2.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.15", + "version": "2.2.16", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.112.1", + "version": "1.113.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index d739cc3895..316f8fb37a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.15", + "version": "2.2.16", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index c2bce22893..1dc2cd3a1f 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.113.0", + "url": "https://v1.113.0.archive.immich.app" + }, { "label": "v1.112.1", "url": "https://v1.112.1.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index cd591270db..3336c97740 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.112.1", + "version": "1.113.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.112.1", + "version": "1.113.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.15", + "version": "2.2.16", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.112.1", + "version": "1.113.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index add072df84..063b01cff1 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.112.1", + "version": "1.113.0", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 05ac4618cd..08e7d01bf4 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.112.1" +version = "1.113.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 3905d6d555..c0284945ff 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 154, - "android.injected.version.name" => "1.112.1", + "android.injected.version.code" => 155, + "android.injected.version.name" => "1.113.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index c7d078ceea..a9000ba86d 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.112.1" + version_number: "1.113.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b831f60b9a..66707f9175 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.112.1 +- API version: 1.113.0 - Generator version: 7.5.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 51a31a24e3..eeaae505a3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.112.1+154 +version: 1.113.0+155 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 97a31ead26..0b0e40b478 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7394,7 +7394,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.112.1", + "version": "1.113.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index afa002a5a3..312858d0a3 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.112.1", + "version": "1.113.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.112.1", + "version": "1.113.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index d7d6ba6cc5..c86a58ffb9 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.112.1", + "version": "1.113.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 2c336f98be..e7e4e6adbe 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.112.1 + * 1.113.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index e90256e29b..4fb6a04deb 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.112.1", + "version": "1.113.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.112.1", + "version": "1.113.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 42552f20b7..e3fa9a6081 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.112.1", + "version": "1.113.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index d5a2747893..fded54b2dc 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.112.1", + "version": "1.113.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.112.1", + "version": "1.113.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,7 +74,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.112.1", + "version": "1.113.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index d87b6e6c08..383bde7ac8 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.112.1", + "version": "1.113.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 51a11d0cb6d1557531e55fefbfaa988ff721a5ca Mon Sep 17 00:00:00 2001 From: Bastian Machek <16717398+bmachek@users.noreply.github.com> Date: Fri, 30 Aug 2024 20:01:50 +0200 Subject: [PATCH 225/723] docs(project): lightroom project (#12149) * Update community-projects.tsx Added my community project: lrc-immich-plugin * Update community-projects.tsx typo --- docs/src/components/community-projects.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx index 23a55ca9ce..d8273c67c2 100644 --- a/docs/src/components/community-projects.tsx +++ b/docs/src/components/community-projects.tsx @@ -38,6 +38,11 @@ const projects: CommunityProjectProps[] = [ description: 'Lightroom plugin to publish photos from Lightroom collections to Immich albums.', url: 'https://github.com/midzelis/mi.Immich.Publisher', }, + { + title: 'Lightroom Immich Plugin: lrc-immich-plugin', + description: 'Another Lightroom plugin to publish or export photos from Lightroom to Immich.', + url: 'https://github.com/bmachek/lrc-immich-plugin', + }, { title: 'Immich Duplicate Finder', description: 'Webapp that uses machine learning to identify near-duplicate images.', From 40854f358c86e2503269b6b172fddd4e3b18e026 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 30 Aug 2024 14:03:44 -0400 Subject: [PATCH 226/723] chore(deps): update dependency svelte to v4.2.19 [security] (#12147) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index fded54b2dc..d62a186189 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7719,9 +7719,9 @@ } }, "node_modules/svelte": { - "version": "4.2.18", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.18.tgz", - "integrity": "sha512-d0FdzYIiAePqRJEb90WlJDkjUEx42xhivxN8muUBmfZnP+tzUgz12DJ2hRJi8sIHCME7jeK1PTMgKPSfTd8JrA==", + "version": "4.2.19", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz", + "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.1", From 5e6ac87eafd4d93c205c834075d23729be4a5dd7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 30 Aug 2024 14:38:53 -0400 Subject: [PATCH 227/723] chore: object shorthand linting rule (#12152) chore: object shorthand --- cli/eslint.config.mjs | 1 + e2e/eslint.config.mjs | 1 + server/eslint.config.mjs | 1 + server/src/dtos/user-profile.dto.ts | 4 ++-- server/src/repositories/map.repository.ts | 2 +- server/src/services/album.service.ts | 2 +- server/src/services/auth.service.spec.ts | 6 +++--- server/src/services/auth.service.ts | 2 +- server/src/services/library.service.ts | 4 ++-- server/src/services/storage-template.service.spec.ts | 2 +- server/src/services/storage-template.service.ts | 2 +- server/test/fixtures/asset.stub.ts | 2 +- web/eslint.config.mjs | 1 + .../lib/components/faces-page/merge-face-selector.svelte | 2 +- .../components/photos-page/actions/remove-from-album.svelte | 2 +- .../photos-page/actions/remove-from-shared-link.svelte | 2 +- .../lib/components/photos-page/measure-date-group.svelte | 6 ++---- .../sharedlinks-page/covers/__tests__/share-cover.spec.ts | 2 +- web/src/lib/utils/asset-store-task-manager.ts | 4 ++-- web/src/lib/utils/asset-utils.ts | 4 ++-- web/src/lib/utils/person.ts | 2 +- web/src/lib/utils/timeline-util.ts | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- 23 files changed, 30 insertions(+), 28 deletions(-) diff --git a/cli/eslint.config.mjs b/cli/eslint.config.mjs index 3f724506a3..9115a1feb7 100644 --- a/cli/eslint.config.mjs +++ b/cli/eslint.config.mjs @@ -55,6 +55,7 @@ export default [ 'unicorn/import-style': 'off', curly: 2, 'prettier/prettier': 0, + 'object-shorthand': ['error', 'always'], }, }, ]; diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs index 9a1bb99598..fd1e8a0af6 100644 --- a/e2e/eslint.config.mjs +++ b/e2e/eslint.config.mjs @@ -59,6 +59,7 @@ export default [ 'unicorn/prefer-top-level-await': 'off', 'unicorn/prefer-event-target': 'off', 'unicorn/no-thenable': 'off', + 'object-shorthand': ['error', 'always'], }, }, ]; diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 638b7b2959..d29b6f7238 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -63,6 +63,7 @@ export default [ '@typescript-eslint/require-await': 'error', curly: 2, 'prettier/prettier': 0, + 'object-shorthand': ['error', 'always'], 'no-restricted-imports': [ 'error', diff --git a/server/src/dtos/user-profile.dto.ts b/server/src/dtos/user-profile.dto.ts index b14662c844..9659fa3965 100644 --- a/server/src/dtos/user-profile.dto.ts +++ b/server/src/dtos/user-profile.dto.ts @@ -13,7 +13,7 @@ export class CreateProfileImageResponseDto { export function mapCreateProfileImageResponse(userId: string, profileImagePath: string): CreateProfileImageResponseDto { return { - userId: userId, - profileImagePath: profileImagePath, + userId, + profileImagePath, }; } diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 555f1042bb..da4e30d47c 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -317,7 +317,7 @@ export class MapRepository implements IMapRepository { } const input = createReadStream(filePath); - const lineReader = readLine.createInterface({ input: input }); + const lineReader = readLine.createInterface({ input }); const adminMap = new Map(); for await (const line of lineReader) { diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 1cd5237b7a..b59364af9f 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -239,7 +239,7 @@ export class AlbumService { throw new BadRequestException('User not found'); } - await this.albumUserRepository.create({ userId: userId, albumId: id, role }); + await this.albumUserRepository.create({ userId, albumId: id, role }); await this.eventRepository.emit('album.invite', { id, userId }); } diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index d73896edb1..f2fa0c520a 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -46,7 +46,7 @@ const fixtures = { }; const oauthUserWithDefaultQuota = { - email: email, + email, name: ' ', oauthId: sub, quotaSizeInBytes: 1_073_741_824, @@ -561,7 +561,7 @@ describe('AuthService', () => { ); expect(userMock.create).toHaveBeenCalledWith({ - email: email, + email, name: ' ', oauthId: sub, quotaSizeInBytes: null, @@ -581,7 +581,7 @@ describe('AuthService', () => { ); expect(userMock.create).toHaveBeenCalledWith({ - email: email, + email, name: ' ', oauthId: sub, quotaSizeInBytes: 5_368_709_120, diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 10cf93b6a4..2b25decc07 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -421,7 +421,7 @@ export class AuthService { await this.sessionRepository.update({ id: session.id, updatedAt: new Date() }); } - return { user: session.user, session: session }; + return { user: session.user, session }; } throw new UnauthorizedException('Invalid user token'); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index c7f82eddea..bcd0a842c7 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -339,7 +339,7 @@ export class LibraryService { const libraryId = job.id; const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => - this.assetRepository.getAll(pagination, { libraryId: libraryId, withDeleted: true }), + this.assetRepository.getAll(pagination, { libraryId, withDeleted: true }), ); let assetsFound = false; @@ -465,7 +465,7 @@ export class LibraryService { libraryId: job.id, checksum: pathHash, originalPath: assetPath, - deviceAssetId: deviceAssetId, + deviceAssetId, deviceId: 'Library Import', fileCreatedAt: stats.mtime, fileModifiedAt: stats.mtime, diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 92d11eaa12..093cc5b2ff 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -309,7 +309,7 @@ describe(StorageTemplateService.name, () => { entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, - newPath: newPath, + newPath, }); expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); expect(storageMock.copyFile).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 829863e228..9836ad40ac 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -227,7 +227,7 @@ export class StorageTemplateService { const storagePath = this.render(this.template.compiled, { asset, filename: sanitized, - extension: extension, + extension, albumName, }); const fullPath = path.normalize(path.join(rootPath, storagePath)); diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index b8c7e06d82..5ee42224ba 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -31,7 +31,7 @@ const files: AssetFileEntity[] = [previewFile, thumbnailFile]; export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => { return { id: stackId, - assets: assets, + assets, owner: assets[0].owner, ownerId: assets[0].ownerId, primaryAsset: assets[0], diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index f4aec0e728..f1ba46355f 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -87,6 +87,7 @@ export default [ '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-misused-promises': 'error', '@typescript-eslint/require-await': 'error', + 'object-shorthand': ['error', 'always'], }, }, { diff --git a/web/src/lib/components/faces-page/merge-face-selector.svelte b/web/src/lib/components/faces-page/merge-face-selector.svelte index ea1445a938..71358361ce 100644 --- a/web/src/lib/components/faces-page/merge-face-selector.svelte +++ b/web/src/lib/components/faces-page/merge-face-selector.svelte @@ -81,7 +81,7 @@ const mergedPerson = await getPerson({ id: person.id }); const count = results.filter(({ success }) => success).length; notificationController.show({ - message: $t('merged_people_count', { values: { count: count } }), + message: $t('merged_people_count', { values: { count } }), type: NotificationType.Info, }); dispatch('merge', mergedPerson); diff --git a/web/src/lib/components/photos-page/actions/remove-from-album.svelte b/web/src/lib/components/photos-page/actions/remove-from-album.svelte index d76ea7b275..2384f95d2e 100644 --- a/web/src/lib/components/photos-page/actions/remove-from-album.svelte +++ b/web/src/lib/components/photos-page/actions/remove-from-album.svelte @@ -40,7 +40,7 @@ const count = results.filter(({ success }) => success).length; notificationController.show({ type: NotificationType.Info, - message: $t('assets_removed_count', { values: { count: count } }), + message: $t('assets_removed_count', { values: { count } }), }); clearSelect(); diff --git a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte index 0c785830d0..e838f0813d 100644 --- a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte +++ b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte @@ -45,7 +45,7 @@ notificationController.show({ type: NotificationType.Info, - message: $t('assets_removed_count', { values: { count: count } }), + message: $t('assets_removed_count', { values: { count } }), }); clearSelect(); diff --git a/web/src/lib/components/photos-page/measure-date-group.svelte b/web/src/lib/components/photos-page/measure-date-group.svelte index 98e423ae94..f458fe40dd 100644 --- a/web/src/lib/components/photos-page/measure-date-group.svelte +++ b/web/src/lib/components/photos-page/measure-date-group.svelte @@ -39,7 +39,7 @@ if (!heightPending) { const height = element.getBoundingClientRect().height; if (height !== 0) { - $assetStore.updateBucket(bucket.bucketDate, { height: height, measured: true }); + $assetStore.updateBucket(bucket.bucketDate, { height, measured: true }); } onMeasured(); @@ -65,9 +65,7 @@
    {#each bucket.dateGroups as dateGroup}
    -
    $assetStore.updateBucketDateGroup(bucket, dateGroup, { height: height })} - > +
    $assetStore.updateBucketDateGroup(bucket, dateGroup, { height })}>
    { it.skip('renders fallback image when asset is not resized', () => { const link = sharedLinkFactory.build({ assets: [assetFactory.build()] }); render(ShareCover, { - link: link, + link, preload: false, }); diff --git a/web/src/lib/utils/asset-store-task-manager.ts b/web/src/lib/utils/asset-store-task-manager.ts index 6ca4f057bd..e476738456 100644 --- a/web/src/lib/utils/asset-store-task-manager.ts +++ b/web/src/lib/utils/asset-store-task-manager.ts @@ -350,7 +350,7 @@ class IntersectionTask { this.internalTaskManager.queueScrollSensitiveTask({ task, cleanup, - componentId: componentId, + componentId, priority: this.priority, taskId: this.intersectedKey, }); @@ -367,7 +367,7 @@ class IntersectionTask { this.internalTaskManager.queueSeparateTask({ task, cleanup, - componentId: componentId, + componentId, taskId: this.separatedKey, }); } diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index ce7944b9c9..e309db5ff6 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -52,7 +52,7 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show timeout: 5000, message: count > 0 - ? $t('assets_added_to_album_count', { values: { count: count } }) + ? $t('assets_added_to_album_count', { values: { count } }) : $t('assets_were_part_of_album_count', { values: { count: assetIds.length } }), button: { text: $t('view_album'), @@ -264,7 +264,7 @@ export const downloadFile = async (asset: AssetResponseDto) => { downloadBlob(data, filename); } catch (error) { - handleError(error, $t('errors.error_downloading', { values: { filename: filename } })); + handleError(error, $t('errors.error_downloading', { values: { filename } })); downloadManager.clear(downloadKey); } finally { setTimeout(() => downloadManager.clear(downloadKey), 5000); diff --git a/web/src/lib/utils/person.ts b/web/src/lib/utils/person.ts index 79f9284d8a..0b30556516 100644 --- a/web/src/lib/utils/person.ts +++ b/web/src/lib/utils/person.ts @@ -28,5 +28,5 @@ export const searchNameLocal = ( }; export const getPersonNameWithHiddenValue = derived(t, ($t) => { - return (name: string, isHidden: boolean) => $t('person_hidden', { values: { name: name, hidden: isHidden } }); + return (name: string, isHidden: boolean) => $t('person_hidden', { values: { name, hidden: isHidden } }); }); diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 3a8f66ee08..541ebea7f5 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -107,7 +107,7 @@ export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | heightActual: false, intersecting: false, geometry: emptyGeometry(), - bucket: bucket, + bucket, }; }); } diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6762e3a1cc..0fa325c6f5 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -291,7 +291,7 @@ const count = results.filter(({ success }) => success).length; notificationController.show({ type: NotificationType.Info, - message: $t('assets_added_count', { values: { count: count } }), + message: $t('assets_added_count', { values: { count } }), }); await refreshAlbum(); From fcbc1ba399d24b73f788f7be4fd511cd1a249014 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 30 Aug 2024 14:00:31 -0500 Subject: [PATCH 228/723] fix(web): memory view in timeline href (#12158) --- web/src/lib/components/memory-page/memory-viewer.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 48a6cd1cec..0088eb7a43 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -309,7 +309,7 @@ class:opacity-100={!galleryInView} > Date: Fri, 30 Aug 2024 23:31:42 +0200 Subject: [PATCH 229/723] fix(web): unable to scroll timeline after using gesture (#12163) --- .../lib/components/asset-viewer/photo-viewer.svelte | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 6f6af652b9..0a3da9ade3 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -169,7 +169,13 @@
    {:else if !imageError} -
    +
    {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} {$getAltText(asset)}{value} + {value} {#if isOpen} From d18bc7007a5f1b63cd80202bd3b96af5096b5bb1 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 30 Aug 2024 17:33:42 -0400 Subject: [PATCH 231/723] fix: keyword parsing (#12164) --- server/src/services/metadata.service.spec.ts | 11 +++++++++++ server/src/services/metadata.service.ts | 7 +++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 8f44962279..3e3e5e0db1 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -408,6 +408,17 @@ describe(MetadataService.name, () => { expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); }); + it('should extract tags from Keywords as a list with a number', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] as any[] }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); + }); + it('should extract hierarchal tags from Keywords', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent/Child' }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 875414d84d..a0a8f9ebef 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -352,22 +352,21 @@ export class MetadataService { } private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) { - const tags: string[] = []; - + const tags: unknown[] = []; if (exifTags.TagsList) { tags.push(...exifTags.TagsList); } if (exifTags.Keywords) { let keywords = exifTags.Keywords; - if (typeof keywords === 'string') { + if (!Array.isArray(keywords)) { keywords = [keywords]; } tags.push(...keywords); } if (tags.length > 0) { - const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags }); + const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags: tags.map(String) }); const tagIds = results.map((tag) => tag.id); await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds }); } From 40327ad987d3811e80b8563bcb460e055a99ac2a Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 30 Aug 2024 16:35:06 -0500 Subject: [PATCH 232/723] chore(mobile): post release tasks (#12157) * sent to reviewer * sent to reviewer * update to app store * update to app store --- .../android/app/src/main/AndroidManifest.xml | 4 +- mobile/android/fastlane/Fastfile | 2 +- mobile/ios/Runner.xcodeproj/project.pbxproj | 40 +++++++++---------- mobile/ios/Runner/Base.lproj/Main.storyboard | 13 +++--- mobile/ios/Runner/Info.plist | 4 +- mobile/pubspec.yaml | 2 +- 6 files changed, 34 insertions(+), 31 deletions(-) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 041a4dbb72..39827b9391 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -69,7 +69,7 @@ - + @@ -14,13 +16,14 @@ - + - + + diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index c7a5991212..1c3ac477f8 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.112.1 + 1.113.0 CFBundleSignature ???? CFBundleVersion - 169 + 171 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index eeaae505a3..7b31e4f231 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.113.0+155 +version: 1.113.0+156 environment: sdk: '>=3.3.0 <4.0.0' From 67468ea3672f482cb374a274de1896e62e48ddff Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sat, 31 Aug 2024 19:24:38 +0200 Subject: [PATCH 233/723] fix(web): avoid deleting empty album unexpectedly (#12175) --- e2e/src/web/specs/album.e2e-spec.ts | 25 +++++++++++++++++++ .../[[assetId=id]]/+page.svelte | 4 +-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 e2e/src/web/specs/album.e2e-spec.ts diff --git a/e2e/src/web/specs/album.e2e-spec.ts b/e2e/src/web/specs/album.e2e-spec.ts new file mode 100644 index 0000000000..953c7d00ae --- /dev/null +++ b/e2e/src/web/specs/album.e2e-spec.ts @@ -0,0 +1,25 @@ +import { LoginResponseDto } from '@immich/sdk'; +import { test } from '@playwright/test'; +import { utils } from 'src/utils'; + +test.describe('Album', () => { + let admin: LoginResponseDto; + + test.beforeAll(async () => { + utils.initSdk(); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + }); + + test(`doesn't delete album after canceling add assets`, async ({ context, page }) => { + await utils.setAuthCookies(context, admin.accessToken); + + await page.goto('/albums'); + await page.getByRole('button', { name: 'Create album' }).click(); + await page.getByRole('button', { name: 'Select photos' }).click(); + await page.getByRole('button', { name: 'Close' }).click(); + + await page.reload(); + await page.getByRole('button', { name: 'Select photos' }).waitFor(); + }); +}); diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 0fa325c6f5..46812ff289 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -419,8 +419,8 @@ } }; - onNavigate(async () => { - if (album.assetCount === 0 && !album.albumName) { + onNavigate(async ({ to }) => { + if (!isAlbumsRoute(to?.route.id) && album.assetCount === 0 && !album.albumName) { await deleteAlbum(album); } }); From 6bfe54788f65e28c9d64b1e638a6d7eee7ffd41e Mon Sep 17 00:00:00 2001 From: Marco Malavolti Date: Sat, 31 Aug 2024 19:33:17 +0200 Subject: [PATCH 234/723] docs: update google oauth examples (#12162) * Small update on oauth.md for Google Authn * Replace "demo" with "example" to be consistent with other example --- docs/docs/administration/oauth.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index 96dca68e4f..2aba658933 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -154,21 +154,21 @@ Configuration of Authorised redirect URIs (Google Console) Configuration of OAuth in Immich System Settings -| Setting | Value | -| ---------------------------- | ------------------------------------------------------------------------------------------------------ | -| Issuer URL | [https://accounts.google.com](https://accounts.google.com) | -| Client ID | 7\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***vuls.apps.googleusercontent.com | -| Client Secret | G\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***OO | -| Scope | openid email profile | -| Signing Algorithm | RS256 | -| Storage Label Claim | preferred_username | -| Storage Quota Claim | immich_quota | -| Default Storage Quota (GiB) | 0 (0 for unlimited quota) | -| Button Text | Sign in with Google (optional) | -| Auto Register | Enabled (optional) | -| Auto Launch | Enabled | -| Mobile Redirect URI Override | Enabled (required) | -| Mobile Redirect URI | [https://demo.immich.app/api/oauth/mobile-redirect](https://demo.immich.app/api/oauth/mobile-redirect) | +| Setting | Value | +| ---------------------------- | ---------------------------------------------------------------------------------------------------- | +| Issuer URL | `https://accounts.google.com` | +| Client ID | 7\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***vuls.apps.googleusercontent.com | +| Client Secret | G\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***OO | +| Scope | openid email profile | +| Signing Algorithm | RS256 | +| Storage Label Claim | preferred_username | +| Storage Quota Claim | immich_quota | +| Default Storage Quota (GiB) | 0 (0 for unlimited quota) | +| Button Text | Sign in with Google (optional) | +| Auto Register | Enabled (optional) | +| Auto Launch | Enabled | +| Mobile Redirect URI Override | Enabled (required) | +| Mobile Redirect URI | `https://example.immich.app/api/oauth/mobile-redirect` | From 28bc7f318e6418960456327e76564970b7728b96 Mon Sep 17 00:00:00 2001 From: Qhilm <3350433+Qhilm@users.noreply.github.com> Date: Sat, 31 Aug 2024 21:52:20 +0200 Subject: [PATCH 235/723] docs: typo - accesible => accessible (#12178) [typo] accesible => accessible --- docs/docs/guides/remote-access.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/guides/remote-access.md b/docs/docs/guides/remote-access.md index 326ac6c93d..1ea068c3a0 100644 --- a/docs/docs/guides/remote-access.md +++ b/docs/docs/guides/remote-access.md @@ -48,7 +48,7 @@ A reverse proxy is a service that sits between web servers and clients. A revers If you're hosting your own reverse proxy, [Nginx](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) is a great option. An example configuration for Nginx is provided [here](/docs/administration/reverse-proxy.md). -You'll also need your own certificate to authenticate https connections. If you're making Immich publicly accesible, [Let's Encrypt](https://letsencrypt.org/) can provide a free certificate for your domain and is the recommended option. Alternatively, a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate) allows you to encrypt your connection to Immich, but it raises a security warning on the client's browser. +You'll also need your own certificate to authenticate https connections. If you're making Immich publicly accessible, [Let's Encrypt](https://letsencrypt.org/) can provide a free certificate for your domain and is the recommended option. Alternatively, a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate) allows you to encrypt your connection to Immich, but it raises a security warning on the client's browser. A remote reverse proxy like [Cloudflare](https://www.cloudflare.com/learning/cdn/glossary/reverse-proxy/) increases security by hiding the server IP address, which makes targeted attacks like [DDoS](https://www.cloudflare.com/learning/ddos/what-is-a-ddos-attack/) harder. From 39141d3f1cd3413ad8459bd85db41640eae84ab0 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Mon, 2 Sep 2024 01:06:35 +0200 Subject: [PATCH 236/723] fix(server): remove offline assets from trash (#12199) * use port not taken by immich-dev for e2e * remove offline files from trash --- e2e/src/api/specs/library.e2e-spec.ts | 58 +++++++++++++++++++-- server/src/interfaces/asset.interface.ts | 7 ++- server/src/repositories/asset.repository.ts | 8 ++- server/src/services/library.service.ts | 2 +- 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index ec42cbe4fa..2f6274d1fc 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -635,10 +635,11 @@ describe('/libraries', () => { it('should remove offline files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline2`], + importPaths: [`${testAssetDirInternal}/temp/offline`], }); - utils.createImageFile(`${testAssetDir}/temp/offline2/assetA.png`); + utils.createImageFile(`${testAssetDir}/temp/offline/online.png`); + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); @@ -646,9 +647,9 @@ describe('/libraries', () => { const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id, }); - expect(initialAssets.count).toBe(1); + expect(initialAssets.count).toBe(2); - utils.removeImageFile(`${testAssetDir}/temp/offline2/assetA.png`); + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); @@ -669,7 +670,54 @@ describe('/libraries', () => { const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(0); + expect(assets.count).toBe(1); + + utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`); + }); + + it('should remove offline files from trash', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/online.png`); + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + }); + + expect(initialAssets.count).toBe(2); + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + isOffline: true, + }); + expect(offlineAssets.count).toBe(1); + + const { status } = await request(app) + .post(`/libraries/${library.id}/removeOffline`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(assets.count).toBe(1); + expect(assets.items[0].isOffline).toBe(false); + expect(assets.items[0].originalPath).toEqual(`${testAssetDirInternal}/temp/offline/online.png`); + + utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`); }); it('should not remove online files', async () => { diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index e323d98640..9f7213de82 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -169,7 +169,12 @@ export interface IAssetRepository { order?: FindOptionsOrder, ): Promise; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; - getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated; + getWith( + pagination: PaginationOptions, + property: WithProperty, + libraryId?: string, + withDeleted?: boolean, + ): Paginated; getRandom(userId: string, count: number): Promise; getFirstAssetForAlbumId(albumId: string): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index dd526dd664..77622b1618 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -527,7 +527,12 @@ export class AssetRepository implements IAssetRepository { }); } - getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated { + getWith( + pagination: PaginationOptions, + property: WithProperty, + libraryId?: string, + withDeleted = false, + ): Paginated { let where: FindOptionsWhere | FindOptionsWhere[] = {}; switch (property) { @@ -557,6 +562,7 @@ export class AssetRepository implements IAssetRepository { return paginate(this.repository, pagination, { where, + withDeleted, order: { // Ensures correct order when paginating createdAt: 'ASC', diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index bcd0a842c7..2aa0df402a 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -581,7 +581,7 @@ export class LibraryService { this.logger.debug(`Removing offline assets for library ${job.id}`); const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => - this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id), + this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id, true), ); let offlineAssets = 0; From 438344fc8f2a8cbcb4c5d066306cc20f16ad6eb2 Mon Sep 17 00:00:00 2001 From: PyKen Date: Mon, 2 Sep 2024 22:31:02 +0900 Subject: [PATCH 237/723] fix(server): get assetFiles when retrieving assets WithoutProperty.THUMBNAIL (#12225) --- ...25258039306-UpsertMissingAssetJobStatus.ts | 21 +++++++++++++++++++ server/src/repositories/asset.repository.ts | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 server/src/migrations/1725258039306-UpsertMissingAssetJobStatus.ts diff --git a/server/src/migrations/1725258039306-UpsertMissingAssetJobStatus.ts b/server/src/migrations/1725258039306-UpsertMissingAssetJobStatus.ts new file mode 100644 index 0000000000..8eb47db438 --- /dev/null +++ b/server/src/migrations/1725258039306-UpsertMissingAssetJobStatus.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpsertMissingAssetJobStatus1725258039306 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `INSERT INTO "asset_job_status" ("assetId", "facesRecognizedAt", "metadataExtractedAt", "duplicatesDetectedAt", "previewAt", "thumbnailAt") SELECT "assetId", NULL, NULL, NULL, NULL, NULL FROM "asset_files" f WHERE "f"."path" IS NOT NULL ON CONFLICT DO NOTHING`, + ); + + await queryRunner.query( + `UPDATE "asset_job_status" SET "previewAt" = NOW() FROM "asset_files" f WHERE "previewAt" IS NULL AND "asset_job_status"."assetId" = "f"."assetId" AND "f"."type" = 'preview' AND "f"."path" IS NOT NULL`, + ); + + await queryRunner.query( + `UPDATE "asset_job_status" SET "thumbnailAt" = NOW() FROM "asset_files" f WHERE "thumbnailAt" IS NULL AND "asset_job_status"."assetId" = "f"."assetId" AND "f"."type" = 'thumbnail' AND "f"."path" IS NOT NULL`, + ); + } + + public async down(): Promise { + // do nothing + } +} diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 77622b1618..3763cccd53 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -395,7 +395,7 @@ export class AssetRepository implements IAssetRepository { switch (property) { case WithoutProperty.THUMBNAIL: { - relations = { jobStatus: true }; + relations = { jobStatus: true, files: true }; where = [ { jobStatus: { previewAt: IsNull() }, isVisible: true }, { jobStatus: { thumbnailAt: IsNull() }, isVisible: true }, From b80cc0d90f18cc7a43848215ba143bfd56f1d5a5 Mon Sep 17 00:00:00 2001 From: Niklas Fischer Date: Mon, 2 Sep 2024 18:33:32 +0200 Subject: [PATCH 238/723] fix(web): German translation for explorer (#12180) fix German translation for explorer --- web/src/lib/i18n/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index e06ff80693..95b9850634 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -716,7 +716,7 @@ "expired": "Verfallen", "expires_date": "Läuft am {date} ab", "explore": "Erkunden", - "explorer": "Entdeccker", + "explorer": "Explorer", "export": "Exportieren", "export_as_json": "Als JSON exportieren", "extension": "Erweiterung", From bd6c5e1b1c991c3a1ba46ee0401ec94aa7920ebc Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 2 Sep 2024 14:39:16 -0500 Subject: [PATCH 239/723] feat(web): tag button in album/shared album (#12172) --- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 46812ff289..2c3f058e14 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -38,7 +38,7 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; - import { user } from '$lib/stores/user.store'; + import { preferences, user } from '$lib/stores/user.store'; import { handlePromiseError } from '$lib/utils'; import { downloadAlbum } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; @@ -85,6 +85,7 @@ import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; import { confirmAlbumDelete } from '$lib/utils/album-utils'; + import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; export let data: PageData; @@ -458,6 +459,11 @@ {/if} assetStore.triggerUpdate()} /> {/if} + + {#if $preferences.tags.enabled && isAllUserOwned} + + {/if} + {#if isOwned || isAllUserOwned} {/if} From 862d6d9abe68d255f3f44ced12dbdf5fb01d5da6 Mon Sep 17 00:00:00 2001 From: Vietbao Tran <46217210+TapuCosmo@users.noreply.github.com> Date: Mon, 2 Sep 2024 12:39:55 -0700 Subject: [PATCH 240/723] feat(web): load original panorama image when zoomed in to 75% or above (#12222) * feat(web): load original panorama image when zoomed in to 75% or above * add checks that original 360 image is web compatible and better error handling * fix web compatability check typing * fix asset type --- .../asset-viewer/panorama-viewer.svelte | 15 +++++++++++++-- .../photo-sphere-viewer-adapter.svelte | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/asset-viewer/panorama-viewer.svelte b/web/src/lib/components/asset-viewer/panorama-viewer.svelte index 71ed4b8997..dee9a5f8ec 100644 --- a/web/src/lib/components/asset-viewer/panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/panorama-viewer.svelte @@ -1,11 +1,12 @@
    @@ -34,7 +38,14 @@ {#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte'), ...photoSphereConfigs])} {:then [data, module, adapter, plugins, navbar]} - + {:catch} {$t('errors.failed_to_load_asset')} {/await} diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index 0c0e707693..30a2018feb 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -1,6 +1,7 @@ + + diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index d0d330480a..25f3b6ea2f 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1191,7 +1191,7 @@ "to_change_password": "Change password", "to_favorite": "Favorite", "to_login": "Login", - "to_root": "To root", + "to_parent": "Go to parent", "to_trash": "Trash", "toggle_settings": "Toggle settings", "toggle_theme": "Toggle dark theme", diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index a8b8602c02..1ffa64d3a3 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,8 +1,6 @@ @@ -14,7 +14,6 @@ - From ee6550c02cc9154485154772bd26a14859b3eb21 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 4 Sep 2024 09:20:45 -0400 Subject: [PATCH 271/723] feat(web): add Malay language (#12311) feat(web): add ms.json --- web/src/lib/constants.ts | 1 + web/src/lib/i18n/ms.json | 1 + 2 files changed, 2 insertions(+) create mode 100644 web/src/lib/i18n/ms.json diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index ce5cefd815..05011680dc 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -284,6 +284,7 @@ export const langs = [ { name: 'Lithuanian', code: 'lt', loader: () => import('$lib/i18n/lt.json') }, { name: 'Latvian', code: 'lv', loader: () => import('$lib/i18n/lv.json') }, { name: 'Mongolian', code: 'mn', loader: () => import('$lib/i18n/mn.json') }, + { name: 'Malay', code: 'ms', loader: () => import('$lib/i18n/ms.json') }, { name: 'Norwegian Bokmål', code: 'nb-NO', weblateCode: 'nb_NO', loader: () => import('$lib/i18n/nb_NO.json') }, { name: 'Dutch', code: 'nl', loader: () => import('$lib/i18n/nl.json') }, { name: 'Polish', code: 'pl', loader: () => import('$lib/i18n/pl.json') }, diff --git a/web/src/lib/i18n/ms.json b/web/src/lib/i18n/ms.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/src/lib/i18n/ms.json @@ -0,0 +1 @@ +{} From cbb0a7f8d40f87535c877257e598853c29f66d32 Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Wed, 4 Sep 2024 16:27:04 +0200 Subject: [PATCH 272/723] fix(server): parse time zone with explicit zero offset (#12307) * fix(server): fix test: use data as returned by exiftool-vendored * fix(server): retain +00:00 timezone if set explicitly --- server/src/services/metadata.service.spec.ts | 42 ++++++++++++++++---- server/src/services/metadata.service.ts | 27 +++++++++++-- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 2fc95df00e..834fd16afc 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1,4 +1,4 @@ -import { BinaryField } from 'exiftool-vendored'; +import { BinaryField, ExifDateTime } from 'exiftool-vendored'; import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; @@ -746,6 +746,8 @@ describe(MetadataService.name, () => { }); it('should save all metadata', async () => { + const dateForTest = new Date('1970-01-01T00:00:00.000-11:30'); + const tags: ImmichTags = { BitsPerSample: 1, ComponentBitDepth: 1, @@ -753,7 +755,7 @@ describe(MetadataService.name, () => { BitDepth: 1, ColorBitDepth: 1, ColorSpace: '1', - DateTimeOriginal: new Date('1970-01-01').toISOString(), + DateTimeOriginal: ExifDateTime.fromISO(dateForTest.toISOString()), ExposureTime: '100ms', FocalLength: 20, ImageDescription: 'test description', @@ -762,11 +764,11 @@ describe(MetadataService.name, () => { MediaGroupUUID: 'livePhoto', Make: 'test-factory', Model: "'mockel'", - ModifyDate: new Date('1970-01-01').toISOString(), + ModifyDate: ExifDateTime.fromISO(dateForTest.toISOString()), Orientation: 0, ProfileDescription: 'extensive description', ProjectionType: 'equirectangular', - tz: '+02:00', + tz: 'UTC-11:30', Rating: 3, }; assetMock.getByIds.mockResolvedValue([assetStub.image]); @@ -779,7 +781,7 @@ describe(MetadataService.name, () => { bitsPerSample: expect.any(Number), autoStackId: null, colorspace: tags.ColorSpace, - dateTimeOriginal: new Date('1970-01-01'), + dateTimeOriginal: dateForTest, description: tags.ImageDescription, exifImageHeight: null, exifImageWidth: null, @@ -805,11 +807,37 @@ describe(MetadataService.name, () => { expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, - fileCreatedAt: new Date('1970-01-01'), - localDateTime: new Date('1970-01-01'), + fileCreatedAt: dateForTest, + localDateTime: dateForTest, }); }); + it('should extract +00:00 timezone from raw value', async () => { + // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly + // https://github.com/photostructure/exiftool-vendored.js/issues/203 + + // this only tests our assumptions of exiftool-vendored, demonstrating the issue + const someDate = '2024-09-01T00:00:00.000'; + expect(ExifDateTime.fromISO(someDate + 'Z')?.zone).toBe('UTC'); + expect(ExifDateTime.fromISO(someDate + '+00:00')?.zone).toBe('UTC'); // this is the issue, should be UTC+0 + expect(ExifDateTime.fromISO(someDate + '+04:00')?.zone).toBe('UTC+4'); + + const tags: ImmichTags = { + DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), + tz: undefined, + }; + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue(tags); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + timeZone: 'UTC+0', + }), + ); + }); + it('should extract duration', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); mediaMock.probe.mockResolvedValue({ diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 29aebc4a36..7eab4702ad 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -531,12 +531,16 @@ export class MetadataService { this.logger.verbose('Exif Tags', exifTags); + const dateTimeOriginalWithRawValue = this.getDateTimeOriginalWithRawValue(exifTags); + const dateTimeOriginal = dateTimeOriginalWithRawValue.exifDate ?? asset.fileCreatedAt; + const timeZone = this.getTimeZone(exifTags, dateTimeOriginalWithRawValue.rawValue); + const exifData = { // altitude: tags.GPSAltitude ?? null, assetId: asset.id, bitsPerSample: this.getBitsPerSample(exifTags), colorspace: exifTags.ColorSpace ?? null, - dateTimeOriginal: this.getDateTimeOriginal(exifTags) ?? asset.fileCreatedAt, + dateTimeOriginal, description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), exifImageHeight: validate(exifTags.ImageHeight), exifImageWidth: validate(exifTags.ImageWidth), @@ -557,7 +561,7 @@ export class MetadataService { orientation: validate(exifTags.Orientation)?.toString() ?? null, profileDescription: exifTags.ProfileDescription || null, projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, - timeZone: exifTags.tz ?? null, + timeZone, rating: exifTags.Rating ?? null, }; @@ -578,10 +582,25 @@ export class MetadataService { } private getDateTimeOriginal(tags: ImmichTags | Tags | null) { + return this.getDateTimeOriginalWithRawValue(tags).exifDate; + } + + private getDateTimeOriginalWithRawValue(tags: ImmichTags | Tags | null): { exifDate: Date | null; rawValue: string } { if (!tags) { - return null; + return { exifDate: null, rawValue: '' }; } - return exifDate(firstDateTime(tags as Tags, EXIF_DATE_TAGS)); + const first = firstDateTime(tags as Tags, EXIF_DATE_TAGS); + return { exifDate: exifDate(first), rawValue: first?.rawValue ?? '' }; + } + + private getTimeZone(exifTags: ImmichTags, rawValue: string) { + const timeZone = exifTags.tz ?? null; + if (timeZone == null && rawValue.endsWith('+00:00')) { + // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly + // https://github.com/photostructure/exiftool-vendored.js/issues/203 + return 'UTC+0'; + } + return timeZone; } private getBitsPerSample(tags: ImmichTags): number | null { From 4bf82fb4c4880d4cf43b2935cdb713bb02867697 Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Wed, 4 Sep 2024 16:47:40 +0200 Subject: [PATCH 273/723] fix(web): retain selected time zone offset also for +00:00 (#12310) Co-authored-by: Alex --- .../shared-components/change-date.svelte | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte index 962a97ecf7..916b9349f9 100644 --- a/web/src/lib/components/shared-components/change-date.svelte +++ b/web/src/lib/components/shared-components/change-date.svelte @@ -10,18 +10,25 @@ type ZoneOption = { /** - * Timezone name + * Timezone name with offset * * e.g. Asia/Jerusalem (+03:00) */ label: string; /** - * Timezone offset + * Timezone name * - * e.g. UTC+01:00 + * e.g. Asia/Jerusalem */ value: string; + + /** + * Timezone offset in minutes + * + * e.g. 300 + */ + offsetMinutes: number; }; const timezones: ZoneOption[] = Intl.supportedValuesOf('timeZone') @@ -37,21 +44,23 @@ const offset = zone.toFormat('ZZ'); return { label: `${zone.zoneName} (${offset})`, - value: 'UTC' + offset, + value: zone.zoneName, + offsetMinutes: zone.offset, }; }); - const initialOption = timezones.find((item) => item.value === 'UTC' + initialDate.toFormat('ZZ')); + const initialOption = timezones.find((item) => item.offsetMinutes === initialDate.offset); let selectedOption = initialOption && { label: initialOption?.label || '', + offsetMinutes: initialOption?.offsetMinutes || 0, value: initialOption?.value || '', }; let selectedDate = initialDate.toFormat("yyyy-MM-dd'T'HH:mm"); - // Keep local time if not it's really confusing - $: date = DateTime.fromISO(selectedDate).setZone(selectedOption?.value, { keepLocalTime: true }); + // when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it) + $: date = DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }); const dispatch = createEventDispatcher<{ cancel: void; From d685bc1f340762156619f8f403ac3f721b467c8f Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 4 Sep 2024 10:39:31 -0500 Subject: [PATCH 274/723] chore(mobile): handle sync album on duplicated (#12173) * chore(mobile): handle sync album on duplicated * remove check for duplicate in manual sync * linting --- mobile/lib/services/asset.service.dart | 13 ------------- mobile/lib/services/backup.service.dart | 2 +- mobile/pubspec.lock | 4 ++-- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 17508cba51..c4f258e259 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -8,7 +8,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -309,18 +308,6 @@ class AssetService { useTimeFilter: false, ); - final duplicates = await _apiService.assetsApi.checkExistingAssets( - CheckExistingAssetsDto( - deviceAssetIds: candidates.map((c) => c.asset.id).toList(), - deviceId: Store.get(StoreKey.deviceId), - ), - ); - - if (duplicates != null) { - candidates - .removeWhere((c) => !duplicates.existingIds.contains(c.asset.id)); - } - await refreshRemoteAssets(); final remoteAssets = await _db.assets .where() diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 12edd14d60..858499443e 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -484,7 +484,7 @@ class BackupService { ), ); - if (shouldSyncAlbums && !isDuplicate) { + if (shouldSyncAlbums) { await _albumService.syncUploadAlbums( candidate.albumNames, [responseBody['id'] as String], diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 14b487ce4d..c9493f6490 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1737,10 +1737,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.4" + version: "14.2.5" wakelock_plus: dependency: "direct main" description: From 1783dfd393168dd98ece34e0a6fbaec6d37a62e5 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Wed, 4 Sep 2024 17:02:37 +0100 Subject: [PATCH 275/723] fix(web): handle RTL languages in the map component (#12308) --- web/package-lock.json | 115 +++++++++++++++++- web/package.json | 3 +- .../shared-components/map/map.svelte | 3 + 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 97b1a303a5..4ddc6d9baa 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", "@immich/sdk": "file:../open-api/typescript-sdk", + "@mapbox/mapbox-gl-rtl-text": "^0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", @@ -27,7 +28,7 @@ "svelte-gestures": "^5.0.4", "svelte-i18n": "^4.0.0", "svelte-local-storage-store": "^0.6.4", - "svelte-maplibre": "^0.9.0", + "svelte-maplibre": "^0.9.13", "thumbhash": "^0.1.1" }, "devDependencies": { @@ -1446,6 +1447,13 @@ "geojson-rewind": "geojson-rewind" } }, + "node_modules/@mapbox/geojson-types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", + "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", + "license": "ISC", + "peer": true + }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", @@ -1454,6 +1462,25 @@ "node": ">= 0.6" } }, + "node_modules/@mapbox/mapbox-gl-rtl-text": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-rtl-text/-/mapbox-gl-rtl-text-0.2.3.tgz", + "integrity": "sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw==", + "license": "BSD-2-Clause", + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", + "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", + "license": "BSD-3-Clause", + "peer": true, + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, "node_modules/@mapbox/point-geometry": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", @@ -3307,6 +3334,13 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT", + "peer": true + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4563,6 +4597,13 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC", + "peer": true + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -5388,6 +5429,78 @@ "node": ">=10" } }, + "node_modules/mapbox-gl": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", + "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", + "license": "SEE LICENSE IN LICENSE.txt", + "peer": true, + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/mapbox-gl/node_modules/@mapbox/tiny-sdf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", + "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", + "peer": true, + "dependencies": { + "kdbush": "^3.0.0" + } + }, "node_modules/maplibre-gl": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.0.1.tgz", diff --git a/web/package.json b/web/package.json index 1996f4eaef..1ba350022d 100644 --- a/web/package.json +++ b/web/package.json @@ -67,6 +67,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", "@immich/sdk": "file:../open-api/typescript-sdk", + "@mapbox/mapbox-gl-rtl-text": "^0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", @@ -83,7 +84,7 @@ "svelte-gestures": "^5.0.4", "svelte-i18n": "^4.0.0", "svelte-local-storage-store": "^0.6.4", - "svelte-maplibre": "^0.9.0", + "svelte-maplibre": "^0.9.13", "thumbhash": "^0.1.1" }, "volta": { diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index 45d2879b37..7d0dbbee6f 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -7,6 +7,7 @@ import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js'; import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson'; import type { GeoJSONSource, LngLatLike, StyleSpecification } from 'maplibre-gl'; + import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url'; import maplibregl from 'maplibre-gl'; import { createEventDispatcher } from 'svelte'; import { @@ -51,6 +52,8 @@ let map: maplibregl.Map; let marker: maplibregl.Marker | null = null; + void maplibregl.setRTLTextPlugin(mapboxRtlUrl, true); + $: style = (() => getMapStyle({ theme: ($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT) as unknown as MapTheme, From 12b65e3c24bc78d7cef6d33a713723f150c07528 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 4 Sep 2024 13:32:43 -0400 Subject: [PATCH 276/723] fix(server): auto-reconnect to database (#12320) --- server/src/app.module.ts | 17 +++++-- server/src/interfaces/database.interface.ts | 1 + server/src/middleware/error.interceptor.ts | 8 ++-- .../src/middleware/global-exception.filter.ts | 47 +++++++++++++++++++ .../src/middleware/http-exception.filter.ts | 39 --------------- .../src/repositories/database.repository.ts | 13 +++++ server/src/repositories/logger.repository.ts | 2 +- server/src/services/database.service.ts | 25 ++++++++++ .../src/utils/{logger-colors.ts => logger.ts} | 23 +++++++++ .../repositories/database.repository.mock.ts | 1 + 10 files changed, 130 insertions(+), 46 deletions(-) create mode 100644 server/src/middleware/global-exception.filter.ts delete mode 100644 server/src/middleware/http-exception.filter.ts rename server/src/utils/{logger-colors.ts => logger.ts} (55%) diff --git a/server/src/app.module.ts b/server/src/app.module.ts index c6cd68a96f..9446010127 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -18,10 +18,11 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthGuard } from 'src/middleware/auth.guard'; import { ErrorInterceptor } from 'src/middleware/error.interceptor'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; -import { HttpExceptionFilter } from 'src/middleware/http-exception.filter'; +import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter'; import { LoggingInterceptor } from 'src/middleware/logging.interceptor'; import { repositories } from 'src/repositories'; import { services } from 'src/services'; +import { DatabaseService } from 'src/services/database.service'; import { setupEventHandlers } from 'src/utils/events'; import { otelConfig } from 'src/utils/instrumentation'; @@ -29,7 +30,7 @@ const common = [...services, ...repositories]; const middleware = [ FileUploadInterceptor, - { provide: APP_FILTER, useClass: HttpExceptionFilter }, + { provide: APP_FILTER, useClass: GlobalExceptionFilter }, { provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) }, { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, { provide: APP_INTERCEPTOR, useClass: ErrorInterceptor }, @@ -43,7 +44,17 @@ const imports = [ ConfigModule.forRoot(immichAppConfig), EventEmitterModule.forRoot(), OpenTelemetryModule.forRoot(otelConfig), - TypeOrmModule.forRoot(databaseConfig), + TypeOrmModule.forRootAsync({ + inject: [ModuleRef], + useFactory: (moduleRef: ModuleRef) => { + return { + ...databaseConfig, + poolErrorHandler: (error) => { + moduleRef.get(DatabaseService, { strict: false }).handleConnectionError(error); + }, + }; + }, + }), TypeOrmModule.forFeature(entities), ]; diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index 98bb0c0288..373f109142 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -40,6 +40,7 @@ export interface VectorUpdateResult { export const IDatabaseRepository = 'IDatabaseRepository'; export interface IDatabaseRepository { + reconnect(): Promise; getExtensionVersion(extension: DatabaseExtension): Promise; getExtensionVersionRange(extension: VectorExtension): string; getPostgresVersion(): Promise; diff --git a/server/src/middleware/error.interceptor.ts b/server/src/middleware/error.interceptor.ts index a0c333e4b2..5d93b40dc2 100644 --- a/server/src/middleware/error.interceptor.ts +++ b/server/src/middleware/error.interceptor.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/common'; import { Observable, catchError, throwError } from 'rxjs'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { logGlobalError } from 'src/utils/logger'; import { routeToErrorMessage } from 'src/utils/misc'; @Injectable() @@ -25,9 +26,10 @@ export class ErrorInterceptor implements NestInterceptor { return error; } - const errorMessage = routeToErrorMessage(context.getHandler().name); - this.logger.error(errorMessage, error, error?.errors, error?.stack); - return new InternalServerErrorException(errorMessage); + logGlobalError(this.logger, error); + + const message = routeToErrorMessage(context.getHandler().name); + return new InternalServerErrorException(message); }), ), ); diff --git a/server/src/middleware/global-exception.filter.ts b/server/src/middleware/global-exception.filter.ts new file mode 100644 index 0000000000..6200363e86 --- /dev/null +++ b/server/src/middleware/global-exception.filter.ts @@ -0,0 +1,47 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Inject } from '@nestjs/common'; +import { Response } from 'express'; +import { ClsService } from 'nestjs-cls'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { logGlobalError } from 'src/utils/logger'; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + constructor( + @Inject(ILoggerRepository) private logger: ILoggerRepository, + private cls: ClsService, + ) { + this.logger.setContext(GlobalExceptionFilter.name); + } + + catch(error: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const { status, body } = this.fromError(error); + if (!response.headersSent) { + response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() }); + } + } + + private fromError(error: Error) { + logGlobalError(this.logger, error); + + if (error instanceof HttpException) { + const status = error.getStatus(); + let body = error.getResponse(); + + // unclear what circumstances would return a string + if (typeof body === 'string') { + body = { message: body }; + } + + return { status, body }; + } + + return { + status: 500, + body: { + message: 'Internal server error', + }, + }; + } +} diff --git a/server/src/middleware/http-exception.filter.ts b/server/src/middleware/http-exception.filter.ts deleted file mode 100644 index 3306b50ca6..0000000000 --- a/server/src/middleware/http-exception.filter.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Inject } from '@nestjs/common'; -import { Response } from 'express'; -import { ClsService } from 'nestjs-cls'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; - -@Catch(HttpException) -export class HttpExceptionFilter implements ExceptionFilter { - constructor( - @Inject(ILoggerRepository) private logger: ILoggerRepository, - private cls: ClsService, - ) { - this.logger.setContext(HttpExceptionFilter.name); - } - - catch(exception: HttpException, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const status = exception.getStatus(); - - this.logger.debug(`HttpException(${status}) ${JSON.stringify(exception.getResponse())}`); - - let responseBody = exception.getResponse(); - // unclear what circumstances would return a string - if (typeof responseBody === 'string') { - responseBody = { - error: 'Unknown', - message: responseBody, - statusCode: status, - }; - } - - if (!response.headersSent) { - response.status(status).json({ - ...responseBody, - correlationId: this.cls.getId(), - }); - } - } -} diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 9ee7f8e6fc..0453421a39 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -31,6 +31,19 @@ export class DatabaseRepository implements IDatabaseRepository { this.logger.setContext(DatabaseRepository.name); } + async reconnect() { + try { + if (this.dataSource.isInitialized) { + await this.dataSource.destroy(); + } + const { isInitialized } = await this.dataSource.initialize(); + return isInitialized; + } catch (error) { + this.logger.error(`Database connection failed: ${error}`); + return false; + } + } + async getExtensionVersion(extension: DatabaseExtension): Promise { const [res]: ExtensionVersion[] = await this.dataSource.query( `SELECT default_version as "availableVersion", installed_version as "installedVersion" diff --git a/server/src/repositories/logger.repository.ts b/server/src/repositories/logger.repository.ts index 1527965b49..1e0c7b74d9 100644 --- a/server/src/repositories/logger.repository.ts +++ b/server/src/repositories/logger.repository.ts @@ -3,7 +3,7 @@ import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-en import { ClsService } from 'nestjs-cls'; import { LogLevel } from 'src/config'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { LogColor } from 'src/utils/logger-colors'; +import { LogColor } from 'src/utils/logger'; const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index d2a2813a05..a5280ff28b 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { Duration } from 'luxon'; import semver from 'semver'; import { getVectorExtension } from 'src/database.config'; import { OnEmit } from 'src/decorators'; @@ -59,8 +60,12 @@ const messages = { If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`, }; +const RETRY_DURATION = Duration.fromObject({ seconds: 5 }); + @Injectable() export class DatabaseService { + private reconnection?: NodeJS.Timeout; + constructor( @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @@ -117,6 +122,26 @@ export class DatabaseService { }); } + handleConnectionError(error: Error) { + if (this.reconnection) { + return; + } + + this.logger.error(`Database disconnected: ${error}`); + this.reconnection = setInterval(() => void this.reconnect(), RETRY_DURATION.toMillis()); + } + + private async reconnect() { + const isConnected = await this.databaseRepository.reconnect(); + if (isConnected) { + this.logger.log('Database reconnected'); + clearInterval(this.reconnection); + delete this.reconnection; + } else { + this.logger.warn(`Database connection failed, retrying in ${RETRY_DURATION.toHuman()}`); + } + } + private async createExtension(extension: DatabaseExtension) { try { await this.databaseRepository.createExtension(extension); diff --git a/server/src/utils/logger-colors.ts b/server/src/utils/logger.ts similarity index 55% rename from server/src/utils/logger-colors.ts rename to server/src/utils/logger.ts index 36104ee520..d4eb02ead2 100644 --- a/server/src/utils/logger-colors.ts +++ b/server/src/utils/logger.ts @@ -1,3 +1,7 @@ +import { HttpException } from '@nestjs/common'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { TypeORMError } from 'typeorm'; + type ColorTextFn = (text: string) => string; const isColorAllowed = () => !process.env.NO_COLOR; @@ -15,3 +19,22 @@ export const LogColor = { export const LogStyle = { bold: colorIfAllowed((text: string) => `\u001B[1m${text}\u001B[0m`), }; + +export const logGlobalError = (logger: ILoggerRepository, error: Error) => { + if (error instanceof HttpException) { + const status = error.getStatus(); + const response = error.getResponse(); + logger.debug(`HttpException(${status}): ${JSON.stringify(response)}`); + return; + } + + if (error instanceof TypeORMError) { + logger.error(`Database error: ${error}`); + return; + } + + if (error instanceof Error) { + logger.error(`Unknown error: ${error}`); + return; + } +}; diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index e8b0817dfe..0e1d4ab3e7 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -3,6 +3,7 @@ import { Mocked, vitest } from 'vitest'; export const newDatabaseRepositoryMock = (): Mocked => { return { + reconnect: vitest.fn(), getExtensionVersion: vitest.fn(), getExtensionVersionRange: vitest.fn(), getPostgresVersion: vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'), From f8211a128e4f92cd79bdf425f73494b9841e9dd9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 4 Sep 2024 14:36:12 -0400 Subject: [PATCH 277/723] fix(deps): update machine-learning (#12257) --- machine-learning/Dockerfile | 4 +- machine-learning/export/Dockerfile | 2 +- machine-learning/poetry.lock | 123 +++++++++++++++-------------- 3 files changed, 65 insertions(+), 64 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 8fc72b308f..f680aac826 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:f7543d9969bdc112dd9819ca642e14433fdacfe857f170f6b803392fc7e451ad AS builder-cpu +FROM python:3.11-bookworm@sha256:20c1819af5af3acba0b2b66074a2615e398ceee6842adf03cd7ad5f8d0ee3daf AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:ad5dadd957a398226996bc4846e522c39f2a77340b531b28aaab85b2d361210b AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:ed4e985674f478c90ce879e9aa224fbb772c84e39b4aed5155b9e2280f131039 AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index d458d92d15..eaa35d14be 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:475730daef12ff9c0733e70092aeeefdf4c373a584c952dac3f7bdb739601990 AS builder +FROM mambaorg/micromamba:bookworm-slim@sha256:29174348bd09352e5f1b1f6756cf1d00021487b8340fae040e91e4f98e954ce5 AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 7385d1269d..bd09bd8469 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -680,13 +680,13 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi-slim" -version = "0.112.1" +version = "0.112.2" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_slim-0.112.1-py3-none-any.whl", hash = "sha256:cc227cf9402d0ba54a24f80eb205c33bcb25d3ea18d53fdac3fd76ea5af8e76d"}, - {file = "fastapi_slim-0.112.1.tar.gz", hash = "sha256:876ebd24e72273986709db2d469b75dc18f04c3ab9140ffd78b29d7785d26687"}, + {file = "fastapi_slim-0.112.2-py3-none-any.whl", hash = "sha256:c023f74768f187af142c2fe5ff9e4ca3c4c1940bbde7df008cb283532422a23f"}, + {file = "fastapi_slim-0.112.2.tar.gz", hash = "sha256:75b8eb0c6ee05a20270da7a527ac7ad53b83414602f42b68f7027484dab3aedb"}, ] [package.dependencies] @@ -695,8 +695,8 @@ starlette = ">=0.37.2,<0.39.0" typing-extensions = ">=4.8.0" [package.extras] -all = ["email_validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email_validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "filelock" @@ -1212,13 +1212,13 @@ test = ["Cython (>=0.29.24,<0.30.0)"] [[package]] name = "httpx" -version = "0.27.0" +version = "0.27.2" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, - {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, ] [package.dependencies] @@ -1233,6 +1233,7 @@ brotli = ["brotli", "brotlicffi"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] [[package]] name = "huggingface-hub" @@ -1530,13 +1531,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "locust" -version = "2.31.3" +version = "2.31.5" description = "Developer-friendly load testing framework" optional = false python-versions = ">=3.9" files = [ - {file = "locust-2.31.3-py3-none-any.whl", hash = "sha256:03122e007519b371a5a553d578af502826755de83551d79ea8a412ea1c660115"}, - {file = "locust-2.31.3.tar.gz", hash = "sha256:25f4603f24afa11ef1ee1f26b1c86a232eb9a1140be30b2a4642c12d7a7af8ae"}, + {file = "locust-2.31.5-py3-none-any.whl", hash = "sha256:2904ff6307d54d3202c9ebd776f9170214f6dfbe4059504dad9e3ffaca03f600"}, + {file = "locust-2.31.5.tar.gz", hash = "sha256:14b2fa6f95bf248668e6dc92d100a44f06c5dcb1c26f88a5442bcaaee18faceb"}, ] [package.dependencies] @@ -1794,38 +1795,38 @@ files = [ [[package]] name = "mypy" -version = "1.11.1" +version = "1.11.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c"}, - {file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411"}, - {file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03"}, - {file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4"}, - {file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58"}, - {file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5"}, - {file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca"}, - {file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de"}, - {file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"}, - {file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72"}, - {file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8"}, - {file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a"}, - {file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417"}, - {file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e"}, - {file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525"}, - {file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2"}, - {file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b"}, - {file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0"}, - {file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd"}, - {file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb"}, - {file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe"}, - {file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c"}, - {file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69"}, - {file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74"}, - {file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b"}, - {file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54"}, - {file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08"}, + {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, + {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, + {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"}, + {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"}, + {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"}, + {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"}, + {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"}, + {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"}, + {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"}, + {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"}, + {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"}, + {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"}, + {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"}, + {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"}, + {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"}, + {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"}, + {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"}, + {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"}, + {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"}, ] [package.dependencies] @@ -2815,13 +2816,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "13.7.1" +version = "13.8.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.7.0" files = [ - {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, - {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, + {file = "rich-13.8.0-py3-none-any.whl", hash = "sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc"}, + {file = "rich-13.8.0.tar.gz", hash = "sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4"}, ] [package.dependencies] @@ -2833,29 +2834,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.6.2" +version = "0.6.3" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.6.2-py3-none-linux_armv6l.whl", hash = "sha256:5c8cbc6252deb3ea840ad6a20b0f8583caab0c5ef4f9cca21adc5a92b8f79f3c"}, - {file = "ruff-0.6.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:17002fe241e76544448a8e1e6118abecbe8cd10cf68fde635dad480dba594570"}, - {file = "ruff-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3dbeac76ed13456f8158b8f4fe087bf87882e645c8e8b606dd17b0b66c2c1158"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:094600ee88cda325988d3f54e3588c46de5c18dae09d683ace278b11f9d4d534"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:316d418fe258c036ba05fbf7dfc1f7d3d4096db63431546163b472285668132b"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d72b8b3abf8a2d51b7b9944a41307d2f442558ccb3859bbd87e6ae9be1694a5d"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2aed7e243be68487aa8982e91c6e260982d00da3f38955873aecd5a9204b1d66"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d371f7fc9cec83497fe7cf5eaf5b76e22a8efce463de5f775a1826197feb9df8"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8f310d63af08f583363dfb844ba8f9417b558199c58a5999215082036d795a1"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7db6880c53c56addb8638fe444818183385ec85eeada1d48fc5abe045301b2f1"}, - {file = "ruff-0.6.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1175d39faadd9a50718f478d23bfc1d4da5743f1ab56af81a2b6caf0a2394f23"}, - {file = "ruff-0.6.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939f9c86d51635fe486585389f54582f0d65b8238e08c327c1534844b3bb9a"}, - {file = "ruff-0.6.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d0d62ca91219f906caf9b187dea50d17353f15ec9bb15aae4a606cd697b49b4c"}, - {file = "ruff-0.6.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7438a7288f9d67ed3c8ce4d059e67f7ed65e9fe3aa2ab6f5b4b3610e57e3cb56"}, - {file = "ruff-0.6.2-py3-none-win32.whl", hash = "sha256:279d5f7d86696df5f9549b56b9b6a7f6c72961b619022b5b7999b15db392a4da"}, - {file = "ruff-0.6.2-py3-none-win_amd64.whl", hash = "sha256:d9f3469c7dd43cd22eb1c3fc16926fb8258d50cb1b216658a07be95dd117b0f2"}, - {file = "ruff-0.6.2-py3-none-win_arm64.whl", hash = "sha256:f28fcd2cd0e02bdf739297516d5643a945cc7caf09bd9bcb4d932540a5ea4fa9"}, - {file = "ruff-0.6.2.tar.gz", hash = "sha256:239ee6beb9e91feb8e0ec384204a763f36cb53fb895a1a364618c6abb076b3be"}, + {file = "ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3"}, + {file = "ruff-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b061e49b5cf3a297b4d1c27ac5587954ccb4ff601160d3d6b2f70b1622194dc"}, + {file = "ruff-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:34e2824a13bb8c668c71c1760a6ac7d795ccbd8d38ff4a0d8471fdb15de910b1"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8"}, + {file = "ruff-0.6.3-py3-none-win32.whl", hash = "sha256:7a62d3b5b0d7f9143d94893f8ba43aa5a5c51a0ffc4a401aa97a81ed76930521"}, + {file = "ruff-0.6.3-py3-none-win_amd64.whl", hash = "sha256:746af39356fee2b89aada06c7376e1aa274a23493d7016059c3a72e3b296befb"}, + {file = "ruff-0.6.3-py3-none-win_arm64.whl", hash = "sha256:14a9528a8b70ccc7a847637c29e56fd1f9183a9db743bbc5b8e0c4ad60592a82"}, + {file = "ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983"}, ] [[package]] From 0a8bd7dc661e4a7033aaedf13f923161c6bfc7e2 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 4 Sep 2024 14:07:32 -0500 Subject: [PATCH 278/723] fix(web): correct color for active tree item (#12318) * fix(web): correct color for active tree item * remove white space --- web/src/lib/components/shared-components/tree/tree.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/tree/tree.svelte b/web/src/lib/components/shared-components/tree/tree.svelte index 5bc7a715ac..5c4b367a54 100644 --- a/web/src/lib/components/shared-components/tree/tree.svelte +++ b/web/src/lib/components/shared-components/tree/tree.svelte @@ -13,7 +13,7 @@ export let getColor: (path: string) => string | undefined; $: path = normalizeTreePath(`${parent}/${value}`); - $: isActive = active.startsWith(path); + $: isActive = active === path || active.startsWith(`${path}/`); $: isOpen = isActive; $: isTarget = active === path; $: color = getColor(path); From 720412645f3c5344d9c8e2ef4ef538e4563e0b0c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 4 Sep 2024 18:21:21 -0400 Subject: [PATCH 279/723] feat(web): sort albums in modal (#12331) --- .../components/album-page/albums-list.svelte | 71 +++---------------- .../album-selection-modal.svelte | 18 ++--- web/src/lib/utils/album-utils.ts | 62 ++++++++++++++++ 3 files changed, 81 insertions(+), 70 deletions(-) diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index 4355aca94d..5e3499bd10 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -1,6 +1,6 @@ + +
    +
    + +
    + +
    + + onReset({ ...options, configKeys: ['metadata'] })} + onSave={() => onSave({ metadata: config.metadata })} + showResetToDefault={!isEqual(savedConfig.metadata.faces.import, defaultConfig.metadata.faces.import)} + {disabled} + /> + +
    +
    diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 25f3b6ea2f..113998dc89 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -137,7 +137,11 @@ "map_settings_description": "Manage map settings", "map_style_description": "URL to a style.json map theme", "metadata_extraction_job": "Extract metadata", - "metadata_extraction_job_description": "Extract metadata information from each asset, such as GPS and resolution", + "metadata_extraction_job_description": "Extract metadata information from each asset, such as GPS, faces and resolution", + "metadata_faces_import_setting": "Enable face import", + "metadata_faces_import_setting_description": "Import faces from image EXIF data and sidecar files", + "metadata_settings": "Metadata Settings", + "metadata_settings_description": "Manage metadata settings", "migration_job": "Migration", "migration_job_description": "Migrate thumbnails for assets and faces to the latest folder structure", "no_paths_added": "No paths added", diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index 1d3c4bc00e..14d1e4e66e 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -8,6 +8,7 @@ export const featureFlags = writable({ smartSearch: true, duplicateDetection: false, facialRecognition: true, + importFaces: false, sidecar: true, map: true, reverseGeocoding: true, diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index 0555bab256..d03865cb39 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -4,6 +4,7 @@ import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte'; import ImageSettings from '$lib/components/admin-page/settings/image/image-settings.svelte'; import JobSettings from '$lib/components/admin-page/settings/job-settings/job-settings.svelte'; + import MetadataSettings from '$lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte'; import LibrarySettings from '$lib/components/admin-page/settings/library-settings/library-settings.svelte'; import LoggingSettings from '$lib/components/admin-page/settings/logging-settings/logging-settings.svelte'; import MachineLearningSettings from '$lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte'; @@ -86,6 +87,12 @@ subtitle: $t('admin.job_settings_description'), key: 'job', }, + { + component: MetadataSettings, + title: $t('admin.metadata_settings'), + subtitle: $t('admin.metadata_settings_description'), + key: 'metadata', + }, { component: LibrarySettings, title: $t('admin.library_settings'), From 0d6bef2c05a10b16ebbd4b2b4d1b238f34fec988 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Wed, 4 Sep 2024 23:28:30 +0100 Subject: [PATCH 281/723] ci: job naming improvements and success job for matrix (#12316) Co-authored-by: bo0tzz --- .github/workflows/cli.yml | 2 +- .github/workflows/docker.yml | 13 +++++++++++++ .github/workflows/docs-build.yml | 1 + .github/workflows/docs-deploy.yml | 2 ++ .github/workflows/docs-destroy.yml | 1 + .github/workflows/test.yml | 12 ++++++------ 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 1ec17b381d..5292075cce 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -22,7 +22,7 @@ permissions: jobs: publish: - name: Publish + name: CLI Publish runs-on: ubuntu-latest defaults: run: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7784b32f36..6be26c9bbe 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -234,3 +234,16 @@ jobs: BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }} BUILD_SOURCE_REF=${{ github.ref_name }} BUILD_SOURCE_COMMIT=${{ github.sha }} + + success-check: + name: Docker Build & Push Success + needs: [build_and_push_ml, build_and_push_server] + runs-on: ubuntu-latest + if: always() + steps: + - name: Any jobs failed? + if: ${{ contains(needs.*.result, 'failure') }} + run: exit 1 + - name: All jobs passed or skipped + if: ${{ !(contains(needs.*.result, 'failure')) }} + run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}" diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 682e3c45f0..387d8e0424 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -30,6 +30,7 @@ jobs: run: echo "should_force=${{ github.event_name == 'release' }}" >> "$GITHUB_OUTPUT" build: + name: Docs Build needs: pre-job if: ${{ needs.pre-job.outputs.should_run == 'true' }} runs-on: ubuntu-latest diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index a863cf8ed2..ab197fa459 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -7,6 +7,7 @@ on: jobs: checks: + name: Docs Deploy Checks runs-on: ubuntu-latest outputs: parameters: ${{ steps.parameters.outputs.result }} @@ -91,6 +92,7 @@ jobs: return parameters; deploy: + name: Docs Deploy runs-on: ubuntu-latest needs: checks if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }} diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index 861a6319fe..8070056924 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -5,6 +5,7 @@ on: jobs: deploy: + name: Docs Destroy runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ac6236d2eb..24e3e08623 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,7 +48,7 @@ jobs: run: echo "should_force=${{ github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT" server-unit-tests: - name: Server + name: Test & Lint Server needs: pre-job if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} runs-on: ubuntu-latest @@ -85,7 +85,7 @@ jobs: if: ${{ !cancelled() }} cli-unit-tests: - name: CLI + name: Unit Test CLI needs: pre-job if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }} runs-on: ubuntu-latest @@ -126,7 +126,7 @@ jobs: if: ${{ !cancelled() }} cli-unit-tests-win: - name: CLI (Windows) + name: Unit Test CLI (Windows) needs: pre-job if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }} runs-on: windows-latest @@ -160,7 +160,7 @@ jobs: if: ${{ !cancelled() }} web-unit-tests: - name: Web + name: Test & Lint Web needs: pre-job if: ${{ needs.pre-job.outputs.should_run_web == 'true' }} runs-on: ubuntu-latest @@ -327,7 +327,7 @@ jobs: if: ${{ !cancelled() }} mobile-unit-tests: - name: Mobile + name: Unit Test Mobile needs: pre-job if: ${{ needs.pre-job.outputs.should_run_mobile == 'true' }} runs-on: ubuntu-latest @@ -343,7 +343,7 @@ jobs: run: flutter test -j 1 ml-unit-tests: - name: Machine Learning + name: Unit Test ML needs: pre-job if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }} runs-on: ubuntu-latest From f4ec8425775c37c25e11c89b3dfd5b11de46b42d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 4 Sep 2024 23:38:55 -0400 Subject: [PATCH 282/723] refactor(web): upload panel (#12326) Co-authored-by: Alex --- web/src/lib/components/elements/icon.svelte | 3 +- .../upload-asset-preview.svelte | 146 +++++++++--------- .../shared-components/upload-panel.svelte | 37 +++-- web/src/lib/models/upload-asset.ts | 2 +- web/src/lib/stores/upload.ts | 89 +++++++---- web/src/lib/utils/file-uploader.ts | 47 ++++-- 6 files changed, 184 insertions(+), 140 deletions(-) diff --git a/web/src/lib/components/elements/icon.svelte b/web/src/lib/components/elements/icon.svelte index bb22276286..5965928718 100644 --- a/web/src/lib/components/elements/icon.svelte +++ b/web/src/lib/components/elements/icon.svelte @@ -16,13 +16,14 @@ export let ariaLabelledby: string | undefined = undefined; export let strokeWidth: number = 0; export let strokeColor: string = 'currentColor'; + export let spin = false; + import Icon from '$lib/components/elements/icon.svelte'; + import { AppRoute } from '$lib/constants'; import type { UploadAsset } from '$lib/models/upload-asset'; import { UploadState } from '$lib/models/upload-asset'; import { locale } from '$lib/stores/preferences.store'; - import { getByteUnitString } from '$lib/utils/byte-units'; - import { fade } from 'svelte/transition'; - import ImmichLogo from './immich-logo.svelte'; - import { getFilenameExtension } from '$lib/utils/asset-utils'; import { uploadAssetsStore } from '$lib/stores/upload'; - import Icon from '$lib/components/elements/icon.svelte'; + import { getByteUnitString } from '$lib/utils/byte-units'; import { fileUploadHandler } from '$lib/utils/file-uploader'; - import { mdiRefresh, mdiCancel } from '@mdi/js'; + import { + mdiAlertCircle, + mdiCheckCircle, + mdiCircleOutline, + mdiClose, + mdiLoading, + mdiOpenInNew, + mdiRestart, + } from '@mdi/js'; import { t } from 'svelte-i18n'; + import { fade } from 'svelte/transition'; export let uploadAsset: UploadAsset; + const handleDismiss = (uploadAsset: UploadAsset) => { + uploadAssetsStore.removeItem(uploadAsset.id); + }; + const handleRetry = async (uploadAsset: UploadAsset) => { - uploadAssetsStore.removeUploadAsset(uploadAsset.id); + uploadAssetsStore.removeItem(uploadAsset.id); await fileUploadHandler([uploadAsset.file], uploadAsset.albumId); }; @@ -23,86 +34,69 @@
    -
    -
    -
    - -
    -
    -

    - .{getFilenameExtension(uploadAsset.file.name)} -

    -
    +
    +
    + {#if uploadAsset.state === UploadState.PENDING} + + {:else if uploadAsset.state === UploadState.STARTED} + + {:else if uploadAsset.state === UploadState.ERROR} + + {:else if uploadAsset.state === UploadState.DUPLICATED} + + {:else if uploadAsset.state === UploadState.DONE} + + {/if}
    -
    - + + {uploadAsset.file.name} -
    - {#if uploadAsset.state === UploadState.STARTED} -
    -

    - {#if uploadAsset.message} - {uploadAsset.message} - {:else} - {uploadAsset.progress}% - {getByteUnitString(uploadAsset.speed || 0, $locale)}/s - {uploadAsset.eta}s - {/if} -

    - {:else if uploadAsset.state === UploadState.PENDING} -
    -

    {$t('pending')}

    - {:else if uploadAsset.state === UploadState.ERROR} -
    -

    {$t('error')}

    - {:else if uploadAsset.state === UploadState.DUPLICATED} -
    -

    - {$t('asset_skipped')} - {#if uploadAsset.message} - ({uploadAsset.message}) - {/if} -

    - {:else if uploadAsset.state === UploadState.DONE} -
    -

    - {$t('asset_uploaded')} - {#if uploadAsset.message} - ({uploadAsset.message}) - {/if} -

    - {/if} -
    -
    - {#if uploadAsset.state === UploadState.ERROR} -
    - - +
    + {:else if uploadAsset.state === UploadState.ERROR} +
    + +
    {/if}
    + {#if uploadAsset.state === UploadState.STARTED} +
    +
    +

    + {#if uploadAsset.message} + {uploadAsset.message} + {:else} + {uploadAsset.progress}% - {getByteUnitString(uploadAsset.speed || 0, $locale)}/s - {uploadAsset.eta}s + {/if} +

    +
    + {/if} + {#if uploadAsset.state === UploadState.ERROR}
    -

    +

    {uploadAsset.error}

    diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte index ee213d7969..d536053286 100644 --- a/web/src/lib/components/shared-components/upload-panel.svelte +++ b/web/src/lib/components/shared-components/upload-panel.svelte @@ -15,8 +15,7 @@ let showOptions = false; let concurrency = uploadExecutionQueue.concurrency; - let { isUploading, hasError, remainingUploads, errorCounter, duplicateCounter, successCounter, totalUploadCounter } = - uploadAssetsStore; + let { stats, isDismissible, isUploading, remainingUploads } = uploadAssetsStore; const autoHide = () => { if (!$isUploading && showDetail) { @@ -33,29 +32,29 @@ } -{#if $hasError || $isUploading} +{#if $isUploading}
    { - if ($errorCounter > 0) { + if ($stats.errors > 0) { notificationController.show({ - message: $t('upload_errors', { values: { count: $errorCounter } }), + message: $t('upload_errors', { values: { count: $stats.errors } }), type: NotificationType.Warning, }); - } else if ($successCounter > 0) { + } else if ($stats.success > 0) { notificationController.show({ message: $t('upload_success'), type: NotificationType.Info, }); } - if ($duplicateCounter > 0) { + if ($stats.duplicates > 0) { notificationController.show({ - message: $t('upload_skipped_duplicates', { values: { count: $duplicateCounter } }), + message: $t('upload_skipped_duplicates', { values: { count: $stats.duplicates } }), type: NotificationType.Warning, }); } - uploadAssetsStore.resetStore(); + uploadAssetsStore.reset(); }} class="fixed bottom-6 right-6 z-[10000]" > @@ -70,20 +69,20 @@ {$t('upload_progress', { values: { remaining: $remainingUploads, - processed: $successCounter + $errorCounter, - total: $totalUploadCounter, + processed: $stats.total - $remainingUploads, + total: $stats.total, }, })}

    {$t('upload_status_uploaded')} - {$successCounter.toLocaleString($locale)} + {$stats.success.toLocaleString($locale)} - {$t('upload_status_errors')} - {$errorCounter.toLocaleString($locale)} + {$stats.errors.toLocaleString($locale)} - {$t('upload_status_duplicates')} - {$duplicateCounter.toLocaleString($locale)} + {$stats.duplicates.toLocaleString($locale)}

    @@ -103,7 +102,7 @@ on:click={() => (showDetail = false)} />
    - {#if $hasError} + {#if $isDismissible}
    {#if showOptions} -
    +
    @@ -133,7 +132,7 @@ />
    {/if} -
    +
    {#each $uploadAssetsStore as uploadAsset (uploadAsset.id)} {/each} @@ -149,14 +148,14 @@ > {$remainingUploads.toLocaleString($locale)} - {#if $hasError} + {#if $stats.errors > 0} {/if}
    {/if} - +

    { expect(img.alt).toBe('123'); expect(img.getAttribute('src')).toBe('wee'); expect(img.getAttribute('loading')).toBe('eager'); - expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square asdf'); + expect(img.className).toBe('size-full rounded-xl object-cover aspect-square asdf'); }); }); diff --git a/web/src/lib/components/sharedlinks-page/covers/__tests__/no-cover.spec.ts b/web/src/lib/components/sharedlinks-page/covers/__tests__/no-cover.spec.ts index bdf0b8878c..bb87c02135 100644 --- a/web/src/lib/components/sharedlinks-page/covers/__tests__/no-cover.spec.ts +++ b/web/src/lib/components/sharedlinks-page/covers/__tests__/no-cover.spec.ts @@ -10,7 +10,7 @@ describe('NoCover component', () => { }); const img = component.getByTestId('album-image') as HTMLImageElement; expect(img.alt).toBe('123'); - expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square asdf'); + expect(img.className).toBe('size-full rounded-xl object-cover aspect-square asdf'); expect(img.getAttribute('loading')).toBe('eager'); expect(img.src).toStrictEqual(expect.any(String)); }); diff --git a/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts b/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts index 83ca07b40e..76de04ea31 100644 --- a/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts +++ b/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts @@ -17,7 +17,7 @@ describe('ShareCover component', () => { const img = component.getByTestId('album-image') as HTMLImageElement; expect(img.alt).toBe('123'); expect(img.getAttribute('loading')).toBe('lazy'); - expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square text'); + expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text'); }); it('renders an image when the shared link is an individual share', () => { @@ -30,7 +30,7 @@ describe('ShareCover component', () => { const img = component.getByTestId('album-image') as HTMLImageElement; expect(img.alt).toBe('individual_share'); expect(img.getAttribute('loading')).toBe('lazy'); - expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square text'); + expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text'); expect(img.getAttribute('src')).toBe('/asdf'); expect(getAssetThumbnailUrl).toHaveBeenCalledWith('someId'); }); @@ -44,7 +44,7 @@ describe('ShareCover component', () => { const img = component.getByTestId('album-image') as HTMLImageElement; expect(img.alt).toBe('unnamed_share'); expect(img.getAttribute('loading')).toBe('lazy'); - expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square text'); + expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text'); }); it.skip('renders fallback image when asset is not resized', () => { diff --git a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte index d8b0a1b0d7..bf5031e39d 100644 --- a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte @@ -16,7 +16,7 @@ (isBroken = true)} - class="z-0 rounded-xl object-cover aspect-square {className}" + class="size-full rounded-xl object-cover aspect-square {className}" data-testid="album-image" draggable="false" loading={preload ? 'eager' : 'lazy'} diff --git a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte index 45d7d4b315..087204d6a5 100644 --- a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte @@ -7,7 +7,7 @@ Date: Fri, 6 Sep 2024 15:16:59 +0200 Subject: [PATCH 301/723] feat: optimize copy image to clipboard (#12366) * feat: optimize copy image to clipboard * pr feedback * fix: urlToBlob Co-authored-by: Jason Rasmussen * fix: imgToBlob Co-authored-by: Jason Rasmussen * chore: finish rename * fix: dimensions --------- Co-authored-by: Jason Rasmussen --- web/package-lock.json | 6 --- web/package.json | 1 - .../asset-viewer/asset-viewer-nav-bar.svelte | 4 +- .../asset-viewer/photo-viewer.svelte | 14 +++---- web/src/lib/utils/asset-utils.spec.ts | 8 +++- web/src/lib/utils/asset-utils.ts | 38 +++++++++++++++++++ 6 files changed, 52 insertions(+), 19 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 4ddc6d9baa..7bcd5c2b01 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,7 +17,6 @@ "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", "@photo-sphere-viewer/video-plugin": "^5.7.2", "@zoom-image/svelte": "^0.2.6", - "copy-image-clipboard": "^2.1.2", "dom-to-image": "^2.6.0", "handlebars": "^4.7.8", "intl-messageformat": "^10.5.14", @@ -3284,11 +3283,6 @@ "node": ">= 0.6" } }, - "node_modules/copy-image-clipboard": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/copy-image-clipboard/-/copy-image-clipboard-2.1.2.tgz", - "integrity": "sha512-3VCXVl2IpFfOyD8drv9DozcNlwmqBqxOlsgkEGyVAzadjlPk1go8YNZyy8QmTnwHPxSFpeCR9OdsStEdVK7qDA==" - }, "node_modules/core-js-compat": { "version": "3.37.1", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", diff --git a/web/package.json b/web/package.json index 1ba350022d..c84bbf0db4 100644 --- a/web/package.json +++ b/web/package.json @@ -73,7 +73,6 @@ "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", "@photo-sphere-viewer/video-plugin": "^5.7.2", "@zoom-image/svelte": "^0.2.6", - "copy-image-clipboard": "^2.1.2", "dom-to-image": "^2.6.0", "handlebars": "^4.7.8", "intl-messageformat": "^10.5.14", diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 0f75f9bb83..db216641d5 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -41,7 +41,7 @@ mdiPresentationPlay, mdiUpload, } from '@mdi/js'; - import { canCopyImagesToClipboard } from 'copy-image-clipboard'; + import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; import { t } from 'svelte-i18n'; export let asset: AssetResponseDto; @@ -101,7 +101,7 @@ on:click={onZoomImage} /> {/if} - {#if canCopyImagesToClipboard() && asset.type === AssetTypeEnum.Image} + {#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image} {/if} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 40a36fa0e0..4157c558d2 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -8,17 +8,17 @@ import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; - import { isWebCompatibleImage } from '$lib/utils/asset-utils'; + import { isWebCompatibleImage, canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; import { getAltText } from '$lib/utils/thumbnail-util'; import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; - import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard'; import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { type SwipeCustomEvent, swipe } from 'svelte-gestures'; import { fade } from 'svelte/transition'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; + import { handleError } from '$lib/utils/handle-error'; export let asset: AssetResponseDto; export let preloadAssets: AssetResponseDto[] | undefined = undefined; @@ -81,23 +81,19 @@ }; copyImage = async () => { - if (!canCopyImagesToClipboard()) { + if (!canCopyImageToClipboard()) { return; } try { - await copyImageToClipboard(assetFileUrl); + await copyImageToClipboard($photoViewer ?? assetFileUrl); notificationController.show({ type: NotificationType.Info, message: $t('copied_image_to_clipboard'), timeout: 3000, }); } catch (error) { - console.error('Error [photo-viewer]:', error); - notificationController.show({ - type: NotificationType.Error, - message: 'Copying image to clipboard failed.', - }); + handleError(error, $t('copy_error')); } }; diff --git a/web/src/lib/utils/asset-utils.spec.ts b/web/src/lib/utils/asset-utils.spec.ts index 8970a6a652..b3a668192d 100644 --- a/web/src/lib/utils/asset-utils.spec.ts +++ b/web/src/lib/utils/asset-utils.spec.ts @@ -1,5 +1,5 @@ import type { AssetResponseDto } from '@immich/sdk'; -import { getAssetFilename, getFilenameExtension } from './asset-utils'; +import { canCopyImageToClipboard, getAssetFilename, getFilenameExtension } from './asset-utils'; describe('get file extension from filename', () => { it('returns the extension without including the dot', () => { @@ -56,3 +56,9 @@ describe('get asset filename', () => { } }); }); + +describe('copy image to clipboard', () => { + it('should not allow copy image to clipboard', () => { + expect(canCopyImageToClipboard()).toEqual(false); + }); +}); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index e309db5ff6..84a896452f 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -527,3 +527,41 @@ export const archiveAssets = async (assets: AssetResponseDto[], archive: boolean export const delay = async (ms: number) => { return new Promise((resolve) => setTimeout(resolve, ms)); }; + +export const canCopyImageToClipboard = (): boolean => { + return !!(navigator.clipboard && window.ClipboardItem); +}; + +const imgToBlob = async (imageElement: HTMLImageElement) => { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + canvas.width = imageElement.naturalWidth; + canvas.height = imageElement.naturalHeight; + + if (context) { + context.drawImage(imageElement, 0, 0); + + return await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + throw new Error('Canvas conversion to Blob failed'); + } + }); + }); + } + + throw new Error('Canvas context is null'); +}; + +const urlToBlob = async (imageSource: string) => { + const response = await fetch(imageSource); + return await response.blob(); +}; + +export const copyImageToClipboard = async (source: HTMLImageElement | string) => { + const blob = source instanceof HTMLImageElement ? await imgToBlob(source) : await urlToBlob(source); + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); +}; From 529b7fe748e5e5830b6b28b5130f220826ea0837 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:18:45 +0200 Subject: [PATCH 302/723] fix(web): show focus outline for asset thumbnails again (#12382) * fix(web): show focus outline for asset thumbnails again * fix e2e test --- e2e/src/web/specs/shared-link.e2e-spec.ts | 2 +- web/src/lib/components/assets/thumbnail/thumbnail.svelte | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index 8679bb3236..2a02e429a5 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -44,7 +44,7 @@ test.describe('Shared Links', () => { test('download from a shared link', async ({ page }) => { await page.goto(`/share/${sharedLink.key}`); await page.getByRole('heading', { name: 'Test Album' }).waitFor(); - await page.locator('.group').first().hover(); + await page.locator(`[data-asset-id="${asset.id}"]`).hover(); await page.waitForSelector('#asset-group-by-date svg'); await page.getByRole('checkbox').click(); await page.getByRole('button', { name: 'Download' }).click(); diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 69f777f530..af22887185 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -175,7 +175,7 @@ data-int={intersecting} style:width="{width}px" style:height="{height}px" - class="group focus-visible:outline-none flex overflow-hidden {disabled + class="focus-visible:outline-none flex overflow-hidden {disabled ? 'bg-gray-300' : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}" > @@ -193,6 +193,7 @@

    Date: Fri, 6 Sep 2024 06:26:58 -0700 Subject: [PATCH 303/723] feat(web): add download shortcut on the timeline & asset viewer (#12339) feat(web): implement download shortcut --- .../lib/components/photos-page/actions/download-action.svelte | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/src/lib/components/photos-page/actions/download-action.svelte b/web/src/lib/components/photos-page/actions/download-action.svelte index 073d20901c..7716fbe36d 100644 --- a/web/src/lib/components/photos-page/actions/download-action.svelte +++ b/web/src/lib/components/photos-page/actions/download-action.svelte @@ -2,6 +2,7 @@ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { downloadArchive, downloadFile } from '$lib/utils/asset-utils'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; + import { shortcut } from '$lib/actions/shortcut'; import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -26,6 +27,8 @@ $: menuItemIcon = getAssets().size === 1 ? mdiFileDownloadOutline : mdiFolderDownloadOutline; + + {#if menuItem} {:else} From 5d8052202e4deb4b9f1f8f459b5ee0830ab20c00 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 6 Sep 2024 08:30:26 -0500 Subject: [PATCH 304/723] chore(mobile): Translations update (#12392) chore(mobile): translation update --- mobile/assets/i18n/ar-JO.json | 4 ++ mobile/assets/i18n/cs-CZ.json | 6 ++- mobile/assets/i18n/da-DK.json | 4 ++ mobile/assets/i18n/de-DE.json | 4 ++ mobile/assets/i18n/el-GR.json | 4 ++ mobile/assets/i18n/en-US.json | 13 +++-- mobile/assets/i18n/es-ES.json | 34 ++++++------ mobile/assets/i18n/es-MX.json | 4 ++ mobile/assets/i18n/es-PE.json | 4 ++ mobile/assets/i18n/es-US.json | 4 ++ mobile/assets/i18n/fi-FI.json | 4 ++ mobile/assets/i18n/fr-CA.json | 4 ++ mobile/assets/i18n/fr-FR.json | 4 ++ mobile/assets/i18n/he-IL.json | 22 ++++---- mobile/assets/i18n/hi-IN.json | 4 ++ mobile/assets/i18n/hu-HU.json | 4 ++ mobile/assets/i18n/it-IT.json | 4 ++ mobile/assets/i18n/ja-JP.json | 4 ++ mobile/assets/i18n/ko-KR.json | 94 +++++++++++++++++---------------- mobile/assets/i18n/lt-LT.json | 4 ++ mobile/assets/i18n/lv-LV.json | 4 ++ mobile/assets/i18n/mn.json | 4 ++ mobile/assets/i18n/nb-NO.json | 4 ++ mobile/assets/i18n/nl-NL.json | 4 ++ mobile/assets/i18n/pl-PL.json | 4 ++ mobile/assets/i18n/pt-PT.json | 4 ++ mobile/assets/i18n/ro-RO.json | 4 ++ mobile/assets/i18n/ru-RU.json | 4 ++ mobile/assets/i18n/sk-SK.json | 4 ++ mobile/assets/i18n/sl-SI.json | 4 ++ mobile/assets/i18n/sr-Cyrl.json | 4 ++ mobile/assets/i18n/sr-Latn.json | 4 ++ mobile/assets/i18n/sv-FI.json | 4 ++ mobile/assets/i18n/sv-SE.json | 4 ++ mobile/assets/i18n/th-TH.json | 4 ++ mobile/assets/i18n/uk-UA.json | 4 ++ mobile/assets/i18n/vi-VN.json | 6 ++- mobile/assets/i18n/zh-CN.json | 4 ++ mobile/assets/i18n/zh-Hans.json | 4 ++ mobile/assets/i18n/zh-TW.json | 4 ++ 40 files changed, 235 insertions(+), 76 deletions(-) diff --git a/mobile/assets/i18n/ar-JO.json b/mobile/assets/i18n/ar-JO.json index 8b9f8c42c4..fdc54da2b7 100644 --- a/mobile/assets/i18n/ar-JO.json +++ b/mobile/assets/i18n/ar-JO.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "حذف الرابط المشترك", "description_input_hint_text": "اضف وصفا...", "description_input_submit_error": "خطأ تحديث الوصف ، تحقق من السجل لمزيد من التفاصيل", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "التاريخ و الوقت", "edit_date_time_dialog_timezone": "وحدة زمنية", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/cs-CZ.json b/mobile/assets/i18n/cs-CZ.json index 9872aa1324..4a81de7596 100644 --- a/mobile/assets/i18n/cs-CZ.json +++ b/mobile/assets/i18n/cs-CZ.json @@ -63,7 +63,7 @@ "assets_trashed_from_server": "{} položek vyhozeno do koše na Immich serveru", "asset_viewer_settings_title": "Prohlížeč", "backup_album_selection_page_albums_device": "Alba v zařízení ({})", - "backup_album_selection_page_albums_tap": "Klepnutím na položku ji zahrnete, dvojím klepnutím ji vyloučíte", + "backup_album_selection_page_albums_tap": "Klepnutím na položku ji zahrnete, opětovným klepnutím ji vyloučíte", "backup_album_selection_page_assets_scatter": "Položky mohou být roztroušeny ve více albech. To umožňuje zahrnout nebo vyloučit alba během procesu zálohování.", "backup_album_selection_page_select_albums": "Vybraná alba", "backup_album_selection_page_selection_info": "Informace o výběru", @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Odstranit sdílený odkaz", "description_input_hint_text": "Přidat popis...", "description_input_submit_error": "Chyba aktualizace popisu, další podrobnosti najdete v logu", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Datum a čas", "edit_date_time_dialog_timezone": "Časové pásmo", "edit_image_title": "Upravit", diff --git a/mobile/assets/i18n/da-DK.json b/mobile/assets/i18n/da-DK.json index b9688a39c9..20c3c43b09 100644 --- a/mobile/assets/i18n/da-DK.json +++ b/mobile/assets/i18n/da-DK.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Slet delt link", "description_input_hint_text": "Tilføj en beskrivelse...", "description_input_submit_error": "Fejl med at opdatere beskrivelsen. Tjek loggen for flere detaljer", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Dato og klokkeslæt", "edit_date_time_dialog_timezone": "Tidszone", "edit_image_title": "Rediger", diff --git a/mobile/assets/i18n/de-DE.json b/mobile/assets/i18n/de-DE.json index 1da83fb551..bb2ed31f8a 100644 --- a/mobile/assets/i18n/de-DE.json +++ b/mobile/assets/i18n/de-DE.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Geteilten Link löschen", "description_input_hint_text": "Beschreibung hinzufügen...", "description_input_submit_error": "Beschreibung konnte nicht geändert werden, bitte im Log für mehr Details nachsehen.", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Datum und Uhrzeit", "edit_date_time_dialog_timezone": "Zeitzone", "edit_image_title": "Bearbeiten", diff --git a/mobile/assets/i18n/el-GR.json b/mobile/assets/i18n/el-GR.json index 29efeb03d1..88426a6076 100644 --- a/mobile/assets/i18n/el-GR.json +++ b/mobile/assets/i18n/el-GR.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Διαγραφή Κοινοποιημένου Συνδέσμου", "description_input_hint_text": "Προσθήκη περιγραφής...", "description_input_submit_error": "Σφάλμα κατά την ενημέρωση της περιγραφής, ελέγξτε το αρχείο καταγραφής για περισσότερες λεπτομέρειες", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Ημερομηνία και Ώρα", "edit_date_time_dialog_timezone": "Ζώνη ώρας", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 9dbe49589f..324c9069fd 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", @@ -252,10 +256,9 @@ "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", "image_saved_successfully": "Image saved", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_started": "Download Started", + "image_viewer_page_state_provider_download_success": "Download Success", "image_viewer_page_state_provider_share_error": "Share Error", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", @@ -586,4 +589,4 @@ "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" -} +} \ No newline at end of file diff --git a/mobile/assets/i18n/es-ES.json b/mobile/assets/i18n/es-ES.json index 82e77aa476..1943116b4f 100644 --- a/mobile/assets/i18n/es-ES.json +++ b/mobile/assets/i18n/es-ES.json @@ -173,8 +173,8 @@ "control_bottom_app_bar_delete": "Eliminar", "control_bottom_app_bar_delete_from_immich": "Borrar de Immich", "control_bottom_app_bar_delete_from_local": "Borrar del dispositivo", - "control_bottom_app_bar_download": "Download", - "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_download": "Descargar", + "control_bottom_app_bar_edit": "Editar", "control_bottom_app_bar_edit_location": "Editar ubicación", "control_bottom_app_bar_edit_time": "Editar fecha y hora", "control_bottom_app_bar_favorite": "Favorito", @@ -190,7 +190,7 @@ "create_shared_album_page_share": "Compartir", "create_shared_album_page_share_add_assets": "AGREGAR ELEMENTOS", "create_shared_album_page_share_select_photos": "Seleccionar Fotos", - "crop": "Crop", + "crop": "Recortar", "curated_location_page_title": "Lugares", "curated_object_page_title": "Objetos", "daily_title_text_date": "E dd, MMM", @@ -210,9 +210,13 @@ "delete_shared_link_dialog_title": "Eliminar enlace compartido", "description_input_hint_text": "Agregar descripción...", "description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Fecha y Hora", "edit_date_time_dialog_timezone": "Zona horaria", - "edit_image_title": "Edit", + "edit_image_title": "Editar", "edit_location_dialog_title": "Ubicación", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Agregar Descripción...", @@ -251,13 +255,13 @@ "home_page_first_time_notice": "Si esta es la primera vez que usas la app, por favor, asegúrate de elegir un álbum de respaldo para que la línea de tiempo pueda cargar fotos y videos en los álbumes.", "home_page_share_err_local": "No se pueden compartir elementos locales a través de un enlace, omitiendo", "home_page_upload_err_limit": "Solo se pueden subir 30 elementos simultáneamente, omitiendo", - "image_saved_successfully": "Image saved", + "image_saved_successfully": "Imágenes guardas", "image_viewer_page_state_provider_download_error": "Error de descarga", "image_viewer_page_state_provider_download_started": "Descarga Iniciada", "image_viewer_page_state_provider_download_success": "Descarga exitosa", "image_viewer_page_state_provider_share_error": "Error al compartir", - "invalid_date": "Invalid date", - "invalid_date_format": "Invalid date format", + "invalid_date": "Fecha incorrecta", + "invalid_date_format": "Formato de fecha incorrecto", "library_page_albums": "Álbumes", "library_page_archive": "Archivo", "library_page_device_albums": "Álbumes en el dispositivo", @@ -380,27 +384,27 @@ "profile_drawer_sign_out": "Cerrar Sesión", "profile_drawer_trash": "Papelera", "recently_added_page_title": "Recién Agregadas", - "save_to_gallery": "Save to gallery", + "save_to_gallery": "Guardado en la galería", "scaffold_body_error_occurred": "Ha ocurrido un error", "search_bar_hint": "Busca tus fotos", "search_filter_apply": "Aplicar filtros", - "search_filter_camera": "Camera", + "search_filter_camera": "Cámara", "search_filter_camera_make": "Marca", "search_filter_camera_model": "Modelo", "search_filter_camera_title": "Select camera type", - "search_filter_date": "Date", - "search_filter_date_interval": "{start} to {end}", - "search_filter_date_title": "Select a date range", + "search_filter_date": "Fecha", + "search_filter_date_interval": "{start} al {end}", + "search_filter_date_title": "Selecciona un intervalo de fechas", "search_filter_display_option_archive": "Archivado", "search_filter_display_option_favorite": "Favorito", "search_filter_display_option_not_in_album": "No en álbum", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", - "search_filter_location": "Location", + "search_filter_location": "Ubicación", "search_filter_location_city": "Ciudad", "search_filter_location_country": "País", "search_filter_location_state": "Estado", - "search_filter_location_title": "Select location", + "search_filter_location_title": "Seleccionar una ubicación", "search_filter_media_type": "Media Type", "search_filter_media_type_all": "Todos", "search_filter_media_type_image": "Imagen", @@ -535,7 +539,7 @@ "sharing_silver_appbar_create_shared_album": "Crear un álbum compartido", "sharing_silver_appbar_shared_links": "Enlaces compartidos", "sharing_silver_appbar_share_partner": "Compartir con el compañero", - "sync": "Sync", + "sync": "Sincronizar", "sync_albums": "Sync albums", "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", diff --git a/mobile/assets/i18n/es-MX.json b/mobile/assets/i18n/es-MX.json index 80eeae8d39..8361e9a285 100644 --- a/mobile/assets/i18n/es-MX.json +++ b/mobile/assets/i18n/es-MX.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Eliminar enlace compartido", "description_input_hint_text": "Agregar descripción...", "description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/es-PE.json b/mobile/assets/i18n/es-PE.json index 4971435f9e..cee06c9512 100644 --- a/mobile/assets/i18n/es-PE.json +++ b/mobile/assets/i18n/es-PE.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Eliminar enlace compartido", "description_input_hint_text": "Agregar descripción...", "description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/es-US.json b/mobile/assets/i18n/es-US.json index cff40b28ba..ea0b328a80 100644 --- a/mobile/assets/i18n/es-US.json +++ b/mobile/assets/i18n/es-US.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Eliminar enlace compartido", "description_input_hint_text": "Agregar descripción...", "description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/fi-FI.json b/mobile/assets/i18n/fi-FI.json index 410a7e4719..cb687ecef5 100644 --- a/mobile/assets/i18n/fi-FI.json +++ b/mobile/assets/i18n/fi-FI.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Poista jaettu linkki", "description_input_hint_text": "Lisää kuvaus...", "description_input_submit_error": "Virhe kuvauksen päivittämisessä, tarkista lisätiedot lokista", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Päivämäärä ja aika", "edit_date_time_dialog_timezone": "Aikavyöhyke", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/fr-CA.json b/mobile/assets/i18n/fr-CA.json index 97a23c4cc7..8d742c3a59 100644 --- a/mobile/assets/i18n/fr-CA.json +++ b/mobile/assets/i18n/fr-CA.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Supprimer le lien partagé", "description_input_hint_text": "Ajouter une description...", "description_input_submit_error": "Erreur de mise à jour de la description, vérifier le journal pour plus de détails", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/fr-FR.json b/mobile/assets/i18n/fr-FR.json index fe280aa4d2..9ff5c6f280 100644 --- a/mobile/assets/i18n/fr-FR.json +++ b/mobile/assets/i18n/fr-FR.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Supprimer le lien partagé", "description_input_hint_text": "Ajouter une description…", "description_input_submit_error": "Erreur de mise à jour de la description, vérifier le journal pour plus de détails", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Date et heure", "edit_date_time_dialog_timezone": "Fuseau horaire", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/he-IL.json b/mobile/assets/i18n/he-IL.json index b57b6c01d6..7ddbb392a0 100644 --- a/mobile/assets/i18n/he-IL.json +++ b/mobile/assets/i18n/he-IL.json @@ -190,7 +190,7 @@ "create_shared_album_page_share": "שתף", "create_shared_album_page_share_add_assets": "הוסף נכסים", "create_shared_album_page_share_select_photos": "בחירת תמונות", - "crop": "Crop", + "crop": "חתוך", "curated_location_page_title": "מקומות", "curated_object_page_title": "דברים", "daily_title_text_date": "E, MMM dd", @@ -210,11 +210,15 @@ "delete_shared_link_dialog_title": "מחק קישור משותף", "description_input_hint_text": "הוסף תיאור...", "description_input_submit_error": "שגיאה בעדכון תיאור, בדוק את היומן לפרטים נוספים", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "תאריך וזמן", "edit_date_time_dialog_timezone": "אזור זמן", - "edit_image_title": "Edit", + "edit_image_title": "ערוך", "edit_location_dialog_title": "מיקום", - "error_saving_image": "Error: {}", + "error_saving_image": "שגיאה: {}", "exif_bottom_sheet_description": "הוסף תיאור...", "exif_bottom_sheet_details": "פרטים", "exif_bottom_sheet_location": "מיקום", @@ -251,7 +255,7 @@ "home_page_first_time_notice": "אם זאת הפעם הראשונה שאת/ה משתמש/ת ביישום, נא להקפיד לבחור אלבומ(ים) לגיבוי כך שציר הזמן יוכל לאכלס תמונות וסרטונים באלבומ(ים)", "home_page_share_err_local": "לא ניתן לשתף נכסים מקומיים על ידי קישור, מדלג", "home_page_upload_err_limit": "ניתן להעלות רק מקסימום של 30 נכסים בכל פעם, מדלג", - "image_saved_successfully": "Image saved", + "image_saved_successfully": "תמונה נשמרה", "image_viewer_page_state_provider_download_error": "שגיאת הורדה", "image_viewer_page_state_provider_download_started": "ההורדה החלה", "image_viewer_page_state_provider_download_success": "הצלחת הורדה", @@ -380,7 +384,7 @@ "profile_drawer_sign_out": "יציאה", "profile_drawer_trash": "אשפה", "recently_added_page_title": "נוסף לאחרונה", - "save_to_gallery": "Save to gallery", + "save_to_gallery": "שמור לגלריה", "scaffold_body_error_occurred": "אירעה שגיאה", "search_bar_hint": "חפש/י בתמונות שלך", "search_filter_apply": "החל סינון", @@ -535,10 +539,10 @@ "sharing_silver_appbar_create_shared_album": "אלבום משותף חדש", "sharing_silver_appbar_shared_links": "קישורים משותפים", "sharing_silver_appbar_share_partner": "שיתוף עם שותף", - "sync": "Sync", - "sync_albums": "Sync albums", - "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", - "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "sync": "סנכרן", + "sync_albums": "סנכרן אלבומים", + "sync_albums_manual_subtitle": "סנכרן את כל הסרטונים והתמונות שהועלו לאלבומי הגיבוי שנבחרו", + "sync_upload_album_setting_subtitle": "צור והעלה תמונות וסרטונים שלך לאלבומים שנבחרו ביישום", "tab_controller_nav_library": "ספרייה", "tab_controller_nav_photos": "תמונות", "tab_controller_nav_search": "חיפוש", diff --git a/mobile/assets/i18n/hi-IN.json b/mobile/assets/i18n/hi-IN.json index 7d415cc2f8..534cae0622 100644 --- a/mobile/assets/i18n/hi-IN.json +++ b/mobile/assets/i18n/hi-IN.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "साझा किए गए लिंक को हटाएं", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/hu-HU.json b/mobile/assets/i18n/hu-HU.json index 72012b1ca3..8f14b9673a 100644 --- a/mobile/assets/i18n/hu-HU.json +++ b/mobile/assets/i18n/hu-HU.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Megosztott Link Törlése", "description_input_hint_text": "Leírás hozzáadása...", "description_input_submit_error": "Nem sikerült frissíteni a leírást. További információért kérjük, nézd meg az eseménynaplót", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Dátum és Idő", "edit_date_time_dialog_timezone": "Időzóna", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/it-IT.json b/mobile/assets/i18n/it-IT.json index febac12c05..d7585c753c 100644 --- a/mobile/assets/i18n/it-IT.json +++ b/mobile/assets/i18n/it-IT.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Elimina link condiviso", "description_input_hint_text": "Aggiungi descrizione...", "description_input_submit_error": "Errore modificare descrizione, controlli I log per maggiori dettagli", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Data e ora", "edit_date_time_dialog_timezone": "Fuso orario", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/ja-JP.json b/mobile/assets/i18n/ja-JP.json index ccd78380a7..21b8bea9e3 100644 --- a/mobile/assets/i18n/ja-JP.json +++ b/mobile/assets/i18n/ja-JP.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "共有リンクを消す", "description_input_hint_text": "説明を追加", "description_input_submit_error": "説明の編集に失敗しました。詳細はログを確認してください。", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "日付と時間", "edit_date_time_dialog_timezone": "タイムゾーン", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/ko-KR.json b/mobile/assets/i18n/ko-KR.json index bf381fee22..e6da75c2f6 100644 --- a/mobile/assets/i18n/ko-KR.json +++ b/mobile/assets/i18n/ko-KR.json @@ -31,7 +31,7 @@ "album_viewer_appbar_share_err_delete": "앨범을 삭제하지 못했습니다.", "album_viewer_appbar_share_err_leave": "앨범에서 나가지 못했습니다.", "album_viewer_appbar_share_err_remove": "앨범에서 항목을 제거하지 못했습니다.", - "album_viewer_appbar_share_err_title": "앨범 이름을 변경하지 못했습니다.", + "album_viewer_appbar_share_err_title": "앨범명을 변경하지 못했습니다.", "album_viewer_appbar_share_leave": "앨범 나가기", "album_viewer_appbar_share_remove": "앨범에서 제거", "album_viewer_appbar_share_to": "공유 대상", @@ -55,12 +55,12 @@ "asset_list_settings_subtitle": "사진 배열 레이아웃 설정", "asset_list_settings_title": "사진 배열", "asset_restored_successfully": "항목이 성공적으로 복원되었습니다.", - "assets_deleted_permanently": "{} 미디어가 영구 삭제됨", - "assets_deleted_permanently_from_server": "Immich 서버에서 {} 미디어가 영구 삭제되었습니다.", - "assets_removed_permanently_from_device": "장치에서 {} 미디어가 영구적으로 제거되었습니다.", - "assets_restored_successfully": "{} 미디어가 성공적으로 복원되었습니다.", - "assets_trashed": "{} 미디어가 휴지통에 버려졌습니다.", - "assets_trashed_from_server": "Immich 서버에서 {} 미디어를 삭제했습니다.", + "assets_deleted_permanently": "{}개 항목이 영구적으로 삭제됨", + "assets_deleted_permanently_from_server": "{}개 항목이 Immich 서버에서 영구적으로 삭제됨", + "assets_removed_permanently_from_device": "{}개 항목이 기기에서 영구적으로 삭제됨", + "assets_restored_successfully": "항목 {}개를 복원했습니다.", + "assets_trashed": "휴지통으로 {}개 항목이 이동되었습니다.", + "assets_trashed_from_server": "휴지통으로 Immich 서버의 {}개 항목이 이동되었습니다.", "asset_viewer_settings_title": "보기 옵션", "backup_album_selection_page_albums_device": "기기의 앨범 ({})", "backup_album_selection_page_albums_tap": "한 번 눌러 선택, 두 번 눌러 제외하세요.", @@ -69,55 +69,55 @@ "backup_album_selection_page_selection_info": "선택한 앨범", "backup_album_selection_page_total_assets": "전체 항목", "backup_all": "모두", - "backup_background_service_backup_failed_message": "백업하지 못했습니다. 다시 시도하는 중...", + "backup_background_service_backup_failed_message": "항목을 백업하지 못했습니다. 다시 시도하는 중...", "backup_background_service_connection_failed_message": "서버에 연결하지 못했습니다. 다시 시도하는 중...", "backup_background_service_current_upload_notification": "{} 업로드 중", "backup_background_service_default_notification": "백업할 항목을 확인하는 중...", "backup_background_service_error_title": "백업 오류", "backup_background_service_in_progress_notification": "선택한 항목을 백업하는 중...", "backup_background_service_upload_failure_notification": "{} 업로드 실패", - "backup_controller_page_albums": "백업 대상 앨범", + "backup_controller_page_albums": "백업할 앨범", "backup_controller_page_background_app_refresh_disabled_content": "백그라운드 백업을 사용하려면 설정 > 일반 > 백그라운드 앱 새로 고침에서 백그라운드 앱 새로 고침을 활성화하세요.", "backup_controller_page_background_app_refresh_disabled_title": "백그라운드 새로 고침 비활성화됨", "backup_controller_page_background_app_refresh_enable_button_text": "설정으로 이동", "backup_controller_page_background_battery_info_link": "설정 방법", - "backup_controller_page_background_battery_info_message": "최상의 백그라운드 백업 환경을 위해, Immich의 백그라운드 활동을 제한하는 배터리 최적화를 비활성화하세요.\n\n설정 방법은 기기마다 다르므로, 제조 업체에서 관련 정보를 찾아보세요.", + "backup_controller_page_background_battery_info_message": "최상의 백그라운드 백업 환경을 위해 Immich 백그라운드 활동을 제한하는 배터리 최적화 기능을 비활성화하세요.\n\n기기마다 설정 방법에 차이가 있어 제조 업체에서 관련 정보를 찾아보세요.", "backup_controller_page_background_battery_info_ok": "확인", "backup_controller_page_background_battery_info_title": "배터리 최적화", "backup_controller_page_background_charging": "충전 중에만", "backup_controller_page_background_configure_error": "백그라운드 서비스 구성 실패", "backup_controller_page_background_delay": "새 콘텐츠 백업 간격: {}", - "backup_controller_page_background_description": "백그라운드 서비스를 활성화하면 앱을 열지 않고도 새 콘텐츠를 자동으로 백업할 수 있습니다.", - "backup_controller_page_background_is_off": "자동 백그라운드 백업이 비활성화되었습니다.", - "backup_controller_page_background_is_on": "자동 백그라운드 백업이 활성화되었습니다.", + "backup_controller_page_background_description": "백그라운드 서비스를 활성화하여 앱을 실행하지 않고 새 항목을 자동으로 백업하세요.", + "backup_controller_page_background_is_off": "백그라운드 백업이 비활성화되었습니다.", + "backup_controller_page_background_is_on": "백그라운드 백업이 활성화되었습니다.", "backup_controller_page_background_turn_off": "백그라운드 서비스 비활성화", "backup_controller_page_background_turn_on": "백그라운드 서비스 활성화", "backup_controller_page_background_wifi": "Wi-Fi에서만", "backup_controller_page_backup": "백업", - "backup_controller_page_backup_selected": "선택: ", + "backup_controller_page_backup_selected": "선택됨:", "backup_controller_page_backup_sub": "백업된 사진 및 동영상", "backup_controller_page_cancel": "취소", "backup_controller_page_created": "생성일: {}", - "backup_controller_page_desc_backup": "앱을 열 때 새 항목을 서버에 자동으로 업로드하려면 포그라운드 백업을 활성화하세요.", - "backup_controller_page_excluded": "제외: ", + "backup_controller_page_desc_backup": "포그라운드 백업을 활성화하여 앱을 시작할 때 새 항목을 서버에 자동으로 업로드하세요.", + "backup_controller_page_excluded": "제외됨:", "backup_controller_page_failed": "실패 ({})", "backup_controller_page_filename": "파일명: {} [{}]", "backup_controller_page_id": "ID: {}", "backup_controller_page_info": "백업 정보", "backup_controller_page_none_selected": "선택한 항목이 없습니다.", "backup_controller_page_remainder": "남은 항목", - "backup_controller_page_remainder_sub": "백업할 사진 및 동영상", + "backup_controller_page_remainder_sub": "백업 대기 중인 사진 및 동영상", "backup_controller_page_select": "선택", "backup_controller_page_server_storage": "저장 공간", "backup_controller_page_start_backup": "백업 시작", - "backup_controller_page_status_off": "자동 백업이 비활성화되었습니다.", - "backup_controller_page_status_on": "자동 백업이 활성화되었습니다.", + "backup_controller_page_status_off": "포그라운드 백업이 비활성화되었습니다.", + "backup_controller_page_status_on": "포그라운드 백업이 활성화되었습니다.", "backup_controller_page_storage_format": "{} 사용 중, 전체 {}", - "backup_controller_page_to_backup": "백업 대상 앨범 목록", + "backup_controller_page_to_backup": "백업할 앨범 목록", "backup_controller_page_total": "전체", - "backup_controller_page_total_sub": "선택한 앨범의 모든 사진 및 동영상", - "backup_controller_page_turn_off": "백업 비활성화", - "backup_controller_page_turn_on": "백업 활성화", + "backup_controller_page_total_sub": "선택한 앨범의 고유한 사진 및 동영상", + "backup_controller_page_turn_off": "비활성화", + "backup_controller_page_turn_on": "활성화", "backup_controller_page_uploading_file_info": "파일 정보 업로드 중", "backup_err_only_album": "유일한 앨범은 제거할 수 없습니다.", "backup_info_card_assets": "항목", @@ -154,7 +154,7 @@ "client_cert_enter_password": "비밀번호 입력", "client_cert_import": "가져오기", "client_cert_import_success_msg": "클라이언트 인증서를 가져왔습니다.", - "client_cert_invalid_msg": "유효하지 않은 인증서이거나 비밀번호가 일치하지 않습니다.", + "client_cert_invalid_msg": "유효하지 않은 인증서 또는 패스프레이즈가 일치하지 않습니다.", "client_cert_remove": "제거", "client_cert_remove_msg": "클라이언트 인증서가 제거되었습니다.", "client_cert_subtitle": "인증서 가져오기/제거는 로그인 전에만 가능합니다. PKCS12 (.p12, .pfx) 형식을 지원합니다.", @@ -210,11 +210,15 @@ "delete_shared_link_dialog_title": "공유 링크 삭제", "description_input_hint_text": "설명 추가...", "description_input_submit_error": "설명을 변경하는 중 문제가 발생했습니다. 자세한 내용은 로그를 참조하세요.", + "download_error": "다운로드 중 문제가 발생했습니다.", + "download_started": "다운로드가 시작되었습니다.", + "download_sucess": "다운로드가 완료되었습니다.", + "download_sucess_android": "미디어가 DCIM/Immich에 저장되었습니다.", "edit_date_time_dialog_date_time": "날짜 및 시간", "edit_date_time_dialog_timezone": "시간대", "edit_image_title": "편집", "edit_location_dialog_title": "위치", - "error_saving_image": "오류입니다: {}", + "error_saving_image": "오류: {}", "exif_bottom_sheet_description": "설명 추가...", "exif_bottom_sheet_details": "상세 정보", "exif_bottom_sheet_location": "위치", @@ -251,7 +255,7 @@ "home_page_first_time_notice": "앱을 처음 사용하는 경우 타임라인에 앨범의 사진과 동영상을 채울 수 있도록 백업할 앨범을 선택하세요.", "home_page_share_err_local": "기기의 항목은 링크로 공유할 수 없습니다. 건너뜁니다.", "home_page_upload_err_limit": "한 번에 최대 30개의 항목만 업로드할 수 있습니다.", - "image_saved_successfully": "이미지 저장", + "image_saved_successfully": "이미지가 저장되었습니다.", "image_viewer_page_state_provider_download_error": "다운로드 오류", "image_viewer_page_state_provider_download_started": "다운로드가 시작되었습니다.", "image_viewer_page_state_provider_download_success": "다운로드 완료", @@ -282,14 +286,14 @@ "login_form_back_button_text": "뒤로", "login_form_button_text": "로그인", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "https://your-server-ip:port/api", + "login_form_endpoint_hint": "http://your-server-ip:port/api", "login_form_endpoint_url": "서버 엔드포인트 URL", "login_form_err_http": "http:// 또는 https://로 시작해야 합니다.", "login_form_err_invalid_email": "유효하지 않은 이메일", "login_form_err_invalid_url": "잘못된 URL입니다.", - "login_form_err_leading_whitespace": "앞에 공백 문자가 있습니다.", - "login_form_err_trailing_whitespace": "뒤에 공백 문자가 있습니다.", - "login_form_failed_get_oauth_server_config": "OAuth 로그인 중 문제 발생, 서버 URL을 확인해주세요.", + "login_form_err_leading_whitespace": "시작 부분에 공백이 있습니다.", + "login_form_err_trailing_whitespace": "끝 부분에 공백이 있습니다.", + "login_form_failed_get_oauth_server_config": "OAuth 로그인 중 문제 발생, 서버 URL을 확인하세요.", "login_form_failed_get_oauth_server_disable": "이 서버는 OAuth 기능을 지원하지 않습니다.", "login_form_failed_login": "로그인 오류. 서버 URL, 이메일 및 비밀번호를 확인하세요.", "login_form_handshake_exception": "서버와 통신 중 인증서 예외가 발생했습니다. 자체 서명된 인증서를 사용 중이라면, 설정에서 자체 서명된 인증서 허용을 활성화하세요.", @@ -343,7 +347,7 @@ "notification_permission_dialog_cancel": "취소", "notification_permission_dialog_content": "알림을 활성화하려면 설정에서 알림 권한을 허용하세요.", "notification_permission_dialog_settings": "설정", - "notification_permission_list_tile_content": "알림을 활성화하기 위해 권한을 부여하세요.", + "notification_permission_list_tile_content": "알림을 활성화하려면 권한을 부여하세요.", "notification_permission_list_tile_enable_button": "알림 활성화", "notification_permission_list_tile_title": "알림 권한", "partner_list_user_photos": "{user}님의 사진", @@ -371,7 +375,7 @@ "profile_drawer_app_logs": "로그", "profile_drawer_client_out_of_date_major": "모바일 앱이 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요.", "profile_drawer_client_out_of_date_minor": "모바일 앱이 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요.", - "profile_drawer_client_server_up_to_date": "모바일 앱과 서버가 최신 버전입니다.", + "profile_drawer_client_server_up_to_date": "클라이언트와 서버가 최신입니다.", "profile_drawer_documentation": "문서", "profile_drawer_github": "Github", "profile_drawer_server_out_of_date_major": "서버 버전이 최신이 아닙니다. 최신 버전으로 업데이트하세요.", @@ -389,7 +393,7 @@ "search_filter_camera_model": "모델명", "search_filter_camera_title": "카메라 종류 선택", "search_filter_date": "날짜", - "search_filter_date_interval": "{start}에서 {end} 까지", + "search_filter_date_interval": "{start} - {end}", "search_filter_date_title": "날짜 범위 선택", "search_filter_display_option_archive": "보관함", "search_filter_display_option_favorite": "즐겨찾기", @@ -411,8 +415,8 @@ "search_page_categories": "분류", "search_page_favorites": "즐겨찾기", "search_page_motion_photos": "모션 포토", - "search_page_no_objects": "사물 정보가 없습니다.", - "search_page_no_places": "장소 정보가 없습니다.", + "search_page_no_objects": "사용 가능한 사물 정보 없음", + "search_page_no_places": "사용 가능한 위치 정보 없음", "search_page_people": "인물", "search_page_person_add_name_dialog_cancel": "취소", "search_page_person_add_name_dialog_hint": "이름", @@ -435,7 +439,7 @@ "search_suggestion_list_smart_search_hint_2": "m:your-search-term", "select_additional_user_for_sharing_page_suggestions": "추천", "select_user_for_sharing_page_err_album": "앨범을 생성하지 못했습니다.", - "select_user_for_sharing_page_share_suggestions": "추천", + "select_user_for_sharing_page_share_suggestions": "제안", "server_info_box_app_version": "앱 버전", "server_info_box_latest_release": "최신 버전", "server_info_box_server_url": "서버 URL", @@ -454,12 +458,12 @@ "setting_notifications_notify_minutes": "{}분 후", "setting_notifications_notify_never": "알리지 않음", "setting_notifications_notify_seconds": "{}초", - "setting_notifications_single_progress_subtitle": "각 항목의 세부 업로드 정보 표시", - "setting_notifications_single_progress_title": "백그라운드 작업의 세부 진행률 표시", + "setting_notifications_single_progress_subtitle": "개별 항목의 상세 업로드 정보 표시", + "setting_notifications_single_progress_title": "백그라운드 백업 상세 진행률 표시", "setting_notifications_subtitle": "알림 기본 설정 조정", "setting_notifications_title": "알림", "setting_notifications_total_progress_subtitle": "전체 업로드 진행률 (완료/전체)", - "setting_notifications_total_progress_title": "백그라운드 작업의 전체 진행률 표시", + "setting_notifications_total_progress_title": "백그라운드 백업 전체 진행률 표시", "setting_pages_app_bar_settings": "설정", "settings_require_restart": "설정을 적용하려면 Immich를 다시 시작하세요.", "setting_video_viewer_looping_subtitle": "상세 보기에서 동영상을 자동으로 반복합니다.", @@ -467,7 +471,7 @@ "setting_video_viewer_title": "동영상", "share_add": "추가", "share_add_photos": "사진 추가", - "share_add_title": "앨범 제목 입력", + "share_add_title": "앨범명 추가", "share_assets_selected": "{}개 항목 선택됨", "share_create_album": "앨범 생성", "shared_album_activities_input_disable": "댓글이 비활성화되었습니다", @@ -528,7 +532,7 @@ "shared_link_manage_links": "공유 링크 관리", "shared_link_public_album": "공개 앨범", "share_done": "완료", - "share_invite": "앨범에 초대", + "share_invite": "앨범으로 초대", "sharing_page_album": "공유 앨범", "sharing_page_description": "공유 앨범을 만들어 주변 사람들과 사진 및 동영상을 공유하세요.", "sharing_page_empty_list": "공유 앨범 없음", @@ -537,8 +541,8 @@ "sharing_silver_appbar_share_partner": "파트너와 공유", "sync": "동기화", "sync_albums": "앨범 동기화", - "sync_albums_manual_subtitle": "업로드한 모든 동영상과 사진을 선택한 백업 앨범에 동기화합니다.", - "sync_upload_album_setting_subtitle": "Immich에서 선택한 앨범에 사진 및 동영상을 만들고 업로드하세요.", + "sync_albums_manual_subtitle": "업로드한 모든 동영상과 사진을 선택한 백업 앨범에 동기화", + "sync_upload_album_setting_subtitle": "선택한 앨범을 Immich에 생성하고 사진 및 동영상을 업로드하세요.", "tab_controller_nav_library": "라이브러리", "tab_controller_nav_photos": "사진", "tab_controller_nav_search": "검색", @@ -546,7 +550,7 @@ "theme_setting_asset_list_storage_indicator_title": "항목에 스토리지 동기화 여부 표시", "theme_setting_asset_list_tiles_per_row_title": "한 줄에 표시할 항목 수 ({})", "theme_setting_colorful_interface_subtitle": "배경에 대표 색상을 적용합니다.", - "theme_setting_colorful_interface_title": "컬러풀 인터페이스", + "theme_setting_colorful_interface_title": "미려한 인터페이스", "theme_setting_dark_mode_switch": "다크 모드", "theme_setting_image_viewer_quality_subtitle": "상세 보기 이미지 품질 조정", "theme_setting_image_viewer_quality_title": "이미지 보기 품질", @@ -559,7 +563,7 @@ "theme_setting_three_stage_loading_subtitle": "이 기능은 앱의 로드 성능을 향상시킬 수 있지만 더 많은 데이터를 사용합니다.", "theme_setting_three_stage_loading_title": "3단계 로드 활성화", "translated_text_options": "옵션", - "trash_emptied": "휴지통 비우기", + "trash_emptied": "휴지통을 비움", "trash_page_delete": "삭제", "trash_page_delete_all": "모두 삭제", "trash_page_empty_trash_btn": "휴지통 비우기", diff --git a/mobile/assets/i18n/lt-LT.json b/mobile/assets/i18n/lt-LT.json index 3d1fb4e4d6..324c9069fd 100644 --- a/mobile/assets/i18n/lt-LT.json +++ b/mobile/assets/i18n/lt-LT.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/lv-LV.json b/mobile/assets/i18n/lv-LV.json index 70af1ac37e..c9f86535fc 100644 --- a/mobile/assets/i18n/lv-LV.json +++ b/mobile/assets/i18n/lv-LV.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Dzēst Kopīgošanas saiti", "description_input_hint_text": "Pievienot aprakstu...", "description_input_submit_error": "Atjauninot aprakstu, radās kļūda; papildinformāciju skatiet žurnālā", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Datums un Laiks", "edit_date_time_dialog_timezone": "Laika zona", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/mn.json b/mobile/assets/i18n/mn.json index 5ebabbee8c..cf951cea0b 100644 --- a/mobile/assets/i18n/mn.json +++ b/mobile/assets/i18n/mn.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/nb-NO.json b/mobile/assets/i18n/nb-NO.json index a5f151db59..7141faef72 100644 --- a/mobile/assets/i18n/nb-NO.json +++ b/mobile/assets/i18n/nb-NO.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Slett delt link", "description_input_hint_text": "Legg til beskrivelse ...", "description_input_submit_error": "Feil ved oppdatering av beskrivelse, sjekk loggen for flere detaljer", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Dato og tid", "edit_date_time_dialog_timezone": "Tidssone", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/nl-NL.json b/mobile/assets/i18n/nl-NL.json index f34480f985..a6a151d506 100644 --- a/mobile/assets/i18n/nl-NL.json +++ b/mobile/assets/i18n/nl-NL.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Verwijder gedeelde link", "description_input_hint_text": "Beschrijving toevoegen...", "description_input_submit_error": "Beschrijving bijwerken mislukt, controleer het logboek voor meer details", + "download_error": "Fout bij downloaden", + "download_started": "Download gestart", + "download_sucess": "Succesvol gedownload", + "download_sucess_android": "Het bestand is gedownload naar DCIM/Immich", "edit_date_time_dialog_date_time": "Datum en tijd", "edit_date_time_dialog_timezone": "Tijdzone", "edit_image_title": "Bewerken", diff --git a/mobile/assets/i18n/pl-PL.json b/mobile/assets/i18n/pl-PL.json index 8a737d31d5..ec9009e28f 100644 --- a/mobile/assets/i18n/pl-PL.json +++ b/mobile/assets/i18n/pl-PL.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Usuń udostępniony link", "description_input_hint_text": "Dodaj opis...", "description_input_submit_error": "Błąd aktualizacji opisu, sprawdź dziennik, aby uzyskać więcej szczegółów", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Data i godzina", "edit_date_time_dialog_timezone": "Strefa czasowa", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/pt-PT.json b/mobile/assets/i18n/pt-PT.json index 769289b59c..991fdfaf36 100644 --- a/mobile/assets/i18n/pt-PT.json +++ b/mobile/assets/i18n/pt-PT.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Excluir link compartilhado", "description_input_hint_text": "Adicionar descrição...", "description_input_submit_error": "Erro ao atualizar a descrição, verifique o registo para obter mais detalhes", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Data e Hora", "edit_date_time_dialog_timezone": "Fuso horário", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/ro-RO.json b/mobile/assets/i18n/ro-RO.json index 8ac8601714..4cb043d196 100644 --- a/mobile/assets/i18n/ro-RO.json +++ b/mobile/assets/i18n/ro-RO.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Șterge link distribuire", "description_input_hint_text": "Adaugă descriere...", "description_input_submit_error": "Eroare actualizare descriere, verifică log-urile pentru mai multe detalii", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Dată și Oră", "edit_date_time_dialog_timezone": "Fus orar", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/ru-RU.json b/mobile/assets/i18n/ru-RU.json index 8679b46df3..1c5741a963 100644 --- a/mobile/assets/i18n/ru-RU.json +++ b/mobile/assets/i18n/ru-RU.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Удалить общую ссылку", "description_input_hint_text": "Добавить описание...", "description_input_submit_error": "Не удалось обновить описание, проверьте логи, чтобы узнать причину", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Дата и время", "edit_date_time_dialog_timezone": "Часовой пояс", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/sk-SK.json b/mobile/assets/i18n/sk-SK.json index 2c48a7f6c5..200db9e320 100644 --- a/mobile/assets/i18n/sk-SK.json +++ b/mobile/assets/i18n/sk-SK.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Odstrániť zdieľaný odkaz", "description_input_hint_text": "Pridať popis...", "description_input_submit_error": "Chyba pri aktualizovaní popisu, zobrazte log pre viac detailov", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Dátum a čas", "edit_date_time_dialog_timezone": "Časové pásmo", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/sl-SI.json b/mobile/assets/i18n/sl-SI.json index c7018f7b6f..7871d65de9 100644 --- a/mobile/assets/i18n/sl-SI.json +++ b/mobile/assets/i18n/sl-SI.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Izbriši povezavo skupne rabe", "description_input_hint_text": "Dodaj opis ...", "description_input_submit_error": "Napaka pri posodabljanju opisa, preverite dnevnik za več podrobnosti", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Datum in ura", "edit_date_time_dialog_timezone": "Časovni pas", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/sr-Cyrl.json b/mobile/assets/i18n/sr-Cyrl.json index 3d1fb4e4d6..324c9069fd 100644 --- a/mobile/assets/i18n/sr-Cyrl.json +++ b/mobile/assets/i18n/sr-Cyrl.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/sr-Latn.json b/mobile/assets/i18n/sr-Latn.json index 31a0d0f48e..744ebe72ce 100644 --- a/mobile/assets/i18n/sr-Latn.json +++ b/mobile/assets/i18n/sr-Latn.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/sv-FI.json b/mobile/assets/i18n/sv-FI.json index 3d1fb4e4d6..324c9069fd 100644 --- a/mobile/assets/i18n/sv-FI.json +++ b/mobile/assets/i18n/sv-FI.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/sv-SE.json b/mobile/assets/i18n/sv-SE.json index d51bdd54ed..0d6c7a3108 100644 --- a/mobile/assets/i18n/sv-SE.json +++ b/mobile/assets/i18n/sv-SE.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Ta Bort Delad Länk", "description_input_hint_text": "Lägg till beskrivning...", "description_input_submit_error": "Fel vid uppdatering av beskrivning, se loggen för fler detaljer", + "download_error": "Fel vid nedladdning", + "download_started": "Nedladdning påbörjad", + "download_sucess": "Nedladdning lyckades", + "download_sucess_android": "Media har laddats ner till DCIM/Immich", "edit_date_time_dialog_date_time": "Datum och Tid", "edit_date_time_dialog_timezone": "Tidszon", "edit_image_title": "Redigera", diff --git a/mobile/assets/i18n/th-TH.json b/mobile/assets/i18n/th-TH.json index 9a45ff463a..c93b0a37cf 100644 --- a/mobile/assets/i18n/th-TH.json +++ b/mobile/assets/i18n/th-TH.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "ลบลิงก์ที่แชร์", "description_input_hint_text": "เพื่มรายละเอียด...", "description_input_submit_error": "อัพเดตรายละเอียดผิดพลาด ตรวจสอบ log เพื่อรายละเอียดเพิ่มเติม", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "วันและเวลา", "edit_date_time_dialog_timezone": "เขดเวลา", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/uk-UA.json b/mobile/assets/i18n/uk-UA.json index 816ef5277f..f3b2b0ba5f 100644 --- a/mobile/assets/i18n/uk-UA.json +++ b/mobile/assets/i18n/uk-UA.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Видалити спільне посилання", "description_input_hint_text": "Додати опис...", "description_input_submit_error": "Помилка оновлення опису, перевірте логи для подробиць", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Дата і час", "edit_date_time_dialog_timezone": "Часовий пояс", "edit_image_title": "Edit", diff --git a/mobile/assets/i18n/vi-VN.json b/mobile/assets/i18n/vi-VN.json index 31eff88dd7..6cd2a080e4 100644 --- a/mobile/assets/i18n/vi-VN.json +++ b/mobile/assets/i18n/vi-VN.json @@ -94,7 +94,7 @@ "backup_controller_page_background_turn_on": "Bật dịch vụ nền", "backup_controller_page_background_wifi": "Chỉ khi dùng Wi-Fi", "backup_controller_page_backup": "Sao lưu", - "backup_controller_page_backup_selected": "Đã chọn:", + "backup_controller_page_backup_selected": "Đã chọn: ", "backup_controller_page_backup_sub": "Ảnh và video đã sao lưu", "backup_controller_page_cancel": "Từ chối", "backup_controller_page_created": "Tạo vào: {}", @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Xoá liên kết đã chia sẻ", "description_input_hint_text": "Thêm mô tả...", "description_input_submit_error": "Cập nhật mô tả không thành công, vui lòng kiểm tra nhật ký để biết thêm chi tiết", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Ngày và Giờ", "edit_date_time_dialog_timezone": "Múi giờ", "edit_image_title": "Sửa", diff --git a/mobile/assets/i18n/zh-CN.json b/mobile/assets/i18n/zh-CN.json index e421fff575..d4e7f0406e 100644 --- a/mobile/assets/i18n/zh-CN.json +++ b/mobile/assets/i18n/zh-CN.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "删除共享链接", "description_input_hint_text": "添加描述...", "description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息", + "download_error": "下载出错", + "download_started": "开始下载", + "download_sucess": "下载成功", + "download_sucess_android": "媒体已下载至 DCIM/Immich", "edit_date_time_dialog_date_time": "日期和时间", "edit_date_time_dialog_timezone": "时区", "edit_image_title": "编辑", diff --git a/mobile/assets/i18n/zh-Hans.json b/mobile/assets/i18n/zh-Hans.json index d98299a046..f5ec6ab2a1 100644 --- a/mobile/assets/i18n/zh-Hans.json +++ b/mobile/assets/i18n/zh-Hans.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "删除共享链接", "description_input_hint_text": "添加描述...", "description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息", + "download_error": "下载出错", + "download_started": "开始下载", + "download_sucess": "下载成功", + "download_sucess_android": "媒体已下载至 DCIM/Immich", "edit_date_time_dialog_date_time": "日期和时间", "edit_date_time_dialog_timezone": "时区", "edit_image_title": "编辑", diff --git a/mobile/assets/i18n/zh-TW.json b/mobile/assets/i18n/zh-TW.json index 3d1fb4e4d6..324c9069fd 100644 --- a/mobile/assets/i18n/zh-TW.json +++ b/mobile/assets/i18n/zh-TW.json @@ -210,6 +210,10 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", From 068904f7461118d4e4d6f8733e7e4186b6c38c84 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 6 Sep 2024 13:49:08 +0000 Subject: [PATCH 305/723] chore: version v1.114.0 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 3778fac8c0..f443c141b9 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.17", + "version": "2.2.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.17", + "version": "2.2.18", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.113.1", + "version": "1.114.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index efb52c8afa..0d560c8456 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.17", + "version": "2.2.18", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index c6c7832b62..c16413f4c5 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.114.0", + "url": "https://v1.114.0.archive.immich.app" + }, { "label": "v1.113.1", "url": "https://v1.113.1.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 7c54a3f227..97e396c09f 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.113.1", + "version": "1.114.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.113.1", + "version": "1.114.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.17", + "version": "2.2.18", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.113.1", + "version": "1.114.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 3da113da35..3577bc4510 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.113.1", + "version": "1.114.0", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index ac2ff0e34e..a69fb33a8d 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.113.1" +version = "1.114.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 0338c6ff34..c127032b19 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 157, - "android.injected.version.name" => "1.113.1", + "android.injected.version.code" => 158, + "android.injected.version.name" => "1.114.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 68b577c9c9..c1740771d9 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.113.1" + version_number: "1.114.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b67a3e3383..bb84515797 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.113.1 +- API version: 1.114.0 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 728b90c3f3..3db5457c8c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.113.1+157 +version: 1.114.0+158 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bbfabfe1d7..2325f24ee5 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7394,7 +7394,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.113.1", + "version": "1.114.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 3914032651..6d5b78ee9a 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.113.1", + "version": "1.114.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.113.1", + "version": "1.114.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index a62e032ef6..afa5f45858 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.113.1", + "version": "1.114.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9e74ae88a0..43777552c5 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.113.1 + * 1.114.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index ca6a54c82c..51f038dfa0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.113.1", + "version": "1.114.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.113.1", + "version": "1.114.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 58d7208adf..48e873a8f8 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.113.1", + "version": "1.114.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 7bcd5c2b01..0fe66f8832 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.113.1", + "version": "1.114.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.113.1", + "version": "1.114.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,7 +74,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.113.1", + "version": "1.114.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index c84bbf0db4..4dddc36e41 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.113.1", + "version": "1.114.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 8e677ed844592741b8849db09dfcf139ce025b97 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 6 Sep 2024 19:01:01 +0100 Subject: [PATCH 306/723] ci: tag ml and server images even when they aren't built (#12390) --- .github/workflows/docker.yml | 70 ++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 6be26c9bbe..8a2ba9f841 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -40,6 +40,57 @@ jobs: id: should_force run: echo "should_force=${{ github.event_name == 'workflow_dispatch' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT" + retag_ml: + name: Re-Tag ML + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_ml == 'false' }} + runs-on: ubuntu-latest + strategy: + matrix: + suffix: ["", "-cuda", "-openvino", "-armnn"] + steps: + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + # Skip when PR from a fork + if: ${{ !github.event.pull_request.head.repo.fork }} + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Re-tag image + run: | + REGISTRY_NAME="ghcr.io" + REPOSITORY=${{ github.repository_owner }}/immich-machine-learning + TAG_OLD=main${{ matrix.suffix }} + TAG_NEW=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} + docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_NEW $REGISTRY_NAME/$REPOSITORY:$TAG_OLD + + retag_server: + name: Re-Tag Server + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_server == 'false' }} + runs-on: ubuntu-latest + strategy: + matrix: + suffix: [""] + steps: + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + # Skip when PR from a fork + if: ${{ !github.event.pull_request.head.repo.fork }} + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Re-tag image + run: | + REGISTRY_NAME="ghcr.io" + REPOSITORY=${{ github.repository_owner }}/immich-server + TAG_OLD=main${{ matrix.suffix }} + TAG_NEW=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} + docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_NEW $REGISTRY_NAME/$REPOSITORY:$TAG_OLD + + build_and_push_ml: name: Build and Push ML needs: pre-job @@ -235,9 +286,22 @@ jobs: BUILD_SOURCE_REF=${{ github.ref_name }} BUILD_SOURCE_COMMIT=${{ github.sha }} - success-check: - name: Docker Build & Push Success - needs: [build_and_push_ml, build_and_push_server] + success-check-server: + name: Docker Build & Push Server Success + needs: [build_and_push_server, retag_server] + runs-on: ubuntu-latest + if: always() + steps: + - name: Any jobs failed? + if: ${{ contains(needs.*.result, 'failure') }} + run: exit 1 + - name: All jobs passed or skipped + if: ${{ !(contains(needs.*.result, 'failure')) }} + run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}" + + success-check-ml: + name: Docker Build & Push ML Success + needs: [build_and_push_ml, retag_ml] runs-on: ubuntu-latest if: always() steps: From 7bcef37ba70837008c7b2e613de8386bc029ae41 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 6 Sep 2024 15:13:17 -0400 Subject: [PATCH 307/723] chore: auto-label translations (#12404) --- .github/labeler.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/labeler.yml b/.github/labeler.yml index a0eec41346..2a9abc7840 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -33,3 +33,8 @@ documentation: - changed-files: - any-glob-to-any-file: - machine-learning/app/** + +changelog:translation: + - changed-files: + - any-glob-to-any-file: + - web/src/lib/i18n/*.json From 8f73313b2360377fd1a13e0a380ea6b559fadbce Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Sat, 7 Sep 2024 15:14:59 +0200 Subject: [PATCH 308/723] docs: update public sharing support in README feature table (#12437) Closes #8205 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8585457707..44c38e6d14 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ For the mobile app, you can use `https://demo.immich.app/api` for the `Server En | LivePhoto/MotionPhoto backup and playback | Yes | Yes | | Support 360 degree image display | No | Yes | | User-defined storage structure | Yes | Yes | -| Public Sharing | No | Yes | +| Public Sharing | Yes | Yes | | Archive and Favorites | Yes | Yes | | Global Map | Yes | Yes | | Partner Sharing | Yes | Yes | From 5fc3cb556730d9757307bf846d3ffcb60584fc35 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 7 Sep 2024 13:19:33 -0400 Subject: [PATCH 309/723] chore(deps): update docker.io/redis:6.2-alpine docker digest to d72905e (#12422) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 927a95f527..2f7d41271d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -48,7 +48,7 @@ services: redis: container_name: immich_redis - image: docker.io/redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e + image: docker.io/redis:6.2-alpine@sha256:d72905e7e3d53aa237968c0724f7bb22165c6a2068a06e66a361e4add0df75fd healthcheck: test: redis-cli ping || exit 1 restart: always From 0dabb890cfc550b2a2e6efd630ed85df02777643 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 7 Sep 2024 13:20:00 -0400 Subject: [PATCH 310/723] chore(deps): update redis:6.2-alpine docker digest to d72905e (#12423) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.dev.yml | 2 +- docker/docker-compose.prod.yml | 2 +- e2e/docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 831b308a0c..16ed032dfb 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -98,7 +98,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e + image: redis:6.2-alpine@sha256:d72905e7e3d53aa237968c0724f7bb22165c6a2068a06e66a361e4add0df75fd healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 509674f328..3e62e7f561 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -47,7 +47,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e + image: redis:6.2-alpine@sha256:d72905e7e3d53aa237968c0724f7bb22165c6a2068a06e66a361e4add0df75fd healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index cbeca0deca..dd7632b212 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -33,7 +33,7 @@ services: - 2285:3001 redis: - image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e + image: redis:6.2-alpine@sha256:d72905e7e3d53aa237968c0724f7bb22165c6a2068a06e66a361e4add0df75fd database: image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 From 2554cc96b0e0d65fe0a2c34318fc6e0f5c97020b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sat, 7 Sep 2024 13:21:05 -0400 Subject: [PATCH 311/723] feat(web): logout of all tabs (#12407) --- server/src/interfaces/event.interface.ts | 7 +++++- server/src/repositories/event.repository.ts | 12 ++++++---- server/src/services/auth.service.spec.ts | 7 +++++- server/src/services/auth.service.ts | 3 +++ .../src/services/notification.service.spec.ts | 15 +++++++++++- server/src/services/notification.service.ts | 9 ++++++- .../navigation-bar/navigation-bar.svelte | 24 +++++++------------ web/src/lib/stores/websocket.ts | 4 ++++ web/src/lib/utils/auth.ts | 17 ++++++++++++- 9 files changed, 73 insertions(+), 25 deletions(-) diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index bb2b0d9ab4..ec6e776f59 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -21,6 +21,9 @@ type EmitEventMap = { 'asset.tag': [{ assetId: string }]; 'asset.untag': [{ assetId: string }]; + // session events + 'session.delete': [{ sessionId: string }]; + // user events 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; }; @@ -43,6 +46,7 @@ export enum ClientEvent { SERVER_VERSION = 'on_server_version', CONFIG_UPDATE = 'on_config_update', NEW_RELEASE = 'on_new_release', + SESSION_DELETE = 'on_session_delete', } export interface ClientEventMap { @@ -58,6 +62,7 @@ export interface ClientEventMap { [ClientEvent.SERVER_VERSION]: ServerVersionResponseDto; [ClientEvent.CONFIG_UPDATE]: Record; [ClientEvent.NEW_RELEASE]: ReleaseNotification; + [ClientEvent.SESSION_DELETE]: string; } export enum ServerEvent { @@ -77,7 +82,7 @@ export interface IEventRepository { /** * Send to connected clients for a specific user */ - clientSend(event: E, userId: string, data: ClientEventMap[E]): void; + clientSend(event: E, room: string, data: ClientEventMap[E]): void; /** * Send to all connected clients */ diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 668eac48d9..9aa12e15dd 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { OnGatewayConnection, @@ -37,7 +38,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect private server?: Server; constructor( - private authService: AuthService, + private moduleRef: ModuleRef, private eventEmitter: EventEmitter2, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { @@ -62,12 +63,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect async handleConnection(client: Socket) { try { this.logger.log(`Websocket Connect: ${client.id}`); - const auth = await this.authService.authenticate({ + const auth = await this.moduleRef.get(AuthService).authenticate({ headers: client.request.headers, queryParams: {}, metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' }, }); await client.join(auth.user.id); + if (auth.session) { + await client.join(auth.session.id); + } this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id }); } catch (error: Error | any) { this.logger.error(`Websocket connection error: ${error}`, error?.stack); @@ -96,8 +100,8 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect } } - clientSend(event: E, userId: string, data: ClientEventMap[E]) { - this.server?.to(userId).emit(event, data); + clientSend(event: E, room: string, data: ClientEventMap[E]) { + this.server?.to(room).emit(event, data); } clientBroadcast(event: E, data: ClientEventMap[E]) { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index f2fa0c520a..acc2d3459c 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -6,6 +6,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; @@ -20,6 +21,7 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock'; import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; +import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; @@ -56,6 +58,7 @@ const oauthUserWithDefaultQuota = { describe('AuthService', () => { let sut: AuthService; let cryptoMock: Mocked; + let eventMock: Mocked; let userMock: Mocked; let loggerMock: Mocked; let systemMock: Mocked; @@ -87,6 +90,7 @@ describe('AuthService', () => { } as any); cryptoMock = newCryptoRepositoryMock(); + eventMock = newEventRepositoryMock(); userMock = newUserRepositoryMock(); loggerMock = newLoggerRepositoryMock(); systemMock = newSystemMetadataRepositoryMock(); @@ -94,7 +98,7 @@ describe('AuthService', () => { shareMock = newSharedLinkRepositoryMock(); keyMock = newKeyRepositoryMock(); - sut = new AuthService(cryptoMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock); + sut = new AuthService(cryptoMock, eventMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock); }); it('should be defined', () => { @@ -208,6 +212,7 @@ describe('AuthService', () => { }); expect(sessionMock.delete).toHaveBeenCalledWith('token123'); + expect(eventMock.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' }); }); it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 2b25decc07..6eaf755d0e 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -34,6 +34,7 @@ import { UserEntity } from 'src/entities/user.entity'; import { Permission } from 'src/enum'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; @@ -75,6 +76,7 @@ export class AuthService { constructor( @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, + @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @@ -114,6 +116,7 @@ export class AuthService { async logout(auth: AuthDto, authType: AuthType): Promise { if (auth.session) { await this.sessionRepository.delete(auth.session.id); + await this.eventRepository.emit('session.delete', { sessionId: auth.session.id }); } return { diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 5bcead0ff3..9d9f8f5fcf 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -6,6 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; @@ -17,6 +18,7 @@ import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock'; @@ -64,6 +66,7 @@ const configs = { describe(NotificationService.name, () => { let albumMock: Mocked; let assetMock: Mocked; + let eventMock: Mocked; let jobMock: Mocked; let loggerMock: Mocked; let notificationMock: Mocked; @@ -74,13 +77,23 @@ describe(NotificationService.name, () => { beforeEach(() => { albumMock = newAlbumRepositoryMock(); assetMock = newAssetRepositoryMock(); + eventMock = newEventRepositoryMock(); jobMock = newJobRepositoryMock(); loggerMock = newLoggerRepositoryMock(); notificationMock = newNotificationRepositoryMock(); systemMock = newSystemMetadataRepositoryMock(); userMock = newUserRepositoryMock(); - sut = new NotificationService(systemMock, notificationMock, userMock, jobMock, loggerMock, assetMock, albumMock); + sut = new NotificationService( + eventMock, + systemMock, + notificationMock, + userMock, + jobMock, + loggerMock, + assetMock, + albumMock, + ); }); it('should work', () => { diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 274c91661c..d450f8dc75 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -6,7 +6,7 @@ import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ArgOf } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IEmailJob, IJobRepository, @@ -30,6 +30,7 @@ export class NotificationService { private configCore: SystemConfigCore; constructor( + @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(INotificationRepository) private notificationRepository: INotificationRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @@ -74,6 +75,12 @@ export class NotificationService { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } + @OnEmit({ event: 'session.delete' }) + onSessionDelete({ sessionId }: ArgOf<'session.delete'>) { + // after the response is sent + setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500); + } + async sendTestEmail(id: string, dto: SystemConfigSmtpDto) { const user = await this.userRepository.get(id, { withDeleted: false }); if (!user) { diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 044a81b222..ad8801ff3f 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -1,15 +1,17 @@ @@ -153,7 +145,7 @@ {/if} {#if shouldShowAccountInfoPanel} - + {/if}
    diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 6422983d94..d398ca52a9 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -1,3 +1,5 @@ +import { AppRoute } from '$lib/constants'; +import { handleLogout } from '$lib/utils/auth'; import { createEventEmitter } from '$lib/utils/eventemitter'; import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; @@ -24,6 +26,7 @@ export interface Events { on_server_version: (serverVersion: ServerVersionResponseDto) => void; on_config_update: () => void; on_new_release: (newRelase: ReleaseEvent) => void; + on_session_delete: (sessionId: string) => void; } const websocket: Socket = io({ @@ -47,6 +50,7 @@ websocket .on('disconnect', () => websocketStore.connected.set(false)) .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) .on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion)) + .on('on_session_delete', () => handleLogout(AppRoute.AUTH_LOGIN)) .on('connect_error', (e) => console.log('Websocket Connect Error', e)); export const openWebsocketConnection = () => { diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index d37f1bb960..0ac1658948 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,7 +1,9 @@ import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; +import { foldersStore } from '$lib/stores/folders.store'; import { purchaseStore } from '$lib/stores/purchase.store'; import { serverInfo } from '$lib/stores/server-info.store'; -import { preferences as preferences$, user as user$ } from '$lib/stores/user.store'; +import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store'; import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; import { DateTime } from 'luxon'; @@ -87,3 +89,16 @@ export const getAccountAge = (): number => { return Number(accountAge); }; + +export const handleLogout = async (redirectUri: string) => { + try { + if (redirectUri.startsWith('/')) { + await goto(redirectUri); + } else { + window.location.href = redirectUri; + } + } finally { + resetSavedUser(); + foldersStore.clearCache(); + } +}; From 1e3052bd0bdaad65150f0c71fc1ea8de46db8750 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sat, 7 Sep 2024 13:21:25 -0400 Subject: [PATCH 312/723] feat(server): start up folder checks (#12401) --- server/src/entities/system-metadata.entity.ts | 10 +-- server/src/enum.ts | 1 + server/src/interfaces/database.interface.ts | 1 + server/src/main.ts | 6 ++ server/src/services/storage.service.spec.ts | 42 ++++++++++- server/src/services/storage.service.ts | 69 ++++++++++++++++++- server/src/utils/events.ts | 3 + server/src/workers/api.ts | 8 ++- server/src/workers/microservices.ts | 7 +- 9 files changed, 133 insertions(+), 14 deletions(-) diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index ae01c47b84..0a238e1da5 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -12,12 +12,14 @@ export class SystemMetadataEntity> { - [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; - [SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string }; [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; - [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial; - [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; + [SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string }; [SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date }; + [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; + [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial; + [SystemMetadataKey.SYSTEM_FLAGS]: SystemFlags; + [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; } diff --git a/server/src/enum.ts b/server/src/enum.ts index 28973e0205..32254854e4 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -153,6 +153,7 @@ export enum SystemMetadataKey { FACIAL_RECOGNITION_STATE = 'facial-recognition-state', ADMIN_ONBOARDING = 'admin-onboarding', SYSTEM_CONFIG = 'system-config', + SYSTEM_FLAGS = 'system-flags', VERSION_CHECK_STATE = 'version-check-state', LICENSE = 'license', } diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index 373f109142..51b39b95a8 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -15,6 +15,7 @@ export enum VectorIndex { export enum DatabaseLock { GeodataImport = 100, Migrations = 200, + SystemFileMounts = 300, StorageTemplateMigration = 420, CLIPDimSize = 512, LibraryWatch = 1337, diff --git a/server/src/main.ts b/server/src/main.ts index 7839bafd2f..ee4de1a259 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -17,7 +17,13 @@ async function bootstrapImmichAdmin() { function bootstrapWorker(name: string) { console.log(`Starting ${name} worker`); + const worker = name === 'api' ? fork(`./dist/workers/${name}.js`) : new Worker(`./dist/workers/${name}.js`); + + worker.on('error', (error) => { + console.error(`${name} worker error: ${error}`); + }); + worker.on('exit', (exitCode) => { if (exitCode !== 0) { console.error(`${name} worker exited with code ${exitCode}`); diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index d9b4c8eefb..b0f38554cb 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -1,19 +1,29 @@ +import { SystemMetadataKey } from 'src/enum'; +import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { StorageService } from 'src/services/storage.service'; +import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked } from 'vitest'; describe(StorageService.name, () => { let sut: StorageService; + let databaseMock: Mocked; let storageMock: Mocked; let loggerMock: Mocked; + let systemMock: Mocked; beforeEach(() => { + databaseMock = newDatabaseRepositoryMock(); storageMock = newStorageRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new StorageService(storageMock, loggerMock); + systemMock = newSystemMetadataRepositoryMock(); + + sut = new StorageService(databaseMock, storageMock, loggerMock, systemMock); }); it('should work', () => { @@ -21,9 +31,35 @@ describe(StorageService.name, () => { }); describe('onBootstrap', () => { - it('should create the library folder on initialization', () => { - sut.onBootstrap(); + it('should enable mount folder checking', async () => { + systemMock.get.mockResolvedValue(null); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountFiles: true }); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/encoded-video'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile'); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); + }); + + it('should throw an error if .immich is missing', async () => { + systemMock.get.mockResolvedValue({ mountFiles: true }); + storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + + await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); + + expect(storageMock.writeFile).not.toHaveBeenCalled(); + expect(systemMock.set).not.toHaveBeenCalled(); + }); + + it('should throw an error if .immich is present but read-only', async () => { + systemMock.get.mockResolvedValue({ mountFiles: true }); + storageMock.writeFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + + await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); + + expect(systemMock.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index c3f2c06438..a8f6a76e74 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,23 +1,52 @@ import { Inject, Injectable } from '@nestjs/common'; +import { join } from 'node:path'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { OnEmit } from 'src/decorators'; +import { SystemMetadataKey } from 'src/enum'; +import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ImmichStartupError } from 'src/utils/events'; @Injectable() export class StorageService { constructor( + @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ISystemMetadataRepository) private systemMetadata: ISystemMetadataRepository, ) { this.logger.setContext(StorageService.name); } @OnEmit({ event: 'app.bootstrap' }) - onBootstrap() { - const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); - this.storageRepository.mkdirSync(libraryBase); + async onBootstrap() { + await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { + const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; + + this.logger.log('Verifying system mount folder checks'); + + // check each folder exists and is writable + for (const folder of Object.values(StorageFolder)) { + if (!flags.mountFiles) { + this.logger.log(`Writing initial mount file for the ${folder} folder`); + await this.verifyWriteAccess(folder); + } + + await this.verifyReadAccess(folder); + await this.verifyWriteAccess(folder); + } + + if (!flags.mountFiles) { + flags.mountFiles = true; + await this.systemMetadata.set(SystemMetadataKey.SYSTEM_FLAGS, flags); + this.logger.log('Successfully enabled system mount folders checks'); + } + + this.logger.log('Successfully verified system mount folder checks'); + }); } async handleDeleteFiles(job: IDeleteFilesJob) { @@ -38,4 +67,38 @@ export class StorageService { return JobStatus.SUCCESS; } + + private async verifyReadAccess(folder: StorageFolder) { + const { filePath } = this.getMountFilePaths(folder); + try { + await this.storageRepository.readFile(filePath); + } catch (error) { + this.logger.error(`Failed to read ${filePath}: ${error}`); + this.logger.error( + `The "${folder}" folder appears to be offline/missing, please make sure the volume is mounted with the correct permissions`, + ); + throw new ImmichStartupError(`Failed to validate folder mount (read from "/${folder}")`); + } + } + + private async verifyWriteAccess(folder: StorageFolder) { + const { folderPath, filePath } = this.getMountFilePaths(folder); + try { + this.storageRepository.mkdirSync(folderPath); + await this.storageRepository.writeFile(filePath, Buffer.from(`${Date.now()}`)); + } catch (error) { + this.logger.error(`Failed to write ${filePath}: ${error}`); + this.logger.error( + `The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`, + ); + throw new ImmichStartupError(`Failed to validate folder mount (write to "/${folder}")`); + } + } + + private getMountFilePaths(folder: StorageFolder) { + const folderPath = StorageCore.getBaseFolder(folder); + const filePath = join(folderPath, '.immich'); + + return { folderPath, filePath }; + } } diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts index 2dd7e7fd5d..064c9f7507 100644 --- a/server/src/utils/events.ts +++ b/server/src/utils/events.ts @@ -12,6 +12,9 @@ type Item = { label: string; }; +export class ImmichStartupError extends Error {} +export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError; + export const setupEventHandlers = (moduleRef: ModuleRef) => { const reflector = moduleRef.get(Reflector, { strict: false }); const repository = moduleRef.get(IEventRepository); diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index 5857f587a0..629c50c653 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -9,6 +9,7 @@ import { envName, excludePaths, isDev, resourcePaths, serverVersion } from 'src/ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; import { ApiService } from 'src/services/api.service'; +import { isStartUpError } from 'src/utils/events'; import { otelStart } from 'src/utils/instrumentation'; import { useSwagger } from 'src/utils/misc'; @@ -73,6 +74,9 @@ async function bootstrap() { } bootstrap().catch((error) => { - console.error(error); - throw error; + if (!isStartUpError(error)) { + console.error(error); + } + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); }); diff --git a/server/src/workers/microservices.ts b/server/src/workers/microservices.ts index f920e8c947..789b6f5287 100644 --- a/server/src/workers/microservices.ts +++ b/server/src/workers/microservices.ts @@ -4,6 +4,7 @@ import { MicroservicesModule } from 'src/app.module'; import { envName, serverVersion } from 'src/constants'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; +import { isStartUpError } from 'src/utils/events'; import { otelStart } from 'src/utils/instrumentation'; export async function bootstrap() { @@ -25,7 +26,9 @@ export async function bootstrap() { if (!isMainThread) { bootstrap().catch((error) => { - console.error(error); - process.exit(1); + if (!isStartUpError(error)) { + console.error(error); + } + throw error; }); } From 00a5da0ebc30c74657cf70e3b008f4e227f430fe Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 7 Sep 2024 12:26:18 -0500 Subject: [PATCH 313/723] chore(mobile): post release task (#12398) Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index fb96609a06..2d8439e36a 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -401,7 +401,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 172; + CURRENT_PROJECT_VERSION = 173; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 172; + CURRENT_PROJECT_VERSION = 173; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -571,7 +571,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 172; + CURRENT_PROJECT_VERSION = 173; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 138b0e426d..b33be9a370 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.113.1 + 1.114.0 CFBundleSignature ???? CFBundleVersion - 172 + 173 FLTEnableImpeller ITSAppUsesNonExemptEncryption From a9caa407ec521870caa1cad43c1d5d39622d4422 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sat, 7 Sep 2024 13:39:10 -0400 Subject: [PATCH 314/723] refactor: metadata extraction (#12359) --- server/src/interfaces/map.interface.ts | 2 +- server/src/interfaces/metadata.interface.ts | 2 +- server/src/repositories/map.repository.ts | 4 +- .../src/repositories/metadata.repository.ts | 6 +- server/src/services/metadata.service.spec.ts | 7 +- server/src/services/metadata.service.ts | 263 +++++++++--------- 6 files changed, 146 insertions(+), 138 deletions(-) diff --git a/server/src/interfaces/map.interface.ts b/server/src/interfaces/map.interface.ts index dce75ffd25..80b37c3a5f 100644 --- a/server/src/interfaces/map.interface.ts +++ b/server/src/interfaces/map.interface.ts @@ -26,7 +26,7 @@ export interface MapMarker extends ReverseGeocodeResult { export interface IMapRepository { init(): Promise; - reverseGeocode(point: GeoPoint): Promise; + reverseGeocode(point: GeoPoint): Promise; getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise; fetchStyle(url: string): Promise; } diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index 04e7b89d1e..39ff6ab4af 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -50,7 +50,7 @@ export interface ImmichTags extends Omit { export interface IMetadataRepository { teardown(): Promise; - readTags(path: string): Promise; + readTags(path: string): Promise; writeTags(path: string, tags: Partial): Promise; extractBinaryTag(tagName: string, path: string): Promise; getCountries(userIds: string[]): Promise>; diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index da4e30d47c..3508de720b 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -124,7 +124,7 @@ export class MapRepository implements IMapRepository { } } - async reverseGeocode(point: GeoPoint): Promise { + async reverseGeocode(point: GeoPoint): Promise { this.logger.debug(`Request: ${point.latitude},${point.longitude}`); const response = await this.geodataPlacesRepository @@ -159,7 +159,7 @@ export class MapRepository implements IMapRepository { `Response from database for natural earth reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`, ); - return null; + return { country: null, state: null, city: null }; } this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index abffc1b785..9902f04d9b 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -36,11 +36,11 @@ export class MetadataRepository implements IMetadataRepository { await this.exiftool.end(); } - readTags(path: string): Promise { + readTags(path: string): Promise { return this.exiftool.read(path).catch((error) => { this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack); - return null; - }) as Promise; + return {}; + }) as Promise; } extractBinaryTag(path: string, tagName: string): Promise { diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 52f6609772..5b447c2355 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -522,13 +522,13 @@ describe(MetadataService.name, () => { it('should extract the correct video orientation', async () => { assetMock.getByIds.mockResolvedValue([assetStub.video]); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - metadataMock.readTags.mockResolvedValue(null); + metadataMock.readTags.mockResolvedValue({}); await sut.handleMetadataExtraction({ id: assetStub.video.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); expect(assetMock.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ orientation: Orientation.Rotate270CW }), + expect.objectContaining({ orientation: Orientation.Rotate270CW.toString() }), ); }); @@ -814,6 +814,9 @@ describe(MetadataService.name, () => { projectionType: 'EQUIRECTANGULAR', timeZone: tags.tz, rating: tags.Rating, + country: null, + state: null, + city: null, }); expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 58e7b99448..cf51a332f8 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored'; +import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import _ from 'lodash'; import { Duration } from 'luxon'; @@ -11,7 +11,6 @@ import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEmit } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetType, SourceType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -30,7 +29,7 @@ import { QueueName, } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMapRepository } from 'src/interfaces/map.interface'; +import { IMapRepository, ReverseGeocodeResult } from 'src/interfaces/map.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; @@ -56,23 +55,16 @@ const EXIF_DATE_TAGS: Array = [ ]; export enum Orientation { - Horizontal = '1', - MirrorHorizontal = '2', - Rotate180 = '3', - MirrorVertical = '4', - MirrorHorizontalRotate270CW = '5', - Rotate90CW = '6', - MirrorHorizontalRotate90CW = '7', - Rotate270CW = '8', + Horizontal = 1, + MirrorHorizontal = 2, + Rotate180 = 3, + MirrorVertical = 4, + MirrorHorizontalRotate270CW = 5, + Rotate90CW = 6, + MirrorHorizontalRotate90CW = 7, + Rotate270CW = 8, } -type ExifEntityWithoutGeocodeAndTypeOrm = Omit & { - dateTimeOriginal: Date; -}; - -const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null); -const tzOffset = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.tzoffsetMinutes : null); - const validate = (value: T): NonNullable | null => { // handle lists of numbers if (Array.isArray(value)) { @@ -218,36 +210,73 @@ export class MetadataService { } async handleMetadataExtraction({ id }: IEntityJob): Promise { - const { metadata } = await this.configCore.getConfig({ withCache: true }); + const { metadata, reverseGeocoding } = await this.configCore.getConfig({ withCache: true }); const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { return JobStatus.FAILED; } - const { exifData, exifTags } = await this.exifData(asset); + const stats = await this.storageRepository.stat(asset.originalPath); - if (asset.type === AssetType.VIDEO) { - await this.applyVideoMetadata(asset, exifData); - } + const exifTags = await this.getExifTags(asset); + + this.logger.verbose('Exif Tags', exifTags); + + const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags); + const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding); + + const exifData = { + assetId: asset.id, + + // dates + dateTimeOriginal, + modifyDate, + timeZone, + + // gps + latitude, + longitude, + country, + state, + city, + + // image/file + fileSizeInByte: stats.size, + exifImageHeight: validate(exifTags.ImageHeight), + exifImageWidth: validate(exifTags.ImageWidth), + orientation: validate(exifTags.Orientation)?.toString() ?? null, + projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, + bitsPerSample: this.getBitsPerSample(exifTags), + colorspace: exifTags.ColorSpace ?? null, + + // camera + make: exifTags.Make ?? null, + model: exifTags.Model ?? null, + fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), + iso: validate(exifTags.ISO), + exposureTime: exifTags.ExposureTime ?? null, + lensModel: exifTags.LensModel ?? null, + fNumber: validate(exifTags.FNumber), + focalLength: validate(exifTags.FocalLength), + + // comments + description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), + profileDescription: exifTags.ProfileDescription || null, + rating: exifTags.Rating ?? null, + + // grouping + livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, + autoStackId: this.getAutoStackId(exifTags), + }; - await this.applyMotionPhotos(asset, exifTags); - await this.applyReverseGeocoding(asset, exifData); await this.applyTagList(asset, exifTags); + await this.applyMotionPhotos(asset, exifTags); await this.assetRepository.upsertExif(exifData); - const dateTimeOriginal = exifData.dateTimeOriginal; - let localDateTime = dateTimeOriginal ?? undefined; - - const timeZoneOffset = tzOffset(firstDateTime(exifTags as Tags)) ?? 0; - - if (dateTimeOriginal && timeZoneOffset) { - localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000); - } - await this.assetRepository.update({ id: asset.id, - duration: asset.duration, + duration: exifTags.Duration?.toString() ?? null, localDateTime, fileCreatedAt: exifData.dateTimeOriginal ?? undefined, }); @@ -338,25 +367,20 @@ export class MetadataService { return JobStatus.SUCCESS; } - private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { - const { latitude, longitude } = exifData; - const { reverseGeocoding } = await this.configCore.getConfig({ withCache: true }); - if (!reverseGeocoding.enabled || !longitude || !latitude) { - return; + private async getExifTags(asset: AssetEntity): Promise { + const mediaTags = await this.repository.readTags(asset.originalPath); + const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : {}; + const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {}; + + // make sure dates comes from sidecar + const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS); + if (sidecarDate) { + for (const tag of EXIF_DATE_TAGS) { + delete mediaTags[tag]; + } } - try { - const reverseGeocode = await this.mapRepository.reverseGeocode({ latitude, longitude }); - if (!reverseGeocode) { - return; - } - Object.assign(exifData, reverseGeocode); - } catch (error: Error | any) { - this.logger.warn( - `Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`, - error?.stack, - ); - } + return { ...mediaTags, ...videoTags, ...sidecarTags }; } private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) { @@ -576,66 +600,65 @@ export class MetadataService { ); } - private async exifData( - asset: AssetEntity, - ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> { - const stats = await this.storageRepository.stat(asset.originalPath); - const mediaTags = await this.repository.readTags(asset.originalPath); - const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null; + private getDates(asset: AssetEntity, exifTags: ImmichTags) { + const dateTime = firstDateTime(exifTags as Maybe, EXIF_DATE_TAGS); + this.logger.debug(`Asset ${asset.id} date time is ${dateTime}`); - // ensure date from sidecar is used if present - const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags); - if (mediaTags && hasDateOverride) { - for (const tag of EXIF_DATE_TAGS) { - delete mediaTags[tag]; - } + // created + let dateTimeOriginal = dateTime?.toDate(); + if (!dateTimeOriginal) { + this.logger.warn(`Asset ${asset.id} has no valid date (${dateTime}), falling back to asset.fileCreatedAt`); + dateTimeOriginal = asset.fileCreatedAt; } - const exifTags = { ...mediaTags, ...sidecarTags }; + // timezone + let timeZone = exifTags.tz ?? null; + if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) { + // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly + // https://github.com/photostructure/exiftool-vendored.js/issues/203 + timeZone = 'UTC+0'; + } - this.logger.verbose('Exif Tags', exifTags); + if (timeZone) { + this.logger.debug(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`); + } else { + this.logger.warn(`Asset ${asset.id} has no time zone information`); + } - const dateTimeOriginalWithRawValue = this.getDateTimeOriginalWithRawValue(exifTags); - const dateTimeOriginal = dateTimeOriginalWithRawValue.exifDate ?? asset.fileCreatedAt; - const timeZone = this.getTimeZone(exifTags, dateTimeOriginalWithRawValue.rawValue); + // offset minutes + const offsetMinutes = dateTime?.tzoffsetMinutes || 0; + let localDateTime = dateTimeOriginal; + if (offsetMinutes) { + localDateTime = new Date(dateTimeOriginal.getTime() + offsetMinutes * 60_000); + this.logger.debug(`Asset ${asset.id} local time is offset by ${offsetMinutes} minutes`); + } - const exifData = { - // altitude: tags.GPSAltitude ?? null, - assetId: asset.id, - bitsPerSample: this.getBitsPerSample(exifTags), - colorspace: exifTags.ColorSpace ?? null, + return { dateTimeOriginal, - description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), - exifImageHeight: validate(exifTags.ImageHeight), - exifImageWidth: validate(exifTags.ImageWidth), - exposureTime: exifTags.ExposureTime ?? null, - fileSizeInByte: stats.size, - fNumber: validate(exifTags.FNumber), - focalLength: validate(exifTags.FocalLength), - fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), - iso: validate(exifTags.ISO), - latitude: validate(exifTags.GPSLatitude), - lensModel: exifTags.LensModel ?? null, - livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, - autoStackId: this.getAutoStackId(exifTags), - longitude: validate(exifTags.GPSLongitude), - make: exifTags.Make ?? null, - model: exifTags.Model ?? null, - modifyDate: exifDate(exifTags.ModifyDate) ?? asset.fileModifiedAt, - orientation: validate(exifTags.Orientation)?.toString() ?? null, - profileDescription: exifTags.ProfileDescription || null, - projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, timeZone, - rating: exifTags.Rating ?? null, + localDateTime, + modifyDate: (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? asset.fileModifiedAt, }; + } - if (exifData.latitude === 0 && exifData.longitude === 0) { - this.logger.warn('Exif data has latitude and longitude of 0, setting to null'); - exifData.latitude = null; - exifData.longitude = null; + private async getGeo(tags: ImmichTags, reverseGeocoding: SystemConfig['reverseGeocoding']) { + let latitude = validate(tags.GPSLatitude); + let longitude = validate(tags.GPSLongitude); + + // TODO take ref into account + + if (latitude === 0 && longitude === 0) { + this.logger.warn('Latitude and longitude of 0, setting to null'); + latitude = null; + longitude = null; } - return { exifData, exifTags }; + let result: ReverseGeocodeResult = { country: null, state: null, city: null }; + if (reverseGeocoding.enabled && longitude && latitude) { + result = await this.mapRepository.reverseGeocode({ latitude, longitude }); + } + + return { ...result, latitude, longitude }; } private getAutoStackId(tags: ImmichTags | null): string | null { @@ -645,28 +668,6 @@ export class MetadataService { return tags.BurstID ?? tags.BurstUUID ?? tags.CameraBurstID ?? tags.MediaUniqueID ?? null; } - private getDateTimeOriginal(tags: ImmichTags | Tags | null) { - return this.getDateTimeOriginalWithRawValue(tags).exifDate; - } - - private getDateTimeOriginalWithRawValue(tags: ImmichTags | Tags | null): { exifDate: Date | null; rawValue: string } { - if (!tags) { - return { exifDate: null, rawValue: '' }; - } - const first = firstDateTime(tags as Tags, EXIF_DATE_TAGS); - return { exifDate: exifDate(first), rawValue: first?.rawValue ?? '' }; - } - - private getTimeZone(exifTags: ImmichTags, rawValue: string) { - const timeZone = exifTags.tz ?? null; - if (timeZone == null && rawValue.endsWith('+00:00')) { - // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly - // https://github.com/photostructure/exiftool-vendored.js/issues/203 - return 'UTC+0'; - } - return timeZone; - } - private getBitsPerSample(tags: ImmichTags): number | null { const bitDepthTags = [ tags.BitsPerSample, @@ -685,33 +686,37 @@ export class MetadataService { return bitsPerSample; } - private async applyVideoMetadata(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { - const { videoStreams, format } = await this.mediaRepository.probe(asset.originalPath); + private async getVideoTags(originalPath: string) { + const { videoStreams, format } = await this.mediaRepository.probe(originalPath); + + const tags: Pick = {}; if (videoStreams[0]) { switch (videoStreams[0].rotation) { case -90: { - exifData.orientation = Orientation.Rotate90CW; + tags.Orientation = Orientation.Rotate90CW; break; } case 0: { - exifData.orientation = Orientation.Horizontal; + tags.Orientation = Orientation.Horizontal; break; } case 90: { - exifData.orientation = Orientation.Rotate270CW; + tags.Orientation = Orientation.Rotate270CW; break; } case 180: { - exifData.orientation = Orientation.Rotate180; + tags.Orientation = Orientation.Rotate180; break; } } } if (format.duration) { - asset.duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS'); + tags.Duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS'); } + + return tags; } private async processSidecar(id: string, isSync: boolean): Promise { From 7b1de6209d67b26f440e30026a176cc14a3b71d0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 7 Sep 2024 14:51:48 -0400 Subject: [PATCH 315/723] chore(deps): update docker.io/redis:6.2-alpine docker digest to fd1b540 (#12447) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2f7d41271d..f0590385e4 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -48,7 +48,7 @@ services: redis: container_name: immich_redis - image: docker.io/redis:6.2-alpine@sha256:d72905e7e3d53aa237968c0724f7bb22165c6a2068a06e66a361e4add0df75fd + image: docker.io/redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01 healthcheck: test: redis-cli ping || exit 1 restart: always From 2bf6a46927e2709012974e222b4ddaf8b80437ed Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 7 Sep 2024 14:52:04 -0400 Subject: [PATCH 316/723] chore(deps): update redis:6.2-alpine docker digest to fd1b540 (#12448) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.dev.yml | 2 +- docker/docker-compose.prod.yml | 2 +- e2e/docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 16ed032dfb..492f5b0c3d 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -98,7 +98,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:d72905e7e3d53aa237968c0724f7bb22165c6a2068a06e66a361e4add0df75fd + image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 3e62e7f561..20126457aa 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -47,7 +47,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:d72905e7e3d53aa237968c0724f7bb22165c6a2068a06e66a361e4add0df75fd + image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index dd7632b212..ce3d1d7ab1 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -33,7 +33,7 @@ services: - 2285:3001 redis: - image: redis:6.2-alpine@sha256:d72905e7e3d53aa237968c0724f7bb22165c6a2068a06e66a361e4add0df75fd + image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01 database: image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 From 56bf3cc3d197710e2e034dee9108669f6d721561 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 7 Sep 2024 23:08:11 -0400 Subject: [PATCH 317/723] chore(ml): bump intel driver version (#12455) update to 24.31.30508.7 --- machine-learning/Dockerfile | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index f680aac826..12fb183c95 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -40,11 +40,10 @@ FROM prod-cpu AS prod-openvino RUN apt-get update && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ - wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-core_1.0.17193.4_amd64.deb && \ - wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-opencl_1.0.17193.4_amd64.deb && \ - wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd-dbgsym_24.26.30049.6_amd64.ddeb && \ - wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd_24.26.30049.6_amd64.deb && \ - wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/libigdgmm12_22.3.20_amd64.deb && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \ dpkg -i *.deb && \ rm *.deb && \ apt-get remove wget -yqq && \ From d1ce9e4d3c15b73b334e5a2ddbbaafc07ad419cf Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Sun, 8 Sep 2024 15:09:27 +0200 Subject: [PATCH 318/723] fix: only apply changelog:translation label to weblate branch (#12468) --- .github/labeler.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 2a9abc7840..c0c52f1d7e 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -35,6 +35,4 @@ documentation: - machine-learning/app/** changelog:translation: - - changed-files: - - any-glob-to-any-file: - - web/src/lib/i18n/*.json + - head-branch: ['^chore/translations$'] From c6cff180b23a2aa5ebc151233ced2c78cbb08190 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 8 Sep 2024 20:23:51 -0400 Subject: [PATCH 319/723] chore(deps): update redis:6.2-alpine docker digest to 2d14632 (#12470) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.dev.yml | 2 +- docker/docker-compose.prod.yml | 2 +- e2e/docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 492f5b0c3d..f42bcc0ab0 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -98,7 +98,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01 + image: redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 20126457aa..05e35ac8c1 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -47,7 +47,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01 + image: redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index ce3d1d7ab1..dbb95f176d 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -33,7 +33,7 @@ services: - 2285:3001 redis: - image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01 + image: redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792 database: image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 From 184a662fda012fab05e8fb6e5e29bea7b642182f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 9 Sep 2024 00:40:20 -0400 Subject: [PATCH 320/723] fix(server): remove hidden assets from albums (#12449) * fix(server): remove hidden assets from albums * fix: linting --------- Co-authored-by: Daniel Dietzler --- .../1725730782681-RemoveHiddenAssetsFromAlbums.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 server/src/migrations/1725730782681-RemoveHiddenAssetsFromAlbums.ts diff --git a/server/src/migrations/1725730782681-RemoveHiddenAssetsFromAlbums.ts b/server/src/migrations/1725730782681-RemoveHiddenAssetsFromAlbums.ts new file mode 100644 index 0000000000..2dfb5b7978 --- /dev/null +++ b/server/src/migrations/1725730782681-RemoveHiddenAssetsFromAlbums.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveHiddenAssetsFromAlbums1725730782681 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "albums_assets_assets" WHERE "assetsId" IN (SELECT "id" FROM "assets" WHERE "isVisible" = false)`, + ); + } + + public async down(): Promise { + // noop + } +} From a66ccb345283c010a338ba750101bf4708c769d1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 00:42:19 -0400 Subject: [PATCH 321/723] chore(deps): update docker.io/redis:6.2-alpine docker digest to 2d14632 (#12469) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f0590385e4..eec723dc08 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -48,7 +48,7 @@ services: redis: container_name: immich_redis - image: docker.io/redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01 + image: docker.io/redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792 healthcheck: test: redis-cli ping || exit 1 restart: always From 0a649f28d940e7261bc4622db2eb06fd77da1b8b Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Mon, 9 Sep 2024 11:00:48 +0200 Subject: [PATCH 322/723] fix: skip docker retag jobs on fork PRs (#12491) --- .github/workflows/docker.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8a2ba9f841..bf393bbcf6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -43,7 +43,7 @@ jobs: retag_ml: name: Re-Tag ML needs: pre-job - if: ${{ needs.pre-job.outputs.should_run_ml == 'false' }} + if: ${{ needs.pre-job.outputs.should_run_ml == 'false' && !github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest strategy: matrix: @@ -51,8 +51,6 @@ jobs: steps: - name: Login to GitHub Container Registry uses: docker/login-action@v3 - # Skip when PR from a fork - if: ${{ !github.event.pull_request.head.repo.fork }} with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -68,7 +66,7 @@ jobs: retag_server: name: Re-Tag Server needs: pre-job - if: ${{ needs.pre-job.outputs.should_run_server == 'false' }} + if: ${{ needs.pre-job.outputs.should_run_server == 'false' && !github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest strategy: matrix: @@ -76,8 +74,6 @@ jobs: steps: - name: Login to GitHub Container Registry uses: docker/login-action@v3 - # Skip when PR from a fork - if: ${{ !github.event.pull_request.head.repo.fork }} with: registry: ghcr.io username: ${{ github.repository_owner }} From a287a766d99a492e77c3b2f4e106314c3ea1bb4b Mon Sep 17 00:00:00 2001 From: pbustamantes <69287399+pbustamantes@users.noreply.github.com> Date: Mon, 9 Sep 2024 02:11:24 -0700 Subject: [PATCH 323/723] fix typo on asset-media.service.ts (#12486) --- server/src/services/asset-media.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 9ce2e58d28..76c6b49716 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -163,7 +163,7 @@ export class AssetMediaService { throw new BadRequestException('Live photo video not found'); } if (motionAsset.type !== AssetType.VIDEO) { - throw new BadRequestException('Live photo vide must be a video'); + throw new BadRequestException('Live photo video must be a video'); } if (motionAsset.ownerId !== auth.user.id) { throw new BadRequestException('Live photo video does not belong to the user'); From 17773f0a77abb95564891960533a0e00d6c66ecf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 11:50:07 +0100 Subject: [PATCH 324/723] chore(deps): update terraform cloudflare to v4.41.0 (#12487) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../docs-release/.terraform.lock.hcl | 60 +++++++++---------- .../modules/cloudflare/docs-release/config.tf | 2 +- .../cloudflare/docs/.terraform.lock.hcl | 60 +++++++++---------- deployment/modules/cloudflare/docs/config.tf | 2 +- 4 files changed, 62 insertions(+), 62 deletions(-) diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index 096177bb05..afa00e6067 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.40.0" - constraints = "4.40.0" + version = "4.41.0" + constraints = "4.41.0" hashes = [ - "h1:GP2N1tXrmpxu+qEDvFAmkfv9aeZNhag3bchyJpGpYbU=", - "h1:HDJKZBQkVU0kQl4gViQ5L7EcFLn9hB0iuvO+ORJiDS4=", - "h1:KrbeEsZoCJOnnX68yNI5h3QhMjc5bBCQW4yvYaEFq3s=", - "h1:LelwnzU0OVn6g2+T9Ub9XdpC+vbheraIL/qgXhWBs/k=", - "h1:TIq9CynfWrKgCxKL97Akj89cYlvJKn/AL4UXogd8/FM=", - "h1:Uoy5oPdm1ipDG7yIMCUN1IXMpsTGXahPw3I0rVA/6wA=", - "h1:Wunfpm+IZhENdoimrh4iXiakVnCsfKOHo80yJUjMQXM=", - "h1:cRdCuahMOFrNyldnCInqGQRBT1DTkRPSfPnaf5r05iw=", - "h1:k+zpXg8BO7gdbTIfSGyQisHhs5aVWQVbPLa5uUdr2UA=", - "h1:kWNrzZ8Rh0OpHikexkmwJIIucD6SMZPi4oGyDsKJitw=", - "h1:lomfTTjK78BdSEVTFcJUBQRy7IQHuGQImMaPWaYpfgQ=", - "h1:oWcWlZe52ZRyLQciNe94RaWzhHifSTu03nlK0uL7rlM=", - "h1:p3JJrhGEPlPQP7Uwy9FNMdvqCyD8tuT4lnXuJ+pSF/M=", - "h1:wtB0sKxG2K/H41hWJI4uJdImWquuaP34Sip5LmfE410=", - "zh:01742e5946f936548f8e42120287ffc757abf97e7cbbe34e25c266a438fb54fd", - "zh:08d81f5a5aab4cc269f983b8c6b5be0e278105136aca9681740802619577371f", - "zh:0d75131ba70902cfc94a7a5900369bdde56528b2aad6e10b164449cc97d57396", - "zh:3890a715a012e197541daacdacb8cceec6d364814daa4640ddfe98a8ba9036cb", - "zh:58254ce5ebe1faed4664df86210c39d660bcdc60280f17b25fe4d4dbea21ea8c", - "zh:6b0abc1adbc2edee79368ce9f7338ebcb5d0bf941e8d7d9ac505b750f20f80a2", - "zh:81cc415d1477174a1ca288d25fdb57e5ee488c2d7f61f265ef995b255a53b0ce", - "zh:8680140c7fe5beaefe61c5cfa471bf88422dc0c0f05dad6d3cb482d4ffd22be4", + "h1:0mc+YrjQrcctGrGYDmzlcqcgSv9MYB74rvMaZylIKC8=", + "h1:0zUx4vk4jOORQqn6xHBF7dO6N6bielFHdJ0mgF4Obn8=", + "h1:AsIZW3uLFNOZO7kL/K7/Y/S0IYxUV9Hz85NNk/3TTsA=", + "h1:FSgYM4+LHMbX/a4Y1kx7FPPWmXqS3/MQYzvjMJHHHWM=", + "h1:Tx6Nh3BWP1x9L3KK/Eyi+ET0T26g3+jf1jyiuqpNIis=", + "h1:VRI9wu8P43xxfpeTndRwsisLnqncfnmEYMOEH5zH4pQ=", + "h1:YxQqmiES/Yanq/VfGqBEqg+VIO7FGhO88aKoWFHyGIg=", + "h1:ZWHiaesjgDLKWlfdNj0oKyj/DWdxcfsO6NINu39zfpY=", + "h1:a2aCgDDBz3ccrr8YstIMl7VFnKo1xZAp+rOv59PPJ7U=", + "h1:aRyv8tB6wBAF9lKsLEdiHyCqnK5LfZq0FqMXCcUB4UU=", + "h1:lXpuO7zv2uD2GzPE1ARxznreRAh+QHTc2lAJ7iOoFgY=", + "h1:sA1xq0QNQ4fH8SHXouYNq50xirVD18SamKQwPsBQrrY=", + "h1:v7sHvKq7oqMYPn47ULHFyIQsKD9o+6Xg/uHbxQUixEw=", + "h1:wo/x4atWyXuWGlfR6h5nH0YwBAmBwTRY27HtWP8ycLo=", + "zh:339d26e06dc6fb299ea8aad9476a60fd65bb1d40631ae8eeb81cddf2dd2bebc8", + "zh:3dec2ad96ac2c283fd34ce65781b55c4edbb4d5c5cb53da8e31537176c0ed562", + "zh:5f63a5f8080319a2fff09d4d49944829fa708723436520787cfb60725ced80cf", + "zh:67162c28ccea71cb8141ed15c0637e35621354ebe14878e0b75a8f160fc5505d", + "zh:6ac1e07f5347b6395aca690ed22101bb25e957d25f986f760ff673a7adfd5ef6", + "zh:70282a723c7b52fcabde2baad41c864ed3a8d69f0c4d27a6b6933cac434cffc6", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:a491d26236122ccb83dac8cb490d2c0aa1f4d3a0b4abe99300fd49b1a624f42f", - "zh:a70d9c469dc8d55715ba77c9d1a4ede1fdebf79e60ee18438a0844868db54e0d", - "zh:a7fcb7d5c4222e14ec6d9a15adf8b9a083d84b102c3d0e4a0d102df5a1360b62", - "zh:b4f9677174fabd199c8ebd2e9e5eb3528cf887e700569a4fb61eef4e070cec5e", - "zh:c27f0f7519221d75dae4a3787a59e05acd5cc9a0d30a390eff349a77d20d52e6", - "zh:db00d8605dbf43ca42fe1481a6c67fdcaa73debb7d2a0f613cb95ae5c5e7150e", + "zh:924cd23abc326c6b3914e2cd9c94c7832c2552e1e9ae258fb9fd9aedaa5f7ce7", + "zh:a4b75e4c239879296259e7d54f1befbc7fdc16da2d62d1294e9f73add4cae61e", + "zh:a6ceb08feb63b00c7141783b31e45a154c76fd8cdebbdf371074805f0053572d", + "zh:afae1843f9ba85f2f6d94108c65cf43a457e83531a632d44d863e935160cb2ba", + "zh:bd6628ce60c778960a5755f7010b7e2cc5c6ff0341a21c175341b28058ec843d", + "zh:cd30866a1ff99d72b5fa1699db582fa4f25562e6ab21dcc6870324f3056108e0", + "zh:df5924cca691a8220aaaebb5cb55c3d6c32ff0a881f198695eff28155eb12b54", + "zh:e78d0696c941aba58df1cb36b8a0d25cd5f3963f01d9338fdbda74db58afdd49", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index 63c96fc498..18d8ff1eb4 100644 --- a/deployment/modules/cloudflare/docs-release/config.tf +++ b/deployment/modules/cloudflare/docs-release/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.40.0" + version = "4.41.0" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index 096177bb05..afa00e6067 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.40.0" - constraints = "4.40.0" + version = "4.41.0" + constraints = "4.41.0" hashes = [ - "h1:GP2N1tXrmpxu+qEDvFAmkfv9aeZNhag3bchyJpGpYbU=", - "h1:HDJKZBQkVU0kQl4gViQ5L7EcFLn9hB0iuvO+ORJiDS4=", - "h1:KrbeEsZoCJOnnX68yNI5h3QhMjc5bBCQW4yvYaEFq3s=", - "h1:LelwnzU0OVn6g2+T9Ub9XdpC+vbheraIL/qgXhWBs/k=", - "h1:TIq9CynfWrKgCxKL97Akj89cYlvJKn/AL4UXogd8/FM=", - "h1:Uoy5oPdm1ipDG7yIMCUN1IXMpsTGXahPw3I0rVA/6wA=", - "h1:Wunfpm+IZhENdoimrh4iXiakVnCsfKOHo80yJUjMQXM=", - "h1:cRdCuahMOFrNyldnCInqGQRBT1DTkRPSfPnaf5r05iw=", - "h1:k+zpXg8BO7gdbTIfSGyQisHhs5aVWQVbPLa5uUdr2UA=", - "h1:kWNrzZ8Rh0OpHikexkmwJIIucD6SMZPi4oGyDsKJitw=", - "h1:lomfTTjK78BdSEVTFcJUBQRy7IQHuGQImMaPWaYpfgQ=", - "h1:oWcWlZe52ZRyLQciNe94RaWzhHifSTu03nlK0uL7rlM=", - "h1:p3JJrhGEPlPQP7Uwy9FNMdvqCyD8tuT4lnXuJ+pSF/M=", - "h1:wtB0sKxG2K/H41hWJI4uJdImWquuaP34Sip5LmfE410=", - "zh:01742e5946f936548f8e42120287ffc757abf97e7cbbe34e25c266a438fb54fd", - "zh:08d81f5a5aab4cc269f983b8c6b5be0e278105136aca9681740802619577371f", - "zh:0d75131ba70902cfc94a7a5900369bdde56528b2aad6e10b164449cc97d57396", - "zh:3890a715a012e197541daacdacb8cceec6d364814daa4640ddfe98a8ba9036cb", - "zh:58254ce5ebe1faed4664df86210c39d660bcdc60280f17b25fe4d4dbea21ea8c", - "zh:6b0abc1adbc2edee79368ce9f7338ebcb5d0bf941e8d7d9ac505b750f20f80a2", - "zh:81cc415d1477174a1ca288d25fdb57e5ee488c2d7f61f265ef995b255a53b0ce", - "zh:8680140c7fe5beaefe61c5cfa471bf88422dc0c0f05dad6d3cb482d4ffd22be4", + "h1:0mc+YrjQrcctGrGYDmzlcqcgSv9MYB74rvMaZylIKC8=", + "h1:0zUx4vk4jOORQqn6xHBF7dO6N6bielFHdJ0mgF4Obn8=", + "h1:AsIZW3uLFNOZO7kL/K7/Y/S0IYxUV9Hz85NNk/3TTsA=", + "h1:FSgYM4+LHMbX/a4Y1kx7FPPWmXqS3/MQYzvjMJHHHWM=", + "h1:Tx6Nh3BWP1x9L3KK/Eyi+ET0T26g3+jf1jyiuqpNIis=", + "h1:VRI9wu8P43xxfpeTndRwsisLnqncfnmEYMOEH5zH4pQ=", + "h1:YxQqmiES/Yanq/VfGqBEqg+VIO7FGhO88aKoWFHyGIg=", + "h1:ZWHiaesjgDLKWlfdNj0oKyj/DWdxcfsO6NINu39zfpY=", + "h1:a2aCgDDBz3ccrr8YstIMl7VFnKo1xZAp+rOv59PPJ7U=", + "h1:aRyv8tB6wBAF9lKsLEdiHyCqnK5LfZq0FqMXCcUB4UU=", + "h1:lXpuO7zv2uD2GzPE1ARxznreRAh+QHTc2lAJ7iOoFgY=", + "h1:sA1xq0QNQ4fH8SHXouYNq50xirVD18SamKQwPsBQrrY=", + "h1:v7sHvKq7oqMYPn47ULHFyIQsKD9o+6Xg/uHbxQUixEw=", + "h1:wo/x4atWyXuWGlfR6h5nH0YwBAmBwTRY27HtWP8ycLo=", + "zh:339d26e06dc6fb299ea8aad9476a60fd65bb1d40631ae8eeb81cddf2dd2bebc8", + "zh:3dec2ad96ac2c283fd34ce65781b55c4edbb4d5c5cb53da8e31537176c0ed562", + "zh:5f63a5f8080319a2fff09d4d49944829fa708723436520787cfb60725ced80cf", + "zh:67162c28ccea71cb8141ed15c0637e35621354ebe14878e0b75a8f160fc5505d", + "zh:6ac1e07f5347b6395aca690ed22101bb25e957d25f986f760ff673a7adfd5ef6", + "zh:70282a723c7b52fcabde2baad41c864ed3a8d69f0c4d27a6b6933cac434cffc6", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:a491d26236122ccb83dac8cb490d2c0aa1f4d3a0b4abe99300fd49b1a624f42f", - "zh:a70d9c469dc8d55715ba77c9d1a4ede1fdebf79e60ee18438a0844868db54e0d", - "zh:a7fcb7d5c4222e14ec6d9a15adf8b9a083d84b102c3d0e4a0d102df5a1360b62", - "zh:b4f9677174fabd199c8ebd2e9e5eb3528cf887e700569a4fb61eef4e070cec5e", - "zh:c27f0f7519221d75dae4a3787a59e05acd5cc9a0d30a390eff349a77d20d52e6", - "zh:db00d8605dbf43ca42fe1481a6c67fdcaa73debb7d2a0f613cb95ae5c5e7150e", + "zh:924cd23abc326c6b3914e2cd9c94c7832c2552e1e9ae258fb9fd9aedaa5f7ce7", + "zh:a4b75e4c239879296259e7d54f1befbc7fdc16da2d62d1294e9f73add4cae61e", + "zh:a6ceb08feb63b00c7141783b31e45a154c76fd8cdebbdf371074805f0053572d", + "zh:afae1843f9ba85f2f6d94108c65cf43a457e83531a632d44d863e935160cb2ba", + "zh:bd6628ce60c778960a5755f7010b7e2cc5c6ff0341a21c175341b28058ec843d", + "zh:cd30866a1ff99d72b5fa1699db582fa4f25562e6ab21dcc6870324f3056108e0", + "zh:df5924cca691a8220aaaebb5cb55c3d6c32ff0a881f198695eff28155eb12b54", + "zh:e78d0696c941aba58df1cb36b8a0d25cd5f3963f01d9338fdbda74db58afdd49", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index 63c96fc498..18d8ff1eb4 100644 --- a/deployment/modules/cloudflare/docs/config.tf +++ b/deployment/modules/cloudflare/docs/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.40.0" + version = "4.41.0" } } } From 0a552d2bfada73eee072ac5d036b907d90f24bb6 Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Mon, 9 Sep 2024 10:29:23 -0400 Subject: [PATCH 325/723] feat(web): responsive top navigation (#12373) - icons fit in mobile - guarantee the search bar space in all screen sizes - fix the storage bar being too wide --- .../navigation-bar/account-info-panel.svelte | 49 +++++++++---- .../navigation-bar/navigation-bar.svelte | 73 ++++++------------- .../side-bar/storage-space.svelte | 2 +- .../shared-components/theme-button.svelte | 5 +- 4 files changed, 60 insertions(+), 69 deletions(-) diff --git a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte index a23ef6eab2..ef103a9e03 100644 --- a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte @@ -7,13 +7,14 @@ import { preferences, user } from '$lib/stores/user.store'; import { handleError } from '$lib/utils/handle-error'; import { deleteProfileImage, updateMyPreferences, type UserAvatarColor } from '@immich/sdk'; - import { mdiCog, mdiLogout, mdiPencil } from '@mdi/js'; + import { mdiCog, mdiLogout, mdiPencil, mdiWrench } from '@mdi/js'; import { createEventDispatcher } from 'svelte'; import { fade } from 'svelte/transition'; import { NotificationType, notificationController } from '../notification/notification'; import UserAvatar from '../user-avatar.svelte'; import AvatarSelector from './avatar-selector.svelte'; import { t } from 'svelte-i18n'; + import { page } from '$app/stores'; let isShowSelectAvatar = false; @@ -46,7 +47,7 @@ in:fade={{ duration: 100 }} out:fade={{ duration: 100 }} id="account-info-panel" - class="absolute right-[25px] top-[75px] z-[100] w-[360px] rounded-3xl bg-gray-200 shadow-lg dark:border dark:border-immich-dark-gray dark:bg-immich-dark-gray" + class="absolute right-[25px] top-[75px] z-[100] w-[min(360px,100vw-50px)] rounded-3xl bg-gray-200 shadow-lg dark:border dark:border-immich-dark-gray dark:bg-immich-dark-gray" use:focusTrap >
    {$user.email}

    - +
    + + {#if $user.isAdmin} + + {/if} +
    diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index ad8801ff3f..58a4c23d74 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -9,10 +9,10 @@ import { user } from '$lib/stores/user.store'; import { handleLogout } from '$lib/utils/auth'; import { logout } from '@immich/sdk'; - import { mdiCog, mdiMagnify, mdiTrayArrowUp } from '@mdi/js'; + import { mdiMagnify, mdiTrayArrowUp } from '@mdi/js'; import { createEventDispatcher } from 'svelte'; import { t } from 'svelte-i18n'; - import { fade, fly } from 'svelte/transition'; + import { fade } from 'svelte/transition'; import { AppRoute } from '../../../constants'; import ImmichLogo from '../immich-logo.svelte'; import SearchBar from '../search-bar/search-bar.svelte'; @@ -45,72 +45,41 @@ -
    -
    + +{#if isOpen} + +
    +
    + +
    +
    +
    +{/if} From 186b4e133336300a1ead4876c9838e0a23b310c9 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 16 Sep 2024 15:51:03 -0500 Subject: [PATCH 381/723] feat(web): improve UI/UX for settings pages (#12626) * fix(web): local date time for buckets * feat(web): improve UI/UX for setting pages * search admin settings and icon * clean up * fix translation file * Update web/src/routes/admin/system-settings/+page.svelte Co-authored-by: Ben <45583362+ben-basten@users.noreply.github.com> * Update web/src/lib/components/shared-components/settings/setting-accordion.svelte Co-authored-by: Ben <45583362+ben-basten@users.noreply.github.com> * better search bar on smaller screen * lint * template syntax --------- Co-authored-by: Jason Rasmussen Co-authored-by: Ben <45583362+ben-basten@users.noreply.github.com> --- .../settings/auth/auth-settings.svelte | 2 +- .../settings/setting-accordion.svelte | 22 +++++-- .../feature-settings.svelte | 2 +- .../user-settings-list.svelte | 57 ++++++++++++++--- web/src/lib/i18n/en.json | 1 + .../routes/admin/system-settings/+page.svelte | 62 +++++++++++++++++-- 6 files changed, 126 insertions(+), 20 deletions(-) diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte index 37f875c604..9b0e4b3270 100644 --- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -71,7 +71,7 @@
    -
    +
    -
    +
    {/each} diff --git a/web/src/lib/components/shared-components/settings/setting-switch.svelte b/web/src/lib/components/shared-components/settings/setting-switch.svelte index d933b27ab5..24b539f0a1 100644 --- a/web/src/lib/components/shared-components/settings/setting-switch.svelte +++ b/web/src/lib/components/shared-components/settings/setting-switch.svelte @@ -43,11 +43,5 @@
    - onToggle(detail)} - ariaDescribedBy={subtitleId} - /> +
    diff --git a/web/src/lib/components/shared-components/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte index ebc0dd688c..2bd1b8976b 100644 --- a/web/src/lib/components/shared-components/show-shortcuts.svelte +++ b/web/src/lib/components/shared-components/show-shortcuts.svelte @@ -1,9 +1,8 @@ - dispatch('close')}> +
    {#if shortcuts.general.length > 0}
    diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index 676e984364..d43977ea08 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -15,14 +15,10 @@ mdiUbuntu, } from '@mdi/js'; import { DateTime, type ToRelativeCalendarOptions } from 'luxon'; - import { createEventDispatcher } from 'svelte'; import { t } from 'svelte-i18n'; export let device: SessionResponseDto; - - const dispatcher = createEventDispatcher<{ - delete: void; - }>(); + export let onDelete: (() => void) | undefined = undefined; const options: ToRelativeCalendarOptions = { unit: 'days', @@ -68,14 +64,14 @@
    - {#if !device.current} + {#if !device.current && onDelete}
    dispatcher('delete')} + on:click={onDelete} />
    {/if} diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte index 57299bb46f..26e03c35d8 100644 --- a/web/src/lib/components/user-settings-page/device-list.svelte +++ b/web/src/lib/components/user-settings-page/device-list.svelte @@ -68,7 +68,7 @@ {$t('other_devices').toUpperCase()} {#each otherDevices as device, index} - handleDelete(device)} /> + handleDelete(device)} /> {#if index !== otherDevices.length - 1}
    {/if} diff --git a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte index 3cff1cd1de..8ab747aa27 100644 --- a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte +++ b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte @@ -1,19 +1,18 @@ - dispatch('close')}> +

    {$t('settings').toUpperCase()}

    @@ -68,14 +64,14 @@ title={$t('comments_and_likes')} subtitle={$t('let_others_respond')} checked={album.isActivityEnabled} - on:toggle={() => dispatch('toggleEnableActivity')} + onToggle={onToggleEnabledActivity} />
    {$t('people').toUpperCase()}
    -
    {/key} @@ -152,10 +151,8 @@ rounded="full" disabled={Object.keys(selectedUsers).length === 0} on:click={() => - dispatch( - 'select', - Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })), - )}>{$t('add')} ({ userId: user.id, ...rest })))} + >{$t('add')}
    {/if} @@ -166,7 +163,7 @@ -
    - onSelect(detail)} /> +
    diff --git a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte index d781e1cc56..f869790eba 100644 --- a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte +++ b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte @@ -4,7 +4,6 @@ import { getPeopleThumbnailUrl } from '$lib/utils'; import { type PersonResponseDto } from '@immich/sdk'; import { mdiArrowLeft, mdiMerge } from '@mdi/js'; - import { createEventDispatcher } from 'svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import Button from '../elements/buttons/button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; @@ -13,25 +12,22 @@ export let personMerge1: PersonResponseDto; export let personMerge2: PersonResponseDto; export let potentialMergePeople: PersonResponseDto[]; + export let onReject: () => void; + export let onConfirm: ([personMerge1, personMerge2]: [PersonResponseDto, PersonResponseDto]) => void; + export let onClose: () => void; let choosePersonToMerge = false; const title = personMerge2.name; - const dispatch = createEventDispatcher<{ - reject: void; - confirm: [PersonResponseDto, PersonResponseDto]; - close: void; - }>(); - - const changePersonToMerge = (newperson: PersonResponseDto) => { - const index = potentialMergePeople.indexOf(newperson); + const changePersonToMerge = (newPerson: PersonResponseDto) => { + const index = potentialMergePeople.indexOf(newPerson); [potentialMergePeople[index], personMerge2] = [personMerge2, potentialMergePeople[index]]; choosePersonToMerge = false; }; - dispatch('close')}> +
    {#if !choosePersonToMerge}
    @@ -105,7 +101,7 @@

    {$t('they_will_be_merged_together')}

    - - + + diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 21f48e42eb..6791a26232 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -9,7 +9,6 @@ mdiDotsVertical, mdiEyeOffOutline, } from '@mdi/js'; - import { createEventDispatcher } from 'svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import MenuOption from '../shared-components/context-menu/menu-option.svelte'; import { t } from 'svelte-i18n'; @@ -18,19 +17,12 @@ export let person: PersonResponseDto; export let preload = false; - - type MenuItemEvent = 'change-name' | 'set-birth-date' | 'merge-people' | 'hide-person'; - let dispatch = createEventDispatcher<{ - 'change-name': void; - 'set-birth-date': void; - 'merge-people': void; - 'hide-person': void; - }>(); + export let onChangeName: () => void; + export let onSetBirthDate: () => void; + export let onMergePeople: () => void; + export let onHidePerson: () => void; let showVerticalDots = false; - const onMenuClick = (event: MenuItemEvent) => { - dispatch(event); - };
    - onMenuClick('hide-person')} icon={mdiEyeOffOutline} text={$t('hide_person')} /> - onMenuClick('change-name')} icon={mdiAccountEditOutline} text={$t('change_name')} /> - onMenuClick('set-birth-date')} - icon={mdiCalendarEditOutline} - text={$t('set_date_of_birth')} - /> - onMenuClick('merge-people')} - icon={mdiAccountMultipleCheckOutline} - text={$t('merge_people')} - /> + + + +
    {/if} diff --git a/web/src/lib/components/faces-page/people-list.svelte b/web/src/lib/components/faces-page/people-list.svelte index 5130baf30b..230c8750ae 100644 --- a/web/src/lib/components/faces-page/people-list.svelte +++ b/web/src/lib/components/faces-page/people-list.svelte @@ -1,6 +1,5 @@ - +

    {$t('birthdate_set_description')}

    - handleSubmit()} autocomplete="off" id="set-birth-date-form"> + onUpdate(birthDate)} autocomplete="off" id="set-birth-date-form">
    - + diff --git a/web/src/lib/components/faces-page/unmerge-face-selector.svelte b/web/src/lib/components/faces-page/unmerge-face-selector.svelte index c89c8338d3..753e46c219 100644 --- a/web/src/lib/components/faces-page/unmerge-face-selector.svelte +++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte @@ -10,7 +10,7 @@ type PersonResponseDto, } from '@immich/sdk'; import { mdiMerge, mdiPlus } from '@mdi/js'; - import { createEventDispatcher, onMount } from 'svelte'; + import { onMount } from 'svelte'; import { quintOut } from 'svelte/easing'; import { fly } from 'svelte/transition'; import Button from '../elements/buttons/button.svelte'; @@ -23,6 +23,8 @@ export let assetIds: string[]; export let personAssets: PersonResponseDto; + export let onConfirm: () => void; + export let onClose: () => void; let people: PersonResponseDto[] = []; let selectedPerson: PersonResponseDto | null = null; @@ -34,11 +36,6 @@ $: peopleToNotShow = selectedPerson ? [personAssets, selectedPerson] : [personAssets]; - let dispatch = createEventDispatcher<{ - confirm: void; - close: void; - }>(); - const selectedPeople: AssetFaceUpdateItem[] = []; for (const assetId of assetIds) { @@ -50,10 +47,6 @@ people = data.people; }); - const onClose = () => { - dispatch('close'); - }; - const handleSelectedPerson = (person: PersonResponseDto) => { if (selectedPerson && selectedPerson.id === person.id) { handleRemoveSelectedPerson(); @@ -87,7 +80,7 @@ } showLoadingSpinnerCreate = false; - dispatch('confirm'); + onConfirm(); }; const handleReassign = async () => { @@ -113,7 +106,7 @@ } showLoadingSpinnerReassign = false; - dispatch('confirm'); + onConfirm(); }; @@ -123,7 +116,7 @@ transition:fly={{ y: 500, duration: 100, easing: quintOut }} class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg" > - +
    @@ -180,7 +173,7 @@
    {/if} - handleSelectedPerson(detail)} /> +
    diff --git a/web/src/lib/components/forms/api-key-secret.svelte b/web/src/lib/components/forms/api-key-secret.svelte index b7bf8e1836..f43e1da38e 100644 --- a/web/src/lib/components/forms/api-key-secret.svelte +++ b/web/src/lib/components/forms/api-key-secret.svelte @@ -1,20 +1,15 @@ - handleDone()}> +

    {$t('api_key_description')} @@ -28,6 +23,6 @@ - + diff --git a/web/src/lib/components/forms/change-password-form.svelte b/web/src/lib/components/forms/change-password-form.svelte index 799dde7ef3..cbf2ff07f0 100644 --- a/web/src/lib/components/forms/change-password-form.svelte +++ b/web/src/lib/components/forms/change-password-form.svelte @@ -1,10 +1,11 @@ diff --git a/web/src/lib/components/forms/create-user-form.svelte b/web/src/lib/components/forms/create-user-form.svelte index 8f049685a4..9c4b83002b 100644 --- a/web/src/lib/components/forms/create-user-form.svelte +++ b/web/src/lib/components/forms/create-user-form.svelte @@ -5,13 +5,14 @@ import { ByteUnit, convertToBytes } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; import { createUserAdmin } from '@immich/sdk'; - import { createEventDispatcher } from 'svelte'; import { t } from 'svelte-i18n'; import Button from '../elements/buttons/button.svelte'; import Slider from '../elements/slider.svelte'; import PasswordField from '../shared-components/password-field.svelte'; export let onClose: () => void; + export let onSubmit: () => void; + export let onCancel: () => void; let error: string; let success: string; @@ -39,10 +40,6 @@ canCreateUser = true; } } - const dispatch = createEventDispatcher<{ - submit: void; - cancel: void; - }>(); async function registerUser() { if (canCreateUser && !isCreatingUser) { @@ -63,7 +60,7 @@ success = $t('new_user_created'); - dispatch('submit'); + onSubmit(); return; } catch (error) { @@ -132,7 +129,7 @@ {/if} - + diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte index b326565122..0079a695bc 100644 --- a/web/src/lib/components/forms/edit-user-form.svelte +++ b/web/src/lib/components/forms/edit-user-form.svelte @@ -5,7 +5,6 @@ import { handleError } from '$lib/utils/handle-error'; import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; import { mdiAccountEditOutline } from '@mdi/js'; - import { createEventDispatcher } from 'svelte'; import Button from '../elements/buttons/button.svelte'; import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; @@ -15,6 +14,8 @@ export let canResetPassword = true; export let newPassword: string; export let onClose: () => void; + export let onResetPasswordSuccess: () => void; + export let onEditSuccess: () => void; let error: string; let success: string; @@ -27,12 +28,6 @@ !!quotaSize && convertToBytes(Number(quotaSize), ByteUnit.GiB) > $serverInfo.diskSizeRaw; - const dispatch = createEventDispatcher<{ - close: void; - resetPasswordSuccess: void; - editSuccess: void; - }>(); - const editUser = async () => { try { const { id, email, name, storageLabel } = user; @@ -46,7 +41,7 @@ }, }); - dispatch('editSuccess'); + onEditSuccess(); } catch (error) { handleError(error, $t('errors.unable_to_update_user')); } @@ -72,7 +67,7 @@ }, }); - dispatch('resetPasswordSuccess'); + onResetPasswordSuccess(); } catch (error) { handleError(error, $t('errors.unable_to_reset_password')); } diff --git a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte index c09f1fbaf6..05d47c0a0f 100644 --- a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte +++ b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte @@ -1,5 +1,4 @@ - -

    handleSubmit()} autocomplete="off" id="add-exclusion-pattern-form"> + + onSubmit(exclusionPattern)} autocomplete="off" id="add-exclusion-pattern-form">

    {$t('admin.exclusion_pattern_description')}

    @@ -53,9 +47,9 @@

    - + {#if isEditing} - + {/if} diff --git a/web/src/lib/components/forms/library-import-path-form.svelte b/web/src/lib/components/forms/library-import-path-form.svelte index f82d573386..8bfca80aec 100644 --- a/web/src/lib/components/forms/library-import-path-form.svelte +++ b/web/src/lib/components/forms/library-import-path-form.svelte @@ -1,5 +1,4 @@ - -
    handleSubmit()} autocomplete="off" id="library-import-path-form"> + + onSubmit(importPath)} autocomplete="off" id="library-import-path-form">

    {$t('admin.library_import_path_description')}

    @@ -47,9 +41,9 @@
    - + {#if isEditing} - + {/if} diff --git a/web/src/lib/components/forms/library-import-paths-form.svelte b/web/src/lib/components/forms/library-import-paths-form.svelte index a2bb3a9686..9e7ae11a63 100644 --- a/web/src/lib/components/forms/library-import-paths-form.svelte +++ b/web/src/lib/components/forms/library-import-paths-form.svelte @@ -1,5 +1,5 @@ -
    handleSubmit()} autocomplete="off" class="m-4 flex flex-col gap-2"> + onSubmit({ ...library })} autocomplete="off" class="m-4 flex flex-col gap-2">
    - +
    diff --git a/web/src/lib/components/forms/library-scan-settings-form.svelte b/web/src/lib/components/forms/library-scan-settings-form.svelte index 5e025a406a..a9a42c31f7 100644 --- a/web/src/lib/components/forms/library-scan-settings-form.svelte +++ b/web/src/lib/components/forms/library-scan-settings-form.svelte @@ -1,7 +1,7 @@ - -
    handleSubmit()} autocomplete="off" id="select-library-owner-form"> + + onSubmit(ownerId)} autocomplete="off" id="select-library-owner-form">

    {$t('admin.note_cannot_be_changed_later')}

    - +
    diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 5bca13b060..ed232b80cd 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -21,7 +21,7 @@
    {#if !hideNavbar} - openFileUploadDialog()} /> + openFileUploadDialog()} /> {/if} diff --git a/web/src/lib/components/map-page/map-settings-modal.svelte b/web/src/lib/components/map-page/map-settings-modal.svelte index b442396c84..35df9f2285 100644 --- a/web/src/lib/components/map-page/map-settings-modal.svelte +++ b/web/src/lib/components/map-page/map-settings-modal.svelte @@ -4,7 +4,6 @@ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import type { MapSettings } from '$lib/stores/preferences.store'; import { Duration } from 'luxon'; - import { createEventDispatcher } from 'svelte'; import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; import Button from '../elements/buttons/button.svelte'; @@ -12,19 +11,15 @@ import DateInput from '../elements/date-input.svelte'; export let settings: MapSettings; + export let onClose: () => void; + export let onSave: (settings: MapSettings) => void; + let customDateRange = !!settings.dateAfter || !!settings.dateBefore; - - const dispatch = createEventDispatcher<{ - close: void; - save: MapSettings; - }>(); - - const handleClose = () => dispatch('close'); - +
    dispatch('save', settings)} + on:submit|preventDefault={() => onSave(settings)} class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary" id="map-settings-form" > @@ -108,7 +103,7 @@ {/if}
    - +
    diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index ae6416873e..919433f79b 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -250,7 +250,7 @@
    {#if current && current.memory.assets.length > 0} - goto(AppRoute.PHOTOS)} forceDark> + goto(AppRoute.PHOTOS)} forceDark>

    {$memoryLaneTitle(current.memory.yearsAgo)} diff --git a/web/src/lib/components/photos-page/actions/add-to-album.svelte b/web/src/lib/components/photos-page/actions/add-to-album.svelte index 976f4bd9cf..d3998510cd 100644 --- a/web/src/lib/components/photos-page/actions/add-to-album.svelte +++ b/web/src/lib/components/photos-page/actions/add-to-album.svelte @@ -40,8 +40,8 @@ {#if showAlbumPicker} handleAddToNewAlbum(detail)} - on:album={({ detail }) => handleAddToAlbum(detail)} + onNewAlbum={handleAddToNewAlbum} + onAlbumClick={handleAddToAlbum} onClose={handleHideAlbumPicker} /> {/if} diff --git a/web/src/lib/components/photos-page/actions/change-date-action.svelte b/web/src/lib/components/photos-page/actions/change-date-action.svelte index 6ee775fa69..114315348d 100644 --- a/web/src/lib/components/photos-page/actions/change-date-action.svelte +++ b/web/src/lib/components/photos-page/actions/change-date-action.svelte @@ -31,9 +31,5 @@ (isShowChangeDate = true)} /> {/if} {#if isShowChangeDate} - handleConfirm(date)} - on:cancel={() => (isShowChangeDate = false)} - /> + (isShowChangeDate = false)} /> {/if} diff --git a/web/src/lib/components/photos-page/actions/change-location-action.svelte b/web/src/lib/components/photos-page/actions/change-location-action.svelte index 0e19696a42..3fe1db4327 100644 --- a/web/src/lib/components/photos-page/actions/change-location-action.svelte +++ b/web/src/lib/components/photos-page/actions/change-location-action.svelte @@ -35,8 +35,5 @@ /> {/if} {#if isShowChangeLocation} - handleConfirm(point)} - on:cancel={() => (isShowChangeLocation = false)} - /> + (isShowChangeLocation = false)} /> {/if} diff --git a/web/src/lib/components/photos-page/actions/delete-assets.svelte b/web/src/lib/components/photos-page/actions/delete-assets.svelte index 5c79e7b221..6d3275c74d 100644 --- a/web/src/lib/components/photos-page/actions/delete-assets.svelte +++ b/web/src/lib/components/photos-page/actions/delete-assets.svelte @@ -49,7 +49,7 @@ {#if isShowConfirmation} (isShowConfirmation = false)} + onConfirm={handleDelete} + onCancel={() => (isShowConfirmation = false)} /> {/if} diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index 240b6c2ba2..b2780cc1a0 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -8,7 +8,7 @@ import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util'; import type { AssetResponseDto } from '@immich/sdk'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; - import { createEventDispatcher, onDestroy } from 'svelte'; + import { onDestroy } from 'svelte'; import { fly } from 'svelte/transition'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import { TUNABLES } from '$lib/utils/tunables'; @@ -29,6 +29,9 @@ export let onScrollTarget: ScrollTargetListener | undefined = undefined; export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined; + export let onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void; + export let onSelectAssets: (asset: AssetResponseDto) => void; + export let onSelectAssetCandidates: (asset: AssetResponseDto | null) => void; const componentId = generateId(); $: bucketDate = bucket.bucketDate; @@ -41,11 +44,6 @@ const TITLE_HEIGHT = 51; const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore; - const dispatch = createEventDispatcher<{ - select: { title: string; assets: AssetResponseDto[] }; - selectAssets: AssetResponseDto; - selectAssetCandidates: AssetResponseDto | null; - }>(); let isMouseOverGroup = false; let hoveredDateGroup = ''; @@ -65,10 +63,10 @@ } }; - const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets }); + const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets }); const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => { - dispatch('selectAssets', asset); + onSelectAssets(asset); // Check if all assets are selected in a group to toggle the group selection's icon let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => $selectedAssets.has(asset)).length; @@ -86,7 +84,7 @@ hoveredDateGroup = groupTitle; if ($isMultiSelectState) { - dispatch('selectAssetCandidates', asset); + onSelectAssetCandidates(asset); } }; diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 3bf0c65bc9..6de36c803e 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -28,7 +28,7 @@ import { TUNABLES } from '$lib/utils/tunables'; import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk'; import { throttle } from 'lodash-es'; - import { createEventDispatcher, onDestroy, onMount } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import Portal from '../shared-components/portal/portal.svelte'; import Scrubber from '../shared-components/scrubber/scrubber.svelte'; import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; @@ -64,6 +64,8 @@ export let isShared = false; export let album: AlbumResponseDto | null = null; export let isShowDeleteConfirmation = false; + export let onSelect: (asset: AssetResponseDto) => void = () => {}; + export let onEscape: () => void = () => {}; let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore; const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } = @@ -127,8 +129,6 @@ }, } = TUNABLES; - const dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>(); - const isViewportOrigin = () => { return viewport.height === 0 && viewport.width === 0; }; @@ -447,7 +447,7 @@ const ids = await stackAssets(Array.from($selectedAssets)); if (ids) { $assetStore.removeAssets(ids); - dispatch('escape'); + onEscape(); } }; @@ -471,7 +471,7 @@ } const shortcuts: ShortcutOptions[] = [ - { shortcut: { key: 'Escape' }, onShortcut: () => dispatch('escape') }, + { shortcut: { key: 'Escape' }, onShortcut: onEscape }, { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) }, @@ -539,7 +539,7 @@ return !!nextAsset; }; - const handleClose = async ({ detail: { asset } }: { detail: { asset: AssetResponseDto } }) => { + const handleClose = async ({ asset }: { asset: AssetResponseDto }) => { assetViewingStore.showAssetViewer(false); showSkeleton = true; $gridScrollTarget = { at: asset.id }; @@ -554,7 +554,7 @@ case AssetAction.DELETE: { // find the next asset to show or close the viewer // eslint-disable-next-line @typescript-eslint/no-unused-expressions - (await handleNext()) || (await handlePrevious()) || (await handleClose({ detail: { asset: action.asset } })); + (await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset })); // delete after find the next one assetStore.removeAssets([action.asset.id]); @@ -649,7 +649,7 @@ return; } - dispatch('select', asset); + onSelect(asset); if (singleSelect) { element.scrollTop = 0; @@ -754,8 +754,8 @@ {#if isShowDeleteConfirmation} (isShowDeleteConfirmation = false)} - on:confirm={() => handlePromiseError(trashOrDelete(true))} + onCancel={() => (isShowDeleteConfirmation = false)} + onConfirm={() => handlePromiseError(trashOrDelete(true))} /> {/if} @@ -847,9 +847,9 @@ {onAssetInGrid} {bucket} viewport={safeViewport} - on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)} - on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)} - on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)} + onSelect={({ title, assets }) => handleGroupSelect(title, assets)} + onSelectAssetCandidates={handleSelectAssetCandidates} + onSelectAssets={handleSelectAssets} /> {/if} @@ -869,9 +869,9 @@ {isShared} {album} onAction={handleAction} - on:previous={handlePrevious} - on:next={handleNext} - on:close={handleClose} + onPrevious={handlePrevious} + onNext={handleNext} + onClose={handleClose} /> {/await} {/if} diff --git a/web/src/lib/components/photos-page/asset-select-control-bar.svelte b/web/src/lib/components/photos-page/asset-select-control-bar.svelte index c802c53454..79a0ea75e6 100644 --- a/web/src/lib/components/photos-page/asset-select-control-bar.svelte +++ b/web/src/lib/components/photos-page/asset-select-control-bar.svelte @@ -30,7 +30,7 @@ }); - +

    {assets.size}

    diff --git a/web/src/lib/components/photos-page/delete-asset-dialog.svelte b/web/src/lib/components/photos-page/delete-asset-dialog.svelte index 84782b2d7f..3eff428a7b 100644 --- a/web/src/lib/components/photos-page/delete-asset-dialog.svelte +++ b/web/src/lib/components/photos-page/delete-asset-dialog.svelte @@ -1,5 +1,4 @@ @@ -27,7 +23,7 @@ title={$t('permanently_delete_assets_count', { values: { count: size } })} confirmText={$t('delete')} onConfirm={handleConfirm} - onCancel={() => dispatch('cancel')} + {onCancel} >

    diff --git a/web/src/lib/components/shared-components/album-selection-modal.svelte b/web/src/lib/components/shared-components/album-selection-modal.svelte index 0690374c01..6d28bd12c0 100644 --- a/web/src/lib/components/shared-components/album-selection-modal.svelte +++ b/web/src/lib/components/shared-components/album-selection-modal.svelte @@ -2,7 +2,7 @@ import Icon from '$lib/components/elements/icon.svelte'; import { getAllAlbums, type AlbumResponseDto } from '@immich/sdk'; import { mdiPlus } from '@mdi/js'; - import { createEventDispatcher, onMount } from 'svelte'; + import { onMount } from 'svelte'; import AlbumListItem from '../asset-viewer/album-list-item.svelte'; import { normalizeSearchString } from '$lib/utils/string-utils'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; @@ -11,17 +11,15 @@ import { sortAlbums } from '$lib/utils/album-utils'; import { albumViewSettings } from '$lib/stores/preferences.store'; + export let onNewAlbum: (search: string) => void; + export let onAlbumClick: (album: AlbumResponseDto) => void; + let albums: AlbumResponseDto[] = []; let recentAlbums: AlbumResponseDto[] = []; let filteredAlbums: AlbumResponseDto[] = []; let loading = true; let search = ''; - const dispatch = createEventDispatcher<{ - newAlbum: string; - album: AlbumResponseDto; - }>(); - export let shared: boolean; export let onClose: () => void; @@ -40,14 +38,6 @@ { sortBy: $albumViewSettings.sortBy, orderBy: $albumViewSettings.sortOrder }, ); - const handleSelect = (album: AlbumResponseDto) => { - dispatch('album', album); - }; - - const handleNew = () => { - dispatch('newAlbum', search.length > 0 ? search : ''); - }; - const getTitle = () => { if (shared) { return $t('add_to_shared_album'); @@ -81,7 +71,7 @@

    @@ -180,7 +162,7 @@ center={lat && lng ? { lat, lng } : undefined} simplified={true} clickable={true} - on:clickedPoint={({ detail: point }) => handleSelect(point)} + onClickPoint={(selected) => (point = selected)} /> {/await}
    diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 7c71fe8aea..241f937be0 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -21,7 +21,7 @@ import { fly } from 'svelte/transition'; import Icon from '$lib/components/elements/icon.svelte'; import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js'; - import { createEventDispatcher, tick } from 'svelte'; + import { tick } from 'svelte'; import type { FormEventHandler } from 'svelte/elements'; import { shortcuts } from '$lib/actions/shortcut'; import { focusOutside } from '$lib/actions/focus-outside'; @@ -35,6 +35,7 @@ export let options: ComboBoxOption[] = []; export let selectedOption: ComboBoxOption | undefined = undefined; export let placeholder = ''; + export let onSelect: (option: ComboBoxOption | undefined) => void = () => {}; /** * Unique identifier for the combobox. @@ -61,10 +62,6 @@ searchQuery = selectedOption ? selectedOption.label : ''; } - const dispatch = createEventDispatcher<{ - select: ComboBoxOption | undefined; - }>(); - const activate = () => { isActive = true; searchQuery = ''; @@ -105,10 +102,10 @@ optionRefs[0]?.scrollIntoView({ block: 'nearest' }); }; - let onSelect = (option: ComboBoxOption) => { + let handleSelect = (option: ComboBoxOption) => { selectedOption = option; searchQuery = option.label; - dispatch('select', option); + onSelect(option); closeDropdown(); }; @@ -117,7 +114,7 @@ selectedIndex = undefined; selectedOption = undefined; searchQuery = ''; - dispatch('select', selectedOption); + onSelect(selectedOption); }; @@ -188,7 +185,7 @@ shortcut: { key: 'Enter' }, onShortcut: () => { if (selectedIndex !== undefined && filteredOptions.length > 0) { - onSelect(filteredOptions[selectedIndex]); + handleSelect(filteredOptions[selectedIndex]); } closeDropdown(); }, @@ -245,7 +242,7 @@ bind:this={optionRefs[index]} class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700" id={`${listboxId}-${index}`} - on:click={() => onSelect(option)} + on:click={() => handleSelect(option)} role="option" > {option.label} diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte index cf128104d1..228cd88a86 100644 --- a/web/src/lib/components/shared-components/control-app-bar.svelte +++ b/web/src/lib/components/shared-components/control-app-bar.svelte @@ -1,7 +1,7 @@ diff --git a/web/src/lib/components/shared-components/settings/setting-switch.svelte b/web/src/lib/components/shared-components/settings/setting-switch.svelte index 24b539f0a1..11716526f8 100644 --- a/web/src/lib/components/shared-components/settings/setting-switch.svelte +++ b/web/src/lib/components/shared-components/settings/setting-switch.svelte @@ -1,7 +1,6 @@
    diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte index 13ec440082..a63bdb3ca9 100644 --- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte +++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte @@ -102,7 +102,7 @@ {/if} {#if secret} - (secret = '')} /> + (secret = '')} /> {/if} {#if editKey} diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 2f1efc487c..fd5b68d8c3 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -151,15 +151,15 @@ 1} - on:next={() => { + onNext={() => { const index = getAssetIndex($viewingAsset.id) + 1; setAsset(assets[index % assets.length]); }} - on:previous={() => { + onPrevious={() => { const index = getAssetIndex($viewingAsset.id) - 1 + assets.length; setAsset(assets[index % assets.length]); }} - on:close={() => { + onClose={() => { assetViewingStore.showAssetViewer(false); handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); }} diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6e75273f3b..57d09ed53a 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -674,8 +674,8 @@ disabled={!album.isActivityEnabled} {isLiked} numberOfComments={$numberOfComments} - on:favorite={handleFavorite} - on:openActivityTab={handleOpenAndCloseActivityTab} + onFavorite={handleFavorite} + onOpenActivityTab={handleOpenAndCloseActivityTab} />
    {/if} @@ -697,10 +697,10 @@ albumId={album.id} {isLiked} bind:reactions - on:addComment={() => updateNumberOfComments(1)} - on:deleteComment={() => updateNumberOfComments(-1)} - on:deleteLike={() => (isLiked = null)} - on:close={handleOpenAndCloseActivityTab} + onAddComment={() => updateNumberOfComments(1)} + onDeleteComment={() => updateNumberOfComments(-1)} + onDeleteLike={() => (isLiked = null)} + onClose={handleOpenAndCloseActivityTab} />
    @@ -709,8 +709,8 @@ {#if viewMode === ViewMode.SELECT_USERS} handleAddUsers(users)} - on:share={() => (viewMode = ViewMode.LINK_SHARING)} + onSelect={handleAddUsers} + onShare={() => (viewMode = ViewMode.LINK_SHARING)} onClose={() => (viewMode = ViewMode.VIEW)} /> {/if} @@ -723,8 +723,8 @@ (viewMode = ViewMode.VIEW)} {album} - on:remove={({ detail: userId }) => handleRemoveUser(userId)} - on:refreshAlbum={refreshAlbum} + onRemove={handleRemoveUser} + onRefreshAlbum={refreshAlbum} /> {/if} @@ -737,9 +737,9 @@ albumOrder = order; await setModeToView(); }} - on:close={() => (viewMode = ViewMode.VIEW)} - on:toggleEnableActivity={handleToggleEnableActivity} - on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} + onClose={() => (viewMode = ViewMode.VIEW)} + onToggleEnabledActivity={handleToggleEnableActivity} + onShowSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} /> {/if} diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 0ea0ed18bb..2e109823ed 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -122,9 +122,9 @@ 1} - on:next={navigateNext} - on:previous={navigatePrevious} - on:close={() => { + onNext={navigateNext} + onPrevious={navigatePrevious} + onClose={() => { assetViewingStore.showAssetViewer(false); handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); }} @@ -137,11 +137,11 @@ {#if showSettingsModal} (showSettingsModal = false)} - on:save={async ({ detail }) => { - const shouldUpdate = !isEqual(omit(detail, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode')); + onClose={() => (showSettingsModal = false)} + onSave={async (settings) => { + const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode')); showSettingsModal = false; - $mapSettings = detail; + $mapSettings = settings; if (shouldUpdate) { mapMarkers = await loadMapMarkers(); diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index f1a2674e24..b6d25c48bf 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -302,9 +302,9 @@ {personMerge1} {personMerge2} {potentialMergePeople} - on:close={() => (showMergeModal = false)} - on:reject={() => changeName()} - on:confirm={(event) => handleMergeSamePerson(event.detail)} + onClose={() => (showMergeModal = false)} + onReject={changeName} + onConfirm={handleMergeSamePerson} /> {/if} @@ -349,10 +349,10 @@ handleChangeName(person)} - on:set-birth-date={() => handleSetBirthDate(person)} - on:merge-people={() => handleMergePeople(person)} - on:hide-person={() => handleHidePerson(person)} + onChangeName={() => handleChangeName(person)} + onSetBirthDate={() => handleSetBirthDate(person)} + onMergePeople={() => handleMergePeople(person)} + onHidePerson={() => handleHidePerson(person)} /> {:else} @@ -397,8 +397,8 @@ {#if showSetBirthDateModal} (showSetBirthDateModal = false)} - on:updated={(event) => submitBirthDateChange(event.detail)} + onClose={() => (showSetBirthDateModal = false)} + onUpdate={submitBirthDateChange} /> {/if} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index daa5821e85..bb648228b9 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -347,8 +347,8 @@ a.id)} personAssets={person} - on:close={() => (viewMode = ViewMode.VIEW_ASSETS)} - on:confirm={handleUnmerge} + onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onConfirm={handleUnmerge} /> {/if} @@ -357,22 +357,22 @@ {personMerge1} {personMerge2} {potentialMergePeople} - on:close={() => (viewMode = ViewMode.VIEW_ASSETS)} - on:reject={() => changeName()} - on:confirm={(event) => handleMergeSamePerson(event.detail)} + onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onReject={changeName} + onConfirm={handleMergeSamePerson} /> {/if} {#if viewMode === ViewMode.BIRTH_DATE} (viewMode = ViewMode.VIEW_ASSETS)} - on:updated={(event) => handleSetBirthDate(event.detail)} + onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onUpdate={handleSetBirthDate} /> {/if} {#if viewMode === ViewMode.MERGE_PEOPLE} - handleMerge(detail)} /> + {/if}
    @@ -464,7 +464,7 @@ bind:suggestedPeople name={person.name} bind:isSearchingPeople - on:change={(event) => handleNameChange(event.detail)} + onChange={handleNameChange} {thumbnailData} /> {:else} diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 74db5628ba..5ce3296a03 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -267,10 +267,7 @@ {#if toCreateLibrary} - handleCreate(detail.ownerId)} - on:cancel={() => (toCreateLibrary = false)} - /> + (toCreateLibrary = false)} /> {/if} @@ -385,28 +382,20 @@ {#if renameLibrary === index}
    - handleUpdate(detail)} - on:cancel={() => (renameLibrary = null)} - /> + (renameLibrary = null)} />
    {/if} {#if editImportPaths === index}
    - handleUpdate(detail)} - on:cancel={() => (editImportPaths = null)} - /> + (editImportPaths = null)} />
    {/if} {#if editScanSettings === index}
    handleUpdate(library)} - on:cancel={() => (editScanSettings = null)} + onSubmit={handleUpdate} + onCancel={() => (editScanSettings = null)} />
    {/if} diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index b040ce293c..2313b17cb1 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -110,8 +110,8 @@
    {#if shouldShowCreateUserForm} (shouldShowCreateUserForm = false)} + onSubmit={onUserCreated} + onCancel={() => (shouldShowCreateUserForm = false)} onClose={() => (shouldShowCreateUserForm = false)} /> {/if} @@ -121,8 +121,8 @@ user={selectedUser} bind:newPassword canResetPassword={selectedUser?.id !== $user.id} - on:editSuccess={onEditUserSuccess} - on:resetPasswordSuccess={onEditPasswordSuccess} + onEditSuccess={onEditUserSuccess} + onResetPasswordSuccess={onEditPasswordSuccess} onClose={() => (shouldShowEditUserForm = false)} /> {/if} diff --git a/web/src/routes/auth/change-password/+page.svelte b/web/src/routes/auth/change-password/+page.svelte index aa23e4e7d2..eaf5a88fe2 100644 --- a/web/src/routes/auth/change-password/+page.svelte +++ b/web/src/routes/auth/change-password/+page.svelte @@ -25,5 +25,5 @@ {$t('change_password_description')}

    - + From 8cd3f6b8840a8f8f66c42d40dc694aac2307e930 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sat, 21 Sep 2024 00:24:46 +0200 Subject: [PATCH 404/723] fix(web): events as props (#12825) --- .../admin-page/settings/ffmpeg/ffmpeg-settings.svelte | 4 ++-- .../admin-page/settings/image/image-settings.svelte | 4 ++-- .../asset-viewer/video-wrapper-viewer.svelte | 10 +++++++++- web/src/lib/components/faces-page/people-list.svelte | 2 +- web/src/lib/components/faces-page/people-search.svelte | 4 ++-- .../components/faces-page/unmerge-face-selector.svelte | 2 +- web/src/lib/components/forms/tag-asset-form.svelte | 2 +- .../share-page/individual-shared-viewer.svelte | 2 +- .../purchasing/purchase-activation-success.svelte | 2 +- .../search-bar/search-camera-section.svelte | 4 ++-- .../search-bar/search-location-section.svelte | 6 +++--- .../shared-components/settings/setting-combobox.svelte | 9 +-------- .../components/user-settings-page/app-settings.svelte | 10 +++++----- .../user-settings-page/partner-settings.svelte | 2 +- .../user-settings-page/user-purchase-settings.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 10 +++++----- .../map/[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 8 ++++---- .../routes/(user)/photos/[[assetId=id]]/+page.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- web/src/routes/(user)/sharing/sharedlinks/+page.svelte | 2 +- 22 files changed, 47 insertions(+), 46 deletions(-) diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte index 7ddb71cbde..c048a22207 100644 --- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte +++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte @@ -99,7 +99,7 @@ ]} name="vcodec" isEdited={config.ffmpeg.targetVideoCodec !== savedConfig.ffmpeg.targetVideoCodec} - on:select={() => (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])} + onSelect={() => (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])} /> + onSelect={() => config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec) ? null : config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec)} diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index a7b47920fd..d6fc814b98 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -96,7 +96,7 @@ title={$t('admin.image_prefer_wide_gamut')} subtitle={$t('admin.image_prefer_wide_gamut_setting_description')} checked={config.image.colorspace === Colorspace.P3} - on:toggle={(e) => (config.image.colorspace = e.detail ? Colorspace.P3 : Colorspace.Srgb)} + onToggle={(isChecked) => (config.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)} isEdited={config.image.colorspace !== savedConfig.image.colorspace} {disabled} /> @@ -105,7 +105,7 @@ title={$t('admin.image_prefer_embedded_preview')} subtitle={$t('admin.image_prefer_embedded_preview_setting_description')} checked={config.image.extractEmbedded} - on:toggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)} + onToggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)} isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded} {disabled} /> diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte index ae9fda8c69..5f03784c42 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -15,5 +15,13 @@ {#if projectionType === ProjectionType.EQUIRECTANGULAR} {:else} - + {/if} diff --git a/web/src/lib/components/faces-page/people-list.svelte b/web/src/lib/components/faces-page/people-list.svelte index 230c8750ae..10626a6a93 100644 --- a/web/src/lib/components/faces-page/people-list.svelte +++ b/web/src/lib/components/faces-page/people-list.svelte @@ -32,7 +32,7 @@ >
    {#each showPeople as person (person.id)} - onSelect(person)} circle border selectable /> + onSelect(person)} circle border selectable /> {/each}
    diff --git a/web/src/lib/components/faces-page/people-search.svelte b/web/src/lib/components/faces-page/people-search.svelte index cfd4c8f29a..2a952b8145 100644 --- a/web/src/lib/components/faces-page/people-search.svelte +++ b/web/src/lib/components/faces-page/people-search.svelte @@ -83,8 +83,8 @@ bind:name={searchName} {showLoadingSpinner} {placeholder} - on:reset={handleReset} - on:search={({ detail }) => handleSearch(detail.force ?? false)} + onReset={handleReset} + onSearch={({ force }) => handleSearch(force ?? false)} /> {:else} diff --git a/web/src/lib/components/forms/tag-asset-form.svelte b/web/src/lib/components/forms/tag-asset-form.svelte index 7500a6faac..b5e358ec96 100644 --- a/web/src/lib/components/forms/tag-asset-form.svelte +++ b/web/src/lib/components/forms/tag-asset-form.svelte @@ -52,7 +52,7 @@
    handleSelect(option)} + onSelect={handleSelect} label={$t('tag')} options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))} placeholder={$t('search_tags')} diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index af5c54c988..1b5368b133 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -84,7 +84,7 @@ {/if} {:else} - goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}> + goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}> diff --git a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte index 2b8c678543..3bd462f997 100644 --- a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte +++ b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte @@ -20,7 +20,7 @@ title={$t('show_supporter_badge')} subtitle={$t('show_supporter_badge_description')} bind:checked={$preferences.purchase.showSupportBadge} - on:toggle={({ detail }) => setSupportBadgeVisibility(detail)} + onToggle={setSupportBadgeVisibility} />
    diff --git a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte index f1cd0c8596..3ac8cb8d5a 100644 --- a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte @@ -56,7 +56,7 @@
    (filters.make = detail?.value)} + onSelect={(option) => (filters.make = option?.value)} options={asComboboxOptions(makes)} placeholder={$t('search_camera_make')} selectedOption={asSelectedOption(makeFilter)} @@ -66,7 +66,7 @@
    (filters.model = detail?.value)} + onSelect={(option) => (filters.model = option?.value)} options={asComboboxOptions(models)} placeholder={$t('search_camera_model')} selectedOption={asSelectedOption(modelFilter)} diff --git a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte index ce265d0030..71912264ed 100644 --- a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte @@ -73,7 +73,7 @@
    (filters.country = detail?.value)} + onSelect={(option) => (filters.country = option?.value)} options={asComboboxOptions(countries)} placeholder={$t('search_country')} selectedOption={asSelectedOption(filters.country)} @@ -83,7 +83,7 @@
    (filters.state = detail?.value)} + onSelect={(option) => (filters.state = option?.value)} options={asComboboxOptions(states)} placeholder={$t('search_state')} selectedOption={asSelectedOption(filters.state)} @@ -93,7 +93,7 @@
    (filters.city = detail?.value)} + onSelect={(option) => (filters.city = option?.value)} options={asComboboxOptions(cities)} placeholder={$t('search_city')} selectedOption={asSelectedOption(filters.city)} diff --git a/web/src/lib/components/shared-components/settings/setting-combobox.svelte b/web/src/lib/components/shared-components/settings/setting-combobox.svelte index 502cd94cce..722af048a5 100644 --- a/web/src/lib/components/shared-components/settings/setting-combobox.svelte +++ b/web/src/lib/components/shared-components/settings/setting-combobox.svelte @@ -32,14 +32,7 @@

    {subtitle}

    - onSelect(detail)} - /> +
    diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index de4bbafdd9..e6ce8f6aae 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -99,7 +99,7 @@ title={$t('theme_selection')} subtitle={$t('theme_selection_description')} bind:checked={$colorTheme.system} - on:toggle={handleToggleColorTheme} + onToggle={handleToggleColorTheme} />
    @@ -119,7 +119,7 @@ title={$t('default_locale')} subtitle={$t('default_locale_description')} checked={$locale == undefined} - on:toggle={handleToggleLocaleBrowser} + onToggle={handleToggleLocaleBrowser} >

    {selectedDate}

    @@ -142,7 +142,7 @@ title={$t('display_original_photos')} subtitle={$t('display_original_photos_setting_description')} bind:checked={$alwaysLoadOriginalFile} - on:toggle={() => ($alwaysLoadOriginalFile = !$alwaysLoadOriginalFile)} + onToggle={() => ($alwaysLoadOriginalFile = !$alwaysLoadOriginalFile)} />
    @@ -150,7 +150,7 @@ title={$t('video_hover_setting')} subtitle={$t('video_hover_setting_description')} bind:checked={$playVideoThumbnailOnHover} - on:toggle={() => ($playVideoThumbnailOnHover = !$playVideoThumbnailOnHover)} + onToggle={() => ($playVideoThumbnailOnHover = !$playVideoThumbnailOnHover)} />
    @@ -158,7 +158,7 @@ title={$t('loop_videos')} subtitle={$t('loop_videos_description')} bind:checked={$loopVideo} - on:toggle={() => ($loopVideo = !$loopVideo)} + onToggle={() => ($loopVideo = !$loopVideo)} />
    diff --git a/web/src/lib/components/user-settings-page/partner-settings.svelte b/web/src/lib/components/user-settings-page/partner-settings.svelte index ee57e4c688..050e2c42f3 100644 --- a/web/src/lib/components/user-settings-page/partner-settings.svelte +++ b/web/src/lib/components/user-settings-page/partner-settings.svelte @@ -177,7 +177,7 @@ title={$t('show_in_timeline')} subtitle={$t('show_in_timeline_setting_description')} bind:checked={partner.inTimeline} - on:toggle={({ detail }) => handleShowOnTimelineChanged(partner, detail)} + onToggle={(isChecked) => handleShowOnTimelineChanged(partner, isChecked)} /> {/if}
    diff --git a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte index bf0fd3c874..71f76d07c0 100644 --- a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte +++ b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte @@ -115,7 +115,7 @@ title={$t('show_supporter_badge')} subtitle={$t('show_supporter_badge_description')} bind:checked={$preferences.purchase.showSupportBadge} - on:toggle={({ detail }) => setSupportBadgeVisibility(detail)} + onToggle={setSupportBadgeVisibility} /> diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 57d09ed53a..cbdb38192e 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -470,7 +470,7 @@ {:else} {#if viewMode === ViewMode.VIEW} - goto(backUrl)}> + goto(backUrl)}> {#if isEditor} +

    {#if $timelineSelected.size === 0} @@ -554,7 +554,7 @@ {/if} {#if viewMode === ViewMode.SELECT_THUMBNAIL} - (viewMode = ViewMode.VIEW)}> + (viewMode = ViewMode.VIEW)}> {$t('select_album_cover')} {/if} @@ -583,8 +583,8 @@ isSelectionMode={viewMode === ViewMode.SELECT_THUMBNAIL} singleSelect={viewMode === ViewMode.SELECT_THUMBNAIL} showArchiveIcon - on:select={({ detail: asset }) => handleUpdateThumbnail(asset.id)} - on:escape={handleEscape} + onSelect={({ id }) => handleUpdateThumbnail(id)} + onEscape={handleEscape} > {#if viewMode !== ViewMode.SELECT_THUMBNAIL} diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2e109823ed..adbc3cfe69 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -113,7 +113,7 @@ {#if $featureFlags.loaded && $featureFlags.map}

    - onViewAssets(event.detail)} /> +
    diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index b580c4faa5..2caab9de82 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -38,7 +38,7 @@ {:else} - goto(AppRoute.SHARING)}> + goto(AppRoute.SHARING)}>

    {data.partner.name}'s photos diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index bb648228b9..83019d67cd 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -400,7 +400,7 @@ {:else} {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} - goto(previousRoute)}> + goto(previousRoute)}> (viewMode = ViewMode.VIEW_ASSETS)}> + (viewMode = ViewMode.VIEW_ASSETS)}> {$t('select_featured_photo')} {/if} @@ -444,8 +444,8 @@ {assetInteractionStore} isSelectionMode={viewMode === ViewMode.SELECT_PERSON} singleSelect={viewMode === ViewMode.SELECT_PERSON} - on:select={({ detail: asset }) => handleSelectFeaturePhoto(asset)} - on:escape={handleEscape} + onSelect={handleSelectFeaturePhoto} + onEscape={handleEscape} > {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 4649da8205..ba8ee13cc9 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -127,7 +127,7 @@ {assetStore} {assetInteractionStore} removeAction={AssetAction.ARCHIVE} - on:escape={handleEscape} + onEscape={handleEscape} withStacked > {#if $preferences.memories.enabled} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index da85eb49c8..9c6a8f9e75 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -246,7 +246,7 @@ {:else}

    - goto(previousRoute)} backIcon={mdiArrowLeft}> + goto(previousRoute)} backIcon={mdiArrowLeft}>
    diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte index 5e934143df..67e80f4703 100644 --- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte +++ b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte @@ -52,7 +52,7 @@ }; - goto(AppRoute.SHARING)}> + goto(AppRoute.SHARING)}> {$t('shared_links')} From af7011164589a34f83fa896d756b5c7b1d4c5d81 Mon Sep 17 00:00:00 2001 From: Shubham Date: Sat, 21 Sep 2024 04:31:26 +0530 Subject: [PATCH 405/723] fix(mobile): Issue Selecting Many Albuns for Backup (#12784) * Update backup.provider.dart * Revert "Update backup.provider.dart" This reverts commit ac2b7acef9c4390a61a30884a05589723f572403. * Reapply "Update backup.provider.dart" This reverts commit c9fe934b3bde472a579b465fbd3b21448b819930. * dart formatting --- mobile/lib/providers/backup/backup.provider.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 9329f9b1f7..0885f35f77 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -313,6 +313,9 @@ class BackupNotifier extends StateNotifier { /// Those assets are unique and are used as the total assets /// Future _updateBackupAssetCount() async { + // Save to persistent storage + await _updatePersistentAlbumsSelection(); + final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds(); final Set assetsFromSelectedAlbums = {}; final Set assetsFromExcludedAlbums = {}; @@ -408,9 +411,6 @@ class BackupNotifier extends StateNotifier { selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, ); } - - // Save to persistent storage - await _updatePersistentAlbumsSelection(); } /// Get all necessary information for calculating the available albums, From 5a1a841365a842eab345a70c420380cc00606e2e Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Sat, 21 Sep 2024 00:16:53 +0100 Subject: [PATCH 406/723] fix: rework file handling so we always explicitly create, overwrite or both (#12812) --- server/src/interfaces/storage.interface.ts | 4 +++- server/src/repositories/storage.repository.ts | 12 +++++++++-- server/src/services/metadata.service.spec.ts | 10 ++++----- server/src/services/metadata.service.ts | 2 +- server/src/services/storage.service.spec.ts | 9 ++++++-- server/src/services/storage.service.ts | 21 +++++++++++++++---- .../repositories/storage.repository.mock.ts | 4 +++- 7 files changed, 46 insertions(+), 16 deletions(-) diff --git a/server/src/interfaces/storage.interface.ts b/server/src/interfaces/storage.interface.ts index fec3d66dd5..321f7b8367 100644 --- a/server/src/interfaces/storage.interface.ts +++ b/server/src/interfaces/storage.interface.ts @@ -35,7 +35,9 @@ export interface IStorageRepository { createZipStream(): ImmichZipStream; createReadStream(filepath: string, mimeType?: string | null): Promise; readFile(filepath: string, options?: FileReadOptions): Promise; - writeFile(filepath: string, buffer: Buffer): Promise; + createFile(filepath: string, buffer: Buffer): Promise; + createOrOverwriteFile(filepath: string, buffer: Buffer): Promise; + overwriteFile(filepath: string, buffer: Buffer): Promise; realpath(filepath: string): Promise; unlink(filepath: string): Promise; unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise; diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index c699047ce1..6fd9bb8b04 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -40,8 +40,16 @@ export class StorageRepository implements IStorageRepository { return fs.stat(filepath); } - writeFile(filepath: string, buffer: Buffer) { - return fs.writeFile(filepath, buffer); + createFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'wx' }); + } + + createOrOverwriteFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'w' }); + } + + overwriteFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'r+' }); } rename(source: string, target: string) { diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 19aaa2ea1a..4eac4a4cf9 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -511,7 +511,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); - expect(storageMock.writeFile).not.toHaveBeenCalled(); + expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith( @@ -581,7 +581,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -624,7 +624,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -668,7 +668,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -716,7 +716,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); expect(assetMock.create).toHaveBeenCalledTimes(0); - expect(storageMock.writeFile).toHaveBeenCalledTimes(0); + expect(storageMock.createOrOverwriteFile).toHaveBeenCalledTimes(0); // The still asset gets saved by handleMetadataExtraction, but not the video expect(assetMock.update).toHaveBeenCalledTimes(1); expect(jobMock.queue).toHaveBeenCalledTimes(0); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index eaa491c3ee..60a1e12a5a 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -529,7 +529,7 @@ export class MetadataService { const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath); if (!existsOnDisk) { this.storageCore.ensureFolders(motionAsset.originalPath); - await this.storageRepository.writeFile(motionAsset.originalPath, video); + await this.storageRepository.createFile(motionAsset.originalPath, video); this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } }); } diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index b0f38554cb..930fb3c726 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -41,6 +41,11 @@ describe(StorageService.name, () => { expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); }); it('should throw an error if .immich is missing', async () => { @@ -49,13 +54,13 @@ describe(StorageService.name, () => { await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); - expect(storageMock.writeFile).not.toHaveBeenCalled(); + expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); expect(systemMock.set).not.toHaveBeenCalled(); }); it('should throw an error if .immich is present but read-only', async () => { systemMock.get.mockResolvedValue({ mountFiles: true }); - storageMock.writeFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index a8f6a76e74..15328b0c21 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -32,7 +32,7 @@ export class StorageService { for (const folder of Object.values(StorageFolder)) { if (!flags.mountFiles) { this.logger.log(`Writing initial mount file for the ${folder} folder`); - await this.verifyWriteAccess(folder); + await this.createMountFile(folder); } await this.verifyReadAccess(folder); @@ -81,17 +81,30 @@ export class StorageService { } } - private async verifyWriteAccess(folder: StorageFolder) { + private async createMountFile(folder: StorageFolder) { const { folderPath, filePath } = this.getMountFilePaths(folder); try { this.storageRepository.mkdirSync(folderPath); - await this.storageRepository.writeFile(filePath, Buffer.from(`${Date.now()}`)); + await this.storageRepository.createFile(filePath, Buffer.from(`${Date.now()}`)); + } catch (error) { + this.logger.error(`Failed to create ${filePath}: ${error}`); + this.logger.error( + `The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`, + ); + throw new ImmichStartupError(`Failed to validate folder mount (write to "/${folder}")`); + } + } + + private async verifyWriteAccess(folder: StorageFolder) { + const { filePath } = this.getMountFilePaths(folder); + try { + await this.storageRepository.overwriteFile(filePath, Buffer.from(`${Date.now()}`)); } catch (error) { this.logger.error(`Failed to write ${filePath}: ${error}`); this.logger.error( `The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`, ); - throw new ImmichStartupError(`Failed to validate folder mount (write to "/${folder}")`); + throw new ImmichStartupError(`Failed to validate folder mount (write to "/${folder}")`); } } diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 5c2951e097..5226e0bb1e 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -48,7 +48,9 @@ export const newStorageRepositoryMock = (reset = true): Mocked Date: Sat, 21 Sep 2024 07:29:07 +0700 Subject: [PATCH 407/723] fix(mobile): fix uncaught error in getting file cause hashing procses to be aborted entirely (#12826) * fix(mobile): fix uncaught error in getting file cause hashing procses to be aborted entirely * log error --- mobile/lib/services/hash.service.dart | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/mobile/lib/services/hash.service.dart b/mobile/lib/services/hash.service.dart index 2ec545453f..94d680972f 100644 --- a/mobile/lib/services/hash.service.dart +++ b/mobile/lib/services/hash.service.dart @@ -65,7 +65,19 @@ class HashService { if (hashes[i] != null) { continue; } - final file = await assets[i].local!.originFile; + + File? file; + + try { + file = await assets[i].local!.originFile; + } catch (error, stackTrace) { + _log.warning( + "Error getting file to hash for asset ${assets[i].localId}, name: ${assets[i].fileName}, created on: ${assets[i].fileCreatedAt}, skipping", + error, + stackTrace, + ); + } + if (file == null) { final fileName = assets[i].fileName; From 7c1ea2dc73219aa06c9b5d3ee90a2a04417279d7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 22 Sep 2024 07:29:30 +0700 Subject: [PATCH 408/723] chore(deps): update dependency flutter to v3.24.3 (#11738) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- mobile/.fvmrc | 2 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/.fvmrc b/mobile/.fvmrc index 971587f297..ee6eaac06f 100644 --- a/mobile/.fvmrc +++ b/mobile/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.24.0" + "flutter": "3.24.3" } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 7fe33c3270..aaea00d699 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1854,4 +1854,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.24.0" + flutter: ">=3.24.3" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 8787fd8565..0f75463547 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -6,7 +6,7 @@ version: 1.115.0+159 environment: sdk: '>=3.3.0 <4.0.0' - flutter: 3.24.0 + flutter: 3.24.3 dependencies: flutter: From 39ea73d654c79bdffe70d4e4804f813b049b512b Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Sun, 22 Sep 2024 15:24:08 +0200 Subject: [PATCH 409/723] chore(mobile): restrict isar use via CI checks (#12840) --- mobile/analysis_options.yaml | 20 +++++++++++++++++++ mobile/lib/pages/library/favorite.page.dart | 2 +- ...e_provider.dart => favorite.provider.dart} | 0 3 files changed, 21 insertions(+), 1 deletion(-) rename mobile/lib/providers/{favorite_provider.dart => favorite.provider.dart} (100%) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 2783e8f1d1..8f9d41d736 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -58,6 +58,26 @@ custom_lint: # refactor to make the providers and services testable - lib/providers/backup/{backup,manual_upload}.provider.dart # uses only PMProgressHandler - lib/services/{background,backup}.service.dart # uses only PMProgressHandler + - import_rule_isar: + message: isar must only be used in entities and repositories + restrict: package:isar + allowed: + # required / wanted + - lib/entities/*.entity.dart + - lib/repositories/{album,asset,backup,user}.repository.dart + # acceptable exceptions for the time being + - integration_test/test_utils/general_helper.dart + - lib/main.dart + - lib/routing/router.dart + - lib/utils/{db,image_url_builder,migration,renderlist_generator}.dart + - test/**.dart + # refactor to make the providers and services testable + - lib/pages/common/{album_asset_selection,gallery_viewer}.page.dart + - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart + - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart + - lib/services/{asset,asset_description,background,backup,backup_verification,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart + - lib/widgets/asset_grid/{asset_grid_data_structure,thumbnail_image}.dart + - import_rule_openapi: message: openapi must only be used through ApiRepositories restrict: package:openapi diff --git a/mobile/lib/pages/library/favorite.page.dart b/mobile/lib/pages/library/favorite.page.dart index 7462dc8f21..cc422f88c7 100644 --- a/mobile/lib/pages/library/favorite.page.dart +++ b/mobile/lib/pages/library/favorite.page.dart @@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/favorite_provider.dart'; +import 'package:immich_mobile/providers/favorite.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; diff --git a/mobile/lib/providers/favorite_provider.dart b/mobile/lib/providers/favorite.provider.dart similarity index 100% rename from mobile/lib/providers/favorite_provider.dart rename to mobile/lib/providers/favorite.provider.dart From 9abfa6940ca09ec3aa069b74f50a6a67c61e063e Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Mon, 23 Sep 2024 06:11:23 +0200 Subject: [PATCH 410/723] docs: mobile architecture diagram (#12841) --- docs/docs/developer/architecture.mdx | 10 +- .../img/immich_mobile_architecture.drawio | 104 ++++++++++++++++++ .../img/immich_mobile_architecture.svg | 3 + 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 docs/docs/developer/img/immich_mobile_architecture.drawio create mode 100644 docs/docs/developer/img/immich_mobile_architecture.svg diff --git a/docs/docs/developer/architecture.mdx b/docs/docs/developer/architecture.mdx index cf004a1119..7b5debef4c 100644 --- a/docs/docs/developer/architecture.mdx +++ b/docs/docs/developer/architecture.mdx @@ -3,6 +3,7 @@ sidebar_position: 1 --- import AppArchitecture from './img/app-architecture.png'; +import MobileArchitecture from './img/immich_mobile_architecture.svg'; # Architecture @@ -28,7 +29,14 @@ All three clients use [OpenAPI](./open-api.md) to auto-generate rest clients for ### Mobile App -The mobile app is written in [Flutter](https://flutter.dev/). It uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management. +The mobile app is written in [Dart](https://dart.dev/) using [Flutter](https://flutter.dev/). Below is an architecture overview: + + + +The diagrams shows the target architecture, the current state of the code-base is not always following the architecture yet. New code and contributions should follow this architecture. +Currently, it uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers). +Entities and Models are the two types of data classes used. While entities are stored in the on-device database, models are ephemeral and only kept in memory. +The Repositories should be the only place where other data classes are used internally (such as OpenAPI DTOs). However, their interfaces must not use foreign data classes! ### Web Client diff --git a/docs/docs/developer/img/immich_mobile_architecture.drawio b/docs/docs/developer/img/immich_mobile_architecture.drawio new file mode 100644 index 0000000000..548cda0938 --- /dev/null +++ b/docs/docs/developer/img/immich_mobile_architecture.drawio @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/docs/developer/img/immich_mobile_architecture.svg b/docs/docs/developer/img/immich_mobile_architecture.svg new file mode 100644 index 0000000000..71f28235bf --- /dev/null +++ b/docs/docs/developer/img/immich_mobile_architecture.svg @@ -0,0 +1,3 @@ + + +
    Mobile App
    Mobile App
    Services
    Services
    Repositories
    Repositories
    Providers
    Providers
    Pages
    Pages
    Widgets
    Widgets
    User
    User
    platform
    system
    platform...
    on-device
    database
    on-device...
    server
    server
    OpenAPI
    OpenAPI
    UI part
    UI part
    non-UI part
    non-UI part
    Models
    Models
    Entities
    Entities
    \ No newline at end of file From 147747de32a7362db842d95433a4ab1688eece92 Mon Sep 17 00:00:00 2001 From: kurama <52566613+zp33dy@users.noreply.github.com> Date: Mon, 23 Sep 2024 09:40:23 +0200 Subject: [PATCH 411/723] docs: add section for Traefik Reverse Proxy (#12813) * added a section for the Traefik Proxy * minimized the configs * replaced config with a comment. * Update docs/docs/administration/reverse-proxy.md changed timeout values Co-authored-by: dvbthien <89862334+dvbthien@users.noreply.github.com> * changed timeouts back to 10 minutes * fixed typo and set default writeTimeout 600s Leaving it at 0 may be also bad practice * removed whitespace * run `npm run format -- --check -w` --------- Co-authored-by: dvbthien <89862334+dvbthien@users.noreply.github.com> --- docs/docs/administration/reverse-proxy.md | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/docs/administration/reverse-proxy.md b/docs/docs/administration/reverse-proxy.md index 1d2488f119..c40fecbdc4 100644 --- a/docs/docs/administration/reverse-proxy.md +++ b/docs/docs/administration/reverse-proxy.md @@ -64,3 +64,43 @@ Below is an example config for Apache2 site configuration. ProxyPreserveHost On ``` + +### Traefik Proxy example config + +The example below is for Traefik version 3. + +The most important is to increase the `respondingTimeouts` of the entrypoint used by immich. In this example of entrypoint `websecure` for port `443`. Per default it's set to 60s which leeds to videos stop uploading after 1 minute (Error Code 499). With this config it will fail after 10 minutes which is in most cases enough. Increase it if needed. + +`traefik.yaml` + +```yaml +[...] +entryPoints: + websecure: + address: :443 + # this section needs to be added + transport: + respondingTimeouts: + readTimeout: 600s + idleTimeout: 600s + writeTimeout: 600s +``` + +The second part is in the `docker-compose.yml` file where immich is in. Add the Traefik specific labels like in the example. + +`docker-compose.yml` + +```yaml +services: + immich-server: + [...] + labels: + traefik.enable: true + # increase readingTimeouts for the entrypoint used here + traefik.http.routers.immich.entrypoints: websecure + traefik.http.routers.immich.rule: Host(`immich.your-domain.com`) + traefik.http.services.immich.loadbalancer.server.port: 3001 +``` + +Keep in mind, that Traefik needs to communicate with the network where immich is in, usually done +by adding the Traefik network to the `immich-server`. From b1cdf73a2425cf789aff1e3ab874e05d377dfe0f Mon Sep 17 00:00:00 2001 From: Nuno Antunes Date: Mon, 23 Sep 2024 08:50:18 +0100 Subject: [PATCH 412/723] feat(server): validate rating (#12855) * feat(server): validate exif rating tag * fix(server): change allowed range for rating * refactor: better readibility * docs: comments * remove log line --- server/src/services/metadata.service.spec.ts | 24 ++++++++++++++++++++ server/src/services/metadata.service.ts | 14 +++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 4eac4a4cf9..ad01aa5784 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1107,6 +1107,30 @@ describe(MetadataService.name, () => { }), ); }); + + it('should handle invalid rating value', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Rating: 6 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: null, + }), + ); + }); + + it('should handle valid rating value', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Rating: 5 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: 5, + }), + ); + }); }); describe('handleQueueSidecar', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 60a1e12a5a..bf76be0731 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -83,6 +83,18 @@ const validate = (value: T): NonNullable | null => { return value ?? null; }; +const validateRange = (value: number | undefined, min: number, max: number): NonNullable | null => { + // reutilizes the validate function + const val = validate(value); + + // check if the value is within the range + if (val == null || val < min || val > max) { + return null; + } + + return val; +}; + @Injectable() export class MetadataService { private storageCore: StorageCore; @@ -261,7 +273,7 @@ export class MetadataService { // comments description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), profileDescription: exifTags.ProfileDescription || null, - rating: exifTags.Rating ?? null, + rating: validateRange(exifTags.Rating, 0, 5), // grouping livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, From 0cce7ebf25b8684709ff4a270b74ab1b1f097bec Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 23 Sep 2024 11:16:25 -0400 Subject: [PATCH 413/723] fix: web e2e (#12869) --- e2e/docker-compose.yml | 5 ----- e2e/playwright.config.ts | 4 +++- e2e/src/setup/docker-compose.ts | 3 ++- e2e/src/utils.ts | 3 +-- server/src/services/storage.service.ts | 5 +++-- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index dbb95f176d..6169a4bfa1 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -22,7 +22,6 @@ services: - IMMICH_METRICS=true - IMMICH_ENV=testing volumes: - - upload:/usr/src/app/upload - ./test-assets:/test-assets extra_hosts: - 'auth-server:host-gateway' @@ -44,7 +43,3 @@ services: POSTGRES_DB: immich ports: - 5435:5432 - -volumes: - model-cache: - upload: diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 55032bd364..2576a2c5c9 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -53,8 +53,10 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'docker compose up --build -V --remove-orphans', + command: 'docker compose up --build --renew-anon-volumes --force-recreate --remove-orphans', url: 'http://127.0.0.1:2285', + stdout: 'pipe', + stderr: 'pipe', reuseExistingServer: true, }, }); diff --git a/e2e/src/setup/docker-compose.ts b/e2e/src/setup/docker-compose.ts index 3ae87417a2..49a702e776 100644 --- a/e2e/src/setup/docker-compose.ts +++ b/e2e/src/setup/docker-compose.ts @@ -12,7 +12,8 @@ const setup = async () => { const timeout = setTimeout(() => _reject(new Error('Timeout starting e2e environment')), 60_000); - const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' }); + const command = 'compose up --build --renew-anon-volumes --force-recreate --remove-orphans'; + const child = spawn('docker', command.split(' '), { stdio: 'pipe' }); child.stdout.on('data', (data) => { const input = data.toString(); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index c67e569697..3c9d4284ce 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -156,8 +156,7 @@ export const utils = { for (const table of tables) { if (table === 'system_metadata') { - // prevent reverse geocoder from being re-initialized - sql.push(`DELETE FROM "system_metadata" where "key" != 'reverse-geocoding-state';`); + sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`); } else { sql.push(`DELETE FROM ${table} CASCADE;`); } diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 15328b0c21..1591149dc2 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -25,12 +25,13 @@ export class StorageService { async onBootstrap() { await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; + const enabled = flags.mountFiles ?? false; - this.logger.log('Verifying system mount folder checks'); + this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`); // check each folder exists and is writable for (const folder of Object.values(StorageFolder)) { - if (!flags.mountFiles) { + if (!enabled) { this.logger.log(`Writing initial mount file for the ${folder} folder`); await this.createMountFile(folder); } From 9a4a320cfb82b2cf5a7e273801e4955452a4e524 Mon Sep 17 00:00:00 2001 From: Caesiumhydroxid Date: Mon, 23 Sep 2024 17:38:50 +0200 Subject: [PATCH 414/723] fix(web): Fix same key for delete and stack actions (#12865) Fix same key for delete and stack actions --- .../duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5207cf8445..e1029b7ccb 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -42,7 +42,7 @@ { key: ['s'], action: $t('view') }, { key: ['d'], action: $t('unselect_all_duplicates') }, { key: ['⇧', 'c'], action: $t('resolve_duplicates') }, - { key: ['⇧', 'c'], action: $t('stack_duplicates') }, + { key: ['⇧', 's'], action: $t('stack_duplicates') }, ], }; From a7719a94fcac4e52edefe482a7661019708fde53 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 23 Sep 2024 17:40:25 +0200 Subject: [PATCH 415/723] fix: normalize external domain (#12831) chore: normalize external domain --- server/src/cores/system-config.core.ts | 4 ++++ .../src/services/system-config.service.spec.ts | 17 +++++++++++++++++ web/src/lib/utils.ts | 6 +----- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index 7c1434004a..8ed53344cc 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -120,6 +120,10 @@ export class SystemConfigCore { } } + if (config.server.externalDomain.length > 0) { + config.server.externalDomain = new URL(config.server.externalDomain).origin; + } + if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 409cd6a52f..7e25e0cd46 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -289,6 +289,23 @@ describe(SystemConfigService.name, () => { expect(config.machineLearning.url).toEqual('immich_machine_learning'); }); + const externalDomainTests = [ + { should: 'with a trailing slash', externalDomain: 'https://demo.immich.app/' }, + { should: 'without a trailing slash', externalDomain: 'https://demo.immich.app' }, + { should: 'with a port', externalDomain: 'https://demo.immich.app:42', result: 'https://demo.immich.app:42' }, + ]; + + for (const { should, externalDomain, result } of externalDomainTests) { + it(`should normalize an external domain ${should}`, async () => { + process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + const partialConfig = { server: { externalDomain } }; + systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + + const config = await sut.getConfig(); + expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); + }); + } + it('should warn for unknown options in yaml', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml'; const partialConfig = ` diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 29c7552d0c..dccb03c9bf 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -257,11 +257,7 @@ export const copyToClipboard = async (secret: string) => { }; export const makeSharedLinkUrl = (externalDomain: string, key: string) => { - let url = externalDomain || window.location.origin; - if (!url.endsWith('/')) { - url += '/'; - } - return `${url}share/${key}`; + return new URL(`share/${key}`, externalDomain || window.location.origin).href; }; export const oauth = { From 9f8a7e0beac3615fd2b7b3e2f8cbb4d91448e238 Mon Sep 17 00:00:00 2001 From: jschwalbe Date: Mon, 23 Sep 2024 12:09:26 -0400 Subject: [PATCH 416/723] feat(server): sort assets randomly from the API 'api/search/metadata' endpoint by including 'order': 'rand' in the API call. (#12741) feat(server): search metadata random sort order Co-authored-by: Jason Rasmussen --- mobile/openapi/README.md | 3 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/assets_api.dart | 7 +- mobile/openapi/lib/api/deprecated_api.dart | 59 ++ mobile/openapi/lib/api/search_api.dart | 47 ++ mobile/openapi/lib/api_client.dart | 2 + .../openapi/lib/model/random_search_dto.dart | 583 ++++++++++++++++++ open-api/immich-openapi-specs.json | 176 +++++- open-api/typescript-sdk/src/fetch-client.ts | 49 ++ server/src/controllers/asset.controller.ts | 2 + server/src/controllers/search.controller.ts | 8 + server/src/dtos/search.dto.ts | 16 +- server/src/interfaces/search.interface.ts | 1 + server/src/repositories/search.repository.ts | 7 +- server/src/services/search.service.ts | 17 + 15 files changed, 967 insertions(+), 11 deletions(-) create mode 100644 mobile/openapi/lib/model/random_search_dto.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 697239fa44..c8135519de 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -116,6 +116,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *DeprecatedApi* | [**getPersonAssets**](doc//DeprecatedApi.md#getpersonassets) | **GET** /people/{id}/assets | +*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | @@ -172,6 +173,7 @@ Class | Method | HTTP request | Description *SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata | *SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | *SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | +*SearchApi* | [**searchRandom**](doc//SearchApi.md#searchrandom) | **POST** /search/random | *SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart | *ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license | *ServerApi* | [**getAboutInfo**](doc//ServerApi.md#getaboutinfo) | **GET** /server/about | @@ -379,6 +381,7 @@ Class | Method | HTTP request | Description - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueStatusDto](doc//QueueStatusDto.md) + - [RandomSearchDto](doc//RandomSearchDto.md) - [RatingsResponse](doc//RatingsResponse.md) - [RatingsUpdate](doc//RatingsUpdate.md) - [ReactionLevel](doc//ReactionLevel.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8a1655d35a..7fa06b0487 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -192,6 +192,7 @@ part 'model/places_response_dto.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; part 'model/queue_status_dto.dart'; +part 'model/random_search_dto.dart'; part 'model/ratings_response.dart'; part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index ceba3574cd..bd1d5b8484 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -449,7 +449,10 @@ class AssetsApi { return null; } - /// Performs an HTTP 'GET /assets/random' operation and returns the [Response]. + /// This property was deprecated in v1.116.0 + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [num] count: @@ -482,6 +485,8 @@ class AssetsApi { ); } + /// This property was deprecated in v1.116.0 + /// /// Parameters: /// /// * [num] count: diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index 96cb3c2ef0..bc8f50092a 100644 --- a/mobile/openapi/lib/api/deprecated_api.dart +++ b/mobile/openapi/lib/api/deprecated_api.dart @@ -71,4 +71,63 @@ class DeprecatedApi { } return null; } + + /// This property was deprecated in v1.116.0 + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [num] count: + Future getRandomWithHttpInfo({ num? count, }) async { + // ignore: prefer_const_declarations + final path = r'/assets/random'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (count != null) { + queryParams.addAll(_queryParams('', 'count', count)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This property was deprecated in v1.116.0 + /// + /// Parameters: + /// + /// * [num] count: + Future?> getRandom({ num? count, }) async { + final response = await getRandomWithHttpInfo( count: count, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } } diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 4b6cdfea78..3b981e0ccb 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -351,6 +351,53 @@ class SearchApi { return null; } + /// Performs an HTTP 'POST /search/random' operation and returns the [Response]. + /// Parameters: + /// + /// * [RandomSearchDto] randomSearchDto (required): + Future searchRandomWithHttpInfo(RandomSearchDto randomSearchDto,) async { + // ignore: prefer_const_declarations + final path = r'/search/random'; + + // ignore: prefer_final_locals + Object? postBody = randomSearchDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [RandomSearchDto] randomSearchDto (required): + Future searchRandom(RandomSearchDto randomSearchDto,) async { + final response = await searchRandomWithHttpInfo(randomSearchDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchResponseDto',) as SearchResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /search/smart' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4976c8a75f..597a15d5b0 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -439,6 +439,8 @@ class ApiClient { return PurchaseUpdate.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); + case 'RandomSearchDto': + return RandomSearchDto.fromJson(value); case 'RatingsResponse': return RatingsResponse.fromJson(value); case 'RatingsUpdate': diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart new file mode 100644 index 0000000000..8dbbeb5387 --- /dev/null +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -0,0 +1,583 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class RandomSearchDto { + /// Returns a new [RandomSearchDto] instance. + RandomSearchDto({ + this.city, + this.country, + this.createdAfter, + this.createdBefore, + this.deviceId, + this.isArchived, + this.isEncoded, + this.isFavorite, + this.isMotion, + this.isNotInAlbum, + this.isOffline, + this.isVisible, + this.lensModel, + this.libraryId, + this.make, + this.model, + this.page, + this.personIds = const [], + this.size, + this.state, + this.takenAfter, + this.takenBefore, + this.trashedAfter, + this.trashedBefore, + this.type, + this.updatedAfter, + this.updatedBefore, + this.withArchived = false, + this.withDeleted, + this.withExif, + this.withPeople, + this.withStacked, + }); + + String? city; + + String? country; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? createdAfter; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? createdBefore; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? deviceId; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isArchived; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isEncoded; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isMotion; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isNotInAlbum; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isOffline; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isVisible; + + String? lensModel; + + String? libraryId; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? make; + + String? model; + + /// Minimum value: 1 + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? page; + + List personIds; + + /// Minimum value: 1 + /// Maximum value: 1000 + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? size; + + String? state; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? takenAfter; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? takenBefore; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? trashedAfter; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? trashedBefore; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AssetTypeEnum? type; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? updatedAfter; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? updatedBefore; + + bool withArchived; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withDeleted; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withExif; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withPeople; + + /// + /// 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 + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withStacked; + + @override + bool operator ==(Object other) => identical(this, other) || other is RandomSearchDto && + other.city == city && + other.country == country && + other.createdAfter == createdAfter && + other.createdBefore == createdBefore && + other.deviceId == deviceId && + other.isArchived == isArchived && + other.isEncoded == isEncoded && + other.isFavorite == isFavorite && + other.isMotion == isMotion && + other.isNotInAlbum == isNotInAlbum && + other.isOffline == isOffline && + other.isVisible == isVisible && + other.lensModel == lensModel && + other.libraryId == libraryId && + other.make == make && + other.model == model && + other.page == page && + _deepEquality.equals(other.personIds, personIds) && + other.size == size && + other.state == state && + other.takenAfter == takenAfter && + other.takenBefore == takenBefore && + other.trashedAfter == trashedAfter && + other.trashedBefore == trashedBefore && + other.type == type && + other.updatedAfter == updatedAfter && + other.updatedBefore == updatedBefore && + other.withArchived == withArchived && + other.withDeleted == withDeleted && + other.withExif == withExif && + other.withPeople == withPeople && + other.withStacked == withStacked; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (city == null ? 0 : city!.hashCode) + + (country == null ? 0 : country!.hashCode) + + (createdAfter == null ? 0 : createdAfter!.hashCode) + + (createdBefore == null ? 0 : createdBefore!.hashCode) + + (deviceId == null ? 0 : deviceId!.hashCode) + + (isArchived == null ? 0 : isArchived!.hashCode) + + (isEncoded == null ? 0 : isEncoded!.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + + (isMotion == null ? 0 : isMotion!.hashCode) + + (isNotInAlbum == null ? 0 : isNotInAlbum!.hashCode) + + (isOffline == null ? 0 : isOffline!.hashCode) + + (isVisible == null ? 0 : isVisible!.hashCode) + + (lensModel == null ? 0 : lensModel!.hashCode) + + (libraryId == null ? 0 : libraryId!.hashCode) + + (make == null ? 0 : make!.hashCode) + + (model == null ? 0 : model!.hashCode) + + (page == null ? 0 : page!.hashCode) + + (personIds.hashCode) + + (size == null ? 0 : size!.hashCode) + + (state == null ? 0 : state!.hashCode) + + (takenAfter == null ? 0 : takenAfter!.hashCode) + + (takenBefore == null ? 0 : takenBefore!.hashCode) + + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + + (trashedBefore == null ? 0 : trashedBefore!.hashCode) + + (type == null ? 0 : type!.hashCode) + + (updatedAfter == null ? 0 : updatedAfter!.hashCode) + + (updatedBefore == null ? 0 : updatedBefore!.hashCode) + + (withArchived.hashCode) + + (withDeleted == null ? 0 : withDeleted!.hashCode) + + (withExif == null ? 0 : withExif!.hashCode) + + (withPeople == null ? 0 : withPeople!.hashCode) + + (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, page=$page, 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]'; + + Map toJson() { + final json = {}; + if (this.city != null) { + json[r'city'] = this.city; + } else { + // json[r'city'] = null; + } + if (this.country != null) { + json[r'country'] = this.country; + } else { + // json[r'country'] = null; + } + if (this.createdAfter != null) { + json[r'createdAfter'] = this.createdAfter!.toUtc().toIso8601String(); + } else { + // json[r'createdAfter'] = null; + } + if (this.createdBefore != null) { + json[r'createdBefore'] = this.createdBefore!.toUtc().toIso8601String(); + } else { + // json[r'createdBefore'] = null; + } + if (this.deviceId != null) { + json[r'deviceId'] = this.deviceId; + } else { + // json[r'deviceId'] = null; + } + if (this.isArchived != null) { + json[r'isArchived'] = this.isArchived; + } else { + // json[r'isArchived'] = null; + } + if (this.isEncoded != null) { + json[r'isEncoded'] = this.isEncoded; + } else { + // json[r'isEncoded'] = null; + } + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } + if (this.isMotion != null) { + json[r'isMotion'] = this.isMotion; + } else { + // json[r'isMotion'] = null; + } + if (this.isNotInAlbum != null) { + json[r'isNotInAlbum'] = this.isNotInAlbum; + } else { + // json[r'isNotInAlbum'] = null; + } + if (this.isOffline != null) { + json[r'isOffline'] = this.isOffline; + } else { + // json[r'isOffline'] = null; + } + if (this.isVisible != null) { + json[r'isVisible'] = this.isVisible; + } else { + // json[r'isVisible'] = null; + } + if (this.lensModel != null) { + json[r'lensModel'] = this.lensModel; + } else { + // json[r'lensModel'] = null; + } + if (this.libraryId != null) { + json[r'libraryId'] = this.libraryId; + } else { + // json[r'libraryId'] = null; + } + if (this.make != null) { + json[r'make'] = this.make; + } else { + // json[r'make'] = null; + } + if (this.model != null) { + json[r'model'] = this.model; + } else { + // json[r'model'] = null; + } + if (this.page != null) { + json[r'page'] = this.page; + } else { + // json[r'page'] = null; + } + json[r'personIds'] = this.personIds; + if (this.size != null) { + json[r'size'] = this.size; + } else { + // json[r'size'] = null; + } + if (this.state != null) { + json[r'state'] = this.state; + } else { + // json[r'state'] = null; + } + if (this.takenAfter != null) { + json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); + } else { + // json[r'takenAfter'] = null; + } + if (this.takenBefore != null) { + json[r'takenBefore'] = this.takenBefore!.toUtc().toIso8601String(); + } else { + // json[r'takenBefore'] = null; + } + if (this.trashedAfter != null) { + json[r'trashedAfter'] = this.trashedAfter!.toUtc().toIso8601String(); + } else { + // json[r'trashedAfter'] = null; + } + if (this.trashedBefore != null) { + json[r'trashedBefore'] = this.trashedBefore!.toUtc().toIso8601String(); + } else { + // json[r'trashedBefore'] = null; + } + if (this.type != null) { + json[r'type'] = this.type; + } else { + // json[r'type'] = null; + } + if (this.updatedAfter != null) { + json[r'updatedAfter'] = this.updatedAfter!.toUtc().toIso8601String(); + } else { + // json[r'updatedAfter'] = null; + } + if (this.updatedBefore != null) { + json[r'updatedBefore'] = this.updatedBefore!.toUtc().toIso8601String(); + } else { + // json[r'updatedBefore'] = null; + } + json[r'withArchived'] = this.withArchived; + if (this.withDeleted != null) { + json[r'withDeleted'] = this.withDeleted; + } else { + // json[r'withDeleted'] = null; + } + if (this.withExif != null) { + json[r'withExif'] = this.withExif; + } else { + // json[r'withExif'] = null; + } + if (this.withPeople != null) { + json[r'withPeople'] = this.withPeople; + } else { + // json[r'withPeople'] = null; + } + if (this.withStacked != null) { + json[r'withStacked'] = this.withStacked; + } else { + // json[r'withStacked'] = null; + } + return json; + } + + /// Returns a new [RandomSearchDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static RandomSearchDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return RandomSearchDto( + city: mapValueOfType(json, r'city'), + country: mapValueOfType(json, r'country'), + createdAfter: mapDateTime(json, r'createdAfter', r''), + createdBefore: mapDateTime(json, r'createdBefore', r''), + deviceId: mapValueOfType(json, r'deviceId'), + isArchived: mapValueOfType(json, r'isArchived'), + isEncoded: mapValueOfType(json, r'isEncoded'), + isFavorite: mapValueOfType(json, r'isFavorite'), + isMotion: mapValueOfType(json, r'isMotion'), + isNotInAlbum: mapValueOfType(json, r'isNotInAlbum'), + isOffline: mapValueOfType(json, r'isOffline'), + isVisible: mapValueOfType(json, r'isVisible'), + lensModel: mapValueOfType(json, r'lensModel'), + libraryId: mapValueOfType(json, r'libraryId'), + make: mapValueOfType(json, r'make'), + model: mapValueOfType(json, r'model'), + page: num.parse('${json[r'page']}'), + personIds: json[r'personIds'] is Iterable + ? (json[r'personIds'] as Iterable).cast().toList(growable: false) + : const [], + size: num.parse('${json[r'size']}'), + state: mapValueOfType(json, r'state'), + takenAfter: mapDateTime(json, r'takenAfter', r''), + takenBefore: mapDateTime(json, r'takenBefore', r''), + trashedAfter: mapDateTime(json, r'trashedAfter', r''), + trashedBefore: mapDateTime(json, r'trashedBefore', r''), + type: AssetTypeEnum.fromJson(json[r'type']), + updatedAfter: mapDateTime(json, r'updatedAfter', r''), + updatedBefore: mapDateTime(json, r'updatedBefore', r''), + withArchived: mapValueOfType(json, r'withArchived') ?? false, + withDeleted: mapValueOfType(json, r'withDeleted'), + withExif: mapValueOfType(json, r'withExif'), + withPeople: mapValueOfType(json, r'withPeople'), + withStacked: mapValueOfType(json, r'withStacked'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = RandomSearchDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = RandomSearchDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of RandomSearchDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = RandomSearchDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f48fa989da..706ff5b8fb 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1646,6 +1646,8 @@ }, "/assets/random": { "get": { + "deprecated": true, + "description": "This property was deprecated in v1.116.0", "operationId": "getRandom", "parameters": [ { @@ -1685,8 +1687,12 @@ } ], "tags": [ - "Assets" - ] + "Assets", + "Deprecated" + ], + "x-immich-lifecycle": { + "deprecatedAt": "v1.116.0" + } } }, "/assets/statistics": { @@ -4677,6 +4683,48 @@ ] } }, + "/search/random": { + "post": { + "operationId": "searchRandom", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RandomSearchDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, "/search/smart": { "post": { "operationId": "searchSmart", @@ -10454,6 +10502,130 @@ ], "type": "object" }, + "RandomSearchDto": { + "properties": { + "city": { + "nullable": true, + "type": "string" + }, + "country": { + "nullable": true, + "type": "string" + }, + "createdAfter": { + "format": "date-time", + "type": "string" + }, + "createdBefore": { + "format": "date-time", + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "isArchived": { + "type": "boolean" + }, + "isEncoded": { + "type": "boolean" + }, + "isFavorite": { + "type": "boolean" + }, + "isMotion": { + "type": "boolean" + }, + "isNotInAlbum": { + "type": "boolean" + }, + "isOffline": { + "type": "boolean" + }, + "isVisible": { + "type": "boolean" + }, + "lensModel": { + "nullable": true, + "type": "string" + }, + "libraryId": { + "format": "uuid", + "nullable": true, + "type": "string" + }, + "make": { + "type": "string" + }, + "model": { + "nullable": true, + "type": "string" + }, + "page": { + "minimum": 1, + "type": "number" + }, + "personIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "size": { + "maximum": 1000, + "minimum": 1, + "type": "number" + }, + "state": { + "nullable": true, + "type": "string" + }, + "takenAfter": { + "format": "date-time", + "type": "string" + }, + "takenBefore": { + "format": "date-time", + "type": "string" + }, + "trashedAfter": { + "format": "date-time", + "type": "string" + }, + "trashedBefore": { + "format": "date-time", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/AssetTypeEnum" + }, + "updatedAfter": { + "format": "date-time", + "type": "string" + }, + "updatedBefore": { + "format": "date-time", + "type": "string" + }, + "withArchived": { + "default": false, + "type": "boolean" + }, + "withDeleted": { + "type": "boolean" + }, + "withExif": { + "type": "boolean" + }, + "withPeople": { + "type": "boolean" + }, + "withStacked": { + "type": "boolean" + } + }, + "type": "object" + }, "RatingsResponse": { "properties": { "enabled": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c2d73bda1a..8e607f7570 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -837,6 +837,40 @@ export type PlacesResponseDto = { longitude: number; name: string; }; +export type RandomSearchDto = { + city?: string | null; + country?: string | null; + createdAfter?: string; + createdBefore?: string; + deviceId?: string; + isArchived?: boolean; + isEncoded?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isNotInAlbum?: boolean; + isOffline?: boolean; + isVisible?: boolean; + lensModel?: string | null; + libraryId?: string | null; + make?: string; + model?: string | null; + page?: number; + personIds?: string[]; + size?: number; + state?: string | null; + takenAfter?: string; + takenBefore?: string; + trashedAfter?: string; + trashedBefore?: string; + "type"?: AssetTypeEnum; + updatedAfter?: string; + updatedBefore?: string; + withArchived?: boolean; + withDeleted?: boolean; + withExif?: boolean; + withPeople?: boolean; + withStacked?: boolean; +}; export type SmartSearchDto = { city?: string | null; country?: string | null; @@ -1696,6 +1730,9 @@ export function getMemoryLane({ day, month }: { ...opts })); } +/** + * This property was deprecated in v1.116.0 + */ export function getRandom({ count }: { count?: number; }, opts?: Oazapfts.RequestOpts) { @@ -2500,6 +2537,18 @@ export function searchPlaces({ name }: { ...opts })); } +export function searchRandom({ randomSearchDto }: { + randomSearchDto: RandomSearchDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SearchResponseDto; + }>("/search/random", oazapfts.json({ + ...opts, + method: "POST", + body: randomSearchDto + }))); +} export function searchSmart({ smartSearchDto }: { smartSearchDto: SmartSearchDto; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index c6fdac1710..9d3d230657 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { EndpointLifecycle } from 'src/decorators'; import { AssetResponseDto, MemoryLaneResponseDto } from 'src/dtos/asset-response.dto'; import { AssetBulkDeleteDto, @@ -31,6 +32,7 @@ export class AssetController { @Get('random') @Authenticated() + @EndpointLifecycle({ deprecatedAt: 'v1.116.0' }) getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise { return this.service.getRandom(auth, dto.count ?? 1); } diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 5b8c1eeece..5b6deb2981 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto'; import { MetadataSearchDto, PlacesResponseDto, + RandomSearchDto, SearchExploreResponseDto, SearchPeopleDto, SearchPlacesDto, @@ -28,6 +29,13 @@ export class SearchController { return this.service.searchMetadata(auth, dto); } + @Post('random') + @HttpCode(HttpStatus.OK) + @Authenticated() + searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { + return this.service.searchRandom(auth, dto); + } + @Post('smart') @HttpCode(HttpStatus.OK) @Authenticated() diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 9e36cfee80..ddc6c192c5 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -119,7 +119,15 @@ class BaseSearchDto { personIds?: string[]; } -export class MetadataSearchDto extends BaseSearchDto { +export class RandomSearchDto extends BaseSearchDto { + @ValidateBoolean({ optional: true }) + withStacked?: boolean; + + @ValidateBoolean({ optional: true }) + withPeople?: boolean; +} + +export class MetadataSearchDto extends RandomSearchDto { @ValidateUUID({ optional: true }) id?: string; @@ -133,12 +141,6 @@ export class MetadataSearchDto extends BaseSearchDto { @Optional() checksum?: string; - @ValidateBoolean({ optional: true }) - withStacked?: boolean; - - @ValidateBoolean({ optional: true }) - withPeople?: boolean; - @IsString() @IsNotEmpty() @Optional() diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 6578d0a483..0ba524c00a 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -116,6 +116,7 @@ export interface SearchPeopleOptions { export interface SearchOrderOptions { orderDirection?: 'ASC' | 'DESC'; + random?: boolean; } export interface SearchPaginationOptions { diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 999e9063ef..8115c72cf6 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -73,8 +73,13 @@ export class SearchRepository implements ISearchRepository { async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { let builder = this.assetRepository.createQueryBuilder('asset'); builder = searchAssetBuilder(builder, options); - builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); + + if (options.random) { + // TODO replace with complicated SQL magic after kysely migration + builder.addSelect('RANDOM() as r').orderBy('r'); + } + return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, skip: (pagination.page - 1) * pagination.size, diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 73ace233d0..dc6e71f345 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto'; import { MetadataSearchDto, PlacesResponseDto, + RandomSearchDto, SearchPeopleDto, SearchPlacesDto, SearchResponseDto, @@ -93,6 +94,22 @@ export class SearchService { return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } + async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { + const userIds = await this.getUserIdsToSearch(auth); + const page = dto.page ?? 1; + const size = dto.size || 250; + const { hasNextPage, items } = await this.searchRepository.searchMetadata( + { page, size }, + { + ...dto, + userIds, + random: true, + }, + ); + + return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); + } + async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { const { machineLearning } = await this.configCore.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) { From e748945b4f3ba06c5f615ad93d20b113e6ed5ee9 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 23 Sep 2024 13:22:36 -0400 Subject: [PATCH 417/723] fix(server): gracefully handle unknown jobs (#12870) --- server/src/services/job.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 03a6edf126..5ed9f32024 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -186,11 +186,16 @@ export class JobService { this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise => { const { name, data } = item; + const handler = jobHandlers[name]; + if (!handler) { + this.logger.warn(`Skipping unknown job: "${name}"`); + return; + } + const queueMetric = `immich.queues.${snakeCase(queueName)}.active`; this.metricRepository.jobs.addToGauge(queueMetric, 1); try { - const handler = jobHandlers[name]; const status = await handler(data); const jobMetric = `immich.jobs.${name.replaceAll('-', '_')}.${status}`; this.metricRepository.jobs.addToCounter(jobMetric, 1); From 87c54d6659a73916a2c3133966b00b5b78b4e408 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 23 Sep 2024 19:37:08 +0200 Subject: [PATCH 418/723] fix: show asset count for unassigned faces (#12871) --- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 83019d67cd..037feaf35f 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -486,17 +486,10 @@
    - {#if person.name} -

    {person.name}

    -

    - {$t('assets_count', { values: { count: numberOfAssets } })} -

    - {:else} -

    {$t('add_a_name')}

    -

    - {$t('find_them_fast')} -

    - {/if} +

    {person.name || $t('add_a_name')}

    +

    + {$t('assets_count', { values: { count: numberOfAssets } })} +

    From 3008050e4c71ea6ea2be9f0831ea19b24fd37500 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 23 Sep 2024 13:51:03 -0400 Subject: [PATCH 419/723] fix: remove no longer needed LD_LIBRARY_PATH (#12872) --- docker/hwaccel.transcoding.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker/hwaccel.transcoding.yml b/docker/hwaccel.transcoding.yml index bd4e2a46b8..33fb7b3c06 100644 --- a/docker/hwaccel.transcoding.yml +++ b/docker/hwaccel.transcoding.yml @@ -51,5 +51,4 @@ services: volumes: - /usr/lib/wsl:/usr/lib/wsl environment: - - LD_LIBRARY_PATH=/usr/lib/wsl/lib - LIBVA_DRIVER_NAME=d3d12 From ad33ce5938c34edc7885b4244cef83edb09e39d5 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 23 Sep 2024 15:41:41 -0400 Subject: [PATCH 420/723] refactor(mobile): open api dto upgrade (#12793) --- mobile/openapi/lib/api_client.dart | 1 - .../lib/model/activity_create_dto.dart | 1 + .../lib/model/activity_response_dto.dart | 1 + .../activity_statistics_response_dto.dart | 1 + mobile/openapi/lib/model/add_users_dto.dart | 1 + .../model/admin_onboarding_update_dto.dart | 1 + .../openapi/lib/model/album_response_dto.dart | 1 + .../model/album_statistics_response_dto.dart | 1 + .../openapi/lib/model/album_user_add_dto.dart | 1 + .../lib/model/album_user_create_dto.dart | 1 + .../lib/model/album_user_response_dto.dart | 1 + .../model/all_job_status_response_dto.dart | 1 + .../openapi/lib/model/api_key_create_dto.dart | 1 + .../model/api_key_create_response_dto.dart | 1 + .../lib/model/api_key_response_dto.dart | 1 + .../openapi/lib/model/api_key_update_dto.dart | 1 + .../lib/model/asset_bulk_delete_dto.dart | 1 + .../lib/model/asset_bulk_update_dto.dart | 1 + .../model/asset_bulk_upload_check_dto.dart | 1 + .../model/asset_bulk_upload_check_item.dart | 1 + .../asset_bulk_upload_check_response_dto.dart | 1 + .../model/asset_bulk_upload_check_result.dart | 1 + .../lib/model/asset_delta_sync_dto.dart | 1 + .../model/asset_delta_sync_response_dto.dart | 1 + .../lib/model/asset_face_response_dto.dart | 1 + .../lib/model/asset_face_update_dto.dart | 1 + .../lib/model/asset_face_update_item.dart | 1 + ...sset_face_without_person_response_dto.dart | 1 + .../lib/model/asset_full_sync_dto.dart | 1 + mobile/openapi/lib/model/asset_ids_dto.dart | 1 + .../lib/model/asset_ids_response_dto.dart | 1 + mobile/openapi/lib/model/asset_jobs_dto.dart | 1 + .../lib/model/asset_media_response_dto.dart | 1 + .../openapi/lib/model/asset_response_dto.dart | 1 + .../lib/model/asset_stack_response_dto.dart | 1 + .../lib/model/asset_stats_response_dto.dart | 1 + .../lib/model/audit_deletes_response_dto.dart | 1 + mobile/openapi/lib/model/avatar_response.dart | 1 + mobile/openapi/lib/model/avatar_update.dart | 1 + .../lib/model/bulk_id_response_dto.dart | 1 + mobile/openapi/lib/model/bulk_ids_dto.dart | 1 + .../lib/model/change_password_dto.dart | 1 + .../lib/model/check_existing_assets_dto.dart | 1 + .../check_existing_assets_response_dto.dart | 1 + mobile/openapi/lib/model/clip_config.dart | 1 + .../openapi/lib/model/create_album_dto.dart | 1 + .../openapi/lib/model/create_library_dto.dart | 1 + .../create_profile_image_response_dto.dart | 1 + .../lib/model/download_archive_info.dart | 1 + .../openapi/lib/model/download_info_dto.dart | 1 + .../openapi/lib/model/download_response.dart | 1 + .../lib/model/download_response_dto.dart | 1 + mobile/openapi/lib/model/download_update.dart | 1 + .../lib/model/duplicate_detection_config.dart | 1 + .../lib/model/duplicate_response_dto.dart | 1 + .../model/email_notifications_response.dart | 1 + .../lib/model/email_notifications_update.dart | 1 + .../openapi/lib/model/exif_response_dto.dart | 1 + mobile/openapi/lib/model/face_dto.dart | 1 + .../lib/model/facial_recognition_config.dart | 1 + .../openapi/lib/model/file_checksum_dto.dart | 1 + .../lib/model/file_checksum_response_dto.dart | 1 + mobile/openapi/lib/model/file_report_dto.dart | 1 + .../lib/model/file_report_fix_dto.dart | 1 + .../lib/model/file_report_item_dto.dart | 1 + .../openapi/lib/model/folders_response.dart | 1 + mobile/openapi/lib/model/folders_update.dart | 1 + mobile/openapi/lib/model/job_command_dto.dart | 1 + mobile/openapi/lib/model/job_counts_dto.dart | 1 + mobile/openapi/lib/model/job_create_dto.dart | 1 + .../openapi/lib/model/job_settings_dto.dart | 1 + mobile/openapi/lib/model/job_status_dto.dart | 1 + .../lib/model/library_response_dto.dart | 1 + .../lib/model/library_stats_response_dto.dart | 1 + mobile/openapi/lib/model/license_key_dto.dart | 1 + .../lib/model/license_response_dto.dart | 1 + .../lib/model/login_credential_dto.dart | 1 + .../openapi/lib/model/login_response_dto.dart | 1 + .../lib/model/logout_response_dto.dart | 1 + .../lib/model/map_marker_response_dto.dart | 1 + .../map_reverse_geocode_response_dto.dart | 1 + .../openapi/lib/model/memories_response.dart | 1 + mobile/openapi/lib/model/memories_update.dart | 1 + .../openapi/lib/model/memory_create_dto.dart | 1 + .../lib/model/memory_lane_response_dto.dart | 1 + .../lib/model/memory_response_dto.dart | 1 + .../openapi/lib/model/memory_update_dto.dart | 1 + .../openapi/lib/model/merge_person_dto.dart | 1 + .../lib/model/metadata_search_dto.dart | 1 + .../model/o_auth_authorize_response_dto.dart | 1 + .../lib/model/o_auth_callback_dto.dart | 1 + .../openapi/lib/model/o_auth_config_dto.dart | 1 + mobile/openapi/lib/model/on_this_day_dto.dart | 1 + .../lib/model/partner_response_dto.dart | 1 + mobile/openapi/lib/model/people_response.dart | 1 + .../lib/model/people_response_dto.dart | 1 + mobile/openapi/lib/model/people_update.dart | 1 + .../openapi/lib/model/people_update_dto.dart | 1 + .../openapi/lib/model/people_update_item.dart | 1 + .../openapi/lib/model/person_create_dto.dart | 1 + .../lib/model/person_response_dto.dart | 1 + .../model/person_statistics_response_dto.dart | 1 + .../openapi/lib/model/person_update_dto.dart | 1 + .../model/person_with_faces_response_dto.dart | 1 + .../lib/model/places_response_dto.dart | 1 + .../openapi/lib/model/purchase_response.dart | 1 + mobile/openapi/lib/model/purchase_update.dart | 1 + .../openapi/lib/model/queue_status_dto.dart | 1 + .../openapi/lib/model/ratings_response.dart | 1 + mobile/openapi/lib/model/ratings_update.dart | 1 + .../reverse_geocoding_state_response_dto.dart | 1 + .../openapi/lib/model/scan_library_dto.dart | 1 + .../lib/model/search_album_response_dto.dart | 1 + .../lib/model/search_asset_response_dto.dart | 1 + .../lib/model/search_explore_item.dart | 1 + .../model/search_explore_response_dto.dart | 1 + .../search_facet_count_response_dto.dart | 1 + .../lib/model/search_facet_response_dto.dart | 1 + .../lib/model/search_response_dto.dart | 1 + .../lib/model/server_about_response_dto.dart | 1 + .../openapi/lib/model/server_config_dto.dart | 1 + .../lib/model/server_features_dto.dart | 1 + .../server_media_types_response_dto.dart | 1 + .../lib/model/server_ping_response.dart | 1 + .../lib/model/server_stats_response_dto.dart | 1 + .../model/server_storage_response_dto.dart | 1 + .../openapi/lib/model/server_theme_dto.dart | 1 + .../model/server_version_response_dto.dart | 1 + .../lib/model/session_response_dto.dart | 1 + .../lib/model/shared_link_create_dto.dart | 1 + .../lib/model/shared_link_edit_dto.dart | 1 + .../lib/model/shared_link_response_dto.dart | 1 + mobile/openapi/lib/model/sign_up_dto.dart | 1 + .../lib/model/smart_info_response_dto.dart | 1 + .../openapi/lib/model/smart_search_dto.dart | 1 + .../openapi/lib/model/stack_create_dto.dart | 1 + .../openapi/lib/model/stack_response_dto.dart | 1 + .../openapi/lib/model/stack_update_dto.dart | 1 + .../openapi/lib/model/system_config_dto.dart | 1 + .../lib/model/system_config_f_fmpeg_dto.dart | 1 + .../lib/model/system_config_faces_dto.dart | 1 + .../lib/model/system_config_image_dto.dart | 1 + .../lib/model/system_config_job_dto.dart | 1 + .../lib/model/system_config_library_dto.dart | 1 + .../model/system_config_library_scan_dto.dart | 1 + .../system_config_library_watch_dto.dart | 1 + .../lib/model/system_config_logging_dto.dart | 1 + .../system_config_machine_learning_dto.dart | 1 + .../lib/model/system_config_map_dto.dart | 1 + .../lib/model/system_config_metadata_dto.dart | 1 + .../system_config_new_version_check_dto.dart | 1 + .../system_config_notifications_dto.dart | 1 + .../lib/model/system_config_o_auth_dto.dart | 1 + .../system_config_password_login_dto.dart | 1 + .../system_config_reverse_geocoding_dto.dart | 1 + .../lib/model/system_config_server_dto.dart | 1 + .../lib/model/system_config_smtp_dto.dart | 1 + .../system_config_smtp_transport_dto.dart | 1 + .../system_config_storage_template_dto.dart | 1 + ...em_config_template_storage_option_dto.dart | 1 + .../lib/model/system_config_theme_dto.dart | 1 + .../lib/model/system_config_trash_dto.dart | 1 + .../lib/model/system_config_user_dto.dart | 1 + .../lib/model/tag_bulk_assets_dto.dart | 1 + .../model/tag_bulk_assets_response_dto.dart | 1 + mobile/openapi/lib/model/tag_create_dto.dart | 1 + .../openapi/lib/model/tag_response_dto.dart | 1 + mobile/openapi/lib/model/tag_update_dto.dart | 1 + mobile/openapi/lib/model/tag_upsert_dto.dart | 1 + mobile/openapi/lib/model/tags_response.dart | 1 + mobile/openapi/lib/model/tags_update.dart | 1 + .../lib/model/time_bucket_response_dto.dart | 1 + .../openapi/lib/model/trash_response_dto.dart | 1 + .../openapi/lib/model/update_album_dto.dart | 1 + .../lib/model/update_album_user_dto.dart | 1 + .../openapi/lib/model/update_asset_dto.dart | 1 + .../openapi/lib/model/update_library_dto.dart | 1 + .../openapi/lib/model/update_partner_dto.dart | 1 + .../openapi/lib/model/usage_by_user_dto.dart | 1 + .../lib/model/user_admin_create_dto.dart | 1 + .../lib/model/user_admin_delete_dto.dart | 1 + .../lib/model/user_admin_response_dto.dart | 1 + .../lib/model/user_admin_update_dto.dart | 1 + mobile/openapi/lib/model/user_license.dart | 1 + .../model/user_preferences_response_dto.dart | 1 + .../model/user_preferences_update_dto.dart | 1 + .../openapi/lib/model/user_response_dto.dart | 1 + .../openapi/lib/model/user_update_me_dto.dart | 1 + .../validate_access_token_response_dto.dart | 1 + .../lib/model/validate_library_dto.dart | 1 + ...date_library_import_path_response_dto.dart | 1 + .../model/validate_library_response_dto.dart | 1 + open-api/bin/generate-open-api.sh | 8 +- open-api/templates/mobile/api_client.mustache | 264 ------------------ .../mobile/api_client.mustache.patch | 10 - .../native/native_class.mustache | 1 + .../native/native_class.mustache.patch | 18 +- 197 files changed, 205 insertions(+), 288 deletions(-) delete mode 100644 open-api/templates/mobile/api_client.mustache delete mode 100644 open-api/templates/mobile/api_client.mustache.patch diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 597a15d5b0..e857f51e3a 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -166,7 +166,6 @@ class ApiClient { /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { - upgradeDto(value, targetType); try { switch (targetType) { case 'String': diff --git a/mobile/openapi/lib/model/activity_create_dto.dart b/mobile/openapi/lib/model/activity_create_dto.dart index b54fa2ca72..ce4b4a0176 100644 --- a/mobile/openapi/lib/model/activity_create_dto.dart +++ b/mobile/openapi/lib/model/activity_create_dto.dart @@ -78,6 +78,7 @@ class ActivityCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityCreateDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/activity_response_dto.dart b/mobile/openapi/lib/model/activity_response_dto.dart index bfffd8485b..25fb0f53f8 100644 --- a/mobile/openapi/lib/model/activity_response_dto.dart +++ b/mobile/openapi/lib/model/activity_response_dto.dart @@ -78,6 +78,7 @@ class ActivityResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/activity_statistics_response_dto.dart b/mobile/openapi/lib/model/activity_statistics_response_dto.dart index 20d4696b1b..ad0b814a58 100644 --- a/mobile/openapi/lib/model/activity_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/activity_statistics_response_dto.dart @@ -40,6 +40,7 @@ class ActivityStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/add_users_dto.dart b/mobile/openapi/lib/model/add_users_dto.dart index 2daa571265..531c1ec785 100644 --- a/mobile/openapi/lib/model/add_users_dto.dart +++ b/mobile/openapi/lib/model/add_users_dto.dart @@ -40,6 +40,7 @@ class AddUsersDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AddUsersDto? fromJson(dynamic value) { + upgradeDto(value, "AddUsersDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart index 2277f0958c..298bf318a2 100644 --- a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart +++ b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart @@ -40,6 +40,7 @@ class AdminOnboardingUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AdminOnboardingUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AdminOnboardingUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index c98a95775d..547a6a70fd 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -186,6 +186,7 @@ class AlbumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_statistics_response_dto.dart b/mobile/openapi/lib/model/album_statistics_response_dto.dart index 90dbe52016..9e19002cf1 100644 --- a/mobile/openapi/lib/model/album_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/album_statistics_response_dto.dart @@ -52,6 +52,7 @@ class AlbumStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_add_dto.dart b/mobile/openapi/lib/model/album_user_add_dto.dart index e654a2ff5d..3f72d5c893 100644 --- a/mobile/openapi/lib/model/album_user_add_dto.dart +++ b/mobile/openapi/lib/model/album_user_add_dto.dart @@ -56,6 +56,7 @@ class AlbumUserAddDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserAddDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserAddDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_create_dto.dart b/mobile/openapi/lib/model/album_user_create_dto.dart index 708acd472b..93a0661b30 100644 --- a/mobile/openapi/lib/model/album_user_create_dto.dart +++ b/mobile/openapi/lib/model/album_user_create_dto.dart @@ -46,6 +46,7 @@ class AlbumUserCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserCreateDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_response_dto.dart b/mobile/openapi/lib/model/album_user_response_dto.dart index 8f86cf254e..bbae03fba7 100644 --- a/mobile/openapi/lib/model/album_user_response_dto.dart +++ b/mobile/openapi/lib/model/album_user_response_dto.dart @@ -46,6 +46,7 @@ class AlbumUserResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/all_job_status_response_dto.dart b/mobile/openapi/lib/model/all_job_status_response_dto.dart index 1ee5253c38..6ec248a638 100644 --- a/mobile/openapi/lib/model/all_job_status_response_dto.dart +++ b/mobile/openapi/lib/model/all_job_status_response_dto.dart @@ -118,6 +118,7 @@ class AllJobStatusResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AllJobStatusResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AllJobStatusResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_create_dto.dart b/mobile/openapi/lib/model/api_key_create_dto.dart index 433855c4cf..848774e9c9 100644 --- a/mobile/openapi/lib/model/api_key_create_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_dto.dart @@ -56,6 +56,7 @@ class APIKeyCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyCreateDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_create_response_dto.dart b/mobile/openapi/lib/model/api_key_create_response_dto.dart index 93065654ac..cdaa70e37d 100644 --- a/mobile/openapi/lib/model/api_key_create_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_response_dto.dart @@ -46,6 +46,7 @@ class APIKeyCreateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyCreateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyCreateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_response_dto.dart b/mobile/openapi/lib/model/api_key_response_dto.dart index b6ca86c050..fd0d91f673 100644 --- a/mobile/openapi/lib/model/api_key_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_response_dto.dart @@ -64,6 +64,7 @@ class APIKeyResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyResponseDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_update_dto.dart b/mobile/openapi/lib/model/api_key_update_dto.dart index 318f4936e1..7295d1ea1f 100644 --- a/mobile/openapi/lib/model/api_key_update_dto.dart +++ b/mobile/openapi/lib/model/api_key_update_dto.dart @@ -40,6 +40,7 @@ class APIKeyUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_delete_dto.dart b/mobile/openapi/lib/model/asset_bulk_delete_dto.dart index 0f6913a7f4..c4453054b1 100644 --- a/mobile/openapi/lib/model/asset_bulk_delete_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_delete_dto.dart @@ -56,6 +56,7 @@ class AssetBulkDeleteDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkDeleteDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index c9b21683fb..da23d2f09d 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -148,6 +148,7 @@ class AssetBulkUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart index 55ea41b598..36c13bfdf6 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart @@ -40,6 +40,7 @@ class AssetBulkUploadCheckDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart index 16294cdae6..13dfa340fa 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart @@ -47,6 +47,7 @@ class AssetBulkUploadCheckItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckItem? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart index 5bfacbff57..8c3651e9fa 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart @@ -40,6 +40,7 @@ class AssetBulkUploadCheckResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart index a016b357e7..88e46dae7d 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart @@ -88,6 +88,7 @@ class AssetBulkUploadCheckResult { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckResult? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckResult"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_delta_sync_dto.dart b/mobile/openapi/lib/model/asset_delta_sync_dto.dart index a5ee10f33e..845aadcdcd 100644 --- a/mobile/openapi/lib/model/asset_delta_sync_dto.dart +++ b/mobile/openapi/lib/model/asset_delta_sync_dto.dart @@ -46,6 +46,7 @@ class AssetDeltaSyncDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetDeltaSyncDto? fromJson(dynamic value) { + upgradeDto(value, "AssetDeltaSyncDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart b/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart index 3b14fa68cf..a64e1a2fbe 100644 --- a/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart +++ b/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart @@ -52,6 +52,7 @@ class AssetDeltaSyncResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetDeltaSyncResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetDeltaSyncResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_response_dto.dart b/mobile/openapi/lib/model/asset_face_response_dto.dart index 7a8588ce5c..c05b511649 100644 --- a/mobile/openapi/lib/model/asset_face_response_dto.dart +++ b/mobile/openapi/lib/model/asset_face_response_dto.dart @@ -102,6 +102,7 @@ class AssetFaceResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_update_dto.dart b/mobile/openapi/lib/model/asset_face_update_dto.dart index 58def49ae1..71bdde8e9a 100644 --- a/mobile/openapi/lib/model/asset_face_update_dto.dart +++ b/mobile/openapi/lib/model/asset_face_update_dto.dart @@ -40,6 +40,7 @@ class AssetFaceUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_update_item.dart b/mobile/openapi/lib/model/asset_face_update_item.dart index 5ea37ea4db..c2c4803259 100644 --- a/mobile/openapi/lib/model/asset_face_update_item.dart +++ b/mobile/openapi/lib/model/asset_face_update_item.dart @@ -46,6 +46,7 @@ class AssetFaceUpdateItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceUpdateItem? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceUpdateItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart index ecfe06bd7d..8bf07e1534 100644 --- a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart +++ b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart @@ -92,6 +92,7 @@ class AssetFaceWithoutPersonResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceWithoutPersonResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceWithoutPersonResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_full_sync_dto.dart b/mobile/openapi/lib/model/asset_full_sync_dto.dart index e80638f6b0..7151094b95 100644 --- a/mobile/openapi/lib/model/asset_full_sync_dto.dart +++ b/mobile/openapi/lib/model/asset_full_sync_dto.dart @@ -79,6 +79,7 @@ class AssetFullSyncDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFullSyncDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFullSyncDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_ids_dto.dart b/mobile/openapi/lib/model/asset_ids_dto.dart index c8c7a69b89..b44888f396 100644 --- a/mobile/openapi/lib/model/asset_ids_dto.dart +++ b/mobile/openapi/lib/model/asset_ids_dto.dart @@ -40,6 +40,7 @@ class AssetIdsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetIdsDto? fromJson(dynamic value) { + upgradeDto(value, "AssetIdsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_ids_response_dto.dart b/mobile/openapi/lib/model/asset_ids_response_dto.dart index a642c0924c..ff63091caa 100644 --- a/mobile/openapi/lib/model/asset_ids_response_dto.dart +++ b/mobile/openapi/lib/model/asset_ids_response_dto.dart @@ -56,6 +56,7 @@ class AssetIdsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetIdsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetIdsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_jobs_dto.dart b/mobile/openapi/lib/model/asset_jobs_dto.dart index 16ed2644fd..0f8bfab009 100644 --- a/mobile/openapi/lib/model/asset_jobs_dto.dart +++ b/mobile/openapi/lib/model/asset_jobs_dto.dart @@ -46,6 +46,7 @@ class AssetJobsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetJobsDto? fromJson(dynamic value) { + upgradeDto(value, "AssetJobsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_media_response_dto.dart b/mobile/openapi/lib/model/asset_media_response_dto.dart index c2801c93cc..75428ec5f6 100644 --- a/mobile/openapi/lib/model/asset_media_response_dto.dart +++ b/mobile/openapi/lib/model/asset_media_response_dto.dart @@ -46,6 +46,7 @@ class AssetMediaResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetMediaResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMediaResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index bfb461efdc..c11dedcbfd 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -293,6 +293,7 @@ class AssetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_stack_response_dto.dart b/mobile/openapi/lib/model/asset_stack_response_dto.dart index 89d30f7810..bb4becb129 100644 --- a/mobile/openapi/lib/model/asset_stack_response_dto.dart +++ b/mobile/openapi/lib/model/asset_stack_response_dto.dart @@ -52,6 +52,7 @@ class AssetStackResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetStackResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetStackResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_stats_response_dto.dart b/mobile/openapi/lib/model/asset_stats_response_dto.dart index c21d7fdbff..d11ce55a5c 100644 --- a/mobile/openapi/lib/model/asset_stats_response_dto.dart +++ b/mobile/openapi/lib/model/asset_stats_response_dto.dart @@ -52,6 +52,7 @@ class AssetStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/audit_deletes_response_dto.dart b/mobile/openapi/lib/model/audit_deletes_response_dto.dart index 690a52e811..6b1df74eb4 100644 --- a/mobile/openapi/lib/model/audit_deletes_response_dto.dart +++ b/mobile/openapi/lib/model/audit_deletes_response_dto.dart @@ -46,6 +46,7 @@ class AuditDeletesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AuditDeletesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AuditDeletesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/avatar_response.dart b/mobile/openapi/lib/model/avatar_response.dart index edd242df4e..8ce0287565 100644 --- a/mobile/openapi/lib/model/avatar_response.dart +++ b/mobile/openapi/lib/model/avatar_response.dart @@ -40,6 +40,7 @@ class AvatarResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AvatarResponse? fromJson(dynamic value) { + upgradeDto(value, "AvatarResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/avatar_update.dart b/mobile/openapi/lib/model/avatar_update.dart index b92eb8dcbd..875eb138a8 100644 --- a/mobile/openapi/lib/model/avatar_update.dart +++ b/mobile/openapi/lib/model/avatar_update.dart @@ -50,6 +50,7 @@ class AvatarUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AvatarUpdate? fromJson(dynamic value) { + upgradeDto(value, "AvatarUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/bulk_id_response_dto.dart b/mobile/openapi/lib/model/bulk_id_response_dto.dart index ef3cf2e0db..67a587e8d0 100644 --- a/mobile/openapi/lib/model/bulk_id_response_dto.dart +++ b/mobile/openapi/lib/model/bulk_id_response_dto.dart @@ -56,6 +56,7 @@ class BulkIdResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static BulkIdResponseDto? fromJson(dynamic value) { + upgradeDto(value, "BulkIdResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/bulk_ids_dto.dart b/mobile/openapi/lib/model/bulk_ids_dto.dart index 6942875f0a..6a7f8ceeec 100644 --- a/mobile/openapi/lib/model/bulk_ids_dto.dart +++ b/mobile/openapi/lib/model/bulk_ids_dto.dart @@ -40,6 +40,7 @@ class BulkIdsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static BulkIdsDto? fromJson(dynamic value) { + upgradeDto(value, "BulkIdsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/change_password_dto.dart b/mobile/openapi/lib/model/change_password_dto.dart index 1074aaf74d..33b7f4a607 100644 --- a/mobile/openapi/lib/model/change_password_dto.dart +++ b/mobile/openapi/lib/model/change_password_dto.dart @@ -46,6 +46,7 @@ class ChangePasswordDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ChangePasswordDto? fromJson(dynamic value) { + upgradeDto(value, "ChangePasswordDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/check_existing_assets_dto.dart b/mobile/openapi/lib/model/check_existing_assets_dto.dart index 49ef36cc09..42ce6d5c3e 100644 --- a/mobile/openapi/lib/model/check_existing_assets_dto.dart +++ b/mobile/openapi/lib/model/check_existing_assets_dto.dart @@ -46,6 +46,7 @@ class CheckExistingAssetsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CheckExistingAssetsDto? fromJson(dynamic value) { + upgradeDto(value, "CheckExistingAssetsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/check_existing_assets_response_dto.dart b/mobile/openapi/lib/model/check_existing_assets_response_dto.dart index d8b0f43a6d..ad93578ebc 100644 --- a/mobile/openapi/lib/model/check_existing_assets_response_dto.dart +++ b/mobile/openapi/lib/model/check_existing_assets_response_dto.dart @@ -40,6 +40,7 @@ class CheckExistingAssetsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CheckExistingAssetsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "CheckExistingAssetsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/clip_config.dart b/mobile/openapi/lib/model/clip_config.dart index 6e95c15fbf..b500d20f2e 100644 --- a/mobile/openapi/lib/model/clip_config.dart +++ b/mobile/openapi/lib/model/clip_config.dart @@ -46,6 +46,7 @@ class CLIPConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CLIPConfig? fromJson(dynamic value) { + upgradeDto(value, "CLIPConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_album_dto.dart b/mobile/openapi/lib/model/create_album_dto.dart index fa28b782ac..ff8c1df647 100644 --- a/mobile/openapi/lib/model/create_album_dto.dart +++ b/mobile/openapi/lib/model/create_album_dto.dart @@ -68,6 +68,7 @@ class CreateAlbumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateAlbumDto? fromJson(dynamic value) { + upgradeDto(value, "CreateAlbumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_library_dto.dart b/mobile/openapi/lib/model/create_library_dto.dart index 65ceec8e8a..bffa5f4279 100644 --- a/mobile/openapi/lib/model/create_library_dto.dart +++ b/mobile/openapi/lib/model/create_library_dto.dart @@ -68,6 +68,7 @@ class CreateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "CreateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_profile_image_response_dto.dart b/mobile/openapi/lib/model/create_profile_image_response_dto.dart index 86624ed06b..ee98142e86 100644 --- a/mobile/openapi/lib/model/create_profile_image_response_dto.dart +++ b/mobile/openapi/lib/model/create_profile_image_response_dto.dart @@ -52,6 +52,7 @@ class CreateProfileImageResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateProfileImageResponseDto? fromJson(dynamic value) { + upgradeDto(value, "CreateProfileImageResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_archive_info.dart b/mobile/openapi/lib/model/download_archive_info.dart index e324850bdc..5f3fd1a8c1 100644 --- a/mobile/openapi/lib/model/download_archive_info.dart +++ b/mobile/openapi/lib/model/download_archive_info.dart @@ -46,6 +46,7 @@ class DownloadArchiveInfo { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadArchiveInfo? fromJson(dynamic value) { + upgradeDto(value, "DownloadArchiveInfo"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_info_dto.dart b/mobile/openapi/lib/model/download_info_dto.dart index 4c38769010..6f4777975c 100644 --- a/mobile/openapi/lib/model/download_info_dto.dart +++ b/mobile/openapi/lib/model/download_info_dto.dart @@ -89,6 +89,7 @@ class DownloadInfoDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadInfoDto? fromJson(dynamic value) { + upgradeDto(value, "DownloadInfoDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_response.dart b/mobile/openapi/lib/model/download_response.dart index 25c5159a8b..041da44b71 100644 --- a/mobile/openapi/lib/model/download_response.dart +++ b/mobile/openapi/lib/model/download_response.dart @@ -46,6 +46,7 @@ class DownloadResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadResponse? fromJson(dynamic value) { + upgradeDto(value, "DownloadResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_response_dto.dart b/mobile/openapi/lib/model/download_response_dto.dart index f32cba9253..5c6bd11266 100644 --- a/mobile/openapi/lib/model/download_response_dto.dart +++ b/mobile/openapi/lib/model/download_response_dto.dart @@ -46,6 +46,7 @@ class DownloadResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadResponseDto? fromJson(dynamic value) { + upgradeDto(value, "DownloadResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_update.dart b/mobile/openapi/lib/model/download_update.dart index 2c3839a687..8df825a922 100644 --- a/mobile/openapi/lib/model/download_update.dart +++ b/mobile/openapi/lib/model/download_update.dart @@ -67,6 +67,7 @@ class DownloadUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadUpdate? fromJson(dynamic value) { + upgradeDto(value, "DownloadUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/duplicate_detection_config.dart b/mobile/openapi/lib/model/duplicate_detection_config.dart index 0bc6091784..e4fc352028 100644 --- a/mobile/openapi/lib/model/duplicate_detection_config.dart +++ b/mobile/openapi/lib/model/duplicate_detection_config.dart @@ -48,6 +48,7 @@ class DuplicateDetectionConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DuplicateDetectionConfig? fromJson(dynamic value) { + upgradeDto(value, "DuplicateDetectionConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/duplicate_response_dto.dart b/mobile/openapi/lib/model/duplicate_response_dto.dart index b93ecfe5f5..6ac7c46871 100644 --- a/mobile/openapi/lib/model/duplicate_response_dto.dart +++ b/mobile/openapi/lib/model/duplicate_response_dto.dart @@ -46,6 +46,7 @@ class DuplicateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DuplicateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "DuplicateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/email_notifications_response.dart b/mobile/openapi/lib/model/email_notifications_response.dart index cef92957c6..d6dcfb9273 100644 --- a/mobile/openapi/lib/model/email_notifications_response.dart +++ b/mobile/openapi/lib/model/email_notifications_response.dart @@ -52,6 +52,7 @@ class EmailNotificationsResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static EmailNotificationsResponse? fromJson(dynamic value) { + upgradeDto(value, "EmailNotificationsResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/email_notifications_update.dart b/mobile/openapi/lib/model/email_notifications_update.dart index dcd1ec4322..dad0a52fde 100644 --- a/mobile/openapi/lib/model/email_notifications_update.dart +++ b/mobile/openapi/lib/model/email_notifications_update.dart @@ -82,6 +82,7 @@ class EmailNotificationsUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static EmailNotificationsUpdate? fromJson(dynamic value) { + upgradeDto(value, "EmailNotificationsUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 0185f300fa..17397b2081 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -254,6 +254,7 @@ class ExifResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ExifResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ExifResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/face_dto.dart b/mobile/openapi/lib/model/face_dto.dart index 4fcc86debf..c84a518b8c 100644 --- a/mobile/openapi/lib/model/face_dto.dart +++ b/mobile/openapi/lib/model/face_dto.dart @@ -40,6 +40,7 @@ class FaceDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FaceDto? fromJson(dynamic value) { + upgradeDto(value, "FaceDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/facial_recognition_config.dart b/mobile/openapi/lib/model/facial_recognition_config.dart index 52400fd7e1..4acfd4e20f 100644 --- a/mobile/openapi/lib/model/facial_recognition_config.dart +++ b/mobile/openapi/lib/model/facial_recognition_config.dart @@ -69,6 +69,7 @@ class FacialRecognitionConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FacialRecognitionConfig? fromJson(dynamic value) { + upgradeDto(value, "FacialRecognitionConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_checksum_dto.dart b/mobile/openapi/lib/model/file_checksum_dto.dart index c7e8aa1da6..7dc9ccdf2f 100644 --- a/mobile/openapi/lib/model/file_checksum_dto.dart +++ b/mobile/openapi/lib/model/file_checksum_dto.dart @@ -40,6 +40,7 @@ class FileChecksumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileChecksumDto? fromJson(dynamic value) { + upgradeDto(value, "FileChecksumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_checksum_response_dto.dart b/mobile/openapi/lib/model/file_checksum_response_dto.dart index d4bae3c273..7b963c8bd5 100644 --- a/mobile/openapi/lib/model/file_checksum_response_dto.dart +++ b/mobile/openapi/lib/model/file_checksum_response_dto.dart @@ -46,6 +46,7 @@ class FileChecksumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileChecksumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "FileChecksumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_dto.dart b/mobile/openapi/lib/model/file_report_dto.dart index 422215ff6c..3dc892e5e7 100644 --- a/mobile/openapi/lib/model/file_report_dto.dart +++ b/mobile/openapi/lib/model/file_report_dto.dart @@ -46,6 +46,7 @@ class FileReportDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_fix_dto.dart b/mobile/openapi/lib/model/file_report_fix_dto.dart index cf09242b0f..d46cdeb4b7 100644 --- a/mobile/openapi/lib/model/file_report_fix_dto.dart +++ b/mobile/openapi/lib/model/file_report_fix_dto.dart @@ -40,6 +40,7 @@ class FileReportFixDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportFixDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportFixDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_item_dto.dart b/mobile/openapi/lib/model/file_report_item_dto.dart index 5255005daa..1ef08c2b48 100644 --- a/mobile/openapi/lib/model/file_report_item_dto.dart +++ b/mobile/openapi/lib/model/file_report_item_dto.dart @@ -74,6 +74,7 @@ class FileReportItemDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportItemDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportItemDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/folders_response.dart b/mobile/openapi/lib/model/folders_response.dart index 5bfc4c793d..248b64b054 100644 --- a/mobile/openapi/lib/model/folders_response.dart +++ b/mobile/openapi/lib/model/folders_response.dart @@ -46,6 +46,7 @@ class FoldersResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FoldersResponse? fromJson(dynamic value) { + upgradeDto(value, "FoldersResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/folders_update.dart b/mobile/openapi/lib/model/folders_update.dart index 088c98a4d8..0234717754 100644 --- a/mobile/openapi/lib/model/folders_update.dart +++ b/mobile/openapi/lib/model/folders_update.dart @@ -66,6 +66,7 @@ class FoldersUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FoldersUpdate? fromJson(dynamic value) { + upgradeDto(value, "FoldersUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/job_command_dto.dart index 5c56715644..649e0128a7 100644 --- a/mobile/openapi/lib/model/job_command_dto.dart +++ b/mobile/openapi/lib/model/job_command_dto.dart @@ -46,6 +46,7 @@ class JobCommandDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobCommandDto? fromJson(dynamic value) { + upgradeDto(value, "JobCommandDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_counts_dto.dart b/mobile/openapi/lib/model/job_counts_dto.dart index cf1d0b457d..afc90d1084 100644 --- a/mobile/openapi/lib/model/job_counts_dto.dart +++ b/mobile/openapi/lib/model/job_counts_dto.dart @@ -70,6 +70,7 @@ class JobCountsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobCountsDto? fromJson(dynamic value) { + upgradeDto(value, "JobCountsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_create_dto.dart b/mobile/openapi/lib/model/job_create_dto.dart index a4734791bb..fe6743cba0 100644 --- a/mobile/openapi/lib/model/job_create_dto.dart +++ b/mobile/openapi/lib/model/job_create_dto.dart @@ -40,6 +40,7 @@ class JobCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobCreateDto? fromJson(dynamic value) { + upgradeDto(value, "JobCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_settings_dto.dart b/mobile/openapi/lib/model/job_settings_dto.dart index 9c59d503ca..af354bef9e 100644 --- a/mobile/openapi/lib/model/job_settings_dto.dart +++ b/mobile/openapi/lib/model/job_settings_dto.dart @@ -41,6 +41,7 @@ class JobSettingsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobSettingsDto? fromJson(dynamic value) { + upgradeDto(value, "JobSettingsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_status_dto.dart b/mobile/openapi/lib/model/job_status_dto.dart index fd925bd53a..18fab8dfb3 100644 --- a/mobile/openapi/lib/model/job_status_dto.dart +++ b/mobile/openapi/lib/model/job_status_dto.dart @@ -46,6 +46,7 @@ class JobStatusDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobStatusDto? fromJson(dynamic value) { + upgradeDto(value, "JobStatusDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/library_response_dto.dart b/mobile/openapi/lib/model/library_response_dto.dart index e27b489104..3cf1248508 100644 --- a/mobile/openapi/lib/model/library_response_dto.dart +++ b/mobile/openapi/lib/model/library_response_dto.dart @@ -92,6 +92,7 @@ class LibraryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LibraryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LibraryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/library_stats_response_dto.dart b/mobile/openapi/lib/model/library_stats_response_dto.dart index 8cfb292855..afe67da31a 100644 --- a/mobile/openapi/lib/model/library_stats_response_dto.dart +++ b/mobile/openapi/lib/model/library_stats_response_dto.dart @@ -58,6 +58,7 @@ class LibraryStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LibraryStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LibraryStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/license_key_dto.dart b/mobile/openapi/lib/model/license_key_dto.dart index aece85f81e..d27d579bb4 100644 --- a/mobile/openapi/lib/model/license_key_dto.dart +++ b/mobile/openapi/lib/model/license_key_dto.dart @@ -46,6 +46,7 @@ class LicenseKeyDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LicenseKeyDto? fromJson(dynamic value) { + upgradeDto(value, "LicenseKeyDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/license_response_dto.dart b/mobile/openapi/lib/model/license_response_dto.dart index f83668af57..6d3009433f 100644 --- a/mobile/openapi/lib/model/license_response_dto.dart +++ b/mobile/openapi/lib/model/license_response_dto.dart @@ -52,6 +52,7 @@ class LicenseResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LicenseResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LicenseResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/login_credential_dto.dart b/mobile/openapi/lib/model/login_credential_dto.dart index ac2f511691..7e892ab5fb 100644 --- a/mobile/openapi/lib/model/login_credential_dto.dart +++ b/mobile/openapi/lib/model/login_credential_dto.dart @@ -46,6 +46,7 @@ class LoginCredentialDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LoginCredentialDto? fromJson(dynamic value) { + upgradeDto(value, "LoginCredentialDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/login_response_dto.dart b/mobile/openapi/lib/model/login_response_dto.dart index 6a0eb2355c..dbc82d07ba 100644 --- a/mobile/openapi/lib/model/login_response_dto.dart +++ b/mobile/openapi/lib/model/login_response_dto.dart @@ -76,6 +76,7 @@ class LoginResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LoginResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LoginResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/logout_response_dto.dart b/mobile/openapi/lib/model/logout_response_dto.dart index ca1e8d23bb..aa94904e2a 100644 --- a/mobile/openapi/lib/model/logout_response_dto.dart +++ b/mobile/openapi/lib/model/logout_response_dto.dart @@ -46,6 +46,7 @@ class LogoutResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LogoutResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LogoutResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/map_marker_response_dto.dart b/mobile/openapi/lib/model/map_marker_response_dto.dart index ca1ec3c8a1..74ac51a271 100644 --- a/mobile/openapi/lib/model/map_marker_response_dto.dart +++ b/mobile/openapi/lib/model/map_marker_response_dto.dart @@ -82,6 +82,7 @@ class MapMarkerResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MapMarkerResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MapMarkerResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart index ac99dd91a9..6d8757d39f 100644 --- a/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart +++ b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart @@ -64,6 +64,7 @@ class MapReverseGeocodeResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MapReverseGeocodeResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MapReverseGeocodeResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memories_response.dart b/mobile/openapi/lib/model/memories_response.dart index e215a66a03..b9f8b5d8b1 100644 --- a/mobile/openapi/lib/model/memories_response.dart +++ b/mobile/openapi/lib/model/memories_response.dart @@ -40,6 +40,7 @@ class MemoriesResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoriesResponse? fromJson(dynamic value) { + upgradeDto(value, "MemoriesResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memories_update.dart b/mobile/openapi/lib/model/memories_update.dart index d309491361..71efd71ae7 100644 --- a/mobile/openapi/lib/model/memories_update.dart +++ b/mobile/openapi/lib/model/memories_update.dart @@ -50,6 +50,7 @@ class MemoriesUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoriesUpdate? fromJson(dynamic value) { + upgradeDto(value, "MemoriesUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_create_dto.dart b/mobile/openapi/lib/model/memory_create_dto.dart index 2efdf88936..15985f2f1c 100644 --- a/mobile/openapi/lib/model/memory_create_dto.dart +++ b/mobile/openapi/lib/model/memory_create_dto.dart @@ -90,6 +90,7 @@ class MemoryCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryCreateDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_lane_response_dto.dart b/mobile/openapi/lib/model/memory_lane_response_dto.dart index 4abe607381..27248d05c1 100644 --- a/mobile/openapi/lib/model/memory_lane_response_dto.dart +++ b/mobile/openapi/lib/model/memory_lane_response_dto.dart @@ -46,6 +46,7 @@ class MemoryLaneResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryLaneResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryLaneResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_response_dto.dart b/mobile/openapi/lib/model/memory_response_dto.dart index f794be53cd..652c993536 100644 --- a/mobile/openapi/lib/model/memory_response_dto.dart +++ b/mobile/openapi/lib/model/memory_response_dto.dart @@ -120,6 +120,7 @@ class MemoryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_update_dto.dart b/mobile/openapi/lib/model/memory_update_dto.dart index 318f4b42ad..e750f9faad 100644 --- a/mobile/openapi/lib/model/memory_update_dto.dart +++ b/mobile/openapi/lib/model/memory_update_dto.dart @@ -82,6 +82,7 @@ class MemoryUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/merge_person_dto.dart b/mobile/openapi/lib/model/merge_person_dto.dart index ea23042e2c..fd225276b6 100644 --- a/mobile/openapi/lib/model/merge_person_dto.dart +++ b/mobile/openapi/lib/model/merge_person_dto.dart @@ -40,6 +40,7 @@ class MergePersonDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MergePersonDto? fromJson(dynamic value) { + upgradeDto(value, "MergePersonDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index fabf7a2610..0aef1f623e 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -637,6 +637,7 @@ class MetadataSearchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MetadataSearchDto? fromJson(dynamic value) { + upgradeDto(value, "MetadataSearchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart index ffd017f816..869c3be753 100644 --- a/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart +++ b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart @@ -40,6 +40,7 @@ class OAuthAuthorizeResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthAuthorizeResponseDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthAuthorizeResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_callback_dto.dart b/mobile/openapi/lib/model/o_auth_callback_dto.dart index 89ad0f60b0..d0b98d5c6f 100644 --- a/mobile/openapi/lib/model/o_auth_callback_dto.dart +++ b/mobile/openapi/lib/model/o_auth_callback_dto.dart @@ -40,6 +40,7 @@ class OAuthCallbackDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthCallbackDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthCallbackDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_config_dto.dart b/mobile/openapi/lib/model/o_auth_config_dto.dart index 7d76758864..86c79b4e04 100644 --- a/mobile/openapi/lib/model/o_auth_config_dto.dart +++ b/mobile/openapi/lib/model/o_auth_config_dto.dart @@ -40,6 +40,7 @@ class OAuthConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthConfigDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthConfigDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/on_this_day_dto.dart b/mobile/openapi/lib/model/on_this_day_dto.dart index be170caf85..bfcc4fd630 100644 --- a/mobile/openapi/lib/model/on_this_day_dto.dart +++ b/mobile/openapi/lib/model/on_this_day_dto.dart @@ -41,6 +41,7 @@ class OnThisDayDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OnThisDayDto? fromJson(dynamic value) { + upgradeDto(value, "OnThisDayDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/partner_response_dto.dart b/mobile/openapi/lib/model/partner_response_dto.dart index 375303c94a..f61df86b42 100644 --- a/mobile/openapi/lib/model/partner_response_dto.dart +++ b/mobile/openapi/lib/model/partner_response_dto.dart @@ -86,6 +86,7 @@ class PartnerResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PartnerResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PartnerResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_response.dart b/mobile/openapi/lib/model/people_response.dart index e12f86eeab..1312c73874 100644 --- a/mobile/openapi/lib/model/people_response.dart +++ b/mobile/openapi/lib/model/people_response.dart @@ -46,6 +46,7 @@ class PeopleResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleResponse? fromJson(dynamic value) { + upgradeDto(value, "PeopleResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_response_dto.dart b/mobile/openapi/lib/model/people_response_dto.dart index 87e8c34fb0..49f0e85aad 100644 --- a/mobile/openapi/lib/model/people_response_dto.dart +++ b/mobile/openapi/lib/model/people_response_dto.dart @@ -69,6 +69,7 @@ class PeopleResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PeopleResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update.dart b/mobile/openapi/lib/model/people_update.dart index 7803e62970..fb4eeeb434 100644 --- a/mobile/openapi/lib/model/people_update.dart +++ b/mobile/openapi/lib/model/people_update.dart @@ -66,6 +66,7 @@ class PeopleUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdate? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update_dto.dart b/mobile/openapi/lib/model/people_update_dto.dart index 9fcfdc8761..f771084f75 100644 --- a/mobile/openapi/lib/model/people_update_dto.dart +++ b/mobile/openapi/lib/model/people_update_dto.dart @@ -40,6 +40,7 @@ class PeopleUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 8af0a8b11a..042e4fa36f 100644 --- a/mobile/openapi/lib/model/people_update_item.dart +++ b/mobile/openapi/lib/model/people_update_item.dart @@ -103,6 +103,7 @@ class PeopleUpdateItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdateItem? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdateItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_create_dto.dart b/mobile/openapi/lib/model/person_create_dto.dart index 9889328dee..36bd6dfee9 100644 --- a/mobile/openapi/lib/model/person_create_dto.dart +++ b/mobile/openapi/lib/model/person_create_dto.dart @@ -79,6 +79,7 @@ class PersonCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonCreateDto? fromJson(dynamic value) { + upgradeDto(value, "PersonCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 50ee28f0af..0b36fcde3b 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -85,6 +85,7 @@ class PersonResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_statistics_response_dto.dart b/mobile/openapi/lib/model/person_statistics_response_dto.dart index 929fbc29d2..d9f84e9f4c 100644 --- a/mobile/openapi/lib/model/person_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/person_statistics_response_dto.dart @@ -40,6 +40,7 @@ class PersonStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index 1af03890a2..51a7ea25d0 100644 --- a/mobile/openapi/lib/model/person_update_dto.dart +++ b/mobile/openapi/lib/model/person_update_dto.dart @@ -96,6 +96,7 @@ class PersonUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "PersonUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_with_faces_response_dto.dart b/mobile/openapi/lib/model/person_with_faces_response_dto.dart index af2e7101c3..b14bad7895 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -91,6 +91,7 @@ class PersonWithFacesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonWithFacesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonWithFacesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/places_response_dto.dart b/mobile/openapi/lib/model/places_response_dto.dart index d3e1fc449b..4f77788263 100644 --- a/mobile/openapi/lib/model/places_response_dto.dart +++ b/mobile/openapi/lib/model/places_response_dto.dart @@ -84,6 +84,7 @@ class PlacesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PlacesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PlacesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/purchase_response.dart b/mobile/openapi/lib/model/purchase_response.dart index 284d899528..a117206977 100644 --- a/mobile/openapi/lib/model/purchase_response.dart +++ b/mobile/openapi/lib/model/purchase_response.dart @@ -46,6 +46,7 @@ class PurchaseResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PurchaseResponse? fromJson(dynamic value) { + upgradeDto(value, "PurchaseResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/purchase_update.dart b/mobile/openapi/lib/model/purchase_update.dart index ca0a27e3bc..69057e6c55 100644 --- a/mobile/openapi/lib/model/purchase_update.dart +++ b/mobile/openapi/lib/model/purchase_update.dart @@ -66,6 +66,7 @@ class PurchaseUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PurchaseUpdate? fromJson(dynamic value) { + upgradeDto(value, "PurchaseUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/queue_status_dto.dart b/mobile/openapi/lib/model/queue_status_dto.dart index 7f7d310f6f..77591affe2 100644 --- a/mobile/openapi/lib/model/queue_status_dto.dart +++ b/mobile/openapi/lib/model/queue_status_dto.dart @@ -46,6 +46,7 @@ class QueueStatusDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static QueueStatusDto? fromJson(dynamic value) { + upgradeDto(value, "QueueStatusDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/ratings_response.dart b/mobile/openapi/lib/model/ratings_response.dart index c8791aa91a..8e1951277a 100644 --- a/mobile/openapi/lib/model/ratings_response.dart +++ b/mobile/openapi/lib/model/ratings_response.dart @@ -40,6 +40,7 @@ class RatingsResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static RatingsResponse? fromJson(dynamic value) { + upgradeDto(value, "RatingsResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/ratings_update.dart b/mobile/openapi/lib/model/ratings_update.dart index bde51bad1b..5d9f9a655f 100644 --- a/mobile/openapi/lib/model/ratings_update.dart +++ b/mobile/openapi/lib/model/ratings_update.dart @@ -50,6 +50,7 @@ class RatingsUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static RatingsUpdate? fromJson(dynamic value) { + upgradeDto(value, "RatingsUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart index eb414be984..5b3648b46b 100644 --- a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart +++ b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart @@ -54,6 +54,7 @@ class ReverseGeocodingStateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ReverseGeocodingStateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ReverseGeocodingStateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/scan_library_dto.dart b/mobile/openapi/lib/model/scan_library_dto.dart index 1b31aaaf01..8ff978be05 100644 --- a/mobile/openapi/lib/model/scan_library_dto.dart +++ b/mobile/openapi/lib/model/scan_library_dto.dart @@ -66,6 +66,7 @@ class ScanLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ScanLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "ScanLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_album_response_dto.dart b/mobile/openapi/lib/model/search_album_response_dto.dart index 46ce5273ac..e9b47e85ec 100644 --- a/mobile/openapi/lib/model/search_album_response_dto.dart +++ b/mobile/openapi/lib/model/search_album_response_dto.dart @@ -58,6 +58,7 @@ class SearchAlbumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchAlbumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchAlbumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_asset_response_dto.dart b/mobile/openapi/lib/model/search_asset_response_dto.dart index 21ddbbb213..3d214e61d9 100644 --- a/mobile/openapi/lib/model/search_asset_response_dto.dart +++ b/mobile/openapi/lib/model/search_asset_response_dto.dart @@ -68,6 +68,7 @@ class SearchAssetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchAssetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchAssetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_explore_item.dart b/mobile/openapi/lib/model/search_explore_item.dart index 951fdd1bc8..d44b2cd704 100644 --- a/mobile/openapi/lib/model/search_explore_item.dart +++ b/mobile/openapi/lib/model/search_explore_item.dart @@ -46,6 +46,7 @@ class SearchExploreItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchExploreItem? fromJson(dynamic value) { + upgradeDto(value, "SearchExploreItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_explore_response_dto.dart b/mobile/openapi/lib/model/search_explore_response_dto.dart index 5bc601de9e..3b5d4f9849 100644 --- a/mobile/openapi/lib/model/search_explore_response_dto.dart +++ b/mobile/openapi/lib/model/search_explore_response_dto.dart @@ -46,6 +46,7 @@ class SearchExploreResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchExploreResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchExploreResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_facet_count_response_dto.dart b/mobile/openapi/lib/model/search_facet_count_response_dto.dart index b40710e525..f8eee84485 100644 --- a/mobile/openapi/lib/model/search_facet_count_response_dto.dart +++ b/mobile/openapi/lib/model/search_facet_count_response_dto.dart @@ -46,6 +46,7 @@ class SearchFacetCountResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchFacetCountResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchFacetCountResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_facet_response_dto.dart b/mobile/openapi/lib/model/search_facet_response_dto.dart index 0784921c6b..aeec873c8d 100644 --- a/mobile/openapi/lib/model/search_facet_response_dto.dart +++ b/mobile/openapi/lib/model/search_facet_response_dto.dart @@ -46,6 +46,7 @@ class SearchFacetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchFacetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchFacetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_response_dto.dart b/mobile/openapi/lib/model/search_response_dto.dart index 9b2b7fd3cf..ca742ae35c 100644 --- a/mobile/openapi/lib/model/search_response_dto.dart +++ b/mobile/openapi/lib/model/search_response_dto.dart @@ -46,6 +46,7 @@ class SearchResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_about_response_dto.dart b/mobile/openapi/lib/model/server_about_response_dto.dart index 9c71d1fccd..1ab51a80f1 100644 --- a/mobile/openapi/lib/model/server_about_response_dto.dart +++ b/mobile/openapi/lib/model/server_about_response_dto.dart @@ -276,6 +276,7 @@ class ServerAboutResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerAboutResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerAboutResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index 47cc52fb2c..c45ed32ac0 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -76,6 +76,7 @@ class ServerConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerConfigDto? fromJson(dynamic value) { + upgradeDto(value, "ServerConfigDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_features_dto.dart b/mobile/openapi/lib/model/server_features_dto.dart index 0a7d8a4b47..5149c3796a 100644 --- a/mobile/openapi/lib/model/server_features_dto.dart +++ b/mobile/openapi/lib/model/server_features_dto.dart @@ -118,6 +118,7 @@ class ServerFeaturesDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerFeaturesDto? fromJson(dynamic value) { + upgradeDto(value, "ServerFeaturesDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_media_types_response_dto.dart b/mobile/openapi/lib/model/server_media_types_response_dto.dart index 35ddef1956..506cbb44b4 100644 --- a/mobile/openapi/lib/model/server_media_types_response_dto.dart +++ b/mobile/openapi/lib/model/server_media_types_response_dto.dart @@ -52,6 +52,7 @@ class ServerMediaTypesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerMediaTypesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerMediaTypesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_ping_response.dart b/mobile/openapi/lib/model/server_ping_response.dart index e23dc15c61..621ebfa294 100644 --- a/mobile/openapi/lib/model/server_ping_response.dart +++ b/mobile/openapi/lib/model/server_ping_response.dart @@ -40,6 +40,7 @@ class ServerPingResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerPingResponse? fromJson(dynamic value) { + upgradeDto(value, "ServerPingResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_stats_response_dto.dart b/mobile/openapi/lib/model/server_stats_response_dto.dart index 6996e49aa5..654a34ee6b 100644 --- a/mobile/openapi/lib/model/server_stats_response_dto.dart +++ b/mobile/openapi/lib/model/server_stats_response_dto.dart @@ -58,6 +58,7 @@ class ServerStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_storage_response_dto.dart b/mobile/openapi/lib/model/server_storage_response_dto.dart index 89d97d32ea..8d12e77834 100644 --- a/mobile/openapi/lib/model/server_storage_response_dto.dart +++ b/mobile/openapi/lib/model/server_storage_response_dto.dart @@ -76,6 +76,7 @@ class ServerStorageResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerStorageResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerStorageResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_theme_dto.dart b/mobile/openapi/lib/model/server_theme_dto.dart index 65b9b9163e..69e1b2d2c8 100644 --- a/mobile/openapi/lib/model/server_theme_dto.dart +++ b/mobile/openapi/lib/model/server_theme_dto.dart @@ -40,6 +40,7 @@ class ServerThemeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerThemeDto? fromJson(dynamic value) { + upgradeDto(value, "ServerThemeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_version_response_dto.dart b/mobile/openapi/lib/model/server_version_response_dto.dart index e507f3372a..751347fabd 100644 --- a/mobile/openapi/lib/model/server_version_response_dto.dart +++ b/mobile/openapi/lib/model/server_version_response_dto.dart @@ -52,6 +52,7 @@ class ServerVersionResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerVersionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerVersionResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index 82673b3874..92e2dc6067 100644 --- a/mobile/openapi/lib/model/session_response_dto.dart +++ b/mobile/openapi/lib/model/session_response_dto.dart @@ -70,6 +70,7 @@ class SessionResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SessionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SessionResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_create_dto.dart b/mobile/openapi/lib/model/shared_link_create_dto.dart index 623bc3125f..bc96b31fd2 100644 --- a/mobile/openapi/lib/model/shared_link_create_dto.dart +++ b/mobile/openapi/lib/model/shared_link_create_dto.dart @@ -132,6 +132,7 @@ class SharedLinkCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkCreateDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart index 2369c85db1..a394ba9b3b 100644 --- a/mobile/openapi/lib/model/shared_link_edit_dto.dart +++ b/mobile/openapi/lib/model/shared_link_edit_dto.dart @@ -141,6 +141,7 @@ class SharedLinkEditDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkEditDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkEditDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_response_dto.dart b/mobile/openapi/lib/model/shared_link_response_dto.dart index 018a1a51de..9cc8b3ac80 100644 --- a/mobile/openapi/lib/model/shared_link_response_dto.dart +++ b/mobile/openapi/lib/model/shared_link_response_dto.dart @@ -144,6 +144,7 @@ class SharedLinkResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/sign_up_dto.dart b/mobile/openapi/lib/model/sign_up_dto.dart index 772749fdba..7e0ff4045c 100644 --- a/mobile/openapi/lib/model/sign_up_dto.dart +++ b/mobile/openapi/lib/model/sign_up_dto.dart @@ -52,6 +52,7 @@ class SignUpDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SignUpDto? fromJson(dynamic value) { + upgradeDto(value, "SignUpDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/smart_info_response_dto.dart b/mobile/openapi/lib/model/smart_info_response_dto.dart index 52e7c108b8..4631eccf2c 100644 --- a/mobile/openapi/lib/model/smart_info_response_dto.dart +++ b/mobile/openapi/lib/model/smart_info_response_dto.dart @@ -54,6 +54,7 @@ class SmartInfoResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SmartInfoResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SmartInfoResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 2a42b75768..4e1408cafa 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -467,6 +467,7 @@ class SmartSearchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SmartSearchDto? fromJson(dynamic value) { + upgradeDto(value, "SmartSearchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/stack_create_dto.dart b/mobile/openapi/lib/model/stack_create_dto.dart index 9b37bc6e2e..cb51081eb1 100644 --- a/mobile/openapi/lib/model/stack_create_dto.dart +++ b/mobile/openapi/lib/model/stack_create_dto.dart @@ -41,6 +41,7 @@ class StackCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static StackCreateDto? fromJson(dynamic value) { + upgradeDto(value, "StackCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/stack_response_dto.dart b/mobile/openapi/lib/model/stack_response_dto.dart index 3d0aaf91d1..b6cb747caf 100644 --- a/mobile/openapi/lib/model/stack_response_dto.dart +++ b/mobile/openapi/lib/model/stack_response_dto.dart @@ -52,6 +52,7 @@ class StackResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static StackResponseDto? fromJson(dynamic value) { + upgradeDto(value, "StackResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/stack_update_dto.dart b/mobile/openapi/lib/model/stack_update_dto.dart index 0e97127210..0101499edf 100644 --- a/mobile/openapi/lib/model/stack_update_dto.dart +++ b/mobile/openapi/lib/model/stack_update_dto.dart @@ -50,6 +50,7 @@ class StackUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static StackUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "StackUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index aff8062c8a..5306370d2d 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -142,6 +142,7 @@ class SystemConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart index a75a77c669..73f7d35aec 100644 --- a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart +++ b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart @@ -175,6 +175,7 @@ class SystemConfigFFmpegDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigFFmpegDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigFFmpegDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_faces_dto.dart b/mobile/openapi/lib/model/system_config_faces_dto.dart index 980e494fb7..4e18eb8de2 100644 --- a/mobile/openapi/lib/model/system_config_faces_dto.dart +++ b/mobile/openapi/lib/model/system_config_faces_dto.dart @@ -40,6 +40,7 @@ class SystemConfigFacesDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigFacesDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigFacesDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 388949c759..681a8c00c3 100644 --- a/mobile/openapi/lib/model/system_config_image_dto.dart +++ b/mobile/openapi/lib/model/system_config_image_dto.dart @@ -80,6 +80,7 @@ class SystemConfigImageDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigImageDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigImageDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_job_dto.dart b/mobile/openapi/lib/model/system_config_job_dto.dart index 1bc0f6b29c..c0fed5cccc 100644 --- a/mobile/openapi/lib/model/system_config_job_dto.dart +++ b/mobile/openapi/lib/model/system_config_job_dto.dart @@ -100,6 +100,7 @@ class SystemConfigJobDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigJobDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigJobDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_dto.dart b/mobile/openapi/lib/model/system_config_library_dto.dart index 4f55e33e80..e728b0bf20 100644 --- a/mobile/openapi/lib/model/system_config_library_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_scan_dto.dart b/mobile/openapi/lib/model/system_config_library_scan_dto.dart index 31df272594..6a6558b4b3 100644 --- a/mobile/openapi/lib/model/system_config_library_scan_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_scan_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLibraryScanDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryScanDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryScanDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_watch_dto.dart b/mobile/openapi/lib/model/system_config_library_watch_dto.dart index 9d152f366a..1a1f5d7126 100644 --- a/mobile/openapi/lib/model/system_config_library_watch_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_watch_dto.dart @@ -40,6 +40,7 @@ class SystemConfigLibraryWatchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryWatchDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryWatchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_logging_dto.dart b/mobile/openapi/lib/model/system_config_logging_dto.dart index 60c0be3d2c..f025221eff 100644 --- a/mobile/openapi/lib/model/system_config_logging_dto.dart +++ b/mobile/openapi/lib/model/system_config_logging_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLoggingDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLoggingDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLoggingDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart index 3923bacad4..d665f0bfa5 100644 --- a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart +++ b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart @@ -64,6 +64,7 @@ class SystemConfigMachineLearningDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMachineLearningDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMachineLearningDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_map_dto.dart b/mobile/openapi/lib/model/system_config_map_dto.dart index 6631885182..d53d5711db 100644 --- a/mobile/openapi/lib/model/system_config_map_dto.dart +++ b/mobile/openapi/lib/model/system_config_map_dto.dart @@ -52,6 +52,7 @@ class SystemConfigMapDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMapDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMapDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_metadata_dto.dart b/mobile/openapi/lib/model/system_config_metadata_dto.dart index 60ca35c835..3c32fc551d 100644 --- a/mobile/openapi/lib/model/system_config_metadata_dto.dart +++ b/mobile/openapi/lib/model/system_config_metadata_dto.dart @@ -40,6 +40,7 @@ class SystemConfigMetadataDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMetadataDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMetadataDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_new_version_check_dto.dart b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart index c7b8c98695..c63d2abc1b 100644 --- a/mobile/openapi/lib/model/system_config_new_version_check_dto.dart +++ b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart @@ -40,6 +40,7 @@ class SystemConfigNewVersionCheckDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigNewVersionCheckDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigNewVersionCheckDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_notifications_dto.dart b/mobile/openapi/lib/model/system_config_notifications_dto.dart index 22f08b3ab4..35d3d31833 100644 --- a/mobile/openapi/lib/model/system_config_notifications_dto.dart +++ b/mobile/openapi/lib/model/system_config_notifications_dto.dart @@ -40,6 +40,7 @@ class SystemConfigNotificationsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigNotificationsDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigNotificationsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_o_auth_dto.dart b/mobile/openapi/lib/model/system_config_o_auth_dto.dart index 6ebbe8d25c..9125bb7bba 100644 --- a/mobile/openapi/lib/model/system_config_o_auth_dto.dart +++ b/mobile/openapi/lib/model/system_config_o_auth_dto.dart @@ -125,6 +125,7 @@ class SystemConfigOAuthDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigOAuthDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigOAuthDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_password_login_dto.dart b/mobile/openapi/lib/model/system_config_password_login_dto.dart index 61896a890c..69c8942bb6 100644 --- a/mobile/openapi/lib/model/system_config_password_login_dto.dart +++ b/mobile/openapi/lib/model/system_config_password_login_dto.dart @@ -40,6 +40,7 @@ class SystemConfigPasswordLoginDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigPasswordLoginDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigPasswordLoginDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart b/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart index 2eb586cac6..6c1673d46c 100644 --- a/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart +++ b/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart @@ -40,6 +40,7 @@ class SystemConfigReverseGeocodingDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigReverseGeocodingDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigReverseGeocodingDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_server_dto.dart b/mobile/openapi/lib/model/system_config_server_dto.dart index ccb48ee61d..b1b92c9515 100644 --- a/mobile/openapi/lib/model/system_config_server_dto.dart +++ b/mobile/openapi/lib/model/system_config_server_dto.dart @@ -46,6 +46,7 @@ class SystemConfigServerDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigServerDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigServerDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_smtp_dto.dart b/mobile/openapi/lib/model/system_config_smtp_dto.dart index 6588d244ee..fcde49cf35 100644 --- a/mobile/openapi/lib/model/system_config_smtp_dto.dart +++ b/mobile/openapi/lib/model/system_config_smtp_dto.dart @@ -58,6 +58,7 @@ class SystemConfigSmtpDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigSmtpDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigSmtpDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart b/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart index 63dfdca4cf..bdaaa426c5 100644 --- a/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart +++ b/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart @@ -66,6 +66,7 @@ class SystemConfigSmtpTransportDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigSmtpTransportDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigSmtpTransportDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_storage_template_dto.dart b/mobile/openapi/lib/model/system_config_storage_template_dto.dart index 13323aebda..596aafc195 100644 --- a/mobile/openapi/lib/model/system_config_storage_template_dto.dart +++ b/mobile/openapi/lib/model/system_config_storage_template_dto.dart @@ -52,6 +52,7 @@ class SystemConfigStorageTemplateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigStorageTemplateDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigStorageTemplateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart b/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart index 82e0a6f747..f8586d344c 100644 --- a/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart +++ b/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart @@ -82,6 +82,7 @@ class SystemConfigTemplateStorageOptionDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigTemplateStorageOptionDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTemplateStorageOptionDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_theme_dto.dart b/mobile/openapi/lib/model/system_config_theme_dto.dart index 2f7f4d2f3b..a97c2cf84c 100644 --- a/mobile/openapi/lib/model/system_config_theme_dto.dart +++ b/mobile/openapi/lib/model/system_config_theme_dto.dart @@ -40,6 +40,7 @@ class SystemConfigThemeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigThemeDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigThemeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_trash_dto.dart b/mobile/openapi/lib/model/system_config_trash_dto.dart index 336019fde4..51b39e9a55 100644 --- a/mobile/openapi/lib/model/system_config_trash_dto.dart +++ b/mobile/openapi/lib/model/system_config_trash_dto.dart @@ -47,6 +47,7 @@ class SystemConfigTrashDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigTrashDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTrashDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_user_dto.dart b/mobile/openapi/lib/model/system_config_user_dto.dart index c466374460..8e6bd3c9c3 100644 --- a/mobile/openapi/lib/model/system_config_user_dto.dart +++ b/mobile/openapi/lib/model/system_config_user_dto.dart @@ -41,6 +41,7 @@ class SystemConfigUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigUserDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_bulk_assets_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart index c11cb66ce0..26a575e193 100644 --- a/mobile/openapi/lib/model/tag_bulk_assets_dto.dart +++ b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart @@ -46,6 +46,7 @@ class TagBulkAssetsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagBulkAssetsDto? fromJson(dynamic value) { + upgradeDto(value, "TagBulkAssetsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart index d4dcb91d8c..009f26bfe4 100644 --- a/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart +++ b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart @@ -40,6 +40,7 @@ class TagBulkAssetsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagBulkAssetsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TagBulkAssetsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_create_dto.dart b/mobile/openapi/lib/model/tag_create_dto.dart index dd7e537a0a..9a5171074d 100644 --- a/mobile/openapi/lib/model/tag_create_dto.dart +++ b/mobile/openapi/lib/model/tag_create_dto.dart @@ -66,6 +66,7 @@ class TagCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagCreateDto? fromJson(dynamic value) { + upgradeDto(value, "TagCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_response_dto.dart b/mobile/openapi/lib/model/tag_response_dto.dart index 1d1a88c3cf..cd684b163a 100644 --- a/mobile/openapi/lib/model/tag_response_dto.dart +++ b/mobile/openapi/lib/model/tag_response_dto.dart @@ -96,6 +96,7 @@ class TagResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TagResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_update_dto.dart b/mobile/openapi/lib/model/tag_update_dto.dart index 661f65896e..ab1adb127b 100644 --- a/mobile/openapi/lib/model/tag_update_dto.dart +++ b/mobile/openapi/lib/model/tag_update_dto.dart @@ -44,6 +44,7 @@ class TagUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "TagUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_upsert_dto.dart b/mobile/openapi/lib/model/tag_upsert_dto.dart index 941d25b6ae..d60a00f466 100644 --- a/mobile/openapi/lib/model/tag_upsert_dto.dart +++ b/mobile/openapi/lib/model/tag_upsert_dto.dart @@ -40,6 +40,7 @@ class TagUpsertDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagUpsertDto? fromJson(dynamic value) { + upgradeDto(value, "TagUpsertDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tags_response.dart b/mobile/openapi/lib/model/tags_response.dart index 3a5ea3b20b..2470edf979 100644 --- a/mobile/openapi/lib/model/tags_response.dart +++ b/mobile/openapi/lib/model/tags_response.dart @@ -46,6 +46,7 @@ class TagsResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagsResponse? fromJson(dynamic value) { + upgradeDto(value, "TagsResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tags_update.dart b/mobile/openapi/lib/model/tags_update.dart index 8355b00a00..d992369140 100644 --- a/mobile/openapi/lib/model/tags_update.dart +++ b/mobile/openapi/lib/model/tags_update.dart @@ -66,6 +66,7 @@ class TagsUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagsUpdate? fromJson(dynamic value) { + upgradeDto(value, "TagsUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/time_bucket_response_dto.dart b/mobile/openapi/lib/model/time_bucket_response_dto.dart index 2c86a56b3c..56044b27a8 100644 --- a/mobile/openapi/lib/model/time_bucket_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_response_dto.dart @@ -46,6 +46,7 @@ class TimeBucketResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TimeBucketResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TimeBucketResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/trash_response_dto.dart b/mobile/openapi/lib/model/trash_response_dto.dart index 52a05ff6d4..2df154d06c 100644 --- a/mobile/openapi/lib/model/trash_response_dto.dart +++ b/mobile/openapi/lib/model/trash_response_dto.dart @@ -40,6 +40,7 @@ class TrashResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TrashResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TrashResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_album_dto.dart b/mobile/openapi/lib/model/update_album_dto.dart index f9c9762887..8353dba14e 100644 --- a/mobile/openapi/lib/model/update_album_dto.dart +++ b/mobile/openapi/lib/model/update_album_dto.dart @@ -114,6 +114,7 @@ class UpdateAlbumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAlbumDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAlbumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_album_user_dto.dart b/mobile/openapi/lib/model/update_album_user_dto.dart index f77223acf5..43218cae6e 100644 --- a/mobile/openapi/lib/model/update_album_user_dto.dart +++ b/mobile/openapi/lib/model/update_album_user_dto.dart @@ -40,6 +40,7 @@ class UpdateAlbumUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAlbumUserDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAlbumUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 9aa413d242..9ebce5fd92 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -158,6 +158,7 @@ class UpdateAssetDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAssetDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAssetDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_library_dto.dart b/mobile/openapi/lib/model/update_library_dto.dart index 85847c0ddf..b85df40172 100644 --- a/mobile/openapi/lib/model/update_library_dto.dart +++ b/mobile/openapi/lib/model/update_library_dto.dart @@ -62,6 +62,7 @@ class UpdateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_partner_dto.dart b/mobile/openapi/lib/model/update_partner_dto.dart index f695f99535..3af3c83ad1 100644 --- a/mobile/openapi/lib/model/update_partner_dto.dart +++ b/mobile/openapi/lib/model/update_partner_dto.dart @@ -40,6 +40,7 @@ class UpdatePartnerDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdatePartnerDto? fromJson(dynamic value) { + upgradeDto(value, "UpdatePartnerDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/usage_by_user_dto.dart b/mobile/openapi/lib/model/usage_by_user_dto.dart index 0bbbba00bb..e6f9216d74 100644 --- a/mobile/openapi/lib/model/usage_by_user_dto.dart +++ b/mobile/openapi/lib/model/usage_by_user_dto.dart @@ -74,6 +74,7 @@ class UsageByUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UsageByUserDto? fromJson(dynamic value) { + upgradeDto(value, "UsageByUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_create_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart index db514a1d57..f2709be57b 100644 --- a/mobile/openapi/lib/model/user_admin_create_dto.dart +++ b/mobile/openapi/lib/model/user_admin_create_dto.dart @@ -105,6 +105,7 @@ class UserAdminCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminCreateDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_delete_dto.dart b/mobile/openapi/lib/model/user_admin_delete_dto.dart index 7778b15775..2cf68ad7b2 100644 --- a/mobile/openapi/lib/model/user_admin_delete_dto.dart +++ b/mobile/openapi/lib/model/user_admin_delete_dto.dart @@ -50,6 +50,7 @@ class UserAdminDeleteDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminDeleteDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index 461596b7bf..e5ae8e1d4e 100644 --- a/mobile/openapi/lib/model/user_admin_response_dto.dart +++ b/mobile/openapi/lib/model/user_admin_response_dto.dart @@ -156,6 +156,7 @@ class UserAdminResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index dd0db767fe..6c6f73ae8e 100644 --- a/mobile/openapi/lib/model/user_admin_update_dto.dart +++ b/mobile/openapi/lib/model/user_admin_update_dto.dart @@ -119,6 +119,7 @@ class UserAdminUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_license.dart b/mobile/openapi/lib/model/user_license.dart index c7abb085f2..9bed8d5c43 100644 --- a/mobile/openapi/lib/model/user_license.dart +++ b/mobile/openapi/lib/model/user_license.dart @@ -52,6 +52,7 @@ class UserLicense { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserLicense? fromJson(dynamic value) { + upgradeDto(value, "UserLicense"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index d3927df8d7..23d9ea84ec 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -88,6 +88,7 @@ class UserPreferencesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserPreferencesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserPreferencesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 2841c2f572..208dbf6860 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -178,6 +178,7 @@ class UserPreferencesUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserPreferencesUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "UserPreferencesUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index 282a5a40dc..a02da29948 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -70,6 +70,7 @@ class UserResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_update_me_dto.dart b/mobile/openapi/lib/model/user_update_me_dto.dart index 2d665fc784..8f3f4df37a 100644 --- a/mobile/openapi/lib/model/user_update_me_dto.dart +++ b/mobile/openapi/lib/model/user_update_me_dto.dart @@ -82,6 +82,7 @@ class UserUpdateMeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserUpdateMeDto? fromJson(dynamic value) { + upgradeDto(value, "UserUpdateMeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_access_token_response_dto.dart b/mobile/openapi/lib/model/validate_access_token_response_dto.dart index e970f7e840..5e36efcfed 100644 --- a/mobile/openapi/lib/model/validate_access_token_response_dto.dart +++ b/mobile/openapi/lib/model/validate_access_token_response_dto.dart @@ -40,6 +40,7 @@ class ValidateAccessTokenResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateAccessTokenResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateAccessTokenResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_dto.dart b/mobile/openapi/lib/model/validate_library_dto.dart index 05e122b1a1..08199e3aa6 100644 --- a/mobile/openapi/lib/model/validate_library_dto.dart +++ b/mobile/openapi/lib/model/validate_library_dto.dart @@ -46,6 +46,7 @@ class ValidateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart index 23aac0b742..11fbbd74c2 100644 --- a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart +++ b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart @@ -62,6 +62,7 @@ class ValidateLibraryImportPathResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryImportPathResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryImportPathResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_response_dto.dart b/mobile/openapi/lib/model/validate_library_response_dto.dart index b213f9ba98..e0dc2a2d14 100644 --- a/mobile/openapi/lib/model/validate_library_response_dto.dart +++ b/mobile/openapi/lib/model/validate_library_response_dto.dart @@ -40,6 +40,7 @@ class ValidateLibraryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index 2ca0463046..bf8b24b557 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -9,11 +9,7 @@ function dart { wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache patch --no-backup-if-mismatch -u native_class.mustache header}} -{{>part_of}} -class ApiClient { - ApiClient({this.basePath = '{{{basePath}}}', this.authentication,}); - - final String basePath; - final Authentication? authentication; - - var _client = Client(); - final _defaultHeaderMap = {}; - - /// Returns the current HTTP [Client] instance to use in this class. - /// - /// The return value is guaranteed to never be null. - Client get client => _client; - - /// Requests to use a new HTTP [Client] in this class. - set client(Client newClient) { - _client = newClient; - } - - Map get defaultHeaderMap => _defaultHeaderMap; - - void addDefaultHeader(String key, String value) { - _defaultHeaderMap[key] = value; - } - - // We don't use a Map for queryParams. - // If collectionFormat is 'multi', a key might appear multiple times. - Future invokeAPI( - String path, - String method, - List queryParams, - Object? body, - Map headerParams, - Map formParams, - String? contentType, - ) async { - await authentication?.applyToParams(queryParams, headerParams); - - headerParams.addAll(_defaultHeaderMap); - if (contentType != null) { - headerParams['Content-Type'] = contentType; - } - - final urlEncodedQueryParams = queryParams.map((param) => '$param'); - final queryString = urlEncodedQueryParams.isNotEmpty ? '?${urlEncodedQueryParams.join('&')}' : ''; - final uri = Uri.parse('$basePath$path$queryString'); - - try { - // Special case for uploading a single file which isn't a 'multipart/form-data'. - if ( - body is MultipartFile && (contentType == null || - !contentType.toLowerCase().startsWith('multipart/form-data')) - ) { - final request = StreamedRequest(method, uri); - request.headers.addAll(headerParams); - request.contentLength = body.length; - body.finalize().listen( - request.sink.add, - onDone: request.sink.close, - // ignore: avoid_types_on_closure_parameters - onError: (Object error, StackTrace trace) => request.sink.close(), - cancelOnError: true, - ); - final response = await _client.send(request); - return Response.fromStream(response); - } - - if (body is MultipartRequest) { - final request = MultipartRequest(method, uri); - request.fields.addAll(body.fields); - request.files.addAll(body.files); - request.headers.addAll(body.headers); - request.headers.addAll(headerParams); - final response = await _client.send(request); - return Response.fromStream(response); - } - - final msgBody = contentType == 'application/x-www-form-urlencoded' - ? formParams - : await serializeAsync(body); - final nullableHeaderParams = headerParams.isEmpty ? null : headerParams; - - switch(method) { - case 'POST': return await _client.post(uri, headers: nullableHeaderParams, body: msgBody,); - case 'PUT': return await _client.put(uri, headers: nullableHeaderParams, body: msgBody,); - case 'DELETE': return await _client.delete(uri, headers: nullableHeaderParams, body: msgBody,); - case 'PATCH': return await _client.patch(uri, headers: nullableHeaderParams, body: msgBody,); - case 'HEAD': return await _client.head(uri, headers: nullableHeaderParams,); - case 'GET': return await _client.get(uri, headers: nullableHeaderParams,); - } - } on SocketException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'Socket operation failed: $method $path', - error, - trace, - ); - } on TlsException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'TLS/SSL communication failed: $method $path', - error, - trace, - ); - } on IOException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'I/O operation failed: $method $path', - error, - trace, - ); - } on ClientException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'HTTP connection failed: $method $path', - error, - trace, - ); - } on Exception catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'Exception occurred: $method $path', - error, - trace, - ); - } - - throw ApiException( - HttpStatus.badRequest, - 'Invalid HTTP operation: $method $path', - ); - } -{{#native_serialization}} - - Future deserializeAsync(String value, String targetType, {bool growable = false,}) async => - // ignore: deprecated_member_use_from_same_package - deserialize(value, targetType, growable: growable); - - @Deprecated('Scheduled for removal in OpenAPI Generator 6.x. Use deserializeAsync() instead.') - dynamic deserialize(String value, String targetType, {bool growable = false,}) { - // Remove all spaces. Necessary for regular expressions as well. - targetType = targetType.replaceAll(' ', ''); // ignore: parameter_assignments - - // If the expected target type is String, nothing to do... - return targetType == 'String' - ? value - : fromJson(json.decode(value), targetType, growable: growable); - } -{{/native_serialization}} - - // ignore: deprecated_member_use_from_same_package - Future serializeAsync(Object? value) async => serialize(value); - - @Deprecated('Scheduled for removal in OpenAPI Generator 6.x. Use serializeAsync() instead.') - String serialize(Object? value) => value == null ? '' : json.encode(value); - -{{#native_serialization}} - /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. - static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { - upgradeDto(value, targetType); - try { - switch (targetType) { - case 'String': - return value is String ? value : value.toString(); - case 'int': - return value is int ? value : int.parse('$value'); - case 'double': - return value is double ? value : double.parse('$value'); - case 'bool': - if (value is bool) { - return value; - } - final valueString = '$value'.toLowerCase(); - return valueString == 'true' || valueString == '1'; - case 'DateTime': - return value is DateTime ? value : DateTime.tryParse(value); - {{#models}} - {{#model}} - case '{{{classname}}}': - {{#isEnum}} - {{#native_serialization}}return {{{classname}}}TypeTransformer().decode(value);{{/native_serialization}} - {{/isEnum}} - {{^isEnum}} - return {{{classname}}}.fromJson(value); - {{/isEnum}} - {{/model}} - {{/models}} - default: - dynamic match; - if (value is List && (match = _regList.firstMatch(targetType)?.group(1)) != null) { - return value - .map((dynamic v) => fromJson(v, match, growable: growable,)) - .toList(growable: growable); - } - if (value is Set && (match = _regSet.firstMatch(targetType)?.group(1)) != null) { - return value - .map((dynamic v) => fromJson(v, match, growable: growable,)) - .toSet(); - } - if (value is Map && (match = _regMap.firstMatch(targetType)?.group(1)) != null) { - return Map.fromIterables( - value.keys.cast(), - value.values.map((dynamic v) => fromJson(v, match, growable: growable,)), - ); - } - } - } on Exception catch (error, trace) { - throw ApiException.withInner(HttpStatus.internalServerError, 'Exception during deserialization.', error, trace,); - } - throw ApiException(HttpStatus.internalServerError, 'Could not find a suitable class for deserialization',); - } -{{/native_serialization}} -} -{{#native_serialization}} - -/// Primarily intended for use in an isolate. -class DeserializationMessage { - const DeserializationMessage({ - required this.json, - required this.targetType, - this.growable = false, - }); - - /// The JSON value to deserialize. - final String json; - - /// Target type to deserialize to. - final String targetType; - - /// Whether to make deserialized lists or maps growable. - final bool growable; -} - -/// Primarily intended for use in an isolate. -Future decodeAsync(DeserializationMessage message) async { - // Remove all spaces. Necessary for regular expressions as well. - final targetType = message.targetType.replaceAll(' ', ''); - - // If the expected target type is String, nothing to do... - return targetType == 'String' - ? message.json - : json.decode(message.json); -} - -/// Primarily intended for use in an isolate. -Future deserializeAsync(DeserializationMessage message) async { - // Remove all spaces. Necessary for regular expressions as well. - final targetType = message.targetType.replaceAll(' ', ''); - - // If the expected target type is String, nothing to do... - return targetType == 'String' - ? message.json - : ApiClient.fromJson( - json.decode(message.json), - targetType, - growable: message.growable, - ); -} -{{/native_serialization}} - -/// Primarily intended for use in an isolate. -Future serializeAsync(Object? value) async => value == null ? '' : json.encode(value); diff --git a/open-api/templates/mobile/api_client.mustache.patch b/open-api/templates/mobile/api_client.mustache.patch deleted file mode 100644 index 3805cd8f79..0000000000 --- a/open-api/templates/mobile/api_client.mustache.patch +++ /dev/null @@ -1,10 +0,0 @@ ---- api_client.mustache 2024-08-13 14:29:04.056364916 -0500 -+++ api_client_new.mustache 2024-08-13 14:29:36.224410735 -0500 -@@ -159,6 +159,7 @@ - {{#native_serialization}} - /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. - static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { -+ upgradeDto(value, targetType); - try { - switch (targetType) { - case 'String': diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache index 254843e00e..9a7b1439b1 100644 --- a/open-api/templates/mobile/serialization/native/native_class.mustache +++ b/open-api/templates/mobile/serialization/native/native_class.mustache @@ -111,6 +111,7 @@ class {{{classname}}} { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static {{{classname}}}? fromJson(dynamic value) { + upgradeDto(value, "{{{classname}}}"); if (value is Map) { final json = value.cast(); diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache.patch b/open-api/templates/mobile/serialization/native/native_class.mustache.patch index 02e07f933a..4ba6594966 100644 --- a/open-api/templates/mobile/serialization/native/native_class.mustache.patch +++ b/open-api/templates/mobile/serialization/native/native_class.mustache.patch @@ -1,5 +1,5 @@ ---- native_class.mustache 2023-08-31 23:09:59.584269162 +0200 -+++ native_class1.mustache 2023-08-31 22:59:53.633083270 +0200 +--- native_class.mustache 2024-09-19 11:41:07.855683995 -0400 ++++ native_class_temp.mustache 2024-09-19 11:41:57.113249395 -0400 @@ -91,14 +91,14 @@ {{/isDateTime}} {{#isNullable}} @@ -17,10 +17,14 @@ } {{/defaultValue}} {{/required}} -@@ -114,17 +114,6 @@ +@@ -111,20 +111,10 @@ + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static {{{classname}}}? fromJson(dynamic value) { ++ upgradeDto(value, "{{{classname}}}"); if (value is Map) { final json = value.cast(); - + - // Ensure that the map contains the required keys. - // Note 1: the values aren't checked for validity beyond being non-null. - // Note 2: this code is stripped in release mode! @@ -35,9 +39,9 @@ return {{{classname}}}( {{#vars}} {{#isDateTime}} -@@ -215,6 +204,10 @@ +@@ -215,6 +205,10 @@ ? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}} - : {{{datatypeWithEnum}}}.parse(json[r'{{{baseName}}}'].toString()), + : {{/isNullable}}{{{datatypeWithEnum}}}.parse('${json[r'{{{baseName}}}']}'), {{/isNumber}} + {{#isDouble}} + {{{name}}}: (mapValueOfType(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}).toDouble(), @@ -46,7 +50,7 @@ {{^isNumber}} {{^isEnum}} {{{name}}}: mapValueOfType<{{{datatypeWithEnum}}}>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, -@@ -223,6 +216,7 @@ +@@ -223,6 +217,7 @@ {{{name}}}: {{{enumName}}}.fromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, {{/isEnum}} {{/isNumber}} From e41785b1a1e6591c7b385f97d45d8417f8c451ef Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 23 Sep 2024 22:08:01 +0200 Subject: [PATCH 421/723] fix: open api (#12878) --- mobile/openapi/lib/model/random_search_dto.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 8dbbeb5387..419cb451e2 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -493,6 +493,7 @@ class RandomSearchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static RandomSearchDto? fromJson(dynamic value) { + upgradeDto(value, "RandomSearchDto"); if (value is Map) { final json = value.cast(); From bcd416477b0d9dd76f3a2f11547f220354c0f9a0 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Mon, 23 Sep 2024 21:30:23 +0100 Subject: [PATCH 422/723] feat: serve map tile styles from tiles.immich.cloud (#12858) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- e2e/package-lock.json | 6 +- e2e/src/api/specs/map.e2e-spec.ts | 65 +- e2e/src/api/specs/server-info.e2e-spec.ts | 2 + e2e/src/api/specs/server.e2e-spec.ts | 2 + mobile/.vscode/settings.json | 2 +- .../server_info/server_config.model.dart | 10 +- mobile/lib/pages/search/map/map.page.dart | 15 +- .../lib/providers/map/map_state.provider.dart | 75 +- .../lib/providers/server_info.provider.dart | 3 + mobile/lib/utils/openapi_patching.dart | 13 + mobile/openapi/README.md | 2 - mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api/map_api.dart | 56 - mobile/openapi/lib/api_client.dart | 2 - mobile/openapi/lib/api_helper.dart | 3 - mobile/openapi/lib/model/map_theme.dart | 85 -- .../openapi/lib/model/server_config_dto.dart | 18 +- open-api/immich-openapi-specs.json | 66 +- open-api/typescript-sdk/src/fetch-client.ts | 20 +- server/package-lock.json | 1122 +++++++++-------- server/src/config.ts | 4 +- server/src/controllers/map.controller.ts | 7 - server/src/dtos/server.dto.ts | 2 + server/src/dtos/system-config.dto.ts | 6 +- server/src/services/map.service.ts | 11 - server/src/services/server.service.spec.ts | 2 + server/src/services/server.service.ts | 2 + .../services/system-config.service.spec.ts | 4 +- .../shared-components/map/map.svelte | 16 +- web/src/lib/stores/server-config.store.ts | 2 + 30 files changed, 676 insertions(+), 948 deletions(-) delete mode 100644 mobile/openapi/lib/model/map_theme.dart diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 865f154d6b..ab4fd53fbf 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -5016,9 +5016,9 @@ } }, "node_modules/path-to-regexp": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", - "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true }, "node_modules/pathe": { diff --git a/e2e/src/api/specs/map.e2e-spec.ts b/e2e/src/api/specs/map.e2e-spec.ts index 343a7c91d0..da5f779cff 100644 --- a/e2e/src/api/specs/map.e2e-spec.ts +++ b/e2e/src/api/specs/map.e2e-spec.ts @@ -1,8 +1,7 @@ -import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; +import { LoginResponseDto } from '@immich/sdk'; import { readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; -import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; @@ -11,18 +10,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; describe('/map', () => { let websocket: Socket; let admin: LoginResponseDto; - let nonAdmin: LoginResponseDto; - let asset: AssetMediaResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); websocket = await utils.connectWebsocket(admin.accessToken); - asset = await utils.createAsset(admin.accessToken); - const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg']; utils.resetEvents(); const uploadFile = async (input: string) => { @@ -103,63 +97,6 @@ describe('/map', () => { }); }); - describe('GET /map/style.json', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/map/style.json'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should allow shared link access', async () => { - const sharedLink = await utils.createSharedLink(admin.accessToken, { - type: SharedLinkType.Individual, - assetIds: [asset.id], - }); - const { status, body } = await request(app).get(`/map/style.json?key=${sharedLink.key}`).query({ theme: 'dark' }); - - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - - it('should throw an error if a theme is not light or dark', async () => { - for (const theme of ['dark1', true, 123, '', null, undefined]) { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['theme must be one of the following values: light, dark'])); - } - }); - - it('should return the light style.json', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'light' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' })); - }); - - it('should return the dark style.json', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'dark' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - - it('should not require admin authentication', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'dark' }) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - }); - describe('GET /map/reverse-geocode', () => { it('should require authentication', async () => { const { status, body } = await request(app).get('/map/reverse-geocode'); diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index 571d98cda7..1ef8d8602a 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -128,6 +128,8 @@ describe('/server-info', () => { isInitialized: true, externalDomain: '', isOnboarded: false, + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); }); }); diff --git a/e2e/src/api/specs/server.e2e-spec.ts b/e2e/src/api/specs/server.e2e-spec.ts index b19e6d85c4..3133460ada 100644 --- a/e2e/src/api/specs/server.e2e-spec.ts +++ b/e2e/src/api/specs/server.e2e-spec.ts @@ -134,6 +134,8 @@ describe('/server', () => { isInitialized: true, externalDomain: '', isOnboarded: false, + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); }); }); diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json index aa43dab3fb..ceaf9a6ab8 100644 --- a/mobile/.vscode/settings.json +++ b/mobile/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.24.0", + "dart.flutterSdkPath": ".fvm/versions/3.24.3", "search.exclude": { "**/.fvm": true }, diff --git a/mobile/lib/models/server_info/server_config.model.dart b/mobile/lib/models/server_info/server_config.model.dart index 8936939135..f07ffde522 100644 --- a/mobile/lib/models/server_info/server_config.model.dart +++ b/mobile/lib/models/server_info/server_config.model.dart @@ -4,11 +4,15 @@ class ServerConfig { final int trashDays; final String oauthButtonText; final String externalDomain; + final String mapDarkStyleUrl; + final String mapLightStyleUrl; const ServerConfig({ required this.trashDays, required this.oauthButtonText, required this.externalDomain, + required this.mapDarkStyleUrl, + required this.mapLightStyleUrl, }); ServerConfig copyWith({ @@ -20,6 +24,8 @@ class ServerConfig { trashDays: trashDays ?? this.trashDays, oauthButtonText: oauthButtonText ?? this.oauthButtonText, externalDomain: externalDomain ?? this.externalDomain, + mapDarkStyleUrl: mapDarkStyleUrl, + mapLightStyleUrl: mapLightStyleUrl, ); } @@ -30,7 +36,9 @@ class ServerConfig { ServerConfig.fromDto(ServerConfigDto dto) : trashDays = dto.trashDays, oauthButtonText = dto.oauthButtonText, - externalDomain = dto.externalDomain; + externalDomain = dto.externalDomain, + mapDarkStyleUrl = dto.mapDarkStyleUrl, + mapLightStyleUrl = dto.mapLightStyleUrl; @override bool operator ==(covariant ServerConfig other) { diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index d226ea55a3..3be7e9b3e5 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -1,4 +1,5 @@ import 'dart:math'; + import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -7,27 +8,27 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:geolocator/geolocator.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/latlngbounds_extension.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/models/map/map_event.model.dart'; import 'package:immich_mobile/models/map/map_marker.model.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/map/map_marker.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/debounce.dart'; +import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/utils/map_utils.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/map/map_app_bar.dart'; import 'package:immich_mobile/widgets/map/map_asset_grid.dart'; import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/utils/immich_loading_overlay.dart'; -import 'package:immich_mobile/utils/debounce.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() @@ -304,7 +305,7 @@ class MapPage extends HookConsumerWidget { ), Positioned( right: 0, - bottom: MediaQuery.of(context).padding.bottom + 16, + bottom: MediaQuery.paddingOf(context).bottom + 16, child: ElevatedButton( onPressed: onZoomToLocation, style: ElevatedButton.styleFrom( diff --git a/mobile/lib/providers/map/map_state.provider.dart b/mobile/lib/providers/map/map_state.provider.dart index 6d1630bba2..189a23cd0a 100644 --- a/mobile/lib/providers/map/map_state.provider.dart +++ b/mobile/lib/providers/map/map_state.provider.dart @@ -1,28 +1,23 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/models/map/map_state.model.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'map_state.provider.g.dart'; @Riverpod(keepAlive: true) class MapStateNotifier extends _$MapStateNotifier { - final _log = Logger("MapStateNotifier"); - @override MapState build() { final appSettingsProvider = ref.read(appSettingsServiceProvider); - // Fetch and save the Style JSONs - loadStyles(); + final lightStyleUrl = + ref.read(serverInfoProvider).serverConfig.mapLightStyleUrl; + final darkStyleUrl = + ref.read(serverInfoProvider).serverConfig.mapDarkStyleUrl; + return MapState( themeMode: ThemeMode.values[ appSettingsProvider.getSetting(AppSettingsEnum.mapThemeMode)], @@ -34,65 +29,11 @@ class MapStateNotifier extends _$MapStateNotifier { appSettingsProvider.getSetting(AppSettingsEnum.mapwithPartners), relativeTime: appSettingsProvider.getSetting(AppSettingsEnum.mapRelativeDate), + lightStyleFetched: AsyncData(lightStyleUrl), + darkStyleFetched: AsyncData(darkStyleUrl), ); } - void loadStyles() async { - final documents = (await getApplicationDocumentsDirectory()).path; - - // Set to loading - state = state.copyWith(lightStyleFetched: const AsyncLoading()); - - // Fetch and save light theme - final lightResponse = await ref - .read(apiServiceProvider) - .mapApi - .getMapStyleWithHttpInfo(MapTheme.light); - - if (lightResponse.statusCode >= HttpStatus.badRequest) { - state = state.copyWith( - lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current), - ); - _log.severe( - "Cannot fetch map light style", - lightResponse.toLoggerString(), - ); - return; - } - - final lightJSON = lightResponse.body; - final lightFile = await File("$documents/map-style-light.json") - .writeAsString(lightJSON, flush: true); - - // Update state with path - state = - state.copyWith(lightStyleFetched: AsyncData(lightFile.absolute.path)); - - // Set to loading - state = state.copyWith(darkStyleFetched: const AsyncLoading()); - - // Fetch and save dark theme - final darkResponse = await ref - .read(apiServiceProvider) - .mapApi - .getMapStyleWithHttpInfo(MapTheme.dark); - - if (darkResponse.statusCode >= HttpStatus.badRequest) { - state = state.copyWith( - darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current), - ); - _log.severe("Cannot fetch map dark style", darkResponse.toLoggerString()); - return; - } - - final darkJSON = darkResponse.body; - final darkFile = await File("$documents/map-style-dark.json") - .writeAsString(darkJSON, flush: true); - - // Update state with path - state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path)); - } - void switchTheme(ThemeMode mode) { ref.read(appSettingsServiceProvider).setSetting( AppSettingsEnum.mapThemeMode, diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index 6327f992f5..14521b06f6 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -34,6 +34,9 @@ class ServerInfoNotifier extends StateNotifier { trashDays: 30, oauthButtonText: '', externalDomain: '', + mapLightStyleUrl: + 'https://tiles.immich.cloud/v1/style/light.json', + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', ), serverDiskInfo: const ServerDiskInfo( diskAvailable: "0", diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index c473fbb833..255ad01247 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -12,6 +12,19 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'tags', TagsResponse().toJson()); } break; + case 'ServerConfigDto': + if (value is Map) { + addDefault( + value, + 'mapLightStyleUrl', + 'https://tiles.immich.cloud/v1/style/light.json', + ); + addDefault( + value, + 'mapDarkStyleUrl', + 'https://tiles.immich.cloud/v1/style/dark.json', + ); + } case 'UserResponseDto': if (value is Map) { addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c8135519de..285514e11c 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -138,7 +138,6 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | *MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | -*MapApi* | [**getMapStyle**](doc//MapApi.md#getmapstyle) | **GET** /map/style.json | *MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | *MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | *MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories | @@ -348,7 +347,6 @@ Class | Method | HTTP request | Description - [ManualJobName](doc//ManualJobName.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - - [MapTheme](doc//MapTheme.md) - [MemoriesResponse](doc//MemoriesResponse.md) - [MemoriesUpdate](doc//MemoriesUpdate.md) - [MemoryCreateDto](doc//MemoryCreateDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 7fa06b0487..fc0224a8c2 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -159,7 +159,6 @@ part 'model/logout_response_dto.dart'; part 'model/manual_job_name.dart'; part 'model/map_marker_response_dto.dart'; part 'model/map_reverse_geocode_response_dto.dart'; -part 'model/map_theme.dart'; part 'model/memories_response.dart'; part 'model/memories_update.dart'; part 'model/memory_create_dto.dart'; diff --git a/mobile/openapi/lib/api/map_api.dart b/mobile/openapi/lib/api/map_api.dart index 2846dae6c3..9644fbfc5c 100644 --- a/mobile/openapi/lib/api/map_api.dart +++ b/mobile/openapi/lib/api/map_api.dart @@ -105,62 +105,6 @@ class MapApi { return null; } - /// Performs an HTTP 'GET /map/style.json' operation and returns the [Response]. - /// Parameters: - /// - /// * [MapTheme] theme (required): - /// - /// * [String] key: - Future getMapStyleWithHttpInfo(MapTheme theme, { String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/map/style.json'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - queryParams.addAll(_queryParams('', 'theme', theme)); - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [MapTheme] theme (required): - /// - /// * [String] key: - Future getMapStyle(MapTheme theme, { String? key, }) async { - final response = await getMapStyleWithHttpInfo(theme, key: key, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object; - - } - return null; - } - /// Performs an HTTP 'GET /map/reverse-geocode' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index e857f51e3a..828c0b9ed9 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -372,8 +372,6 @@ class ApiClient { return MapMarkerResponseDto.fromJson(value); case 'MapReverseGeocodeResponseDto': return MapReverseGeocodeResponseDto.fromJson(value); - case 'MapTheme': - return MapThemeTypeTransformer().decode(value); case 'MemoriesResponse': return MemoriesResponse.fromJson(value); case 'MemoriesUpdate': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 0f3cc41097..b7c6ad5e01 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -100,9 +100,6 @@ String parameterToString(dynamic value) { if (value is ManualJobName) { return ManualJobNameTypeTransformer().encode(value).toString(); } - if (value is MapTheme) { - return MapThemeTypeTransformer().encode(value).toString(); - } if (value is MemoryType) { return MemoryTypeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/map_theme.dart b/mobile/openapi/lib/model/map_theme.dart deleted file mode 100644 index e2553790c6..0000000000 --- a/mobile/openapi/lib/model/map_theme.dart +++ /dev/null @@ -1,85 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class MapTheme { - /// Instantiate a new enum with the provided [value]. - const MapTheme._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const light = MapTheme._(r'light'); - static const dark = MapTheme._(r'dark'); - - /// List of all possible values in this [enum][MapTheme]. - static const values = [ - light, - dark, - ]; - - static MapTheme? fromJson(dynamic value) => MapThemeTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = MapTheme.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [MapTheme] to String, -/// and [decode] dynamic data back to [MapTheme]. -class MapThemeTypeTransformer { - factory MapThemeTypeTransformer() => _instance ??= const MapThemeTypeTransformer._(); - - const MapThemeTypeTransformer._(); - - String encode(MapTheme data) => data.value; - - /// Decodes a [dynamic value][data] to a MapTheme. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - MapTheme? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'light': return MapTheme.light; - case r'dark': return MapTheme.dark; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [MapThemeTypeTransformer] instance. - static MapThemeTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index c45ed32ac0..bd5c2405e2 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -17,6 +17,8 @@ class ServerConfigDto { required this.isInitialized, required this.isOnboarded, required this.loginPageMessage, + required this.mapDarkStyleUrl, + required this.mapLightStyleUrl, required this.oauthButtonText, required this.trashDays, required this.userDeleteDelay, @@ -30,6 +32,10 @@ class ServerConfigDto { String loginPageMessage; + String mapDarkStyleUrl; + + String mapLightStyleUrl; + String oauthButtonText; int trashDays; @@ -42,6 +48,8 @@ class ServerConfigDto { other.isInitialized == isInitialized && other.isOnboarded == isOnboarded && other.loginPageMessage == loginPageMessage && + other.mapDarkStyleUrl == mapDarkStyleUrl && + other.mapLightStyleUrl == mapLightStyleUrl && other.oauthButtonText == oauthButtonText && other.trashDays == trashDays && other.userDeleteDelay == userDeleteDelay; @@ -53,12 +61,14 @@ class ServerConfigDto { (isInitialized.hashCode) + (isOnboarded.hashCode) + (loginPageMessage.hashCode) + + (mapDarkStyleUrl.hashCode) + + (mapLightStyleUrl.hashCode) + (oauthButtonText.hashCode) + (trashDays.hashCode) + (userDeleteDelay.hashCode); @override - String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; + String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; Map toJson() { final json = {}; @@ -66,6 +76,8 @@ class ServerConfigDto { json[r'isInitialized'] = this.isInitialized; json[r'isOnboarded'] = this.isOnboarded; json[r'loginPageMessage'] = this.loginPageMessage; + json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl; + json[r'mapLightStyleUrl'] = this.mapLightStyleUrl; json[r'oauthButtonText'] = this.oauthButtonText; json[r'trashDays'] = this.trashDays; json[r'userDeleteDelay'] = this.userDeleteDelay; @@ -85,6 +97,8 @@ class ServerConfigDto { isInitialized: mapValueOfType(json, r'isInitialized')!, isOnboarded: mapValueOfType(json, r'isOnboarded')!, loginPageMessage: mapValueOfType(json, r'loginPageMessage')!, + mapDarkStyleUrl: mapValueOfType(json, r'mapDarkStyleUrl')!, + mapLightStyleUrl: mapValueOfType(json, r'mapLightStyleUrl')!, oauthButtonText: mapValueOfType(json, r'oauthButtonText')!, trashDays: mapValueOfType(json, r'trashDays')!, userDeleteDelay: mapValueOfType(json, r'userDeleteDelay')!, @@ -139,6 +153,8 @@ class ServerConfigDto { 'isInitialized', 'isOnboarded', 'loginPageMessage', + 'mapDarkStyleUrl', + 'mapLightStyleUrl', 'oauthButtonText', 'trashDays', 'userDeleteDelay', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 706ff5b8fb..4e7c711978 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3167,55 +3167,6 @@ ] } }, - "/map/style.json": { - "get": { - "operationId": "getMapStyle", - "parameters": [ - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "theme", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/MapTheme" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Map" - ] - } - }, "/memories": { "get": { "operationId": "searchMemories", @@ -5356,8 +5307,8 @@ "name": "password", "required": false, "in": "query", - "example": "password", "schema": { + "example": "password", "type": "string" } }, @@ -9695,13 +9646,6 @@ ], "type": "object" }, - "MapTheme": { - "enum": [ - "light", - "dark" - ], - "type": "string" - }, "MemoriesResponse": { "properties": { "enabled": { @@ -10917,6 +10861,12 @@ "loginPageMessage": { "type": "string" }, + "mapDarkStyleUrl": { + "type": "string" + }, + "mapLightStyleUrl": { + "type": "string" + }, "oauthButtonText": { "type": "string" }, @@ -10932,6 +10882,8 @@ "isInitialized", "isOnboarded", "loginPageMessage", + "mapDarkStyleUrl", + "mapLightStyleUrl", "oauthButtonText", "trashDays", "userDeleteDelay" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 8e607f7570..d1b88afabb 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -928,6 +928,8 @@ export type ServerConfigDto = { isInitialized: boolean; isOnboarded: boolean; loginPageMessage: string; + mapDarkStyleUrl: string; + mapLightStyleUrl: string; oauthButtonText: string; trashDays: number; userDeleteDelay: number; @@ -2138,20 +2140,6 @@ export function reverseGeocode({ lat, lon }: { ...opts })); } -export function getMapStyle({ key, theme }: { - key?: string; - theme: MapTheme; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: object; - }>(`/map/style.json${QS.query(QS.explode({ - key, - theme - }))}`, { - ...opts - })); -} export function searchMemories(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3469,10 +3457,6 @@ export enum JobCommand { Empty = "empty", ClearFailed = "clear-failed" } -export enum MapTheme { - Light = "light", - Dark = "dark" -} export enum MemoryType { OnThisDay = "on_this_day" } diff --git a/server/package-lock.json b/server/package-lock.json index ee432b9e06..9abfc6b5ce 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -733,9 +733,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -749,9 +749,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -765,9 +765,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -781,9 +781,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -797,9 +797,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -813,9 +813,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -829,9 +829,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -845,9 +845,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -861,9 +861,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -877,9 +877,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -893,9 +893,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -909,9 +909,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -925,9 +925,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -941,9 +941,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -957,9 +957,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -973,9 +973,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -989,9 +989,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -1005,9 +1005,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -1021,9 +1021,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -1037,9 +1037,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -1053,9 +1053,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -1069,9 +1069,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -1085,9 +1085,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -2085,16 +2085,16 @@ } }, "node_modules/@nestjs/core": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.1.tgz", - "integrity": "sha512-9I1WdfOBCCHdUm+ClBJupOuZQS6UxzIWHIq6Vp1brAA5ZKl/Wq6BVwSsbnUJGBy3J3PM2XHmR0EQ4fwX3nR7lA==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.4.tgz", + "integrity": "sha512-y9tjmAzU6LTh1cC/lWrRsCcOd80khSR0qAHAqwY2svbW+AhsR/XCzgpZrAAKJrm/dDfjLCZKyxJSayeirGcW5Q==", "hasInstallScript": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "3.2.0", - "tslib": "2.6.3", + "path-to-regexp": "3.3.0", + "tslib": "2.7.0", "uid": "2.0.2" }, "funding": { @@ -2121,6 +2121,11 @@ } } }, + "node_modules/@nestjs/core/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/event-emitter": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.0.4.tgz", @@ -2153,15 +2158,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.1.tgz", - "integrity": "sha512-ccfqIDAq/bg1ShLI5KGtaLaYGykuAdvCi57ohewH7eKJSIpWY1DQjbgKlFfXokALYUq1YOMGqjeZ244OWHfDQg==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.4.tgz", + "integrity": "sha512-y52q1MxhbHaT3vAgWd08RgiYon0lJgtTa8U6g6gV0KI0IygwZhDQFJVxnrRDUdxQGIP5CKHmfQu3sk9gTNFoEA==", "dependencies": { - "body-parser": "1.20.2", + "body-parser": "1.20.3", "cors": "2.8.5", - "express": "4.19.2", + "express": "4.21.0", "multer": "1.4.4-lts.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2172,6 +2177,11 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/platform-socket.io": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", @@ -2238,15 +2248,15 @@ "dev": true }, "node_modules/@nestjs/swagger": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.0.tgz", - "integrity": "sha512-dCiwKkRxcR7dZs5jtrGspBAe/nqJd1AYzOBTzw9iCdbq3BGrLpwokelk6lFZPe4twpTsPQqzNKBwKzVbI6AR/g==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", "dependencies": { "@microsoft/tsdoc": "^0.15.0", "@nestjs/mapped-types": "2.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", - "path-to-regexp": "3.2.0", + "path-to-regexp": "3.3.0", "swagger-ui-dist": "5.17.14" }, "peerDependencies": { @@ -4551,9 +4561,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.3.tgz", - "integrity": "sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], @@ -4564,9 +4574,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.3.tgz", - "integrity": "sha512-eQK5JIi+POhFpzk+LnjKIy4Ks+pwJ+NXmPxOCSvOKSNRPONzKuUvWE+P9JxGZVxrtzm6BAYMaL50FFuPe0oWMQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], @@ -4577,9 +4587,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.3.tgz", - "integrity": "sha512-Od4vE6f6CTT53yM1jgcLqNfItTsLt5zE46fdPaEmeFHvPs5SjZYlLpHrSiHEKR1+HdRfxuzXHjDOIxQyC3ptBA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], @@ -4590,9 +4600,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.3.tgz", - "integrity": "sha512-0IMAO21axJeNIrvS9lSe/PGthc8ZUS+zC53O0VhF5gMxfmcKAP4ESkKOCwEi6u2asUrt4mQv2rjY8QseIEb1aw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], @@ -4603,9 +4613,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.3.tgz", - "integrity": "sha512-ge2DC7tHRHa3caVEoSbPRJpq7azhG+xYsd6u2MEnJ6XzPSzQsTKyXvh6iWjXRf7Rt9ykIUWHtl0Uz3T6yXPpKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], @@ -4616,9 +4626,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.14.3.tgz", - "integrity": "sha512-ljcuiDI4V3ySuc7eSk4lQ9wU8J8r8KrOUvB2U+TtK0TiW6OFDmJ+DdIjjwZHIw9CNxzbmXY39wwpzYuFDwNXuw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], @@ -4629,9 +4639,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.3.tgz", - "integrity": "sha512-Eci2us9VTHm1eSyn5/eEpaC7eP/mp5n46gTRB3Aar3BgSvDQGJZuicyq6TsH4HngNBgVqC5sDYxOzTExSU+NjA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], @@ -4642,9 +4652,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.3.tgz", - "integrity": "sha512-UrBoMLCq4E92/LCqlh+blpqMz5h1tJttPIniwUgOFJyjWI1qrtrDhhpHPuFxULlUmjFHfloWdixtDhSxJt5iKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], @@ -4655,9 +4665,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.3.tgz", - "integrity": "sha512-5aRjvsS8q1nWN8AoRfrq5+9IflC3P1leMoy4r2WjXyFqf3qcqsxRCfxtZIV58tCxd+Yv7WELPcO9mY9aeQyAmw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], @@ -4668,9 +4678,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.3.tgz", - "integrity": "sha512-sk/Qh1j2/RJSX7FhEpJn8n0ndxy/uf0kI/9Zc4b1ELhqULVdTfN6HL31CDaTChiBAOgLcsJ1sgVZjWv8XNEsAQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], @@ -4681,9 +4691,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.3.tgz", - "integrity": "sha512-jOO/PEaDitOmY9TgkxF/TQIjXySQe5KVYB57H/8LRP/ux0ZoO8cSHCX17asMSv3ruwslXW/TLBcxyaUzGRHcqg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], @@ -4694,9 +4704,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.3.tgz", - "integrity": "sha512-8ybV4Xjy59xLMyWo3GCfEGqtKV5M5gCSrZlxkPGvEPCGDLNla7v48S662HSGwRd6/2cSneMQWiv+QzcttLrrOA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "cpu": [ "x64" ], @@ -4707,9 +4717,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.3.tgz", - "integrity": "sha512-s+xf1I46trOY10OqAtZ5Rm6lzHre/UiLA1J2uOhCFXWkbZrJRkYBPO6FhvGfHmdtQ3Bx793MNa7LvoWFAm93bg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "cpu": [ "x64" ], @@ -4720,9 +4730,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.3.tgz", - "integrity": "sha512-+4h2WrGOYsOumDQ5S2sYNyhVfrue+9tc9XcLWLh+Kw3UOxAvrfOrSMFon60KspcDdytkNDh7K2Vs6eMaYImAZg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], @@ -4733,9 +4743,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.3.tgz", - "integrity": "sha512-T1l7y/bCeL/kUwh9OD4PQT4aM7Bq43vX05htPJJ46RTI4r5KNt6qJRzAfNfM+OYMNEVBWQzR2Gyk+FXLZfogGw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], @@ -4746,9 +4756,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.3.tgz", - "integrity": "sha512-/BypzV0H1y1HzgYpxqRaXGBRqfodgoBBCcsrujT6QRcakDQdfU+Lq9PENPh5jB4I44YWq+0C2eHsHya+nZY1sA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], @@ -6689,9 +6699,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -6701,7 +6711,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -7995,9 +8005,9 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } @@ -8012,9 +8022,9 @@ } }, "node_modules/engine.io": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.2.tgz", - "integrity": "sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "dependencies": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", @@ -8025,7 +8035,7 @@ "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" + "ws": "~8.17.1" }, "engines": { "node": ">=10.2.0" @@ -8097,9 +8107,9 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "bin": { @@ -8109,29 +8119,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/escalade": { @@ -8524,36 +8534,36 @@ ] }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -8586,9 +8596,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/extend": { "version": "3.0.2", @@ -8719,12 +8729,12 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -10281,9 +10291,12 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", @@ -10308,11 +10321,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -10928,9 +10941,12 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11247,9 +11263,9 @@ } }, "node_modules/path-to-regexp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", - "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==" }, "node_modules/path-type": { "version": "4.0.0", @@ -11384,9 +11400,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "node_modules/picomatch": { "version": "4.0.2", @@ -11433,9 +11449,9 @@ "integrity": "sha512-3hTIM2j/v9Lio+wOyur3kckD4NxruZhpowUbEgmyikW+a2Kppjtu1eN+AhnMQtoHW46zld88JiYWv6fxpsDrTQ==" }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "funding": [ { "type": "opencollective", @@ -11452,8 +11468,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -11806,11 +11822,11 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -12862,9 +12878,9 @@ } }, "node_modules/rollup": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.3.tgz", - "integrity": "sha512-ag5tTQKYsj1bhrFC9+OEWqb5O6VYgtQDO9hPDBMmIbePwhfSr+ExlcU741t8Dhw5DkPCQf6noz0jb36D6W9/hw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -12877,22 +12893,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.14.3", - "@rollup/rollup-android-arm64": "4.14.3", - "@rollup/rollup-darwin-arm64": "4.14.3", - "@rollup/rollup-darwin-x64": "4.14.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.3", - "@rollup/rollup-linux-arm-musleabihf": "4.14.3", - "@rollup/rollup-linux-arm64-gnu": "4.14.3", - "@rollup/rollup-linux-arm64-musl": "4.14.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.3", - "@rollup/rollup-linux-riscv64-gnu": "4.14.3", - "@rollup/rollup-linux-s390x-gnu": "4.14.3", - "@rollup/rollup-linux-x64-gnu": "4.14.3", - "@rollup/rollup-linux-x64-musl": "4.14.3", - "@rollup/rollup-win32-arm64-msvc": "4.14.3", - "@rollup/rollup-win32-ia32-msvc": "4.14.3", - "@rollup/rollup-win32-x64-msvc": "4.14.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" } }, @@ -13047,9 +13063,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -13082,6 +13098,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -13092,14 +13116,14 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -13228,13 +13252,17 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -13314,26 +13342,6 @@ "ws": "~8.17.1" } }, - "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -13356,9 +13364,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } @@ -13820,9 +13828,9 @@ } }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -14794,14 +14802,14 @@ } }, "node_modules/vite": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", - "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", + "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", "dev": true, "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -14820,6 +14828,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -14837,6 +14846,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -15167,15 +15179,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -15779,163 +15791,163 @@ } }, "@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "dev": true, "optional": true }, "@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "dev": true, "optional": true }, "@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "dev": true, "optional": true }, "@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "dev": true, "optional": true }, "@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "dev": true, "optional": true }, "@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "dev": true, "optional": true }, "@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "dev": true, "optional": true }, "@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "dev": true, "optional": true }, "@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "dev": true, "optional": true }, "@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "dev": true, "optional": true }, "@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "dev": true, "optional": true }, "@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "dev": true, "optional": true }, "@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "dev": true, "optional": true }, "@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "dev": true, "optional": true }, "@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "dev": true, "optional": true }, "@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "dev": true, "optional": true }, "@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "dev": true, "optional": true }, "@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "dev": true, "optional": true }, "@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "dev": true, "optional": true }, "@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "dev": true, "optional": true }, "@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "dev": true, "optional": true }, "@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "dev": true, "optional": true }, @@ -16520,16 +16532,23 @@ } }, "@nestjs/core": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.1.tgz", - "integrity": "sha512-9I1WdfOBCCHdUm+ClBJupOuZQS6UxzIWHIq6Vp1brAA5ZKl/Wq6BVwSsbnUJGBy3J3PM2XHmR0EQ4fwX3nR7lA==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.4.tgz", + "integrity": "sha512-y9tjmAzU6LTh1cC/lWrRsCcOd80khSR0qAHAqwY2svbW+AhsR/XCzgpZrAAKJrm/dDfjLCZKyxJSayeirGcW5Q==", "requires": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "3.2.0", - "tslib": "2.6.3", + "path-to-regexp": "3.3.0", + "tslib": "2.7.0", "uid": "2.0.2" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/event-emitter": { @@ -16547,15 +16566,22 @@ "requires": {} }, "@nestjs/platform-express": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.1.tgz", - "integrity": "sha512-ccfqIDAq/bg1ShLI5KGtaLaYGykuAdvCi57ohewH7eKJSIpWY1DQjbgKlFfXokALYUq1YOMGqjeZ244OWHfDQg==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.4.tgz", + "integrity": "sha512-y52q1MxhbHaT3vAgWd08RgiYon0lJgtTa8U6g6gV0KI0IygwZhDQFJVxnrRDUdxQGIP5CKHmfQu3sk9gTNFoEA==", "requires": { - "body-parser": "1.20.2", + "body-parser": "1.20.3", "cors": "2.8.5", - "express": "4.19.2", + "express": "4.21.0", "multer": "1.4.4-lts.1", - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/platform-socket.io": { @@ -16605,15 +16631,15 @@ } }, "@nestjs/swagger": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.0.tgz", - "integrity": "sha512-dCiwKkRxcR7dZs5jtrGspBAe/nqJd1AYzOBTzw9iCdbq3BGrLpwokelk6lFZPe4twpTsPQqzNKBwKzVbI6AR/g==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", "requires": { "@microsoft/tsdoc": "^0.15.0", "@nestjs/mapped-types": "2.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", - "path-to-regexp": "3.2.0", + "path-to-regexp": "3.3.0", "swagger-ui-dist": "5.17.14" } }, @@ -18061,114 +18087,114 @@ } }, "@rollup/rollup-android-arm-eabi": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.3.tgz", - "integrity": "sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "dev": true, "optional": true }, "@rollup/rollup-android-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.3.tgz", - "integrity": "sha512-eQK5JIi+POhFpzk+LnjKIy4Ks+pwJ+NXmPxOCSvOKSNRPONzKuUvWE+P9JxGZVxrtzm6BAYMaL50FFuPe0oWMQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "dev": true, "optional": true }, "@rollup/rollup-darwin-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.3.tgz", - "integrity": "sha512-Od4vE6f6CTT53yM1jgcLqNfItTsLt5zE46fdPaEmeFHvPs5SjZYlLpHrSiHEKR1+HdRfxuzXHjDOIxQyC3ptBA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "dev": true, "optional": true }, "@rollup/rollup-darwin-x64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.3.tgz", - "integrity": "sha512-0IMAO21axJeNIrvS9lSe/PGthc8ZUS+zC53O0VhF5gMxfmcKAP4ESkKOCwEi6u2asUrt4mQv2rjY8QseIEb1aw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.3.tgz", - "integrity": "sha512-ge2DC7tHRHa3caVEoSbPRJpq7azhG+xYsd6u2MEnJ6XzPSzQsTKyXvh6iWjXRf7Rt9ykIUWHtl0Uz3T6yXPpKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-musleabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.14.3.tgz", - "integrity": "sha512-ljcuiDI4V3ySuc7eSk4lQ9wU8J8r8KrOUvB2U+TtK0TiW6OFDmJ+DdIjjwZHIw9CNxzbmXY39wwpzYuFDwNXuw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.3.tgz", - "integrity": "sha512-Eci2us9VTHm1eSyn5/eEpaC7eP/mp5n46gTRB3Aar3BgSvDQGJZuicyq6TsH4HngNBgVqC5sDYxOzTExSU+NjA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.3.tgz", - "integrity": "sha512-UrBoMLCq4E92/LCqlh+blpqMz5h1tJttPIniwUgOFJyjWI1qrtrDhhpHPuFxULlUmjFHfloWdixtDhSxJt5iKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "dev": true, "optional": true }, "@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.3.tgz", - "integrity": "sha512-5aRjvsS8q1nWN8AoRfrq5+9IflC3P1leMoy4r2WjXyFqf3qcqsxRCfxtZIV58tCxd+Yv7WELPcO9mY9aeQyAmw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.3.tgz", - "integrity": "sha512-sk/Qh1j2/RJSX7FhEpJn8n0ndxy/uf0kI/9Zc4b1ELhqULVdTfN6HL31CDaTChiBAOgLcsJ1sgVZjWv8XNEsAQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "dev": true, "optional": true }, "@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.3.tgz", - "integrity": "sha512-jOO/PEaDitOmY9TgkxF/TQIjXySQe5KVYB57H/8LRP/ux0ZoO8cSHCX17asMSv3ruwslXW/TLBcxyaUzGRHcqg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.3.tgz", - "integrity": "sha512-8ybV4Xjy59xLMyWo3GCfEGqtKV5M5gCSrZlxkPGvEPCGDLNla7v48S662HSGwRd6/2cSneMQWiv+QzcttLrrOA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.3.tgz", - "integrity": "sha512-s+xf1I46trOY10OqAtZ5Rm6lzHre/UiLA1J2uOhCFXWkbZrJRkYBPO6FhvGfHmdtQ3Bx793MNa7LvoWFAm93bg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "dev": true, "optional": true }, "@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.3.tgz", - "integrity": "sha512-+4h2WrGOYsOumDQ5S2sYNyhVfrue+9tc9XcLWLh+Kw3UOxAvrfOrSMFon60KspcDdytkNDh7K2Vs6eMaYImAZg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "dev": true, "optional": true }, "@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.3.tgz", - "integrity": "sha512-T1l7y/bCeL/kUwh9OD4PQT4aM7Bq43vX05htPJJ46RTI4r5KNt6qJRzAfNfM+OYMNEVBWQzR2Gyk+FXLZfogGw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "dev": true, "optional": true }, "@rollup/rollup-win32-x64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.3.tgz", - "integrity": "sha512-/BypzV0H1y1HzgYpxqRaXGBRqfodgoBBCcsrujT6QRcakDQdfU+Lq9PENPh5jB4I44YWq+0C2eHsHya+nZY1sA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "dev": true, "optional": true }, @@ -19691,9 +19717,9 @@ } }, "body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "requires": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -19703,7 +19729,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -20626,9 +20652,9 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, "end-of-stream": { "version": "1.4.4", @@ -20640,9 +20666,9 @@ } }, "engine.io": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.2.tgz", - "integrity": "sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "requires": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", @@ -20653,7 +20679,7 @@ "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" + "ws": "~8.17.1" } }, "engine.io-parser": { @@ -20704,34 +20730,34 @@ "dev": true }, "esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "requires": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "escalade": { @@ -20995,36 +21021,36 @@ "optional": true }, "express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -21051,9 +21077,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" } } }, @@ -21167,12 +21193,12 @@ } }, "finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "requires": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -22320,9 +22346,9 @@ } }, "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" }, "merge-stream": { "version": "2.0.0", @@ -22341,11 +22367,11 @@ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" }, "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "requires": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "dependencies": { @@ -22784,9 +22810,9 @@ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" }, "object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==" }, "obuf": { "version": "1.1.2", @@ -23022,9 +23048,9 @@ } }, "path-to-regexp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", - "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==" }, "path-type": { "version": "4.0.0", @@ -23123,9 +23149,9 @@ } }, "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "picomatch": { "version": "4.0.2", @@ -23156,13 +23182,13 @@ "integrity": "sha512-3hTIM2j/v9Lio+wOyur3kckD4NxruZhpowUbEgmyikW+a2Kppjtu1eN+AhnMQtoHW46zld88JiYWv6fxpsDrTQ==" }, "postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "requires": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" } }, "postcss-import": { @@ -23389,11 +23415,11 @@ "dev": true }, "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "requires": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" } }, "queue-microtask": { @@ -24051,27 +24077,27 @@ } }, "rollup": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.3.tgz", - "integrity": "sha512-ag5tTQKYsj1bhrFC9+OEWqb5O6VYgtQDO9hPDBMmIbePwhfSr+ExlcU741t8Dhw5DkPCQf6noz0jb36D6W9/hw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "requires": { - "@rollup/rollup-android-arm-eabi": "4.14.3", - "@rollup/rollup-android-arm64": "4.14.3", - "@rollup/rollup-darwin-arm64": "4.14.3", - "@rollup/rollup-darwin-x64": "4.14.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.3", - "@rollup/rollup-linux-arm-musleabihf": "4.14.3", - "@rollup/rollup-linux-arm64-gnu": "4.14.3", - "@rollup/rollup-linux-arm64-musl": "4.14.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.3", - "@rollup/rollup-linux-riscv64-gnu": "4.14.3", - "@rollup/rollup-linux-s390x-gnu": "4.14.3", - "@rollup/rollup-linux-x64-gnu": "4.14.3", - "@rollup/rollup-linux-x64-musl": "4.14.3", - "@rollup/rollup-win32-arm64-msvc": "4.14.3", - "@rollup/rollup-win32-ia32-msvc": "4.14.3", - "@rollup/rollup-win32-x64-msvc": "4.14.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "@types/estree": "1.0.5", "fsevents": "~2.3.2" } @@ -24176,9 +24202,9 @@ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==" }, "send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "requires": { "debug": "2.6.9", "depd": "2.0.0", @@ -24209,6 +24235,11 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" } } + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" } } }, @@ -24222,14 +24253,14 @@ } }, "serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "requires": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" } }, "set-blocking": { @@ -24332,13 +24363,14 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" } }, "siginfo": { @@ -24403,14 +24435,6 @@ "requires": { "debug": "~4.3.4", "ws": "~8.17.1" - }, - "dependencies": { - "ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "requires": {} - } } }, "socket.io-parser": { @@ -24429,9 +24453,9 @@ "dev": true }, "source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, "source-map-support": { "version": "0.5.21", @@ -24766,9 +24790,9 @@ "dev": true }, "tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "requires": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -25399,15 +25423,15 @@ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, "vite": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", - "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", + "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", "dev": true, "requires": { - "esbuild": "^0.20.1", + "esbuild": "^0.21.3", "fsevents": "~2.3.3", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" } }, "vite-node": { @@ -25627,9 +25651,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "requires": {} }, "xtend": { diff --git a/server/src/config.ts b/server/src/config.ts index 057c9a69e2..03ea3f111b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -285,8 +285,8 @@ export const defaults = Object.freeze({ }, map: { enabled: true, - lightStyle: '', - darkStyle: '', + lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', + darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', }, reverseGeocoding: { enabled: true, diff --git a/server/src/controllers/map.controller.ts b/server/src/controllers/map.controller.ts index d6c26c58a0..88104e6b58 100644 --- a/server/src/controllers/map.controller.ts +++ b/server/src/controllers/map.controller.ts @@ -7,7 +7,6 @@ import { MapReverseGeocodeDto, MapReverseGeocodeResponseDto, } from 'src/dtos/map.dto'; -import { MapThemeDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MapService } from 'src/services/map.service'; @@ -22,12 +21,6 @@ export class MapController { return this.service.getMapMarkers(auth, options); } - @Authenticated({ sharedLink: true }) - @Get('style.json') - getMapStyle(@Query() dto: MapThemeDto) { - return this.service.getMapStyle(dto.theme); - } - @Authenticated() @Get('reverse-geocode') @HttpCode(HttpStatus.OK) diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 78e59e4d1a..aafadff478 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -121,6 +121,8 @@ export class ServerConfigDto { isInitialized!: boolean; isOnboarded!: boolean; externalDomain!: string; + mapDarkStyleUrl!: string; + mapLightStyleUrl!: string; } export class ServerFeaturesDto { diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 14027aa16a..336f50f39b 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -296,10 +296,12 @@ class SystemConfigMapDto { @ValidateBoolean() enabled!: boolean; - @IsString() + @IsNotEmpty() + @IsUrl() lightStyle!: string; - @IsString() + @IsNotEmpty() + @IsUrl() darkStyle!: string; } diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts index ffd84a3e02..5836505e54 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -43,17 +43,6 @@ export class MapService { return this.mapRepository.getMapMarkers(userIds, albumIds, options); } - async getMapStyle(theme: 'light' | 'dark') { - const { map } = await this.configCore.getConfig({ withCache: false }); - const styleUrl = theme === 'dark' ? map.darkStyle : map.lightStyle; - - if (styleUrl) { - return this.mapRepository.fetchStyle(styleUrl); - } - - return JSON.parse(await this.systemMetadataRepository.readFile(`./resources/style-${theme}.json`)); - } - async reverseGeocode(dto: MapReverseGeocodeDto) { const { lat: latitude, lon: longitude } = dto; // eventually this should probably return an array of results diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index ac899f7b13..4e6a8972b0 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -186,6 +186,8 @@ describe(ServerService.name, () => { isInitialized: undefined, isOnboarded: false, externalDomain: '', + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); expect(systemMock.get).toHaveBeenCalled(); }); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index e57a206765..9db90e41b3 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -129,6 +129,8 @@ export class ServerService { isInitialized, isOnboarded: onboarding?.isOnboarded || false, externalDomain: config.server.externalDomain, + mapDarkStyleUrl: config.map.darkStyle, + mapLightStyleUrl: config.map.lightStyle, }; } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 7e25e0cd46..52ad6d276b 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -100,8 +100,8 @@ const updatedConfig = Object.freeze({ }, map: { enabled: true, - lightStyle: '', - darkStyle: '', + lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', + darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', }, reverseGeocoding: { enabled: true, diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index 83ea3016fd..4f60131d69 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -6,8 +6,8 @@ import Icon from '$lib/components/elements/icon.svelte'; import { Theme } from '$lib/constants'; import { colorTheme, mapSettings } from '$lib/stores/preferences.store'; - import { getAssetThumbnailUrl, getKey, handlePromiseError } from '$lib/utils'; - import { getMapStyle, MapTheme, type MapMarkerResponseDto } from '@immich/sdk'; + import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; + import { getServerConfig, type MapMarkerResponseDto } from '@immich/sdk'; import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text?url'; import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js'; import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson'; @@ -57,11 +57,13 @@ let map: maplibregl.Map; let marker: maplibregl.Marker | null = null; - $: style = (() => - getMapStyle({ - theme: ($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT) as unknown as MapTheme, - key: getKey(), - }) as Promise)(); + $: style = (async () => { + const config = await getServerConfig(); + const theme = $mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT; + const styleUrl = theme === Theme.DARK ? config.mapDarkStyleUrl : config.mapLightStyleUrl; + const style = await fetch(styleUrl).then((response) => response.json()); + return style as StyleSpecification; + })(); function handleAssetClick(assetId: string, map: Map | null) { if (!map) { diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index 14d1e4e66e..358765fe0b 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -32,6 +32,8 @@ export const serverConfig = writable({ isInitialized: false, isOnboarded: false, externalDomain: '', + mapDarkStyleUrl: '', + mapLightStyleUrl: '', }); export const retrieveServerConfig = async () => { From ec32a9e6109342bcb9871eac0fa22bb055cfb3af Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 24 Sep 2024 04:03:59 +0200 Subject: [PATCH 423/723] fix: set min values for face detection to reasonable values (#12877) fix: set min values for face detection to >0 --- mobile/openapi/lib/model/facial_recognition_config.dart | 4 ++-- open-api/immich-openapi-specs.json | 4 ++-- server/src/dtos/model-config.dto.ts | 4 ++-- .../machine-learning-settings.svelte | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/openapi/lib/model/facial_recognition_config.dart b/mobile/openapi/lib/model/facial_recognition_config.dart index 4acfd4e20f..439efbbfae 100644 --- a/mobile/openapi/lib/model/facial_recognition_config.dart +++ b/mobile/openapi/lib/model/facial_recognition_config.dart @@ -22,14 +22,14 @@ class FacialRecognitionConfig { bool enabled; - /// Minimum value: 0 + /// Minimum value: 0.1 /// Maximum value: 2 double maxDistance; /// Minimum value: 1 int minFaces; - /// Minimum value: 0 + /// Minimum value: 0.1 /// Maximum value: 1 double minScore; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4e7c711978..99ea313063 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9119,7 +9119,7 @@ "maxDistance": { "format": "double", "maximum": 2, - "minimum": 0, + "minimum": 0.1, "type": "number" }, "minFaces": { @@ -9129,7 +9129,7 @@ "minScore": { "format": "double", "maximum": 1, - "minimum": 0, + "minimum": 0.1, "type": "number" }, "modelName": { diff --git a/server/src/dtos/model-config.dto.ts b/server/src/dtos/model-config.dto.ts index dffacc793d..f8b9e2043f 100644 --- a/server/src/dtos/model-config.dto.ts +++ b/server/src/dtos/model-config.dto.ts @@ -27,14 +27,14 @@ export class DuplicateDetectionConfig extends TaskConfig { export class FacialRecognitionConfig extends ModelConfig { @IsNumber() - @Min(0) + @Min(0.1) @Max(1) @Type(() => Number) @ApiProperty({ type: 'number', format: 'double' }) minScore!: number; @IsNumber() - @Min(0) + @Min(0.1) @Max(2) @Type(() => Number) @ApiProperty({ type: 'number', format: 'double' }) diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index 05a5224bd0..aac8cd5212 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -145,7 +145,7 @@ desc={$t('admin.machine_learning_min_detection_score_description')} bind:value={config.machineLearning.facialRecognition.minScore} step="0.1" - min={0} + min={0.1} max={1} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} isEdited={config.machineLearning.facialRecognition.minScore !== @@ -158,7 +158,7 @@ desc={$t('admin.machine_learning_max_recognition_distance_description')} bind:value={config.machineLearning.facialRecognition.maxDistance} step="0.1" - min={0} + min={0.1} max={2} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} isEdited={config.machineLearning.facialRecognition.maxDistance !== From 56f680ce04506f7969104a8866eaca330602af3a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Sep 2024 22:05:04 -0400 Subject: [PATCH 424/723] chore(deps): update typescript-projects (#12882) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 200 ++++++------- docs/package-lock.json | 6 +- e2e/package-lock.json | 424 +++++++++++++-------------- server/package-lock.json | 607 ++++++++++++++++++++------------------- web/package-lock.json | 258 ++++++++--------- 5 files changed, 718 insertions(+), 777 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index f74e86a385..6e148fbe09 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1353,17 +1353,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1387,16 +1387,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" }, "engines": { @@ -1416,14 +1416,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1434,14 +1434,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1459,9 +1459,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, "license": "MIT", "engines": { @@ -1473,14 +1473,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1502,16 +1502,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1525,13 +1525,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1543,9 +1543,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1566,8 +1566,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.0", - "vitest": "2.1.0" + "@vitest/browser": "2.1.1", + "vitest": "2.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1576,14 +1576,14 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -1592,9 +1592,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "license": "MIT", "dependencies": { @@ -1606,7 +1606,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.0", + "@vitest/spy": "2.1.1", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -1633,13 +1633,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -1647,13 +1647,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -1661,23 +1661,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1688,13 +1675,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -1702,19 +1689,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -4241,9 +4215,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "license": "MIT", "dependencies": { @@ -4283,19 +4257,19 @@ } }, "node_modules/vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -4306,7 +4280,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4321,8 +4295,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.0", - "@vitest/ui": "2.1.0", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, diff --git a/docs/package-lock.json b/docs/package-lock.json index 5f14d39ac7..3b4e6c4f95 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -16091,9 +16091,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.11.tgz", - "integrity": "sha512-qhEuBcLemjSJk5ajccN9xJFtM/h0AVCPaA6C92jNP+M2J8kX+eMJHI7R2HFKUvvAsMpcfLILMCFYSeDwpMmlUg==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.12.tgz", + "integrity": "sha512-Htf/gHj2+soPb9UayUNci/Ja3d8pTmu9ONTfh4QY8r3MATTZOzmv6UYWF7ZwikEIC8okpfqmGqrmDehua8mF8w==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index ab4fd53fbf..73c6ac6175 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1149,13 +1149,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.0.tgz", - "integrity": "sha512-SgAdlSwYVpToI4e/IH19IHHWvoijAYH5hu2MWSXptRypLSnzj51PcGD+rsOXFayde4P9ZLi+loXVwArg6IUkCA==", + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.1.tgz", + "integrity": "sha512-dbWpcNQZ5nj16m+A5UNScYx7HX5trIy7g4phrcitn+Nk83S32EBX/CLU4hiF4RGKX/yRc93AAqtfaXB7JWBd4Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.47.0" + "playwright": "1.47.1" }, "bin": { "playwright": "cli.js" @@ -1165,9 +1165,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", - "integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], @@ -1179,9 +1179,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", - "integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], @@ -1193,9 +1193,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", - "integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], @@ -1207,9 +1207,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", - "integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], @@ -1221,9 +1221,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", - "integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], @@ -1235,9 +1235,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", - "integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], @@ -1249,9 +1249,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", - "integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], @@ -1263,9 +1263,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", - "integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], @@ -1277,9 +1277,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", - "integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], @@ -1291,9 +1291,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", - "integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], @@ -1305,9 +1305,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", - "integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], @@ -1319,9 +1319,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", - "integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "cpu": [ "x64" ], @@ -1333,9 +1333,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", - "integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "cpu": [ "x64" ], @@ -1347,9 +1347,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", - "integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], @@ -1361,9 +1361,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", - "integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], @@ -1375,9 +1375,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", - "integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], @@ -1471,9 +1471,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true, "license": "MIT" }, @@ -1596,9 +1596,9 @@ } }, "node_modules/@types/pg": { - "version": "8.11.9", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.9.tgz", - "integrity": "sha512-M4mYeJZRBD9lCBCGa72F44uKSV9eJrAFfjlPJagdA6pgIr2OPJULFB7nqnZzOdqXG0qzHlgtZKzTdIgbmHitSg==", + "version": "8.11.10", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", + "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", "dev": true, "license": "MIT", "dependencies": { @@ -1733,17 +1733,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1767,16 +1767,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" }, "engines": { @@ -1796,14 +1796,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1814,14 +1814,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1839,9 +1839,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, "license": "MIT", "engines": { @@ -1853,14 +1853,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1908,16 +1908,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1931,13 +1931,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1949,9 +1949,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1972,8 +1972,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.0", - "vitest": "2.1.0" + "@vitest/browser": "2.1.1", + "vitest": "2.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1982,14 +1982,14 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -1998,9 +1998,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "license": "MIT", "dependencies": { @@ -2012,7 +2012,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.0", + "@vitest/spy": "2.1.1", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -2039,13 +2039,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -2053,13 +2053,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -2067,23 +2067,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2094,13 +2081,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -2108,19 +2095,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -4168,9 +4142,9 @@ } }, "node_modules/jose": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.8.0.tgz", - "integrity": "sha512-E7CqYpL/t7MMnfGnK/eg416OsFCVUrU/Y3Vwe7QjKhu/BkS1Ms455+2xsqZQVN57/U2MHMBvEb5SrmAZWAIntA==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.2.tgz", + "integrity": "sha512-ILI2xx/I57b20sd7rHZvgiiQrmp2mcotwsAH+5ajbpFQbrYVQdNHYlQhoA5cFb78CgtBOxtC05TeA+mcgkuCqQ==", "dev": true, "license": "MIT", "funding": { @@ -5039,15 +5013,15 @@ } }, "node_modules/pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.0.tgz", + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", "dev": true, "license": "MIT", "dependencies": { - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -5074,10 +5048,11 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==", - "dev": true + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "dev": true, + "license": "MIT" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -5098,19 +5073,21 @@ } }, "node_modules/pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "dev": true, + "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==", - "dev": true + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==", + "dev": true, + "license": "MIT" }, "node_modules/pg-types": { "version": "2.2.0", @@ -5158,13 +5135,13 @@ } }, "node_modules/playwright": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.0.tgz", - "integrity": "sha512-jOWiRq2pdNAX/mwLiwFYnPHpEZ4rM+fRSQpRHwEwZlP2PUANvL3+aJOF/bvISMhFD30rqMxUB4RJx9aQbfh4Ww==", + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.1.tgz", + "integrity": "sha512-SUEKi6947IqYbKxRiqnbUobVZY4bF1uu+ZnZNJX9DfU1tlf2UhWfvVjLf01pQx9URsOr18bFVUKXmanYWhbfkw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.47.0" + "playwright-core": "1.47.1" }, "bin": { "playwright": "cli.js" @@ -5177,9 +5154,9 @@ } }, "node_modules/playwright-core": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.0.tgz", - "integrity": "sha512-1DyHT8OqkcfCkYUD9zzUTfg7EfTd+6a8MkD/NWOvjo0u/SCNd5YmY/lJwFvUZOxJbWNds+ei7ic2+R/cRz/PDg==", + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.1.tgz", + "integrity": "sha512-i1iyJdLftqtt51mEk6AhYFaAJCDx0xQ/O5NU8EKaWFgMjItPVma542Nh/Aq8aLCjIJSzjaiEQGW/nyqLkGF1OQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5629,9 +5606,9 @@ } }, "node_modules/rollup": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", - "integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "license": "MIT", "dependencies": { @@ -5645,25 +5622,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.21.3", - "@rollup/rollup-android-arm64": "4.21.3", - "@rollup/rollup-darwin-arm64": "4.21.3", - "@rollup/rollup-darwin-x64": "4.21.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.21.3", - "@rollup/rollup-linux-arm-musleabihf": "4.21.3", - "@rollup/rollup-linux-arm64-gnu": "4.21.3", - "@rollup/rollup-linux-arm64-musl": "4.21.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", - "@rollup/rollup-linux-riscv64-gnu": "4.21.3", - "@rollup/rollup-linux-s390x-gnu": "4.21.3", - "@rollup/rollup-linux-x64-gnu": "4.21.3", - "@rollup/rollup-linux-x64-musl": "4.21.3", - "@rollup/rollup-win32-arm64-msvc": "4.21.3", - "@rollup/rollup-win32-ia32-msvc": "4.21.3", - "@rollup/rollup-win32-x64-msvc": "4.21.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6403,9 +6387,9 @@ } }, "node_modules/vite": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz", - "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", + "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6463,9 +6447,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "license": "MIT", "dependencies": { @@ -6500,19 +6484,19 @@ } }, "node_modules/vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -6523,7 +6507,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -6538,8 +6522,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.0", - "@vitest/ui": "2.1.0", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, diff --git a/server/package-lock.json b/server/package-lock.json index 9abfc6b5ce..ba9f33dc1e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -2043,12 +2043,12 @@ } }, "node_modules/@nestjs/common": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", - "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.3.tgz", + "integrity": "sha512-4hbLd3XIJubHSylYd/1WSi4VQvG68KM/ECYpMDqA3k3J1/T17SAg40sDoq3ZoO5OZgU0xuNyjuISdOTjs11qVg==", "dependencies": { "iterare": "1.2.1", - "tslib": "2.6.3", + "tslib": "2.7.0", "uid": "2.0.2" }, "funding": { @@ -2070,6 +2070,11 @@ } } }, + "node_modules/@nestjs/common/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/config": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.2.3.tgz", @@ -2183,12 +2188,12 @@ "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, "node_modules/@nestjs/platform-socket.io": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", - "integrity": "sha512-cxn5vKBAbqtEVPl0qVcJpR4sC12+hzcY/mYXGW6ippOKQDBNc2OF8oZXu6V3O1MvAl+VM7eNNEsLmP9DRKQlnw==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.3.tgz", + "integrity": "sha512-jTatT8q15LB5CFWsaIez3IigMixt7tNGJ4QLlRJ5NggPOPKRZssJnloODyEadFNHJjZiyufp5/NoPKBtNMf+lg==", "dependencies": { "socket.io": "4.7.5", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2200,10 +2205,15 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/platform-socket.io/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/schedule": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.0.tgz", - "integrity": "sha512-WEc96WTXZW+VI/Ng+uBpiBUwm6TWtAbQ4RKWkfbmzKvmbRGzA/9k/UyAWDS9k0pp+ZcbC+MaZQtt7TjQHrwX6g==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.1.tgz", + "integrity": "sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==", "dependencies": { "cron": "3.1.7", "uuid": "10.0.0" @@ -2280,12 +2290,12 @@ } }, "node_modules/@nestjs/testing": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.1.tgz", - "integrity": "sha512-pR+su5+YGqCLH0RhhVkPowQK7FCORU0/PWAywPK7LScAOtD67ZoviZ7hAU4vnGdwkg4HCB0D7W8Bkg19CGU8Xw==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.3.tgz", + "integrity": "sha512-SBNWrMU51YAlYmW86wyjlGZ2uLnASNiOPD0lBcNIlxxei0b05/aI3nh7OPuxbXQUdedUJfPq2d2jZj4TRG4S0w==", "dev": true, "dependencies": { - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2306,6 +2316,12 @@ } } }, + "node_modules/@nestjs/testing/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "dev": true + }, "node_modules/@nestjs/typeorm": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", @@ -2322,13 +2338,13 @@ } }, "node_modules/@nestjs/websockets": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.1.tgz", - "integrity": "sha512-p0Eq94WneczV2bnLEu9hl24iCIfH5eUCGgBuYOkVDySBwvya5L+gD4wUoqIqGoX1c6rkhQa+pMR7pi1EY4t93w==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.3.tgz", + "integrity": "sha512-EW5/GR0jImJwrb8+YpHPoFN2tlhYQzVE2yAN5Se5sygUr/ZFMNAG84sd79NmWGd4RxoxR0aFH9nRycQ/0Ebe5w==", "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "peerDependencies": { "@nestjs/common": "^10.0.0", @@ -2343,6 +2359,11 @@ } } }, + "node_modules/@nestjs/websockets/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@next/env": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", @@ -5376,9 +5397,9 @@ } }, "node_modules/@types/nodemailer": { - "version": "6.4.15", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", - "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "version": "6.4.16", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz", + "integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==", "dev": true, "dependencies": { "@types/node": "*" @@ -5485,9 +5506,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.3.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.5.tgz", - "integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.7.tgz", + "integrity": "sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==", "dev": true, "dependencies": { "@types/prop-types": "*", @@ -5604,16 +5625,16 @@ "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -5637,15 +5658,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" }, "engines": { @@ -5665,13 +5686,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5682,13 +5703,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -5706,9 +5727,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5719,13 +5740,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -5771,15 +5792,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5793,12 +5814,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -5810,9 +5831,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.3.0", @@ -5832,8 +5853,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.0", - "vitest": "2.1.0" + "@vitest/browser": "2.1.1", + "vitest": "2.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -5851,13 +5872,13 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "dependencies": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -5866,9 +5887,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "dependencies": { "@vitest/spy": "^2.1.0-beta.1", @@ -5879,7 +5900,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.0", + "@vitest/spy": "2.1.1", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -5914,12 +5935,12 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "dependencies": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -5927,12 +5948,12 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -5940,18 +5961,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/snapshot/node_modules/magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", @@ -5962,9 +5971,9 @@ } }, "node_modules/@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "dependencies": { "tinyspy": "^3.0.0" @@ -5974,12 +5983,12 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -5987,18 +5996,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@webassemblyjs/ast": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", @@ -11311,13 +11308,13 @@ } }, "node_modules/pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.0.tgz", + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", "dependencies": { - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -11343,9 +11340,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -11364,17 +11361,17 @@ } }, "node_modules/pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" }, "node_modules/pg-types": { "version": "2.2.0", @@ -14565,9 +14562,9 @@ } }, "node_modules/ua-parser-js": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", - "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", + "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==", "funding": [ { "type": "opencollective", @@ -14582,6 +14579,9 @@ "url": "https://github.com/sponsors/faisalman" } ], + "bin": { + "ua-parser-js": "script/cli.js" + }, "engines": { "node": "*" } @@ -14861,9 +14861,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "dependencies": { "cac": "^6.7.14", @@ -14901,18 +14901,18 @@ } }, "node_modules/vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "dependencies": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -14923,7 +14923,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -14938,8 +14938,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.0", - "@vitest/ui": "2.1.0", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, @@ -16512,13 +16512,20 @@ } }, "@nestjs/common": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", - "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.3.tgz", + "integrity": "sha512-4hbLd3XIJubHSylYd/1WSi4VQvG68KM/ECYpMDqA3k3J1/T17SAg40sDoq3ZoO5OZgU0xuNyjuISdOTjs11qVg==", "requires": { "iterare": "1.2.1", - "tslib": "2.6.3", + "tslib": "2.7.0", "uid": "2.0.2" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/config": { @@ -16585,18 +16592,25 @@ } }, "@nestjs/platform-socket.io": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", - "integrity": "sha512-cxn5vKBAbqtEVPl0qVcJpR4sC12+hzcY/mYXGW6ippOKQDBNc2OF8oZXu6V3O1MvAl+VM7eNNEsLmP9DRKQlnw==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.3.tgz", + "integrity": "sha512-jTatT8q15LB5CFWsaIez3IigMixt7tNGJ4QLlRJ5NggPOPKRZssJnloODyEadFNHJjZiyufp5/NoPKBtNMf+lg==", "requires": { "socket.io": "4.7.5", - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/schedule": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.0.tgz", - "integrity": "sha512-WEc96WTXZW+VI/Ng+uBpiBUwm6TWtAbQ4RKWkfbmzKvmbRGzA/9k/UyAWDS9k0pp+ZcbC+MaZQtt7TjQHrwX6g==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.1.tgz", + "integrity": "sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==", "requires": { "cron": "3.1.7", "uuid": "10.0.0" @@ -16644,12 +16658,20 @@ } }, "@nestjs/testing": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.1.tgz", - "integrity": "sha512-pR+su5+YGqCLH0RhhVkPowQK7FCORU0/PWAywPK7LScAOtD67ZoviZ7hAU4vnGdwkg4HCB0D7W8Bkg19CGU8Xw==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.3.tgz", + "integrity": "sha512-SBNWrMU51YAlYmW86wyjlGZ2uLnASNiOPD0lBcNIlxxei0b05/aI3nh7OPuxbXQUdedUJfPq2d2jZj4TRG4S0w==", "dev": true, "requires": { - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "dev": true + } } }, "@nestjs/typeorm": { @@ -16661,13 +16683,20 @@ } }, "@nestjs/websockets": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.1.tgz", - "integrity": "sha512-p0Eq94WneczV2bnLEu9hl24iCIfH5eUCGgBuYOkVDySBwvya5L+gD4wUoqIqGoX1c6rkhQa+pMR7pi1EY4t93w==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.3.tgz", + "integrity": "sha512-EW5/GR0jImJwrb8+YpHPoFN2tlhYQzVE2yAN5Se5sygUr/ZFMNAG84sd79NmWGd4RxoxR0aFH9nRycQ/0Ebe5w==", "requires": { "iterare": "1.2.1", "object-hash": "3.0.0", - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@next/env": { @@ -18680,9 +18709,9 @@ } }, "@types/nodemailer": { - "version": "6.4.15", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", - "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "version": "6.4.16", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz", + "integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==", "dev": true, "requires": { "@types/node": "*" @@ -18776,9 +18805,9 @@ "dev": true }, "@types/react": { - "version": "18.3.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.5.tgz", - "integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.7.tgz", + "integrity": "sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==", "dev": true, "requires": { "@types/prop-types": "*", @@ -18895,16 +18924,16 @@ "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, "@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "requires": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -18912,54 +18941,54 @@ } }, "@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "requires": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" } }, "@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "requires": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" } }, "@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "requires": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -18989,31 +19018,31 @@ } }, "@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" } }, "@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "requires": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" } }, "@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "requires": { "@ampproject/remapping": "^2.3.0", @@ -19042,21 +19071,21 @@ } }, "@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "requires": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" } }, "@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "requires": { "@vitest/spy": "^2.1.0-beta.1", @@ -19085,35 +19114,26 @@ } }, "@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "requires": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" } }, "@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "requires": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "dependencies": { - "@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "requires": { - "tinyrainbow": "^1.2.0" - } - }, "magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", @@ -19126,34 +19146,23 @@ } }, "@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "requires": { "tinyspy": "^3.0.0" } }, "@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "requires": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" - }, - "dependencies": { - "@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "requires": { - "tinyrainbow": "^1.2.0" - } - } } }, "@webassemblyjs/ast": { @@ -23084,14 +23093,14 @@ "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==" }, "pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.0.tgz", + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", "requires": { "pg-cloudflare": "^1.1.1", - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" } @@ -23103,9 +23112,9 @@ "optional": true }, "pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" }, "pg-int8": { "version": "1.0.1", @@ -23118,15 +23127,15 @@ "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==" }, "pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "requires": {} }, "pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" }, "pg-types": { "version": "2.2.0", @@ -25268,9 +25277,9 @@ "devOptional": true }, "ua-parser-js": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", - "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==" + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", + "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==" }, "uglify-js": { "version": "3.17.4", @@ -25435,9 +25444,9 @@ } }, "vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "requires": { "cac": "^6.7.14", @@ -25458,18 +25467,18 @@ } }, "vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "requires": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -25480,7 +25489,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "dependencies": { diff --git a/web/package-lock.json b/web/package-lock.json index ce30d1ccb4..b652f58ce0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -759,9 +759,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.0.0.tgz", - "integrity": "sha512-dTDHJSmz6c1OJ6HO7jiUiIb4sB20Dlkb3pxYsKm0qTXm2Bmj97rlXIhlvaFsW2rvCi+OLlwKLVSS6ZxFUVZvjQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.0.1.tgz", + "integrity": "sha512-4mDeYIgM3By7X6t5E6eYwLAa+2h4DeZDF7thhzIg6XB76jeEvMwadYAMCFJL/R4AnEBcAUO9+gL0vhy3s+qvZA==", "dev": true, "funding": [ { @@ -1875,9 +1875,9 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.4.tgz", - "integrity": "sha512-Qm4GAHCnRXwfWG9/AtnQ7mqjyjTs7i0Opyb8H2KH9rMR7fLxqiPx/tXeoE6HHo66+72CjyOb4nFH3lrejY4vzA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.5.tgz", + "integrity": "sha512-kFJR7RxeB6FBvrKZWAEzIALatgy11ISaaZbcPup8JdWUdrmmfUHHTJ738YHJTEfnCiiXi6aX8Q6ePY7tnSMD6Q==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1885,9 +1885,9 @@ } }, "node_modules/@sveltejs/enhanced-img": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.4.tgz", - "integrity": "sha512-eX+ob5uWr0bTLMKeG9nhhM84aR88hqiLiyEfWZPX7ijhk/wlmYSUX9nOiaVHh2ct1U+Ju9Hhb90Copw+ZNOB8w==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.8.tgz", + "integrity": "sha512-n66u46ZeqHltiTm0BEjWptYmCrCY0EltEEvakmC7d5o5ZejDbOvOWm914mebbRKaP2Bezv65TNCod/wqvw/0KA==", "dev": true, "license": "MIT", "dependencies": { @@ -1901,9 +1901,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.5.26", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.26.tgz", - "integrity": "sha512-8l1JTIM2L+bS8ebq1E+nGjv/YSKSnD9Q19bYIUkc41vaEG2JjVUx6ikvPIJv2hkQAuqJLzoPrXlKk4KcyWOv3Q==", + "version": "2.5.28", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.28.tgz", + "integrity": "sha512-/O7pvFGBsQPcFa9UrW8eUC5uHTOXLsUp3SN0dY6YmRAL9nfPSrJsSJk//j5vMpinSshzUjteAFcfQTU+04Ka1w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2318,17 +2318,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2352,16 +2352,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" }, "engines": { @@ -2381,14 +2381,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2399,14 +2399,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -2424,9 +2424,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, "license": "MIT", "engines": { @@ -2438,14 +2438,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2493,16 +2493,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2516,13 +2516,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2534,9 +2534,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2557,8 +2557,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.0", - "vitest": "2.1.0" + "@vitest/browser": "2.1.1", + "vitest": "2.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2567,14 +2567,14 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -2583,9 +2583,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "license": "MIT", "dependencies": { @@ -2597,7 +2597,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.0", + "@vitest/spy": "2.1.1", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -2624,13 +2624,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -2638,13 +2638,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -2652,23 +2652,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2679,13 +2666,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -2693,23 +2680,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@zoom-image/core": { - "version": "0.37.1", - "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.37.1.tgz", - "integrity": "sha512-mIJaZJBi3jvOD2gtzoSe4yhnxfvx7GcYlVTLoJE6VPawb3Ei5dvHuRRXa8/dNHtCf1Xf2RNSEm1Za2+TqkAiBQ==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.38.0.tgz", + "integrity": "sha512-rA6/qTGfsRtWRs+WfMF0dIs+Ft9GBFusxXzEqqFsQa/0iYtN0MmOiuKzXGYPcIFKTbmQW/qqk0afIBtWd9163g==", "license": "MIT", "dependencies": { "@namnode/store": "^0.1.0" @@ -2720,12 +2694,12 @@ } }, "node_modules/@zoom-image/svelte": { - "version": "0.2.21", - "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.21.tgz", - "integrity": "sha512-242xKpIaVZC/cymvNF4+JlcKwAaM9l3W2QS4DHSsnqT8xvPBgBgns+1lqOuYYKSAa85DB1UL0NMBhTg8Gk4RpA==", + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.22.tgz", + "integrity": "sha512-lExo4M511/HtkmCsBzV5f8ABs8bEMZGtIrwl1pJro77iJ+5j9Yt7KUlPs6o+Yp028T6fqGJUsOCxCNWNZn9BIg==", "license": "MIT", "dependencies": { - "@zoom-image/core": "0.37.1" + "@zoom-image/core": "0.38.0" }, "funding": { "type": "github", @@ -3864,9 +3838,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "2.43.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.43.0.tgz", - "integrity": "sha512-REkxQWvg2pp7QVLxQNa+dJ97xUqRe7Y2JJbSWkHSuszu0VcblZtXkPBPckkivk99y5CdLw4slqfPylL2d/X4jQ==", + "version": "2.44.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.44.0.tgz", + "integrity": "sha512-wav4MOs02vBb1WjvTCYItwJCxMkuk2Z4p+K/eyjL0N/z7ahXLP+0LtQQjiKc2ezuif7GnZLbD1F3o1VHzSvdVg==", "dev": true, "license": "MIT", "dependencies": { @@ -3880,7 +3854,7 @@ "postcss-safe-parser": "^6.0.0", "postcss-selector-parser": "^6.1.0", "semver": "^7.6.2", - "svelte-eslint-parser": "^0.41.0" + "svelte-eslint-parser": "^0.41.1" }, "engines": { "node": "^14.17.0 || >=16.0.0" @@ -7160,9 +7134,9 @@ } }, "node_modules/svelte-eslint-parser": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.0.tgz", - "integrity": "sha512-L6f4hOL+AbgfBIB52Z310pg1d2QjRqm7wy3kI1W6hhdhX5bvu7+f0R6w4ykp5HoDdzq+vGhIJmsisaiJDGmVfA==", + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.1.tgz", + "integrity": "sha512-08ndI6zTghzI8SuJAFpvMbA/haPSGn3xz19pjre19yYMw8Nw/wQJ2PrZBI/L8ijGTgtkWCQQiLLy+Z1tfaCwNA==", "dev": true, "license": "MIT", "dependencies": { @@ -7678,9 +7652,9 @@ "peer": true }, "node_modules/tailwindcss": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.11.tgz", - "integrity": "sha512-qhEuBcLemjSJk5ajccN9xJFtM/h0AVCPaA6C92jNP+M2J8kX+eMJHI7R2HFKUvvAsMpcfLILMCFYSeDwpMmlUg==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.12.tgz", + "integrity": "sha512-Htf/gHj2+soPb9UayUNci/Ja3d8pTmu9ONTfh4QY8r3MATTZOzmv6UYWF7ZwikEIC8okpfqmGqrmDehua8mF8w==", "dev": true, "license": "MIT", "dependencies": { @@ -8246,9 +8220,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "license": "MIT", "dependencies": { @@ -8282,19 +8256,19 @@ } }, "node_modules/vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -8305,7 +8279,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8320,8 +8294,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.0", - "@vitest/ui": "2.1.0", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, From e0fa3cdbc75817226bebe6eb58dd9a069e112d39 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Tue, 24 Sep 2024 08:24:48 +0200 Subject: [PATCH 425/723] refactor(mobile): more repositories (#12879) * ExifInfoRepository * ActivityApiRepository * initial AssetApiRepository --- mobile/analysis_options.yaml | 9 +-- .../interfaces/activity_api.interface.dart | 16 ++++ mobile/lib/interfaces/asset.interface.dart | 12 +++ .../lib/interfaces/asset_api.interface.dart | 16 ++++ .../lib/interfaces/exif_info.interface.dart | 9 +++ .../lib/models/activities/activity.model.dart | 17 ++-- .../providers/activity_service.provider.dart | 4 +- .../activity_statistics.provider.dart | 2 +- .../repositories/activity_api.repository.dart | 67 +++++++++++++++ .../repositories/album_api.repository.dart | 25 +++--- mobile/lib/repositories/asset.repository.dart | 80 ++++++++++++++++++ .../repositories/asset_api.repository.dart | 25 ++++++ .../lib/repositories/base_api.repository.dart | 11 +++ .../repositories/exif_info.repository.dart | 28 +++++++ mobile/lib/services/activity.service.dart | 48 ++++------- mobile/lib/services/asset.service.dart | 52 ++++++++++++ .../services/asset_description.service.dart | 66 --------------- .../services/backup_verification.service.dart | 81 +++++++------------ .../asset_viewer/description_input.dart | 10 ++- .../activity_statistics_provider_test.dart | 7 +- 20 files changed, 392 insertions(+), 193 deletions(-) create mode 100644 mobile/lib/interfaces/activity_api.interface.dart create mode 100644 mobile/lib/interfaces/asset_api.interface.dart create mode 100644 mobile/lib/interfaces/exif_info.interface.dart create mode 100644 mobile/lib/repositories/activity_api.repository.dart create mode 100644 mobile/lib/repositories/asset_api.repository.dart create mode 100644 mobile/lib/repositories/base_api.repository.dart create mode 100644 mobile/lib/repositories/exif_info.repository.dart delete mode 100644 mobile/lib/services/asset_description.service.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 8f9d41d736..e996a54372 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -64,7 +64,7 @@ custom_lint: allowed: # required / wanted - lib/entities/*.entity.dart - - lib/repositories/{album,asset,backup,user}.repository.dart + - lib/repositories/{album,asset,backup,exif_info,user}.repository.dart # acceptable exceptions for the time being - integration_test/test_utils/general_helper.dart - lib/main.dart @@ -75,7 +75,7 @@ custom_lint: - lib/pages/common/{album_asset_selection,gallery_viewer}.page.dart - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart - - lib/services/{asset,asset_description,background,backup,backup_verification,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart + - lib/services/{asset,background,backup,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart - lib/widgets/asset_grid/{asset_grid_data_structure,thumbnail_image}.dart - import_rule_openapi: @@ -83,13 +83,12 @@ custom_lint: restrict: package:openapi allowed: # requried / wanted - - lib/repositories/album_api.repository.dart + - lib/repositories/*_api.repository.dart # acceptable exceptions for the time being - lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities - lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine - test/modules/utils/openapi_patching_test.dart # filename is self-explanatory... # refactor - - lib/models/activities/activity.model.dart - lib/models/map/map_marker.model.dart - lib/models/search/search_filter.model.dart - lib/models/server_info/server_{config,disk_info,features,version}.model.dart @@ -102,7 +101,7 @@ custom_lint: - lib/providers/search/{people,search,search_filter}.provider.dart - lib/providers/websocket.provider.dart - lib/routing/auth_guard.dart - - lib/services/{activity,api,asset,asset_description,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart + - lib/services/{api,asset,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart - lib/widgets/album/album_thumbnail_listtile.dart - lib/widgets/forms/login/login_form.dart - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart diff --git a/mobile/lib/interfaces/activity_api.interface.dart b/mobile/lib/interfaces/activity_api.interface.dart new file mode 100644 index 0000000000..99aef6f4d4 --- /dev/null +++ b/mobile/lib/interfaces/activity_api.interface.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/models/activities/activity.model.dart'; + +abstract interface class IActivityApiRepository { + Future> getAll( + String albumId, { + String? assetId, + }); + Future create( + String albumId, + ActivityType type, { + String? assetId, + String? comment, + }); + Future delete(String id); + Future getStats(String albumId, {String? assetId}); +} diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 2574e52112..98f4c7687c 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -7,4 +7,16 @@ abstract interface class IAssetRepository { Future> getAllByRemoteId(Iterable ids); Future> getByAlbum(Album album, {User? notOwnedBy}); Future deleteById(List ids); + Future> getAll({ + required int ownerId, + bool? remote, + int limit = 100, + }); + + Future> getMatches({ + required List assets, + required int ownerId, + bool? remote, + int limit = 100, + }); } diff --git a/mobile/lib/interfaces/asset_api.interface.dart b/mobile/lib/interfaces/asset_api.interface.dart new file mode 100644 index 0000000000..201c85cea7 --- /dev/null +++ b/mobile/lib/interfaces/asset_api.interface.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/entities/asset.entity.dart'; + +abstract interface class IAssetApiRepository { + // Future get(String id); + + // Future> getAll(); + + // Future create(Asset asset); + + Future update( + String id, { + String? description, + }); + + // Future delete(String id); +} diff --git a/mobile/lib/interfaces/exif_info.interface.dart b/mobile/lib/interfaces/exif_info.interface.dart new file mode 100644 index 0000000000..fa8ca08f9d --- /dev/null +++ b/mobile/lib/interfaces/exif_info.interface.dart @@ -0,0 +1,9 @@ +import 'package:immich_mobile/entities/exif_info.entity.dart'; + +abstract interface class IExifInfoRepository { + Future get(int id); + + Future update(ExifInfo exifInfo); + + Future delete(int id); +} diff --git a/mobile/lib/models/activities/activity.model.dart b/mobile/lib/models/activities/activity.model.dart index 6adb80dca9..4702753f41 100644 --- a/mobile/lib/models/activities/activity.model.dart +++ b/mobile/lib/models/activities/activity.model.dart @@ -1,5 +1,4 @@ import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:openapi/api.dart'; enum ActivityType { comment, like } @@ -38,16 +37,6 @@ class Activity { ); } - Activity.fromDto(ActivityResponseDto dto) - : id = dto.id, - assetId = dto.assetId, - comment = dto.comment, - createdAt = dto.createdAt, - type = dto.type == ReactionType.comment - ? ActivityType.comment - : ActivityType.like, - user = User.fromSimpleUserDto(dto.user); - @override String toString() { return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)'; @@ -75,3 +64,9 @@ class Activity { user.hashCode; } } + +class ActivityStats { + final int comments; + + const ActivityStats({required this.comments}); +} diff --git a/mobile/lib/providers/activity_service.provider.dart b/mobile/lib/providers/activity_service.provider.dart index dcfaac883f..6bd139c565 100644 --- a/mobile/lib/providers/activity_service.provider.dart +++ b/mobile/lib/providers/activity_service.provider.dart @@ -1,9 +1,9 @@ +import 'package:immich_mobile/repositories/activity_api.repository.dart'; import 'package:immich_mobile/services/activity.service.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'activity_service.provider.g.dart'; @riverpod ActivityService activityService(ActivityServiceRef ref) => - ActivityService(ref.watch(apiServiceProvider)); + ActivityService(ref.watch(activityApiRepositoryProvider)); diff --git a/mobile/lib/providers/activity_statistics.provider.dart b/mobile/lib/providers/activity_statistics.provider.dart index afb43e8cba..b1d2b4b987 100644 --- a/mobile/lib/providers/activity_statistics.provider.dart +++ b/mobile/lib/providers/activity_statistics.provider.dart @@ -11,7 +11,7 @@ class ActivityStatistics extends _$ActivityStatistics { ref .watch(activityServiceProvider) .getStatistics(albumId, assetId: assetId) - .then((comments) => state = comments); + .then((stats) => state = stats.comments); return 0; } diff --git a/mobile/lib/repositories/activity_api.repository.dart b/mobile/lib/repositories/activity_api.repository.dart new file mode 100644 index 0000000000..0b1b4d99f3 --- /dev/null +++ b/mobile/lib/repositories/activity_api.repository.dart @@ -0,0 +1,67 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/activity_api.interface.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final activityApiRepositoryProvider = Provider( + (ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi), +); + +class ActivityApiRepository extends BaseApiRepository + implements IActivityApiRepository { + final ActivitiesApi _api; + + ActivityApiRepository(this._api); + + @override + Future> getAll(String albumId, {String? assetId}) async { + final response = + await checkNull(_api.getActivities(albumId, assetId: assetId)); + return response.map(_toActivity).toList(); + } + + @override + Future create( + String albumId, + ActivityType type, { + String? assetId, + String? comment, + }) async { + final dto = ActivityCreateDto( + albumId: albumId, + type: type == ActivityType.comment + ? ReactionType.comment + : ReactionType.like, + assetId: assetId, + comment: comment, + ); + final response = await checkNull(_api.createActivity(dto)); + return _toActivity(response); + } + + @override + Future delete(String id) { + return checkNull(_api.deleteActivity(id)); + } + + @override + Future getStats(String albumId, {String? assetId}) async { + final response = + await checkNull(_api.getActivityStatistics(albumId, assetId: assetId)); + return ActivityStats(comments: response.comments); + } + + static Activity _toActivity(ActivityResponseDto dto) => Activity( + id: dto.id, + createdAt: dto.createdAt, + type: dto.type == ReactionType.comment + ? ActivityType.comment + : ActivityType.like, + user: User.fromSimpleUserDto(dto.user), + assetId: dto.assetId, + comment: dto.comment, + ); +} diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart index 6b7865f8e4..0e27e44684 100644 --- a/mobile/lib/repositories/album_api.repository.dart +++ b/mobile/lib/repositories/album_api.repository.dart @@ -1,30 +1,31 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/errors.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; import 'package:openapi/api.dart'; final albumApiRepositoryProvider = Provider( (ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi), ); -class AlbumApiRepository implements IAlbumApiRepository { +class AlbumApiRepository extends BaseApiRepository + implements IAlbumApiRepository { final AlbumsApi _api; AlbumApiRepository(this._api); @override Future get(String id) async { - final dto = await _checkNull(_api.getAlbumInfo(id)); + final dto = await checkNull(_api.getAlbumInfo(id)); return _toAlbum(dto); } @override Future> getAll({bool? shared}) async { - final dtos = await _checkNull(_api.getAllAlbums(shared: shared)); + final dtos = await checkNull(_api.getAllAlbums(shared: shared)); return dtos.map(_toAlbum).toList().cast(); } @@ -37,7 +38,7 @@ class AlbumApiRepository implements IAlbumApiRepository { final users = sharedUserIds.map( (id) => AlbumUserCreateDto(userId: id, role: AlbumUserRole.editor), ); - final responseDto = await _checkNull( + final responseDto = await checkNull( _api.createAlbum( CreateAlbumDto( albumName: name, @@ -57,7 +58,7 @@ class AlbumApiRepository implements IAlbumApiRepository { String? description, bool? activityEnabled, }) async { - final response = await _checkNull( + final response = await checkNull( _api.updateAlbumInfo( albumId, UpdateAlbumDto( @@ -81,7 +82,7 @@ class AlbumApiRepository implements IAlbumApiRepository { String albumId, Iterable assetIds, ) async { - final response = await _checkNull( + final response = await checkNull( _api.addAssetsToAlbum( albumId, BulkIdsDto(ids: assetIds.toList()), @@ -106,7 +107,7 @@ class AlbumApiRepository implements IAlbumApiRepository { String albumId, Iterable assetIds, ) async { - final response = await _checkNull( + final response = await checkNull( _api.removeAssetFromAlbum( albumId, BulkIdsDto(ids: assetIds.toList()), @@ -127,7 +128,7 @@ class AlbumApiRepository implements IAlbumApiRepository { Future addUsers(String albumId, Iterable userIds) async { final albumUsers = userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList(); - final response = await _checkNull( + final response = await checkNull( _api.addUsersToAlbum( albumId, AddUsersDto(albumUsers: albumUsers), @@ -141,12 +142,6 @@ class AlbumApiRepository implements IAlbumApiRepository { return _api.removeUserFromAlbum(albumId, userId); } - static Future _checkNull(Future future) async { - final response = await future; - if (response == null) throw NoResponseDtoError(); - return response; - } - static Album _toAlbum(AlbumResponseDto dto) { final Album album = Album( remoteId: dto.id, diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index 8ec028f728..c6012af371 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -35,4 +35,84 @@ class AssetRepository implements IAssetRepository { @override Future> getAllByRemoteId(Iterable ids) => _db.assets.getAllByRemoteId(ids); + + @override + Future> getAll({ + required int ownerId, + bool? remote, + int limit = 100, + }) { + if (remote == null) { + return _db.assets + .where() + .ownerIdEqualToAnyChecksum(ownerId) + .limit(limit) + .findAll(); + } + final QueryBuilder query; + if (remote) { + query = _db.assets + .where() + .localIdIsNull() + .filter() + .remoteIdIsNotNull() + .ownerIdEqualTo(ownerId); + } else { + query = _db.assets + .where() + .remoteIdIsNull() + .filter() + .localIdIsNotNull() + .ownerIdEqualTo(ownerId); + } + + return query.limit(limit).findAll(); + } + + @override + Future> getMatches({ + required List assets, + required int ownerId, + bool? remote, + int limit = 100, + }) { + final QueryBuilder query; + if (remote == null) { + query = _db.assets.filter().remoteIdIsNotNull().or().localIdIsNotNull(); + } else if (remote) { + query = _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(); + } else { + query = _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(); + } + return _getMatchesImpl(query, ownerId, assets, limit); + } } + +Future> _getMatchesImpl( + QueryBuilder query, + int ownerId, + List assets, + int limit, +) => + query + .ownerIdEqualTo(ownerId) + .anyOf( + assets, + (q, Asset a) => q + .fileNameEqualTo(a.fileName) + .and() + .durationInSecondsEqualTo(a.durationInSeconds) + .and() + .fileCreatedAtBetween( + a.fileCreatedAt.subtract(const Duration(hours: 12)), + a.fileCreatedAt.add(const Duration(hours: 12)), + ) + .and() + .not() + .checksumEqualTo(a.checksum), + ) + .sortByFileName() + .thenByFileCreatedAt() + .thenByFileModifiedAt() + .limit(limit) + .findAll(); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart new file mode 100644 index 0000000000..3ad0e1cba0 --- /dev/null +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -0,0 +1,25 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final assetApiRepositoryProvider = Provider( + (ref) => AssetApiRepository(ref.watch(apiServiceProvider).assetsApi), +); + +class AssetApiRepository extends BaseApiRepository + implements IAssetApiRepository { + final AssetsApi _api; + + AssetApiRepository(this._api); + + @override + Future update(String id, {String? description}) async { + final response = await checkNull( + _api.updateAsset(id, UpdateAssetDto(description: description)), + ); + return Asset.remote(response); + } +} diff --git a/mobile/lib/repositories/base_api.repository.dart b/mobile/lib/repositories/base_api.repository.dart new file mode 100644 index 0000000000..418cba84f8 --- /dev/null +++ b/mobile/lib/repositories/base_api.repository.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/constants/errors.dart'; + +abstract class BaseApiRepository { + @protected + Future checkNull(Future future) async { + final response = await future; + if (response == null) throw NoResponseDtoError(); + return response; + } +} diff --git a/mobile/lib/repositories/exif_info.repository.dart b/mobile/lib/repositories/exif_info.repository.dart new file mode 100644 index 0000000000..a165e98bdb --- /dev/null +++ b/mobile/lib/repositories/exif_info.repository.dart @@ -0,0 +1,28 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final exifInfoRepositoryProvider = + Provider((ref) => ExifInfoRepository(ref.watch(dbProvider))); + +class ExifInfoRepository implements IExifInfoRepository { + final Isar _db; + + ExifInfoRepository( + this._db, + ); + + @override + Future delete(int id) => _db.exifInfos.delete(id); + + @override + Future get(int id) => _db.exifInfos.get(id); + + @override + Future update(ExifInfo exifInfo) async { + await _db.writeTxn(() => _db.exifInfos.put(exifInfo)); + return exifInfo; + } +} diff --git a/mobile/lib/services/activity.service.dart b/mobile/lib/services/activity.service.dart index 58af26e204..5496041416 100644 --- a/mobile/lib/services/activity.service.dart +++ b/mobile/lib/services/activity.service.dart @@ -1,41 +1,31 @@ -import 'package:immich_mobile/constants/errors.dart'; +import 'package:immich_mobile/interfaces/activity_api.interface.dart'; import 'package:immich_mobile/mixins/error_logger.mixin.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; class ActivityService with ErrorLoggerMixin { - final ApiService _apiService; + final IActivityApiRepository _activityApiRepository; @override final Logger logger = Logger("ActivityService"); - ActivityService(this._apiService); + ActivityService(this._activityApiRepository); Future> getAllActivities( String albumId, { String? assetId, }) async { return logError( - () async { - final list = await _apiService.activitiesApi - .getActivities(albumId, assetId: assetId); - return list != null ? list.map(Activity.fromDto).toList() : []; - }, + () => _activityApiRepository.getAll(albumId, assetId: assetId), defaultValue: [], errorMessage: "Failed to get all activities for album $albumId", ); } - Future getStatistics(String albumId, {String? assetId}) async { + Future getStatistics(String albumId, {String? assetId}) async { return logError( - () async { - final dto = await _apiService.activitiesApi - .getActivityStatistics(albumId, assetId: assetId); - return dto?.comments ?? 0; - }, - defaultValue: 0, + () => _activityApiRepository.getStats(albumId, assetId: assetId), + defaultValue: const ActivityStats(comments: 0), errorMessage: "Failed to statistics for album $albumId", ); } @@ -43,7 +33,7 @@ class ActivityService with ErrorLoggerMixin { Future removeActivity(String id) async { return logError( () async { - await _apiService.activitiesApi.deleteActivity(id); + await _activityApiRepository.delete(id); return true; }, defaultValue: false, @@ -58,22 +48,12 @@ class ActivityService with ErrorLoggerMixin { String? comment, }) async { return guardError( - () async { - final dto = await _apiService.activitiesApi.createActivity( - ActivityCreateDto( - albumId: albumId, - type: type == ActivityType.comment - ? ReactionType.comment - : ReactionType.like, - assetId: assetId, - comment: comment, - ), - ); - if (dto != null) { - return Activity.fromDto(dto); - } - throw NoResponseDtoError(); - }, + () => _activityApiRepository.create( + albumId, + type, + assetId: assetId, + comment: comment, + ), errorMessage: "Failed to create $type for album $albumId", ); } diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 90c46ae90a..262040026e 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -9,9 +9,13 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; @@ -24,6 +28,8 @@ import 'package:openapi/api.dart'; final assetServiceProvider = Provider( (ref) => AssetService( + ref.watch(assetApiRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), @@ -34,6 +40,8 @@ final assetServiceProvider = Provider( ); class AssetService { + final IAssetApiRepository _assetApiRepository; + final IExifInfoRepository _exifInfoRepository; final ApiService _apiService; final SyncService _syncService; final UserService _userService; @@ -43,6 +51,8 @@ class AssetService { final Isar _db; AssetService( + this._assetApiRepository, + this._exifInfoRepository, this._apiService, this._syncService, this._userService, @@ -342,4 +352,46 @@ class AssetService { log.severe("Error while syncing uploaded asset to albums", error, stack); } } + + Future setDescription( + Asset asset, + String newDescription, + ) async { + final remoteAssetId = asset.remoteId; + final localExifId = asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (remoteAssetId == null || localExifId == null) { + return; + } + + final result = await _assetApiRepository.update( + remoteAssetId, + description: newDescription, + ); + + final description = result.exifInfo?.description; + + if (description != null) { + var exifInfo = await _exifInfoRepository.get(localExifId); + + if (exifInfo != null) { + exifInfo.description = description; + await _exifInfoRepository.update(exifInfo); + } + } + } + + Future getDescription(Asset asset) async { + final localExifId = asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (localExifId == null) { + return ""; + } + + final exifInfo = await _exifInfoRepository.get(localExifId); + + return exifInfo?.description ?? ""; + } } diff --git a/mobile/lib/services/asset_description.service.dart b/mobile/lib/services/asset_description.service.dart deleted file mode 100644 index 196e29dc6a..0000000000 --- a/mobile/lib/services/asset_description.service.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; -import 'package:openapi/api.dart'; - -class AssetDescriptionService { - AssetDescriptionService(this._db, this._api); - - final Isar _db; - final ApiService _api; - - Future setDescription( - Asset asset, - String newDescription, - ) async { - final remoteAssetId = asset.remoteId; - final localExifId = asset.exifInfo?.id; - - // Guard [remoteAssetId] and [localExifId] null - if (remoteAssetId == null || localExifId == null) { - return; - } - - final result = await _api.assetsApi.updateAsset( - remoteAssetId, - UpdateAssetDto(description: newDescription), - ); - - final description = result?.exifInfo?.description; - - if (description != null) { - var exifInfo = await _db.exifInfos.get(localExifId); - - if (exifInfo != null) { - exifInfo.description = description; - await _db.writeTxn( - () => _db.exifInfos.put(exifInfo), - ); - } - } - } - - String getAssetDescription(Asset asset) { - final localExifId = asset.exifInfo?.id; - - // Guard [remoteAssetId] and [localExifId] null - if (localExifId == null) { - return ""; - } - - final exifInfo = _db.exifInfos.getSync(localExifId); - - return exifInfo?.description ?? ""; - } -} - -final assetDescriptionServiceProvider = Provider( - (ref) => AssetDescriptionService( - ref.watch(dbProvider), - ref.watch(apiServiceProvider), - ), -); diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index 66a61d2914..da9d8da164 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -8,41 +8,46 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; /// Finds duplicates originating from missing EXIF information class BackupVerificationService { - final Isar _db; final IFileMediaRepository _fileMediaRepository; + final IAssetRepository _assetRepository; + final IExifInfoRepository _exifInfoRepository; - BackupVerificationService(this._db, this._fileMediaRepository); + BackupVerificationService( + this._fileMediaRepository, + this._assetRepository, + this._exifInfoRepository, + ); /// Returns at most [limit] assets that were backed up without exif Future> findWronglyBackedUpAssets({int limit = 100}) async { final owner = Store.get(StoreKey.currentUser).isarId; - final List onlyLocal = await _db.assets - .where() - .remoteIdIsNull() - .filter() - .ownerIdEqualTo(owner) - .localIdIsNotNull() - .findAll(); - final List remoteMatches = await _getMatches( - _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(), - owner, - onlyLocal, - limit, + final List onlyLocal = await _assetRepository.getAll( + ownerId: owner, + remote: false, + limit: limit, ); - final List localMatches = await _getMatches( - _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(), - owner, - remoteMatches, - limit, + final List remoteMatches = await _assetRepository.getMatches( + assets: onlyLocal, + ownerId: owner, + remote: true, + limit: limit, + ); + final List localMatches = await _assetRepository.getMatches( + assets: remoteMatches, + ownerId: owner, + remote: false, + limit: limit, ); final List deleteCandidates = [], originals = []; @@ -52,7 +57,7 @@ class BackupVerificationService { localMatches, compare: (a, b) => a.fileName.compareTo(b.fileName), both: (a, b) async { - a.exifInfo = await _db.exifInfos.get(a.id); + a.exifInfo = await _exifInfoRepository.get(a.id); deleteCandidates.add(a); originals.add(b); return false; @@ -192,35 +197,6 @@ class BackupVerificationService { return bytes.buffer.asUint64List(start); } - static Future> _getMatches( - QueryBuilder query, - int ownerId, - List assets, - int limit, - ) => - query - .ownerIdEqualTo(ownerId) - .anyOf( - assets, - (q, Asset a) => q - .fileNameEqualTo(a.fileName) - .and() - .durationInSecondsEqualTo(a.durationInSeconds) - .and() - .fileCreatedAtBetween( - a.fileCreatedAt.subtract(const Duration(hours: 12)), - a.fileCreatedAt.add(const Duration(hours: 12)), - ) - .and() - .not() - .checksumEqualTo(a.checksum), - ) - .sortByFileName() - .thenByFileCreatedAt() - .thenByFileModifiedAt() - .limit(limit) - .findAll(); - static bool _sameExceptTimeZone(DateTime a, DateTime b) { final ms = a.isAfter(b) ? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch @@ -233,7 +209,8 @@ class BackupVerificationService { final backupVerificationServiceProvider = Provider( (ref) => BackupVerificationService( - ref.watch(dbProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), ), ); diff --git a/mobile/lib/widgets/asset_viewer/description_input.dart b/mobile/lib/widgets/asset_viewer/description_input.dart index 18ef394e2d..3fdd40130a 100644 --- a/mobile/lib/widgets/asset_viewer/description_input.dart +++ b/mobile/lib/widgets/asset_viewer/description_input.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/asset_description.service.dart'; +import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:logging/logging.dart'; @@ -29,14 +29,16 @@ class DescriptionInput extends HookConsumerWidget { final focusNode = useFocusNode(); final isFocus = useState(false); final isTextEmpty = useState(controller.text.isEmpty); - final descriptionProvider = ref.watch(assetDescriptionServiceProvider); + final assetService = ref.watch(assetServiceProvider); final owner = ref.watch(currentUserProvider); final hasError = useState(false); final assetWithExif = ref.watch(assetDetailProvider(asset)); useEffect( () { - controller.text = descriptionProvider.getAssetDescription(asset); + assetService + .getDescription(asset) + .then((value) => controller.text = value); return null; }, [assetWithExif.value], @@ -45,7 +47,7 @@ class DescriptionInput extends HookConsumerWidget { submitDescription(String description) async { hasError.value = false; try { - await descriptionProvider.setDescription( + await assetService.setDescription( asset, description, ); diff --git a/mobile/test/modules/activity/activity_statistics_provider_test.dart b/mobile/test/modules/activity/activity_statistics_provider_test.dart index 9edabcc0d0..0216528ddd 100644 --- a/mobile/test/modules/activity/activity_statistics_provider_test.dart +++ b/mobile/test/modules/activity/activity_statistics_provider_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/providers/activity_service.provider.dart'; import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:mocktail/mocktail.dart'; @@ -25,7 +26,7 @@ void main() { test('Returns the proper count family', () async { when( () => activityMock.getStatistics('test-album', assetId: 'test-asset'), - ).thenAnswer((_) async => 5); + ).thenAnswer((_) async => const ActivityStats(comments: 5)); // Read here to make the getStatistics call container.read(activityStatisticsProvider('test-album', 'test-asset')); @@ -50,7 +51,7 @@ void main() { test('Adds activity', () async { when( () => activityMock.getStatistics('test-album'), - ).thenAnswer((_) async => 10); + ).thenAnswer((_) async => const ActivityStats(comments: 10)); final provider = activityStatisticsProvider('test-album'); container.listen( @@ -71,7 +72,7 @@ void main() { test('Removes activity', () async { when( () => activityMock.getStatistics('new-album', assetId: 'test-asset'), - ).thenAnswer((_) async => 10); + ).thenAnswer((_) async => const ActivityStats(comments: 10)); final provider = activityStatisticsProvider('new-album', 'test-asset'); container.listen( From 202082f62ee6ad4f9a4a8fb19e2c3b486ec7bf9e Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:50:21 +0200 Subject: [PATCH 426/723] refactor(mobile): use repositories in a number of services (#12891) * UserService * PartnerService * HashService * MemoryService * PersonService * SearchService * StackService --- mobile/analysis_options.yaml | 14 ++-- mobile/lib/constants/constants.dart | 1 + mobile/lib/interfaces/asset.interface.dart | 5 ++ .../lib/interfaces/asset_api.interface.dart | 2 + .../lib/interfaces/partner_api.interface.dart | 13 +++ .../lib/interfaces/person_api.interface.dart | 22 +++++ mobile/lib/interfaces/user.interface.dart | 2 + mobile/lib/interfaces/user_api.interface.dart | 11 +++ .../models/search/search_filter.model.dart | 6 +- .../lib/pages/common/gallery_viewer.page.dart | 4 +- .../lib/pages/search/search_input.page.dart | 4 +- .../activity_service.provider.g.dart | 2 +- .../activity_statistics.provider.g.dart | 2 +- .../suggested_shared_users.provider.dart | 2 +- .../providers/map/map_state.provider.g.dart | 2 +- .../lib/providers/search/people.provider.dart | 4 +- .../providers/search/people.provider.g.dart | 7 +- mobile/lib/repositories/asset.repository.dart | 25 ++++++ .../repositories/asset_api.repository.dart | 31 ++++++- .../repositories/partner_api.repository.dart | 51 ++++++++++++ .../repositories/person_api.repository.dart | 38 +++++++++ mobile/lib/repositories/user.repository.dart | 16 ++++ .../lib/repositories/user_api.repository.dart | 41 ++++++++++ mobile/lib/services/background.service.dart | 19 +++-- mobile/lib/services/hash.service.dart | 29 +++---- mobile/lib/services/memory.service.dart | 13 ++- mobile/lib/services/partner.service.dart | 62 ++++++-------- mobile/lib/services/person.service.dart | 77 +++++++----------- mobile/lib/services/person.service.g.dart | 2 +- mobile/lib/services/search.service.dart | 12 +-- mobile/lib/services/stack.service.dart | 15 ++-- mobile/lib/services/user.service.dart | 80 ++++++++----------- mobile/lib/utils/image_url_builder.dart | 4 +- .../widgets/asset_grid/thumbnail_image.dart | 4 +- .../search/search_filter/people_picker.dart | 8 +- 35 files changed, 416 insertions(+), 214 deletions(-) create mode 100644 mobile/lib/constants/constants.dart create mode 100644 mobile/lib/interfaces/partner_api.interface.dart create mode 100644 mobile/lib/interfaces/person_api.interface.dart create mode 100644 mobile/lib/interfaces/user_api.interface.dart create mode 100644 mobile/lib/repositories/partner_api.repository.dart create mode 100644 mobile/lib/repositories/person_api.repository.dart create mode 100644 mobile/lib/repositories/user_api.repository.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index e996a54372..6a7d7a6b4d 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -69,14 +69,14 @@ custom_lint: - integration_test/test_utils/general_helper.dart - lib/main.dart - lib/routing/router.dart - - lib/utils/{db,image_url_builder,migration,renderlist_generator}.dart + - lib/utils/{db,migration,renderlist_generator}.dart - test/**.dart # refactor to make the providers and services testable - - lib/pages/common/{album_asset_selection,gallery_viewer}.page.dart + - lib/pages/common/album_asset_selection.page.dart - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart - - lib/services/{asset,background,backup,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart - - lib/widgets/asset_grid/{asset_grid_data_structure,thumbnail_image}.dart + - lib/services/{asset,background,backup,immich_logger,sync}.service.dart + - lib/widgets/asset_grid/asset_grid_data_structure.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories @@ -90,18 +90,16 @@ custom_lint: - test/modules/utils/openapi_patching_test.dart # filename is self-explanatory... # refactor - lib/models/map/map_marker.model.dart - - lib/models/search/search_filter.model.dart - lib/models/server_info/server_{config,disk_info,features,version}.model.dart - lib/models/shared_link/shared_link.model.dart - - lib/pages/search/search_input.page.dart - lib/providers/asset_viewer/asset_people.provider.dart - lib/providers/authentication.provider.dart - lib/providers/image/immich_remote_{image,thumbnail}_provider.dart - lib/providers/map/map_state.provider.dart - - lib/providers/search/{people,search,search_filter}.provider.dart + - lib/providers/search/{search,search_filter}.provider.dart - lib/providers/websocket.provider.dart - lib/routing/auth_guard.dart - - lib/services/{api,asset,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart + - lib/services/{api,asset,backup,memory,oauth,search,shared_link,stack,trash}.service.dart - lib/widgets/album/album_thumbnail_listtile.dart - lib/widgets/forms/login/login_form.dart - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart new file mode 100644 index 0000000000..8b74b1a66f --- /dev/null +++ b/mobile/lib/constants/constants.dart @@ -0,0 +1 @@ +const int noDbId = -9223372036854775808; // from Isar diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 98f4c7687c..0d2dcfa1b5 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -1,5 +1,6 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/device_asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; abstract interface class IAssetRepository { @@ -12,6 +13,7 @@ abstract interface class IAssetRepository { bool? remote, int limit = 100, }); + Future> updateAll(List assets); Future> getMatches({ required List assets, @@ -19,4 +21,7 @@ abstract interface class IAssetRepository { bool? remote, int limit = 100, }); + + Future> getDeviceAssetsById(List ids); + Future upsertDeviceAssets(List deviceAssets); } diff --git a/mobile/lib/interfaces/asset_api.interface.dart b/mobile/lib/interfaces/asset_api.interface.dart index 201c85cea7..fe3320c9bb 100644 --- a/mobile/lib/interfaces/asset_api.interface.dart +++ b/mobile/lib/interfaces/asset_api.interface.dart @@ -13,4 +13,6 @@ abstract interface class IAssetApiRepository { }); // Future delete(String id); + + Future> search({List personIds = const []}); } diff --git a/mobile/lib/interfaces/partner_api.interface.dart b/mobile/lib/interfaces/partner_api.interface.dart new file mode 100644 index 0000000000..bca1baf66d --- /dev/null +++ b/mobile/lib/interfaces/partner_api.interface.dart @@ -0,0 +1,13 @@ +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IPartnerApiRepository { + Future> getAll(Direction direction); + Future create(String id); + Future update(String id, {required bool inTimeline}); + Future delete(String id); +} + +enum Direction { + sharedWithMe, + sharedByMe, +} diff --git a/mobile/lib/interfaces/person_api.interface.dart b/mobile/lib/interfaces/person_api.interface.dart new file mode 100644 index 0000000000..b2fa28df8c --- /dev/null +++ b/mobile/lib/interfaces/person_api.interface.dart @@ -0,0 +1,22 @@ +abstract interface class IPersonApiRepository { + Future> getAll(); + Future update(String id, {String? name}); +} + +class Person { + Person({ + required this.id, + required this.isHidden, + required this.name, + required this.thumbnailPath, + this.birthDate, + this.updatedAt, + }); + + final String id; + final DateTime? birthDate; + final bool isHidden; + final String name; + final String thumbnailPath; + final DateTime? updatedAt; +} diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index 4e847ea022..828a7b2398 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -3,4 +3,6 @@ import 'package:immich_mobile/entities/user.entity.dart'; abstract interface class IUserRepository { Future> getByIds(List ids); Future get(String id); + Future> getAll({bool self = true}); + Future update(User user); } diff --git a/mobile/lib/interfaces/user_api.interface.dart b/mobile/lib/interfaces/user_api.interface.dart new file mode 100644 index 0000000000..67ac3c0883 --- /dev/null +++ b/mobile/lib/interfaces/user_api.interface.dart @@ -0,0 +1,11 @@ +import 'dart:typed_data'; + +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IUserApiRepository { + Future> getAll(); + Future<({String profileImagePath})> createProfileImage({ + required String name, + required Uint8List data, + }); +} diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 6a7c612b15..297a819b6a 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; class SearchLocationFilter { String? country; @@ -235,7 +235,7 @@ class SearchDisplayFilters { class SearchFilter { String? context; String? filename; - Set people; + Set people; SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; @@ -258,7 +258,7 @@ class SearchFilter { SearchFilter copyWith({ String? context, String? filename, - Set? people, + Set? people, SearchLocationFilter? location, SearchCameraFilter? camera, SearchDateFilter? date, diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index d8ea7cd89b..1434d1cca5 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/pages/common/video_viewer.page.dart'; @@ -30,7 +31,6 @@ import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.dart'; import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart'; import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart'; -import 'package:isar/isar.dart'; @RoutePage() // ignore: must_be_immutable @@ -73,7 +73,7 @@ class GalleryViewerPage extends HookConsumerWidget { : []; final stackElements = showStack ? [currentAsset, ...stack] : []; // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = currentAsset.id == Isar.autoIncrement; + final isFromDto = currentAsset.id == noDbId; Asset asset = stackIndex.value == -1 ? currentAsset diff --git a/mobile/lib/pages/search/search_input.page.dart b/mobile/lib/pages/search/search_input.page.dart index acabc75aa4..2ca2a37918 100644 --- a/mobile/lib/pages/search/search_input.page.dart +++ b/mobile/lib/pages/search/search_input.page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; @@ -19,7 +20,6 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart'; import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart'; -import 'package:openapi/api.dart'; @RoutePage() class SearchInputPage extends HookConsumerWidget { @@ -110,7 +110,7 @@ class SearchInputPage extends HookConsumerWidget { } showPeoplePicker() { - handleOnSelect(Set value) { + handleOnSelect(Set value) { filter.value = filter.value.copyWith( people: value, ); diff --git a/mobile/lib/providers/activity_service.provider.g.dart b/mobile/lib/providers/activity_service.provider.g.dart index 8e5ef43260..d42b2a39e4 100644 --- a/mobile/lib/providers/activity_service.provider.g.dart +++ b/mobile/lib/providers/activity_service.provider.g.dart @@ -6,7 +6,7 @@ part of 'activity_service.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$activityServiceHash() => r'5dd4955d14f5bf01c00d7f8750d07e7ace7cc4b0'; +String _$activityServiceHash() => r'23a3ee7db71676d2719daa64217a683cc5c7eab0'; /// See also [activityService]. @ProviderFor(activityService) diff --git a/mobile/lib/providers/activity_statistics.provider.g.dart b/mobile/lib/providers/activity_statistics.provider.g.dart index 79856c525b..16a3c0e81b 100644 --- a/mobile/lib/providers/activity_statistics.provider.g.dart +++ b/mobile/lib/providers/activity_statistics.provider.g.dart @@ -7,7 +7,7 @@ part of 'activity_statistics.provider.dart'; // ************************************************************************** String _$activityStatisticsHash() => - r'a5f7bbee1891c33b72919a34e632ca9ef9cd8dbf'; + r'1f43f0bcb11c754ca3cb586a13570db25023b9a8'; /// Copied from Dart SDK class _SystemHash { diff --git a/mobile/lib/providers/album/suggested_shared_users.provider.dart b/mobile/lib/providers/album/suggested_shared_users.provider.dart index 77518f47d0..fe8a1fccce 100644 --- a/mobile/lib/providers/album/suggested_shared_users.provider.dart +++ b/mobile/lib/providers/album/suggested_shared_users.provider.dart @@ -5,5 +5,5 @@ import 'package:immich_mobile/services/user.service.dart'; final otherUsersProvider = FutureProvider.autoDispose>((ref) { UserService userService = ref.watch(userServiceProvider); - return userService.getUsersInDb(); + return userService.getUsers(); }); diff --git a/mobile/lib/providers/map/map_state.provider.g.dart b/mobile/lib/providers/map/map_state.provider.g.dart index eff7b4b68e..23a570d1c8 100644 --- a/mobile/lib/providers/map/map_state.provider.g.dart +++ b/mobile/lib/providers/map/map_state.provider.g.dart @@ -6,7 +6,7 @@ part of 'map_state.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$mapStateNotifierHash() => r'31fafe17aa85c48379a22ed3db3cc94af59ce5b8'; +String _$mapStateNotifierHash() => r'22e4e571bd0730dbc34b109255a62b920e9c7d66'; /// See also [MapStateNotifier]. @ProviderFor(MapStateNotifier) diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart index e2c243354b..7c956f0a37 100644 --- a/mobile/lib/providers/search/people.provider.dart +++ b/mobile/lib/providers/search/people.provider.dart @@ -1,14 +1,14 @@ +import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/services/person.service.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:openapi/api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'people.provider.g.dart'; @riverpod -Future> getAllPeople( +Future> getAllPeople( GetAllPeopleRef ref, ) async { final PersonService personService = ref.read(personServiceProvider); diff --git a/mobile/lib/providers/search/people.provider.g.dart b/mobile/lib/providers/search/people.provider.g.dart index db2edfb956..c5ff6287cd 100644 --- a/mobile/lib/providers/search/people.provider.g.dart +++ b/mobile/lib/providers/search/people.provider.g.dart @@ -6,12 +6,11 @@ part of 'people.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$getAllPeopleHash() => r'4eff6666be5a74710d1e8587e01d8154310d85bd'; +String _$getAllPeopleHash() => r'3417b7e0c211382d4480a415e352139995d57b6d'; /// See also [getAllPeople]. @ProviderFor(getAllPeople) -final getAllPeopleProvider = - AutoDisposeFutureProvider>.internal( +final getAllPeopleProvider = AutoDisposeFutureProvider>.internal( getAllPeople, name: r'getAllPeopleProvider', debugGetCreateSourceHash: @@ -20,7 +19,7 @@ final getAllPeopleProvider = allTransitiveDependencies: null, ); -typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; +typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; String _$personAssetsHash() => r'3dfecb67a54d07e4208bcb9581b2625acd2e1832'; /// Copied from Dart SDK diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index c6012af371..087344302a 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -1,6 +1,11 @@ +import 'dart:io'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/device_asset.entity.dart'; +import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; @@ -69,6 +74,12 @@ class AssetRepository implements IAssetRepository { return query.limit(limit).findAll(); } + @override + Future> updateAll(List assets) async { + await _db.writeTxn(() => _db.assets.putAll(assets)); + return assets; + } + @override Future> getMatches({ required List assets, @@ -86,6 +97,20 @@ class AssetRepository implements IAssetRepository { } return _getMatchesImpl(query, ownerId, assets, limit); } + + @override + Future> getDeviceAssetsById(List ids) => + Platform.isAndroid + ? _db.androidDeviceAssets.getAll(ids.cast()) + : _db.iOSDeviceAssets.getAllById(ids.cast()); + + @override + Future upsertDeviceAssets(List deviceAssets) => + _db.writeTxn( + () => Platform.isAndroid + ? _db.androidDeviceAssets.putAll(deviceAssets.cast()) + : _db.iOSDeviceAssets.putAll(deviceAssets.cast()), + ); } Future> _getMatchesImpl( diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 3ad0e1cba0..eb796f6c6b 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -6,14 +6,18 @@ import 'package:immich_mobile/repositories/base_api.repository.dart'; import 'package:openapi/api.dart'; final assetApiRepositoryProvider = Provider( - (ref) => AssetApiRepository(ref.watch(apiServiceProvider).assetsApi), + (ref) => AssetApiRepository( + ref.watch(apiServiceProvider).assetsApi, + ref.watch(apiServiceProvider).searchApi, + ), ); class AssetApiRepository extends BaseApiRepository implements IAssetApiRepository { final AssetsApi _api; + final SearchApi _searchApi; - AssetApiRepository(this._api); + AssetApiRepository(this._api, this._searchApi); @override Future update(String id, {String? description}) async { @@ -22,4 +26,27 @@ class AssetApiRepository extends BaseApiRepository ); return Asset.remote(response); } + + @override + Future> search({List personIds = const []}) async { + // TODO this always fetches all assets, change API and usage to actually do pagination + final List result = []; + bool hasNext = true; + int currentPage = 1; + while (hasNext) { + final response = await checkNull( + _searchApi.searchMetadata( + MetadataSearchDto( + personIds: personIds, + page: currentPage, + size: 1000, + ), + ), + ); + result.addAll(response.assets.items.map(Asset.remote)); + hasNext = response.assets.nextPage != null; + currentPage++; + } + return result; + } } diff --git a/mobile/lib/repositories/partner_api.repository.dart b/mobile/lib/repositories/partner_api.repository.dart new file mode 100644 index 0000000000..3419a2bc77 --- /dev/null +++ b/mobile/lib/repositories/partner_api.repository.dart @@ -0,0 +1,51 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final partnerApiRepositoryProvider = Provider( + (ref) => PartnerApiRepository( + ref.watch(apiServiceProvider).partnersApi, + ), +); + +class PartnerApiRepository extends BaseApiRepository + implements IPartnerApiRepository { + final PartnersApi _api; + + PartnerApiRepository(this._api); + + @override + Future> getAll(Direction direction) async { + final response = await checkNull( + _api.getPartners( + direction == Direction.sharedByMe + ? PartnerDirection.by + : PartnerDirection.with_, + ), + ); + return response.map(User.fromPartnerDto).toList(); + } + + @override + Future create(String id) async { + final dto = await checkNull(_api.createPartner(id)); + return User.fromPartnerDto(dto); + } + + @override + Future delete(String id) => checkNull(_api.removePartner(id)); + + @override + Future update(String id, {required bool inTimeline}) async { + final dto = await checkNull( + _api.updatePartner( + id, + UpdatePartnerDto(inTimeline: inTimeline), + ), + ); + return User.fromPartnerDto(dto); + } +} diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart new file mode 100644 index 0000000000..8071c33dc2 --- /dev/null +++ b/mobile/lib/repositories/person_api.repository.dart @@ -0,0 +1,38 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final personApiRepositoryProvider = Provider( + (ref) => PersonApiRepository(ref.watch(apiServiceProvider).peopleApi), +); + +class PersonApiRepository extends BaseApiRepository + implements IPersonApiRepository { + final PeopleApi _api; + + PersonApiRepository(this._api); + + @override + Future> getAll() async { + final dto = await checkNull(_api.getAllPeople()); + return dto.people.map(_toPerson).toList(); + } + + @override + Future update(String id, {String? name}) async { + final dto = await checkNull( + _api.updatePerson(id, PersonUpdateDto(name: name)), + ); + return _toPerson(dto); + } + + static Person _toPerson(PersonResponseDto dto) => Person( + birthDate: dto.birthDate, + id: dto.id, + isHidden: dto.isHidden, + name: dto.name, + thumbnailPath: dto.thumbnailPath, + ); +} diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index b05af9a57f..796b1f421b 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; @@ -20,4 +21,19 @@ class UserRepository implements IUserRepository { @override Future get(String id) => _db.users.getById(id); + + @override + Future> getAll({bool self = true}) { + if (self) { + return _db.users.where().findAll(); + } + final int userId = Store.get(StoreKey.currentUser).isarId; + return _db.users.where().isarIdNotEqualTo(userId).findAll(); + } + + @override + Future update(User user) async { + await _db.writeTxn(() => _db.users.put(user)); + return user; + } } diff --git a/mobile/lib/repositories/user_api.repository.dart b/mobile/lib/repositories/user_api.repository.dart new file mode 100644 index 0000000000..ffc50ae4c3 --- /dev/null +++ b/mobile/lib/repositories/user_api.repository.dart @@ -0,0 +1,41 @@ +import 'dart:typed_data'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/user_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final userApiRepositoryProvider = Provider( + (ref) => UserApiRepository( + ref.watch(apiServiceProvider).usersApi, + ), +); + +class UserApiRepository extends BaseApiRepository + implements IUserApiRepository { + final UsersApi _api; + + UserApiRepository(this._api); + + @override + Future> getAll() async { + final dto = await checkNull(_api.searchUsers()); + return dto.map(User.fromSimpleUserDto).toList(); + } + + @override + Future<({String profileImagePath})> createProfileImage({ + required String name, + required Uint8List data, + }) async { + final response = await checkNull( + _api.createProfileImage( + MultipartFile.fromBytes('file', data, filename: name), + ), + ); + return (profileImagePath: response.profileImagePath); + } +} diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 09030a621b..d06bc86d48 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -18,7 +18,9 @@ import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; +import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; @@ -30,7 +32,6 @@ import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/services/partner.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; @@ -362,16 +363,20 @@ class BackgroundService { apiService.setAccessToken(Store.get(StoreKey.accessToken)); AppSettingsService settingService = AppSettingsService(); AppSettingsService settingsService = AppSettingsService(); - PartnerService partnerService = PartnerService(apiService, db); AlbumRepository albumRepository = AlbumRepository(db); AssetRepository assetRepository = AssetRepository(db); BackupRepository backupAlbumRepository = BackupRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); UserRepository userRepository = UserRepository(db); + UserApiRepository userApiRepository = + UserApiRepository(apiService.usersApi); AlbumApiRepository albumApiRepository = AlbumApiRepository(apiService.albumsApi); - HashService hashService = HashService(db, this, albumMediaRepository); + PartnerApiRepository partnerApiRepository = + PartnerApiRepository(apiService.partnersApi); + HashService hashService = + HashService(assetRepository, this, albumMediaRepository); EntityService entityService = EntityService(assetRepository, userRepository); SyncService syncSerive = SyncService( @@ -381,8 +386,12 @@ class BackgroundService { albumMediaRepository, albumApiRepository, ); - UserService userService = - UserService(apiService, db, syncSerive, partnerService); + UserService userService = UserService( + partnerApiRepository, + userApiRepository, + userRepository, + syncSerive, + ); AlbumService albumService = AlbumService( userService, syncSerive, diff --git a/mobile/lib/services/hash.service.dart b/mobile/lib/services/hash.service.dart index 94d680972f..3827e421e6 100644 --- a/mobile/lib/services/hash.service.dart +++ b/mobile/lib/services/hash.service.dart @@ -4,20 +4,24 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; class HashService { - HashService(this._db, this._backgroundService, this._albumMediaRepository); - final Isar _db; + HashService( + this._assetRepository, + this._backgroundService, + this._albumMediaRepository, + ); + final IAssetRepository _assetRepository; final BackgroundService _backgroundService; final IAlbumMediaRepository _albumMediaRepository; final _log = Logger('HashService'); @@ -55,7 +59,8 @@ class HashService { final ids = assets .map(Platform.isAndroid ? (a) => a.localId!.toInt() : (a) => a.localId!) .toList(); - final List hashes = await _lookupHashes(ids); + final List hashes = + await _assetRepository.getDeviceAssetsById(ids); final List toAdd = []; final List toHash = []; @@ -106,12 +111,6 @@ class HashService { return _getHashedAssets(assets, hashes); } - /// Lookup hashes of assets by their local ID - Future> _lookupHashes(List ids) => - Platform.isAndroid - ? _db.androidDeviceAssets.getAll(ids.cast()) - : _db.iOSDeviceAssets.getAllById(ids.cast()); - /// Processes a batch of files and saves any successfully hashed /// values to the DB table. Future _processBatch( @@ -131,11 +130,7 @@ class HashService { final validHashes = anyNull ? toAdd.where((e) => e.hash.length == 20).toList(growable: false) : toAdd; - await _db.writeTxn( - () => Platform.isAndroid - ? _db.androidDeviceAssets.putAll(validHashes.cast()) - : _db.iOSDeviceAssets.putAll(validHashes.cast()), - ); + await _assetRepository.upsertDeviceAssets(validHashes); _log.fine("Hashed ${validHashes.length}/${toHash.length} assets"); } @@ -168,7 +163,7 @@ class HashService { final hashServiceProvider = Provider( (ref) => HashService( - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ref.watch(backgroundServiceProvider), ref.watch(albumMediaRepositoryProvider), ), diff --git a/mobile/lib/services/memory.service.dart b/mobile/lib/services/memory.service.dart index ea07f7c019..b95899df67 100644 --- a/mobile/lib/services/memory.service.dart +++ b/mobile/lib/services/memory.service.dart @@ -1,18 +1,17 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; final memoryServiceProvider = StateProvider((ref) { return MemoryService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ); }); @@ -20,9 +19,9 @@ class MemoryService { final log = Logger("MemoryService"); final ApiService _apiService; - final Isar _db; + final IAssetRepository _assetRepository; - MemoryService(this._apiService, this._db); + MemoryService(this._apiService, this._assetRepository); Future?> getMemoryLane() async { try { @@ -39,7 +38,7 @@ class MemoryService { List memories = []; for (final MemoryLaneResponseDto(:yearsAgo, :assets) in data) { final dbAssets = - await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); + await _assetRepository.getAllByRemoteId(assets.map((e) => e.id)); if (dbAssets.isNotEmpty) { final String title = yearsAgo <= 1 ? 'memories_year_ago'.tr() diff --git a/mobile/lib/services/partner.service.dart b/mobile/lib/services/partner.service.dart index 8cd2fe424f..67d7f4e1d1 100644 --- a/mobile/lib/services/partner.service.dart +++ b/mobile/lib/services/partner.service.dart @@ -1,43 +1,33 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final partnerServiceProvider = Provider( (ref) => PartnerService( - ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(partnerApiRepositoryProvider), + ref.watch(userRepositoryProvider), ), ); class PartnerService { - final ApiService _apiService; - final Isar _db; + final IPartnerApiRepository _partnerApiRepository; + final IUserRepository _userRepository; final Logger _log = Logger("PartnerService"); - PartnerService(this._apiService, this._db); - - Future?> getPartners(PartnerDirection direction) async { - try { - final userDtos = await _apiService.partnersApi.getPartners(direction); - if (userDtos != null) { - return userDtos.map((u) => User.fromPartnerDto(u)).toList(); - } - } catch (e) { - _log.warning("Failed to get partners for direction $direction", e); - } - return null; - } + PartnerService( + this._partnerApiRepository, + this._userRepository, + ); Future removePartner(User partner) async { try { - await _apiService.partnersApi.removePartner(partner.id); + await _partnerApiRepository.delete(partner.id); partner.isPartnerSharedBy = false; - await _db.writeTxn(() => _db.users.put(partner)); + await _userRepository.update(partner); } catch (e) { _log.warning("Failed to remove partner ${partner.id}", e); return false; @@ -47,12 +37,10 @@ class PartnerService { Future addPartner(User partner) async { try { - final dto = await _apiService.partnersApi.createPartner(partner.id); - if (dto != null) { - partner.isPartnerSharedBy = true; - await _db.writeTxn(() => _db.users.put(partner)); - return true; - } + await _partnerApiRepository.create(partner.id); + partner.isPartnerSharedBy = true; + await _userRepository.update(partner); + return true; } catch (e) { _log.warning("Failed to add partner ${partner.id}", e); } @@ -61,13 +49,13 @@ class PartnerService { Future updatePartner(User partner, {required bool inTimeline}) async { try { - final dto = await _apiService.partnersApi - .updatePartner(partner.id, UpdatePartnerDto(inTimeline: inTimeline)); - if (dto != null) { - partner.inTimeline = dto.inTimeline ?? partner.inTimeline; - await _db.writeTxn(() => _db.users.put(partner)); - return true; - } + final dto = await _partnerApiRepository.update( + partner.id, + inTimeline: inTimeline, + ); + partner.inTimeline = dto.inTimeline; + await _userRepository.update(partner); + return true; } catch (e) { _log.warning("Failed to update partner ${partner.id}", e); } diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart index ddb61f5e48..5b325acdc5 100644 --- a/mobile/lib/services/person.service.dart +++ b/mobile/lib/services/person.service.dart @@ -1,29 +1,37 @@ import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/person_api.repository.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'person.service.g.dart'; @riverpod -PersonService personService(PersonServiceRef ref) => - PersonService(ref.read(apiServiceProvider), ref.read(dbProvider)); +PersonService personService(PersonServiceRef ref) => PersonService( + ref.watch(personApiRepositoryProvider), + ref.watch(assetApiRepositoryProvider), + ref.read(assetRepositoryProvider), + ); class PersonService { final Logger _log = Logger("PersonService"); - final ApiService _apiService; - final Isar _db; + final IPersonApiRepository _personApiRepository; + final IAssetApiRepository _assetApiRepository; + final IAssetRepository _assetRepository; - PersonService(this._apiService, this._db); + PersonService( + this._personApiRepository, + this._assetApiRepository, + this._assetRepository, + ); - Future> getAllPeople() async { + Future> getAllPeople() async { try { - final peopleResponseDto = await _apiService.peopleApi.getAllPeople(); - return peopleResponseDto?.people ?? []; + return await _personApiRepository.getAll(); } catch (error, stack) { _log.severe("Error while fetching curated people", error, stack); return []; @@ -31,50 +39,19 @@ class PersonService { } Future> getPersonAssets(String id) async { - List result = []; - var hasNext = true; - var currentPage = 1; - try { - while (hasNext) { - final response = await _apiService.searchApi.searchMetadata( - MetadataSearchDto( - personIds: [id], - page: currentPage, - size: 1000, - ), - ); - - if (response == null) { - break; - } - - if (response.assets.nextPage == null) { - hasNext = false; - } - - final assets = response.assets.items; - final mapAssets = - await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); - result.addAll(mapAssets); - - currentPage++; - } + final assets = await _assetApiRepository.search(personIds: [id]); + return await _assetRepository + .getAllByRemoteId(assets.map((a) => a.remoteId!)); } catch (error, stack) { _log.severe("Error while fetching person assets", error, stack); } - - return result; + return []; } - Future updateName(String id, String name) async { + Future updateName(String id, String name) async { try { - return await _apiService.peopleApi.updatePerson( - id, - PersonUpdateDto( - name: name, - ), - ); + return await _personApiRepository.update(id, name: name); } catch (error, stack) { _log.severe("Error while updating person name", error, stack); } diff --git a/mobile/lib/services/person.service.g.dart b/mobile/lib/services/person.service.g.dart index 01a5ed8f30..9a24069fbf 100644 --- a/mobile/lib/services/person.service.g.dart +++ b/mobile/lib/services/person.service.g.dart @@ -6,7 +6,7 @@ part of 'person.service.dart'; // RiverpodGenerator // ************************************************************************** -String _$personServiceHash() => r'54e6df4b8eea744f6de009f8315c9fe6230f6798'; +String _$personServiceHash() => r'32f28cb5a3de0553c17447e33a0efde7409a43ed'; /// See also [personService]. @ProviderFor(personService) diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index cf3905e5ca..336fe45010 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -1,27 +1,27 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; final searchServiceProvider = Provider( (ref) => SearchService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ), ); class SearchService { final ApiService _apiService; - final Isar _db; + final IAssetRepository _assetRepository; final _log = Logger("SearchService"); - SearchService(this._apiService, this._db); + SearchService(this._apiService, this._assetRepository); Future?> getSearchSuggestions( SearchSuggestionType type, { @@ -103,7 +103,7 @@ class SearchService { return null; } - return _db.assets + return _assetRepository .getAllByRemoteId(response.assets.items.map((e) => e.id)); } catch (error, stackTrace) { _log.severe("Failed to search for assets", error, stackTrace); diff --git a/mobile/lib/services/stack.service.dart b/mobile/lib/services/stack.service.dart index 75074101c2..8bff21fef6 100644 --- a/mobile/lib/services/stack.service.dart +++ b/mobile/lib/services/stack.service.dart @@ -1,17 +1,17 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; class StackService { - StackService(this._api, this._db); + StackService(this._api, this._assetRepository); final ApiService _api; - final Isar _db; + final IAssetRepository _assetRepository; Future getStack(String stackId) async { try { @@ -61,10 +61,7 @@ class StackService { removeAssets.add(asset); } - - _db.writeTxn(() async { - await _db.assets.putAll(removeAssets); - }); + await _assetRepository.updateAll(removeAssets); } catch (error) { debugPrint("Error while deleting stack: $error"); } @@ -74,6 +71,6 @@ class StackService { final stackServiceProvider = Provider( (ref) => StackService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ), ); diff --git a/mobile/lib/services/user.service.dart b/mobile/lib/services/user.service.dart index 9631141c41..4c2b3cbbd0 100644 --- a/mobile/lib/services/user.service.dart +++ b/mobile/lib/services/user.service.dart @@ -1,68 +1,48 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:immich_mobile/services/partner.service.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/interfaces/user_api.interface.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; +import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final userServiceProvider = Provider( (ref) => UserService( - ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(partnerApiRepositoryProvider), + ref.watch(userApiRepositoryProvider), + ref.watch(userRepositoryProvider), ref.watch(syncServiceProvider), - ref.watch(partnerServiceProvider), ), ); class UserService { - final ApiService _apiService; - final Isar _db; + final IPartnerApiRepository _partnerApiRepository; + final IUserApiRepository _userApiRepository; + final IUserRepository _userRepository; final SyncService _syncService; - final PartnerService _partnerService; final Logger _log = Logger("UserService"); UserService( - this._apiService, - this._db, + this._partnerApiRepository, + this._userApiRepository, + this._userRepository, this._syncService, - this._partnerService, ); - Future?> _getAllUsers() async { - try { - final dto = await _apiService.usersApi.searchUsers(); - return dto?.map(User.fromSimpleUserDto).toList(); - } catch (e) { - _log.warning("Failed get all users", e); - return null; - } - } + Future> getUsers({bool self = false}) => + _userRepository.getAll(self: self); - Future> getUsersInDb({bool self = false}) async { - if (self) { - return _db.users.where().findAll(); - } - final int userId = Store.get(StoreKey.currentUser).isarId; - return _db.users.where().isarIdNotEqualTo(userId).findAll(); - } - - Future uploadProfileImage(XFile image) async { + Future<({String profileImagePath})?> uploadProfileImage(XFile image) async { try { - return await _apiService.usersApi.createProfileImage( - MultipartFile.fromBytes( - 'file', - await image.readAsBytes(), - filename: image.name, - ), + return await _userApiRepository.createProfileImage( + name: image.name, + data: await image.readAsBytes(), ); } catch (e) { _log.warning("Failed to upload profile image", e); @@ -71,13 +51,19 @@ class UserService { } Future?> getUsersFromServer() async { - final List? users = await _getAllUsers(); - final List? sharedBy = - await _partnerService.getPartners(PartnerDirection.by); - final List? sharedWith = - await _partnerService.getPartners(PartnerDirection.with_); + List? users; + try { + users = await _userApiRepository.getAll(); + } catch (e) { + _log.warning("Failed to fetch users", e); + users = null; + } + final List sharedBy = + await _partnerApiRepository.getAll(Direction.sharedByMe); + final List sharedWith = + await _partnerApiRepository.getAll(Direction.sharedWithMe); - if (users == null || sharedBy == null || sharedWith == null) { + if (users == null) { _log.warning("Failed to refresh users"); return null; } diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index e7a1b9e39e..9fc7b13eed 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -1,7 +1,7 @@ +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; String getThumbnailUrl( @@ -61,7 +61,7 @@ String getOriginalUrlForRemoteId(final String id) { String getImageCacheKey(final Asset asset) { // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = asset.id == Isar.autoIncrement; + final isFromDto = asset.id == noDbId; return '${isFromDto ? asset.remoteId : asset.id}_fullStage'; } diff --git a/mobile/lib/widgets/asset_grid/thumbnail_image.dart b/mobile/lib/widgets/asset_grid/thumbnail_image.dart index 8e818f64fb..6cadef763d 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_image.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_image.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; import 'package:immich_mobile/utils/storage_indicator.dart'; -import 'package:isar/isar.dart'; class ThumbnailImage extends ConsumerWidget { /// The asset to show the thumbnail image for @@ -46,7 +46,7 @@ class ThumbnailImage extends ConsumerWidget { ? context.primaryColor.darken(amount: 0.6) : context.primaryColor.lighten(amount: 0.8); // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = asset.id == Isar.autoIncrement; + final isFromDto = asset.id == noDbId; Widget buildSelectionIcon(Asset asset) { if (isSelected) { diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index d79ae5bd95..dfc435c807 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -3,23 +3,23 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:openapi/api.dart'; class PeoplePicker extends HookConsumerWidget { const PeoplePicker({super.key, required this.onSelect, this.filter}); - final Function(Set) onSelect; - final Set? filter; + final Function(Set) onSelect; + final Set? filter; @override Widget build(BuildContext context, WidgetRef ref) { var imageSize = 45.0; final people = ref.watch(getAllPeopleProvider); final headers = ApiService.getRequestHeaders(); - final selectedPeople = useState>(filter ?? {}); + final selectedPeople = useState>(filter ?? {}); return people.widgetWhen( onData: (people) { From f031c096870e75e8a31ca357935fd8a24273613f Mon Sep 17 00:00:00 2001 From: JonOcto <22536384+JonOcto@users.noreply.github.com> Date: Wed, 25 Sep 2024 00:18:07 +1000 Subject: [PATCH 427/723] fix(docs): typo in remote-access.md (#12895) Fixed typo in remote-access.md Fixed spelling of "tutorial". --- docs/docs/guides/remote-access.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/guides/remote-access.md b/docs/docs/guides/remote-access.md index 1ea068c3a0..6f401dfc5a 100644 --- a/docs/docs/guides/remote-access.md +++ b/docs/docs/guides/remote-access.md @@ -27,7 +27,7 @@ You may use a VPN service to open an encrypted connection to your Immich instanc If you are unable to open a port on your router for Wireguard or OpenVPN to your server, [Tailscale](https://tailscale.com/) is a good option. Tailscale mediates a peer-to-peer wireguard tunnel between your server and remote device, even if one or both of them are behind a [NAT firewall](https://en.wikipedia.org/wiki/Network_address_translation). -:::tip Video toturial +:::tip Video tutorial You can learn how to set up Tailscale together with Immich with the [tutorial video](https://www.youtube.com/watch?v=Vt4PDUXB_fg) they created. ::: From b85d8943e7ce65f826e2c56d8a23922994dc22fa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:36:25 -0400 Subject: [PATCH 428/723] chore(deps): update base-image to v20240924 (major) (#12893) chore(deps): update base-image to v20240924 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 64dcab758b..66965c0edb 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20240917@sha256:3d92952d37cd68f5bf641aa80e5cc034e0d11f3774147f5db8db93138cfa5b3b AS dev +FROM ghcr.io/immich-app/base-server-dev:20240924@sha256:fff4358d435065a626c64a4c015cbfce6ee714b05fabe39aa0d83d8cff3951f2 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -41,7 +41,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20240917@sha256:67a40250f03812fe1e6f6b6345a3c7b71b3a9f24c65ed4862e82be8b3e53d23a +FROM ghcr.io/immich-app/base-server-prod:20240924@sha256:af3089fe48d7ff162594bd7edfffa56ba4e7014ad10ad69c4ebfd428e39b06ff WORKDIR /usr/src/app ENV NODE_ENV=production \ From af8f3774d0f6dc582c4a8449e315b18d629d68cf Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:38:13 -0400 Subject: [PATCH 429/723] docs: details for windows users how to change docker volume (#12551) * details for windows users * Update requirements.md --- docs/docs/install/docker-compose.mdx | 1 + docs/docs/install/environment-variables.md | 28 ++++++------------- docs/docs/install/requirements.md | 32 ++++++++++++++++++++-- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/docs/docs/install/docker-compose.mdx b/docs/docs/install/docker-compose.mdx index a3bd703a01..b73d51b4d2 100644 --- a/docs/docs/install/docker-compose.mdx +++ b/docs/docs/install/docker-compose.mdx @@ -58,6 +58,7 @@ Optionally, you can enable hardware acceleration for machine learning and transc - Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. - Consider changing `DB_PASSWORD` to a custom value. Postgres is not publically exposed, so this password is only used for local authentication. To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`. +- Set your timezone by uncommenting the `TZ=` line. ### Step 3 - Start the containers diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index a0cf71e044..3944f6755b 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -27,23 +27,14 @@ If this should not work, try running `docker compose up -d --force-recreate`. These environment variables are used by the `docker-compose.yml` file and do **NOT** affect the containers directly. ::: -### Supported filesystems - -The Immich Postgres database (`DB_DATA_LOCATION`) must be located on a filesystem that supports user/group -ownership and permissions (EXT2/3/4, ZFS, APFS, BTRFS, XFS, etc.). It will not work on any filesystem formatted in NTFS or ex/FAT/32. -It will not work in WSL (Windows Subsystem for Linux) when using a mounted host directory (commonly under `/mnt`). -If this is an issue, you can change the bind mount to a Docker volume instead. - -Regardless of filesystem, it is not recommended to use a network share for your database location due to performance and possible data loss issues. - ## General | Variable | Description | Default | Containers | Workers | | :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- | -| `TZ` | Timezone | | server | microservices | +| `TZ` | Timezone | \*1 | server | microservices | | `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices | | `IMMICH_LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices | -| `IMMICH_MEDIA_LOCATION` | Media Location inside the container ⚠️**You probably shouldn't set this**\*1⚠️ | `./upload`\*2 | server | api, microservices | +| `IMMICH_MEDIA_LOCATION` | Media Location inside the container ⚠️**You probably shouldn't set this**\*2⚠️ | `./upload`\*3 | server | api, microservices | | `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices | | `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | | | `CPU_CORES` | Amount of cores available to the immich server | auto-detected cpu core count | server | | @@ -52,16 +43,13 @@ Regardless of filesystem, it is not recommended to use a network share for your | `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | | `IMMICH_TRUSTED_PROXIES` | List of comma separated IPs set as trusted proxies | | server | api | -\*1: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead. - -\*2: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`. -It only need to be set if the Immich deployment method is changing. - -:::tip -`TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. - +\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. `TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution. -::: + +\*2: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead. + +\*3: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`. +It only need to be set if the Immich deployment method is changing. ## Workers diff --git a/docs/docs/install/requirements.md b/docs/docs/install/requirements.md index 88d85c7bee..b96705203a 100644 --- a/docs/docs/install/requirements.md +++ b/docs/docs/install/requirements.md @@ -23,7 +23,33 @@ Immich requires the command `docker compose` - the similarly named `docker-compo - **RAM**: Minimum 4GB, recommended 6GB. - **CPU**: Minimum 2 cores, recommended 4 cores. - **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions. - - This can present an issue for Windows users. See [here](/docs/install/environment-variables#supported-filesystems) - for more details and alternatives. + - This can present an issue for Windows users. See below for details and an alternative setup. - The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average. - - Network shares are supported for the storage of image and video assets only. + - Network shares are supported for the storage of image and video assets only. It is not recommended to use a network share for your database location due to performance and possible data loss issues. + +### Special requirements for Windows users + +
    +Database storage on Windows systems + +The Immich Postgres database (`DB_DATA_LOCATION`) must be located on a filesystem that supports user/group +ownership and permissions (EXT2/3/4, ZFS, APFS, BTRFS, XFS, etc.). It will not work on any filesystem formatted in NTFS or ex/FAT/32. +It will not work in WSL (Windows Subsystem for Linux) when using a mounted host directory (commonly under `/mnt`). +If this is an issue, you can change the bind mount to a Docker volume instead as follows: + +Make the following change to `.env`: + +```diff +- DB_DATA_LOCATION=./postgres ++ DB_DATA_LOCATION=pgdata +``` + +Add the following line to the bottom of `docker-compose.yml`: + +```diff +volumes: + model-cache: ++ pgdata: +``` + +
    From b45fce8ddf773f9e4033d4819de18ea85603209b Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 24 Sep 2024 17:13:37 +0200 Subject: [PATCH 430/723] fix: album title state weirdness (#12874) --- web/src/lib/components/album-page/album-title.svelte | 7 ++++--- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 7 ++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/album-page/album-title.svelte b/web/src/lib/components/album-page/album-title.svelte index 22c26aa10c..1e69ecf1a3 100644 --- a/web/src/lib/components/album-page/album-title.svelte +++ b/web/src/lib/components/album-page/album-title.svelte @@ -7,6 +7,7 @@ export let id: string; export let albumName: string; export let isOwned: boolean; + export let onUpdate: (albumName: string) => void; $: newAlbumName = albumName; @@ -16,17 +17,17 @@ } try { - await updateAlbumInfo({ + ({ albumName } = await updateAlbumInfo({ id, updateAlbumDto: { albumName: newAlbumName, }, - }); + })); + onUpdate(albumName); } catch (error) { handleError(error, $t('errors.unable_to_save_album')); return; } - albumName = newAlbumName; }; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index cbdb38192e..b11bf9b8aa 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -589,7 +589,12 @@ {#if viewMode !== ViewMode.SELECT_THUMBNAIL}
    - + (album.albumName = albumName)} + /> {#if album.assetCount > 0} From 05d8c4c132b08052293ec6e45b8a1ebe7a2eb8e6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 24 Sep 2024 17:53:57 -0400 Subject: [PATCH 431/723] fix: do not use trashed assets as album covers (#12905) --- server/src/queries/album.repository.sql | 27 ++++++++++--------- server/src/repositories/album.repository.ts | 30 +++++++++------------ 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index cc052e9de6..c4f6fbdd32 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -483,16 +483,13 @@ UPDATE "albums" SET "albumThumbnailAssetId" = ( SELECT - "albums_assets2"."assetsId" + "album_assets"."assetsId" FROM - "assets" "assets", - "albums_assets_assets" "albums_assets2" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - ( - "albums_assets2"."assetsId" = "assets"."id" - AND "albums_assets2"."albumsId" = "albums"."id" - ) - AND ("assets"."deletedAt" IS NULL) + "album_assets"."albumsId" = "albums"."id" ORDER BY "assets"."fileCreatedAt" DESC LIMIT @@ -505,17 +502,21 @@ WHERE SELECT 1 FROM - "albums_assets_assets" "albums_assets" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - "albums"."id" = "albums_assets"."albumsId" + "album_assets"."albumsId" = "albums"."id" ) OR "albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM - "albums_assets_assets" "albums_assets" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - "albums"."id" = "albums_assets"."albumsId" - AND "albums"."albumThumbnailAssetId" = "albums_assets"."assetsId" + "album_assets"."albumsId" = "albums"."id" + AND "albums"."albumThumbnailAssetId" = "album_assets"."assetsId" ) diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 4101d78c8e..f7b4cb44aa 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -277,32 +277,26 @@ export class AlbumRepository implements IAlbumRepository { @GenerateSql() async updateThumbnails(): Promise { // Subquery for getting a new thumbnail. - const newThumbnail = this.assetRepository - .createQueryBuilder('assets') - .select('albums_assets2.assetsId') - .addFrom('albums_assets_assets', 'albums_assets2') - .where('albums_assets2.assetsId = assets.id') - .andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query - .orderBy('assets.fileCreatedAt', 'DESC') - .limit(1); - // Using dataSource, because there is no direct access to albums_assets_assets. - const albumHasAssets = this.dataSource - .createQueryBuilder() - .select('1') - .from('albums_assets_assets', 'albums_assets') - .where('"albums"."id" = "albums_assets"."albumsId"'); + const builder = this.dataSource + .createQueryBuilder('albums_assets_assets', 'album_assets') + .innerJoin('assets', 'assets', '"album_assets"."assetsId" = "assets"."id"') + .where('"album_assets"."albumsId" = "albums"."id"'); - const albumContainsThumbnail = albumHasAssets + const newThumbnail = builder .clone() - .andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"'); + .select('"album_assets"."assetsId"') + .orderBy('"assets"."fileCreatedAt"', 'DESC') + .limit(1); + const hasAssets = builder.clone().select('1'); + const hasInvalidAsset = hasAssets.clone().andWhere('"albums"."albumThumbnailAssetId" = "album_assets"."assetsId"'); const updateAlbums = this.repository .createQueryBuilder('albums') .update(AlbumEntity) .set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` }) - .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`) - .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`); + .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${hasAssets.getQuery()})`) + .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${hasInvalidAsset.getQuery()})`); const result = await updateAlbums.execute(); From 06f1376de38682a11fe18fb305ca579c7f54b804 Mon Sep 17 00:00:00 2001 From: Cary Keesler <44330591+carykees98@users.noreply.github.com> Date: Wed, 25 Sep 2024 08:59:35 -0400 Subject: [PATCH 432/723] fix(web): Updated web README.md (#12899) Updated web README.md --- web/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/README.md b/web/README.md index e9693ceb01..603c7ad64e 100644 --- a/web/README.md +++ b/web/README.md @@ -2,4 +2,4 @@ This project uses the [SvelteKit](https://kit.svelte.dev/) web framework. Please refer to [the SvelteKit docs](https://kit.svelte.dev/docs) for information on getting started as a contributor to this project. In particular, it will help you navigate the project's code if you understand the basics of [SvelteKit routing](https://kit.svelte.dev/docs/routing). -When developing locally, you will run a SvelteKit Node.js server. When this project is deployed to production, it is built as a SPA and deployed as part of [../server](the server project). +When developing locally, you will run a SvelteKit Node.js server. When this project is deployed to production, it is built as a SPA and deployed as part of [the server project](../server). From 46fe60693e309cf57eea72c397b3ecf1ba523783 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:56:02 -0400 Subject: [PATCH 433/723] chore(deps): update dependency @types/react to v18.3.8 (#12918) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index ba9f33dc1e..65e9df8d9e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -5506,9 +5506,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.7.tgz", - "integrity": "sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==", + "version": "18.3.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz", + "integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==", "dev": true, "dependencies": { "@types/prop-types": "*", @@ -18805,9 +18805,9 @@ "dev": true }, "@types/react": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.7.tgz", - "integrity": "sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==", + "version": "18.3.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz", + "integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==", "dev": true, "requires": { "@types/prop-types": "*", From 8d515adac517c4871b33fba48cf37e25580e96e3 Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:04:53 -0400 Subject: [PATCH 434/723] feat(web): fixed combobox positioning (#12848) * fix(web): modal sticky bottom scrolling * chore: minor styling tweaks * wip: add portal so modals show on Safari in detail panel * feat: fixed position dropdown menu * chore: refactoring and cleanup * feat: zooming and virtual keyboard working for iPadOS/Safari * Revert "feat: zooming and virtual keyboard working for iPadOS/Safari" This reverts commit cac29bac0df9112cec1d4c66af82dd343081e08a. * wip: minor code cleanup * wip: recover from visual viewport changes * wip: ease in a little more visualviewport magic * wip: code cleanup * fix: only show dropdown above when viewport is zoomed out * fix: code review suggestions for code style Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> * fix: better variable naming * chore: better documentation for the bottom breakpoint --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- .../asset-viewer/detail-panel-tags.svelte | 5 +- .../asset-viewer/detail-panel.svelte | 15 ++- .../shared-components/combobox.svelte | 116 +++++++++++++++++- .../full-screen-modal.svelte | 22 ++-- web/src/lib/i18n/en.json | 2 +- 5 files changed, 134 insertions(+), 26 deletions(-) diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte index 434682f73e..449f61183f 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte @@ -1,6 +1,7 @@ +
    {#if isOpen} @@ -228,7 +334,7 @@ role="option" aria-selected={selectedIndex === 0} aria-disabled={true} - class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700" + class="text-left w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700" id={`${listboxId}-${0}`} on:click={() => closeDropdown()} > @@ -240,7 +346,7 @@
  • handleSelect(option)} role="option" diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index b5b21f0c23..ececa25b1e 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -68,28 +68,24 @@ use:focusTrap >
    -
    +
    - {#if isStickyBottom} -
    - -
    - {/if}
    + {#if isStickyBottom} +
    + +
    + {/if}
  • diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index aaa3c77e2b..534ac08636 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1194,7 +1194,7 @@ "tag_assets": "Tag assets", "tag_created": "Created tag: {tag}", "tag_feature_description": "Browsing photos and videos grouped by logical tag topics", - "tag_not_found_question": "Cannot find a tag? Create one here", + "tag_not_found_question": "Cannot find a tag? Create a new tag.", "tag_updated": "Updated tag: {tag}", "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}", "tags": "Tags", From 005528ab5ec6514e2b93a52a5dbe43481821b733 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 25 Sep 2024 12:05:03 -0400 Subject: [PATCH 435/723] fix(server): http error parsing on endpoints without a default response (#12927) --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/notifications_api.dart | 10 +- mobile/openapi/lib/api_client.dart | 2 + .../lib/model/test_email_response_dto.dart | 99 +++++++++++++++++++ open-api/immich-openapi-specs.json | 18 ++++ open-api/typescript-sdk/src/fetch-client.ts | 8 +- .../controllers/notification.controller.ts | 3 +- server/src/dtos/notification.dto.ts | 3 + .../src/services/notification.service.spec.ts | 5 - server/src/services/notification.service.ts | 12 +-- .../notification.repository.mock.ts | 2 +- web/src/lib/utils/handle-error.ts | 16 ++- 13 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 mobile/openapi/lib/model/test_email_response_dto.dart create mode 100644 server/src/dtos/notification.dto.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 285514e11c..b6b0897e8f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -448,6 +448,7 @@ Class | Method | HTTP request | Description - [TagUpsertDto](doc//TagUpsertDto.md) - [TagsResponse](doc//TagsResponse.md) - [TagsUpdate](doc//TagsUpdate.md) + - [TestEmailResponseDto](doc//TestEmailResponseDto.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketSize](doc//TimeBucketSize.md) - [ToneMapping](doc//ToneMapping.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index fc0224a8c2..d08b6fc521 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -260,6 +260,7 @@ part 'model/tag_update_dto.dart'; part 'model/tag_upsert_dto.dart'; part 'model/tags_response.dart'; part 'model/tags_update.dart'; +part 'model/test_email_response_dto.dart'; part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_size.dart'; part 'model/tone_mapping.dart'; diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart index a3506b9bc1..0681d58247 100644 --- a/mobile/openapi/lib/api/notifications_api.dart +++ b/mobile/openapi/lib/api/notifications_api.dart @@ -48,10 +48,18 @@ class NotificationsApi { /// Parameters: /// /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): - Future sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async { + Future sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async { final response = await sendTestEmailWithHttpInfo(systemConfigSmtpDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TestEmailResponseDto',) as TestEmailResponseDto; + + } + return null; } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 828c0b9ed9..c62d1c5b2e 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -574,6 +574,8 @@ class ApiClient { return TagsResponse.fromJson(value); case 'TagsUpdate': return TagsUpdate.fromJson(value); + case 'TestEmailResponseDto': + return TestEmailResponseDto.fromJson(value); case 'TimeBucketResponseDto': return TimeBucketResponseDto.fromJson(value); case 'TimeBucketSize': diff --git a/mobile/openapi/lib/model/test_email_response_dto.dart b/mobile/openapi/lib/model/test_email_response_dto.dart new file mode 100644 index 0000000000..33e6c042d8 --- /dev/null +++ b/mobile/openapi/lib/model/test_email_response_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TestEmailResponseDto { + /// Returns a new [TestEmailResponseDto] instance. + TestEmailResponseDto({ + required this.messageId, + }); + + String messageId; + + @override + bool operator ==(Object other) => identical(this, other) || other is TestEmailResponseDto && + other.messageId == messageId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (messageId.hashCode); + + @override + String toString() => 'TestEmailResponseDto[messageId=$messageId]'; + + Map toJson() { + final json = {}; + json[r'messageId'] = this.messageId; + return json; + } + + /// Returns a new [TestEmailResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TestEmailResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TestEmailResponseDto"); + if (value is Map) { + final json = value.cast(); + + return TestEmailResponseDto( + messageId: mapValueOfType(json, r'messageId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TestEmailResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TestEmailResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TestEmailResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TestEmailResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'messageId', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 99ea313063..1a070f126b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3491,6 +3491,13 @@ }, "responses": { "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestEmailResponseDto" + } + } + }, "description": "" } }, @@ -12348,6 +12355,17 @@ }, "type": "object" }, + "TestEmailResponseDto": { + "properties": { + "messageId": { + "type": "string" + } + }, + "required": [ + "messageId" + ], + "type": "object" + }, "TimeBucketResponseDto": { "properties": { "count": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d1b88afabb..f2f946f262 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -656,6 +656,9 @@ export type SystemConfigSmtpDto = { replyTo: string; transport: SystemConfigSmtpTransportDto; }; +export type TestEmailResponseDto = { + messageId: string; +}; export type OAuthConfigDto = { redirectUri: string; }; @@ -2220,7 +2223,10 @@ export function addMemoryAssets({ id, bulkIdsDto }: { export function sendTestEmail({ systemConfigSmtpDto }: { systemConfigSmtpDto: SystemConfigSmtpDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/notifications/test-email", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TestEmailResponseDto; + }>("/notifications/test-email", oazapfts.json({ ...opts, method: "POST", body: systemConfigSmtpDto diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts index 2772e93b5d..3dd72dd73a 100644 --- a/server/src/controllers/notification.controller.ts +++ b/server/src/controllers/notification.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; +import { TestEmailResponseDto } from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { NotificationService } from 'src/services/notification.service'; @@ -13,7 +14,7 @@ export class NotificationController { @Post('test-email') @HttpCode(HttpStatus.OK) @Authenticated({ admin: true }) - sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto) { + sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise { return this.service.sendTestEmail(auth.user.id, dto); } } diff --git a/server/src/dtos/notification.dto.ts b/server/src/dtos/notification.dto.ts new file mode 100644 index 0000000000..34b3923580 --- /dev/null +++ b/server/src/dtos/notification.dto.ts @@ -0,0 +1,3 @@ +export class TestEmailResponseDto { + messageId!: string; +} diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 9ef1310bfb..a0b9436f75 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -616,11 +616,6 @@ describe(NotificationService.name, () => { await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED); }); - it('should fail if email could not be sent', async () => { - systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true } } }); - await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.FAILED); - }); - it('should send mail successfully', async () => { systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app' } } }); notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 4eef49c631..bdb23ce700 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEmit } from 'src/decorators'; @@ -140,7 +140,7 @@ export class NotificationService { try { await this.notificationRepository.verifySmtp(dto.transport); } catch (error) { - throw new HttpException('Failed to verify SMTP configuration', HttpStatus.BAD_REQUEST, { cause: error }); + throw new BadRequestException('Failed to verify SMTP configuration', { cause: error }); } const { server } = await this.configCore.getConfig({ withCache: false }); @@ -152,7 +152,7 @@ export class NotificationService { }, }); - await this.notificationRepository.sendEmail({ + const { messageId } = await this.notificationRepository.sendEmail({ to: user.email, subject: 'Test email from Immich', html, @@ -161,6 +161,8 @@ export class NotificationService { replyTo: dto.replyTo || dto.from, smtp: dto.transport, }); + + return { messageId }; } async handleUserSignup({ id, tempPassword }: INotifySignupJob) { @@ -312,10 +314,6 @@ export class NotificationService { imageAttachments: data.imageAttachments, }); - if (!response) { - return JobStatus.FAILED; - } - this.logger.log(`Sent mail with id: ${response.messageId} status: ${response.response}`); return JobStatus.SUCCESS; diff --git a/server/test/repositories/notification.repository.mock.ts b/server/test/repositories/notification.repository.mock.ts index 71975b429c..16862dc3d7 100644 --- a/server/test/repositories/notification.repository.mock.ts +++ b/server/test/repositories/notification.repository.mock.ts @@ -4,7 +4,7 @@ import { Mocked } from 'vitest'; export const newNotificationRepositoryMock = (): Mocked => { return { renderEmail: vitest.fn(), - sendEmail: vitest.fn(), + sendEmail: vitest.fn().mockResolvedValue({ messageId: 'message-1' }), verifySmtp: vitest.fn(), }; }; diff --git a/web/src/lib/utils/handle-error.ts b/web/src/lib/utils/handle-error.ts index 9ca5bc8773..a7e9a4340c 100644 --- a/web/src/lib/utils/handle-error.ts +++ b/web/src/lib/utils/handle-error.ts @@ -2,9 +2,21 @@ import { isHttpError } from '@immich/sdk'; import { notificationController, NotificationType } from '../components/shared-components/notification/notification'; export function getServerErrorMessage(error: unknown) { - if (isHttpError(error)) { - return error.data?.message || error.message; + if (!isHttpError(error)) { + return; } + + // errors for endpoints without return types aren't parsed as json + let data = error.data; + if (typeof data === 'string') { + try { + data = JSON.parse(data); + } catch { + // Not a JSON string + } + } + + return data?.message || error.message; } export function handleError(error: unknown, message: string) { From 35e03c1d6fffc01703b4803100acda9267c3af7d Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 25 Sep 2024 18:19:10 +0200 Subject: [PATCH 436/723] chore(web): update translations (#12737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: -J- Co-authored-by: Albert Stoynov Co-authored-by: Benjamin Gynther Co-authored-by: Bezruchenko Simon Co-authored-by: CanbiZ Co-authored-by: David Abner Ciuhan Co-authored-by: Dean Cvjetanović Co-authored-by: Denis Pacquier Co-authored-by: Fjuro Co-authored-by: Florian Ostertag Co-authored-by: Hary Co-authored-by: Hurricane-32 Co-authored-by: Indrek Haav Co-authored-by: João Gonçalves Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Co-authored-by: Miki Mrvos Co-authored-by: Mārtiņš Bruņenieks Co-authored-by: Petri Hämäläinen Co-authored-by: Shawn Co-authored-by: Xo Co-authored-by: btpv Co-authored-by: chapvic Co-authored-by: dvbthien Co-authored-by: fmis13 Co-authored-by: gallegonovato Co-authored-by: phewi Co-authored-by: pyccl Co-authored-by: pyorot Co-authored-by: rrole Co-authored-by: 李奕寯 --- web/src/lib/i18n/bg.json | 14 +- web/src/lib/i18n/ca.json | 15 +- web/src/lib/i18n/cs.json | 7 + web/src/lib/i18n/de.json | 7 + web/src/lib/i18n/es.json | 7 + web/src/lib/i18n/et.json | 137 ++- web/src/lib/i18n/fi.json | 439 +++++++--- web/src/lib/i18n/fr.json | 31 +- web/src/lib/i18n/he.json | 7 + web/src/lib/i18n/hr.json | 275 ++++-- web/src/lib/i18n/hu.json | 137 ++- web/src/lib/i18n/id.json | 7 + web/src/lib/i18n/lv.json | 133 +-- web/src/lib/i18n/nl.json | 8 + web/src/lib/i18n/pt.json | 1238 ++++++++++++++------------- web/src/lib/i18n/ro.json | 86 +- web/src/lib/i18n/ru.json | 7 + web/src/lib/i18n/sr_Cyrl.json | 7 + web/src/lib/i18n/sr_Latn.json | 9 +- web/src/lib/i18n/uk.json | 7 + web/src/lib/i18n/vi.json | 7 + web/src/lib/i18n/zh_Hant.json | 40 +- web/src/lib/i18n/zh_SIMPLIFIED.json | 18 +- 23 files changed, 1653 insertions(+), 990 deletions(-) diff --git a/web/src/lib/i18n/bg.json b/web/src/lib/i18n/bg.json index 29ac04eda8..f069bec6b3 100644 --- a/web/src/lib/i18n/bg.json +++ b/web/src/lib/i18n/bg.json @@ -12,19 +12,19 @@ "add_a_description": "Добави описание", "add_a_location": "Добави местоположение", "add_a_name": "Добави име", - "add_a_title": "Добави заглавие", + "add_a_title": "Добавете заглавие", "add_exclusion_pattern": "Добави модел за изключване", "add_import_path": "Добави път за импортиране", - "add_location": "Добави местоположение", - "add_more_users": "Добави още потребители", - "add_partner": "Добави партньор", + "add_location": "Добавете местоположение", + "add_more_users": "Добавете още потребители", + "add_partner": "Добавете партньор", "add_path": "Добави път", - "add_photos": "Добави снимки", + "add_photos": "Добавете снимки", "add_to": "Добави към...", "add_to_album": "Добави към албум", "add_to_shared_album": "Добави към споделен албум", - "added_to_archive": "Добавено в архива", - "added_to_favorites": "Добавено към любими", + "added_to_archive": "Добавено към архива", + "added_to_favorites": "Добавени към любимите ви", "added_to_favorites_count": "Добавени {count, number} към любими", "admin": { "add_exclusion_pattern_description": "Добави модели за изключване. Поддържа се \"globbing\" с помощта на *, ** и ?. За да игнорирате всички файлове в директория с име \"Raw\", използвайте \"**/Raw/**\". За да игнорирате всички файлове, завършващи на \".tif\", използвайте \"**/*.tif\". За да игнорирате абсолютен път, използвайте \"/path/to/ignore/**\".", diff --git a/web/src/lib/i18n/ca.json b/web/src/lib/i18n/ca.json index e9c695f79a..518c0abadf 100644 --- a/web/src/lib/i18n/ca.json +++ b/web/src/lib/i18n/ca.json @@ -8,7 +8,7 @@ "active": "Actiu", "activity": "Activitat", "activity_changed": "L'activitat està {enabled, select, true {activada} other {desactivada}}", - "add": "Afig", + "add": "Afegir", "add_a_description": "Afegiu una descripció", "add_a_location": "Afegiu una ubicació", "add_a_name": "Afegir un nom", @@ -41,6 +41,7 @@ "confirm_email_below": "Per a confirmar, escriviu \"{email}\" a sota", "confirm_reprocess_all_faces": "Esteu segur que voleu reprocessar totes les cares? Això també esborrarà la gent que heu anomenat.", "confirm_user_password_reset": "Esteu segur que voleu reinicialitzar la contrasenya de l'usuari {user}?", + "create_job": "Crear tasca", "crontab_guru": "Crontab Guru", "disable_login": "Deshabiliteu l'inici de sessió", "disabled": "Deshabilitat", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Resolució de la miniatura", "image_thumbnail_resolution_description": "S'empra per a veure grups de fotos (cronologia, vista d'àlbum, etc.). L'alta resolució pot preservar més detalls però triguen més en codificar-se, tenen fitxers més pesats i poden reduir la reactivitat de l'aplicació.", "job_concurrency": "{job} concurrència", + "job_created": "Tasca creada", "job_not_concurrency_safe": "Aquesta tasca no és segura per a la conconcurrència.", "job_settings": "Configuració de les tasques", "job_settings_description": "Gestiona la concurrència de tasques", @@ -198,6 +200,7 @@ "password_settings": "Inici de sessió amb contrasenya", "password_settings_description": "Gestiona la configuració de l'inici de sessió amb contrasenya", "paths_validated_successfully": "Tots els camins han estat validats amb èxit", + "person_cleanup_job": "Neteja de persona", "quota_size_gib": "Tamany de la quota (GiB)", "refreshing_all_libraries": "Actualitzant totes les biblioteques", "registration": "Registre d'administrador", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Restablir la configuració guardada més recent", "scanning_library_for_changed_files": "Escanejant llibreria per trobar fitxers modificats", "scanning_library_for_new_files": "Escanejant llibreria per trobar fitxers nous", + "search_jobs": "Tasques de cerca...", "send_welcome_email": "Enviar correu electrònic de benvinguda", "server_external_domain_settings": "Domini extern", "server_external_domain_settings_description": "Domini per enllaços públics compartits, incloent http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Gestiona l'estructura de les carpetes i el nom del fitxers dels elements pujats", "storage_template_user_label": "{label} és l'etiqueta d'emmagatzematge de l'usuari", "system_settings": "Configuració del sistema", + "tag_cleanup_job": "Neteja d'etiqueta", "theme_custom_css_settings": "CSS personalitzat", "theme_custom_css_settings_description": "Els Fulls d'Estil en Cascada permeten personalitzar el disseny d'Immich.", "theme_settings": "Configuració del tema", @@ -312,6 +317,7 @@ "trash_settings_description": "Gestiona la configuració de la paperera", "untracked_files": "Fitxers sense seguiment", "untracked_files_description": "L'aplicació no fa un seguiment d'aquests fitxers. Poden ser el resultat de moviments fallits, càrregues interrompudes o deixades enrere a causa d'un error", + "user_cleanup_job": "Neteja d'usuari", "user_delete_delay": "El compte i els recursos de {user} es programaran per a la supressió permanent en {delay, plural, one {# dia} other {# dies}}.", "user_delete_delay_settings": "Retard de la supressió", "user_delete_delay_settings_description": "Nombre de dies després de la supressió per eliminar permanentment el compte i els elements d'un usuari. El treball de supressió d'usuaris s'executa a mitjanit per comprovar si hi ha usuaris preparats per eliminar. Els canvis en aquesta configuració s'avaluaran en la propera execució.", @@ -925,7 +931,7 @@ "offline_paths_description": "Aquests resultats poden ser deguts a la supressió manual de fitxers que no formen part d'una biblioteca externa.", "ok": "D'acord", "oldest_first": "El més vell primer", - "onboarding": "Onboarding", + "onboarding": "Incorporació", "onboarding_privacy_description": "Les següents funcions (opcionals) depenen de serveis externs i poden desactivarse en qualsevol moment de dels ajustos.", "onboarding_theme_description": "Trieu un tema de color per a la vostra instància. Podeu canviar-ho més endavant a la vostra configuració.", "onboarding_welcome_description": "Configurem la vostra instància amb alguns paràmetres habituals.", @@ -1113,7 +1119,7 @@ "search_albums": "Buscar àlbums", "search_by_context": "Buscar per context", "search_by_filename": "Cerca per nom de fitxer o extensió", - "search_by_filename_example": "i.e. IMG_1234.JPG or PNG", + "search_by_filename_example": "per exemple IMG_1234.JPG o PNG", "search_camera_make": "Buscar per fabricant de càmara...", "search_camera_model": "Buscar per model de càmera...", "search_city": "Buscar per ciutat...", @@ -1124,6 +1130,7 @@ "search_options": "Opcions de cerca", "search_people": "Buscar persones", "search_places": "Buscar llocs", + "search_settings": "Configuració de cerca", "search_state": "Buscar per regió...", "search_tags": "Cercant etiquetes...", "search_timezone": "Buscar per fus horari...", @@ -1240,7 +1247,7 @@ "tag_feature_description": "Exploreu fotos i vídeos agrupats per temes d'etiquetes lògiques", "tag_not_found_question": "No trobeu una etiqueta? Creeu-ne una aquí", "tag_updated": "Etiqueta actualizada: {tag}", - "tagged_assets": "{count, plural, one {#Etiquetat} other {#Etiquetats}} {count, plural, one {# actiu} other {# actius}}", + "tagged_assets": "{count, plural, one {#Etiquetat} other {#Etiquetats}} {count, plural, one {# actiu} other {# actius}}", "tags": "Etiquetes", "template": "Plantilla", "theme": "Tema", diff --git a/web/src/lib/i18n/cs.json b/web/src/lib/i18n/cs.json index c2d7bce0e5..8c262f890b 100644 --- a/web/src/lib/i18n/cs.json +++ b/web/src/lib/i18n/cs.json @@ -41,6 +41,7 @@ "confirm_email_below": "Pro potvrzení zadejte níže \"{email}\"", "confirm_reprocess_all_faces": "Opravdu chcete znovu zpracovat všechny obličeje? Tím se vymažou i pojmenované osoby.", "confirm_user_password_reset": "Opravdu chcete obnovit heslo uživatele {user}?", + "create_job": "Vytvořit úlohu", "crontab_guru": "Crontab Guru", "disable_login": "Zakázat přihlášení", "disabled": "Zakázáno", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Rozlišení miniatur", "image_thumbnail_resolution_description": "Používá se při prohlížení skupin fotografií (hlavní časová osa, zobrazení alba atd.). Vyšší rozlišení může zachovat více detailů, ale trvá déle, než se zakóduje, má větší velikost souboru a může snížit odezvu aplikace.", "job_concurrency": "Souběžnost {job}", + "job_created": "Úloha vytvořena", "job_not_concurrency_safe": "Tato úloha není bezpečená pro souběh.", "job_settings": "Úlohy", "job_settings_description": "Správa souběžnosti úloh", @@ -198,6 +200,7 @@ "password_settings": "Přihlášení heslem", "password_settings_description": "Správa nastavení přihlašování pomocí hesla", "paths_validated_successfully": "Všechny cesty byly úspěšně ověřeny", + "person_cleanup_job": "Promazání osob", "quota_size_gib": "Velikost kvóty (GiB)", "refreshing_all_libraries": "Obnovení všech knihoven", "registration": "Registrace správce", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Obnovit poslední uložené nastavení", "scanning_library_for_changed_files": "Hledání změněných souborů v knihovně", "scanning_library_for_new_files": "Hledání nových souborů v knihovně", + "search_jobs": "Hledat úlohy...", "send_welcome_email": "Odeslat uvítací e-mail", "server_external_domain_settings": "Externí doména", "server_external_domain_settings_description": "Doména pro veřejně sdílené odkazy, včetně http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Správa struktury složek a názvů nahraných souborů", "storage_template_user_label": "{label} je štítek úložiště uživatele", "system_settings": "Systémová nastavení", + "tag_cleanup_job": "Promazání značek", "theme_custom_css_settings": "Vlastní CSS", "theme_custom_css_settings_description": "Kaskádové styly umožňují přizpůsobit design aplikace Immich.", "theme_settings": "Motivy", @@ -312,6 +317,7 @@ "trash_settings_description": "Správa nastavení koše", "untracked_files": "Neznámé soubory", "untracked_files_description": "Tyto soubory nejsou aplikaci známy. Mohou být výsledkem neúspěšných přesunů, přerušeného nahrávání nebo mohou zůstat pozadu kvůli chybě", + "user_cleanup_job": "Promazání uživatelů", "user_delete_delay": "Účet a položky uživatele {user} budou trvale smazány za {delay, plural, one {# den} few {# dny} other {# dní}}.", "user_delete_delay_settings": "Odložení odstranění", "user_delete_delay_settings_description": "Počet dní po odstranění, po kterých bude odstraněn účet a položky uživatele. Úloha odstraňování uživatelů se spouští o půlnoci a kontroluje uživatele, kteří jsou připraveni k odstranění. Změny tohoto nastavení se vyhodnotí při dalším spuštění.", @@ -1142,6 +1148,7 @@ "search_options": "Možnosti vyhledávání", "search_people": "Vyhledat lidi", "search_places": "Vyhledat místa", + "search_settings": "Hledat nastavení", "search_state": "Vyhledat stát...", "search_tags": "Vyhledávat značky...", "search_timezone": "Vyhledat časové pásmo...", diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index 352006ef6e..3ef036b7b0 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -41,6 +41,7 @@ "confirm_email_below": "Bestätige, indem du \"{email}\" unten eingibst", "confirm_reprocess_all_faces": "Bist du sicher, dass du alle Gesichter erneut verarbeiten möchtest? Dies löscht auch alle bereits benannten Personen.", "confirm_user_password_reset": "Bist du sicher, dass du das Passwort für {user} zurücksetzen möchtest?", + "create_job": "Job erstellen", "crontab_guru": "Crontab Guru", "disable_login": "Login deaktvieren", "disabled": "Deaktiviert", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Miniaturansichts-Auflösung", "image_thumbnail_resolution_description": "Dies wird bei der Anzeige von Bildergruppen („Zeitleiste“, „Albumansicht“ usw.) verwendet. Höhere Auflösungen können mehr Details beibehalten, benötigen aber mehr Zeit für die Kodierung, haben größere Dateigrößen und können die Reaktionsfähigkeit der App beeinträchtigen.", "job_concurrency": "{job} - (Anzahl gleichzeitiger Prozesse)", + "job_created": "Job erstellt", "job_not_concurrency_safe": "Dieser Job ist nicht parallelisierungssicher.", "job_settings": "Job-Einstellungen", "job_settings_description": "Gleichzeitige Job-Prozessen verwalten", @@ -198,6 +200,7 @@ "password_settings": "Passwort Login", "password_settings_description": "Passwort-Anmeldeeinstellungen verwalten", "paths_validated_successfully": "Alle Pfade wurden erfolgreich validiert", + "person_cleanup_job": "Personen aufräumen", "quota_size_gib": "Kontingent (GiB)", "refreshing_all_libraries": "Alle Bibliotheken aktualisieren", "registration": "Admin-Registrierung", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Einstellungen auf die zuletzt gespeicherten Einstellungen zurücksetzen", "scanning_library_for_changed_files": "Untersuche Bibliothek auf geänderte Dateien", "scanning_library_for_new_files": "Untersuche Bibliothek auf neue Dateien", + "search_jobs": "Jobs suchen...", "send_welcome_email": "Begrüssungsmail senden", "server_external_domain_settings": "Externe Domain", "server_external_domain_settings_description": "Domäne für öffentlich freigegebene Links, einschließlich http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Die Ordnerstruktur und den Dateinamen der hochgeladenen Datei verwalten", "storage_template_user_label": "{label} is das Speicher-Label des Benutzers", "system_settings": "Systemeinstellungen", + "tag_cleanup_job": "Tags aufräumen", "theme_custom_css_settings": "Benutzerdefiniertes CSS", "theme_custom_css_settings_description": "Mit Cascading Style Sheets (CSS) kann das Design von Immich angepasst werden.", "theme_settings": "Theme-Einstellungen", @@ -312,6 +317,7 @@ "trash_settings_description": "Papierkorb-Einstellungen verwalten", "untracked_files": "Unverfolgte Dateien", "untracked_files_description": "Diese Dateien werden nicht von der Application getrackt. Sie können das Ergebnis fehlgeschlagener Verschiebungen, unterbrochener Uploads oder aufgrund eines Fehlers sein", + "user_cleanup_job": "Benutzer aufräumen", "user_delete_delay": "Das Konto und die Dateien von {user} werden in {delay, plural, one {einem Tag} other {# Tagen}} für eine permanente Löschung geplant.", "user_delete_delay_settings": "Verzögerung für das Löschen von Benutzern", "user_delete_delay_settings_description": "Gibt die Anzahl der Tage bis zur endgültigen Löschung eines Kontos und seiner Dateien an. Der Benutzerlöschauftrag wird täglich um Mitternacht ausgeführt, um zu überprüfen, ob Nutzer zur Löschung bereit sind. Änderungen an dieser Einstellung werden erst bei der nächsten Ausführung berücksichtigt.", @@ -1141,6 +1147,7 @@ "search_options": "Suchoptionen", "search_people": "Suche nach Personen", "search_places": "Suche nach Orten", + "search_settings": "Suche nach Einstellungen", "search_state": "Suche nach Bundesland / Provinz...", "search_tags": "Sache nach Tags...", "search_timezone": "Suche nach Zeitzone...", diff --git a/web/src/lib/i18n/es.json b/web/src/lib/i18n/es.json index 0136319192..0c77b9cfe1 100644 --- a/web/src/lib/i18n/es.json +++ b/web/src/lib/i18n/es.json @@ -41,6 +41,7 @@ "confirm_email_below": "Para confirmar, escribe \"{email}\" debajo", "confirm_reprocess_all_faces": "¿Estás seguro de que quieres volver a procesar todas las caras? Esto también eliminará las personas a las que le hayas asignado nombre.", "confirm_user_password_reset": "¿Estás seguro de que quieres resetear la contraseña de {user}?", + "create_job": "Crear trabajo", "crontab_guru": "Crontab Guru", "disable_login": "Deshabilitar inicio de sesión", "disabled": "Deshabilitado", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Resolución de las miniaturas", "image_thumbnail_resolution_description": "Se utiliza para ver grupos de fotos (cronología, vista de álbum, etc.). Las resoluciones más altas pueden conservar más detalles, pero tardan más en codificarse, tienen archivos de mayor tamaño y pueden reducir la reactividad de la aplicación.", "job_concurrency": "{job}: Procesos simultáneos", + "job_created": "Trabajo creado", "job_not_concurrency_safe": "Esta tarea no es segura para la simultaneidad.", "job_settings": "Configuración tareas", "job_settings_description": "Administrar tareas simultáneas", @@ -198,6 +200,7 @@ "password_settings": "Contraseña de Acceso", "password_settings_description": "Administrar la configuración de inicio de sesión con contraseña", "paths_validated_successfully": "Todas las carpetas se han validado satisfactoriamente", + "person_cleanup_job": "Limpieza de personas", "quota_size_gib": "Tamaño de Quota (GiB)", "refreshing_all_libraries": "Actualizar todas las bibliotecas", "registration": "Registrar administrador", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Restablecer la configuración a la configuración guardada recientemente", "scanning_library_for_changed_files": "Escanear archivos modificados en biblioteca", "scanning_library_for_new_files": "Escanear nuevos archivos en biblioteca", + "search_jobs": "Buscar trabajo...", "send_welcome_email": "Enviar correo de bienvenida", "server_external_domain_settings": "Dominio externo", "server_external_domain_settings_description": "Dominio para enlaces públicos compartidos, incluidos http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Administre la estructura de carpetas y el nombre de archivo del recurso cargado", "storage_template_user_label": "{label} es la etiqueta de almacenamiento del usuario", "system_settings": "Ajustes del Sistema", + "tag_cleanup_job": "Limpieza de etiquetas", "theme_custom_css_settings": "CSS Personalizado", "theme_custom_css_settings_description": "Las Hojas de Estilo (CSS) permiten personalizar el diseño de Immich.", "theme_settings": "Ajustes Tema", @@ -312,6 +317,7 @@ "trash_settings_description": "Administrar la configuración de la papelera", "untracked_files": "Archivos sin seguimiento", "untracked_files_description": "La aplicación no rastrea estos archivos. Puede ser el resultado de movimientos fallidos, cargas interrumpidas o sin procesar debido a un error", + "user_cleanup_job": "Limpieza de usuarios", "user_delete_delay": "La cuenta {user} y los archivos se programarán para su eliminación permanente en {delay, plural, one {# día} other {# días}}.", "user_delete_delay_settings": "Eliminar retardo", "user_delete_delay_settings_description": "Número de días después de la eliminación para eliminar permanentemente la cuenta y los activos de un usuario. El trabajo de eliminación de usuarios se ejecuta a medianoche para comprobar si hay usuarios que estén listos para su eliminación. Los cambios a esta configuración se evaluarán en la próxima ejecución.", @@ -1141,6 +1147,7 @@ "search_options": "Opciones de búsqueda", "search_people": "Buscar personas", "search_places": "Buscar lugar", + "search_settings": "Ajustes de la búsqueda", "search_state": "Buscar región/estado...", "search_tags": "Buscando etiquetas...", "search_timezone": "Buscar zona horaria...", diff --git a/web/src/lib/i18n/et.json b/web/src/lib/i18n/et.json index 49b60cd052..58a9ce024c 100644 --- a/web/src/lib/i18n/et.json +++ b/web/src/lib/i18n/et.json @@ -41,20 +41,22 @@ "confirm_email_below": "Kinnitamiseks sisesta allpool \"{email}\"", "confirm_reprocess_all_faces": "Kas oled kindel, et soovid kõik näod uuesti töödelda? See eemaldab kõik nimega isikud.", "confirm_user_password_reset": "Kas oled kindel, et soovid kasutaja {user} parooli lähtestada?", + "create_job": "Lisa tööde", "disable_login": "Keela sisselogimine", - "duplicate_detection_job_description": "Rakenda üksustele masinõpet, et tuvastada sarnaseid pilte. Kasutab nutiotsingut", + "duplicate_detection_job_description": "Rakenda üksustele masinõpet, et leida sarnaseid pilte. Kasutab nutiotsingut", "exclusion_pattern_description": "Välistamismustrid võimaldavad ignoreerida faile ja kaustu kogu skaneerimisel. See on kasulik, kui sul on kaustu, mis sisaldavad faile, mida sa ei soovi importida, nagu RAW failid.", "external_library_created_at": "Väline kogu (lisatud {date})", "external_library_management": "Väliste kogude haldus", - "face_detection": "Näotuvastus", - "face_detection_description": "Otsi üksustest nägusid masinõppe abil. Videote puhul kasutatakse ainult pisipilti. \"Kõik\" töötleb kõik üksused uuesti. \"Puuduvad\" võtab ette üksused, mida pole veel töödeldud. Leitud näod suunatakse näotuvastusse, et grupeerida nad olemasolevateks või uuteks isikuteks.", - "facial_recognition_job_description": "Grupeeri leitud näod inimesteks. See samm käivitub siis, kui näotuvastus on lõppenud. \"Kõik\" grupeerib kõik näod uuesti. \"Puuduvad\" võtab ette näod, mida pole isikuga seostatud.", + "face_detection": "Näoavastus", + "face_detection_description": "Avasta üksustest nägusid masinõppe abil. Videote puhul kasutatakse ainult pisipilti. \"Kõik\" töötleb kõik üksused uuesti. \"Puuduvad\" võtab ette üksused, mida pole veel töödeldud. Avastatud näod suunatakse näotuvastusse, et grupeerida nad olemasolevateks või uuteks isikuteks.", + "facial_recognition_job_description": "Grupeeri avastatud näod inimesteks. See samm käivitub siis, kui näoavastus on lõppenud. \"Kõik\" grupeerib kõik näod uuesti. \"Puuduvad\" võtab ette näod, mida pole isikuga seostatud.", "failed_job_command": "Käsk {command} ebaõnnestus töötes: {job}", "force_delete_user_warning": "HOIATUS: See kustutab koheselt kasutaja ja kõik üksused. Seda ei saa tagasi võtta ja faile ei saa taastada.", "forcing_refresh_library_files": "Kogu kõigi failide sundvärskendamine", "image_format_description": "WebP failid on väiksemad kui JPEG, aga kodeerimine on aeglasem.", "image_prefer_embedded_preview": "Eelista manustatud eelvaadet", "image_prefer_embedded_preview_setting_description": "Kasuta pilditöötluse sisendina võimalusel RAW fotodesse manustatud eelvaateid. See võib mõnede piltide puhul anda tulemuseks täpsemad värvid, aga eelvaate kvaliteet sõltub konkreetsest kaamerast ning pildis võib olla rohkem tihendusmüra.", + "image_prefer_wide_gamut": "Eelista laia värvigammat", "image_prefer_wide_gamut_setting_description": "Kasuta pisipiltide jaoks Display P3. See säilitab paremini laia värviruumiga piltide erksuse, aga vanematel seadmetel ja vanemate brauseritega võivad pildid teistsugused välja näha. sRGB pildid säilitatakse värvinihete vältimiseks.", "image_preview_format": "Eelvaate formaat", "image_preview_resolution": "Eelvaate resolutsioon", @@ -67,9 +69,13 @@ "image_thumbnail_resolution": "Pisipildi resolutsioon", "image_thumbnail_resolution_description": "Kasutusel fotode mitmekaupa vaatamisel (ajajoon, albumi vaade, jne). Kõrgem resolutsioon säilitab rohkem detaile, aga kodeerimine võtab rohkem aega, tekitab suurema faili ning võib mõjutada rakenduse töökiirust.", "job_concurrency": "{job} samaaegsus", + "job_created": "Tööde lisatud", + "job_not_concurrency_safe": "Seda töödet pole ohutu samaaegselt käivitada.", "job_settings": "Tööte seaded", "job_settings_description": "Halda töödete samaaegsust", "job_status": "Tööte seisund", + "jobs_delayed": "{jobCount, plural, other {# edasi lükatud}}", + "jobs_failed": "{jobCount, plural, other {# ebaõnnestus}}", "library_created": "Lisatud kogu: {library}", "library_cron_expression": "Cron avaldis", "library_cron_expression_description": "Sea skaneerimise intervall cron formaadis. Rohkema info jaoks vaata nt. Crontab Guru", @@ -81,6 +87,7 @@ "library_scanning_enable_description": "Luba kogu perioodiline skaneerimine", "library_settings": "Väline kogu", "library_settings_description": "Halda välise kogu seadeid", + "library_tasks_description": "Soorita kogu toiminguid", "library_watching_enable_description": "Jälgi välises kogus failide muudatusi", "library_watching_settings": "Kogu jälgimine (EKSPERIMENTAALNE)", "library_watching_settings_description": "Jälgi automaatselt muutunud faile", @@ -89,23 +96,26 @@ "logging_settings": "Logimine", "machine_learning_clip_model": "CLIP mudel", "machine_learning_clip_model_description": "CLIP mudeli nimi, mis on loetletud siin. Pane tähele, et mudeli muutmisel pead kõigi piltide peal nutiotsingu tööte uuesti käivitama.", - "machine_learning_duplicate_detection": "Duplikaatide tuvastus", - "machine_learning_duplicate_detection_enabled": "Luba duplikaatide tuvastus", + "machine_learning_duplicate_detection": "Duplikaatide leidmine", + "machine_learning_duplicate_detection_enabled": "Luba duplikaatide leidmine", "machine_learning_duplicate_detection_enabled_description": "Kui keelatud, dedubleeritakse siiski täpselt identsed üksused.", "machine_learning_duplicate_detection_setting_description": "Kasuta CLIP-manuseid, et leida tõenäoliseid duplikaate", "machine_learning_enabled": "Luba masinõpe", "machine_learning_enabled_description": "Kui keelatud, lülitatakse kõik masinõppe funktsioonid välja, sõltumata allolevatest seadetest.", "machine_learning_facial_recognition": "Näotuvastus", - "machine_learning_facial_recognition_description": "Otsi, tuvasta ja grupeeri piltidel näod", + "machine_learning_facial_recognition_description": "Avasta, tuvasta ja grupeeri piltidel näod", "machine_learning_facial_recognition_model": "Näotuvastuse mudel", - "machine_learning_facial_recognition_model_description": "Mudelid on järjestatud suuruse järgi kahanevalt. Suuremad mudelid on aeglasemad ja kasutavad rohkem mälu, kuid annavad parema tulemuse. Mudeli muutmisel tuleb näotuvastuse tööde kõigi piltide peal uuesti käivitada.", + "machine_learning_facial_recognition_model_description": "Mudelid on järjestatud suuruse järgi kahanevalt. Suuremad mudelid on aeglasemad ja kasutavad rohkem mälu, kuid annavad parema tulemuse. Mudeli muutmisel tuleb näoavastuse tööde kõigi piltide peal uuesti käivitada.", "machine_learning_facial_recognition_setting": "Luba näotuvastus", - "machine_learning_max_detection_distance": "Maksimaalne tuvastuskaugus", - "machine_learning_max_detection_distance_description": "Maksimaalne kaugus kahe pildi vahel, mille puhul loetakse nad duplikaatideks, vahemikus 0.001-0.1. Kõrgemad väärtused tuvastavad rohkem duplikaate, aga võivad anda valepositiivseid.", + "machine_learning_facial_recognition_setting_description": "Kui keelatud, siis ei kodeerita pilte näotuvastuse jaoks ning isikute sektsioon Avasta lehel jääb tühjaks.", + "machine_learning_max_detection_distance": "Maksimaalne avastuskaugus", + "machine_learning_max_detection_distance_description": "Maksimaalne kaugus kahe pildi vahel, mille puhul loetakse nad duplikaatideks, vahemikus 0.001-0.1. Kõrgemad väärtused leiavad rohkem duplikaate, aga võib esineda valepositiivseid.", + "machine_learning_max_recognition_distance": "Maksimaalne tuvastuskaugus", "machine_learning_max_recognition_distance_description": "Maksimaalne kaugus kahe näo vahel, mida tuleks lugeda samaks isikuks, vahemikus 0-2. Selle vähendamine aitab vältida erinevate inimeste samaks isikuks märkimist ja tõstmine aitab vältida sama inimese kaheks erinevaks isikuks märkimist. Pane tähele, et kaht isikut ühendada on lihtsam kui üht isikut kaheks eraldada, seega võimalusel kasuta madalamat lävendit.", - "machine_learning_min_detection_score_description": "Minimaalne usaldusskoor näo tuvastamiseks, vahemikus 0-1. Madalamad väärtused leiavad rohkem nägusid, kuid võib esineda valepositiivseid.", - "machine_learning_min_recognized_faces": "Minimaalne leitud nägude arv", - "machine_learning_min_recognized_faces_description": "Minimaalne leitud nägude arv, mida saab isikuks grupeerida. Selle suurendamine teeb näotuvastuse täpsemaks, kuid suureneb tõenäosus, et nägu ei seostata ühegi isikuga.", + "machine_learning_min_detection_score": "Minimaalne avastusskoor", + "machine_learning_min_detection_score_description": "Minimaalne usaldusskoor näo avastamiseks, vahemikus 0-1. Madalamad väärtused leiavad rohkem nägusid, kuid võib esineda valepositiivseid.", + "machine_learning_min_recognized_faces": "Minimaalne tuvastatud nägude arv", + "machine_learning_min_recognized_faces_description": "Minimaalne tuvastatud nägude arv, mida saab isikuks grupeerida. Selle suurendamine teeb näotuvastuse täpsemaks, kuid suureneb tõenäosus, et nägu ei seostata ühegi isikuga.", "machine_learning_settings": "Masinõppe seaded", "machine_learning_settings_description": "Halda masinõppe funktsioone ja seadeid", "machine_learning_smart_search": "Nutiotsing", @@ -113,17 +123,30 @@ "machine_learning_smart_search_enabled": "Luba nutiotsing", "machine_learning_smart_search_enabled_description": "Kui keelatud, siis ei kodeerita pilte nutiotsingu jaoks.", "machine_learning_url_description": "Masinõppe serveri URL", + "manage_concurrency": "Halda samaaegsust", "manage_log_settings": "Halda logi seadeid", "map_dark_style": "Tume stiil", + "map_enable_description": "Luba kaardi funktsioonid", "map_gps_settings": "Kaardi ja GPS-i seaded", + "map_gps_settings_description": "Halda kaardi ja GPS-i (pöördgeokodeerimise) seadeid", + "map_implications": "Kaardifunktsioon kasutab välist kaarditeenust (tiles.immich.cloud)", "map_light_style": "Hele stiil", + "map_manage_reverse_geocoding_settings": "Halda pöördgeokodeerimise seadeid", + "map_reverse_geocoding": "Pöördgeokodeerimine", + "map_reverse_geocoding_enable_description": "Luba pöördgeokodeerimine", + "map_reverse_geocoding_settings": "Pöördgeokodeerimise seaded", "map_settings": "Kaart", "map_settings_description": "Halda kaardi seadeid", + "map_style_description": "Kaarditeema style.json URL", "metadata_extraction_job": "Metaandmete eraldamine", "metadata_extraction_job_description": "Eralda igast üksusest metaandmed, nagu GPS-koordinaadid, näod ja resolutsioon", + "metadata_faces_import_setting": "Luba nägude import", + "metadata_settings": "Metaandmete seaded", + "metadata_settings_description": "Halda metaandmete seadeid", "migration_job": "Migratsioon", "migration_job_description": "Migreeri üksuste ja nägude pisipildid uusimale kaustastruktuurile", "note_cannot_be_changed_later": "MÄRKUS: Seda ei saa hiljem muuta!", + "note_unlimited_quota": "Märkus: Piiramatu kvoodi jaoks sisesta 0", "notification_email_from_address": "Saatja aadress", "notification_email_from_address_description": "Saatja e-posti aadress, näiteks: \"Immich Photo Server \"", "notification_email_host_description": "E-posti serveri host (nt. smtp.immich.app)", @@ -140,17 +163,27 @@ "notification_enable_email_notifications": "Luba e-posti teel teavitused", "notification_settings": "Teavituse seaded", "notification_settings_description": "Halda teavituste seadeid, sh. e-posti teel", + "oauth_auto_launch": "Automaatne käivitamine", + "oauth_auto_launch_description": "Alusta OAuth autentimist automaatselt sisselogimise lehele jõudmisel", + "oauth_auto_register": "Automaatne registreerimine", + "oauth_auto_register_description": "Registreeri uued kasutajad automaatselt OAuth abil sisselogimisel", "oauth_button_text": "Nupu tekst", "oauth_client_id": "Kliendi ID", "oauth_client_secret": "Kliendi saladus", "oauth_enable_description": "Sisene OAuth abil", "oauth_issuer_url": "Väljastaja URL", + "oauth_mobile_redirect_uri": "Mobiilne ümbersuunamise URI", + "oauth_profile_signing_algorithm": "Profiili allkirjastamise algoritm", + "oauth_profile_signing_algorithm_description": "Algoritm, mida kasutatakse kasutajaprofiili allkirjastamiseks.", + "oauth_scope": "Skoop", "oauth_settings": "OAuth", "oauth_settings_description": "Halda OAuth sisselogimise seadeid", + "oauth_signing_algorithm": "Allkirjastamise algoritm", "password_enable_description": "Logi sisse e-posti aadressi ja parooliga", "password_settings": "Parooliga sisselogimine", "password_settings_description": "Halda parooliga sisselogimise seadeid", "paths_validated_successfully": "Kõik teed edukalt valideeritud", + "person_cleanup_job": "Isikute korrastamine", "quota_size_gib": "Kvoot (GiB)", "refreshing_all_libraries": "Kõikide kogude värskendamine", "registration_description": "Kuna sa oled süsteemis esimene kasutaja, määratakse sind administraatoriks, ning sa saad lisada täiendavaid kasutajaid.", @@ -171,6 +204,10 @@ "storage_template_migration_info": "Malli muudatused rakenduvad ainult uutele üksustele. Et rakendada malli tagasiulatuvalt olemasolevatele üksustele, käivita {job}.", "storage_template_settings_description": "Halda üleslaaditud üksuse kaustastruktuuri ja failinime", "system_settings": "Süsteemi seaded", + "tag_cleanup_job": "Siltide korrastamine", + "theme_custom_css_settings": "Kohandatud CSS", + "theme_custom_css_settings_description": "Cascading Style Sheets lubab Immich'i kujunduse kohandamist.", + "theme_settings": "Teema seaded", "theme_settings_description": "Halda Immich'i veebiliidese kohandamist", "thumbnail_generation_job": "Genereeri pisipildid", "thumbnail_generation_job_description": "Genereeri iga üksuse kohta suur, väike ja udustatud pisipilt ning iga isiku kohta pisipilt", @@ -229,13 +266,18 @@ "transcoding_video_codec_description": "VP9 on võimekas ja veebiga ühilduv, aga transkodeerimine võtab kauem aega. HEVC on sarnase jõudluse, aga mitte nii hea veebiga ühilduvusega. H.264 on laialt ühilduv ja transkodeerimine on kiire, aga tulemuseks on suuremad failid. AV1 on kõige võimekam koodek, aga pole vanematel seadmetel toetatud.", "trash_number_of_days": "Päevade arv", "trash_number_of_days_description": "Päevade arv, kui kaua hoida üksusi prügikastis enne nende lõplikku kustutamist", + "user_cleanup_job": "Kasutajate korrastamine", "user_delete_delay": "Kasutaja {user} konto ja üksuste lõplik kustutamine on planeeritud {delay, plural, one {# päeva} other {# päeva}} pärast.", "user_delete_delay_settings_description": "Päevade arv, pärast mida kustutatakse eemaldatud kasutaja konto ja üksused jäädavalt. Kasutajate kustutamise tööde käivitub keskööl, et otsida kustutamiseks valmis kasutajaid. Selle seadistuse muudatused rakenduvad järgmisel käivitumisel.", "user_delete_immediately": "Kasutaja {user} konto ja üksused suunatakse koheselt jäädavale kustutamisele.", "user_delete_immediately_checkbox": "Suuna kasutaja ja üksused jäädavale kustutamisele", + "user_management": "Kasutajate haldus", "user_password_has_been_reset": "Kasutaja parool on lähtestatud:", "user_password_reset_description": "Sisesta kasutajale ajutine parool ja teavita teda, et järgmisel sisselogimisel tuleb parool ära muuta.", "user_restore_description": "Kasutaja {user} konto taastatakse.", + "user_restore_scheduled_removal": "Taasta kasutaja - eemaldamine planeeritud {date, date, long}", + "user_settings": "Kasutajate seaded", + "user_settings_description": "Halda kasutajate seadeid", "user_successfully_removed": "Kasutaja {email} on eemaldatud.", "version_check_enabled_description": "Luba versioonikontroll", "version_check_implications": "Versioonikontroll vajab perioodilist ühendumist github.com-iga", @@ -270,11 +312,17 @@ "all_albums": "Kõik albumid", "all_people": "Kõik isikud", "all_videos": "Kõik videod", + "anti_clockwise": "Vastupäeva", + "api_key": "API võti", "api_key_description": "Seda väärtust kuvatakse ainult üks kord. Kopeeri see enne akna sulgemist.", + "api_key_empty": "Su API võtme nimi ei tohiks olla tühi", + "api_keys": "API võtmed", + "app_settings": "Rakenduse seaded", "archive": "Arhiiv", "archive_or_unarchive_photo": "Arhiveeri või taasta foto", "archive_size": "Arhiivi suurus", "archive_size_description": "Seadista arhiivi suurus allalaadimiseks (GiB)", + "are_these_the_same_person": "Kas need on sama isik?", "are_you_sure_to_do_this": "Kas oled kindel, et soovid seda teha?", "asset_added_to_album": "Lisatud albumisse", "asset_adding_to_album": "Albumisse lisamine...", @@ -307,13 +355,19 @@ "bulk_delete_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} masskustutada? Sellega jäetakse alles iga grupi suurim üksus ning duplikaadid kustutatakse jäädavalt. Seda tegevust ei saa tagasi võtta!", "bulk_keep_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} alles jätta? Sellega märgitakse kõik duplikaadigrupid lahendatuks ilma midagi kustutamata.", "bulk_trash_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} masskustutada? Sellega jäetakse alles iga grupi suurim üksus ning duplikaadid liigutatakse prügikasti.", + "buy": "Osta Immich", "camera": "Kaamera", "camera_brand": "Kaamera mark", "camera_model": "Kaamera mudel", "cancel": "Katkesta", + "cancel_search": "Katkesta otsing", "cannot_merge_people": "Ei saa isikuid ühendada", "cannot_undo_this_action": "Sa ei saa seda tagasi võtta!", "cannot_update_the_description": "Kirjelduse muutmine ebaõnnestus", + "change_date": "Muuda kuupäeva", + "change_expiration_time": "Muuda aegumisaega", + "change_location": "Muuda asukohta", + "change_name": "Muuda nime", "change_password": "Parooli muutmine", "change_password_description": "See on su esimene kord süsteemi siseneda, või on tehtud taotlus parooli muutmiseks. Palun sisesta allpool uus parool.", "change_your_password": "Muuda oma parooli", @@ -322,6 +376,10 @@ "check_logs": "Vaata logisid", "choose_matching_people_to_merge": "Vali kattuvad isikud, mida ühendada", "city": "Linn", + "clear": "Tühjenda", + "clear_all": "Tühjenda kõik", + "clear_all_recent_searches": "Tühjenda hiljutised otsingud", + "clear_value": "Tühjenda väärtus", "clockwise": "Päripäeva", "close": "Sulge", "color": "Värv", @@ -335,6 +393,7 @@ "confirm_delete_shared_link": "Kas oled kindel, et soovid selle jagatud lingi kustutada?", "confirm_password": "Kinnita parool", "context": "Kontekst", + "continue": "Jätka", "copied_image_to_clipboard": "Pilt kopeeritud lõikelauale.", "copied_to_clipboard": "Kopeeritud lõikelauale!", "copy_error": "Kopeeri viga", @@ -383,7 +442,9 @@ "delete_user": "Kustuta kasutaja", "deleted_shared_link": "Jagatud link kustutatud", "description": "Kirjeldus", + "details": "Üksikasjad", "direction": "Suund", + "disallow_edits": "Keela muutmine", "discover": "Avasta", "display_options": "Kuva valikud", "display_original_photos_setting_description": "Eelista üksuse vaatamisel pisipildile algset fotot, kui see on veebiga ühilduv. See võib mõjutada fotode kuvamise kiirust.", @@ -454,6 +515,7 @@ "import_path_already_exists": "See imporditee on juba olemas.", "incorrect_email_or_password": "Vale e-posti aadress või parool", "profile_picture_transparent_pixels": "Profiilipildis ei tohi olla läbipaistvaid piksleid. Palun suumi sisse ja/või liiguta pilti.", + "quota_higher_than_disk_size": "Määratud kvoot on suurem kui kettamaht", "unable_to_add_album_users": "Kasutajate lisamine albumisse ebaõnnestus", "unable_to_add_assets_to_shared_link": "Üksuste jagatud lingile lisamine ebaõnnestus", "unable_to_add_comment": "Kommentaari lisamine ebaõnnestus", @@ -463,6 +525,7 @@ "unable_to_add_remove_archive": "{archived, select, true {Üksuse arhiivist taastamine} other {Üksuse arhiveerimine}} ebaõnnestus", "unable_to_add_remove_favorites": "Üksuse {favorite, select, true {lemmikuks lisamine} other {lemmikutest eemaldamine}} ebaõnnestus", "unable_to_archive_unarchive": "{archived, select, true {Arhiveerimine} other {Arhiivist taastamine}} ebaõnnestus", + "unable_to_change_album_user_role": "Kasutaja rolli albumis muutmine ebaõnnestus", "unable_to_change_date": "Kuupäeva muutmine ebaõnnestus", "unable_to_change_favorite": "Üksuse lemmiku staatuse muutmine ebaõnnestus", "unable_to_change_location": "Asukoha muutmine ebaõnnestus", @@ -536,23 +599,32 @@ "expired": "Aegunud", "expires_date": "Aegub {date}", "explore": "Avasta", + "export": "Ekspordi", "export_as_json": "Ekspordi JSON-formaati", "extension": "Laiend", + "external": "Väline", + "external_libraries": "Välised kogud", "face_unassigned": "Seostamata", "favorite": "Lemmik", "favorites": "Lemmikud", "feature_photo_updated": "Esiletõstetud foto muudetud", + "features": "Funktsioonid", + "features_setting_description": "Halda rakenduse funktsioone", "file_name": "Failinimi", "file_name_or_extension": "Failinimi või -laiend", "filename": "Failinimi", "filetype": "Failitüüp", "filter_people": "Filtreeri isikuid", + "find_them_fast": "Leia teda kiiresti nime järgi otsides", "folders": "Kaustad", "folders_feature_description": "Kaustavaate abil failisüsteemis olevate fotode ja videote sirvimine", "force_re-scan_library_files": "Sundskaneeri kogu kõik failid uuesti", "forward": "Edasi", "general": "Üldine", + "get_help": "Küsi abi", + "getting_started": "Alustamine", "go_back": "Tagasi", + "go_to_search": "Otsingusse", "group_albums_by": "Grupeeri albumid...", "group_no": "Ära grupeeri", "group_owner": "Grupeeri omaniku kaupa", @@ -575,6 +647,7 @@ "immich_logo": "Immich'i logo", "immich_web_interface": "Immich'i veebiliides", "import_from_json": "Impordi JSON-formaadist", + "in_albums": "{count, plural, one {# albumis} other {# albumis}}", "in_archive": "Arhiivis", "info": "Info", "interval": { @@ -595,9 +668,13 @@ "latest_version": "Uusim versioon", "latitude": "Laiuskraad", "leave": "Lahku", + "let_others_respond": "Luba teistel vastata", "library": "Kogu", "library_options": "Kogu seaded", + "light": "Hele", + "link_options": "Lingi valikud", "list": "Loend", + "loading": "Laadimine", "loading_search_results_failed": "Otsitulemuste laadimine ebaõnnestus", "log_out": "Logi välja", "log_out_all_devices": "Logi kõigist seadmetest välja", @@ -607,15 +684,19 @@ "logout_all_device_confirmation": "Kas oled kindel, et soovid kõigist seadmetest välja logida?", "logout_this_device_confirmation": "Kas oled kindel, et soovid sellest seadmest välja logida?", "longitude": "Pikkuskraad", + "look": "Välimus", "make": "Mark", "manage_shared_links": "Halda jagatud linke", "manage_sharing_with_partners": "Halda partneritega jagamist", + "manage_the_app_settings": "Halda rakenduse seadeid", "manage_your_account": "Halda oma kontot", "manage_your_api_keys": "Halda oma API võtmeid", "manage_your_devices": "Halda oma autenditud seadmeid", "map": "Kaart", "map_settings": "Kaardi seaded", + "media_type": "Meedia tüüp", "memories": "Mälestused", + "memories_setting_description": "Halda, mida sa oma mälestustes näed", "memory": "Mälestus", "menu": "Menüü", "merge": "Ühenda", @@ -642,17 +723,24 @@ "next_memory": "Järgmine mälestus", "no": "Ei", "no_albums_message": "Lisa album fotode ja videote organiseerimiseks", + "no_albums_with_name_yet": "Paistab, et sul pole veel ühtegi selle nimega albumit.", + "no_albums_yet": "Paistab, et sul pole veel ühtegi albumit.", "no_archived_assets_message": "Arhiveeri fotod ja videod, et neid Fotod vaatest peita", "no_assets_message": "KLIKI ESIMESE FOTO ÜLESLAADIMISEKS", "no_duplicates_found": "Ühtegi duplikaati ei leitud.", "no_exif_info_available": "Exif info pole saadaval", + "no_explore_results_message": "Oma kogu avastamiseks laadi üles rohkem fotosid.", "no_favorites_message": "Lisa lemmikud, et oma parimaid fotosid ja videosid kiiresti leida", "no_libraries_message": "Lisa väline kogu oma fotode ja videote vaatamiseks", + "no_results": "Vasteid pole", + "no_results_description": "Proovi sünonüümi või üldisemat märksõna", "no_shared_albums_message": "Lisa album, et fotosid ja videosid teistega jagada", + "notes": "Märkused", "notification_toggle_setting_description": "Luba e-posti teel teavitused", "notifications": "Teavitused", "notifications_setting_description": "Halda teavitusi", "oauth": "OAuth", + "ok": "Ok", "oldest_first": "Vanemad eespool", "onboarding_theme_description": "Vali oma serverile värviteema. Saad seda hiljem seadetes muuta.", "onboarding_welcome_user": "Tere tulemast, {user}", @@ -660,11 +748,13 @@ "only_refreshes_modified_files": "Värskendab ainult muudetud failid", "open_in_map_view": "Ava kaardi vaates", "open_in_openstreetmap": "Ava OpenStreetMap", + "open_the_search_filters": "Ava otsingufiltrid", "options": "Valikud", "or": "või", "organize_your_library": "Korrasta oma kogu", "original": "originaal", "other_devices": "Muud seadmed", + "other_variables": "Muud muutujad", "owned": "Minu omad", "owner": "Omanik", "partner": "Partner", @@ -699,6 +789,8 @@ "permanently_deleted_asset": "Üksus jäädavalt kustutatud", "permanently_deleted_assets_count": "{count, plural, one {# üksus} other {# üksust}} jäädavalt kustutatud", "person": "Isik", + "person_hidden": "{name}{hidden, select, true { (peidetud)} other {}}", + "photo_shared_all_users": "Paistab, et oled oma fotosid kõigi kasutajatega jaganud, või pole ühtegi kasutajat, kellega jagada.", "photos": "Fotod", "photos_and_videos": "Fotod ja videod", "photos_count": "{count, plural, one {{count, number} foto} other {{count, number} fotot}}", @@ -739,6 +831,8 @@ "purchase_panel_info_1": "Immich'i arendamine nõuab palju aega ja vaeva ning meie täiskohaga insenerid töötavad selle nimel, et teha see nii heaks kui vähegi võimalik. Meie missiooniks on muuta avatud lähtekoodiga tarkvara ja eetilised äritavad arendajatele jätkusuutlikuks sissetulekuallikaks ning luua privaatsust austav ökosüsteem, mis pakub tõelisi alternatiive ekspluatatiivsetele pilveteenustele.", "purchase_panel_info_2": "Kuna oleme otsustanud maksumüüre mitte lisada, ei anna see ost sulle Immich'is lisavõimalusi. Me loodame Immich'i jätkuvaks arenduseks sinusuguste kasutajate toetusele.", "purchase_panel_title": "Toeta projekti", + "purchase_per_server": "Serveri kohta", + "purchase_per_user": "Kasutaja kohta", "purchase_remove_product_key": "Eemalda tootevõti", "purchase_remove_product_key_prompt": "Kas oled kindel, et soovid tootevõtme eemaldada?", "purchase_remove_server_product_key": "Eemalda serveri tootevõti", @@ -747,10 +841,12 @@ "purchase_server_description_2": "Toetaja staatus", "purchase_server_title": "Server", "purchase_settings_server_activated": "Serveri tootevõtit haldab administraator", + "reaction_options": "Reaktsiooni valikud", "read_changelog": "Vaata muudatuste ülevaadet", "reassigned_assets_to_existing_person": "{count, plural, one {# üksus} other {# üksust}} seostatud {name, select, null {olemasoleva isikuga} other {isikuga {name}}}", "reassigned_assets_to_new_person": "{count, plural, one {# üksus} other {# üksust}} seostatud uue isikuga", "reassing_hint": "Seosta valitud üksused olemasoleva isikuga", + "recent_searches": "Hiljutised otsingud", "refresh": "Värskenda", "refresh_encoded_videos": "Värskenda kodeeritud videod", "refresh_metadata": "Värskenda metaandmed", @@ -766,11 +862,14 @@ "remove_assets_title": "Eemalda üksused?", "remove_from_album": "Eemalda albumist", "remove_from_favorites": "Eemalda lemmikutest", + "remove_from_shared_link": "Eemalda jagatud lingist", "remove_user": "Eemalda kasutaja", "removed_api_key": "API võti eemaldatud: {name}", "removed_from_archive": "Arhiivist eemaldatud", "removed_from_favorites": "Lemmikutest eemaldatud", + "removed_from_favorites_count": "{count, plural, other {# eemaldatud}} lemmikutest", "removed_tagged_assets": "Silt eemaldatud {count, plural, one {# üksuselt} other {# üksuselt}}", + "rename": "Nimeta ümber", "require_password": "Nõua parooli", "require_user_to_change_password_on_first_login": "Nõua kasutajalt esmakordsel sisenemisel parooli muutmist", "reset": "Lähtesta", @@ -806,8 +905,10 @@ "search_for_existing_person": "Otsi olemasolevat isikut", "search_no_people": "Isikuid ei ole", "search_no_people_named": "Ei ole isikuid nimega \"{name}\"", + "search_options": "Otsingu valikud", "search_people": "Otsi inimesi", "search_places": "Otsi kohti", + "search_settings": "Otsingu seaded", "search_state": "Otsi osariiki...", "search_tags": "Otsi silte...", "search_timezone": "Otsi ajavööndit...", @@ -862,13 +963,18 @@ "show_metadata": "Kuva metaandmed", "show_or_hide_info": "Kuva või peida info", "show_password": "Kuva parooli", + "show_progress_bar": "Kuva edenemisriba", + "show_search_options": "Kuva otsingu valikud", "show_supporter_badge": "Toetaja märk", "show_supporter_badge_description": "Kuva toetaja märki", "sidebar": "Külgmenüü", + "sidebar_display_description": "Kuva külgmenüüs linki vaatele", "sign_out": "Logi välja", "sign_up": "Registreeru", "size": "Suurus", "skip_to_content": "Sisu juurde", + "skip_to_folders": "Kaustade juurde", + "skip_to_tags": "Siltide juurde", "slideshow": "Slaidiesitlus", "slideshow_settings": "Slaidiesitluse seaded", "sort_albums_by": "Järjesta albumid...", @@ -902,6 +1008,7 @@ "theme": "Teema", "theme_selection": "Teema valik", "theme_selection_description": "Sea automaatselt hele või tume teema vastavalt veebilehitseja eelistustele", + "time_based_memories": "Ajapõhised mälestused", "timezone": "Ajavöönd", "to_archive": "Arhiivi", "to_change_password": "Muuda parool", @@ -917,6 +1024,8 @@ "unnamed_album_delete_confirmation": "Kas oled kindel, et soovid selle albumi kustutada?", "unsaved_change": "Salvestamata muudatus", "updated_password": "Parool muudetud", + "upload": "Laadi üles", + "upload_concurrency": "Üleslaadimise samaaegsus", "upload_errors": "Üleslaadimine lõpetatud {count, plural, one {# veaga} other {# veaga}}, uute üksuste nägemiseks värskenda lehte.", "upload_skipped_duplicates": "{count, plural, one {# dubleeritud üksus} other {# dubleeritud üksust}} vahele jäetud", "upload_status_duplicates": "Duplikaadid", @@ -927,6 +1036,7 @@ "user": "Kasutaja", "user_id": "Kasutaja ID", "user_liked": "Kasutajale {user} meeldis {type, select, photo {see foto} video {see video} asset {see üksus} other {see}}", + "user_purchase_settings": "Osta", "user_purchase_settings_description": "Halda oma ostu", "username": "Kasutajanimi", "users": "Kasutajad", @@ -935,6 +1045,7 @@ "variables": "Muutujad", "version": "Versioon", "version_announcement_closing": "Sinu sõber, Alex", + "version_announcement_message": "Hei sõber, saadaval on rakenduse uus versioon. Palun võta aega, et lugeda väljalasketeadet ning veendu, et su docker-compose.yml ja .env failid on ajakohased, et vältida konfiguratsiooniprobleeme, eriti kui kasutad WatchTower'it või muud mehhanismi, mis rakendust automaatselt uuendab.", "video": "Video", "video_hover_setting": "Esita hõljutamisel video eelvaade", "video_hover_setting_description": "Esita video eelvaade, kui hiirt selle kohal hõljutada. Isegi kui keelatud, saab taasesituse alustada taasesitusnupu kohal hõljutades.", diff --git a/web/src/lib/i18n/fi.json b/web/src/lib/i18n/fi.json index 15a3dc0a26..a6c07c18e9 100644 --- a/web/src/lib/i18n/fi.json +++ b/web/src/lib/i18n/fi.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Lisää jaettuun albumiin", "added_to_archive": "Arkistoitu", "added_to_favorites": "Lisätty suosikkeihin", - "added_to_favorites_count": "{count} lisätty suosikkeihin", + "added_to_favorites_count": "{count, number} lisätty suosikkeihin", "admin": { "add_exclusion_pattern_description": "Lisää mallit, jonka mukaan jätetään tiedostoja pois. Jokerimerkit *, ** ja ? ovat tuettuna. Jättääksesi pois kaikki tiedostot mistä tahansa löytyvästä kansiosta \"Raw\" käytä \"**/Raw/**\". Jättääksesi pois kaikki \". tif\" päätteiset tiedot, käytä \"**/*.tif\". Jättääksesi pois tarkan tiedostopolun, käytä \"/path/to/ignore/**\".", "authentication_settings": "Autentikointiasetukset", @@ -41,6 +41,7 @@ "confirm_email_below": "Kirjota \"{email}\" vahvistaaksesi", "confirm_reprocess_all_faces": "Haluatko varmasti käsitellä uudelleen kaikki kasvot? Tämä poistaa myös nimetyt henkilöt.", "confirm_user_password_reset": "Haluatko varmasti nollata käyttäjän {user} salasanan?", + "create_job": "Luo tehtävä", "crontab_guru": "Crontab Guru", "disable_login": "Poista kirjautuminen käytöstä", "disabled": "Ei käytössä", @@ -70,12 +71,13 @@ "image_thumbnail_resolution": "Pikkukuvien resoluutio", "image_thumbnail_resolution_description": "Käytetään katsottaessa useita kuvia kerralla (aikajana, albuminäkymä, jne.) Korkeampi resoluutio antaa enemmän yksityiskohtia, mutta niiden luonti kestää kauemmin, tiedostokoot ovat isompia ja voivat heikentää sovelluksen responsiivisuutta.", "job_concurrency": "{job} yhtäaikaisuus", + "job_created": "Tehtävä luotu", "job_not_concurrency_safe": "Tätä tehtävää ei ole turvallista ajaa yhtäaikaisesti.", "job_settings": "Tehtävän asetukset", "job_settings_description": "Hallitse tehtävän samanaikaisuusasetuksia", "job_status": "Tehtävän tila", - "jobs_delayed": "{jobCount} tehtävää viivästetty", - "jobs_failed": "{jobCount} epäonnistui", + "jobs_delayed": "{jobCount, plural, other {# viivästynyttä}}", + "jobs_failed": "{jobCount, plural, other {# epäonnistunutta}}", "library_created": "Kirjasto {library} luotu", "library_cron_expression": "Cron-lauseke", "library_cron_expression_description": "Anna skannaustiheys cron-formaatissa. Saadaksesi lisätietoja katso esimerkiksi Crontab Guru", @@ -135,13 +137,13 @@ "map_reverse_geocoding": "Käänteinen Geokoodaus", "map_reverse_geocoding_enable_description": "Ota käyttöön osoitteiden poiminta karttakoordinaateista", "map_reverse_geocoding_settings": "Käänteisen Geokoodauksen asetukset", - "map_settings": "Kartta-asetukset", + "map_settings": "Kartta", "map_settings_description": "Hallitse kartan asetuksia", "map_style_description": "style.json -karttateeman URL", "metadata_extraction_job": "Kerää metadata", - "metadata_extraction_job_description": "Poimi metatiedot aineistoista, kuten GPS ja resoluutio", + "metadata_extraction_job_description": "Poimi metatiedot aineistoista, kuten GPS, kasvot ja resoluutio", "metadata_faces_import_setting": "Ota käyttöön kasvojen tuonti", - "metadata_faces_import_setting_description": "Tuo kasvot kuvan EXIF -tiedoista ja kylkiäistiedostoista", + "metadata_faces_import_setting_description": "Tuo kasvot kuvan EXIF- ja kylkiäistiedostoista", "metadata_settings": "Metatietoasetukset", "metadata_settings_description": "Hallitse metatietoja", "migration_job": "Migrointi", @@ -178,9 +180,9 @@ "oauth_issuer_url": "Toimitsijan URL", "oauth_mobile_redirect_uri": "Mobiilin uudellenohjaus-URI", "oauth_mobile_redirect_uri_override": "Ohita mobiilin uudelleenohjaus-URI", - "oauth_mobile_redirect_uri_override_description": "Ota käyttöön kun 'app.immich:/' -ohjausta ei tueta.", + "oauth_mobile_redirect_uri_override_description": "Ota käyttöön kun OAuth tarjoaja ei salli mobiili URI:a, kuten '{callback}'", "oauth_profile_signing_algorithm": "Profiilin allekirjoitusalgoritmi", - "oauth_profile_signing_algorithm_description": "Algoritmi, jota käytetään käyttäjäprofiilin allekirjoituksessa", + "oauth_profile_signing_algorithm_description": "Algoritmi, jota käytetään käyttäjäprofiilin allekirjoittamiseen.", "oauth_scope": "Skooppi (Scope)", "oauth_settings": "OAuth", "oauth_settings_description": "Hallitse OAuth kirjautumisen asetuksia", @@ -198,6 +200,7 @@ "password_settings": "Kirjaudu salasanalla", "password_settings_description": "Hallitse salasanakirjautumisen asetuksia", "paths_validated_successfully": "Kaikki polut validoitu", + "person_cleanup_job": "Henkilöpuhdistus", "quota_size_gib": "Kiintiön koko (Gt)", "refreshing_all_libraries": "Virkistetään kaikki kirjastot", "registration": "Pääkäyttäjän rekisteröinti", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Palauta aiemmin tallennetut asetukset", "scanning_library_for_changed_files": "Etsitään kirjaston muuttuneita tiedostoja", "scanning_library_for_new_files": "Etsitään uusia tiedostoja", + "search_jobs": "Etsi tehtäviä...", "send_welcome_email": "Lähetä tervetuloviesti", "server_external_domain_settings": "Ulkoinen osoite", "server_external_domain_settings_description": "Osoite julkisille linkeille, http(s):// mukaan lukien", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Hallitse palvelimelle ladatun aineiston kansiorakennetta ja tiedostonimiä", "storage_template_user_label": "{label} on käyttäjän Tallennustilan Tunniste", "system_settings": "Järjestelmäasetukset", + "tag_cleanup_job": "Merkintäpuhdistus", "theme_custom_css_settings": "Mukautettu CSS", "theme_custom_css_settings_description": "Kustomoi Immichin ulkoasua Cascading Style Sheets:llä.", "theme_settings": "Teeman asetukset", @@ -265,7 +270,7 @@ "transcoding_codecs_learn_more": "Oppiaksesi lisää tässä käytetystä terminologiasta, tutustu FFmpeg- dokumentaatioon H.264 koodaaja, HEVC koodaaja sekä VP9 koodaaja.", "transcoding_constant_quality_mode": "Tasaisen laadun tyyppi", "transcoding_constant_quality_mode_description": "ICQ on parempi kuin CQP, mutta jotkut laitteistokiihdyttimet eivät tue sitä. Tätä asetusta käytetään oletuksena laatuun pohjautuvissa muunnoksissa, paitsi NVENC mikä ei tue ICQ:ta.", - "transcoding_constant_rate_factor": "", + "transcoding_constant_rate_factor": "Vakionopeustekijä", "transcoding_constant_rate_factor_description": "Videon laatu. Yleisimmät arvot ovat 23 H.264:lle, 28 HEVC:lle, 31 VP9:lle ja 35 AV1:lle. Matalampi arvo on parempi, mutta tekee isompia tiedostoja.", "transcoding_disabled_description": "Älä muunna videoita. Voi joissakin päätelaitteissa aiheuttaa videotoiston toimimattomuutta", "transcoding_hardware_acceleration": "Laitteistokiihdytys", @@ -283,7 +288,7 @@ "transcoding_preferred_hardware_device": "Ensisijainen laite", "transcoding_preferred_hardware_device_description": "On voimassa vain VAAPI ja QSV -määritteille. Asettaa laitteistokoodauksessa käytetyn DRI noodin.", "transcoding_preset_preset": "Esiasetus (-asetus)", - "transcoding_preset_preset_description": "Pakkausnopeus. Hitaampi tuottaa pienempiä tiedostoja ja parantaa laatua, kun kohdistetaan tiettyyn bittinopeuteen. VP9 ei huomioi korkeampaa kuin `faster`.", + "transcoding_preset_preset_description": "Pakkausnopeus. Hitaampi tuottaa pienempiä tiedostoja ja parantaa laatua, kun kohdistetaan tiettyyn bittinopeuteen. VP9 ei huomioi korkeampaa kuin 'faster'.", "transcoding_reference_frames": "Kehysviitteet", "transcoding_reference_frames_description": "Viittaavien kehysten määrä kun tiettyä kehystä pakataan. Korkeampi arvo parantaa pakkausta mutta hidastaa enkoodausta. 0 määrittää arvon automaattisesti.", "transcoding_required_description": "Vain videoille, jotka eivät ole hyväksytyssä muodossa", @@ -302,7 +307,7 @@ "transcoding_transcode_policy": "Transkoodauskäytäntö", "transcoding_transcode_policy_description": "Käytäntö miten video tulisi transkoodata. HDR videot transkoodataan aina, paitsi jos transkoodaus on poistettu käytöstä.", "transcoding_two_pass_encoding": "Two-pass enkoodaus", - "transcoding_two_pass_encoding_setting_description": "", + "transcoding_two_pass_encoding_setting_description": "Transkoodaa kahdessa vaiheessa tuottaaksesi paremmin koodattuja videoita. Kun maksimibittinopeus on käytössä (vaaditaan H.264- ja HEVC-koodaukselle), tämä tila käyttää bittinopeusaluetta, joka perustuu maksimibittinopeuteen ja ohittaa CRF. VP9 osalta CRF:ää voidaan käyttää, jos maksimibittinopeus on poistettu käytöstä.", "transcoding_video_codec": "Videokoodekki", "transcoding_video_codec_description": "VP9 on tehokkain ja web-yhteensopiva, mutta muuntaminen kestää kauemmin. HEVC suoriutuu yhtäläisesti, mutta ei ole ihan yhtä yhteensopiva. H.264 on hyvin yhteensopiva ja nopea muuntaa, mutta tuottaa paljon suurempia tiedostoja. AV1 on kaikkein tehokkain koodekki, mutta vanhemmat laitteet eivät sitä tue.", "trash_enabled_description": "Ota käyttöön roskakori", @@ -312,15 +317,22 @@ "trash_settings_description": "Hallitse roskakoriasetuksia", "untracked_files": "Tiedostot joita ei seurata", "untracked_files_description": "Nämä tiedostot eivät ole ohjelman hallitsemia. Ne voivat olla virheellisten siirtojen tai keskeytyneiden latausten tulosta, tai bugista johtuvia jälkeen jääneitä", + "user_cleanup_job": "Käyttäjien puhdistus", + "user_delete_delay": "Käyttäjän {user} tili ja aineistot aikataulutetaan poistettavaksi ajan kuluttua: {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Poiston viive", "user_delete_delay_settings_description": "Montako päivää poistamisen jälkeen käyttäjä ja hänen aineistonsa poistetaan pysyvästi. Joka keskiyö käydään läpi poistetuiksi merkityt käyttäjät. Tämä muutos astuu voimaan seuraavalla ajokerralla.", + "user_delete_immediately": "{user}:n tili ja sen kohteet on ajastettu poistettavaksi heti.", + "user_delete_immediately_checkbox": "Aseta tili ja sen kohteet jonoon välitöntä poistoa varten", "user_management": "Käyttäjien hallinta", "user_password_has_been_reset": "Käyttäjän salasana on nollattu:", "user_password_reset_description": "Anna väliaikainen salasana ja ohjeista käyttäjää vaihtamaan se seuraavan kirjautumisen yhteydessä.", + "user_restore_description": "{user}:n tili palautetaan.", + "user_restore_scheduled_removal": "Palauta käyttäjä - Aikataulutettu poisto tapahtuu {date, date, long}", "user_settings": "Käyttäjäasetukset", "user_settings_description": "Hallitse käyttäjäasetuksia", "user_successfully_removed": "Käyttäjä {email} on poistettu.", - "version_check_enabled_description": "Ota käyttöön säännölliset uusien versioiden tarkistukset GitHubista", + "version_check_enabled_description": "Ota käyttöön versiotarkastus", + "version_check_implications": "Versiontarkistus vaatii säännöllisen yhteyden github.com:iin", "version_check_settings": "Versiotarkistus", "version_check_settings_description": "Ota käyttöön ilmoitukset, kun uusi versio on saatavilla", "video_conversion_job": "Transkoodaa videot", @@ -336,17 +348,21 @@ "album_added": "Albumi lisätty", "album_added_notification_setting_description": "Saa sähköpostia kun sinut lisätään jaettuun albumiin", "album_cover_updated": "Albumin kansikuva päivitetty", - "album_delete_confirmation": "Haluatko varmasti poistaa albumin {album}?\nJos albumi on jaettu, muut eivät pääse siihen enää.", + "album_delete_confirmation": "Haluatko varmasti poistaa albumin {album}?", + "album_delete_confirmation_description": "Jos albumi on jaettu, muut eivät pääse siihen enää.", "album_info_updated": "Albumin tiedot päivitetty", "album_leave": "Poistu albumista?", + "album_leave_confirmation": "Haluatko varmasti poistua albumista {album}?", "album_name": "Albumin nimi", "album_options": "Albumin asetukset", "album_remove_user": "Poista käyttäjä?", - "album_remove_user_confirmation": "Oletko varma että haluat poistaa {user}?", + "album_remove_user_confirmation": "Oletko varma että haluat poistaa {user}?", "album_share_no_users": "Näyttää että olet jakanut tämän albumin kaikkien kanssa, tai sinulla ei ole käyttäjiä joille jakaa.", "album_updated": "Albumi päivitetty", "album_updated_setting_description": "Saa sähköpostia kun jaetussa albumissa on uutta sisältöä", + "album_user_left": "Poistuttiin albumista {album}", "album_user_removed": "{user} poistettu", + "album_with_link_access": "Anna kenen tahansa nähdä linkin kautta tämän albumin valokuvat ja henkilöt.", "albums": "Albumit", "albums_count": "{count, plural, one {{count, number} albumi} other {{count, number} albumia}}", "all": "Kaikki", @@ -355,7 +371,12 @@ "all_videos": "Kaikki videot", "allow_dark_mode": "Salli tumma tila", "allow_edits": "Salli muutokset", + "allow_public_user_to_download": "Salli julkisten käyttäjien ladata tiedostoja", + "allow_public_user_to_upload": "Salli julkisten käyttäjien lähettää tiedostoja", + "anti_clockwise": "Vastapäivään", "api_key": "API-avain", + "api_key_description": "Tämä arvo näytetään vain kerran. Varmista, että olet kopioinut sen ennen kuin suljet ikkunan.", + "api_key_empty": "API-avaimesi ei pitäisi olla tyhjä", "api_keys": "API-avaimet", "app_settings": "Sovellusasetukset", "appears_in": "Esiintyy albumeissa", @@ -369,14 +390,20 @@ "are_you_sure_to_do_this": "Haluatko varmasti tehdä tämän?", "asset_added_to_album": "Lisätty albumiin", "asset_adding_to_album": "Lisätään albumiin...", + "asset_description_updated": "Kohteen kuvaus on päivitetty", + "asset_filename_is_offline": "Kohde {filename} on offline-tilassa", + "asset_has_unassigned_faces": "Kohteella on määrittämättömiä kasvoja", + "asset_hashing": "Hajautetaan...", "asset_offline": "Aineisto offline-tilassa", + "asset_offline_description": "Tämä kohde on offline-tilassa. Immich ei pääse tiedoston sijaintiin. Varmista, että kohde on saatavilla, ja skannaa sitten kirjasto uudelleen.", "asset_skipped": "Ohitettu", + "asset_skipped_in_trash": "Roskakorissa", "asset_uploaded": "Lähetetty", "asset_uploading": "Lähetetään…", "assets": "kohdetta", "assets_added_count": "Lisätty {count, plural, one {# kohde} other {# kohdetta}}", "assets_added_to_album_count": "Albumiin lisätty {count, plural, one {# kohde} other {# kohdetta}}", - "assets_added_to_name_count": "{name}:n lisätty {count, plural, one {# media} other {# mediaa}}", + "assets_added_to_name_count": "Lisätty {count, plural, one {# kohde} other {# kohdetta}} {hasName, select, true {{name}} other {uuteen albumiin}}", "assets_count": "{count, plural, one {# media} other {# mediaa}}", "assets_moved_to_trash": "Siirretty {count, plural, one {# aineisto} other {# aineistoa}} roskakoriin", "assets_moved_to_trash_count": "Siirretty {count, plural, one {# media} other {# mediaa}} roskakoriin", @@ -398,6 +425,7 @@ "bulk_delete_duplicates_confirmation": "Haluatko varmasti poistaa {count, plural, one {# kaksoiskappaleen} other {# kaksoiskappaleet}} kerralla? Tämä säilyttää kustakin mediasta kookkaimman ja poistaa loput pysyvästi. Et voi perua tätä!", "bulk_keep_duplicates_confirmation": "Haluatko varmasti säilyttää {count, plural, one {# kaksoiskappaleen} other {# kaksoiskappaleet}}? Tämä merkitsee kaikki kaksoiskappaleet ratkaistuiksi, eikä poista mitään.", "bulk_trash_duplicates_confirmation": "Haluatko varmasti siirtää {count, plural, one {# kaksoiskappaleen} other {# kaksoiskappaleet}} roskakoriin? Tämä säilyttää kustakin mediasta kookkaimman ja siirtää loput roskakoriin.", + "buy": "Osta lisenssi Immich:iin", "camera": "Kamera", "camera_brand": "Kameran merkki", "camera_model": "Kameran malli", @@ -415,7 +443,7 @@ "change_location": "Vaihda sijainti", "change_name": "Vaihda nimi", "change_name_successfully": "Nimi vaihdettu", - "change_password": "Vaihda salasana", + "change_password": "Vaihda Salasana", "change_password_description": "Tämä on joko ensimmäinen kertasi kun kirjaudut järjestelmään, tai salasanasi on pyydetty vaihtamaan. Määritä uusi salasana alle.", "change_your_password": "Vaihda salasanasi", "changed_visibility_successfully": "Näkyvyys vaihdettu", @@ -425,11 +453,14 @@ "city": "Kaupunki", "clear": "Tyhjennä", "clear_all": "Tyhjennä kaikki", + "clear_all_recent_searches": "Tyhjennä viimeisimmät haut", "clear_message": "Tyhjennä viesti", "clear_value": "Tyhjää arvo", + "clockwise": "Myötäpäivään", "close": "Sulje", "collapse": "Supista", "collapse_all": "Sulje kaikki", + "color": "Väri", "color_theme": "Väriteema", "comment_deleted": "Kommentti poistettu", "comment_options": "Kommentin valinnat", @@ -463,13 +494,15 @@ "create_new_person": "Luo uusi henkilö", "create_new_person_hint": "Määritä valitut mediat uudelle henkilölle", "create_new_user": "Luo uusi käyttäjä", + "create_tag": "Luo tunniste", + "create_tag_description": "Luo uusi tunniste. Sisäkkäisiä tunnisteita varten, syötä tunnisteen täydellinen polku kauttaviiva mukaanluettuna.", "create_user": "Luo käyttäjä", "created": "Luotu", "current_device": "Nykyinen laite", "custom_locale": "Muokatut maa-asetukset", "custom_locale_description": "Muotoile päivämäärät ja numerot perustuen alueen kieleen", "dark": "Tumma", - "date_after": "Päivä jälkeen", + "date_after": "Päivämäärän jälkeen", "date_and_time": "Päivämäärä ja aika", "date_before": "Päivä ennen", "date_of_birth_saved": "Syntymäaika tallennettu", @@ -486,6 +519,8 @@ "delete_library": "Poista kirjasto", "delete_link": "Poista linkki", "delete_shared_link": "Poista jaettu linkki", + "delete_tag": "Poista tunniste", + "delete_tag_confirmation_prompt": "Haluatko varmasti poistaa {tagName}-tunnisteen?", "delete_user": "Poista käyttäjä", "deleted_shared_link": "Jaettu linkki poistettu", "description": "Kuvaus", @@ -503,6 +538,8 @@ "do_not_show_again": "Älä näytä tätä enää", "done": "Valmis", "download": "Lataa", + "download_include_embedded_motion_videos": "Upotetut videot", + "download_include_embedded_motion_videos_description": "Sisällytä liikekuviin upotetut videot erillisinä tiedostoina", "download_settings": "Lataukset", "download_settings_description": "Hallitse aineiston lataukseen liittyviä asetuksia", "downloading": "Ladataan", @@ -532,10 +569,15 @@ "edit_location": "Muokkaa sijaintia", "edit_name": "Muokkaa nimeä", "edit_people": "Muokkaa henkilöitä", + "edit_tag": "Muokkaa tunnistetta", "edit_title": "Muokkaa otsikkoa", "edit_user": "Muokkaa käyttäjää", "edited": "Muokattu", - "editor": "", + "editor": "Editori", + "editor_close_without_save_prompt": "Muutoksia ei tallenneta", + "editor_close_without_save_title": "Suljetaanko editori?", + "editor_crop_tool_h2_aspect_ratios": "Kuvasuhteet", + "editor_crop_tool_h2_rotation": "Rotaatio", "email": "Sähköposti", "empty": "", "empty_album": "", @@ -563,6 +605,7 @@ "error_adding_users_to_album": "Käyttäjiä ei voitu lisätä albumiin", "error_deleting_shared_user": "Jaettua käyttäjää ei voitu poistaa", "error_downloading": "Tiedostoa {filename} ei voitu ladata", + "error_hiding_buy_button": "Virhe osta-painikkeen piilottamisessa", "error_removing_assets_from_album": "Medioiden poisto epäonnistui. Katso konsolista lisätietoja", "error_selecting_all_assets": "Kaikkia medioita ei voitu valita", "exclusion_pattern_already_exists": "Tämä poissulkemismalli on jo olemassa.", @@ -573,6 +616,8 @@ "failed_to_get_people": "Henkilöiden haku epäonnistui", "failed_to_load_asset": "Kohteen lataus epäonnistui", "failed_to_load_assets": "Kohteiden lataus epäonnistui", + "failed_to_load_people": "Henkilöiden lataus epäonnistui", + "failed_to_remove_product_key": "Tuoteavaimen poistaminen epäonnistui", "failed_to_stack_assets": "Medioiden pinoaminen epäonnistui", "failed_to_unstack_assets": "Medioiden pinoamisen purku epäonnistui", "import_path_already_exists": "Tämä tuontipolku on jo olemassa.", @@ -580,54 +625,90 @@ "paths_validation_failed": "{paths, plural, one {# polun} other {# polun}} validointi epäonnistui", "profile_picture_transparent_pixels": "Profiilikuvassa ei voi olla läpinäkyviä pikseleitä. Zoomaa lähemmäs ja/tai siirrä kuvaa.", "quota_higher_than_disk_size": "Asettamasi kiintiö on suurempi kuin levyn koko", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", + "repair_unable_to_check_items": "Ei voida tarkistaa {count, select, one {kohdetta} other {kohteita}}", + "unable_to_add_album_users": "Käyttäjiä ei voi lisätä albumiin", + "unable_to_add_assets_to_shared_link": "Medioiden lisääminen jaettuun linkkiin epäonnistui", + "unable_to_add_comment": "Kommentin lisääminen epäonnistui", + "unable_to_add_exclusion_pattern": "Ei voida lisätä poissulkuohjetta", + "unable_to_add_import_path": "Tuontipolkua ei voitu lisätä", + "unable_to_add_partners": "Kumppaneita ei voitu lisätä", + "unable_to_add_remove_archive": "Ei voida {archived, select, true {poistaa kohdetta arkistosta} other {lisätä kohdetta arkistoon}}", + "unable_to_add_remove_favorites": "Ei voida {favorite, select, true {lisätä kohdetta suosikkeihin} other {poistaa kohdetta suosikeista}}", + "unable_to_archive_unarchive": "Ei voida {archived, select, true {arkistoida} other {poistaa arkistosta}}", + "unable_to_change_album_user_role": "Albumin käyttäjän roolia ei voitu muuttaa", + "unable_to_change_date": "Päivämäärää ei voitu muuttaa", + "unable_to_change_favorite": "Ei voida muuttaa suosikkia kohteelle", "unable_to_change_location": "Sijainnin muuttaminen epäonnistui", "unable_to_change_password": "Salasanan vaihto epäonnistui", + "unable_to_change_visibility": "Ei voida muuttaa näkyvyyttä {count, plural, one {# henkilölle} other {# henkilölle}}", "unable_to_check_item": "", "unable_to_check_items": "", + "unable_to_complete_oauth_login": "OAuth-kirjautumista ei voitu suorittaa loppuun", + "unable_to_connect": "Yhteyttä ei voitu muodostaa", "unable_to_connect_to_server": "Palvelimeen ei saatu yhteyttä", + "unable_to_copy_to_clipboard": "Leikepöydälle ei voitu kopioida, varmista että käytät sivua https-yhteyden kautta", "unable_to_create_admin_account": "Pääkäyttäjän luominen epäonnistui", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", - "unable_to_delete_user": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", + "unable_to_create_api_key": "Uuden API-avaimen luominen epäonnistui", + "unable_to_create_library": "Kirjaston luominen epäonnistui", + "unable_to_create_user": "Käyttäjän luominen epäonnistui", + "unable_to_delete_album": "Albumin poistaminen epäonnistui", + "unable_to_delete_asset": "Kohteen poistaminen epäonnistui", + "unable_to_delete_assets": "Virhe kohteen poistamisessa", + "unable_to_delete_exclusion_pattern": "Ei voida poistaa poissulkuohjetta", + "unable_to_delete_import_path": "Tuontipolkua ei voitu poistaa", + "unable_to_delete_shared_link": "Jaetun linkin poistaminen epäonnistui", + "unable_to_delete_user": "Käyttäjän poistaminen epäonnistui", + "unable_to_download_files": "Tiedostojen lataaminen epäonnistui", + "unable_to_edit_exclusion_pattern": "Ei voida muokata poissulkuohjetta", + "unable_to_edit_import_path": "Tuontipolkua ei voitu muokata", + "unable_to_empty_trash": "Roskakorin tyhjentäminen epäonnistui", + "unable_to_enter_fullscreen": "Koko ruudun tilaan siirtyminen epäonnistui", + "unable_to_exit_fullscreen": "Koko ruudun tilasta poistuminen epäonnistui", + "unable_to_get_comments_number": "Kommenttien määrän hakeminen epäonnistui", + "unable_to_get_shared_link": "Jaetun linkin hakeminen epäonnistui", + "unable_to_hide_person": "Henkilön piilottaminen epäonnistui", + "unable_to_link_motion_video": "Liikekuvan linkitys epäonnistui", + "unable_to_link_oauth_account": "OAuth-tilin linkittäminen epäonnistui", + "unable_to_load_album": "Albumin lataaminen epäonnistui", + "unable_to_load_asset_activity": "Ei voitu ladata kohteen toimintaa", + "unable_to_load_items": "Kohteiden lataaminen epäonnistui", + "unable_to_load_liked_status": "Ei voitu ladata tykkäyksen tilaa", + "unable_to_log_out_all_devices": "Kaikkien laitteiden uloskirjautuminen epäonnistui", + "unable_to_log_out_device": "Laitteen uloskirjautuminen epäonnistui", + "unable_to_login_with_oauth": "OAuth-kirjautuminen epäonnistui", + "unable_to_play_video": "Videon toistaminen epäonnistui", + "unable_to_reassign_assets_existing_person": "Ei voida siirtää kohteita {name, select, null {olemassa olevalle henkilölle} other {{name}}}", + "unable_to_reassign_assets_new_person": "Ei voida siirtää kohteita uudelle henkilölle", + "unable_to_refresh_user": "Käyttäjän päivittäminen epäonnistui", + "unable_to_remove_album_users": "Käyttäjien poistaminen albumista epäonnistui", + "unable_to_remove_api_key": "API-avaimen poistaminen epäonnistui", + "unable_to_remove_assets_from_shared_link": "kohteiden poistaminen jaetusta linkistä epäonnistui", "unable_to_remove_comment": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_library": "Kirjaston poistaminen epäonnistui", + "unable_to_remove_offline_files": "Offline-tiedostojen poistaminen epäonnistui", + "unable_to_remove_partner": "Kumppanin poistaminen epäonnistui", + "unable_to_remove_reaction": "Reaktion poistaminen epäonnistui", "unable_to_remove_user": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", + "unable_to_repair_items": "Kohteiden korjaaminen epäonnistui", + "unable_to_reset_password": "Salasanan nollaaminen epäonnistui", + "unable_to_resolve_duplicate": "Virheilmoitus näkyy, kun palvelin palauttaa virheen painettaessa roskakorin tai säilytä-painiketta.", + "unable_to_restore_assets": "Kohteen palauttaminen epäonnistui", + "unable_to_restore_trash": "Kohteiden palauttaminen epäonnistui", + "unable_to_restore_user": "Käyttäjän palauttaminen epäonnistui", + "unable_to_save_album": "Albumin tallentaminen epäonnistui", + "unable_to_save_api_key": "API-avaimen tallentaminen epäonnistui", + "unable_to_save_date_of_birth": "Syntymäajan tallentaminen epäonnistui", + "unable_to_save_name": "Nimen tallentaminen epäonnistui", + "unable_to_save_profile": "Profiilin tallentaminen epäonnistui", + "unable_to_save_settings": "Asetusten tallentaminen epäonnistui", + "unable_to_scan_libraries": "Kirjastojen skannaaminen epäonnistui", + "unable_to_scan_library": "Kirjaston skannaaminen epäonnistui", + "unable_to_set_feature_photo": "Ei voida asettaa ominaiskuvaa", "unable_to_set_profile_picture": "Profiilikuvan asetus epäonnistui", "unable_to_submit_job": "Työtä ei voitu lähettää", "unable_to_trash_asset": "Median siirto roskakoriin epäonnistui", "unable_to_unlink_account": "Tunnuksen irroitus epäonnistui", + "unable_to_unlink_motion_video": "Ei voida irrottaa liikevideota", "unable_to_update_album_cover": "Albumin kannen päivitys epäonnistui", "unable_to_update_album_info": "Albumin tietojen päivitys epäonnistui", "unable_to_update_library": "Kirjaston päivitys epäonnistui", @@ -648,59 +729,82 @@ "expired": "Voimassaolo päättynyt", "expires_date": "Vanhenee {date}", "explore": "Tutki", + "explorer": "Tutkija", "export": "Vie", "export_as_json": "Vie JSON-muodossa", - "extension": "", - "external_libraries": "", + "extension": "Tiedostopääte", + "external": "Ulkoisesta", + "external_libraries": "Ulkoiset kirjastot", + "face_unassigned": "Ei määritelty", "failed_to_get_people": "", "favorite": "Suosikki", - "favorite_or_unfavorite_photo": "", + "favorite_or_unfavorite_photo": "Suosikki- tai ei-suosikkikuva", "favorites": "Suosikit", "feature": "", "feature_photo_updated": "Kansikuva ladattu", "featurecollection": "", - "file_name": "", - "file_name_or_extension": "", + "features": "Ominaisuudet", + "features_setting_description": "Hallitse sovelluksen ominaisuuksia", + "file_name": "Tiedoston nimi", + "file_name_or_extension": "Tiedostonimi tai tiedostopääte", "filename": "Tiedostonimi", "files": "", "filetype": "Tiedostotyyppi", - "filter_people": "", - "fix_incorrect_match": "", - "force_re-scan_library_files": "", + "filter_people": "Suodata henkilöt", + "find_them_fast": "Löydä nopeasti hakemalla nimellä", + "fix_incorrect_match": "Korjaa virheellinen osuma", + "folders": "Kansiot", + "folders_feature_description": "Käytetään kansionäkymää valokuvien ja videoiden selaamiseen järjestelmässä", + "force_re-scan_library_files": "Pakota kaikkien kirjastotiedostojen uudelleenskannaus", "forward": "Eteenpäin", - "general": "", - "get_help": "", - "getting_started": "", + "general": "Yleinen", + "get_help": "Hae apua", + "getting_started": "Aloittaminen", "go_back": "Palaa", - "go_to_search": "", + "go_to_search": "Siirry hakuun", "go_to_share_page": "", - "group_albums_by": "", + "group_albums_by": "Ryhmitä albumi...", "group_no": "Ei ryhmitystä", "group_owner": "Ryhmitä omistajan mukaan", "group_year": "Ryhmitä vuoden mukaan", - "has_quota": "", + "has_quota": "On kiintiö", "hi_user": "Hei {name} ({email})", - "hide_gallery": "", - "hide_password": "", - "hide_person": "", - "host": "", + "hide_all_people": "Piilota kaikki henkilöt", + "hide_gallery": "Piilota galleria", + "hide_named_person": "Piilota henkilön {name}", + "hide_password": "Piilota salasana", + "hide_person": "Piilota henkilö", + "hide_unnamed_people": "Piilota nimeämättömät henkilöt", + "host": "Isäntä", "hour": "Tunti", "image": "Kuva", + "image_alt_text_date": "{isVideo, select, true {Video} other {Kuva}} otettu {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Kuva}} otettu {person1} kanssa {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Kuva}} otettu {person1}n ja {person2}n kanssa {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Kuva}} otettu {person1}n, {person2}n ja {person3}n kanssa {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Kuva}} otettu {person1}n, {person2}n ja {additionalCount, number} muissa kanssa {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n kanssa {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n ja {person2}n kanssa {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n, {person2}n ja {person3}n kanssa {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n, {person2}n ja {additionalCount, number} muun kanssa {date}", "img": "", - "immich_logo": "", - "import_path": "", + "immich_logo": "Immich Logo", + "immich_web_interface": "Immich verkkoliittymä", + "import_from_json": "Tuo JSON-tiedostosta", + "import_path": "Tuontipolku", "in_albums": "{count, plural, one {# Albumissa} other {# albumissa}}", "in_archive": "Arkistossa", "include_archived": "Sisällytä arkistoidut", - "include_shared_albums": "", - "include_shared_partner_assets": "", - "individual_share": "", + "include_shared_albums": "Sisällytä jaetut albumit", + "include_shared_partner_assets": "Sisällytä jaetut kumppanikohteet", + "individual_share": "Yksittäinen jako", "info": "Lisätietoja", "interval": { - "day_at_onepm": "", - "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" + "day_at_onepm": "Joka päivä klo 13:00", + "hours": "Joka {hours, plural, one {tunti} other {{hours, number} tuntia}}", + "night_at_midnight": "Joka yö keskiyöllä", + "night_at_twoam": "Joka yö klo 02:00" }, "invite_people": "Kutsu ihmisiä", "invite_to_album": "Kutsu albumiin", @@ -714,47 +818,58 @@ "language_setting_description": "Valitse suosimasi kieli", "last_seen": "Viimeksi nähty", "latest_version": "Viimeisin versio", + "latitude": "Leveysaste", "leave": "Lähde", "let_others_respond": "Anna muiden vastata", "level": "Taso", "library": "Kirjasto", - "library_options": "", + "library_options": "Kirjastovaihtoehdot", "license_button_buy": "Osta", "license_button_select": "Valitse", "light": "Vaalea", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", + "like_deleted": "Tykkäys poistettu", + "link_motion_video": "Linkitä liikevideo", + "link_options": "Linkin asetukset", + "link_to_oauth": "Linkki OAuth", + "linked_oauth_account": "Linkitetty OAuth-tili", "list": "Lista", "loading": "Ladataan", - "loading_search_results_failed": "", + "loading_search_results_failed": "Hakutulosten lataaminen epäonnistui", "log_out": "Kirjaudu ulos", "log_out_all_devices": "Kirjaudu ulos kaikilta laitteilta", + "logged_out_all_devices": "Kaikki laitteet kirjattu ulos", + "logged_out_device": "Laite kirjattu ulos", "login": "Kirjaudu", "login_has_been_disabled": "Kirjautuminen on otettu pois käytöstä.", "logout_all_device_confirmation": "Haluatko varmasti kirjautua ulos kaikilta laitteilta?", "logout_this_device_confirmation": "Haluatko varmasti kirjautua ulos näiltä laitteilta?", + "longitude": "Pituusaste", "look": "Tyyli", - "loop_videos": "", - "loop_videos_description": "", + "loop_videos": "Toista videot uudelleen", + "loop_videos_description": "Ota käyttöön videon automaattinen toisto tarkemmassa näkymässä.", "make": "Valmistaja", "manage_shared_links": "Hallitse jaettuja linkkejä", - "manage_sharing_with_partners": "", + "manage_sharing_with_partners": "Hallitse jakamista kumppaneille", "manage_the_app_settings": "Hallitse sovelluksen asetuksia", "manage_your_account": "Hallitse tiliäsi", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", + "manage_your_api_keys": "Hallitse API-avaimiasi", + "manage_your_devices": "Hallitse sisäänkirjautuneita laitteitasi", + "manage_your_oauth_connection": "Hallitse OAuth-yhteyttäsi", "map": "Kartta", - "map_marker_with_image": "", + "map_marker_for_images": "Karttamarkerointi kuville, jotka on otettu {city}ssä, {country}ssä", + "map_marker_with_image": "Karttamarkerointi kuvalla", "map_settings": "Kartta-asetukset", + "matches": "Osumia", "media_type": "Median tyyppi", - "memories": "", - "memories_setting_description": "", + "memories": "Muistoja", + "memories_setting_description": "Hallitse mitä näet muistoissasi", "memory": "Muisto", + "memory_lane_title": "Muistojen polku {title}", "menu": "Valikko", "merge": "Yhdistä", "merge_people": "Yhdistä henkilöt", + "merge_people_limit": "Voit yhdistää vain enintään 5 kasvoa kerrallaan", + "merge_people_prompt": "Haluatko yhdistää nämä henkilöt? Tätä valintaa ei voi peruuttaa.", "merge_people_successfully": "Henkilöt yhdistetty", "merged_people_count": "{count, plural, one {# Henkilö} other {# henkilöä}} yhdistetty", "minimize": "PIenennä", @@ -768,6 +883,7 @@ "name": "Nimi", "name_or_nickname": "Nimi tai lempinimi", "never": "ei koskaan", + "new_album": "Uusi Albumi", "new_api_key": "Uusi API Key", "new_password": "Uusi salasana", "new_person": "Uusi henkilö", @@ -780,42 +896,55 @@ "no_albums_message": "Luo albumi pitääksesi kuvat ja videot järjestyksessä", "no_albums_with_name_yet": "Näyttää siltä, ettei sinulla ole yhtään tämän nimistä albumia.", "no_albums_yet": "Näyttää siltä, ettei sinulla ole vielä yhtään albumia.", - "no_archived_assets_message": "", + "no_archived_assets_message": "Arkistoi kuvia ja videoita piilottaaksesi ne kuvat näkymästä", "no_assets_message": "NAPAUTA LATAAKSESI ENSIMMÄISEN KUVASI", + "no_duplicates_found": "Kaksoiskappaleita ei löytynyt.", "no_exif_info_available": "EXIF-tietoa ei saatavilla", - "no_explore_results_message": "", + "no_explore_results_message": "Lataa lisää kuvia tutkiaksesi kokoelmaasi.", "no_favorites_message": "Lisää suosikkeja löytääksesi nopeasti parhaat kuvasi ja videosi", - "no_libraries_message": "", + "no_libraries_message": "Luo ulkoinen kirjasto nähdäksesi valokuvasi ja videot", "no_name": "Ei nimeä", - "no_places": "", + "no_places": "Ei paikkoja", "no_results": "Ei tuloksia", + "no_results_description": "Kokeile synonyymiä tai yleisempää avainsanaa", "no_shared_albums_message": "Luo albumi, jotta voit jakaa kuvia ja videoita toisille", "not_in_any_album": "Ei yhdessäkään albumissa", + "note_apply_storage_label_to_previously_uploaded assets": "Huom: Jotta voit soveltaa tallennustunnistetta aiemmin ladattuihin kohteisiin, suorita", + "note_unlimited_quota": "Huomio: Syötä 0 rajoittamatonta kiintiötä varten", "notes": "Muistiinpanot", "notification_toggle_setting_description": "Ota sähköpostilmoitukset käyttöön", "notifications": "Ilmoitukset", "notifications_setting_description": "Hallitse ilmoituksia", "oauth": "OAuth", - "offline": "", + "offline": "Offline", + "offline_paths": "Offline-polut", + "offline_paths_description": "Nämä tulokset voivat johtua tiedostojen manuaalisesta poistamisesta, jotka eivät ole osa ulkoista kirjastoa.", "ok": "Ok", "oldest_first": "Vanhin ensin", + "onboarding": "Käyttöönotto", + "onboarding_privacy_description": "Seuraavat (valinnaiset) ominaisuudet perustuvat ulkoisiin palveluihin, ja ne voidaan poistaa käytöstä milloin tahansa hallinta asetuksista.", + "onboarding_theme_description": "Valitse väriteema istunnollesi. Voit muuttaa tämän myöhemmin asetuksistasi.", + "onboarding_welcome_description": "Aloitetaa laittamalla istuntoosi joitakin yleisiä asetuksia.", "onboarding_welcome_user": "Tervetuloa {user}", "online": "Online", "only_favorites": "Vain suosikit", - "only_refreshes_modified_files": "", + "only_refreshes_modified_files": "Päivittää vain muakatut tiedostot", + "open_in_map_view": "Avaa karttanäkymässä", "open_in_openstreetmap": "Avaa OpenStreetMapissa", - "open_the_search_filters": "", + "open_the_search_filters": "Avaa hakusuodattimet", "options": "Vaihtoehdot", "or": "tai", "organize_your_library": "Järjestele kirjastosi", "original": "alkuperäinen", "other": "Muut", "other_devices": "Toiset laitteet", - "other_variables": "", + "other_variables": "Muut muuttujat", "owned": "Omistettu", "owner": "Omistaja", "partner": "Kumppani", "partner_can_access": "{partner} voi päästä", + "partner_can_access_assets": "Kaikki valokuvasi ja videosi, lukuun ottamatta arkistoituja ja poistettuja", + "partner_can_access_location": "Sijainti, jossa kuvasi on otettu", "partner_sharing": "Kumppanijako", "partners": "Kumppanit", "password": "Salasana", @@ -823,22 +952,26 @@ "password_required": "Salasana vaaditaan", "password_reset_success": "Salasanan nollaus onnistui", "past_durations": { - "days": "{years, plural, one {Viimeisin päivä} other {Viimeiset # päivää}}", - "hours": "{years, plural, one {Viimeisin tunti} other {Viimeiset # tuntia}}", + "days": "Viime {days, plural, one {päivä} other {# päivää}}", + "hours": "Viime {hours, plural, one {tunti} other {# tuntia}}", "years": "{years, plural, one {Viimeisin vuosi} other {Viimeiset # vuotta}}" }, "path": "Polku", - "pattern": "", + "pattern": "Kaava", "pause": "Tauko", - "pause_memories": "", + "pause_memories": "Pysäytä muistot", "paused": "Tauotettu", "pending": "Odottaa", "people": "Ihmiset", - "people_sidebar_description": "", + "people_edits_count": "Muokattu {count, plural, one {# henkilö} other {# henkilöä}}", + "people_feature_description": "Selataan valokuvia ja videoita, jotka on ryhmitelty henkilöiden mukaan", + "people_sidebar_description": "Näytä linkki Henkilöihin sivupalkissa", "perform_library_tasks": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", + "permanent_deletion_warning": "Pysyvän poiston varoitus", + "permanent_deletion_warning_setting_description": "Näytä varoitus, kun poistat kohteita pysyvästi", "permanently_delete": "Poista pysyvästi", + "permanently_delete_assets_count": "Poista pysyvästi {count, plural, one {kohde} other {kohteita}}", + "permanently_delete_assets_prompt": "Oletko varma, että haluat poistaa pysyvästi {count, plural, one {tämän kohteen?} other {nämä # kohteet?}} Tämä poistaa myös {count, plural, one {sen sen} other {ne niiden}} albumista.", "permanently_deleted_asset": "Media poistettu pysyvästi", "permanently_deleted_assets_count": "{count, plural, one {# media} other {# mediaa}} poistettu pysyvästi", "person": "Henkilö", @@ -853,7 +986,7 @@ "places": "Paikat", "play": "Toista", "play_memories": "Toista muistot", - "play_motion_photo": "", + "play_motion_photo": "Toista Liikekuva", "play_or_pause_video": "Toista tai keskeytä video", "point": "", "port": "Portti", @@ -863,15 +996,53 @@ "previous_memory": "Edellinen muisto", "previous_or_next_photo": "Edellinen tai seuraava kuva", "primary": "Ensisijainen", + "privacy": "Yksityisyys", "profile_image_of_user": "Käyttäjän {user} profiilikuva", "profile_picture_set": "Profiilikuva asetettu.", "public_album": "Julkinen albumi", "public_share": "Julkinen jako", + "purchase_account_info": "Tukija", + "purchase_activated_subtitle": "Kiitos Immichin ja avoimen lähdekoodin ohjelmiston tukemisesta", + "purchase_activated_time": "Aktivoitu {date, date}", + "purchase_activated_title": "Avaimesi on aktivoitu onnistuneesti", + "purchase_button_activate": "Aktivoi", + "purchase_button_buy": "Osta", + "purchase_button_buy_immich": "Osta Immich", + "purchase_button_never_show_again": "Älä näytä koskaan uudelleen", + "purchase_button_reminder": "Muistuta minua 30 päivän kuluessa", + "purchase_button_remove_key": "Poista avain", + "purchase_button_select": "Valitse", + "purchase_failed_activation": "Aktivointi epäonnistui! Tarkista sähköpostisi oikean tuoteavaimen varalta!", + "purchase_individual_description_1": "Yksittäiselle henkilölle", + "purchase_individual_description_2": "Tukijan tila", + "purchase_individual_title": "Yksittäinen", + "purchase_input_suggestion": "Onko sinulla tuoteavain? Syötä avain alle", + "purchase_license_subtitle": "Osta Immich tukeaksesi palvelun jatkuvaa kehittämistä", + "purchase_lifetime_description": "Elinikäinen osto", + "purchase_option_title": "OSTOVAIHTOEHDOT", + "purchase_panel_info_1": "Immichin rakentaminen vie paljon aikaa ja vaivannäköä, ja meillä on kokopäiväisiä insinöörejä työskentelemässä sen parissa, jotta voimme tehdä siitä mahdollisimman hyvän. Missiomme on, että avoimen lähdekoodin ohjelmistosta ja eettisistä liiketoimintakäytännöistä tulee kestävä tulonlähde kehittäjille, sekä luoda yksityisyyttä kunnioittava ekosysteemi, jossa on todellisia vaihtoehtoja hyväksikäyttöön perustuville pilvipalveluille.", + "purchase_panel_info_2": "Koska olemme sitoutuneet siihen, ettemme lisää maksumuuria, tämä osto ei anna sinulle mitään lisäominaisuuksia Immichissa. Luotamme kaltaisiisi käyttäjiin tukeaksemme Immichin jatkuvaa kehittämistä.", + "purchase_panel_title": "Tue projektia", + "purchase_per_server": "Per serveri", + "purchase_per_user": "Per käyttäjä", + "purchase_remove_product_key": "Poista Tuoteavain", + "purchase_remove_product_key_prompt": "Haluatko varmasti poistaa tuoteavaimen?", + "purchase_remove_server_product_key": "Poista palvelimen tuoteavain", + "purchase_remove_server_product_key_prompt": "Haluatko varmasti poistaa palvelimen tuoteavaimen?", + "purchase_server_description_1": "Koko palvelimelle", + "purchase_server_description_2": "Tukijan tila", + "purchase_server_title": "Serveri", + "purchase_settings_server_activated": "Palvelimen tuoteavainta hallinnoi ylläpitäjä", "range": "", + "rating": "Tähtiarvostelu", + "rating_clear": "Tyhjennä arvostelu", + "rating_count": "{count, plural, one {# tähti} other {# tähteä}}", + "rating_description": "Näytä EXIF-arvosana tiedot-paneelissa", "raw": "", - "reaction_options": "", + "reaction_options": "Reaktioasetukset", "read_changelog": "Lue muutosloki", "reassign": "Määritä uudelleen", + "reassigned_assets_to_existing_person": "Uudelleen määritetty {count, plural, one {# kohde} other {# kohdetta}} {name, select, null {olemassa olevalle henkilölle} other {{name}}}", "reassigned_assets_to_new_person": "Määritetty {count, plural, one {# media} other {# mediaa}} uudelle henkilölle", "reassing_hint": "Määritä valitut mediat käyttäjälle", "recent": "Viimeisin", @@ -899,9 +1070,10 @@ "removed_from_archive": "Poistettu arkistosta", "removed_from_favorites": "Poistettu suosikeista", "removed_from_favorites_count": "{count, plural, other {Poistettu #}} suosikeista", + "removed_tagged_assets": "Poistettu tunniste {count, plural, one {# kohteesta} other {# kohteesta}}", "rename": "Nimeä uudelleen", "repair": "Korjaa", - "repair_no_results_message": "", + "repair_no_results_message": "Seuraamattomat ja puuttuvat tiedostot näkyvät täällä", "replace_with_upload": "Korvaa tiedostolla", "repository": "Tietovarasto", "require_password": "Vaadi salasana", @@ -911,6 +1083,7 @@ "reset_people_visibility": "Nollaa henkilöiden näkyvyysasetukset", "reset_settings_to_default": "", "reset_to_default": "Palauta oletusasetukset", + "resolve_duplicates": "Ratkaise kaksoiskappaleet", "resolved_all_duplicates": "Kaikki kaksoiskappaleet selvitetty", "restore": "Palauta", "restore_all": "Palauta kaikki", @@ -920,7 +1093,7 @@ "retry_upload": "Yritä latausta uudelleen", "review_duplicates": "Tarkastele kaksoiskappaleita", "role": "Rooli", - "role_editor": "Muokkain", + "role_editor": "Editori", "role_viewer": "Toistin", "save": "Tallenna", "saved_api_key": "API Key tallennettu", @@ -935,6 +1108,8 @@ "search": "Haku", "search_albums": "Etsi albumeita", "search_by_context": "Etsi kontekstin perusteella", + "search_by_filename": "Hae tiedostonimen tai -päätteen mukaan", + "search_by_filename_example": "esim. IMG_1234.JPG tai PNG", "search_camera_make": "Etsi kameramerkkiä...", "search_camera_model": "Etsi kameramallia...", "search_city": "Etsi kaupunkia...", @@ -942,9 +1117,12 @@ "search_for_existing_person": "Etsi olemassa olevaa henkilöä", "search_no_people": "Ei henkilöitä", "search_no_people_named": "Ei \"{name}\" nimisiä henkilöitä", + "search_options": "Hakuvaihtoehdot", "search_people": "Etsi ihmisiä", "search_places": "Etsi paikkoja", + "search_settings": "Hakuasetukset", "search_state": "Etsi tilaa...", + "search_tags": "Haku tageja...", "search_timezone": "Etsi aikavyöhyke...", "search_type": "Etsinnän tyyppi", "search_your_photos": "Etsi kuvia", @@ -953,6 +1131,7 @@ "see_all_people": "Näytä kaikki henkilöt", "select_album_cover": "Valitse albmin kansi", "select_all": "Valitse kaikki", + "select_all_duplicates": "Valitse kaikki kaksoiskappaleet", "select_avatar_color": "Valitse avatarin väri", "select_face": "Valitse kasvo", "select_featured_photo": "Valitse esittelykuva", @@ -967,6 +1146,7 @@ "send_message": "Lähetä viesti", "send_welcome_email": "Lähetä tervetuloviesti", "server": "Palvelin", + "server_offline": "Serveri Offline-tilassa", "server_online": "Palvelin on linjalla", "server_stats": "Palvelimen tilastot", "server_version": "Palvelimen versio", @@ -984,6 +1164,7 @@ "shared_by_user": "Käyttäjän {user} jakama", "shared_by_you": "Sinun jakamasi", "shared_from_partner": "{partner}n kuvia", + "shared_link_options": "Jaetun linkin vaihtoehdot", "shared_links": "Jaetut linkit", "shared_photos_and_videos_count": "{assetCount, plural, other {# jaettua kuvaa ja videota.}}", "shared_with_partner": "Jaa {partner} kanssa", @@ -992,6 +1173,7 @@ "sharing_sidebar_description": "Näytä jakamislinkki sivupalkissa", "shift_to_permanent_delete": "Paina ⇧ poistaaksesi median pysyvästi", "show_album_options": "Näytä albumin asetukset", + "show_albums": "Näytä albumit", "show_all_people": "Näytä kaikki henkilöt", "show_and_hide_people": "Näytä / piilota henkilöitä", "show_file_location": "Näytä tiedostosijainti", @@ -1006,11 +1188,17 @@ "show_person_options": "Näytä henkilöasetukset", "show_progress_bar": "Näytä eteneminen", "show_search_options": "Näytä hakuvaihtoehdot", + "show_supporter_badge": "Kannattajan merkki", + "show_supporter_badge_description": "Näytä kannattajan merkki", "shuffle": "Sekoita", + "sidebar": "Sivupalkki", + "sidebar_display_description": "Näytä linkki näkymään sivupalkissa", "sign_out": "Kirjaudu ulos", "sign_up": "Rekisteröidy", "size": "Koko", "skip_to_content": "Siirry sisältöön", + "skip_to_folders": "Siirry kansioihin", + "skip_to_tags": "Siirry tageihin", "slideshow": "Diaesitys", "slideshow_settings": "Diaesityksen asetukset", "sort_albums_by": "Järjestä albumit...", @@ -1022,6 +1210,8 @@ "sort_title": "Otsikko", "source": "Lähde", "stack": "Pinoa", + "stack_duplicates": "Pinoa kaksoiskappaleet", + "stack_select_one_photo": "Valitse yksi pääkuva pinolle", "stack_selected_photos": "Pinoa valitut kuvat", "stacked_assets_count": "Pinottu {count, plural, one {# media} other {# mediaa}}", "stacktrace": "Vianetsintätiedot", @@ -1041,6 +1231,14 @@ "sunrise_on_the_beach": "Auringonnousu rannalla", "swap_merge_direction": "Käännä yhdistämissuunta", "sync": "Synkronoi", + "tag": "Tagi", + "tag_assets": "Merkitse kohde", + "tag_created": "Luotu tunniste: {tag}", + "tag_feature_description": "Selaa valokuvia ja videoita, jotka on ryhmitelty loogisten tagiotsikoiden mukaan", + "tag_not_found_question": "Etkö löydä tunnistetta? Luo yksi tästä", + "tag_updated": "Päivitetty tunniste: {tag}", + "tagged_assets": "Tunnistettu {count, plural, one {# kohde} other {# kohdetta}}", + "tags": "Tagit", "template": "Template", "theme": "Teema", "theme_selection": "Teeman valinta", @@ -1052,14 +1250,15 @@ "to_change_password": "Vaihda salasana", "to_favorite": "Aseta suosikiksi", "to_login": "Kirjaudu sisään", + "to_parent": "Siirry vanhempaan", "to_trash": "Roskakoriin", "toggle_settings": "Määritä asetukset", - "toggle_theme": "Aseta teema", + "toggle_theme": "Aseta tumma teema", "toggle_visibility": "Aseta näkyvyys", "total_usage": "Käyttö yhteensä", "trash": "Roskakori", "trash_all": "Vie kaikki roskakoriin", - "trash_count": "Vie {count} roskakoriin", + "trash_count": "Roskakori {count, number}", "trash_delete_asset": "Poista / vie roskakoriin", "trash_no_results_message": "Roskakorissa olevat kuvat ja videot näytetään täällä.", "trashed_items_will_be_permanently_deleted_after": "Roskakorin kohteet poistetaan pysyvästi {days, plural, one {# päivän} other {# päivän}} päästä.", @@ -1073,13 +1272,17 @@ "unknown_album": "", "unknown_year": "Tuntematon vuosi", "unlimited": "Rajoittamaton", + "unlink_motion_video": "Poista liikevideon linkitys", "unlink_oauth": "Poista OAuth-linkitys", "unlinked_oauth_account": "Linkittämätön OAuth-tili", "unnamed_album": "Nimetön albumi", + "unnamed_album_delete_confirmation": "Haluatko varmasti poistaa tämän albumin?", "unnamed_share": "Nimetön jako", "unsaved_change": "Tallentamaton muutos", "unselect_all": "Poista valinnat", + "unselect_all_duplicates": "Poista kaikkien kaksoiskappaleiden valinta", "unstack": "Pura pino", + "unstacked_assets_count": "Poistettu pinosta {count, plural, one {# kohde} other {# kohdetta}}", "untracked_files": "Tiedostot joita ei seurata", "untracked_files_decription": "Järjestelmä ei seuraa näitä tiedostoja. Ne voivat johtua epäonnistuneista siirroista, keskeytyneistä latauksista, tai ovat jääneet ohjelmavian seurauksena", "up_next": "Seuraavaksi", @@ -1087,7 +1290,7 @@ "upload": "Siirrä palvelimelle", "upload_concurrency": "Latausten samanaikaisuus", "upload_errors": "Lataus valmistui {count, plural, one {# virheen} other {# virheen}} kanssa. Päivitä sivu nähdäksesi ladatut tiedot.", - "upload_progress": "{remaining} jäljellä - {processed}/{total} käsitelty", + "upload_progress": "Jäljellä {remaining, number} - Käsitelty {processed, number}/{total, number}", "upload_skipped_duplicates": "Ohitettiin {count, plural, one {# kaksoiskappale} other {# kaksoiskappaletta}}", "upload_status_duplicates": "Kaksoiskappaleet", "upload_status_errors": "Virheet", @@ -1099,6 +1302,8 @@ "user": "Käyttäjä", "user_id": "Käyttäjän ID", "user_liked": "{user} tykkäsi {type, select, photo {kuvasta} video {videosta} asset {mediasta} other {tästä}}", + "user_purchase_settings": "Osta", + "user_purchase_settings_description": "Hallitse ostostasi", "user_role_set": "Tee käyttäjästä {user} {role}", "user_usage_detail": "Käyttäjän käytön tiedot", "username": "Käyttäjänimi", diff --git a/web/src/lib/i18n/fr.json b/web/src/lib/i18n/fr.json index 9628573b0d..b86251e039 100644 --- a/web/src/lib/i18n/fr.json +++ b/web/src/lib/i18n/fr.json @@ -41,6 +41,7 @@ "confirm_email_below": "Pour confirmer, tapez « {email} » ci-dessous", "confirm_reprocess_all_faces": "Êtes-vous sûr de vouloir retraiter tous les visages ? Cela effacera également les personnes déjà identifiées.", "confirm_user_password_reset": "Êtes-vous sûr de vouloir réinitialiser le mot de passe de {user} ?", + "create_job": "Créer une tâche", "crontab_guru": "Générateur de règles Cron", "disable_login": "Désactiver la connexion", "disabled": "Désactivé", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Résolution des miniatures", "image_thumbnail_resolution_description": "Utilisée lors du visionnage de groupes de photos (vue principale, albums, etc.). Une résolution plus élevée préserve davantage de détails, mais est plus longue à encoder, produit des fichiers plus lourds, et peut réduire la réactivité de l'application.", "job_concurrency": "{job} : nombre de tâches simultanées", + "job_created": "Tâche créée", "job_not_concurrency_safe": "Cette tâche ne peut pas être exécutée en multitâche de façon sûre.", "job_settings": "Paramètres des tâches", "job_settings_description": "Gestion des tâches simultanées", @@ -152,7 +154,7 @@ "note_cannot_be_changed_later": "REMARQUE : Il n'est pas possible de modifier ce paramètre ultérieurement !", "note_unlimited_quota": "Note : saisir 0 pour un quota illimité", "notification_email_from_address": "Depuis l'adresse", - "notification_email_from_address_description": "Adresse courriel de l'expéditeur, par exemple : « Serveur de photos Immich  »", + "notification_email_from_address_description": "Adresse courriel de l'expéditeur, par exemple : « Serveur de photos Immich  »", "notification_email_host_description": "Hôte du serveur de messagerie électronique (par exemple, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorer les erreurs de certificat", "notification_email_ignore_certificate_errors_description": "Ignorer les erreurs de validation du certificat TLS (non recommandé)", @@ -198,6 +200,7 @@ "password_settings": "Connexion par mot de passe", "password_settings_description": "Gérer les paramètres de connexion par mot de passe", "paths_validated_successfully": "Tous les chemins ont été validés avec succès", + "person_cleanup_job": "Nettoyage des personnes", "quota_size_gib": "Taille du quota (Go)", "refreshing_all_libraries": "Actualisation de toutes les bibliothèques", "registration": "Enregistrement de l'administrateur", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Paramètres réinitialisés avec les derniers paramètres enregistrés", "scanning_library_for_changed_files": "Recherche de fichiers modifiés dans la bibliothèque", "scanning_library_for_new_files": "Recherche de nouveaux fichiers dans la bibliothèque", + "search_jobs": "Recherche des tâches ...", "send_welcome_email": "Envoyer un courriel de bienvenue", "server_external_domain_settings": "Domaine externe", "server_external_domain_settings_description": "Nom de domaine pour les liens partagés publics, y compris http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Gérer la structure des dossiers et le nom des fichiers du média envoyé", "storage_template_user_label": "{label} est l'étiquette de stockage de l'utilisateur", "system_settings": "Paramètres du système", + "tag_cleanup_job": "Nettoyage des étiquettes", "theme_custom_css_settings": "CSS personnalisé", "theme_custom_css_settings_description": "Les feuilles de style en cascade (CSS) permettent de personnaliser l'apparence d'Immich.", "theme_settings": "Paramètres du thème", @@ -312,6 +317,7 @@ "trash_settings_description": "Gérer les paramètres de la corbeille", "untracked_files": "Fichiers non suivis", "untracked_files_description": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat d'erreurs de déplacement, d'envois interrompus, ou d'abandons en raison d'un bug", + "user_cleanup_job": "Nettoyage des utilisateurs", "user_delete_delay": "La suppression définitive du compte et des médias de {user} sera programmée dans {delay, plural, one {# jour} other {# jours}}.", "user_delete_delay_settings": "Délai de suppression", "user_delete_delay_settings_description": "Nombre de jours après la validation pour supprimer définitivement le compte et les médias d'un utilisateur. La suppression des utilisateurs se lance à minuit. Les modifications apportées à ce paramètre seront pris en compte lors de la prochaine exécution.", @@ -488,8 +494,8 @@ "create_new_person": "Créer une nouvelle personne", "create_new_person_hint": "Attribuer les médias sélectionnés à une nouvelle personne", "create_new_user": "Créer un nouvel utilisateur", - "create_tag": "Créer un tag", - "create_tag_description": "Créer un nouveau tag. Pour les tags imbriqués, veuillez entrer le chemin complet du tag, y compris les \"/\" avant.", + "create_tag": "Créer une étiquette", + "create_tag_description": "Créer une nouvelle étiquette. Pour les étiquettes imbriquées, veuillez entrer le chemin complet de l'étiquette, y compris les caractères \"/\".", "create_user": "Créer un utilisateur", "created": "Créé", "current_device": "Appareil actuel", @@ -513,8 +519,8 @@ "delete_library": "Supprimer la bibliothèque", "delete_link": "Supprimer le lien", "delete_shared_link": "Supprimer le lien partagé", - "delete_tag": "Supprimer le tag", - "delete_tag_confirmation_prompt": "Êtes-vous sûr de vouloir supprimer le tag {tagName} ?", + "delete_tag": "Supprimer l'étiquette", + "delete_tag_confirmation_prompt": "Êtes-vous sûr de vouloir supprimer l'étiquette {tagName} ?", "delete_user": "Supprimer l'utilisateur", "deleted_shared_link": "Lien partagé supprimé", "description": "Description", @@ -563,7 +569,7 @@ "edit_location": "Modifier la localisation", "edit_name": "Modifier le nom", "edit_people": "Modifier les personnes", - "edit_tag": "Modifier le tag", + "edit_tag": "Modifier l'étiquette", "edit_title": "Modifier le title", "edit_user": "Modifier l'utilisateur", "edited": "Modifié", @@ -1141,8 +1147,9 @@ "search_options": "Rechercher une option", "search_people": "Rechercher une personne", "search_places": "Rechercher un lieu", + "search_settings": "Paramètres de recherche", "search_state": "Rechercher par état/région...", - "search_tags": "Recherche de tags...", + "search_tags": "Recherche d'étiquettes...", "search_timezone": "Rechercher par fuseau horaire...", "search_type": "Rechercher par type", "search_your_photos": "Rechercher vos photos", @@ -1218,7 +1225,7 @@ "size": "Taille", "skip_to_content": "Passer", "skip_to_folders": "Passer vers les dossiers", - "skip_to_tags": "Passer vers les tags", + "skip_to_tags": "Passer vers les étiquettes", "slideshow": "Diaporama", "slideshow_settings": "Paramètres du diaporama", "sort_albums_by": "Trier les albums par...", @@ -1253,12 +1260,12 @@ "sync": "Synchroniser", "tag": "Tag", "tag_assets": "Taguer les médias", - "tag_created": "Tag créé : {tag}", + "tag_created": "Étiquette créée : {tag}", "tag_feature_description": "Parcourir les photos et vidéos groupées par thèmes logiques", - "tag_not_found_question": "Vous ne trouvez pas un tag ? Créez-en un ici", - "tag_updated": "Tag mis à jour : {tag}", + "tag_not_found_question": "Vous ne trouvez pas une étiquette ? Créez-en une ici", + "tag_updated": "Étiquette mise à jour : {tag}", "tagged_assets": "Tag ajouté à {count, plural, one {# média} other {# médias}}", - "tags": "Tags", + "tags": "Étiquettes", "template": "Modèle", "theme": "Thème", "theme_selection": "Sélection du thème", diff --git a/web/src/lib/i18n/he.json b/web/src/lib/i18n/he.json index 05eab7a804..62c30461a5 100644 --- a/web/src/lib/i18n/he.json +++ b/web/src/lib/i18n/he.json @@ -41,6 +41,7 @@ "confirm_email_below": "כדי לאשר, יש להקליד \"{email}\" למטה", "confirm_reprocess_all_faces": "האם את/ה בטוח/ה שברצונך לעבד מחדש את כל הפנים? זה גם ינקה אנשים בעלי שם.", "confirm_user_password_reset": "האם את/ה בטוח/ה שברצונך לאפס את הסיסמה של המשתמש {user}?", + "create_job": "צור עבודה", "crontab_guru": "Crontab Guru", "disable_login": "השבת כניסה", "disabled": "מושבת", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "רזולוציית תמונה ממוזערת", "image_thumbnail_resolution_description": "משמש בעת צפייה בקבוצות של תמונות (ציר זמן ראשי, תצוגת אלבום וכו'). רזולוציות גבוהות יותר יכולות לשמר פירוט רב יותר אך לוקחות יותר זמן לקידוד, יש להן גדלי קבצים גדולים יותר, ויכולות להפחית את תגובתיות היישום.", "job_concurrency": "בו-זמניות של {job}", + "job_created": "עבודה נוצרה", "job_not_concurrency_safe": "משימה זו אינה בטוחה במקביל.", "job_settings": "הגדרות משימה", "job_settings_description": "ניהול בו-זמניות של משימה", @@ -198,6 +200,7 @@ "password_settings": "סיסמת התחברות", "password_settings_description": "נהל הגדרות סיסמת התחברות", "paths_validated_successfully": "כל הנתיבים אומתו בהצלחה", + "person_cleanup_job": "ניקוי אדם", "quota_size_gib": "גודל מכסה (GiB)", "refreshing_all_libraries": "מרענן את כל הספריות", "registration": "רישום מנהל מערכת", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "אפס הגדרות להגדרות שנשמרו לאחרונה", "scanning_library_for_changed_files": "סורק ספרייה לאיתור קבצים שהשתנו", "scanning_library_for_new_files": "סורק ספרייה לאיתור קבצים חדשים", + "search_jobs": "חיפוש עבודות...", "send_welcome_email": "שלח דוא\"ל ברוכים הבאים", "server_external_domain_settings": "דומיין חיצוני", "server_external_domain_settings_description": "דומיין עבור קישורים משותפים ציבוריים, כולל http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "נהל את מבנה התיקיות ואת שם הקובץ של נכס ההעלאה", "storage_template_user_label": "{label} היא תווית האחסון של המשתמש", "system_settings": "הגדרות מערכת", + "tag_cleanup_job": "ניקוי תגים", "theme_custom_css_settings": "CSS בהתאמה אישית", "theme_custom_css_settings_description": "גיליונות סגנון מדורגים (CSS) מאפשרים התאמה אישית של העיצוב של Immich.", "theme_settings": "הגדרות ערכת נושא", @@ -312,6 +317,7 @@ "trash_settings_description": "נהל את הגדרות האשפה", "untracked_files": "קבצים ללא מעקב", "untracked_files_description": "קבצים אלה אינם נמצאים במעקב של היישום. הם יכולים להיות תוצאות של העברות כושלות, העלאות שנקטעו, או שנותרו מאחור בגלל שיבוש בתוכנה", + "user_cleanup_job": "ניקוי משתמשים", "user_delete_delay": "החשבון והנכסים של {user} יתוזמנו למחיקה לצמיתות בעוד {delay, plural, one {יום #} other {# ימים}}.", "user_delete_delay_settings": "עיכוב מחיקה", "user_delete_delay_settings_description": "מספר הימים לאחר ההסרה עד מחיקה לצמיתות של החשבון והנכסים של המשתמש. משימת מחיקת המשתמש פועלת בחצות כדי לבדוק אם יש משתמשים שמוכנים למחיקה. שינויים בהגדרה זו יוערכו בביצוע הבא.", @@ -1141,6 +1147,7 @@ "search_options": "אפשרויות חיפוש", "search_people": "חפש אנשים", "search_places": "חפש מקומות", + "search_settings": "הגדרות חיפוש", "search_state": "חפש מדינה...", "search_tags": "חיפוש תגים...", "search_timezone": "חפש אזור זמן...", diff --git a/web/src/lib/i18n/hr.json b/web/src/lib/i18n/hr.json index 954eeff202..4247de3c42 100644 --- a/web/src/lib/i18n/hr.json +++ b/web/src/lib/i18n/hr.json @@ -41,6 +41,7 @@ "confirm_email_below": "Za potvrdu upišite \"{email}\" ispod", "confirm_reprocess_all_faces": "Jeste li sigurni da želite ponovno obraditi sva lica? Ovo će također obrisati imenovane osobe.", "confirm_user_password_reset": "Jeste li sigurni da želite poništiti lozinku korisnika {user}?", + "create_job": "Izradi zadatak", "crontab_guru": "Crontab Guru", "disable_login": "Onemogući prijavu", "duplicate_detection_job_description": "Pokrenite strojno učenje na materijalima kako biste otkrili slične slike. Oslanja se na Pametno Pretraživanje", @@ -69,6 +70,7 @@ "image_thumbnail_resolution": "Razlučivost sličica", "image_thumbnail_resolution_description": "Koristi se prilikom pregledavanja grupa fotografija (glavna vremenska traka, prikaz albuma itd.). Veće razlučivosti mogu sačuvati više detalja, ali trebaju dulje za kodiranje, imaju veće veličine datoteka i mogu smanjiti odaziv aplikacije.", "job_concurrency": "{job} istovremenost", + "job_created": "Zadatak je kreiran", "job_not_concurrency_safe": "Ovaj posao nije siguran za istovremenost.", "job_settings": "Postavke posla", "job_settings_description": "Upravljajte istovremenošću poslova", @@ -91,8 +93,8 @@ "library_watching_settings": "Gledanje biblioteke (EKSPERIMENTALNO)", "library_watching_settings_description": "Automatsko praćenje promijenjenih datoteke", "logging_enable_description": "Omogući zapisivanje", - "logging_level_description": "Kada je omogućeno, koju razinu zapisavanje koristiti.", - "logging_settings": "Zapisavanje", + "logging_level_description": "Kada je omogućeno, koju razinu zapisivanja koristiti.", + "logging_settings": "Zapisivanje", "machine_learning_clip_model": "CLIP model", "machine_learning_clip_model_description": "Naziv CLIP modela navedenog ovdje. Imajte na umu da morate ponovno pokrenuti posao 'Pametno Pretraživanje' za sve slike nakon promjene modela.", "machine_learning_duplicate_detection": "Detekcija Duplikata", @@ -138,7 +140,7 @@ "map_settings_description": "Upravljanje postavkama karte", "map_style_description": "URL na style.json temu karte", "metadata_extraction_job": "Izdvoj metapodatke", - "metadata_extraction_job_description": "Izdvojite podatke o metapodacima iz svakog sredstva, kao što su GPS i rezolucija", + "metadata_extraction_job_description": "Izdvojite podatke o metapodacima iz svakog sredstva, kao što su GPS, lica i rezolucija", "metadata_faces_import_setting": "Omogući uvoz lica", "metadata_faces_import_setting_description": "Uvezite lica iz EXIF podataka slike i sidecar datoteka", "metadata_settings": "Postavke Metapodataka", @@ -197,6 +199,7 @@ "password_settings": "Prijava zaporkom", "password_settings_description": "Upravljanje postavkama za prijavu zaporkom", "paths_validated_successfully": "Sve su putanje uspješno potvrđene", + "person_cleanup_job": "Čišćenje lica", "quota_size_gib": "Veličina kvote (GiB)", "refreshing_all_libraries": "Osvježavanje svih biblioteka", "registration": "Registracija administratora", @@ -210,6 +213,7 @@ "reset_settings_to_recent_saved": "Resetirajte postavke na nedavno spremljene postavke", "scanning_library_for_changed_files": "Skeniranje biblioteke za promijenjene datoteke", "scanning_library_for_new_files": "Skeniranje biblioteke za nove datoteke", + "search_jobs": "Traži zadatke…", "send_welcome_email": "Pošaljite email dobrodošlice", "server_external_domain_settings": "Vanjska domena", "server_external_domain_settings_description": "Domena za javno dijeljene linkove, uključujući http(s)://", @@ -237,6 +241,7 @@ "storage_template_settings_description": "Upravljajte strukturom mape i nazivom datoteke učitanog sredstva", "storage_template_user_label": "{label} je korisnička oznaka za pohranu", "system_settings": "Postavke Sustava", + "tag_cleanup_job": "Čišćenje oznaka", "theme_custom_css_settings": "Prilagođeni CSS", "theme_custom_css_settings_description": "Kaskadni listovi stilova (CSS) omogućuju prilagođavanje dizajna Immicha.", "theme_settings": "Postavke tema", @@ -310,6 +315,7 @@ "trash_settings_description": "Upravljanje postavkama smeća", "untracked_files": "Nepraćene datoteke", "untracked_files_description": "Aplikacija ne prati ove datoteke. Mogu biti rezultat neuspjelih premještanja, prekinutih prijenosa ili izostale zbog pogreške", + "user_cleanup_job": "Čišćenje korisnika", "user_delete_delay": "Račun i sredstva korisnika {user} bit će zakazani za trajno brisanje za {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Brisanje odgode", "user_delete_delay_settings_description": "Broj dana nakon uklanjanja za trajno brisanje korisničkog računa i imovine. Posao brisanja korisnika pokreće se u ponoć kako bi se provjerili korisnici koji su spremni za brisanje. Promjene ove postavke bit će procijenjene pri sljedećem izvršavanju.", @@ -449,7 +455,7 @@ "clear_value": "Očisti vrijednost", "clockwise": "U smjeru kazaljke na satu", "close": "Zatvori", - "collapse": "Sažimanje", + "collapse": "Sažmi", "collapse_all": "Sažmi sve", "color": "Boja", "color_theme": "Tema boja", @@ -918,98 +924,179 @@ "owner": "Vlasnik", "partner": "Partner", "partner_can_access": "{partner} može pristupiti", - "partner_can_access_assets": "", - "partner_can_access_location": "", - "partner_sharing": "", - "partners": "", - "password": "", - "password_does_not_match": "", - "password_required": "", - "password_reset_success": "", + "partner_can_access_assets": "Sve vaše fotografije i videi osim onih u arhivi i smeću", + "partner_can_access_location": "Mjesto otkuda je slika otkinuta", + "partner_sharing": "Dijeljenje s partnerom", + "partners": "Partneri", + "password": "Zaporka", + "password_does_not_match": "Zaporka se ne podudara", + "password_required": "Zaporka je obavezna", + "password_reset_success": "Reset zaporke je uspješan", "past_durations": { - "days": "", - "hours": "", - "years": "" + "days": "{days, plural, one {Prošli dan} few {Prošlih # dana} other {Prošlih # dana}}", + "hours": "{hours, plural, one {Prošli sat} few {Prošla # sata} other {Prošlih # sati}}", + "years": "{years, plural, one {Prošle godine} few {Prošle # godine} other {Prošlih # godina}}" }, - "path": "", - "pattern": "", - "pause": "", - "pause_memories": "", - "paused": "", - "pending": "", - "people": "", - "people_sidebar_description": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", - "permanently_deleted_asset": "", - "photos": "", - "photos_count": "", - "photos_from_previous_years": "", - "pick_a_location": "", - "place": "", - "places": "", - "play": "", - "play_memories": "", - "play_motion_photo": "", - "play_or_pause_video": "", - "port": "", - "preset": "", - "preview": "", - "previous": "", - "previous_memory": "", - "previous_or_next_photo": "", - "primary": "", - "profile_picture_set": "", - "public_share": "", - "reaction_options": "", - "read_changelog": "", - "recent": "", - "recent_searches": "", - "refresh": "", - "refreshed": "", - "refreshes_every_file": "", - "remove": "", - "remove_from_album": "", - "remove_from_favorites": "", - "remove_from_shared_link": "", - "remove_offline_files": "", - "removed_api_key": "", - "rename": "", - "repair": "", - "repair_no_results_message": "", - "replace_with_upload": "", - "require_password": "", - "require_user_to_change_password_on_first_login": "", - "reset": "", - "reset_password": "", - "reset_people_visibility": "", - "restore": "", - "restore_all": "", - "restore_user": "", - "resume": "", - "retry_upload": "", - "review_duplicates": "", - "role": "", - "save": "", - "saved_api_key": "", - "saved_profile": "", - "saved_settings": "", - "say_something": "", - "scan_all_libraries": "", - "scan_all_library_files": "", - "scan_new_library_files": "", - "scan_settings": "", - "search": "", - "search_albums": "", - "search_by_context": "", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", - "search_for_existing_person": "", - "search_people": "", - "search_places": "", + "path": "Putanja", + "pattern": "Uzorak", + "pause": "Pauza", + "pause_memories": "Pauziraj sjećanja", + "paused": "Pauzirano", + "pending": "Na čekanju", + "people": "Ljudi", + "people_edits_count": "Izmjenjeno {count, plural, one {# osoba} other {# osobe}}", + "people_feature_description": "Pregledavanje fotografija i videozapisa grupiranih po osobama", + "people_sidebar_description": "Prikažite poveznicu na Osobe na bočnoj traci", + "permanent_deletion_warning": "Upozorenje za nepovratno brisanje", + "permanent_deletion_warning_setting_description": "Prikaži upozorenje prilikom trajnog brisanja sredstava", + "permanently_delete": "Nepovratno obriši", + "permanently_delete_assets_count": "Trajno izbriši {count, plural, one {datoteku} other {datoteke}}", + "permanently_delete_assets_prompt": "Da li ste sigurni da želite trajni izbrisati {count, plural, one {ovu datoteku?} other {ove # datoteke?}}Ovo će ih također ukloniti {count, plural, one {iz njihovog} other {iz njihovih}} albuma.", + "permanently_deleted_asset": "Trajno izbrisano sredstvo", + "permanently_deleted_assets_count": "Trajno izbrisano {count, plural, one {# datoteka} other {# datoteke}}", + "person": "Osoba", + "person_hidden": "{name}{hidden, select, true { (skriveno)} other {}}", + "photo_shared_all_users": "Čini se da ste svoje fotografije podijelili sa svim korisnicima ili nemate nijednog korisnika s kojim biste ih podijelili.", + "photos": "Fotografije", + "photos_and_videos": "Fotografije i videozapisi", + "photos_count": "{count, plural, one {{count, number} fotografija} few {{count, number} fotografije} other {{count, number} fotografija}}", + "photos_from_previous_years": "Fotografije iz prethodnih godina", + "pick_a_location": "Odaberite lokaciju", + "place": "Mjesto", + "places": "Mjesta", + "play": "Pokreni", + "play_memories": "Pokreni sjećanja", + "play_motion_photo": "Reproduciraj Pokretnu fotografiju", + "play_or_pause_video": "Reproducirajte ili pauzirajte video", + "port": "Port", + "preset": "Unaprijed postavljeno", + "preview": "Pregled", + "previous": "Prethodno", + "previous_memory": "Prethodno sjećanje", + "previous_or_next_photo": "Prethodna ili sljedeća fotografija", + "primary": "Primarna (Primary)", + "privacy": "Privatnost", + "profile_image_of_user": "Profilna slika korisnika {user}", + "profile_picture_set": "Profilna slika postavljena.", + "public_album": "Javni album", + "public_share": "Javno dijeljenje", + "purchase_account_info": "Podržava softver", + "purchase_activated_subtitle": "Hvala što podržavate Immich i softver otvorenog koda", + "purchase_activated_time": "Aktivirano {date, date}", + "purchase_activated_title": "Vaš ključ je uspješno aktiviran", + "purchase_button_activate": "Aktiviraj", + "purchase_button_buy": "Kupi", + "purchase_button_buy_immich": "Kupi Immich", + "purchase_button_never_show_again": "Nikad više ne prikazuj", + "purchase_button_reminder": "Podsjeti me za 30 dana", + "purchase_button_remove_key": "Ukloni ključ", + "purchase_button_select": "Odaberite", + "purchase_failed_activation": "Aktivacija nije uspjela! Provjerite svoju e-poštu za točan ključ proizvoda!", + "purchase_individual_description_1": "Za pojedinca", + "purchase_individual_description_2": "Status podržavanja", + "purchase_individual_title": "Pojedinačna licenca", + "purchase_input_suggestion": "Imate ključ proizvoda? Unesite ključ ispod", + "purchase_license_subtitle": "Kupite Immich kako biste podržali kontinuirani razvoj usluge", + "purchase_lifetime_description": "Doživotna kupnja", + "purchase_option_title": "MOGUĆNOSTI KUPNJE", + "purchase_panel_info_1": "Za izgradnju Immicha potrebno je puno vremena i truda, a mi imamo inženjere koji rade na tome s punim radnim vremenom kako bismo ga učinili što boljim. Naša je misija da softver otvorenog koda i etička poslovna praksa postanu održivi izvor prihoda za programere i da se stvori ekosustav koji poštuje privatnost sa stvarnim alternativama eksploatacijskim uslugama u oblaku.", + "purchase_panel_info_2": "Budući da se obvezujemo da nećemo dodavati dodatne pretplate, ova vam kupnja neće dodijeliti nikakve dodatne značajke u Immichu. Oslanjamo se na korisnike poput vas da podržimo stalni razvoj Immicha.", + "purchase_panel_title": "Podrži projekt", + "purchase_per_server": "Po serveru", + "purchase_per_user": "Po korisniku", + "purchase_remove_product_key": "Ukloni ključ proizvoda", + "purchase_remove_product_key_prompt": "Jeste li sigurni da želite ukloniti ključ proizvoda?", + "purchase_remove_server_product_key": "Uklonite ključ proizvoda poslužitelja (Server)", + "purchase_remove_server_product_key_prompt": "Jeste li sigurni da želite ukloniti ključ proizvoda poslužitelja (Server)?", + "purchase_server_description_1": "Za cijeli server", + "purchase_server_description_2": "Status podupiratelja", + "purchase_server_title": "Poslužitelj (Server)", + "purchase_settings_server_activated": "Ključem proizvoda poslužitelja upravlja administrator", + "rating": "Broj zvjezdica", + "rating_clear": "Obriši ocjenu", + "rating_count": "{count, plural, one {# zvijezda} other {# zvijezde}}", + "rating_description": "Prikaži EXIF ocjenu na info ploči", + "reaction_options": "Mogućnosti reakcije", + "read_changelog": "Pročitajte Dnevnik promjena", + "reassign": "Ponovno dodijeli", + "reassigned_assets_to_existing_person": "Ponovo dodijeljeno{count, plural, one {# datoteka} other {# datoteke}} postojećoj {name, select, null {osobi} other {{name}}}", + "reassigned_assets_to_new_person": "Ponovo dodijeljeno {count, plural, one {# datoteka} other {# datoteke}} novoj osobi", + "reassing_hint": "Dodijelite odabrane datoteke postojećoj osobi", + "recent": "Nedavno", + "recent_searches": "Nedavne pretrage", + "refresh": "Osvježi", + "refresh_encoded_videos": "Osvježite kodirane videozapise", + "refresh_metadata": "Osvježi metapodatke", + "refresh_thumbnails": "Osvježi sličice", + "refreshed": "Osvježeno", + "refreshes_every_file": "Osvježava svaku datoteku", + "refreshing_encoded_video": "Osvježavanje kodiranog videa", + "refreshing_metadata": "Osvježavanje metapodataka", + "regenerating_thumbnails": "Obnavljanje sličica", + "remove": "Ukloni", + "remove_assets_album_confirmation": "Jeste li sigurni da želite ukloniti {count, plural, one {# datoteku} other {# datoteke}} iz albuma?", + "remove_assets_shared_link_confirmation": "Jeste li sigurni da želite ukloniti {count, plural, one {# datoteku} other {# datoteke}} iz ove dijeljene veze?", + "remove_assets_title": "Ukloniti datoteke?", + "remove_custom_date_range": "Ukloni prilagođeni datumski raspon", + "remove_from_album": "Ukloni iz albuma", + "remove_from_favorites": "Ukloni iz favorita", + "remove_from_shared_link": "Ukloni iz dijeljene poveznice", + "remove_offline_files": "Ukloni izvanmrežne datoteke", + "remove_user": "Ukloni korisnika", + "removed_api_key": "Uklonjen API ključ: {name}", + "removed_from_archive": "Uklonjeno iz arhive", + "removed_from_favorites": "Uklonjeno iz favorita", + "removed_from_favorites_count": "{count, plural, other {Uklonjeno #}} iz omiljenih", + "removed_tagged_assets": "Uklonjena oznaka iz {count, plural, one {# datoteke} other {# datoteka}}", + "rename": "Preimenuj", + "repair": "Popravi", + "repair_no_results_message": "Nepraćene datoteke i datoteke koje nedostaju pojavit će se ovdje", + "replace_with_upload": "Zamijeni s prijenosom", + "repository": "Spremište (Repository)", + "require_password": "Zahtijevaj lozinku", + "require_user_to_change_password_on_first_login": "Zahtijevajte od korisnika promjenu lozinke pri prvoj prijavi", + "reset": "Reset", + "reset_password": "Resetiraj lozinku", + "reset_people_visibility": "Poništi vidljivost ljudi", + "reset_to_default": "Vrati na zadano", + "resolve_duplicates": "Riješite duplikate", + "resolved_all_duplicates": "Razriješi sve duplikate", + "restore": "Oporavi", + "restore_all": "Oporavi sve", + "restore_user": "Vrati korisnika", + "restored_asset": "Obnovljena datoteka", + "resume": "Nastavi", + "retry_upload": "Ponovi prijenos", + "review_duplicates": "Pregledajte duplikate", + "role": "Uloga", + "role_editor": "Urednik", + "role_viewer": "Gledatelj", + "save": "Spremi", + "saved_api_key": "Spremljen API ključ", + "saved_profile": "Spremljen profil", + "saved_settings": "Spremljene postavke", + "say_something": "Reci nešto", + "scan_all_libraries": "Skeniraj sve Knjižnice", + "scan_all_library_files": "Ponovno skenirajte sve datoteke Knjižnice", + "scan_new_library_files": "Skeniraj nove datoteke Knjižnice", + "scan_settings": "Postavke skeniranja", + "scanning_for_album": "Skeniranje albuma...", + "search": "Pretraživanje", + "search_albums": "Traži albume", + "search_by_context": "Pretraživanje po kontekstu", + "search_by_filename": "Pretražujte prema nazivu datoteke ili ekstenziji", + "search_by_filename_example": "npr. IMG_1234.JPG ili PNG", + "search_camera_make": "Pretražite marku kamere...", + "search_camera_model": "Pretražite model kamere...", + "search_city": "Pretražite grad...", + "search_country": "Pretražite državu...", + "search_for_existing_person": "Potražite postojeću osobu", + "search_no_people": "Nema ljudi", + "search_no_people_named": "Nema osoba s imenom \"{name}\"", + "search_options": "Opcije pretraživanja", + "search_people": "Traži ljude", + "search_places": "Traži mjesta", + "search_settings": "Postavke pretraživanja", "search_state": "", "search_timezone": "", "search_type": "", diff --git a/web/src/lib/i18n/hu.json b/web/src/lib/i18n/hu.json index 249b663a77..851171bc99 100644 --- a/web/src/lib/i18n/hu.json +++ b/web/src/lib/i18n/hu.json @@ -34,13 +34,14 @@ "authentication_settings_reenable": "Az újbóli engedélyezéshez használjon egySzerver Parancsot.", "background_task_job": "Háttérfolyamatok", "check_all": "Összes Kipiálása", - "cleared_jobs": "{job} munkák kitörölve", + "cleared_jobs": "{job}: feladatok törölve", "config_set_by_file": "A konfigurációt jelenleg egy konfigurációs fájl állítja be", "confirm_delete_library": "Biztosan ki szeretné törölni a {library} képtárat?", "confirm_delete_library_assets": "Biztosan kitörli ezt a képtárat? Ez kitöröl {count, plural, one {#} other {#}} benne lévő fájlt az Immichből és nem visszavonható. A fájlok a lemezen maradnak.", "confirm_email_below": "A megerősítéshez írja \"{email}\"-t alább", "confirm_reprocess_all_faces": "Biztos benne, hogy újra szeretné feldolgozni az összes arcot? Ez a megnevezett személyeket is törli.", "confirm_user_password_reset": "Biztosan vissza szeretné állítani {user} jelszavát?", + "create_job": "Feladat létrehozása", "crontab_guru": "Crontab Guru", "disable_login": "Belépés letiltása", "disabled": "Letiltva", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Bélyegkép felbontás", "image_thumbnail_resolution_description": "Képek csoportosított nézetekor használatos (idővonal, album nézet stb). Nagyobb felbontás esetén a kép részletgazdagabb marad, de tovább tart elkészíteni, nagyobb fájl méretet eredményes, és ronthatja az alkalmazás reagálását.", "job_concurrency": "{job} párhuzamosság", + "job_created": "Feladat létrehozva", "job_not_concurrency_safe": "Ez a feladat nem párhuzamosság-biztos.", "job_settings": "Feladat beállítások", "job_settings_description": "Feladatok párhuzamosságának beállítása", @@ -96,8 +98,8 @@ "logging_settings": "Naplózás", "machine_learning_clip_model": "CLIP modell", "machine_learning_clip_model_description": "Egy CLIP modell neve az itt felsoroltak közül. A modell megváltoztatása után újra kell futtatni az 'Okos Keresés' munkát minden képre.", - "machine_learning_duplicate_detection": "Másolatok Észlelése", - "machine_learning_duplicate_detection_enabled": "Másolatkeresés engedélyezése", + "machine_learning_duplicate_detection": "Duplikáltak Észlelése", + "machine_learning_duplicate_detection_enabled": "Duplikáltak keresésének engedélyezése", "machine_learning_duplicate_detection_enabled_description": "Ha ki van kapcsolva, a pontosan azonos fájlok akkor sem lesznek duplikálva.", "machine_learning_duplicate_detection_setting_description": "CLIP beágyazások használata a valószínű másolatok kereséséhez", "machine_learning_enabled": "Gépi tanulás engedélyezése", @@ -107,7 +109,7 @@ "machine_learning_facial_recognition_model": "Arcfelismerési modell", "machine_learning_facial_recognition_model_description": "A modellek méret szerint csökkenő sorrendben vannak felsorolva. A nagyobb modellek lassabbak és több memóriát használnak, de jobb eredményt produkálnak. Modellváltás után az összes képen újra le kell futtatni az arcfelismerési feladatot.", "machine_learning_facial_recognition_setting": "Arckeresés engedélyezése", - "machine_learning_facial_recognition_setting_description": "Ha ki van kapcsolva, a képek nem lesznek az arcfelismerésen lefuttatva és a Felfedezés oldalon az Személyek szekcióban nem fog szerepelni senki.", + "machine_learning_facial_recognition_setting_description": "Ha ki van kapcsolva, a képek nem lesznek az arcfelismerésen lefuttatva és a Böngészés oldalon az Személyek szekcióban nem fog szerepelni senki.", "machine_learning_max_detection_distance": "Maximum észlelési távolság", "machine_learning_max_detection_distance_description": "Két kép közötti maximális távolság, amely esetében még másolatnak tekintjük őket (0.001 és 0.1 közötti érték). Magasabb értékek több másolatot találnak meg, de a hamis találatok esélye is nagyobb.", "machine_learning_max_recognition_distance": "Maximum felismerési távolság", @@ -138,11 +140,14 @@ "map_settings": "Térkép", "map_settings_description": "Térkép beállítások kezelése", "map_style_description": "Egy style.json térképstílusra mutató URL", - "metadata_extraction_job": "Metaadatok feldolgozása", - "metadata_extraction_job_description": "Metaadat-információk kinyerése minden tartalomból, például GPS, arcok és felbontás", + "metadata_extraction_job": "Metaadatok kinyerése", + "metadata_extraction_job_description": "Metaadat-információk kinyerése minden fájlból, például GPS, arcok és felbontás", + "metadata_faces_import_setting": "Arc importálás engedélyezése", + "metadata_faces_import_setting_description": "Arcok importálása a kép Exif adatából és metaadat fájlokból", "metadata_settings": "Metaadat beállítások", - "migration_job": "Migráció", - "migration_job_description": "Az képi vagyon és arcok bélyegképeinek migrálása a legújabb mappastruktúrába", + "metadata_settings_description": "Metaadat-beállítások kezelése", + "migration_job": "Migrálás", + "migration_job_description": "A fájlok és arcok bélyegképeinek migrálása a legújabb mappastruktúrába", "no_paths_added": "Nincs megadva elérési útvonal", "no_pattern_added": "Nincs megadva illesztési minta (pattern)", "note_apply_storage_label_previous_assets": "Megjegyzés: Tárolási Cimkék már korábban feltöltött képi vagyonra ragasztásához futtasd a következőt -", @@ -175,7 +180,7 @@ "oauth_issuer_url": "Kibocsátó URL", "oauth_mobile_redirect_uri": "Mobil átirányítási URI", "oauth_mobile_redirect_uri_override": "Mobil átirányítási URI felülírás", - "oauth_mobile_redirect_uri_override_description": "Engedélyezze, ha az 'app.immich:/' érvénytelen átirányítási URI.", + "oauth_mobile_redirect_uri_override_description": "Engedélyezze, ha az OAuth szolgáltató tiltja a mobil URI-t, mint például '{callback}'", "oauth_profile_signing_algorithm": "Profil aláíró algoritmus", "oauth_profile_signing_algorithm_description": "A felhasználói profil aláírásához használt algoritmus.", "oauth_scope": "Hatókör", @@ -195,6 +200,7 @@ "password_settings": "Jelszavas Bejelentkezés", "password_settings_description": "Jelszavas bejelentkezés beállítások kezelése", "paths_validated_successfully": "Összes útvonal sikeresen érvényesítve", + "person_cleanup_job": "Személy törlése", "quota_size_gib": "Kvóta Mérete (GiB)", "refreshing_all_libraries": "Összes képtár újratöltése", "registration": "Admin Regisztráció", @@ -208,6 +214,7 @@ "reset_settings_to_recent_saved": "Beállítások visszaállítása a legutóbb mentettre", "scanning_library_for_changed_files": "Képtár átfésülése megváltozott fájlok után", "scanning_library_for_new_files": "Képtár átfésülése új fájlok után", + "search_jobs": "Feladat keresés...", "send_welcome_email": "Üdvözlő email küldése", "server_external_domain_settings": "Külső domain", "server_external_domain_settings_description": "Nyilvánosan megosztott linkek domainje (http(s)://-sel)", @@ -215,10 +222,10 @@ "server_settings_description": "Szerver beállítások kezelése", "server_welcome_message": "Üdvözlő üzenet", "server_welcome_message_description": "A bejelentkezőoldalon megjelenő üzenet.", - "sidecar_job": "Oldalkocsi fájl metaadatok", - "sidecar_job_description": "Fedezze fel vagy szinkronizálja az oldalkocsi fájlokban tárolt metaadatokat a fájlrendszerből", + "sidecar_job": "Metaadat feldolgozás", + "sidecar_job_description": "Metaadatok keresése vagy szinkronizálása a fájlrendszer alapján", "slideshow_duration_description": "Az egyes képek megjelenítésének ideje másodpercben", - "smart_search_job_description": "Futtasson gépi tanulást a képi vagyonon az intelligens keresés támogatása érdekében", + "smart_search_job_description": "Gépi tanulás futtatása a fájlokon az okos keresés támogatásához", "storage_template_date_time_description": "A fájl készítési időpontja lesz felhasználva az időpont információhoz", "storage_template_date_time_sample": "Példa időpont {date}", "storage_template_enable_description": "Tárolási sablon motor engedélyezése", @@ -235,13 +242,14 @@ "storage_template_settings_description": "Kezelje a feltöltött képi vagyontárgyak mappaszerkezetét és fájlnevét", "storage_template_user_label": "A felhasználó Tároló Címkéje {label}", "system_settings": "Rendszerbeállítások", + "tag_cleanup_job": "Címke törlés", "theme_custom_css_settings": "Egyedi CSS", "theme_custom_css_settings_description": "CSS Stíluslapokkal az Immich stílusa megváltoztatható.", "theme_settings": "Stílus Beállítások", "theme_settings_description": "Kezelje az Immich webes felület testreszabását", "these_files_matched_by_checksum": "Ezek a fájlok egyeznek az ellenőrző összegük alapján", "thumbnail_generation_job": "Bélyegképek Generálása", - "thumbnail_generation_job_description": "Hozzon létre nagy, kicsi és elmosódott bélyegképeket minden egyes elemhez, valamint bélyegképeket minden egyes személyhez", + "thumbnail_generation_job_description": "Nagy, kicsi és elmosódott bélyegképek létrehozása minden elemhez, valamint bélyegképek generálása minden személyhez", "transcode_policy_description": "", "transcoding_acceleration_api": "Gyorsító API", "transcoding_acceleration_api_description": "Az API, amely interakcióba lép az eszközzel az átkódolás felgyorsítása érdekében. Ez a beállítás a „legtöbb, amit megtehetünk” alapon működik: hiba esetén visszaáll a szoftveres átkódolásra. A VP9 a hardvertől függően vagy működik, vagy nem.", @@ -257,11 +265,11 @@ "transcoding_accepted_video_codecs_description": "Válassza ki, mely videó kodexeket nem kell átkódolni. Csak bizonyos átkódolási szabályzatokhoz használatos.", "transcoding_advanced_options_description": "Ezeket az opciókat a legtöbb felhasználónak nem kell módosítania", "transcoding_audio_codec": "Audio kodek", - "transcoding_audio_codec_description": "Az Opus a legjobb minőségű opció (jobb minőség ugyanannyi helyet foglalva), de kevésbé kompatibilis a régi eszközökkel vagy szoftverekkel.", + "transcoding_audio_codec_description": "Az Opus a legjobb minőségű opció (jobb hangminőség ugyanakkora tárhelyen), de kevésbé kompatibilis a régi eszközökkel vagy szoftverekkel.", "transcoding_bitrate_description": "A maximum bitrátát meghaladó vagy nem megfelelő formátumú videókat", "transcoding_codecs_learn_more": "Hogy többet tudjon meg az itt felhasznált kifejezésekről, látogassa meg az FFmpeg dokumentációt a H.264 kodekhez, a HEVC kodekhez és a VP9 kodekhez.", "transcoding_constant_quality_mode": "Állandó minőségi mód", - "transcoding_constant_quality_mode_description": "Az ICQ jobb, mint a CQP, viszont nem minden hardver támogatja. A rendszer az itt beállított módszert részesíti előnyben. A NVENC ignorálja a beállítást, mivel nem támogatja az ICQ-t.", + "transcoding_constant_quality_mode_description": "Az ICQ jobb, mint a CQP, viszont az előbbit nem minden hardver támogatja. A rendszer az itt beállított módot preferálja a minőség orientált enkódoláshoz. Az NVENC nem használja ezt a beállítást, mivel nem támogatja az ICQ-t.", "transcoding_constant_rate_factor": "Állandó ráta tényező (-crf)", "transcoding_constant_rate_factor_description": "Videó minőségi szint. Jellemző értékek kodekenként: H.264: 23, HEVC: 28, VP9: 31, AV1: 35. Minél alacsonyabb, annál jobb minőséget eredményez, viszont nagyobb fájlmérettel is jár.", "transcoding_disabled_description": "Ne transzkódoljon videót. Nem lejátszható videókhoz vezethet néhány kliensen", @@ -287,7 +295,7 @@ "transcoding_settings": "Videó Transzkódolási Beállítások", "transcoding_settings_description": "Videófájlok felbontásának és kódólásának kezelése", "transcoding_target_resolution": "Célfelbontás", - "transcoding_target_resolution_description": "Magasabb felbontás jobb minőségben őrzi meg a részleteket, de tovább tart kódolni, nagyobb fájlmérethez vezet, és csökkentheti az alkalmazás teljesítményét.", + "transcoding_target_resolution_description": "Magasabb felbontás jobb minőségben őrzi meg a részleteket, de tovább tart kódolni, nagyobb fájlmérethez vezet, és csökkentheti az alkalmazás válaszidejét.", "transcoding_temporal_aq": "Időbeli (Temporal) AQ", "transcoding_temporal_aq_description": "Csak NVENC esetén. Növeli a nagyon részletes, keveset mozgó videóanyag minőségét. Nem minden régi hardver támogatja.", "transcoding_threads": "Folyamatok száma", @@ -299,16 +307,17 @@ "transcoding_transcode_policy": "Transzkódolási szabályzat", "transcoding_transcode_policy_description": "Mely videókat transzkódolja. HDR videók mindig transzkódolásra kerülnek (kivéve, ha a transzkódolás ki van kapcsolva).", "transcoding_two_pass_encoding": "Enkódolás két menetben", - "transcoding_two_pass_encoding_setting_description": "Ha két menetben lettek transzkódolva, az elkészült videok jobbak. Ha engedélyezve van a bitráta maximalizálása (amely egyébként szükséges a H.264 és a HEVC használatakor), ez a funkció figyelmen kívül hagyja a CRF-et és a maximális bitráta alapján választja ki a megfelelő bitráta sávot. VP9 használata esetén CRF használható, ha a bitráta nincs maxmalizáva (ki van kapcsolva).", + "transcoding_two_pass_encoding_setting_description": "Ha két menetben lettek transzkódolva, az elkészült videók jobb minőségűek. Ha engedélyezve van a bitráta maximalizálása (amely szükséges a H.264 és a HEVC használatakor), ez a funkció figyelmen kívül hagyja a CRF-et. VP9 használata esetén a CRF használható, ha a bitráta nincs maximalizálva (ki van kapcsolva).", "transcoding_video_codec": "Videó Kodek", "transcoding_video_codec_description": "VP9 hatékonyabb és kompatibilisebb webre, de tovább tart a transzkódolás. HEVC hasonló teljesítményű, de több web kompatibilitási problémát okozhat. H.264 széles körben kompatibilis és gyors a transzkódolása, de sokkal nagyobb fájlokat készít. AV1 a leghatékonyabb kodek, de régebbi eszközök nem támogatják.", "trash_enabled_description": "Lomtár engedélyezése", "trash_number_of_days": "Napok száma", - "trash_number_of_days_description": "Hány napig legyenek a lomtárban tárolva a törölt képek, videok, mielőtt véglegesen kiürítődnek", + "trash_number_of_days_description": "Hány napig legyenek a lomtárban a fájlok a végleges törlés előtt", "trash_settings": "Lomtár Beállítások", "trash_settings_description": "Lomtár beállítások kezelése", "untracked_files": "Nem kezelt fájlok", "untracked_files_description": "Ezekkel a fájlokkal semmit nem csinál az alkalmazás. Ez lehetséges pl. meghiúsult mozgatás, megszakított feltöltés miatt, vagy valamilyen alkalmazáshiba következtében", + "user_cleanup_job": "Felhasználó adatainak törlése", "user_delete_delay": "{user} felhasználói fiókja és képi vagyona véglegesen törölve lesz {delay, plural, one {# nap} other {# nap}} múlva.", "user_delete_delay_settings": "Törlési késleltetés", "user_delete_delay_settings_description": "Ennyi nap teljen el az eltávolítás után a felhasználói fiók és ahhoz tartozó elemek végleges törlése között. A törlésért felelős folyamat éjfélkor indul, és megnézi van-e törlésre kész felhasználó. A beállítás változtatása a következő végrehajtás során lép életbe.", @@ -339,7 +348,7 @@ "album_added": "Albumhoz hozzáadva", "album_added_notification_setting_description": "Küldjön emailes értesítőt, amikor hozzáadnak egy megosztott albumhoz", "album_cover_updated": "Album borító frissítve", - "album_delete_confirmation": "Biztos, hogy ki szeretné törölni a {album} albumot?", + "album_delete_confirmation": "Biztos, hogy ki szeretné törölni a(z) {album} albumot?", "album_delete_confirmation_description": "Amennyiben ez egy megosztott album, a többi felhasználó sem fog tudni hozzáférni.", "album_info_updated": "Album infó frissítve", "album_leave": "Elhagyja az albumot?", @@ -376,7 +385,7 @@ "archive_size": "Archívum mérete", "archive_size_description": "Beállítja letöltésnél az archívum méretét (GiB)", "archived": "Archíválva", - "archived_count": "{count, plural, other {Archived #}}", + "archived_count": "{count, plural, other {Archiválva #}}", "are_these_the_same_person": "Ugyanaz a személy?", "are_you_sure_to_do_this": "Biztosan ezt akarod csinálni?", "asset_added_to_album": "Hozzáadva az albumhoz", @@ -388,6 +397,7 @@ "asset_offline": "Elem offline", "asset_offline_description": "Ez az elem nem elérhető. Immich nem képes elérni a file helyét. Győződjön meg az elem elérhetőségéről és szkennelje újra a könyvtárat.", "asset_skipped": "Kihagyva", + "asset_skipped_in_trash": "Lomtárban", "asset_uploaded": "Feltöltve", "asset_uploading": "Feltöltés...", "assets": "elemek", @@ -396,12 +406,12 @@ "assets_added_to_name_count": "{count, plural, other {# elem}} hozzáadva a(z) {hasName, select, true {{name}} other {új}} albumba", "assets_count": "{count, plural, other {# elem}}", "assets_moved_to_trash": "{count, plural, one {# fájl} other {# fájl}} a lomtárba mozgatva", - "assets_moved_to_trash_count": "{count, plural, other {# elem}} szemétbe mozgatva", + "assets_moved_to_trash_count": "{count, plural, other {# elem}} lomtárba mozgatva", "assets_permanently_deleted_count": "{count, plural, other {# elem}} örökre törölve", "assets_removed_count": "{count, plural, other {# elem}} eltávolítva", - "assets_restore_confirmation": "Biztosan visszaállítja a lomtárbeli elemeket? Ez a művelet nem visszavonható!", + "assets_restore_confirmation": "Biztosan visszaállítja a lomtárban lévő elemeket? Ez a művelet nem visszavonható!", "assets_restored_count": "{count, plural, other {# elem}} visszaállítva", - "assets_trashed_count": "{count, plural, other {# elem}} kidobva", + "assets_trashed_count": "{count, plural, other {# elem}} lomtárba helyezve", "assets_were_part_of_album_count": "{count, plural, other {# elem}} már az album része volt", "authorized_devices": "Engedélyezett készülékek", "back": "Vissza", @@ -484,6 +494,8 @@ "create_new_person": "Új személy létrehozása", "create_new_person_hint": "A kiválasztott képekhez új személyt rendel hozzá", "create_new_user": "Új felhasználó létrehozása", + "create_tag": "Címke létrehozása", + "create_tag_description": "Új címke létrehozása. Beágyazott címkék esetén adja meg a címke teljes elérési útvonalát, beleértve a perjeleket is.", "create_user": "Felhasználó létrehozása", "created": "Készült", "current_device": "Ez az eszköz", @@ -507,6 +519,8 @@ "delete_library": "Képtár törlése", "delete_link": "Link törlése", "delete_shared_link": "Megosztott link törlése", + "delete_tag": "Címke törlése", + "delete_tag_confirmation_prompt": "Biztos, hogy törölni szeretné a {tagName} címkét?", "delete_user": "Felhasználó törlése", "deleted_shared_link": "Törölt megosztott link", "description": "Leírás", @@ -555,6 +569,7 @@ "edit_location": "Hely módosítása", "edit_name": "Név módosítása", "edit_people": "Személyek módosítása", + "edit_tag": "Címke szerkesztése", "edit_title": "Cím Módosítása", "edit_user": "Felhasználó módosítása", "edited": "Módosítva", @@ -567,7 +582,7 @@ "empty": "", "empty_album": "Üres Album", "empty_trash": "Lomtár Ürítése", - "empty_trash_confirmation": "Biztosan kiüríti a lomtárat? Ezzel minden lomtárbeli fájlt véglegesen letöröl az Immich szolgáltatásból.\nEz a művelet nem visszavonható!", + "empty_trash_confirmation": "Biztosan kiüríti a lomtárat? Ezzel minden lomtárban lévő fájlt véglegesen letöröl az Immich szolgáltatásból.\nEz a művelet nem visszavonható!", "enable": "Engedélyezés", "enabled": "Engedélyezve", "end_date": "Vég dátum", @@ -585,7 +600,7 @@ "cant_get_number_of_comments": "Hozzászólások számának lekérdezése sikertelen", "cant_search_people": "Emberek keresése sikertelen", "cant_search_places": "Helyek keresése sikertelen", - "cleared_jobs": "A {job} munkák törölve", + "cleared_jobs": "A {job} feladatok törölve", "error_adding_assets_to_album": "Hiba történt az elemek albumhoz való hozzáadása során", "error_adding_users_to_album": "Hiba történt a felhasználók albumhoz való hozzáadása során", "error_deleting_shared_user": "Hiba történt megosztott felhasználó törlése során", @@ -594,7 +609,7 @@ "error_removing_assets_from_album": "Hiba történt az elemek albumból való eltávolítása során, további információért ellenőrizze a logokat", "error_selecting_all_assets": "Minden elem kijelölése közben hiba lépett fel", "exclusion_pattern_already_exists": "Ez a kizárási minta már létezik.", - "failed_job_command": "Parancs {command} hibával zárult a {job} munkában", + "failed_job_command": "A(z) {command} parancs hibával zárult a(z) {job} feladatban", "failed_to_create_album": "Album készítése sikertelen", "failed_to_create_shared_link": "Megosztott link készítése sikertelen", "failed_to_edit_shared_link": "Megosztott link szerkesztése sikertelen", @@ -652,6 +667,7 @@ "unable_to_get_comments_number": "Hozzászólások számának lekérdezése sikertelen", "unable_to_get_shared_link": "Megosztott link lekérdezése sikertelen", "unable_to_hide_person": "Személy elrejtése sikertelen", + "unable_to_link_motion_video": "Nem lehet a motion videót hozzákapcsolni", "unable_to_link_oauth_account": "OAuth felhasználó csatlakoztatása sikertelen", "unable_to_load_album": "Album betöltése sikertelen", "unable_to_load_asset_activity": "Elem aktivitásának betöltése sikertelen", @@ -689,9 +705,10 @@ "unable_to_scan_library": "Könyvtár ellenőrzése sikertelen", "unable_to_set_feature_photo": "Kijelölt fénykép beállítása sikertelen", "unable_to_set_profile_picture": "Profilkép beállítása sikertelen", - "unable_to_submit_job": "Nem sikerült a profilt elmenteni", + "unable_to_submit_job": "Nem sikerült a feladatot elindítani", "unable_to_trash_asset": "Nem sikerült a fájl lomtárba mozgatása", "unable_to_unlink_account": "Nem sikerült a fiók lekapcsolása", + "unable_to_unlink_motion_video": "Nem lehet a motion videót leválasztani", "unable_to_update_album_cover": "Albumborító beállítása sikertelen", "unable_to_update_album_info": "Album információ frissítése sikertelen", "unable_to_update_library": "Nem sikerült a képtár módosítása", @@ -711,7 +728,8 @@ "expire_after": "Lejárati idő", "expired": "Lejárt", "expires_date": "Lejár {date}", - "explore": "Felfedezés", + "explore": "Böngészés", + "explorer": "Böngésző", "export": "Exportálás", "export_as_json": "Exportálás JSON formátumban", "extension": "Kiterjesztés", @@ -725,6 +743,8 @@ "feature": "", "feature_photo_updated": "Címlapkép frissítve", "featurecollection": "", + "features": "Jellemzők", + "features_setting_description": "Az alkalmazás lehetőségeinek kezelése", "file_name": "Fájlnév", "file_name_or_extension": "Fájlnév vagy kiterjesztés", "filename": "Fájlnév", @@ -733,6 +753,8 @@ "filter_people": "Személyek szűrése", "find_them_fast": "Kereséssel gyorsan megtalálhatóak név alapján", "fix_incorrect_match": "Hibás találat korrigálása", + "folders": "Mappák", + "folders_feature_description": "A fájlrendszerben lévő fényképek és videók mappanézetben való böngészése", "force_re-scan_library_files": "Az összes Képtár fájl újbóli átfésülésének indítása", "forward": "Előre", "general": "Általános", @@ -753,7 +775,7 @@ "hide_password": "Jelszó elrejtése", "hide_person": "Személy elrejtése", "hide_unnamed_people": "Megnevezetlen emberek elrejtése", - "host": "", + "host": "Kiszolgáló", "hour": "Óra", "image": "Kép", "image_alt_text_date": "{isVideo, select, true {Videó} other {Kép}} készítési dátuma {date}", @@ -804,6 +826,7 @@ "library_options": "Képtár beállítások", "light": "Világos", "like_deleted": "Tetszik törölve", + "link_motion_video": "Motion videó hozzárendelése", "link_options": "Link beállítások", "link_to_oauth": "Csatlakoztatás OAuth-hoz", "linked_oauth_account": "Csatlakoztatott OAuth felhasználó", @@ -875,8 +898,8 @@ "no_assets_message": "KATTINTSON AZ ELSŐ FÉNYKÉPE FELTÖLTÉSÉHEZ", "no_duplicates_found": "Duplikátumok nem találhatók.", "no_exif_info_available": "Exif információ nem elérhető", - "no_explore_results_message": "Töltsön fel több fényképet, hogy felfedezze a gyűjteményét.", - "no_favorites_message": "Jelöljön meg kedvenceket, hogy gyorsan megtalálhassa legjobb fényképeit és videóit", + "no_explore_results_message": "Töltsön fel több fényképet, hogy böngészhesse a gyűjteményét.", + "no_favorites_message": "Hozzáadás a kedvencekhez, hogy hamarabb megtalálhassa a legjobb fényképeit és videóit", "no_libraries_message": "Hozzon létre külső képtárat a fényképei és videói megtekintéséhez", "no_name": "Nincs Név", "no_places": "Nincsenek helyek", @@ -939,6 +962,7 @@ "pending": "Folyamatban lévő", "people": "Személyek", "people_edits_count": "{count, plural, other {# személy}} szerkesztve", + "people_feature_description": "Személyek szerint csoportosított fényképek és videók böngészése", "people_sidebar_description": "Jelenítsen meg linket a Személyek fülhöz oldalt", "perform_library_tasks": "", "permanent_deletion_warning": "Figyelmeztetés végleges törlésről", @@ -1009,7 +1033,9 @@ "purchase_settings_server_activated": "A szerver termékkulcsot az admin menedzseli", "range": "", "rating": "Értékelés csillagokkal", - "rating_description": "Exif értékelés megjelenítése az infópanelben", + "rating_clear": "Értékelés törlése", + "rating_count": "{count, plural, one {# csillag} other {# csillagok}}", + "rating_description": "Exif értékelés megjelenítése az infópanelen", "raw": "", "reaction_options": "Reakció lehetőségek", "read_changelog": "Változtatások olvasása", @@ -1042,6 +1068,7 @@ "removed_from_archive": "Archívumból eltávolítva", "removed_from_favorites": "Kedvencekből eltávolítva", "removed_from_favorites_count": "A kedvencekből el lett távolítva {count, plural, other {# elem}}", + "removed_tagged_assets": "Címke eltávolítva az {count, plural, one {# elemről} other {# elemekről}}", "rename": "Átnevezés", "repair": "Javítás", "repair_no_results_message": "Nem megfigyelt és hiányzó fájlok itt jelennek meg", @@ -1074,7 +1101,8 @@ "scan_all_libraries": "Minden könyvtár átnézése", "scan_all_library_files": "Minden könyvtárbeli elem újraellenőrzése", "scan_new_library_files": "Ellenőrzés új könyvtárbeli elemekért", - "scan_settings": "Felfedezési beállítások", + "scan_settings": "Szkennelési beállítások", + "scanning_for_album": "Album szkennelése...", "search": "Keresés", "search_albums": "Albumok keresése", "search_by_context": "Keresés kontextus alapján", @@ -1087,13 +1115,16 @@ "search_for_existing_person": "Már meglévő személy keresése", "search_no_people": "Nincs személy", "search_no_people_named": "Nincs személy \"{name}\" néven", + "search_options": "Keresési lehetőségek", "search_people": "Személyek keresése", "search_places": "Helyek keresése", + "search_settings": "Keresési beállítások", "search_state": "Régió keresése...", + "search_tags": "Címkék keresése...", "search_timezone": "Időzóna keresése...", "search_type": "Típus keresése", "search_your_photos": "Fotók keresése", - "searching_locales": "", + "searching_locales": "Helyszín keresése...", "second": "Másodperc", "see_all_people": "Minden személy megtekintése", "select_album_cover": "Albumborító kiválasztása", @@ -1107,7 +1138,7 @@ "select_library_owner": "Könyvtártulajdonos kijelölése", "select_new_face": "Új arc kiválasztása", "select_photos": "Fotók választása", - "select_trash_all": "Minden szemétbe helyezése", + "select_trash_all": "Minden lomtárba helyezése", "selected": "Kijelölt", "selected_count": "{count, plural, other {# kiválasztva}}", "send_message": "Üzenet küldése", @@ -1127,7 +1158,7 @@ "settings_saved": "Beállítások mentve", "share": "Megosztás", "shared": "Megosztva", - "shared_by": "Megosztva általa:", + "shared_by": "Megosztotta", "shared_by_user": "Megosztva {user} által", "shared_by_you": "Megosztva Ön által", "shared_from_partner": "Fényképek {partner}-tól/től", @@ -1158,10 +1189,14 @@ "show_supporter_badge": "Támogató jelvény", "show_supporter_badge_description": "Támogató jelvény megjelenítése", "shuffle": "Keverés", + "sidebar": "Oldalsáv", + "sidebar_display_description": "Nézetre mutató link megjelenítése az oldalsávban", "sign_out": "Kilépés", "sign_up": "Feliratkozás", "size": "Méret", "skip_to_content": "Ugrás a tartalomhoz", + "skip_to_folders": "Ugrás a mappákra", + "skip_to_tags": "Ugrás a címkékhez", "slideshow": "Diavetítés", "slideshow_settings": "Diavetítés beállításai", "sort_albums_by": "Albumok rendezése...", @@ -1194,6 +1229,14 @@ "sunrise_on_the_beach": "Napkelte a tengerparton", "swap_merge_direction": "Egyesítés irányának megfordítása", "sync": "Szinkronizálás", + "tag": "Címke", + "tag_assets": "Elemek címkézése", + "tag_created": "Létrehozott címke: {tag}", + "tag_feature_description": "Címkék szerinti fényképek és videók böngészése", + "tag_not_found_question": "Nem találja a címkét? Hozzon létre egyet itt", + "tag_updated": "Frissített címke: {tag}", + "tagged_assets": "Címkézett {count, plural, one {# elem} other {# elemek}}", + "tags": "Címkék", "template": "Minta", "theme": "Téma", "theme_selection": "Témaválasztás", @@ -1205,17 +1248,18 @@ "to_change_password": "Jelszó megváltoztatása", "to_favorite": "Kedvenc", "to_login": "Bejelentkezés", - "to_trash": "Szemétbe helyezés", + "to_parent": "Egy szinttel feljebb", + "to_trash": "Lomtárba helyezés", "toggle_settings": "Beállítások változtatása", - "toggle_theme": "Témaváltás", + "toggle_theme": "Sötét téma váltása", "toggle_visibility": "Láthatóság változtatása", "total_usage": "Összesen használatban", "trash": "Lomtár", "trash_all": "Mindet lomtárba", - "trash_count": "{count, number} elem szemétbe helyezése", - "trash_delete_asset": "Elem szemétbe helyezése / törlése", - "trash_no_results_message": "Itt lesznek láthatóak a lomtárba tett képek és videok.", - "trashed_items_will_be_permanently_deleted_after": "A szemeteskosárban lévő elemek véglegesen törlésre kerülnek {days, plural, other {# nap}} múlva.", + "trash_count": "{count, number} elem lomtárba helyezése", + "trash_delete_asset": "Lomtárba helyezés/törlés", + "trash_no_results_message": "Itt lesznek láthatóak a lomtárba tett képek és videók.", + "trashed_items_will_be_permanently_deleted_after": "A lomtárban lévő elemek véglegesen törlésre kerülnek {days, plural, other {# nap}} múlva.", "type": "Típus", "unarchive": "Archívumból kivétel", "unarchived": "Archívumból kivett", @@ -1226,9 +1270,11 @@ "unknown_album": "Ismeretlen Album", "unknown_year": "Ismeretlen év", "unlimited": "Korlátlan", + "unlink_motion_video": "Mozgókép leválasztása", "unlink_oauth": "OAuth leválasztása", "unlinked_oauth_account": "Leválasztott OAuth felhasználó", "unnamed_album": "Névtelen Album", + "unnamed_album_delete_confirmation": "Biztosan törölni szeretné ezt az albumot?", "unnamed_share": "Névtelen Megosztás", "unsaved_change": "Mentés nélküli változtatás", "unselect_all": "Összes kiválasztás törlése", @@ -1240,7 +1286,7 @@ "up_next": "Következik", "updated_password": "Jelszó megváltoztatva", "upload": "Feltöltés", - "upload_concurrency": "", + "upload_concurrency": "Párhuzamos feltöltés", "upload_errors": "Feltöltés befejezve {count, plural, other {# hibával}}, frissítse az oldalt az újonnan feltöltött elemek megtekintéséhez.", "upload_progress": "Hátra van {remaining, number} - Feldolgozva {processed, number}/{total, number}", "upload_skipped_duplicates": "{count, plural, other {# megegyező elem}} kihagyva", @@ -1249,7 +1295,7 @@ "upload_status_uploaded": "Feltöltve", "upload_success": "Feltöltés sikeres, frissítse az oldalt az újonnan feltöltött elemek megtekintéséhez.", "url": "URL", - "usage": "Felhasználás", + "usage": "Használat", "use_custom_date_range": "Szabadon megadott időintervallum használata", "user": "Felhasználó", "user_id": "Felhasználó azonosítója", @@ -1264,6 +1310,8 @@ "validate": "Ellenőrzés", "variables": "Változók", "version": "Verzió", + "version_announcement_closing": "Barátod, Alex", + "version_announcement_message": "Szia barátom, van egy új verziója az alkalmazásnak. Kérjük, szánj időt a verzióinformáció megtekintésére, és győződj meg róla, hogy a docker-compose.yml és a .env beállítások naprakészek, hogy elkerüld a hibás konfigurációt, különösen, ha WatchTower-t vagy valami más automatikus frissítési megoldást használsz.", "video": "Videó", "video_hover_setting": "Bélyegkép felett lebegésnél videó indítás", "video_hover_setting_description": "Ha az egér a bélyegkép felett időzik, a bélyegkép videó lejátszása induljon el. A lejátszás az indítás ikon feletti időzéssel akkor is elindul, ha ez az opció ki van kapcsolva.", @@ -1273,6 +1321,7 @@ "view_album": "Album megtekintése", "view_all": "Összes mutatása", "view_all_users": "Minden felhasználó megtekintése", + "view_in_timeline": "Megtekintés az idővonalon", "view_links": "Linkek megtekintése", "view_next_asset": "Következő elem megtekintése", "view_previous_asset": "Előző elem megtekintése", diff --git a/web/src/lib/i18n/id.json b/web/src/lib/i18n/id.json index 1321bd358b..99df952c21 100644 --- a/web/src/lib/i18n/id.json +++ b/web/src/lib/i18n/id.json @@ -41,6 +41,7 @@ "confirm_email_below": "Untuk mengonfirmasi, ketik \"{email}\" di bawah", "confirm_reprocess_all_faces": "Apakah Anda yakin ingin memproses semua wajah? Ini juga akan menghapus nama orang.", "confirm_user_password_reset": "Apakah Anda yakin ingin mengatur ulang kata sandi {user}?", + "create_job": "Buat tugas", "disable_login": "Nonaktifkan log masuk", "duplicate_detection_job_description": "Jalankan pembelajaran mesin pada aset untuk mendeteksi gambar yang serupa. Bergantung pada Pencarian Pintar", "exclusion_pattern_description": "Pola pengecualian memungkinkan Anda mengabaikan berkas dan folder ketika memindai pustaka Anda. Ini berguna jika Anda memiliki folder yang berisi berkas yang tidak ingin diimpor, seperti berkas RAW.", @@ -68,6 +69,7 @@ "image_thumbnail_resolution": "Resolusi gambar kecil", "image_thumbnail_resolution_description": "Digunakan ketika menampilkan kelompok foto (lini masa utama, tampilan album, dll.). Resolusi yang lebih tinggi dapat menjaga lebih banyak detail tetapi memerlukan waktu lama untuk mengode, memiliki ukuran berkas yang lebih besar, dan dapat mengurangi respons aplikasi.", "job_concurrency": "Konkurensi {job}", + "job_created": "Tugas telah dibuat", "job_not_concurrency_safe": "Tugas ini tidak aman untuk konkurensi.", "job_settings": "Pengaturan Tugas", "job_settings_description": "Kelola konkurensi tugas", @@ -196,6 +198,7 @@ "password_settings": "Log Masuk Kata Sandi", "password_settings_description": "Kelola pengaturan log masuk kata sandi", "paths_validated_successfully": "Semua jalur berhasil divalidasi", + "person_cleanup_job": "Pembersihan data pribadi", "quota_size_gib": "Ukuran Kuota (GiB)", "refreshing_all_libraries": "Menyegarkan semua pustaka", "registration": "Pendaftaran Admin", @@ -209,6 +212,7 @@ "reset_settings_to_recent_saved": "Atur ulang pengaturan ke pengaturan tersimpan terkini", "scanning_library_for_changed_files": "Memindai pustaka untuk berkas yang telah diubah", "scanning_library_for_new_files": "Memindai pustaka untuk berkas baru", + "search_jobs": "Mencari tugas...", "send_welcome_email": "Kirim surel selamat datang", "server_external_domain_settings": "Domain eksternal", "server_external_domain_settings_description": "Domain untuk tautan terbagi publik, termasuk http(s)://", @@ -236,6 +240,7 @@ "storage_template_settings_description": "Kelola struktur folder dan nama berkas dari aset yang diunggah", "storage_template_user_label": "{label} adalah Label Penyimpanan pengguna", "system_settings": "Pengaturan Sistem", + "tag_cleanup_job": "Pembersihan tag", "theme_custom_css_settings": "CSS Kustom", "theme_custom_css_settings_description": "CSS memungkinkan desain Immich untuk diubah.", "theme_settings": "Pengaturan Tema", @@ -309,6 +314,7 @@ "trash_settings_description": "Kelola pengaturan sampah", "untracked_files": "Berkas yang Belum Dilacak", "untracked_files_description": "Berkas ini tidak dilacak oleh aplikasi. Mereka dapat diakibatkan oleh pemindahan gagal, pengunggahan terganggu, atau tertinggal karena oleh kutu", + "user_cleanup_job": "Pembersihan data pengguna", "user_delete_delay": "Akun dan aset {user} akan dijadwalkan untuk penghapusan permanen dalam {delay, plural, one {# hari} other {# hari}}.", "user_delete_delay_settings": "Jeda penghapusan", "user_delete_delay_settings_description": "Jumlah hari setelah penghapusan untuk menghapus akun dan aset pengguna secara permanen. Tugas penghapusan pengguna berjalan pada tengah malam untuk memeriksa pengguna yang siap untuk dihapus. Perubahan pengaturan ini akan dievaluasi pada eksekusi berikutnya.", @@ -1111,6 +1117,7 @@ "search_options": "Pilihan pencarian", "search_people": "Cari orang", "search_places": "Cari tempat", + "search_settings": "Pengaturan pencarian", "search_state": "Cari negara bagian...", "search_tags": "Cari tag...", "search_timezone": "Cari zona waktu...", diff --git a/web/src/lib/i18n/lv.json b/web/src/lib/i18n/lv.json index 2701cda4e8..8f0835397d 100644 --- a/web/src/lib/i18n/lv.json +++ b/web/src/lib/i18n/lv.json @@ -129,7 +129,7 @@ "notification_email_test_email_sent": "", "notification_email_username_description": "", "notification_enable_email_notifications": "", - "notification_settings": "", + "notification_settings": "Paziņojumu iestatījumi", "notification_settings_description": "", "oauth_auto_launch": "", "oauth_auto_launch_description": "", @@ -279,19 +279,22 @@ "archive_or_unarchive_photo": "", "archive_size": "Arhīva izmērs", "archived": "", + "are_these_the_same_person": "Vai šī ir tā pati persona?", "asset_offline": "", "asset_uploading": "Augšupielādē...", "assets": "aktīvi", "authorized_devices": "", "back": "Atpakaļ", "backward": "", + "birthdate_saved": "Dzimšanas datums veiksmīgi saglabāts", + "birthdate_set_description": "Dzimšanas datums tiek izmantots, lai aprēķinātu šīs personas vecumu fotogrāfijas uzņemšanas brīdī.", "blurred_background": "", "camera": "", "camera_brand": "", "camera_model": "", "cancel": "Atcelt", "cancel_search": "", - "cannot_merge_people": "", + "cannot_merge_people": "Nevar apvienot cilvēkus", "cannot_update_the_description": "", "cant_apply_changes": "", "cant_get_faces": "", @@ -301,17 +304,18 @@ "change_expiration_time": "Izmainīt derīguma termiņu", "change_location": "", "change_name": "", - "change_name_successfully": "", - "change_password": "Nomainīt Paroli", + "change_name_successfully": "Vārds veiksmīgi nomainīts", + "change_password": "Nomainīt paroli", "change_your_password": "", "changed_visibility_successfully": "", "check_logs": "", + "choose_matching_people_to_merge": "Izvēlies atbilstošus cilvēkus apvienošanai", "city": "Pilsēta", "clear": "Notīrīt", "clear_all": "", "clear_message": "", "clear_value": "", - "close": "", + "close": "Aizvērt", "collapse_all": "", "color_theme": "", "comment_options": "", @@ -349,6 +353,7 @@ "date_after": "", "date_and_time": "Datums un Laiks", "date_before": "", + "date_of_birth_saved": "Dzimšanas datums veiksmīgi saglabāts", "date_range": "Datumu diapazons", "day": "", "default_locale": "", @@ -401,6 +406,8 @@ "edit_user": "Labot lietotāju", "edited": "", "editor": "", + "editor_close_without_save_prompt": "Izmaiņas netiks saglabātas", + "editor_close_without_save_title": "Aizvērt redaktoru?", "email": "E-pasts", "empty": "", "empty_album": "", @@ -411,6 +418,7 @@ "error": "", "error_loading_image": "", "errors": { + "cant_search_people": "Neizdevās veikt peronu meklēšanu", "failed_to_create_album": "Neizdevās izveidot albumu", "unable_to_add_album_users": "", "unable_to_add_comment": "", @@ -429,7 +437,7 @@ "unable_to_empty_trash": "", "unable_to_enter_fullscreen": "", "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", + "unable_to_hide_person": "Neizdevās paslēpt personu", "unable_to_load_album": "", "unable_to_load_asset_activity": "", "unable_to_load_items": "", @@ -449,6 +457,7 @@ "unable_to_restore_trash": "", "unable_to_restore_user": "", "unable_to_save_album": "", + "unable_to_save_date_of_birth": "Neizdevās saglabāt dzimšanas datumu", "unable_to_save_name": "", "unable_to_save_profile": "", "unable_to_save_settings": "", @@ -500,50 +509,57 @@ "group_albums_by": "", "has_quota": "Ir kvota", "hide_gallery": "", + "hide_named_person": "Paslēpt personu {name}", "hide_password": "", - "hide_person": "", + "hide_person": "Paslēpt personu", "host": "", "hour": "", "image": "Attēls", "img": "", - "immich_logo": "", - "import_path": "", - "in_archive": "", + "immich_logo": "Immich logo", + "import_from_json": "Importēt no JSON", + "import_path": "Importa ceļš", + "in_albums": "{count, plural, one {# albumā} other {# albumos}}", + "in_archive": "Arhīvā", "include_archived": "Iekļaut arhivētos", - "include_shared_albums": "", + "include_shared_albums": "Iekļaut koplietotos albumus", "include_shared_partner_assets": "", "individual_share": "", - "info": "", + "info": "Informācija", "interval": { - "day_at_onepm": "", + "day_at_onepm": "Katru dienu 13.00", "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" + "night_at_midnight": "Katru dienu pusnaktī", + "night_at_twoam": "Katru dienu 2.00 naktī" }, - "invite_people": "", + "invite_people": "Ielūgt cilvēkus", "invite_to_album": "Uzaicināt albumā", "job_settings_description": "", - "jobs": "", - "keep": "", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", - "leave": "", + "jobs": "Darbi", + "keep": "Paturēt", + "keep_all": "Paturēt visus", + "keyboard_shortcuts": "Tastatūras saīsnes", + "language": "Valoda", + "language_setting_description": "Izvēlieties vēlamo valodu", + "last_seen": "Pēdējo reizi redzēts", + "latest_version": "Jaunākā versija", + "latitude": "Ģeogrāfiskais platums", + "leave": "Paturēt", "let_others_respond": "Ļaut citiem atbildēt", - "level": "", + "level": "Līmenis", "library": "Bibliotēka", "library_options": "", "light": "", "link_options": "", "link_to_oauth": "", "linked_oauth_account": "", - "list": "", - "loading": "", + "list": "Saraksts", + "loading": "Ielādē", "loading_search_results_failed": "", "log_out": "Izrakstīties", "log_out_all_devices": "", "login_has_been_disabled": "", + "longitude": "Ģeogrāfiskais garums", "look": "", "loop_videos": "", "loop_videos_description": "Iespējot, lai automātiski videoklips tiktu cikliski palaists detaļu skatītājā.", @@ -559,46 +575,53 @@ "map_marker_for_images": "Kartes marķieris attēliem, kas uzņemti {city}, {country}", "map_marker_with_image": "Kartes marķieris ar attēlu", "map_settings": "Kartes Iestatījumi", - "media_type": "", - "memories": "", + "matches": "Atbilstības", + "media_type": "Multivides veids", + "memories": "Atmiņas", "memories_setting_description": "", - "menu": "", - "merge": "", - "merge_people": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", - "missing": "", + "memory": "Atmiņa", + "menu": "Izvēlne", + "merge": "Apvienot", + "merge_people": "Cilvēku apvienošana", + "merge_people_limit": "Vienlaikus var apvienot ne vairāk kā 5 sejas", + "merge_people_prompt": "Vai vēlies apvienot šos cilvēkus? Šī darbība ir neatgriezeniska.", + "merge_people_successfully": "Cilvēki veiksmīgi apvienoti", + "minimize": "Minimizēt", + "minute": "Minūte", + "missing": "Trūkstošie", "model": "Modelis", "month": "Mēnesis", - "more": "", + "more": "Vairāk", "moved_to_trash": "", - "my_albums": "", + "my_albums": "Mani albumi", "name": "Vārds", - "name_or_nickname": "", + "name_or_nickname": "Vārds vai iesauka", "never": "nekad", - "new_api_key": "", + "new_album": "Jauns albums", + "new_api_key": "Jauna API atslēga", "new_password": "Jaunā parole", - "new_person": "", + "new_person": "Jauna persona", "new_user_created": "Izveidots jauns lietotājs", + "new_version_available": "PIEEJAMA JAUNA VERSIJA", "newest_first": "", "next": "Nākošais", - "next_memory": "", - "no": "", + "next_memory": "Nākamā atmiņa", + "no": "Nē", "no_albums_message": "", "no_archived_assets_message": "", - "no_assets_message": "", + "no_assets_message": "NOKLIKŠĶINIET, LAI AUGŠUPIELĀDĒTU SAVU PIRMO FOTOATTĒLU", "no_duplicates_found": "Dublikāti netika atrasti.", - "no_exif_info_available": "", + "no_exif_info_available": "Nav pieejama exif informācija", "no_explore_results_message": "", "no_favorites_message": "", "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", + "no_name": "Nav nosaukuma", + "no_places": "Nav atrašanās vietu", + "no_results": "Nav rezultātu", + "no_results_description": "Izmēģiniet sinonīmu vai vispārīgāku atslēgvārdu", "no_shared_albums_message": "", - "not_in_any_album": "", - "notes": "", + "not_in_any_album": "Nav nevienā albumā", + "notes": "Piezīmes", "notification_toggle_setting_description": "", "notifications": "Paziņojumi", "notifications_setting_description": "", @@ -707,7 +730,9 @@ "search_city": "", "search_country": "", "search_for_existing_person": "", - "search_people": "", + "search_no_people": "Nav cilvēku", + "search_no_people_named": "Nav cilvēku ar vārdu \"{name}\"", + "search_people": "Meklēt cilvēkus", "search_places": "", "search_state": "", "search_timezone": "", @@ -732,7 +757,7 @@ "set": "", "set_as_album_cover": "", "set_as_profile_picture": "", - "set_date_of_birth": "", + "set_date_of_birth": "Iestatīt dzimšanas datumu", "set_profile_picture": "", "set_slideshow_to_fullscreen": "", "settings": "Iestatījumi", @@ -783,6 +808,7 @@ "theme": "Dizains", "theme_selection": "", "theme_selection_description": "", + "they_will_be_merged_together": "Tās tiks apvienotas", "time_based_memories": "", "timezone": "Laika zona", "toggle_settings": "", @@ -795,8 +821,8 @@ "type": "", "unarchive": "Atarhivēt", "unarchived": "", - "unfavorite": "Noņemt no Izlases", - "unhide_person": "", + "unfavorite": "Noņemt no izlases", + "unhide_person": "Atcelt personas slēpšanu", "unknown": "", "unknown_album": "", "unknown_year": "", @@ -836,6 +862,7 @@ "week": "", "welcome_to_immich": "", "year": "", + "years_ago": "Pirms {years, plural, one {# gada} other {# gadiem}}", "yes": "Jā", "zoom_image": "Pietuvināt attēlu" } diff --git a/web/src/lib/i18n/nl.json b/web/src/lib/i18n/nl.json index dc9f003978..23dfa7633d 100644 --- a/web/src/lib/i18n/nl.json +++ b/web/src/lib/i18n/nl.json @@ -41,6 +41,7 @@ "confirm_email_below": "Typ hieronder \"{email}\" ter bevestiging", "confirm_reprocess_all_faces": "Weet je zeker dat je alle gezichten opnieuw wilt verwerken? Hiermee worden ook alle mensen gewist.", "confirm_user_password_reset": "Weet u zeker dat je het wachtwoord van {user} wilt resetten?", + "create_job": "Taak maken", "crontab_guru": "Crontab Guru", "disable_login": "Inloggen uitschakelen", "disabled": "Uitgeschakeld", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Thumbnail resolutie", "image_thumbnail_resolution_description": "Gebruikt wanneer groepen foto's bekeken worden (hoofdtijdslijn, album, enzo). Hogere resoluties kunnen meer detail behouden maar duren langer om te verwerken, hebben hogere bestandsgrootte, en kunnen de applicatie langzamer maken.", "job_concurrency": "{job} gelijktijdigheid", + "job_created": "Taak aangemaakt", "job_not_concurrency_safe": "Deze taak kan niet gelijktijdig worden uitgevoerd.", "job_settings": "Achtergrondtaak-instellingen", "job_settings_description": "Beheer gelijktijdige taken", @@ -211,6 +213,7 @@ "reset_settings_to_recent_saved": "Instellingen zijn gereset naar de recent opgeslagen instellingen", "scanning_library_for_changed_files": "Bibliotheek scannen op gewijzigde bestanden", "scanning_library_for_new_files": "Bibliotheek scannen op nieuwe bestanden", + "search_jobs": "Taak zoeken...", "send_welcome_email": "Stuur een welkomstmail", "server_external_domain_settings": "Extern domein", "server_external_domain_settings_description": "Domein voor openbaar gedeelde links, inclusief http(s)://", @@ -661,6 +664,7 @@ "unable_to_get_comments_number": "Kan het aantal opmerkingen niet ophalen", "unable_to_get_shared_link": "Kan gedeelde link niet ophalen", "unable_to_hide_person": "Kan persoon niet verbergen", + "unable_to_link_motion_video": "Kan bewegende video niet verbinden", "unable_to_link_oauth_account": "Kan OAuth account niet koppelen", "unable_to_load_album": "Kan album niet laden", "unable_to_load_asset_activity": "Kan asset activiteit niet laden", @@ -701,6 +705,7 @@ "unable_to_submit_job": "Kan taak niet uitvoeren", "unable_to_trash_asset": "Kan asset niet naar prullenbak verplaatsen", "unable_to_unlink_account": "Kan account niet ontkoppelen", + "unable_to_unlink_motion_video": "Kan bewegende video niet los maken", "unable_to_update_album_cover": "Kan album cover niet bijwerken", "unable_to_update_album_info": "Kan albumgegevens niet bijwerken", "unable_to_update_library": "Kan bibliotheek niet bijwerken", @@ -846,6 +851,7 @@ "license_trial_info_4": "Overweeg een licentie te kopen om de verdere ontwikkeling van de service te ondersteunen", "light": "Licht", "like_deleted": "Like verwijderd", + "link_motion_video": "verbind bewegende video", "link_options": "Opties voor link", "link_to_oauth": "Koppel OAuth", "linked_oauth_account": "Gekoppeld OAuth account", @@ -1139,6 +1145,7 @@ "search_options": "Zoekopties", "search_people": "Zoek mensen", "search_places": "Zoek plaatsen", + "search_settings": "Zoek instellingen", "search_state": "Zoek staat...", "search_tags": "Tags zoeken...", "search_timezone": "Zoek tijdzone...", @@ -1291,6 +1298,7 @@ "unknown_album": "Onbekend album", "unknown_year": "Onbekend jaar", "unlimited": "Onbeperkt", + "unlink_motion_video": "Maak bewegende video los", "unlink_oauth": "Ontkoppel OAuth", "unlinked_oauth_account": "OAuth account ontkoppeld", "unnamed_album": "Naamloos album", diff --git a/web/src/lib/i18n/pt.json b/web/src/lib/i18n/pt.json index ebe1e85729..5c20ffb81a 100644 --- a/web/src/lib/i18n/pt.json +++ b/web/src/lib/i18n/pt.json @@ -1,11 +1,11 @@ { "about": "Sobre", "account": "Conta", - "account_settings": "Configurações da Conta", + "account_settings": "Definições da Conta", "acknowledge": "Confirmar", "action": "Ação", "actions": "Ações", - "active": "Ativo", + "active": "Em execução", "activity": "Atividade", "activity_changed": "A actividade está {enabled, select, true {ativada} other {desativada}}", "add": "Adicionar", @@ -22,338 +22,347 @@ "add_photos": "Adicionar fotos", "add_to": "Adicionar a...", "add_to_album": "Adicionar ao álbum", - "add_to_shared_album": "Adicionar ao álbum compartilhado", + "add_to_shared_album": "Adicionar ao álbum partilhado", "added_to_archive": "Adicionado ao arquivo", "added_to_favorites": "Adicionado aos favoritos", "added_to_favorites_count": "{count, plural, one {{count, number} adicionado aos favoritos} other {{count, number} adicionados aos favoritos}}", "admin": { - "add_exclusion_pattern_description": "Adicione padrões de exclusão. Utilizar *, ** ou ? são suportados. Para ignorar todos os arquivos em qualquer diretório chamado \"Raw\", use \"**/Raw/**'. Para ignorar todos os arquivos que finalizam em \".tif\", use \"**/*.tif\". Para ignorar um caminho absoluto, use \"/caminho/para/ignorar/**\".", - "authentication_settings": "Configurações de Autenticação", - "authentication_settings_description": "Gerenciar senhas, OAuth, e outras configurações de autenticação", - "authentication_settings_disable_all": "Tem a certeza que deseja desativar todos os métodos de entrada? Entrar será completamente desativado.", + "add_exclusion_pattern_description": "Adicione padrões de exclusão. Utilizar *, ** ou ? são suportados. Para ignorar todos os ficheiros em qualquer diretório chamado \"Raw\", use \"**/Raw/**'. Para ignorar todos os ficheiros que finalizam em \".tif\", use \"**/*.tif\". Para ignorar um caminho absoluto, use \"/caminho/para/ignorar/**\".", + "authentication_settings": "Definições de Autenticação", + "authentication_settings_description": "Gerir palavras-passe, OAuth, e outras definições de autenticação", + "authentication_settings_disable_all": "Tem a certeza que deseja desativar todos os métodos de início de sessão? O início de sessão será completamente desativado.", "authentication_settings_reenable": "Para reativar, use um Comando de servidor.", "background_task_job": "Tarefas em segundo plano", "check_all": "Selecionar Tudo", "cleared_jobs": "Eliminadas as tarefas de: {job}", - "config_set_by_file": "A configuração está atualmente definida por um arquivo de configuração", - "confirm_delete_library": "Você tem certeza que deseja excluir a biblioteca {library} ?", - "confirm_delete_library_assets": "Você tem certeza que deseja eliminar esta biblioteca? Isto eliminará {count, plural, one {# arquivo incluído} other {todos os # arquivos incluídos}} do Immich e esta ação não pode ser revertida. Os ficheiros permanecerão no disco.", - "confirm_email_below": "Para confirmar, digite o {email} abaixo", - "confirm_reprocess_all_faces": "Tem certeza de que deseja reprocessar todos as faces? Isso também limpará as pessoas nomeadas.", - "confirm_user_password_reset": "Tem certeza de que deseja redefinir a senha de {user}?", + "config_set_by_file": "A configuração está atualmente definida por um ficheiro de configuração", + "confirm_delete_library": "Tem a certeza de que deseja eliminar a biblioteca {library} ?", + "confirm_delete_library_assets": "Você tem certeza que deseja eliminar esta biblioteca? Isto eliminará {count, plural, one {# ficheiro incluído} other {todos os # ficheiros incluídos}} do Immich e esta ação não pode ser revertida. Os ficheiros permanecerão no disco.", + "confirm_email_below": "Para confirmar, escreva \"{email}\" abaixo", + "confirm_reprocess_all_faces": "Tem a certeza de que deseja reprocessar todos os rostos? Isto também limpará os nomes de pessoas.", + "confirm_user_password_reset": "Tem a certeza de que deseja redefinir a palavra-passe de {user}?", + "create_job": "Criar tarefa", "crontab_guru": "Guru do Crontab", - "disable_login": "Desabilitar login", + "disable_login": "Desativar inicio de sessão", "disabled": "", - "duplicate_detection_job_description": "Execute o aprendizado de máquina em arquivos para detectar imagens semelhantes. Depende da pesquisa inteligente", - "exclusion_pattern_description": "Os padrões de exclusão permitem ignorar arquivos e pastas ao escanear sua biblioteca. Isso é útil se você tiver pastas que contenham arquivos que não deseja importar, como arquivos RAW.", + "duplicate_detection_job_description": "Executa a aprendizagem de máquina em ficheiros para detetar imagens semelhantes. Depende da Pesquisa Inteligente", + "exclusion_pattern_description": "Os padrões de exclusão permitem ignorar ficheiros e pastas ao analisar a sua biblioteca. Isto é útil se tiver pastas que contenham ficheiros que não deseja importar, como ficheiros RAW.", "external_library_created_at": "Biblioteca externa (criada em {date})", - "external_library_management": "Gerenciamento de bibliotecas externas", - "face_detection": "Detecção de faces", - "face_detection_description": "Deteta rostos em arquivos com aprendizagem automática. Para vídeos, apenas a miniatura é considerada. \"Todos\" (re)processa todos os arquivos. \"Ausente\" enfileira arquivos que ainda não foram processados. Os rostos detetados serão enfileirados para reconhecimento facial após a conclusão da deteção de rostos, agrupando-os em pessoas novas ou já existentes.", - "facial_recognition_job_description": "Agrupa rostos detectados em pessoas. Esta etapa é executada após a conclusão da deteção de faces. \"Todos\" (re)agrupa todos os rostos. \"Ausentes\" enfileira rostos que ainda não têm uma pessoa atribuída.", + "external_library_management": "Gestão de bibliotecas externas", + "face_detection": "Deteção de Rostos", + "face_detection_description": "Deteta rostos em ficheiros utilizando aprendizagem automática. Para vídeos, apenas a miniatura é considerada. \"Todos\" (re)processa todos os ficheiros. \"Ausente\" coloca em fila ficheiros que ainda não foram processados. Os rostos detetados serão colocados em fila para Reconhecimento Facial após a conclusão da Deteção de Rostos, agrupando-os em pessoas novas ou já existentes.", + "facial_recognition_job_description": "Agrupa rostos detetadas em pessoas. Esta etapa é executada após a conclusão da Deteção de Rostos. \"Todos\" (re)agrupa todos os rostos. \"Ausentes\" coloca em fila rostos que ainda não têm uma pessoa atribuída.", "failed_job_command": "Comando {command} falhou para a tarefa: {job}", - "force_delete_user_warning": "AVISO: Isso removerá imediatamente o utilizador e todos os arquivos. Isso não pode ser desfeito e os ficheiros não poderão ser recuperados.", - "forcing_refresh_library_files": "Forçando a atualização de todos os arquivos da biblioteca", - "image_format_description": "WebP produz arquivos menores que JPEG, mas é mais lento para codificar.", - "image_prefer_embedded_preview": "Prefira visualização incorporada", - "image_prefer_embedded_preview_setting_description": "Use visualizações incorporadas em fotos RAW como entrada para processamento de imagem, quando disponível. Isso pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmera e a imagem pode ter mais artefatos de compactação.", + "force_delete_user_warning": "AVISO: Isto removerá imediatamente o utilizador e todos os ficheiros. Isso não pode ser revertido e os ficheiros não poderão ser recuperados.", + "forcing_refresh_library_files": "A forçar a atualização de todos os ficheiros da biblioteca", + "image_format_description": "WebP produz ficheiros mais pequenos do que JPEG, mas é mais lento para codificar.", + "image_prefer_embedded_preview": "Preferir visualização incorporada", + "image_prefer_embedded_preview_setting_description": "Utilizar visualizações incorporadas em fotos RAW como entrada para processamento de imagem, quando disponível. Isto pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmara e a imagem pode ter mais artefatos de compressão.", "image_prefer_wide_gamut": "Prefira ampla gama", - "image_prefer_wide_gamut_setting_description": "Use o Display P3 para miniaturas. Isso preserva melhor a vibração das imagens com espaços de cores amplos, mas as imagens podem aparecer de maneira diferente em dispositivos antigos com uma versão antiga do navegador. As imagens sRGB são mantidas como sRGB para evitar mudanças de cores.", + "image_prefer_wide_gamut_setting_description": "Utilizar Display P3 para miniaturas. Isso preserva melhor a vibrância das imagens com espaços de cores amplos, mas as imagens podem aparecer de maneira diferente em dispositivos antigos com uma versão antiga do navegador. As imagens sRGB são mantidas como sRGB para evitar mudanças de cores.", "image_preview_format": "Formato de visualização", "image_preview_resolution": "Resolução de visualização", - "image_preview_resolution_description": "Usado ao visualizar uma única foto e para aprendizado de máquina. Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de arquivo maiores e podem reduzir a capacidade de resposta do aplicativo.", + "image_preview_resolution_description": "Usado ao visualizar uma única foto e para aprendizagem de máquina. Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", "image_quality": "Qualidade", - "image_quality_description": "Qualidade de imagem de 1 a 100. Quanto maior, melhor para a qualidade, mas produz arquivos maiores. Esta opção afeta as imagens de visualização e miniatura.", - "image_settings": "Configurações de imagem", - "image_settings_description": "Gerenciar a qualidade e resolução das imagens geradas", + "image_quality_description": "Qualidade de imagem de 1 a 100. Quanto maior, melhor para a qualidade, mas produz ficheiros maiores. Esta definição afeta as imagens de visualização e miniatura.", + "image_settings": "Definições de imagem", + "image_settings_description": "Gerir a qualidade e resolução das imagens geradas", "image_thumbnail_format": "Formato de miniatura", "image_thumbnail_resolution": "Resolução de miniatura", - "image_thumbnail_resolution_description": "Usado ao visualizar grupos de fotos (linha do tempo principal, visualização de álbum, etc.). Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de arquivo maiores e podem reduzir a capacidade de resposta do aplicativo.", - "job_concurrency": "{job} simultâneo", - "job_not_concurrency_safe": "Este trabalho não é compatível com simultaneidade.", - "job_settings": "Configurações de trabalho", - "job_settings_description": "Gerenciar simultaneidade dos trabalhos", - "job_status": "Status do trabalho", + "image_thumbnail_resolution_description": "Utilizado ao visualizar grupos de fotos (linha do tempo principal, visualização de álbum, etc.). Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", + "job_concurrency": "{job} em simultâneo", + "job_created": "Tarefa criada", + "job_not_concurrency_safe": "Esta tarefa não pode ser executada em simultâneo.", + "job_settings": "Definições de Tarefas", + "job_settings_description": "Gerir tarefas em simultâneo", + "job_status": "Estado das Tarefas", "jobs_delayed": "{jobCount, plural, one {# adiado} other {# adiados}}", "jobs_failed": "{jobCount, plural, one {# falhou} other {# falharam}}", - "library_created": "Criado biblioteca: {library}", + "library_created": "Criada biblioteca: {library}", "library_cron_expression": "Expressão Cron", "library_cron_expression_description": "Defina o intervalo de procura utilizando o formato cron. Para mais informações consulte Guru Crontab", "library_cron_expression_presets": "Predefinições de expressão Cron", - "library_deleted": "Biblioteca excluída", - "library_import_path_description": "Especifique uma pasta para importar. Esta pasta, incluindo subpastas, será escaneada em busca de imagens e vídeos.", - "library_scanning": "Escanear periódicamente", - "library_scanning_description": "Configurar o escaneamento periódico da biblioteca", - "library_scanning_enable_description": "Habilitar escaneamento periódico da biblioteca", + "library_deleted": "Biblioteca eliminada", + "library_import_path_description": "Especifique uma pasta para importar. Esta pasta, incluindo sub-pastas, será analisada por imagens e vídeos.", + "library_scanning": "Análise periódica", + "library_scanning_description": "Configurar a análise periódica da biblioteca", + "library_scanning_enable_description": "Ativar análise periódica da biblioteca", "library_settings": "Biblioteca Externa", - "library_settings_description": "Gerenciar configurações de biblioteca externa", - "library_tasks_description": "Execute tarefas de biblioteca", - "library_watching_enable_description": "Observe bibliotecas externas para alterações de arquivos", - "library_watching_settings": "Observação de biblioteca (EXPERIMENTAL)", - "library_watching_settings_description": "Observe automaticamente os arquivos alterados", - "logging_enable_description": "Habilitar registro", - "logging_level_description": "Quando ativado, qual nível de log usar.", - "logging_settings": "Registros", + "library_settings_description": "Gerir definições de biblioteca externa", + "library_tasks_description": "Executa tarefas de biblioteca", + "library_watching_enable_description": "Analisar bibliotecas externas por alterações de ficheiros", + "library_watching_settings": "Análise de biblioteca (EXPERIMENTAL)", + "library_watching_settings_description": "Analise automaticamente por ficheiros alterados", + "logging_enable_description": "Ativar registo", + "logging_level_description": "Quando ativado, qual o nível de log a usar.", + "logging_settings": "Registo", "machine_learning_clip_model": "Modelo CLIP", - "machine_learning_clip_model_description": "O nome do modelo CLIP definido aqui. Note que é necessário voltar a executar a \"Pesquisa Inteligente\" para todas as imagens depois de alterar um modelo.", - "machine_learning_duplicate_detection": "Detecção de duplicidade", - "machine_learning_duplicate_detection_enabled": "Habilitar detecção de duplicidade", - "machine_learning_duplicate_detection_enabled_description": "Se desativado, arquivos exatamente idênticos ainda serão desduplicados.", - "machine_learning_duplicate_detection_setting_description": "Use embeddings CLIP para encontrar prováveis duplicidades", - "machine_learning_enabled": "Habilitar o aprendizado da máquina", - "machine_learning_enabled_description": "Se desativado, todos os recursos de ML serão desativados, independentemente das configurações abaixo.", + "machine_learning_clip_model_description": "O nome do modelo CLIP definido aqui. Tome nota de que é necessário voltar a executar a tarefa de \"Pesquisa Inteligente\" para todas as imagens depois de alterar o modelo.", + "machine_learning_duplicate_detection": "Deteção de Itens Duplicados", + "machine_learning_duplicate_detection_enabled": "Ativar deteção de itens duplicados", + "machine_learning_duplicate_detection_enabled_description": "Se desativado, ficheiros exatamente idênticos serão desduplicados na mesma.", + "machine_learning_duplicate_detection_setting_description": "Utilizar embeddings CLIP para encontrar itens provavelmente duplicados", + "machine_learning_enabled": "Ativar a aprendizagem de máquina", + "machine_learning_enabled_description": "Se desativado, todos as funcionalidades de ML serão desativados, independentemente das definições abaixo.", "machine_learning_facial_recognition": "Reconhecimento Facial", - "machine_learning_facial_recognition_description": "Deteta, reconhece e agrupa rostos em imagens", + "machine_learning_facial_recognition_description": "Detetar, reconhecer e agrupar rostos em imagens", "machine_learning_facial_recognition_model": "Modelo de reconhecimento facial", - "machine_learning_facial_recognition_model_description": "Os modelos estão listados em ordem decrescente de tamanho. Modelos maiores são mais lentos e utilizam mais memória, mas produzem melhores resultados. Observe que ao alterar um modelo, você deve executar novamente o trabalho de Detecção de faces para todas as imagens.", + "machine_learning_facial_recognition_model_description": "Os modelos estão ordenados por ordem decrescente de tamanho. Modelos maiores são mais lentos e utilizam mais memória, mas produzem melhores resultados. Tome conta de que ao alterar um modelo, deve executar novamente a tarefa de \"Deteção de Rostos\" para todas as imagens.", "machine_learning_facial_recognition_setting": "Ativar reconhecimento facial", - "machine_learning_facial_recognition_setting_description": "Se desativado, as imagens não serão codificadas para reconhecimento facial e não preencherão a seção Pessoas na página Explorar.", - "machine_learning_max_detection_distance": "Distância máxima de detecção", - "machine_learning_max_detection_distance_description": "Distância máxima entre duas imagens para considerá-las duplicadas, variando de 0,001 a 0,1. Valores mais altos detectarão mais duplicidades, mas poderão resultar em falsos positivos.", + "machine_learning_facial_recognition_setting_description": "Se desativado, as imagens não serão codificadas para reconhecimento facial e não preencherão a secção Pessoas na página Explorar.", + "machine_learning_max_detection_distance": "Distância máxima de deteção", + "machine_learning_max_detection_distance_description": "Distância máxima entre duas imagens para considerá-las duplicadas, variando de 0,001 a 0,1. Valores mais altos detetarão mais duplicidades, mas poderão resultar em falsos positivos.", "machine_learning_max_recognition_distance": "Distância máxima de reconhecimento", - "machine_learning_max_recognition_distance_description": "Distância máxima entre duas faces para ser considerada a mesma pessoa, variando de 0 a 2. Valores menores evitam rotular duas faces como a mesma pessoa, enquanto valores maiores evitam rotular a mesma face como duas pessoas diferentes. Observe que é mais fácil mesclar duas pessoas do que dividir uma pessoa em duas, portanto tenha preferência por valores mais baixos quando possível.", - "machine_learning_min_detection_score": "Pontuação mínima de detecção", - "machine_learning_min_detection_score_description": "Pontuação mínima de confiança para uma face ser detectada, de 0 a 1. Valores mais baixos detectam mais rostos, mas poderão resultar em falsos positivos.", - "machine_learning_min_recognized_faces": "Mínimo de faces reconhecidas", - "machine_learning_min_recognized_faces_description": "O número mínimo de faces reconhecidas para uma pessoa ser criada na lista. Aumentar isso torna o Reconhecimento Facial mais preciso, ao custo de aumentar a chance de um rosto não ser atribuído a uma pessoa.", - "machine_learning_settings": "Configurações de aprendizado de máquina (Machine Learning)", - "machine_learning_settings_description": "Gerenciar recursos e configurações de aprendizado de máquina", - "machine_learning_smart_search": "Busca inteligente", - "machine_learning_smart_search_description": "Pesquise imagens semanticamente usando embeddings CLIP", - "machine_learning_smart_search_enabled": "Habilite a pesquisa inteligente", - "machine_learning_smart_search_enabled_description": "Se desativado, as imagens não serão codificadas para pesquisa inteligente.", - "machine_learning_url_description": "URL do servidor de aprendizado de máquina", - "manage_concurrency": "Gerenciar simultaneidade", - "manage_log_settings": "Gerenciar configurações de registro", + "machine_learning_max_recognition_distance_description": "Distância máxima entre dois rostos para serem considerados a mesma pessoa, variando de 0 a 2. Valores menores evitam rotular dois rostos como a mesma pessoa, enquanto valores maiores evitam rotular o mesmo rosto como duas pessoas diferentes. Tenha em conta de que é mais fácil unir duas pessoas do que dividir uma pessoa em duas, portanto tenha preferência por valores mais baixos quando possível.", + "machine_learning_min_detection_score": "Pontuação mínima de deteção", + "machine_learning_min_detection_score_description": "Pontuação mínima de confiança para um rosto ser detetado, de 0 a 1. Valores mais baixos detetam mais rostos, mas poderão resultar em falsos positivos.", + "machine_learning_min_recognized_faces": "Mínimo de rostos reconhecidos", + "machine_learning_min_recognized_faces_description": "O número mínimo de faces reconhecidas para uma pessoa ser criada na lista. Aumentar isto torna o Reconhecimento Facial mais preciso, no entanto aumenta a probabilidade de um rosto não ser atribuído a uma pessoa.", + "machine_learning_settings": "Definições de aprendizagem de máquina (Machine Learning)", + "machine_learning_settings_description": "Gerir funcionalidades e definições de aprendizagem de máquina", + "machine_learning_smart_search": "Pesquisa Inteligente", + "machine_learning_smart_search_description": "Pesquise imagens semanticamente utilizando embeddings CLIP", + "machine_learning_smart_search_enabled": "Ativar a Pesquisa Inteligente", + "machine_learning_smart_search_enabled_description": "Se desativado, as imagens não serão codificadas para Pesquisa Inteligente.", + "machine_learning_url_description": "URL do servidor de aprendizagem de máquina", + "manage_concurrency": "Gerir simultaneidade", + "manage_log_settings": "Gerir definições de registo", "map_dark_style": "Tema Escuro", - "map_enable_description": "Ativar recursos do mapa", + "map_enable_description": "Ativar funcionalidades de mapa", "map_gps_settings": "Mapas e Definições de GPS", - "map_gps_settings_description": "Configurações de mapas e GPS (Geocoding inverso)", - "map_implications": "A funcionalidade do mapa necessita um servico externo (tiles.immich.cloud)", + "map_gps_settings_description": "Gerir Definições de Mapas e GPS (Geocodificação Reversa)", + "map_implications": "A funcionalidades do mapa necessita um serviço externo (tiles.immich.cloud)", "map_light_style": "Tema Claro", - "map_manage_reverse_geocoding_settings": "Gerir definições de Geocoding inverso", - "map_reverse_geocoding": "Geocodificação reversa", - "map_reverse_geocoding_enable_description": "Ativar geocodificação reversa", - "map_reverse_geocoding_settings": "Configurações de geocodificação reversa", + "map_manage_reverse_geocoding_settings": "Gerir definições de Geocodificação Reversa", + "map_reverse_geocoding": "Geocodificação Reversa", + "map_reverse_geocoding_enable_description": "Ativar Geocodificação Reversa", + "map_reverse_geocoding_settings": "Definições de Geocodificação Reversa", "map_settings": "Mapa", - "map_settings_description": "Gerenciar configurações do mapa", + "map_settings_description": "Gerir definições do mapa", "map_style_description": "URL para um tema de mapa style.json", "metadata_extraction_job": "Extrair metadados", - "metadata_extraction_job_description": "Extrai informações de metadados de cada ativo, como GPS e resolução", + "metadata_extraction_job_description": "Extrai informações de metadados de cada ficheiro, como GPS, rostos e resolução", "metadata_faces_import_setting": "Ativar a importação facial", + "metadata_faces_import_setting_description": "Importar rostos a partir dos dados EXIF da imagem e ficheiros anexos", + "metadata_settings": "Definições de metadados", + "metadata_settings_description": "Gerir definições de metadados", "migration_job": "Migração", - "migration_job_description": "Migre miniaturas de arquivos e rostos para a estrutura de pastas mais recente", + "migration_job_description": "Migra miniaturas de ficheiros e rostos para a estrutura de pastas mais recente", "no_paths_added": "Nenhum caminho adicionado", "no_pattern_added": "Nenhum padrão adicionado", - "note_apply_storage_label_previous_assets": "Observação: Para aplicar o rótulo de armazenamento a arquivos carregados anteriormente, execute o", + "note_apply_storage_label_previous_assets": "Observação: Para aplicar o Rótulo de Armazenamento a ficheiros carregados anteriormente, execute o", "note_cannot_be_changed_later": "NOTA: Isto não pode ser alterado posteriormente!", - "note_unlimited_quota": "Observação: insira 0 para cota ilimitada", + "note_unlimited_quota": "Observação: insira 0 para quota ilimitada", "notification_email_from_address": "A partir do endereço", - "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", + "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Servidor de Fotos Immich \"", "notification_email_host_description": "Host do servidor de e-mail (por exemplo, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorar erros de certificado", "notification_email_ignore_certificate_errors_description": "Ignorar erros de validação de certificado TLS (não recomendado)", - "notification_email_password_description": "Senha a ser usada ao autenticar no servidor de e-mail", + "notification_email_password_description": "Palavra-passe a ser usada ao autenticar no servidor de e-mail", "notification_email_port_description": "Porta do servidor de e-mail (por exemplo, 25, 465 ou 587)", - "notification_email_sent_test_email_button": "Envie e-mail de teste e salve", - "notification_email_setting_description": "Configurações para envio de notificações por e-mail", + "notification_email_sent_test_email_button": "Enviar e-mail de teste e gravar", + "notification_email_setting_description": "Definições para envio de notificações por e-mail", "notification_email_test_email": "Enviar e-mail de teste", - "notification_email_test_email_failed": "Falha ao enviar e-mail de teste. Verifique seus valores", - "notification_email_test_email_sent": "Um email de teste foi enviado para {email}. Por favor, verifique sua caixa de entrada.", + "notification_email_test_email_failed": "Falha ao enviar e-mail de teste, verifique os valores", + "notification_email_test_email_sent": "Um email de teste foi enviado para {email}. Por favor, verifique a sua caixa de entrada.", "notification_email_username_description": "Nome de utilizador a ser usado ao autenticar com o servidor de e-mail", - "notification_enable_email_notifications": "Habilitar notificações por e-mail", - "notification_settings": "Configurações de notificação", - "notification_settings_description": "Gerenciar configurações de notificação, incluindo e-mail", - "oauth_auto_launch": "Inicialização automática", - "oauth_auto_launch_description": "Inicie o fluxo de login do OAuth automaticamente ao navegar até a página de login", - "oauth_auto_register": "Registro automático", - "oauth_auto_register_description": "Registre automaticamente novos utilizadores após fazer login com OAuth", - "oauth_button_text": "Botão de texto", + "notification_enable_email_notifications": "Ativar notificações por e-mail", + "notification_settings": "Definições de notificações", + "notification_settings_description": "Gerir definições de notificações, incluindo e-mail", + "oauth_auto_launch": "Arranque automático", + "oauth_auto_launch_description": "Iniciar o fluxo de login do OAuth automaticamente ao navegar até a página de inicio de sessão", + "oauth_auto_register": "Registo automático", + "oauth_auto_register_description": "Registar automaticamente novos utilizadores após iniciarem sessão com o OAuth", + "oauth_button_text": "Texto do botão", "oauth_client_id": "ID do Cliente", "oauth_client_secret": "Segredo do cliente", - "oauth_enable_description": "Faça login com OAuth", + "oauth_enable_description": "Iniciar sessão com o OAuth", "oauth_issuer_url": "URL do emissor", "oauth_mobile_redirect_uri": "URI de redirecionamento móvel", "oauth_mobile_redirect_uri_override": "Substituição de URI de redirecionamento móvel", - "oauth_mobile_redirect_uri_override_description": "Ative quando 'app.immich:/' for um URI de redirecionamento inválido.", + "oauth_mobile_redirect_uri_override_description": "Ative quando o provedor do OAuth não permite um URI móvel, como '{callback}'", "oauth_profile_signing_algorithm": "Algoritmo de assinatura de perfis", "oauth_profile_signing_algorithm_description": "Algoritmo utilizado para assinar o perfil de utilizador.", "oauth_scope": "Escopo", "oauth_settings": "OAuth", - "oauth_settings_description": "Gerenciar configurações de login do OAuth", + "oauth_settings_description": "Gerir definições de inicio de sessão do OAuth", "oauth_settings_more_details": "Para mais informações sobre esta funcionalidade, veja a documentação.", "oauth_signing_algorithm": "Algoritmo de assinatura", - "oauth_storage_label_claim": "Reivindicação de rótulo de armazenamento", - "oauth_storage_label_claim_description": "Defina automaticamente o rótulo de armazenamento do utilizador para o valor desta declaração.", - "oauth_storage_quota_claim": "Reivindicação de cota de armazenamento", - "oauth_storage_quota_claim_description": "Defina automaticamente a cota de armazenamento do utilizador para o valor desta declaração.", - "oauth_storage_quota_default": "Cota de armazenamento padrão (GiB)", - "oauth_storage_quota_default_description": "Cota em GiB a ser usada quando nenhuma reivindicação for fornecida (insira 0 para cota ilimitada).", - "offline_paths": "Caminhos off-line", - "offline_paths_description": "Esses resultados podem ser devidos à exclusão manual de arquivos que não fazem parte de uma biblioteca externa.", - "password_enable_description": "Login com e-mail e senha", - "password_settings": "Senha de acesso", - "password_settings_description": "Gerenciar configurações de login e senha", + "oauth_storage_label_claim": "Reivindicação de Rótulo de Armazenamento", + "oauth_storage_label_claim_description": "Definir automaticamente o Rótulo de Armazenamento do utilizador para o valor desta declaração.", + "oauth_storage_quota_claim": "Reivindicação de quota de armazenamento", + "oauth_storage_quota_claim_description": "Definir automaticamente a quota de armazenamento do utilizador para o valor desta declaração.", + "oauth_storage_quota_default": "Quota de armazenamento padrão (GiB)", + "oauth_storage_quota_default_description": "Quota em GiB a ser usada quando nenhuma reivindicação for fornecida (insira 0 para quota ilimitada).", + "offline_paths": "Caminhos Offline", + "offline_paths_description": "Estes resultados podem ser devidos à eliminação manual de ficheiros que não fazem parte de uma biblioteca externa.", + "password_enable_description": "Iniciar sessão com e-mail e palavra-passe", + "password_settings": "Palavra-passe de acesso", + "password_settings_description": "Gerir definições de inicio de sessão e palavra-passe", "paths_validated_successfully": "Todos os caminhos validados com sucesso", - "quota_size_gib": "Tamanho da cota (GiB)", - "refreshing_all_libraries": "Atualizando todas as bibliotecas", - "registration": "Registo de Admin", + "person_cleanup_job": "Limpeza de pessoas", + "quota_size_gib": "Tamanho da quota (GiB)", + "refreshing_all_libraries": "A atualizar todas as bibliotecas", + "registration": "Registo de Administrador", "registration_description": "Como é o primeiro utilizador no sistema, será marcado como administrador, e será responsável pelas tarefas administrativas, sendo que utilizadores adicionais serão criados por si.", - "removing_offline_files": "Removendo arquivos offline", + "removing_offline_files": "A remover ficheiros offline", "repair_all": "Reparar tudo", - "repair_matched_items": "Encontrado {count, plural, one {# item} other {# itens}}", - "repaired_items": "Reparado {count, plural, one {# item} other {# itens}}", - "require_password_change_on_login": "Exigir que o utilizador altere a senha no primeiro início de sessão", - "reset_settings_to_default": "Redefinir as configurações para o padrão", - "reset_settings_to_recent_saved": "Redefinir as configurações para as configurações salvas recentemente", - "scanning_library_for_changed_files": "Escaneando a biblioteca em busca de arquivos alterados", - "scanning_library_for_new_files": "Escaneando a biblioteca em busca de novos arquivos", + "repair_matched_items": "Encontrado(s) {count, plural, one {# item} other {# itens}}", + "repaired_items": "Reparado(s) {count, plural, one {# item} other {# itens}}", + "require_password_change_on_login": "Exigir que o utilizador altere a palavra-passe no primeiro início de sessão", + "reset_settings_to_default": "Redefinir as definições para o padrão", + "reset_settings_to_recent_saved": "Redefinir as definições para as guardadas mais recentemente", + "scanning_library_for_changed_files": "A analisar a biblioteca por ficheiros alterados", + "scanning_library_for_new_files": "A analisar a biblioteca por ficheiros novos", + "search_jobs": "Pesquisar tarefas...", "send_welcome_email": "Enviar e-mail de boas-vindas", "server_external_domain_settings": "Domínio externo", - "server_external_domain_settings_description": "Domínio para links públicos compartilhados, incluindo http(s)://", - "server_settings": "Configurações do servidor", - "server_settings_description": "Gerenciar configurações do servidor", + "server_external_domain_settings_description": "Domínio para links públicos partilhados, incluindo http(s)://", + "server_settings": "Definições do Servidor", + "server_settings_description": "Gerir definições do servidor", "server_welcome_message": "Mensagem de boas-vindas", - "server_welcome_message_description": "Uma mensagem exibida na página de login.", + "server_welcome_message_description": "Uma mensagem que é exibida na página de inicio de sessão.", "sidecar_job": "Metadados secundários", - "sidecar_job_description": "Descubra ou sincronize metadados secundários do sistema de arquivos", + "sidecar_job_description": "Descobrir ou sincronizar metadados secundários a partir do sistema de ficheiros", "slideshow_duration_description": "Tempo em segundos para exibir cada imagem", - "smart_search_job_description": "Execute a aprendizagem automática em arquivos para oferecer suporte à pesquisa inteligente", - "storage_template_date_time_description": "O registro de data e hora da criação é usado para fornecer essas informações", + "smart_search_job_description": "Execute a aprendizagem automática em ficheiros para oferecer apoio à Pesquisa Inteligente", + "storage_template_date_time_description": "O registo de data e hora de criação do ficheiro é usado para fornecer essas informações", "storage_template_date_time_sample": "Exemplo de tempo {date}", - "storage_template_enable_description": "Habilitar mecanismo de modelo de armazenamento", + "storage_template_enable_description": "Ativar mecanismo de modelo de armazenamento", "storage_template_hash_verification_enabled": "Verificação de hash ativada", - "storage_template_hash_verification_enabled_description": "Ativa a verificação de hash, não desative esta opção a menos que tenha certeza das implicações", + "storage_template_hash_verification_enabled_description": "Ativa a verificação de hash, não desative esta opção a menos que tenha a certeza das implicações", "storage_template_migration": "Migração de modelo de armazenamento", - "storage_template_migration_description": "Aplicar o {template} atual para arquivos previamente carregados", - "storage_template_migration_info": "As mudanças do modelo apenas se aplicarão a novos arquivos. Para aplicar o modelo retroativamente para os arquivos carregados anteriormente, execute o {job}.", - "storage_template_migration_job": "Trabalho de migração do modelo de armazenamento", - "storage_template_more_details": "Para mais informações sobre esta funcionalidade, dirija-se a Modelo de Armazenamento e as suas implicações", - "storage_template_onboarding_description": "Quando ativada, esta funcionalidade irá organizar os ficheiros automaticamente baseando-se num modelo definido pelo utilizador. Devido a problemas de estabilidade esta funcionalidade está desativada por defeito. Para mais informações, por favor leia a documentação.", + "storage_template_migration_description": "Aplica o {template} atual para ficheiros previamente carregados", + "storage_template_migration_info": "As mudanças do modelo apenas se aplicarão a novos ficheiros. Para aplicar o modelo retroativamente para os ficheiros carregados anteriormente, execute o {job}.", + "storage_template_migration_job": "Tarefa de Migração do Modelo de Armazenamento", + "storage_template_more_details": "Para mais informações sobre esta funcionalidade, dirija-se a Modelo de Armazenamento e às suas implicações", + "storage_template_onboarding_description": "Quando ativada, esta funcionalidade irá organizar os ficheiros automaticamente baseando-se num modelo definido pelo utilizador. Devido a problemas de estabilidade esta funcionalidade está desativada por padrão. Para mais informações, por favor leia a documentação.", "storage_template_path_length": "Limite aproximado do tamanho do caminho: {length, number}{limit, number}", - "storage_template_settings": "Modelo de armazenamento", - "storage_template_settings_description": "Gerenciar a estrutura de pastas e o nome do arquivo dos ativos carregados", + "storage_template_settings": "Modelo de Armazenamento", + "storage_template_settings_description": "Gerir a estrutura de pastas e o nome do ficheiro carregado", "storage_template_user_label": "{label} é o Rótulo do Armazenamento do utilizador", - "system_settings": "Configurações de Sistema", - "theme_custom_css_settings": "CSS customizado", - "theme_custom_css_settings_description": "Folhas de estilo em cascata permitem que o design do Immich seja personalizado.", - "theme_settings": "Configurações de tema", - "theme_settings_description": "Gerencie a personalização da interface web do Immich", - "these_files_matched_by_checksum": "Esses arquivos são correspondidos por seus checksum", + "system_settings": "Definições de Sistema", + "tag_cleanup_job": "Limpeza de etiquetas", + "theme_custom_css_settings": "CSS Personalizado", + "theme_custom_css_settings_description": "Folhas de estilo em cascata (CSS) permitem que o design do Immich seja personalizado.", + "theme_settings": "Definições de Tema", + "theme_settings_description": "Gerir a personalização da interface web do Immich", + "these_files_matched_by_checksum": "Estes ficheiros são correspondidos pelas suas somas de verificação", "thumbnail_generation_job": "Gerar miniaturas", - "thumbnail_generation_job_description": "Gere miniaturas grandes, pequenas e desfocadas para cada ativo, bem como miniaturas para cada pessoa", + "thumbnail_generation_job_description": "Gera miniaturas grandes, pequenas e desfocadas para cada ficheiro, bem como miniaturas para cada pessoa", "transcode_policy_description": "", "transcoding_acceleration_api": "API de aceleração", - "transcoding_acceleration_api_description": "A API que irá interagir com o seu dispositivo para acelerar a transcodificação. Esta configuração é a 'melhor opção': ela retornará à transcodificação de software em caso de falha. O VP9 pode não funcionar dependendo do seu hardware.", + "transcoding_acceleration_api_description": "A API que irá interagir com o seu dispositivo para acelerar a transcodificação. Esta definição é a 'melhor opção': ela voltará à transcodificação de software em caso de falha. O VP9 pode não funcionar dependendo do seu hardware.", "transcoding_acceleration_nvenc": "NVENC (requer GPU NVIDIA)", "transcoding_acceleration_qsv": "Quick Sync (requer CPU Intel de 7ª geração ou posterior)", "transcoding_acceleration_rkmpp": "RKMPP (apenas em SOCs Rockchip)", "transcoding_acceleration_vaapi": "VAAPI", - "transcoding_accepted_audio_codecs": "Codecs de áudio aceitos", - "transcoding_accepted_audio_codecs_description": "Selecione quais codecs de áudio não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", + "transcoding_accepted_audio_codecs": "Codecs de áudio aceites", + "transcoding_accepted_audio_codecs_description": "Selecione os codecs de áudio que não precisam de ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", "transcoding_accepted_containers": "Contentores aceites", - "transcoding_accepted_containers_description": "Selecione os formatos de contentores que não precisam de ser remuxed para MP4. Apenas usados para algumas políticas de transcodificação.", + "transcoding_accepted_containers_description": "Selecione os formatos de contentores que não precisam de ser remisturados para MP4. Usado apenas para algumas políticas de transcodificação.", "transcoding_accepted_video_codecs": "Codecs de vídeo aceitos", - "transcoding_accepted_video_codecs_description": "Selecione quais codecs de vídeo não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", - "transcoding_advanced_options_description": "Opções que a maioria dos utilizadores não deveria precisar alterar", + "transcoding_accepted_video_codecs_description": "Selecione quais os codecs de vídeo que não precisam de ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", + "transcoding_advanced_options_description": "Opções que a maioria dos utilizadores não deverá precisar de alterar", "transcoding_audio_codec": "Codec de áudio", - "transcoding_audio_codec_description": "Opus é a opção de mais alta qualidade, mas tem menor compatibilidade com dispositivos ou softwares antigos.", - "transcoding_bitrate_description": "Vídeos com taxa de bits superior à máxima ou que não estão em um formato aceito", - "transcoding_codecs_learn_more": "Para aprender mais sobre as terminologias utilizadas aqui, consulte a documentação do FFmpeg para o codec H.264, codec HEVC e codec VP9.", + "transcoding_audio_codec_description": "Opus é a opção de mais alta qualidade, mas tem menor compatibilidade com dispositivos ou software antigos.", + "transcoding_bitrate_description": "Vídeos com taxa de bits superior à máxima ou que não estão num formato aceite", + "transcoding_codecs_learn_more": "Para saber mais sobre as terminologias utilizadas aqui, consulte a documentação do FFmpeg para o codec H.264, codec HEVC e codec VP9.", "transcoding_constant_quality_mode": "Modo de qualidade constante", "transcoding_constant_quality_mode_description": "ICQ é melhor que CQP, mas alguns dispositivos de aceleração de hardware não suportam este modo. Definir esta opção dará preferência ao modo especificado ao usar codificação baseada em qualidade. Ignorado pelo NVENC porque não suporta ICQ.", "transcoding_constant_rate_factor": "Fator de taxa constante (-crf)", - "transcoding_constant_rate_factor_description": "Nível de qualidade do vídeo. Os valores típicos são 23 para H.264, 28 para HEVC, 31 para VP9 e 35 para AV1. Menor é melhor, mas produz arquivos maiores.", - "transcoding_disabled_description": "Não transcodifique nenhum vídeo, pois pode interromper a reprodução em alguns clientes", + "transcoding_constant_rate_factor_description": "Nível de qualidade do vídeo. Os valores típicos são 23 para H.264, 28 para HEVC, 31 para VP9 e 35 para AV1. Menor é melhor, mas produz ficheiros maiores.", + "transcoding_disabled_description": "Não transcodificar nenhum vídeo, no entanto pode causar erros de reprodução em alguns clientes", "transcoding_hardware_acceleration": "Aceleração de hardware", "transcoding_hardware_acceleration_description": "Experimental; muito mais rápido, mas terá qualidade inferior com a mesma taxa de bits", "transcoding_hardware_decoding": "Decodificação de hardware", "transcoding_hardware_decoding_setting_description": "Aplica-se apenas a NVENC, QSV e RKMPP. Permite aceleração ponta a ponta em vez de apenas acelerar a codificação. Pode não funcionar em todos os vídeos.", "transcoding_hevc_codec": "Codec HEVC", "transcoding_max_b_frames": "Máximo de quadros B", - "transcoding_max_b_frames_description": "Valores mais altos melhoram a eficiência da compactação, mas retardam a codificação. Pode não ser compatível com aceleração de hardware em dispositivos mais antigos. 0 desativa os quadros B, enquanto -1 define esse valor automaticamente.", + "transcoding_max_b_frames_description": "Valores mais altos melhoram a eficiência da compressão, mas tornam a codificação mais lenta. Pode não ser compatível com aceleração de hardware em dispositivos mais antigos. 0 desativa os quadros B, enquanto -1 define esse valor automaticamente.", "transcoding_max_bitrate": "Taxa de bits máxima", - "transcoding_max_bitrate_description": "Definir uma taxa de bits máxima pode tornar os tamanhos dos arquivos mais previsíveis com um custo menor de qualidade. Em 720p, os valores típicos são 2.600k para VP9 ou HEVC, ou 4.500k para H.264. Desativado se definido como 0.", + "transcoding_max_bitrate_description": "Definir uma taxa de bits máxima pode tornar os tamanhos dos ficheiros mais previsíveis com um custo menor de qualidade. Em 720p, os valores típicos são 2.600k para VP9 ou HEVC, ou 4.500k para H.264. Desativado se definido como 0.", "transcoding_max_keyframe_interval": "Intervalo máximo de quadro-chave", - "transcoding_max_keyframe_interval_description": "Define a distância máxima do quadro entre os quadros-chave. Valores mais baixos pioram a eficiência da compressão, mas melhoram os tempos de busca e podem melhorar a qualidade em cenas com movimento rápido. 0 define esse valor automaticamente.", - "transcoding_optimal_description": "Vídeos com resolução superior à desejada ou em formato não aceito", + "transcoding_max_keyframe_interval_description": "Define a distância máxima do quadro entre os quadros-chave. Valores mais baixos pioram a eficiência da compressão, mas melhoram os tempos de procura e podem melhorar a qualidade em cenas com movimento rápido. 0 define esse valor automaticamente.", + "transcoding_optimal_description": "Vídeos com resolução superior à desejada ou num formato não aceite", "transcoding_preferred_hardware_device": "Dispositivo de hardware preferido", "transcoding_preferred_hardware_device_description": "Aplica-se apenas a VAAPI e QSV. Define o nó dri usado para transcodificação de hardware.", - "transcoding_preset_preset": "Predefinido (-preset)", - "transcoding_preset_preset_description": "Velocidade de compressão. Predefinições mais lentas produzem arquivos menores e aumentam a qualidade ao atingir uma determinada taxa de bits. VP9 ignora velocidades acima de \"mais rápidas\".", + "transcoding_preset_preset": "Predefinição (-preset)", + "transcoding_preset_preset_description": "Velocidade de compressão. Predefinições mais lentas produzem ficheiros menores e aumentam a qualidade ao atingir uma determinada taxa de bits. VP9 ignora velocidades acima de \"mais rápido\".", "transcoding_reference_frames": "Quadros de referência", - "transcoding_reference_frames_description": "O número de quadros a serem referenciados ao compactar um determinado quadro. Valores mais altos melhoram a eficiência da compactação, mas retardam a codificação. 0 define esse valor automaticamente.", - "transcoding_required_description": "Somente vídeos que não estejam em um formato aceito", - "transcoding_settings": "Configurações de transcodificação de vídeo", - "transcoding_settings_description": "Gerencie as informações de resolução e codificação dos arquivos de vídeo", + "transcoding_reference_frames_description": "O número de quadros a serem referenciados ao comprimir um determinado quadro. Valores mais altos melhoram a eficiência da compressão, mas tornam a codificação mais lenta. 0 define esse valor automaticamente.", + "transcoding_required_description": "Apenas vídeos que não estejam num formato aceite", + "transcoding_settings": "Definições de transcodificação de vídeo", + "transcoding_settings_description": "Gerir as informações de resolução e codificação dos ficheiros de vídeo", "transcoding_target_resolution": "Resolução desejada", - "transcoding_target_resolution_description": "Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de arquivo maiores e podem reduzir a capacidade de resposta do aplicativo.", + "transcoding_target_resolution_description": "Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", "transcoding_temporal_aq": "QA temporal", "transcoding_temporal_aq_description": "Aplica-se apenas ao NVENC. Aumenta a qualidade de cenas com alto detalhe e pouco movimento. Pode não ser compatível com dispositivos mais antigos.", "transcoding_threads": "Threads", - "transcoding_threads_description": "Valores mais altos levam a uma codificação mais rápida, mas deixam menos espaço para o servidor processar outras tarefas enquanto estiver ativo. Este valor não deve ser superior ao número de núcleos da CPU. Maximiza a utilização se definido como 0.", + "transcoding_threads_description": "Valores mais altos levam a uma codificação mais rápida, mas deixam menos espaço para o servidor processar outras tarefas enquanto estiver ativo. Este valor não deve ser superior ao número de núcleos do CPU. Maximiza a utilização se definido como 0.", "transcoding_tone_mapping": "Mapeamento de tons", "transcoding_tone_mapping_description": "Tenta preservar a aparência dos vídeos HDR quando convertidos para SDR. Cada algoritmo faz compensações diferentes em termos de cor, detalhes e brilho. Hable preserva os detalhes, Mobius preserva as cores e Reinhard preserva o brilho.", "transcoding_tone_mapping_npl": "NPL de mapeamento de tons", - "transcoding_tone_mapping_npl_description": "As cores serão ajustadas para parecerem normais para uma exibição com esse brilho. Contra-intuitivamente, valores mais baixos aumentam o brilho do vídeo e vice-versa, uma vez que compensam o brilho da tela. 0 define esse valor automaticamente.", + "transcoding_tone_mapping_npl_description": "As cores serão ajustadas para parecerem normais para uma exibição com esse brilho. Contra-intuitivamente, valores mais baixos aumentam o brilho do vídeo e vice-versa, uma vez que compensam o brilho do ecrã. 0 define esse valor automaticamente.", "transcoding_transcode_policy": "Política de transcodificação", - "transcoding_transcode_policy_description": "Política para quando um vídeo deve ser transcodificado. Os vídeos HDR sempre serão transcodificados (exceto se a transcodificação estiver desativada).", - "transcoding_two_pass_encoding": "Codificação de duas passagens", - "transcoding_two_pass_encoding_setting_description": "Transcodifique em duas passagens para produzir vídeos melhor codificados. Quando a taxa de bits máxima está habilitada (necessária para funcionar com H.264 e HEVC), este modo usa um intervalo de taxa de bits baseado na taxa de bits máxima e ignora o CRF. Para VP9, o CRF pode ser usado se a taxa de bits máxima estiver desabilitada.", + "transcoding_transcode_policy_description": "Política para quando um vídeo deve ser transcodificado. Os vídeos HDR serão sempre transcodificados (exceto se a transcodificação estiver desativada).", + "transcoding_two_pass_encoding": "Codificação em duas passagens", + "transcoding_two_pass_encoding_setting_description": "Transcodificar em duas passagens para produzir vídeos melhor codificados. Quando a taxa de bits máxima está ativada (necessário para funcionar com H.264 e HEVC), este modo usa um intervalo de taxa de bits baseado na taxa de bits máxima e ignora o CRF. Para VP9, o CRF pode ser usado se a taxa de bits máxima estiver desativada.", "transcoding_video_codec": "Codec de vídeo", - "transcoding_video_codec_description": "O VP9 tem alta eficiência e compatibilidade com a web, mas leva mais tempo para transcodificar. HEVC tem desempenho semelhante, mas tem menor compatibilidade com a web. H.264 é amplamente compatível e rápido de transcodificar, mas produz arquivos muito maiores. AV1 é o codec mais eficiente, mas não possui suporte em dispositivos mais antigos.", - "trash_enabled_description": "Ativar recursos da Lixeira", + "transcoding_video_codec_description": "O VP9 tem alta eficiência e compatibilidade com a web, mas leva mais tempo para transcodificar. HEVC tem desempenho semelhante, mas tem menor compatibilidade com a web. H.264 é amplamente compatível e rápido de transcodificar, mas produz ficheiros muito maiores. AV1 é o codec mais eficiente, mas não possui suporte em dispositivos mais antigos.", + "trash_enabled_description": "Ativar funcionalidade da Reciclagem", "trash_number_of_days": "Número de dias", - "trash_number_of_days_description": "Número de dias para manter os arquivos na lixeira antes de eliminar permanentemente", - "trash_settings": "Configurações da Lixeira", - "trash_settings_description": "Gerenciar configurações da lixeira", - "untracked_files": "Arquivos não rastreados", - "untracked_files_description": "Esses arquivos não são rastreados pelo aplicativo. Eles podem ser o resultado de movimentos malsucedidos, carregamentos interrompidos ou deixados para trás devido a um bug", - "user_delete_delay": "A conta e os arquivos de {user} serão agendados para eliminação permanente em {delay, plural, one {# dia} other {# dias}}.", - "user_delete_delay_settings": "Excluir atraso", - "user_delete_delay_settings_description": "Número de dias após a remoção para excluir permanentemente a conta e os arquivos de um utilizador. O trabalho de exclusão de utilizadores é executado à meia-noite para verificar utilizadores que estão prontos para exclusão. As alterações nesta configuração serão avaliadas na próxima execução.", - "user_delete_immediately": "A conta e os arquivos de {user} serão enfileirados para exclusão permanente imediatamente.", - "user_delete_immediately_checkbox": "Adicionar utilizador e arquivos à fila para eliminação imediata", - "user_management": "Gerenciamento de utilizadores", - "user_password_has_been_reset": "A senha do utilizador foi redefinida:", - "user_password_reset_description": "Forneça a senha temporária ao utilizador e informe que ele precisará alterar a senha no próximo início de sessão.", + "trash_number_of_days_description": "Número de dias para manter os ficheiros na reciclagem antes de os eliminar permanentemente", + "trash_settings": "Definições da Reciclagem", + "trash_settings_description": "Gerir definições da reciclagem", + "untracked_files": "Ficheiros não monitorizados", + "untracked_files_description": "Estes ficheiros não são monitorizados pela aplicação. Eles podem ser o resultado de transferências mal-sucedidas, carregamentos interrompidos ou deixados para trás devido a um problema", + "user_cleanup_job": "Limpeza de utilizadores", + "user_delete_delay": "A conta e os ficheiros de {user} serão agendados para eliminação permanente dentro de {delay, plural, one {# dia} other {# dias}}.", + "user_delete_delay_settings": "Atraso de eliminação", + "user_delete_delay_settings_description": "Número de dias após a remoção para excluir permanentemente a conta e os ficheiros de um utilizador. A tarefa de eliminação de utilizadores é executada à meia-noite para verificar utilizadores que estão prontos para eliminação. As alterações a esta definição serão avaliadas na próxima execução.", + "user_delete_immediately": "A conta e os ficheiros de {user} serão colocados em fila para eliminação permanente de imediato.", + "user_delete_immediately_checkbox": "Adicionar utilizador e ficheiros à fila para eliminação imediata", + "user_management": "Gestão de utilizadores", + "user_password_has_been_reset": "A palavra-passe do utilizador foi redefinida:", + "user_password_reset_description": "Por favor forneça a palavra-passe temporária ao utilizador e informe-o(a) de que será necessário alterá-la próximo início de sessão.", "user_restore_description": "A conta de {user} será restaurada.", - "user_restore_scheduled_removal": "Restaurar usuário - planejar remoção em {date, date, long}", - "user_settings": "Configurações do Utilizador", - "user_settings_description": "Gerenciar configurações do utilizador", + "user_restore_scheduled_removal": "Restaurar utilizador - remoção agendada em {date, date, long}", + "user_settings": "Definições do Utilizador", + "user_settings_description": "Gerir definições do utilizador", "user_successfully_removed": "O utilizador {email} foi removido com sucesso.", "version_check_enabled_description": "Ativa verificação de novas versões", - "version_check_implications": "A funcionalidade de verificação da versão necessita comunicação periodica com github.com", + "version_check_implications": "A funcionalidade de verificação da versão necessita de comunicação periódica com o github.com", "version_check_settings": "Verificação de versão", "version_check_settings_description": "Ativar/desativar a notificação de nova versão", "video_conversion_job": "Transcodificar vídeos", - "video_conversion_job_description": "Transcodifique vídeos para maior compatibilidade com navegadores e dispositivos" + "video_conversion_job_description": "Transcodificar vídeos para maior compatibilidade com navegadores e dispositivos" }, "admin_email": "E-mail do administrador", - "admin_password": "Senha do administrador", + "admin_password": "Palavra-passe do administrador", "administration": "Administração", "advanced": "Avançado", "age_months": "Idade {months, plural, one {# mês} other {# meses}}", "age_year_months": "Idade 1 ano, {months, plural, one {# mês} other {# meses}}", - "age_years": "Idade {years, plural, one{# ano} other {# anos}}", + "age_years": "{years, plural, one{# ano} other {# anos}}", "album_added": "Álbum adicionado", - "album_added_notification_setting_description": "Receba uma notificação por e-mail quando você for adicionado a um álbum compartilhado", + "album_added_notification_setting_description": "Receber uma notificação por e-mail quando for adicionado a um álbum partilhado", "album_cover_updated": "Capa do álbum atualizada", - "album_delete_confirmation": "Tem a certeza que quer apagar o álbum {album}? Se o álbum for partilhado, os outros utilizadores não poderão aceder-lhe novamente.", - "album_delete_confirmation_description": "Se este álbum for partilhado, os outros utilizadores deixam de poder aceder.", + "album_delete_confirmation": "Tem a certeza de que quer eliminar o álbum {album}?", + "album_delete_confirmation_description": "Se este álbum for partilhado, os outros utilizadores deixam de o poder aceder.", "album_info_updated": "Informações do álbum atualizadas", "album_leave": "Sair do álbum?", - "album_leave_confirmation": "Tem a certeza que quer sair de {album}?", + "album_leave_confirmation": "Tem a certeza de que quer sair de {album}?", "album_name": "Nome do álbum", "album_options": "Opções de álbum", "album_remove_user": "Remover utilizador?", - "album_remove_user_confirmation": "Tem a certeza que quer remover {user}?", - "album_share_no_users": "Parece que tem este álbum partilhado com todos os utilizadores ou que não existem utilizadores para o partilhar.", + "album_remove_user_confirmation": "Tem a certeza de que quer remover {user}?", + "album_share_no_users": "Parece que tem este álbum partilhado com todos os utilizadores ou que não existem utilizadores com quem o partilhar.", "album_updated": "Álbum atualizado", - "album_updated_setting_description": "Receba uma notificação por e-mail quando um álbum compartilhado tiver novos arquivos", - "album_user_left": "Saída {album}", + "album_updated_setting_description": "Receber uma notificação por e-mail quando um álbum partilhado tiver novos ficheiros", + "album_user_left": "Saíu do {album}", "album_user_removed": "Utilizador {user} removido", - "album_with_link_access": "Permite acesso a fotos e pessoas deste album por qualquer pessoa com o link.", + "album_with_link_access": "Permite o acesso a fotos e pessoas deste álbum por qualquer pessoa com o link.", "albums": "Álbuns", "albums_count": "{count, plural, one {{count, number} Álbum} other {{count, number} Álbuns}}", "all": "Todos", @@ -362,68 +371,69 @@ "all_videos": "Todos os vídeos", "allow_dark_mode": "Permitir modo escuro", "allow_edits": "Permitir edições", - "allow_public_user_to_download": "Permit acesso de download ao user publico", - "allow_public_user_to_upload": "Permite acesso de upload ao user publico", + "allow_public_user_to_download": "Permitir que utilizadores públicos façam transferências", + "allow_public_user_to_upload": "Permitir que utilizadores públicos façam carregamentos", "anti_clockwise": "Sentido anti-horário", "api_key": "Chave de API", "api_key_description": "Este valor será apresentado apenas uma única vez. Por favor, certifique-se que o copiou antes de fechar a janela.", - "api_key_empty": "O nome da API Key não pode ser vazio", + "api_key_empty": "O nome da chave a API não pode estar vazio", "api_keys": "Chaves de API", - "app_settings": "Configurações do Aplicativo", + "app_settings": "Definições da Aplicação", "appears_in": "Aparece em", "archive": "Arquivo", "archive_or_unarchive_photo": "Arquivar ou desarquivar foto", "archive_size": "Tamanho do arquivo", - "archive_size_description": "Configure o tamanho do arquivo para downloads (em GiB)", + "archive_size_description": "Configure o tamanho do arquivo para transferências (em GiB)", "archived": "Arquivado", "archived_count": "{count, plural, other {Arquivado #}}", - "are_these_the_same_person": "São a mesma pessoa?", - "are_you_sure_to_do_this": "Tem a certeza que quer fazer isto?", + "are_these_the_same_person": "Estas pessoas são a mesma pessoa?", + "are_you_sure_to_do_this": "Tem a certeza de que quer fazer isto?", "asset_added_to_album": "Adicionado ao álbum", "asset_adding_to_album": "A adicionar ao álbum...", - "asset_description_updated": "A descrição do arquivo foi atualizada", - "asset_filename_is_offline": "O arquivo {filename} está offline", - "asset_has_unassigned_faces": "O arquivo tem rostos sem atribuição", - "asset_hashing": "Hashing...", - "asset_offline": "Ativo off-line", - "asset_offline_description": "Este arquivo está offline. Immich não consegue acessar o local do arquivo. Certifique-se de que o arquivo esteja disponível e, em seguida, escaneie a biblioteca novamente.", + "asset_description_updated": "A descrição do ficheiro foi atualizada", + "asset_filename_is_offline": "O ficheiro {filename} não está disponível", + "asset_has_unassigned_faces": "O ficheiro tem rostos não atribuídas", + "asset_hashing": "A criar hash...", + "asset_offline": "Ficheiro indisponível", + "asset_offline_description": "Este ficheiro está indisponível. O Immich não consegue aceder ao local do local. Certifique-se de que o ficheiro está disponível e, em seguida, analise a biblioteca novamente.", "asset_skipped": "Ignorado", + "asset_skipped_in_trash": "Na reciclagem", "asset_uploaded": "Enviado", - "asset_uploading": "Em upload...", - "assets": "Arquivos", - "assets_added_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}}", - "assets_added_to_album_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}} ao álbum", - "assets_added_to_name_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}} a {hasName, select, true {{name}} other {novo álbum}}", - "assets_count": "{count, plural, one {# arquivo} other {# arquivos}}", + "asset_uploading": "A enviar...", + "assets": "Ficheiros", + "assets_added_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}}", + "assets_added_to_album_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}} ao álbum", + "assets_added_to_name_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}} a {hasName, select, true {{name}} other {novo álbum}}", + "assets_count": "{count, plural, one {# ficheiro} other {# ficheiros}}", "assets_moved_to_trash": "{count, plural, one {# ativo enviado} other {# ativos enviados}} para a lixeira", - "assets_moved_to_trash_count": "{count, plural, one {# arquivo movido} other {# arquivos movidos}} para a lixeira", - "assets_permanently_deleted_count": "{count, plural, one {# arquivo} other {# arquivos}} excluídos permanentemente", - "assets_removed_count": "{count, plural, one {# arquivo excluído} other {# arquivos excluídos}}", - "assets_restore_confirmation": "Tem a certeza que quer recuperar todos os artigos apagados? Não é possivel voltar atrás nesta acção!", - "assets_restored_count": "{count, plural, one {# arquivo restaurado} other {# arquivos restaurados}}", - "assets_trashed_count": "{count, plural, one {# arquivo enviado} other {# arquivos enviados}} para a lixeira", - "assets_were_part_of_album_count": "{count, plural, one {Arquivo já era} other {Os arquivos já eram}} parte do álbum", + "assets_moved_to_trash_count": "{count, plural, one {# ficheiro movido} other {# aficheiros movidos}} para a reciclagem", + "assets_permanently_deleted_count": "{count, plural, one {# ficheiro} other {# ficheiros}} eliminados permanentemente", + "assets_removed_count": "{count, plural, one {# ficheiro eliminado} other {# ficheiros eliminados}}", + "assets_restore_confirmation": "Tem a certeza de que quer recuperar todos os ficheiros apagados? Não é possível anular esta ação!", + "assets_restored_count": "{count, plural, one {# ficheiro restaurado} other {# ficheiros restaurados}}", + "assets_trashed_count": "{count, plural, one {# ficheiro enviado} other {# ficheiros enviados}} para a reciclagem", + "assets_were_part_of_album_count": "{count, plural, one {O ficheiro já fazia} other {Os ficheiros já faziam}} parte do álbum", "authorized_devices": "Dispositivos Autorizados", "back": "Voltar", "back_close_deselect": "Voltar, fechar ou desmarcar", "backward": "Para trás", "birthdate_saved": "Data de nascimento guardada com sucesso", - "birthdate_set_description": "A data de nascimento é usada para calcular a idade desta pessoa no momento em que uma fotografia foi tirada.", + "birthdate_set_description": "A data de nascimento é utilizada para calcular a idade desta pessoa no momento em que uma fotografia foi tirada.", "blurred_background": "Fundo desfocado", - "build": "Construir", - "build_image": "Construir Imagem", - "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja excluir {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Esta ação mantém o maior arquivo de cada grupo e exclui permanentemente todas as outras duplicidades. Você não pode desfazer esta ação!", - "bulk_keep_duplicates_confirmation": "Tem certeza de que deseja manter {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso resolverá todos os grupos duplicados sem excluir nada.", - "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a lixeira {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso manterá o maior arquivo de cada grupo e moverá para a lixeira todas as outras duplicidades.", + "build": "Versão de compilação", + "build_image": "Imagem de compilação", + "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja eliminar {count, plural, one {# ficheiro duplicado} other {# ficheiros duplicados}}? Esta ação mantém o maior ficheiro de cada grupo e elimina permanentemente todos os outros duplicados. Não é possível anular esta ação!", + "bulk_keep_duplicates_confirmation": "Tem a certeza de que deseja manter {count, plural, one {# ficheiro duplicado} other {# ficheiros duplicados}}? Isto resolverá todos os grupos duplicados sem eliminar nada.", + "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a recicalgem {count, plural, one {# ficheiro duplicado} other {# ficheiros duplicados}}? Isto manterá o maior ficheiro de cada grupo e irá mover para a reciclagem todos os outros duplicados.", "buy": "Comprar Immich", - "camera": "Câmera", - "camera_brand": "Marca da câmera", - "camera_model": "Modelo da câmera", + "camera": "Câmara", + "camera_brand": "Marca da câmara", + "camera_model": "Modelo da câmara", "cancel": "Cancelar", "cancel_search": "Cancelar pesquisa", - "cannot_merge_people": "Não é possível mesclar pessoas", - "cannot_undo_this_action": "Não pode voltar atrás nesta ação!", - "cannot_update_the_description": "Não é possível atualizar a descrição", + "cannot_merge_people": "Não foi possível unir pessoas", + "cannot_undo_this_action": "Não é possível anular esta ação!", + "cannot_update_the_description": "Não foi possível atualizar a descrição", "cant_apply_changes": "Não é possível aplicar alterações", "cant_get_faces": "Não foi possível obter faces", "cant_search_people": "Não foi possível pesquisar pessoas", @@ -433,13 +443,13 @@ "change_location": "Alterar localização", "change_name": "Alterar nome", "change_name_successfully": "Nome alterado com sucesso", - "change_password": "Mudar a senha", - "change_password_description": "Esta é a primeira vez que você está entrando no sistema ou uma solicitação foi feita para alterar sua senha. Insira a nova senha abaixo.", - "change_your_password": "Alterar sua senha", + "change_password": "Alterar a palavra-passe", + "change_password_description": "Esta é a primeira vez que está a entrar no sistema ou um pedido foi feito para alterar a sua palavra-passe. Insira a nova palavra-passe abaixo.", + "change_your_password": "Alterar a sua palavra-passe", "changed_visibility_successfully": "Visibilidade alterada com sucesso", "check_all": "Verificar tudo", - "check_logs": "Verificar registros", - "choose_matching_people_to_merge": "Escolha pessoas correspondentes para mesclar", + "check_logs": "Verificar registos", + "choose_matching_people_to_merge": "Escolha pessoas correspondentes para unir", "city": "Cidade", "clear": "Limpar", "clear_all": "Limpar tudo", @@ -450,26 +460,27 @@ "close": "Fechar", "collapse": "Colapsar", "collapse_all": "Colapsar tudo", - "color_theme": "Tema de cores", + "color": "Cor", + "color_theme": "Esquema de cores", "comment_deleted": "Comentário eliminado", "comment_options": "Opções de comentário", "comments_and_likes": "Comentários e gostos", "comments_are_disabled": "Comentários estão desativados", "confirm": "Confirmar", - "confirm_admin_password": "Confirmar senha de administrador", - "confirm_delete_shared_link": "Tem certeza de que deseja excluir este link compartilhado?", - "confirm_password": "Confirme a senha", - "contain": "Caber", + "confirm_admin_password": "Confirmar palavra-passe de administrador", + "confirm_delete_shared_link": "Tem certeza de que deseja eliminar este link partilhado?", + "confirm_password": "Confirmar a palavra-passe", + "contain": "Ajustar", "context": "Contexto", "continue": "Continuar", "copied_image_to_clipboard": "Imagem copiada para a área de transferência.", "copied_to_clipboard": "Copiado para a área de transferência!", "copy_error": "Copiar erro", - "copy_file_path": "Copiar caminho do arquivo", + "copy_file_path": "Copiar caminho do ficheiro", "copy_image": "Copiar Imagem", "copy_link": "Copiar link", "copy_link_to_clipboard": "Copiar link para a área de transferência", - "copy_password": "Copiar senha", + "copy_password": "Copiar palavra-passe", "copy_to_clipboard": "Copiar para a área de transferência", "country": "País", "cover": "Preencher", @@ -479,15 +490,17 @@ "create_library": "Criar biblioteca", "create_link": "Criar link", "create_link_to_share": "Criar link para partilhar", - "create_link_to_share_description": "Permiter a visualização desta imagem(s) a qualquer pessoa com este link", + "create_link_to_share_description": "Permitir a visualização desta(s) imagem(s) a qualquer pessoa com o link", "create_new_person": "Criar nova pessoa", - "create_new_person_hint": "Associe os arquivos para uma nova pessoa", + "create_new_person_hint": "Associe os ficheiros a uma nova pessoa", "create_new_user": "Criar novo utilizador", + "create_tag": "Criar etiqueta", + "create_tag_description": "Criar uma nova etiqueta. Para etiquetas compostas, introduza o caminho completo, incluindo as barras.", "create_user": "Criar utilizador", "created": "Criado", "current_device": "Dispositivo atual", - "custom_locale": "Localização Customizada", - "custom_locale_description": "Formatar datas e números baseados na linguagem e região", + "custom_locale": "Localização Personalizada", + "custom_locale_description": "Formatar datas e números baseados na língua e na região", "dark": "Escuro", "date_after": "Data após", "date_and_time": "Data e Hora", @@ -495,19 +508,21 @@ "date_of_birth_saved": "Data de nascimento guardada com sucesso", "date_range": "Intervalo de datas", "day": "Dia", - "deduplicate_all": "Limpar todas Duplicidades", + "deduplicate_all": "Limpar todos os itens duplicados", "default_locale": "Localização Padrão", "default_locale_description": "Formatar datas e números baseados na linguagem do seu navegador", - "delete": "Excluir", - "delete_album": "Excluir álbum", - "delete_api_key_prompt": "Tem certeza de que deseja excluir esta chave de API?", - "delete_duplicates_confirmation": "Tem certeza de que deseja excluir permanentemente estas duplicidades?", - "delete_key": "Excluir chave", - "delete_library": "Excluir biblioteca", - "delete_link": "Excluir link", - "delete_shared_link": "Excluir link de compartilhamento", - "delete_user": "Excluir utilizador", - "deleted_shared_link": "Link de compartilhamento excluído", + "delete": "Eliminar", + "delete_album": "Eliminar álbum", + "delete_api_key_prompt": "Tem certeza de que deseja eliminar esta chave de API?", + "delete_duplicates_confirmation": "Tem certeza de que deseja eliminar permanentemente estes itens duplicados?", + "delete_key": "Eliminar chave", + "delete_library": "Eliminar biblioteca", + "delete_link": "Eliminar link", + "delete_shared_link": "Eliminar link de partilha", + "delete_tag": "Eliminar etiqueta", + "delete_tag_confirmation_prompt": "Tem a certeza de que pretende eliminar a etiqueta {tagName} ?", + "delete_user": "Eliminar utilizador", + "deleted_shared_link": "Link de partilha eliminado", "description": "Descrição", "details": "Detalhes", "direction": "Direção", @@ -519,19 +534,19 @@ "display_options": "Opções de exibição", "display_order": "Ordem de exibição", "display_original_photos": "Exibir fotos originais", - "display_original_photos_setting_description": "Prefira exibir a foto original ao visualizar um ativo em vez de miniaturas quando o ativo original é compatível com a web. Isso pode diminuir a velocidade de exibição das fotos.", + "display_original_photos_setting_description": "Preferir a exibição da foto original ao visualizar um ficheiro em vez de miniaturas quando o ficheiro original é compatível com a web. Isso pode diminuir a velocidade de exibição das fotos.", "do_not_show_again": "Não mostrar esta mensagem novamente", "done": "Feito", "download": "Transferir", "download_include_embedded_motion_videos": "Vídeos incorporados", - "download_include_embedded_motion_videos_description": "Incluir vídeos incorporados em fotos em movimento como um arquivo separado", + "download_include_embedded_motion_videos_description": "Incluir vídeos incorporados em fotos em movimento como um ficheiro separado", "download_settings": "Transferir", - "download_settings_description": "Gerenciar configurações relacionadas a transferir ativos", - "downloading": "Baixando", - "downloading_asset_filename": "A transferir o arquivo {filename}", - "drop_files_to_upload": "Coloque os ficheiros em qualquer lugar para fazer o upload", - "duplicates": "Duplicados", - "duplicates_description": "Marque cada grupo indicando quais arquivos, se algum, são duplicados", + "download_settings_description": "Gerir definições relacionadas com a transferência de ficheiros", + "downloading": "A transferir", + "downloading_asset_filename": "A transferir o ficheiro {filename}", + "drop_files_to_upload": "Solte os ficheiros em qualquer lugar para os enviar", + "duplicates": "Itens duplicados", + "duplicates_description": "Marque cada grupo indicando quais ficheiros, se algum, são duplicados", "duration": "Duração", "durations": { "days": "", @@ -542,11 +557,11 @@ }, "edit": "Editar", "edit_album": "Editar álbum", - "edit_avatar": "Editar foto de perfil", + "edit_avatar": "Editar imagem de perfil", "edit_date": "Editar data", "edit_date_and_time": "Editar data e hora", "edit_exclusion_pattern": "Editar o padrão de exclusão", - "edit_faces": "Editar faces", + "edit_faces": "Editar rostos", "edit_import_path": "Editar caminho de importação", "edit_import_paths": "Editar caminhos de importação", "edit_key": "Editar chave", @@ -554,150 +569,153 @@ "edit_location": "Editar Localização", "edit_name": "Editar nome", "edit_people": "Editar pessoas", + "edit_tag": "Editar etiqueta", "edit_title": "Editar Título", "edit_user": "Editar utilizador", "edited": "Editado", - "editor": "Editar", - "editor_close_without_save_prompt": "As alterações não serão salvas", + "editor": "Editor", + "editor_close_without_save_prompt": "As alterações não serão guardadas", "editor_close_without_save_title": "Fechar editor?", - "editor_crop_tool_h2_aspect_ratios": "Proporções de aspecto", + "editor_crop_tool_h2_aspect_ratios": "Relação de aspeto", "editor_crop_tool_h2_rotation": "Rotação", "email": "E-mail", "empty": "", "empty_album": "", - "empty_trash": "Esvaziar lixo", - "empty_trash_confirmation": "Tem certeza de que deseja esvaziar a lixeira? Isso removerá todos os arquivos da lixeira do Immich permanentemente.\nVocê não pode desfazer esta ação!", + "empty_trash": "Esvaziar reciclagem", + "empty_trash_confirmation": "Tem certeza de que deseja esvaziar a reciclagem? Isto removerá todos os ficheiros da reciclagem do Immich permanentemente.\nNão é possível anular esta ação!", "enable": "Ativar", "enabled": "Ativado", "end_date": "Data final", "error": "Erro", - "error_loading_image": "Erro ao carregar a página", + "error_loading_image": "Erro ao carregar a imagem", "error_title": "Erro - Algo correu mal", "errors": { - "cannot_navigate_next_asset": "Não pode navegar para o proximo artigo", - "cannot_navigate_previous_asset": "Não pode navegar para o artigo anterior", + "cannot_navigate_next_asset": "Não foi possível navegar para o próximo ficheiro", + "cannot_navigate_previous_asset": "Não foi possível navegar para o ficheiro anterior", "cant_apply_changes": "Não foi possível aplicar as alterações", - "cant_change_activity": "Não é possível {enabled, select, true {desativar} other {ativar}} atividade", - "cant_change_asset_favorite": "Não pode alterar o favorito deste artigo", - "cant_change_metadata_assets_count": "Não foi possível alterar os metadados de {count, plural, one {# arquivo} other {# arquivos}}", + "cant_change_activity": "Não foi possível {enabled, select, true {desativar} other {ativar}} atividade", + "cant_change_asset_favorite": "Não foi possível alterar o favorito deste ficheiro", + "cant_change_metadata_assets_count": "Não foi possível alterar os metadados de {count, plural, one {# ficheiro} other {# ficheiros}}", "cant_get_faces": "Não foi possível obter os rostos", "cant_get_number_of_comments": "Não foi possível obter o número de comentários", "cant_search_people": "Não foi possível pesquisar pessoas", "cant_search_places": "Não foi possível pesquisar locais", - "cleared_jobs": "Trabalhos eliminados para: {job}", - "error_adding_assets_to_album": "Erro ao adicionar arquivos ao álbum", - "error_adding_users_to_album": "Erro a adicionar utilizador ao album", - "error_deleting_shared_user": "Error a apagar o utilizador partilhado", - "error_downloading": "Erro a transferir {filename}", + "cleared_jobs": "Tarefas eliminadas para: {job}", + "error_adding_assets_to_album": "Erro ao adicionar ficheiros ao álbum", + "error_adding_users_to_album": "Erro ao adicionar utilizador ao álbum", + "error_deleting_shared_user": "Erro ao apagar o utilizador partilhado", + "error_downloading": "Erro ao transferir {filename}", "error_hiding_buy_button": "Erro ao esconder botão de compra", - "error_removing_assets_from_album": "Erro a eliminar artigos do album, verifique a consola para mais detalhes", - "error_selecting_all_assets": "Erro ao selecionar todos os arquivos", + "error_removing_assets_from_album": "Erro ao eliminar ficheiros do álbum, verifique a consola para mais detalhes", + "error_selecting_all_assets": "Erro ao selecionar todos os ficheiros", "exclusion_pattern_already_exists": "Este padrão de exclusão já existe.", - "failed_job_command": "Comando {command} falhou para o trabalho: {job}", - "failed_to_create_album": "Falha ao criar álbum", - "failed_to_create_shared_link": "Falhou a criar um link partilhado", - "failed_to_edit_shared_link": "Falhou a editar o link partilhado", - "failed_to_get_people": "Falha na obtenção de pessoas", - "failed_to_load_asset": "Falha ao carregar arquivo", - "failed_to_load_assets": "Falha ao carregar arquivos", - "failed_to_load_people": "Falha ao carregar pessoas", - "failed_to_remove_product_key": "Falha ao remover chave de produto", - "failed_to_stack_assets": "Falha ao empilhar os arquivos", - "failed_to_unstack_assets": "Falha ao desempilhar arquivos", + "failed_job_command": "Comando {command} falhou para a tarefa: {job}", + "failed_to_create_album": "Não foi possível criar álbum", + "failed_to_create_shared_link": "Não foi possível criar o link partilhado", + "failed_to_edit_shared_link": "Não foi possível editar o link partilhado", + "failed_to_get_people": "Não foi possível obter pessoas", + "failed_to_load_asset": "Não foi possível ler o ficheiro", + "failed_to_load_assets": "Não foi possível ler ficheiros", + "failed_to_load_people": "Não foi possível carregar pessoas", + "failed_to_remove_product_key": "Não foi possível remover chave de produto", + "failed_to_stack_assets": "Não foi possível empilhar os ficheiros", + "failed_to_unstack_assets": "Não foi possível desempilhar ficheiros", "import_path_already_exists": "Este caminho de importação já existe.", - "incorrect_email_or_password": "Email ou password incorretos", - "paths_validation_failed": "a validação de {paths, plural, one {# caminho falhou} other {# caminhos falharam}}", - "profile_picture_transparent_pixels": "Imagem de perfil não pode ter pixels transparentes. Por favor faça zoom in e/ou mova a imagem.", - "quota_higher_than_disk_size": "Você definiu uma cota maior do que o tamanho do disco", + "incorrect_email_or_password": "Email ou palavra-passe incorretos", + "paths_validation_failed": "A validação de {paths, plural, one {# caminho falhou} other {# caminhos falharam}}", + "profile_picture_transparent_pixels": "Imagem de perfil não pode ter pixeis transparentes. Por favor amplie e/ou mova a imagem.", + "quota_higher_than_disk_size": "Definiu uma quota maior do que o tamanho do disco", "repair_unable_to_check_items": "Não foi possível verificar {count, select, one {um item} other {alguns itens}}", "unable_to_add_album_users": "Não foi possível adicionar utilizadores ao álbum", - "unable_to_add_assets_to_shared_link": "Não foi possivel adicionar os artigos ao link partilhado", + "unable_to_add_assets_to_shared_link": "Não foi possível adicionar os ficheiros ao link partilhado", "unable_to_add_comment": "Não foi possível adicionar o comentário", "unable_to_add_exclusion_pattern": "Não foi possível adicionar o padrão de exclusão", "unable_to_add_import_path": "Não foi possível adicionar o caminho de importação", "unable_to_add_partners": "Não foi possível adicionar parceiros", - "unable_to_add_remove_archive": "Não é possível {archived, select, true {remover o arquivo de} other {adicionar o arquivo}}", - "unable_to_add_remove_favorites": "Não foi possível {favorite, select, true {adicionar arquivo aos} other {remover arquivo dos}} favoritos", - "unable_to_archive_unarchive": "Não é possível {archived, select, true {arquivar} other {desarquivar}}", + "unable_to_add_remove_archive": "Não foi possível {archived, select, true {remover o ficheiro de} other {adicionar o ficheiro}}", + "unable_to_add_remove_favorites": "Não foi possível {favorite, select, true {adicionar ficheiro aos} other {remover ficheiro dos}} favoritos", + "unable_to_archive_unarchive": "Não foi possível {archived, select, true {arquivar} other {desarquivar}}", "unable_to_change_album_user_role": "Não foi possível alterar a permissão do utilizador no álbum", "unable_to_change_date": "Não foi possível alterar a data", - "unable_to_change_favorite": "Não foi possivel mudar o favorito do artigo", + "unable_to_change_favorite": "Não foi possível mudar o favorito do ficheiro", "unable_to_change_location": "Não foi possível alterar a localização", - "unable_to_change_password": "Não foi possível alterar a senha", + "unable_to_change_password": "Não foi possível alterar a palavra-passe", "unable_to_change_visibility": "Não é possível alterar a visibilidade de {count, plural, one {# pessoa} other {# pessoas}}", "unable_to_check_item": "", "unable_to_check_items": "", - "unable_to_complete_oauth_login": "Não foi possível completar início de sessão com OAuth", - "unable_to_connect": "Não é possível conectar", + "unable_to_complete_oauth_login": "Não foi possível completar o início de sessão com OAuth", + "unable_to_connect": "Não é possível ligar", "unable_to_connect_to_server": "Não foi possível ligar ao servidor", - "unable_to_copy_to_clipboard": "Não é possível copiar para a área de transferência, certifique-se que está acessando a pagina através de https", + "unable_to_copy_to_clipboard": "Não foi possível copiar para a área de transferência, certifique-se de que está a aceder à pagina através de https", "unable_to_create_admin_account": "Não foi possível criar conta de administrador", "unable_to_create_api_key": "Não foi possível criar uma nova Chave de API", "unable_to_create_library": "Não foi possível criar a biblioteca", "unable_to_create_user": "Não foi possível criar o utilizador", - "unable_to_delete_album": "Não foi possível deletar o álbum", - "unable_to_delete_asset": "Não foi possível deletar o ativo", - "unable_to_delete_assets": "Erro ao eliminar arquivos", - "unable_to_delete_exclusion_pattern": "Não foi possível deletar o padrão de exclusão", - "unable_to_delete_import_path": "Não foi possível deletar o caminho de importação", - "unable_to_delete_shared_link": "Não foi possível deletar o link compartilhado", - "unable_to_delete_user": "Não foi possível deletar o utilizador", + "unable_to_delete_album": "Não foi possível eliminar o álbum", + "unable_to_delete_asset": "Não foi possível eliminar o ficheiro", + "unable_to_delete_assets": "Erro ao eliminar ficheiros", + "unable_to_delete_exclusion_pattern": "Não foi possível eliminar o padrão de exclusão", + "unable_to_delete_import_path": "Não foi possível eliminar o caminho de importação", + "unable_to_delete_shared_link": "Não foi possível eliminar o link compartilhado", + "unable_to_delete_user": "Não foi possível eliminar o utilizador", "unable_to_download_files": "Não foi possível transferir ficheiros", "unable_to_edit_exclusion_pattern": "Não foi possível editar o padrão de exclusão", "unable_to_edit_import_path": "Não foi possível editar o caminho de importação", - "unable_to_empty_trash": "Não foi possível esvaziar a lixeira", - "unable_to_enter_fullscreen": "Não foi possível entrar em modo de tela cheia", - "unable_to_exit_fullscreen": "Não foi possível sair do modo de tela cheia", + "unable_to_empty_trash": "Não foi possível esvaziar a reciclagem", + "unable_to_enter_fullscreen": "Não foi possível entrar em modo de ecrã inteiro", + "unable_to_exit_fullscreen": "Não foi possível sair do modo de ecrã inteiro", "unable_to_get_comments_number": "Não foi possível obter número de comentários", - "unable_to_get_shared_link": "Falha ao obter link compartilhado", + "unable_to_get_shared_link": "Não foi possível obter link partilhado", "unable_to_hide_person": "Não foi possível esconder a pessoa", + "unable_to_link_motion_video": "Não foi possível relacionar o video animado", "unable_to_link_oauth_account": "Não foi possível associar a conta OAuth", "unable_to_load_album": "Não foi possível carregar o álbum", - "unable_to_load_asset_activity": "Não foi possível carregar as atividades do ativo", - "unable_to_load_items": "Não foi possível carregar os items", - "unable_to_load_liked_status": "Não foi possível carregar os status de gostei", + "unable_to_load_asset_activity": "Não foi possível carregar a atividade do ficheiro", + "unable_to_load_items": "Não foi possível carregar os itens", + "unable_to_load_liked_status": "Não foi possível carregar o estado de gostos", "unable_to_log_out_all_devices": "Não foi possível terminar a sessão em todos os dispositivos", "unable_to_log_out_device": "Não foi possível terminar a sessão no dispositivo", "unable_to_login_with_oauth": "Não foi possível iniciar sessão com OAuth", "unable_to_play_video": "Não foi possível reproduzir o vídeo", - "unable_to_reassign_assets_existing_person": "Não é possível reatribuir arquivos para {name, select, null {uma pessoa existente} other {{name}}}", - "unable_to_reassign_assets_new_person": "Não é possível reatribuir os arquivos a uma nova pessoa", - "unable_to_refresh_user": "Não foi possível atualizar o utilizador", + "unable_to_reassign_assets_existing_person": "Não foi possível reatribuir ficheiros para {name, select, null {uma pessoa existente} other {{name}}}", + "unable_to_reassign_assets_new_person": "Não foi possível reatribuir os ficheiros a uma nova pessoa", + "unable_to_refresh_user": "Não foi possível recarregar o utilizador", "unable_to_remove_album_users": "Não foi possível remover utilizador do álbum", - "unable_to_remove_api_key": "Não foi possível a Chave de API", - "unable_to_remove_assets_from_shared_link": "Não é possível remover os arquivos do link compartilhado", + "unable_to_remove_api_key": "Não foi possível remover a Chave de API", + "unable_to_remove_assets_from_shared_link": "Não foi possível remover os ficheiros do link partilhado", "unable_to_remove_comment": "", "unable_to_remove_library": "Não foi possível remover a biblioteca", - "unable_to_remove_offline_files": "Não foi possível remover arquivos offline", + "unable_to_remove_offline_files": "Não foi possível remover ficheiros indisponíveis", "unable_to_remove_partner": "Não foi possível remover parceiro", "unable_to_remove_reaction": "Não foi possível remover a reação", "unable_to_remove_user": "", "unable_to_repair_items": "Não foi possível reparar os itens", - "unable_to_reset_password": "Não foi possível resetar a senha", - "unable_to_resolve_duplicate": "Não foi possível resolver a duplicidade", - "unable_to_restore_assets": "Não foi possível restaurar arquivos", - "unable_to_restore_trash": "Não foi possível restaurar itens da lixeira", + "unable_to_reset_password": "Não foi possível redefinir a palavra-passe", + "unable_to_resolve_duplicate": "Não foi possível resolver as duplicidades", + "unable_to_restore_assets": "Não foi possível restaurar ficheiros", + "unable_to_restore_trash": "Não foi possível restaurar itens da reciclagem", "unable_to_restore_user": "Não foi possível restaurar utilizador", - "unable_to_save_album": "Não foi possível salvar o álbum", - "unable_to_save_api_key": "Não foi possível salvar a Chave de API", + "unable_to_save_album": "Não foi possível guardar o álbum", + "unable_to_save_api_key": "Não foi possível guardar a Chave de API", "unable_to_save_date_of_birth": "Não foi possível guardar a data de nascimento", - "unable_to_save_name": "Não foi possível salvar o nome", - "unable_to_save_profile": "Não foi possível salvar o perfil", - "unable_to_save_settings": "Não foi possível salvar as configurações", - "unable_to_scan_libraries": "Não foi possível escanear as bibliotecas", - "unable_to_scan_library": "Não foi possível escanear a biblioteca", - "unable_to_set_feature_photo": "Não é possível definir a foto do recurso", + "unable_to_save_name": "Não foi possível guardar o nome", + "unable_to_save_profile": "Não foi possível guardar o perfil", + "unable_to_save_settings": "Não foi possível guardar as definições", + "unable_to_scan_libraries": "Não foi possível analisar as bibliotecas", + "unable_to_scan_library": "Não foi possível analisar a biblioteca", + "unable_to_set_feature_photo": "Não foi possível definir a foto de destaque", "unable_to_set_profile_picture": "Não foi possível definir a foto de perfil", - "unable_to_submit_job": "Não foi possível enviar o trabalho", - "unable_to_trash_asset": "Não foi possível enviar o ativo para a lixeira", + "unable_to_submit_job": "Não foi possível enviar a tarefa", + "unable_to_trash_asset": "Não foi possível enviar o ficheiro para a reciclagem", "unable_to_unlink_account": "Não foi possível desvincular conta", + "unable_to_unlink_motion_video": "Não foi possível remover a relação com o video animado", "unable_to_update_album_cover": "Não foi possível atualizar a capa do álbum", "unable_to_update_album_info": "Não foi possível atualizar informações do álbum", "unable_to_update_library": "Não foi possível atualizar a biblioteca", "unable_to_update_location": "Não foi possível atualizar a localização", - "unable_to_update_settings": "Não foi possível atualizar as configurações", + "unable_to_update_settings": "Não foi possível atualizar as definições", "unable_to_update_timeline_display_status": "Não foi possível atualizar o modo de visualização da linha do tempo", - "unable_to_update_user": "Não foi possível atualizar o usuário", + "unable_to_update_user": "Não foi possível atualizar o utilizador", "unable_to_upload_file": "Não foi possível carregar o ficheiro" }, "every_day_at_onepm": "", @@ -707,7 +725,7 @@ "exif": "Exif", "exit_slideshow": "Sair da apresentação", "expand_all": "Expandir tudo", - "expire_after": "Expira depois", + "expire_after": "Expira depois de", "expired": "Expirou", "expires_date": "Expira em {date}", "explore": "Explorar", @@ -720,38 +738,41 @@ "face_unassigned": "Sem atribuição", "failed_to_get_people": "Falha ao carregar as pessoas", "favorite": "Favorito", - "favorite_or_unfavorite_photo": "Marque ou desmarque a foto como favorita", + "favorite_or_unfavorite_photo": "Marcar ou desmarcar a foto como favorita", "favorites": "Favoritos", "feature": "", "feature_photo_updated": "Foto principal atualizada", "featurecollection": "", - "file_name": "Nome do arquivo", - "file_name_or_extension": "Nome do arquivo ou extensão", - "filename": "Nome do arquivo", + "features": "Funcionalidades", + "features_setting_description": "Configurar as funcionalidades da aplicação", + "file_name": "Nome do ficheiro", + "file_name_or_extension": "Nome do ficheiro ou extensão", + "filename": "Nome do ficheiro", "files": "", - "filetype": "Tipo de arquivo", + "filetype": "Tipo de ficheiro", "filter_people": "Filtrar pessoas", - "find_them_fast": "Encontre pelo nome em uma pesquisa", + "find_them_fast": "Encontre-as mais rapidamente pelo nome numa pesquisa", "fix_incorrect_match": "Corrigir correspondência incorreta", "folders": "Pastas", - "force_re-scan_library_files": "Força escanear novamente todos os arquivos da biblioteca", - "forward": "Para frente", + "folders_feature_description": "A navegar na vista de pastas pelas fotos e vídeos no sistema de ficheiros", + "force_re-scan_library_files": "Forçar uma nova análise de todos os ficheiros da biblioteca", + "forward": "Para a frente", "general": "Geral", "get_help": "Obter Ajuda", - "getting_started": "Primeiros passos", - "go_back": "Voltar", + "getting_started": "Primeiros Passos", + "go_back": "Regressar", "go_to_search": "Ir para a pesquisa", "go_to_share_page": "Ir para a página de compartilhamento", "group_albums_by": "Agrupar álbuns por...", "group_no": "Sem agrupamento", "group_owner": "Agrupar por dono", "group_year": "Agrupar por ano", - "has_quota": "Há cota", + "has_quota": "Tem quota", "hi_user": "Olá {name} ({email})", "hide_all_people": "Ocultar todas as pessoas", "hide_gallery": "Ocultar galeria", "hide_named_person": "Ocultar pessoa {name}", - "hide_password": "Ocultar senha", + "hide_password": "Ocultar palavra-passe", "hide_person": "Ocultar pessoa", "hide_unnamed_people": "Ocultar pessoas sem nome", "host": "Host", @@ -768,33 +789,33 @@ "image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e {person3} em {date}", "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e outras {additionalCount, number} pessoas em {date}", "img": "", - "immich_logo": "Logo do Immich", - "immich_web_interface": "Interface web do Immich", - "import_from_json": "Importar do JSON", + "immich_logo": "Logotipo do Immich", + "immich_web_interface": "Interface Web do Immich", + "import_from_json": "Importar a partir de JSON", "import_path": "Caminho de importação", "in_albums": "Em {count, plural, one {# álbum} other {# álbuns}}", "in_archive": "Arquivado", "include_archived": "Incluir arquivados", - "include_shared_albums": "Incluir álbuns compartilhados", - "include_shared_partner_assets": "Incluir arquivos compartilhados por parceiros", - "individual_share": "Compartilhamento único", + "include_shared_albums": "Incluir álbuns partilhados", + "include_shared_partner_assets": "Incluir ficheiros partilhados por parceiros", + "individual_share": "Partilha individual", "info": "Informações", "interval": { - "day_at_onepm": "Todo dia, 1pm", + "day_at_onepm": "Todos os dias, às 13:00", "hours": "A cada {hours, plural, one {hora} other {{hours, number} horas}}", - "night_at_midnight": "Toda noite, meia noite", - "night_at_twoam": "Toda noite, 2am" + "night_at_midnight": "Todas as noites, à meia noite", + "night_at_twoam": "Todas as noites, às 02:00" }, "invite_people": "Convidar Pessoas", "invite_to_album": "Convidar para o álbum", "items_count": "{count, plural, one {item #} other {itens #}}", "job_settings_description": "", - "jobs": "Trabalhos", + "jobs": "Tarefas", "keep": "Manter", "keep_all": "Manter Todos", "keyboard_shortcuts": "Atalhos do teclado", "language": "Idioma", - "language_setting_description": "Selecione seu Idioma preferido", + "language_setting_description": "Selecione o seu Idioma preferido", "last_seen": "Visto pela ultima vez", "latest_version": "Versão mais recente", "latitude": "Latitude", @@ -804,64 +825,65 @@ "library": "Biblioteca", "library_options": "Opções da biblioteca", "light": "Claro", - "like_deleted": "Curtida removida", + "like_deleted": "Gosto removido", + "link_motion_video": "Relacionar video animado", "link_options": "Opções do Link", "link_to_oauth": "Link do OAuth", - "linked_oauth_account": "Conta OAuth Vinculada", + "linked_oauth_account": "Conta OAuth Associada", "list": "Lista", - "loading": "Carregando", - "loading_search_results_failed": "Falha ao carregar os resultados da pesquisa", + "loading": "A Carregar", + "loading_search_results_failed": "Não foi possível carregar os resultados da pesquisa", "log_out": "Sair", - "log_out_all_devices": "Sair de todos dispositivos", + "log_out_all_devices": "Terminar a sessão de todos os dispositivos", "logged_out_all_devices": "Sessão terminada em todos os dispositivos", "logged_out_device": "Sessão terminada no dispositivo", "login": "Iniciar sessão", - "login_has_been_disabled": "Login foi desativado.", - "logout_all_device_confirmation": "Tem certeza de que deseja desconectar todos os dispositivos?", - "logout_this_device_confirmation": "Tem certeza de que deseja sair deste dispositivo?", + "login_has_been_disabled": "Início de sessão foi desativado.", + "logout_all_device_confirmation": "Tem certeza de que deseja terminar a sessão em todos os dispositivos?", + "logout_this_device_confirmation": "Tem certeza de que deseja terminar a sessão deste dispositivo?", "longitude": "Longitude", "look": "Estilo", "loop_videos": "Repetir vídeos", - "loop_videos_description": "Ative para repetir os vídeos automaticamente durante a exibição.", + "loop_videos_description": "Ativar para repetir os vídeos automaticamente durante a exibição.", "make": "Marca", "manage_shared_links": "Gerir links partilhados", - "manage_sharing_with_partners": "Gerenciar compartilhamento com parceiros", - "manage_the_app_settings": "Gerenciar configurações do app", - "manage_your_account": "Gerenciar sua conta", - "manage_your_api_keys": "Gerenciar suas Chaves de API", - "manage_your_devices": "Gerenciar seus dispositivos logados", - "manage_your_oauth_connection": "Gerenciar sua conexão OAuth", + "manage_sharing_with_partners": "Gerir partilha com parceiros", + "manage_the_app_settings": "Gerir definições da aplicação", + "manage_your_account": "Gerir a sua conta", + "manage_your_api_keys": "Gerir as suas Chaves de API", + "manage_your_devices": "Gerir os seus dispositivos com sessão iniciada", + "manage_your_oauth_connection": "Gerir a sua ligação ao OAuth", "map": "Mapa", "map_marker_for_images": "Marcador no mapa para fotos tiradas em {city}, {country}", "map_marker_with_image": "Marcador de mapa com imagem", "map_settings": "Definições do mapa", "matches": "Correspondências", - "media_type": "Tipo de mídia", + "media_type": "Tipo de média", "memories": "Memórias", - "memories_setting_description": "Gerencie o que vê em suas memórias", + "memories_setting_description": "Gerir o que vê nas suas memórias", "memory": "Memória", "memory_lane_title": "Memórias {title}", "menu": "Menu", - "merge": "Mesclar", - "merge_people": "Mesclar pessoas", - "merge_people_limit": "Só é possível mesclar até 5 faces de uma só vez", - "merge_people_prompt": "Tem certeza que deseja mesclar estas pessoas? Esta ação é irreversível.", - "merge_people_successfully": "Pessoas mescladas com sucesso", - "merged_people_count": "Mesclada {count, plural, one {1 pessoa} other {# pessoas}}", + "merge": "Unir", + "merge_people": "Unir pessoas", + "merge_people_limit": "Só é possível unir até 5 rostos de cada vez", + "merge_people_prompt": "Tem a certeza de que deseja unir estas pessoas? Esta ação é irreversível.", + "merge_people_successfully": "Pessoas unidas com sucesso", + "merged_people_count": "Unidas {count, plural, one {# pessoa} other {# pessoas}}", "minimize": "Minimizar", "minute": "Minuto", - "missing": "Faltando", + "missing": "Em falta", "model": "Modelo", "month": "Mês", "more": "Mais", - "moved_to_trash": "Enviado para a lixeira", - "my_albums": "Meus Álbuns", + "moved_to_trash": "Enviado para a reciclagem", + "my_albums": "Os meus álbuns", "name": "Nome", - "name_or_nickname": "Nome ou apelido", + "name_or_nickname": "Nome ou alcunha", "never": "Nunca", "new_album": "Novo Álbum", "new_api_key": "Nova Chave de API", - "new_password": "Nova senha", + "new_password": "Nova palavra-passe", "new_person": "Nova Pessoa", "new_user_created": "Novo utilizador criado", "new_version_available": "NOVA VERSÃO DISPONÍVEL", @@ -869,48 +891,48 @@ "next": "Avançar", "next_memory": "Próxima memória", "no": "Não", - "no_albums_message": "Crie um álbum para organizar suas fotos e vídeos", - "no_albums_with_name_yet": "Parece que você ainda não tem nenhum álbum com este nome.", - "no_albums_yet": "Parece que você ainda não tem nenhum álbum.", + "no_albums_message": "Crie um álbum para organizar as suas fotos e vídeos", + "no_albums_with_name_yet": "Parece que ainda não tem nenhum álbum com este nome.", + "no_albums_yet": "Parece que ainda não tem nenhum álbum.", "no_archived_assets_message": "Arquive fotos e vídeos para os ocultar da sua visualização de fotos", - "no_assets_message": "CLIQUE PARA CARREGAR SUA PRIMEIRA FOTO", - "no_duplicates_found": "Nenhuma duplicidade foi encontrada.", + "no_assets_message": "FAÇA CLIQUE PARA CARREGAR A SUA PRIMEIRA FOTO", + "no_duplicates_found": "Nenhum item duplicado foi encontrado.", "no_exif_info_available": "Sem informações exif disponíveis", - "no_explore_results_message": "Carregue mais fotos para explorar sua coleção.", - "no_favorites_message": "Adicione aos favoritos para encontrar suas melhores fotos e vídeos rapidamente", - "no_libraries_message": "Crie uma biblioteca externa para ver suas fotos e vídeos", + "no_explore_results_message": "Carregue mais fotos para explorar a sua coleção.", + "no_favorites_message": "Adicione aos favoritos para encontrar as suas melhores fotos e vídeos rapidamente", + "no_libraries_message": "Crie uma biblioteca externa para ver as suas fotos e vídeos", "no_name": "Sem nome", "no_places": "Sem lugares", "no_results": "Sem resultados", - "no_results_description": "Tente um sinônimo ou uma palavra-chave mais comum", - "no_shared_albums_message": "Crie um álbum para compartilhar fotos e vídeos com pessoas em sua rede", - "not_in_any_album": "Fora de álbum", - "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar o rótulo de armazenamento a arquivos carregados anteriormente, execute o", - "note_unlimited_quota": "Nota: Digite 0 para cota ilimitada", + "no_results_description": "Tente um sinónimo ou uma palavra-chave mais comum", + "no_shared_albums_message": "Crie um álbum para partilhar fotos e vídeos com pessoas na sua rede", + "not_in_any_album": "Não está em nenhum álbum", + "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar o Rótulo de Armazenamento a ficheiros carregados anteriormente, execute o", + "note_unlimited_quota": "Nota: Escreva 0 para quota ilimitada", "notes": "Notas", - "notification_toggle_setting_description": "Habilitar notificações por e-mail", + "notification_toggle_setting_description": "Ativar notificações por e-mail", "notifications": "Notificações", - "notifications_setting_description": "Gerenciar notificações", + "notifications_setting_description": "Gerir notificações", "oauth": "OAuth", "offline": "Offline", "offline_paths": "Caminhos offline", - "offline_paths_description": "Estes resultados podem ser devidos a arquivos deletados manualmente e que não são parte de uma biblioteca externa.", + "offline_paths_description": "Estes resultados podem ser devidos a ficheiros eliminados manualmente e que não fazem parte de uma biblioteca externa.", "ok": "Ok", "oldest_first": "Mais antigo primeiro", "onboarding": "Integração", - "onboarding_privacy_description": "Os seguintes recursos (opcionais) dependem de serviços externos e podem ser desabilitados a qualquer momento nas configurações de administração.", - "onboarding_theme_description": "Escolha um tema de cor para sua instância. Você pode alterar isso mais tarde em suas configurações.", - "onboarding_welcome_description": "Vamos configurar sua instância com algumas configurações comuns.", + "onboarding_privacy_description": "As seguintes funcionalidades opcionais dependem de serviços externos e podem ser desativados a qualquer momento nas definições de administração.", + "onboarding_theme_description": "Escolha um tema de cor para sua instância. Pode alterar isto mais tarde nas suas definições.", + "onboarding_welcome_description": "Vamos configurar a sua instância com algumas definições comuns.", "onboarding_welcome_user": "Bem-vindo(a), {user}", "online": "Online", - "only_favorites": "Somente favoritos", - "only_refreshes_modified_files": "Somente atualize arquivos modificados", - "open_in_map_view": "Abrir na visualização do mapa", + "only_favorites": "Apenas favoritos", + "only_refreshes_modified_files": "Apenas recarrega ficheiros modificados", + "open_in_map_view": "Abrir na visualização de mapa", "open_in_openstreetmap": "Abrir no OpenStreetMap", - "open_the_search_filters": "Abre os filtros de pesquisa", + "open_the_search_filters": "Abrir os filtros de pesquisa", "options": "Opções", "or": "ou", - "organize_your_library": "Organize sua biblioteca", + "organize_your_library": "Organizar a sua biblioteca", "original": "original", "other": "Outro", "other_devices": "Outros dispositivos", @@ -918,15 +940,15 @@ "owned": "Seu", "owner": "Dono", "partner": "Parceiro", - "partner_can_access": "{partner} pode acessar", - "partner_can_access_assets": "Todas as suas fotos e vídeos, exceto os Arquivados ou Excluídos", + "partner_can_access": "{partner} pode aceder", + "partner_can_access_assets": "Todas as suas fotos e vídeos, exceto os Arquivados ou Eliminados", "partner_can_access_location": "A localização onde as fotos foram tiradas", - "partner_sharing": "Compartilhamento com Parceiro", + "partner_sharing": "Partilha com Parceiro", "partners": "Parceiros", - "password": "Senha", - "password_does_not_match": "As senhas não são iguais", - "password_required": "A senha é obrigatório", - "password_reset_success": "Senha resetada com sucesso", + "password": "Palavra-passe", + "password_does_not_match": "As palavras-passe não condizem", + "password_required": "A palavra-passe é obrigatória", + "password_reset_success": "Palavra-passe redefinida com sucesso", "past_durations": { "days": "{days, plural, one {Último dia} other {# últimos dias}}", "hours": "Últimas {hours, plural, one {horas} other {# horas}}", @@ -934,25 +956,26 @@ }, "path": "Caminho", "pattern": "Padrão", - "pause": "Interromper", - "pause_memories": "Interromper memórias", - "paused": "Interrompido", + "pause": "Pausa", + "pause_memories": "Pausar memórias", + "paused": "Em Pausa", "pending": "Pendente", "people": "Pessoas", "people_edits_count": "{count, plural, one {# pessoa editada} other {# pessoas editadas}}", - "people_sidebar_description": "Exibe o link Pessoas na barra lateral", + "people_feature_description": "A navegar fotos e vídeos agrupados por pessoas", + "people_sidebar_description": "Exibir o link Pessoas na barra lateral", "perform_library_tasks": "", - "permanent_deletion_warning": "Aviso para deletar permanentemente", - "permanent_deletion_warning_setting_description": "Exibe um aviso ao excluir arquivos de forma permanente", - "permanently_delete": "Deletar permanentemente", - "permanently_delete_assets_count": "Excluir permanentemente {count, plural, one {arquivo} other {arquivos}}", - "permanently_delete_assets_prompt": "Tem certeza que deseja excluir permanentemente {count, plural, one {esse arquivo?} other {estes # arquivos?}} Essa ação também removerá {count, plural, one {isto do} other {isto dos}} álbum(s).", - "permanently_deleted_asset": "Ativo deletado permanentemente", + "permanent_deletion_warning": "Aviso de eliminação permanente", + "permanent_deletion_warning_setting_description": "Exibir um aviso ao eliminar ficheiros de forma permanente", + "permanently_delete": "Eliminar permanentemente", + "permanently_delete_assets_count": "Eliminar permanentemente {count, plural, one {ficheiro} other {ficheiros}}", + "permanently_delete_assets_prompt": "Tem a certeza de que deseja eliminar permanentemente {count, plural, one {este ficheiro?} other {estes # ficheiros?}} Esta ação também removerá {count, plural, one {isto do álbum} other {isto dos álbuns}}.", + "permanently_deleted_asset": "Ficheiro eliminado permanentemente", "permanently_deleted_assets": "{count, plural, one {# ativo deletado} other {# ativos deletados}} permanentemente", - "permanently_deleted_assets_count": "{count, plural, one {# arquivo excluído} other {# arquivos excluídos}} permanentemente", + "permanently_deleted_assets_count": "{count, plural, one {# Ficheiro eliminado} other {# Ficheiros eliminados}} permanentemente", "person": "Pessoa", "person_hidden": "{name}{hidden, select, true { (oculto)} other {}}", - "photo_shared_all_users": "Parece que você compartilhou suas fotos com todos os usuários ou não tem nenhum usuário para compartilhar.", + "photo_shared_all_users": "Parece que partilhou as suas fotos com todos os utilizadores ou não tem nenhum utilizador para partilhar.", "photos": "Fotos", "photos_and_videos": "Fotos & Vídeos", "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}", @@ -976,179 +999,183 @@ "profile_image_of_user": "Imagem de perfil de {user}", "profile_picture_set": "Foto de perfil definida.", "public_album": "Álbum público", - "public_share": "Compartilhar Publicamente", - "purchase_account_info": "Apoiador", + "public_share": "Partilhar Publicamente", + "purchase_account_info": "Apoiante", "purchase_activated_subtitle": "Agradecemos por apoiar o Immich e software de código aberto", "purchase_activated_time": "Ativado em {date, date}", - "purchase_activated_title": "Sua chave foi ativada com sucesso", + "purchase_activated_title": "A sua chave foi ativada com sucesso", "purchase_button_activate": "Ativar", "purchase_button_buy": "Comprar", "purchase_button_buy_immich": "Comprar Immich", - "purchase_button_never_show_again": "Nunca mostrar novamente", + "purchase_button_never_show_again": "Não mostrar de novo", "purchase_button_reminder": "Relembrar-me daqui a 30 dias", "purchase_button_remove_key": "Remover chave", "purchase_button_select": "Selecionar", - "purchase_failed_activation": "Falha ao ativar! Verifique seu e-mail para obter a chave de produto correta!", - "purchase_individual_description_1": "Para uma pessoa", - "purchase_individual_description_2": "Status de apoiador", + "purchase_failed_activation": "Não foi possível ativar! Verifique o seu e-mail para obter a chave de produto correta!", + "purchase_individual_description_1": "Para uma pessoa individual", + "purchase_individual_description_2": "Status de apoiante", "purchase_individual_title": "Particular", "purchase_input_suggestion": "Tem uma chave de produto? Insira a chave abaixo", - "purchase_license_subtitle": "Compre Immich para apoiar o desenvolvimento contínuo do serviço", + "purchase_license_subtitle": "Compre o Immich para apoiar o desenvolvimento contínuo do serviço", "purchase_lifetime_description": "Compra vitalícia", "purchase_option_title": "OPÇÕES DE COMPRA", "purchase_panel_info_1": "O desenvolvimento do Immich requer muito tempo e esforço, e temos engenheiros a tempo inteiro a trabalhar nele para melhorá-lo quanto possível. A nossa missão é para que o software de código aberto e práticas de negócio éticas se tornem numa fonte de rendimento sustentável para os desenvolvedores e criar um ecossistema que respeite a privacidade dos utilizadores e que ofereça alternativas reais a serviços cloud explorativos.", - "purchase_panel_info_2": "Como estamos comprometidos em não adicionar acesso pago, esta compra não lhe dará nenhum recurso adicional no Immich. Contamos com usuários como você para dar suporte ao desenvolvimento contínuo do Immich.", + "purchase_panel_info_2": "Como estamos comprometidos a não adicionar acesso pago, esta compra não lhe dará acesso a nenhuma funcionalidade adicional do Immich. Contamos com utilizadores como você para dar suporte ao desenvolvimento contínuo do Immich.", "purchase_panel_title": "Apoie o projeto", "purchase_per_server": "Por servidor", "purchase_per_user": "Por utilizador", "purchase_remove_product_key": "Remover chave de produto", - "purchase_remove_product_key_prompt": "Tem certeza de que deseja remover a chave do produto?", + "purchase_remove_product_key_prompt": "Tem a certeza de que deseja remover a chave do produto?", "purchase_remove_server_product_key": "Remover chave do produto do servidor", - "purchase_remove_server_product_key_prompt": "Tem certeza de que deseja remover a chave do produto do servidor?", + "purchase_remove_server_product_key_prompt": "Tem a certeza de que deseja remover a chave do produto do servidor?", "purchase_server_description_1": "Para o servidor inteiro", - "purchase_server_description_2": "Status de apoiador", + "purchase_server_description_2": "Status de apoiante", "purchase_server_title": "Servidor", - "purchase_settings_server_activated": "A chave de produto para servidor é gerida pelo administrador", + "purchase_settings_server_activated": "A chave de produto do servidor é gerida pelo administrador", "range": "", "rating": "Classificação por estrelas", "rating_clear": "Limpar classificação", - "rating_count": "{contar, plural, um {# estrela} outro {# estrelas}}", - "rating_description": "Exibir a classificação exif no painel de informações", + "rating_count": "{count, plural, one {# estrela} other {# estrelas}}", + "rating_description": "Mostrar a classificação EXIF no painel de informações", "raw": "", "reaction_options": "Opções de reação", "read_changelog": "Ler Novidades", "reassign": "Reatribuir", - "reassigned_assets_to_existing_person": "Reatribuir {count, plural, one {# arquivo} other {# arquivos}} PARA {name, select, null {uma pessoa existente} other {{name}}}", - "reassigned_assets_to_new_person": "Reatribuir {count, plural, one {# arquivo} other {# arquivos}} a uma nova pessoa", - "reassing_hint": "Atribuir ativos selecionados a uma pessoa existente", - "recent": "Recente", + "reassigned_assets_to_existing_person": "Reatribuir {count, plural, one {# ficheiro} other {# ficheiros}} para {name, select, null {uma pessoa existente} other {{name}}}", + "reassigned_assets_to_new_person": "Reatribuído {count, plural, one {# ficheiro} other {# ficheiros}} a uma nova pessoa", + "reassing_hint": "Atribuir ficheiros selecionados a uma pessoa existente", + "recent": "Recentes", "recent_searches": "Pesquisas recentes", "refresh": "Atualizar", "refresh_encoded_videos": "Atualizar vídeos codificados", "refresh_metadata": "Atualizar metadados", "refresh_thumbnails": "Atualizar miniaturas", "refreshed": "Atualizado", - "refreshes_every_file": "Atualiza todos arquivos", - "refreshing_encoded_video": "Atualizando vídeo codificado", + "refreshes_every_file": "Atualiza todos os ficheiros", + "refreshing_encoded_video": "A atualizar vídeo codificado", "refreshing_metadata": "A atualizar metadados", "regenerating_thumbnails": "A atualizar miniaturas", "remove": "Remover", - "remove_assets_album_confirmation": "Tem certeza que deseja remover {count, plural, one {# arquivo} other {# arquivos}} do álbum?", - "remove_assets_shared_link_confirmation": "Tem certeza que deseja remover {count, plural, one {# arquivo} other {# arquivos}} desse link compartilhado?", - "remove_assets_title": "Remover arquivos?", + "remove_assets_album_confirmation": "Tem a certeza de que deseja remover {count, plural, one {# ficheiro} other {# ficheiros}} do álbum?", + "remove_assets_shared_link_confirmation": "Tem certeza de que deseja remover {count, plural, one {# ficheiro} other {# ficheiros}} deste link partilhado?", + "remove_assets_title": "Remover ficheiros?", "remove_custom_date_range": "Remover intervalo de datas personalizado", "remove_from_album": "Remover do álbum", "remove_from_favorites": "Remover dos favoritos", - "remove_from_shared_link": "Remover do link compartilhado", - "remove_offline_files": "Remover arquivos offline", + "remove_from_shared_link": "Remover do link partilhado", + "remove_offline_files": "Remover ficheiros offline", "remove_user": "Remover utilizador", - "removed_api_key": "Removido a Chave de API: {name}", + "removed_api_key": "Foi removida a Chave de API: {name}", "removed_from_archive": "Removido do arquivo", "removed_from_favorites": "Removido dos favoritos", - "removed_from_favorites_count": "{count, plural, other {Removido #}} dos favoritos", - "rename": "Renomear", + "removed_from_favorites_count": "{count, plural, other {Removidos #}} dos favoritos", + "removed_tagged_assets": "Removida a etiqueta de {count, plural, one {# ficheiro} other {# ficheiros}}", + "rename": "Mudar o nome", "repair": "Reparar", - "repair_no_results_message": "Arquivos perdidos ou não rastreados aparecem aqui", - "replace_with_upload": "Substituir", + "repair_no_results_message": "Ficheiros perdidos ou não rastreados irão aparecer aqui", + "replace_with_upload": "Substituir pelo ficheiro carregado", "repository": "Repositório", - "require_password": "Proteger com senha", - "require_user_to_change_password_on_first_login": "Obrigar utilizador a alterar a senha após primeiro início de sessão", - "reset": "Resetar", - "reset_password": "Resetar senha", - "reset_people_visibility": "Resetar pessoas ocultas", + "require_password": "Proteger com palavra-passe", + "require_user_to_change_password_on_first_login": "Obrigar utilizador a alterar a palavra-passe após o primeiro início de sessão", + "reset": "Redefinir", + "reset_password": "Redefinir palavra-passe", + "reset_people_visibility": "Redefinir pessoas ocultas", "reset_settings_to_default": "", "reset_to_default": "Repor predefinições", "resolve_duplicates": "Resolver itens duplicados", - "resolved_all_duplicates": "Todas duplicidades resolvidas", + "resolved_all_duplicates": "Todos os itens duplicados resolvidos", "restore": "Restaurar", "restore_all": "Restaurar tudo", "restore_user": "Restaurar utilizador", - "restored_asset": "Arquivo restaurado", + "restored_asset": "Ficheiro restaurado", "resume": "Continuar", "retry_upload": "Tentar carregar novamente", - "review_duplicates": "Revisar duplicidade", + "review_duplicates": "Rever itens duplicados", "role": "Função", "role_editor": "Editor", "role_viewer": "Visualizador", "save": "Guardar", - "saved_api_key": "Chave de API salva", - "saved_profile": "Perfil Salvo", - "saved_settings": "Configurações salvas", - "say_something": "Diga algo", - "scan_all_libraries": "Escanear Todas Bibliotecas", - "scan_all_library_files": "Re-escanear todos arquivos da biblioteca", - "scan_new_library_files": "Escanear novos arquivos na biblioteca", - "scan_settings": "Opções de escanear", - "scanning_for_album": "Escaneando por álbum...", + "saved_api_key": "Chave de API guardada", + "saved_profile": "Perfil guardado", + "saved_settings": "Definições guardadas", + "say_something": "Diga alguma coisa", + "scan_all_libraries": "Analisar todas as bibliotecas", + "scan_all_library_files": "Re-analisar todos os ficheiros da biblioteca", + "scan_new_library_files": "Analisar novos ficheiros na biblioteca", + "scan_settings": "Opções de análise", + "scanning_for_album": "A analisar por álbum...", "search": "Pesquisar", "search_albums": "Pesquisar álbuns", "search_by_context": "Pesquisar por contexto", "search_by_filename": "Pesquisar por nome de ficheiro ou extensão", "search_by_filename_example": "por exemplo, IMG_1234.JPG ou PNG", - "search_camera_make": "Pesquisar câmeras da marca...", - "search_camera_model": "Pesquisar câmera do modelo...", + "search_camera_make": "Pesquisar por marca da câmara...", + "search_camera_model": "Pesquisar por modelo da câmara...", "search_city": "Pesquisar cidade...", "search_country": "Pesquisar país...", - "search_for_existing_person": "Pesquisar por pessoas", - "search_no_people": "Nenhuma pessoa", + "search_for_existing_person": "Pesquisar por pessoas existentes", + "search_no_people": "Sem pessoas", "search_no_people_named": "Nenhuma pessoa chamada \"{name}\"", + "search_options": "Opções de pesquisa", "search_people": "Pesquisar pessoas", "search_places": "Pesquisar lugares", - "search_state": "Pesquisar estado...", + "search_settings": "Definições de pesquisa", + "search_state": "Pesquisar estado/distrito...", + "search_tags": "Pesquisar etiquetas...", "search_timezone": "Pesquisar fuso horário...", - "search_type": "Pesquisar tipo", + "search_type": "Tipo de pesquisa", "search_your_photos": "Pesquisar fotos", - "searching_locales": "Pesquisar Lugares....", + "searching_locales": "A pesquisar Lugares....", "second": "Segundo", "see_all_people": "Ver todas as pessoas", "select_album_cover": "Escolher capa do álbum", "select_all": "Selecionar todos", "select_all_duplicates": "Selecionar todos os itens duplicados", "select_avatar_color": "Selecionar cor do avatar", - "select_face": "Selecionar face", + "select_face": "Selecionar rosto", "select_featured_photo": "Selecionar foto principal", - "select_from_computer": "Selecionar do computador", - "select_keep_all": "Marcar manter em todos", - "select_library_owner": "Selecione o dono da biblioteca", - "select_new_face": "Selecionar nova face", + "select_from_computer": "Selecionar a partir do computador", + "select_keep_all": "Selecionar manter todos", + "select_library_owner": "Selecionar o dono da biblioteca", + "select_new_face": "Selecionar novo rosto", "select_photos": "Selecionar fotos", - "select_trash_all": "Marcar lixo em todos", + "select_trash_all": "Selecionar todos para reciclagem", "selected": "Selecionados", - "selected_count": "{count, plural, other {# selecionado}}", + "selected_count": "{count, plural, other {# selecionados}}", "send_message": "Enviar mensagem", "send_welcome_email": "Enviar E-mail de boas vindas", "server": "Servidor", "server_offline": "Servidor Offline", "server_online": "Servidor Online", - "server_stats": "Status do servidor", + "server_stats": "Estado do servidor", "server_version": "Versão do servidor", "set": "Definir", "set_as_album_cover": "Definir como capa do álbum", "set_as_profile_picture": "Definir como foto de perfil", "set_date_of_birth": "Definir data de nascimento", "set_profile_picture": "Definir foto de perfil", - "set_slideshow_to_fullscreen": "Apresentação em tela cheia", - "settings": "Configurações", - "settings_saved": "Configurações salvas", - "share": "Compartilhar", - "shared": "Compartilhado", - "shared_by": "Compartilhado por", + "set_slideshow_to_fullscreen": "Apresentação em ecrã inteiro", + "settings": "Definições", + "settings_saved": "Definições guardadas", + "share": "Partilhar", + "shared": "Partilhado", + "shared_by": "Partilhado por", "shared_by_user": "Partilhado por {user}", - "shared_by_you": "Compartilhado por você", + "shared_by_you": "Partilhado por si", "shared_from_partner": "Fotos de {partner}", - "shared_link_options": "Opções de link compartilhado", - "shared_links": "Links compartilhados", - "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos & videos compartilhados.}}", - "shared_with_partner": "Compartilhado com {partner}", - "sharing": "Compartilhar", - "sharing_enter_password": "Por favor, digite a senha para visualizar esta página.", - "sharing_sidebar_description": "Exibe o link Compartilhar na barra lateral", - "shift_to_permanent_delete": "Pressione ⇧ para excluir o arquivo permanentemente", + "shared_link_options": "Opções de link partilhado", + "shared_links": "Links partilhados", + "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos & videos partilhados.}}", + "shared_with_partner": "Partilhado com {partner}", + "sharing": "Partilha", + "sharing_enter_password": "Por favor, insira a palavra-passe para ver esta página.", + "sharing_sidebar_description": "Exibe o link para Partilhar na barra lateral", + "shift_to_permanent_delete": "Pressione ⇧ para eliminar o ficheiro permanentemente", "show_album_options": "Exibir opções do álbum", "show_albums": "Mostrar álbuns", "show_all_people": "Mostrar todas as pessoas", "show_and_hide_people": "Mostrar & ocultar pessoas", - "show_file_location": "Exibir local do arquivo", + "show_file_location": "Exibir localização do ficheiro", "show_gallery": "Exibir galeria", "show_hidden_people": "Exibir pessoas ocultadas", "show_in_timeline": "Exibir na linha do tempo", @@ -1156,19 +1183,23 @@ "show_keyboard_shortcuts": "Exibir atalhos do teclado", "show_metadata": "Mostrar metadados", "show_or_hide_info": "Exibir ou ocultar informações", - "show_password": "Exibir senha", + "show_password": "Mostrar palavra-passe", "show_person_options": "Exibir opções da pessoa", "show_progress_bar": "Exibir barra de progresso", "show_search_options": "Exibir opções de pesquisa", - "show_supporter_badge": "Emblema de apoiador", - "show_supporter_badge_description": "Mostrar um emblema de apoiador", + "show_supporter_badge": "Emblema de apoiante", + "show_supporter_badge_description": "Mostrar um emblema de apoiante", "shuffle": "Aleatório", - "sign_out": "Sair", - "sign_up": "Registrar", + "sidebar": "Barra lateral", + "sidebar_display_description": "Mostrar um link para a vista na barra lateral", + "sign_out": "Terminar sessão", + "sign_up": "Criar conta", "size": "Tamanho", - "skip_to_content": "Pular para o conteúdo", + "skip_to_content": "Saltar para o conteúdo", + "skip_to_folders": "Saltar para pastas", + "skip_to_tags": "Saltar para as etiquetas", "slideshow": "Apresentação", - "slideshow_settings": "Opções de apresentação", + "slideshow_settings": "Definições de apresentação", "sort_albums_by": "Ordenar álbuns por...", "sort_created": "Data de criação", "sort_items": "Número de itens", @@ -1178,49 +1209,58 @@ "sort_title": "Título", "source": "Fonte", "stack": "Empilhar", - "stack_duplicates": "Empilhar duplicados", + "stack_duplicates": "Empilhar itens duplicados", "stack_select_one_photo": "Selecione uma foto principal para a pilha", "stack_selected_photos": "Empilhar fotos selecionadas", - "stacked_assets_count": "Empilhado {count, plural, one {# arquivo} other {# arquivos}}", + "stacked_assets_count": "Empilhado {count, plural, one {# ficheiro} other {# ficheiros}}", "stacktrace": "Stacktrace", - "start": "Início", - "start_date": "Data inicial", + "start": "Iniciar", + "start_date": "Data de início", "state": "Estado", - "status": "Status", + "status": "Estado", "stop_motion_photo": "Parar foto em movimento", - "stop_photo_sharing": "Parar de partilhar as suas fotos?", - "stop_photo_sharing_description": "{partner} não terá mais acesso às suas fotos.", - "stop_sharing_photos_with_user": "Parar de compartilhar as fotos com este utilizador", + "stop_photo_sharing": "Deixar de partilhar as suas fotos?", + "stop_photo_sharing_description": "{partner} deixará de ter acesso às suas fotos.", + "stop_sharing_photos_with_user": "Deixar de partilhar as fotos com este utilizador", "storage": "Espaço de armazenamento", - "storage_label": "Rótulo de armazenamento", - "storage_usage": "utilizado {used} de {available}", + "storage_label": "Rótulo de Armazenamento", + "storage_usage": "Utilizado {used} de {available}", "submit": "Enviar", "suggestions": "Sugestões", "sunrise_on_the_beach": "Nascer do sol na praia", - "swap_merge_direction": "Alternar direção da mesclagem", + "swap_merge_direction": "Alternar direção da união", "sync": "Sincronizar", + "tag": "Etiqueta", + "tag_assets": "Etiquetar ficheiros", + "tag_created": "Criada a etiqueta {tag}", + "tag_feature_description": "A mostrar fotos e videos agrupados por tópicos lógicos de etiquetas", + "tag_not_found_question": "Não consegue encontrar a etiqueta? Crie uma aqui", + "tag_updated": "Atualizada a etiqueta: {tag}", + "tagged_assets": "Etiquetado {count, plural, one {# ficheiros} other {# ficheiros}}", + "tags": "Etiquetas", "template": "Modelo", "theme": "Tema", "theme_selection": "Selecionar tema", - "theme_selection_description": "Defina automaticamente o tema como claro ou escuro com base na preferência do sistema do seu navegador", - "they_will_be_merged_together": "Eles serão mesclados", - "time_based_memories": "Memórias baseada no tempo", + "theme_selection_description": "Definir automaticamente o tema como claro ou escuro com base na preferência do sistema do seu navegador", + "they_will_be_merged_together": "Eles serão unidos", + "time_based_memories": "Memórias baseadas no tempo", "timezone": "Fuso horário", "to_archive": "Arquivar", - "to_change_password": "Alterar senha", + "to_change_password": "Alterar palavra-passe", "to_favorite": "Favorito", - "to_login": "Iniciar sessão", - "to_trash": "Lixo", + "to_login": "Iniciar Sessão", + "to_parent": "Ir para o pai", + "to_trash": "Reciclagem", "toggle_settings": "Alternar configurações", - "toggle_theme": "Alternar tema", + "toggle_theme": "Ativar modo escuro", "toggle_visibility": "Alternar visibilidade", "total_usage": "Total utilizado", - "trash": "Lixeira", - "trash_all": "Todos para o lixo", - "trash_count": "Lixeira {count, number}", - "trash_delete_asset": "Excluir arquivo", - "trash_no_results_message": "Fotos e vídeos enviados para o lixo aparecem aqui.", - "trashed_items_will_be_permanently_deleted_after": "Os itens da lixeira são deletados permanentemente após {days, plural, one {# dia} other {# dias}}.", + "trash": "Reciclagem", + "trash_all": "Mover todos para a reciclagem", + "trash_count": "Reciclar {count, number}", + "trash_delete_asset": "Eliminar ficheiro", + "trash_no_results_message": "Fotos e vídeos enviados para a reciclagem aparecem aqui.", + "trashed_items_will_be_permanently_deleted_after": "Os itens da reciclagem são eliminados permanentemente após {days, plural, one {# dia} other {# dias}}.", "type": "Tipo", "unarchive": "Desarquivar", "unarchived": "Restaurado do arquivo", @@ -1231,70 +1271,72 @@ "unknown_album": "", "unknown_year": "Ano desconhecido", "unlimited": "Ilimitado", + "unlink_motion_video": "Remover relação com video animado", "unlink_oauth": "Desvincular OAuth", "unlinked_oauth_account": "Conta OAuth desvinculada", "unnamed_album": "Álbum sem nome", - "unnamed_album_delete_confirmation": "Tem a certeza que pretende remover este album?", - "unnamed_share": "Compartilhamento sem nome", + "unnamed_album_delete_confirmation": "Tem a certeza de que pretende eliminar este álbum?", + "unnamed_share": "Partilha sem nome", "unsaved_change": "Alteração não guardada", "unselect_all": "Limpar seleção", - "unselect_all_duplicates": "Remover seleção de todos os duplicados", + "unselect_all_duplicates": "Remover seleção de todos os itens duplicados", "unstack": "Desempilhar", - "unstacked_assets_count": "Desempilhar {count, plural, one {# arquivo} other {# arquivos}}", - "untracked_files": "Arquivos não monitorados", - "untracked_files_decription": "Estes arquivos não são monitorados pela aplicação. Podem ser resultados de falhas em uma movimentação, carregamentos interrompidos, ou deixados para trás por causa de um problema", + "unstacked_assets_count": "Desempilhados {count, plural, one {# ficheiro} other {# ficheiros}}", + "untracked_files": "Ficheiros não monitorizados", + "untracked_files_decription": "Estes ficheiros não são monitorizados pela aplicação. Podem ser resultados de falhas numa movimentação, carregamentos interrompidos, ou deixados para trás por causa de um problema", "up_next": "A seguir", - "updated_password": "Senha atualizada", + "updated_password": "Palavra-passe atualizada", "upload": "Carregar", - "upload_concurrency": "Carregar simultâneo", - "upload_errors": "Envio completo com {count, plural, one {# erro} other {# erros}}, atualize a página para ver novos arquivos enviados.", + "upload_concurrency": "Carregamentos em simultâneo", + "upload_errors": "Envio completo com {count, plural, one {# erro} other {# erros}}, atualize a página para ver os novos ficheiros enviados.", "upload_progress": "Restante(s) {remaining, number} - Processado(s) {processed, number}/{total, number}", - "upload_skipped_duplicates": "Ignorado {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}", + "upload_skipped_duplicates": "{count, plural, one {# Ignorado ficheiro duplicado} other {# Ignorados ficheiros duplicados}}", "upload_status_duplicates": "Duplicados", "upload_status_errors": "Erros", "upload_status_uploaded": "Enviado", - "upload_success": "Upload realizado com sucesso, atualize a página para ver os novos ativos de upload.", + "upload_success": "Carregamento realizado com sucesso, atualize a página para ver os novos ficheiros carregados.", "url": "URL", - "usage": "Uso", - "use_custom_date_range": "Usar um intervalo de datas personalizado", + "usage": "Utilização", + "use_custom_date_range": "Utilizar um intervalo de datas personalizado", "user": "Utilizador", "user_id": "ID do utilizador", - "user_liked": "{user} gostou {type, select, photo {dessa foto} video {deste video} asset {deste arquivo} other {disto}}", - "user_purchase_settings": "Compra", - "user_purchase_settings_description": "Gerencie sua compra", + "user_liked": "{user} gostou {type, select, photo {desta fotografia} video {deste video} asset {deste ficheiro} other {disto}}", + "user_purchase_settings": "Comprar", + "user_purchase_settings_description": "Gerir a sua compra", "user_role_set": "Definir {user} como {role}", - "user_usage_detail": "Detalhes de uso do utilizador", - "username": "Nome do utilizador", + "user_usage_detail": "Detalhes de utilização do utilizador", + "username": "Nome de utilizador", "users": "Utilizadores", - "utilities": "Utilitários", + "utilities": "Ferramentas", "validate": "Validar", "variables": "Variáveis", "version": "Versão", - "version_announcement_closing": "Seu amigo, Alex", - "version_announcement_message": "Olá amigo, há uma nova versão do aplicativo. Reserve um tempo para visitar as histórico de mudanças e garantir que suas configurações docker-compose.yml e .env estejam atualizadas para evitar qualquer configuração incorreta, especialmente se você usar o WatchTower ou qualquer mecanismo que lide com a atualização do seu aplicativo automaticamente.", + "version_announcement_closing": "O seu amigo, Alex", + "version_announcement_message": "Olá amigo, há uma nova versão da aplicação. Reserve algum tempo para visitar o histórico de mudanças e garantir que as suas configurações do docker-compose.yml e .env estão atualizadas para evitar qualquer configuração incorreta, especialmente se usar o WatchTower ou qualquer mecanismo que lide com a atualização automática da aplicação.", "video": "Vídeo", - "video_hover_setting": "Reproduzir vídeo em miniatura quando passar por cima", - "video_hover_setting_description": "Reproduzir vídeo em miniatura quando o mouse está sobre o item. Mesmo quando desativado, a reprodução ainda pode ser iniciada passando sobre o ícone.", + "video_hover_setting": "Reproduzir vídeo em miniatura quando passar com o cursor por cima", + "video_hover_setting_description": "Reproduzir vídeo em miniatura quando o cursor está sobre o item. Mesmo quando está desativado, a reprodução ainda pode ser iniciada passando sobre o ícone de reproduzir.", "videos": "Vídeos", "videos_count": "{count, plural, one {# Vídeo} other {# Vídeos}}", "view": "Ver", "view_album": "Ver Álbum", "view_all": "Ver tudo", "view_all_users": "Ver todos os utilizadores", + "view_in_timeline": "Ver na linha do tempo", "view_links": "Ver links", - "view_next_asset": "Ver próximo ativo", - "view_previous_asset": "Ver ativo anterior", - "view_stack": "Visualizar pilha", + "view_next_asset": "Ver próximo ficheiro", + "view_previous_asset": "Ver ficheiro anterior", + "view_stack": "Ver pilha", "viewer": "Visualizar", "visibility_changed": "Visibilidade alterada para {count, plural, one {# pessoa} other {# pessoas}}", - "waiting": "Aguardando", + "waiting": "Em fila", "warning": "Aviso", "week": "Semana", - "welcome": "Bem-vindo", - "welcome_to_immich": "Bem-vindo ao Immich", + "welcome": "Bem-vindo(a)", + "welcome_to_immich": "Bem-vindo(a) ao Immich", "year": "Ano", - "years_ago": "Há {years, plural, one {# ano} other {# anos}}", + "years_ago": "Há {years, plural, one {# ano} other {# anos}} atrás", "yes": "Sim", - "you_dont_have_any_shared_links": "Não há links compartilhados", - "zoom_image": "Ampliar imagem" + "you_dont_have_any_shared_links": "Não tem links partilhados", + "zoom_image": "Ampliar/Reduzir imagem" } diff --git a/web/src/lib/i18n/ro.json b/web/src/lib/i18n/ro.json index 02022569cd..195c33c943 100644 --- a/web/src/lib/i18n/ro.json +++ b/web/src/lib/i18n/ro.json @@ -41,6 +41,7 @@ "confirm_email_below": "Pentru a confirma, tastați „{email}” mai jos", "confirm_reprocess_all_faces": "Sigur doriți să reprocesați toate fețele? Acest lucru va șterge și persoanele cu nume.", "confirm_user_password_reset": "Sigur doriți să resetați parola utilizatorului {user}?", + "create_job": "Creează sarcină", "crontab_guru": "", "disable_login": "Dezactivați autentificarea", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Rezoluție imagini miniatură", "image_thumbnail_resolution_description": "Folosit la vizualizarea unor grupuri de fotografii (cronologie principală, vizualizare album etc.). Rezoluțiile mai mari pot păstra mai multe detalii, dar codarea durează mai mult, au dimensiuni mai mari ale fișierelor și pot reduce capacitatea de răspuns a aplicației.", "job_concurrency": "concurență {job}", + "job_created": "Sarcină creată", "job_not_concurrency_safe": "Acest job nu este sigur pentru a rula în concurență.", "job_settings": "Setări sarcină", "job_settings_description": "Administrează concurența sarcinilor", @@ -238,6 +240,7 @@ "storage_template_settings_description": "Gestionează structura folderelor și numele fișierelor pentru activele încărcate", "storage_template_user_label": "{label} este eticheta de stocare a utilizatorului", "system_settings": "Setǎri de sistem", + "tag_cleanup_job": "Curățare etichete", "theme_custom_css_settings": "CSS personalizat", "theme_custom_css_settings_description": "Foile de stil în cascadă (CSS) permit personalizarea designului Immich.", "theme_settings": "Setări temă", @@ -273,7 +276,7 @@ "transcoding_hardware_decoding": "Decodare hardware", "transcoding_hardware_decoding_setting_description": "Se aplică doar pentru NVENC, QSV și RKMPP. Activează accelerarea completă în loc de doar accelerarea codificării. S-ar putea să nu funcționeze pentru toate videoclipurile.", "transcoding_hevc_codec": "codec HEVC", - "transcoding_max_b_frames": "", + "transcoding_max_b_frames": "Număr maxim de cadre B", "transcoding_max_b_frames_description": "Valorile mai mari îmbunătățesc eficiența compresiei, dar încetinesc codarea. Este posibil să nu fie compatibile cu accelerarea hardware pe dispozitivele mai vechi. 0 dezactivează cadrele B, în timp ce -1 setează această valoare automat.", "transcoding_max_bitrate": "Bitrate maxim", "transcoding_max_bitrate_description": "Setarea unei rate maxime de biți poate face dimensiunile fișierelor mai previzibile, cu un cost minor asupra calității. La 720p, valorile tipice sunt 2600k pentru VP9 sau HEVC, sau 4500k pentru H.264. Dezactivat dacă este setat la 0.", @@ -312,6 +315,7 @@ "trash_settings_description": "Gestioneazǎ setǎrile coșului de gunoi", "untracked_files": "Fișiere neurmărite", "untracked_files_description": "Aceste fișiere nu sunt urmărite de aplicație. Ele pot fi rezultatul unor mutări eșuate, încărcări întrerupte sau pot rămâne în urmă din cauza unei erori", + "user_cleanup_job": "Curățare utilizator", "user_delete_delay": "Contul și resursele utilizatorului {user} vor fi programate pentru ștergere permanentă în {delay, plural, one {# zi} other {# zile}}.", "user_delete_delay_settings": "Întârziere la ștergere", "user_delete_delay_settings_description": "Numărul de zile după eliminare până la ștergerea permanentă a contului și a resurselor unui utilizator. Procesul de ștergere a utilizatorului rulează la miezul nopții pentru a verifica utilizatorii care sunt pregătiți pentru ștergere. Modificările aduse acestei setări vor fi evaluate la următoarea execuție.", @@ -338,6 +342,7 @@ "advanced": "Avansat", "age_months": "Vârstă {months, plural, one {# lună} other {# luni}}", "age_year_months": "Vârstă de 1 an, {months, plural, one {# lună} other {# luni}}", + "age_years": "{years, plural, other {Vârstă #}}", "album_added": "Album adăugat", "album_added_notification_setting_description": "Primiți o notificare prin e-mail când sunteți adăugat la un album partajat", "album_cover_updated": "Coperta albumului a fost actualizată", @@ -561,10 +566,15 @@ "edit_location": "Editează locație", "edit_name": "Editează nume", "edit_people": "Editează persoane", + "edit_tag": "Modifică etichetă", "edit_title": "Editează Titlul", - "edit_user": "", + "edit_user": "Modifică utilizator", "edited": "Editat", - "editor": "", + "editor": "Editor", + "editor_close_without_save_prompt": "Schimbările nu vor fi salvate", + "editor_close_without_save_title": "Închizi editorul?", + "editor_crop_tool_h2_aspect_ratios": "Raporturi de aspect", + "editor_crop_tool_h2_rotation": "Rotire", "email": "Email", "empty": "", "empty_album": "", @@ -580,7 +590,9 @@ "cannot_navigate_next_asset": "Nu se poate naviga către următorul activ", "cannot_navigate_previous_asset": "Nu se poate naviga la activul anterior", "cant_apply_changes": "Nu se pot aplica schimbări", + "cant_change_activity": "Nu se poate {enabled, select, true {dezactiva} other {activa}} activitatea", "cant_change_asset_favorite": "Nu pot schimba favoritul pentru activ", + "cant_change_metadata_assets_count": "Nu se pot modifica metadatele pentru {count, plural, one {# element} other {# elemente}}", "cant_get_faces": "Nu pot obține fețe", "cant_get_number_of_comments": "Nu pot obține numărul de comentarii", "cant_search_people": "Nu pot căuta oameni", @@ -598,12 +610,34 @@ "failed_to_create_album": "A eșuat crearea albumului", "failed_to_create_shared_link": "A eșuat crearea legăturii partajate", "failed_to_edit_shared_link": "A eșuat editarea legăturii partajate", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", + "failed_to_get_people": "Eșec la obținerea persoanelor", + "failed_to_load_asset": "Eșec la încărcarea resursei", + "failed_to_load_assets": "Eșec la încărcarea resurselor", + "failed_to_load_people": "Eșec la încărcarea oamenilor", + "failed_to_remove_product_key": "Eșec la eliminarea cheii de produs", + "failed_to_stack_assets": "Eșec la combinarea resurselor", + "failed_to_unstack_assets": "Eșec la desfășurarea resurselor", + "import_path_already_exists": "Această cale de import există deja.", + "incorrect_email_or_password": "E-mail sau parolă incorect/ă", + "paths_validation_failed": "{paths, plural, one {# cale} other {# căi}} nu a trecut validarea", + "profile_picture_transparent_pixels": "Pozele de profil nu pot avea pixeli transparenți. Te rugăm să mărești imaginea și/sau să o muți.", + "quota_higher_than_disk_size": "Ai stabilit o cotă mai mare decât dimensiunea discului", + "repair_unable_to_check_items": "Imposibil de verificat {count, select, one {element} other {elemente}}", + "unable_to_add_album_users": "Imposibil de adăugat utilizatori în album", + "unable_to_add_assets_to_shared_link": "Imposibil de adăugat resurse la link-ul partajat", + "unable_to_add_comment": "Imposibil de adăugat comentariu", + "unable_to_add_exclusion_pattern": "Nu se poate adăuga modelul de excluziune", + "unable_to_add_import_path": "Imposibil de adăugat calea de import", + "unable_to_add_partners": "Nu se poate de adăuga parteneri", + "unable_to_add_remove_archive": "Nu se poate {archived, select, true {îndepărta resursa din} other {adăuga resursa în}} arhivă", + "unable_to_add_remove_favorites": "Nu se poate {favorite, select, true {adăuga resursa în} other {îndepărta resursa din}} favorite", + "unable_to_archive_unarchive": "Nu se poate {archived, select, true {arhiva} other {dezarhiva}}", + "unable_to_change_album_user_role": "Nu se poate schimba rolul utilizatorului de album", + "unable_to_change_date": "Imposibil de schimbat data", + "unable_to_change_favorite": "Nu se poate modifica favoritele pentru resursă", + "unable_to_change_location": "Imposibil de schimbat locația", + "unable_to_change_password": "Imposibil de schimbat parola", + "unable_to_change_visibility": "Nu se poate schimba vizibilitatea pentru {count, plural, one {# persoană} other {# persoane}}", "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_create_admin_account": "", @@ -625,22 +659,26 @@ "unable_to_remove_album_users": "", "unable_to_remove_comment": "", "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_offline_files": "Nu se pot șterge fișierele offline", + "unable_to_remove_partner": "Imposibil de eliminat partenerul", + "unable_to_remove_reaction": "Nu se poate elimina reația", "unable_to_remove_user": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", + "unable_to_repair_items": "Imposibil de a repara elementele", + "unable_to_reset_password": "Imposibil de a reseta parola", + "unable_to_resolve_duplicate": "Nu se poate de rezolvat duplicatul", + "unable_to_restore_assets": "Nu se pot restaura resursele", + "unable_to_restore_trash": "Nu se poate restaura coșul de gunoi", + "unable_to_restore_user": "Nu se poate restaura utilizatorul", + "unable_to_save_album": "Imposibil de salvat albumul", + "unable_to_save_api_key": "Imposibil de salvat cheia API", + "unable_to_save_date_of_birth": "Imposibil de a salva data de naștere", + "unable_to_save_name": "Imposibil de a salva numele", + "unable_to_save_profile": "Imposibil de a salva profilul", + "unable_to_save_settings": "Nu se pot salva setările", + "unable_to_scan_libraries": "Nu se pot scana librăriile", + "unable_to_scan_library": "Nu se poate de scanat librăria", + "unable_to_set_feature_photo": "Nu se poate seta fotografia principală", + "unable_to_set_profile_picture": "Nu se poate seta fotografia de profil", "unable_to_submit_job": "", "unable_to_trash_asset": "", "unable_to_unlink_account": "", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 44b9e48f95..c6d4cf9481 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -41,6 +41,7 @@ "confirm_email_below": "Чтобы подтвердить, введите \"{email}\" ниже", "confirm_reprocess_all_faces": "Вы уверены, что хотите повторно определить все лица? Будут также удалены имена со всех лиц.", "confirm_user_password_reset": "Вы уверены, что хотите сбросить пароль пользователя {user}?", + "create_job": "Создать задание", "crontab_guru": "Crontab Guru", "disable_login": "Отключить вход", "disabled": "Выключено", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Разрешение миниатюр", "image_thumbnail_resolution_description": "Используется при просмотре групп фотографий (на временной шкале, при просмотре альбомов и т.д.). Миниатюры с более высоким разрешением сохраняют больше деталей, но требуют больше времени для кодирования, имеют больший вес и могут снизить скорость отклика приложения.", "job_concurrency": "Параллельная обработка задания - {job}", + "job_created": "Задание создано", "job_not_concurrency_safe": "Эта задача не обеспечивает безопасность параллельности выполнения.", "job_settings": "Настройки заданий", "job_settings_description": "Управление параллельной обработкой заданий", @@ -198,6 +200,7 @@ "password_settings": "Настройки входа с паролем", "password_settings_description": "Управление настройками входа по паролю", "paths_validated_successfully": "Все пути успешно прошли проверку", + "person_cleanup_job": "Очистка персоны", "quota_size_gib": "Размер квоты (ГБ)", "refreshing_all_libraries": "Обновление всех библиотек", "registration": "Регистрация Администратора", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Сбросьте настройки к последним сохраненным настройкам", "scanning_library_for_changed_files": "Поиск измененных файлов", "scanning_library_for_new_files": "Поиск новых файлов", + "search_jobs": "Поиск заданий...", "send_welcome_email": "Отправить приветственное письмо", "server_external_domain_settings": "Внешний домен", "server_external_domain_settings_description": "Домен для общедоступных ссылок, включая http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Управление структурой папок и именем загружаемого файла", "storage_template_user_label": "{label} - это метка хранилища пользователя", "system_settings": "Системные настройки", + "tag_cleanup_job": "Очистка тега", "theme_custom_css_settings": "Пользовательские CSS", "theme_custom_css_settings_description": "Каскадные таблицы стилей позволяют настраивать дизайн Immich.", "theme_settings": "Настройки темы", @@ -312,6 +317,7 @@ "trash_settings_description": "Управление настройками корзины", "untracked_files": "НЕОТСЛЕЖИВАЕМЫЕ ФАЙЛЫ", "untracked_files_description": "Приложение не отслеживает эти файлы. Они могут быть результатом неудачных перемещений, прерванных загрузок или пропущены из-за ошибки", + "user_cleanup_job": "Очистка пользователя", "user_delete_delay": "Аккаунт и ресурсы пользователя {user} будут запланированы для окончательного удаления через {delay, plural, one {# день} few {# дня} many {# дней} other {# дня}}.", "user_delete_delay_settings": "Отложенное удаление", "user_delete_delay_settings_description": "Срок в днях, по истечение которого происходит окончательное удаление учетной записи пользователя и его ресурсов после удаления учётной записи. Задача по удалению пользователей выполняется в полночь. Изменения этой настройки будут учтены при следующем запуске задачи.", @@ -1141,6 +1147,7 @@ "search_options": "Параметры поиска", "search_people": "Поиск людей", "search_places": "Поиск мест", + "search_settings": "Настройки поиска", "search_state": "Поиск региона...", "search_tags": "Поиск по тегам...", "search_timezone": "Поиск часового пояса...", diff --git a/web/src/lib/i18n/sr_Cyrl.json b/web/src/lib/i18n/sr_Cyrl.json index 1241ad72fe..b9908b78f0 100644 --- a/web/src/lib/i18n/sr_Cyrl.json +++ b/web/src/lib/i18n/sr_Cyrl.json @@ -41,6 +41,7 @@ "confirm_email_below": "Да бисте потврдили, унесите \"{email}\" испод", "confirm_reprocess_all_faces": "Да ли сте сигурни да желите да поново обрадите сва лица? Ово ће такође обрисати именоване особе.", "confirm_user_password_reset": "Да ли сте сигурни да желите да ресетујете лозинку корисника {user}?", + "create_job": "Креирајте посао", "crontab_guru": "Guru servisnih zadataka", "disable_login": "oneмогући пријаву", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Резолуција сличице", "image_thumbnail_resolution_description": "Користи се приликом прегледа група фотографија (главна временска линија, приказ албума, итд.). Веће резолуције могу да сачувају више детаља, али им је потребно више времена за кодирање, имају веће величине датотека и могу да смање брзину апликације.", "job_concurrency": "{job} паралелност", + "job_created": "Посао креиран", "job_not_concurrency_safe": "Овај посао није безбедан да буде паралелно активан.", "job_settings": "Подешавања посла", "job_settings_description": "Управљајте паралелношћу послова", @@ -198,6 +200,7 @@ "password_settings": "Лозинка за пријаву", "password_settings_description": "Управљајте подешавањима за пријаву лозинком", "paths_validated_successfully": "Све путање су успешно потврђене", + "person_cleanup_job": "Чишћење особа", "quota_size_gib": "Величина квоте (ГиБ)", "refreshing_all_libraries": "Освежавање свих библиотека", "registration": "Регистрација администратора", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Ресетујте подешавања на недавно сачувана подешавања", "scanning_library_for_changed_files": "Скенирање библиотеке за промењене датотеке", "scanning_library_for_new_files": "Скенирање библиотеке за нове датотеке", + "search_jobs": "Тражи послове...", "send_welcome_email": "Пошаљите е-пошту добродошлице", "server_external_domain_settings": "Екстерни домаин", "server_external_domain_settings_description": "Домаин за јавне дељене везе, укључујући http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Управљајте структуром директоријума и именом датотеке средства за отпремање", "storage_template_user_label": "{label} је ознака за складиштење корисника", "system_settings": "Подешавања система", + "tag_cleanup_job": "Чишћење ознака (tags)", "theme_custom_css_settings": "Прилагођени CSS", "theme_custom_css_settings_description": "Каскадни листови стилова (CSS) омогућавају прилагођавање дизајна Immich-a.", "theme_settings": "Подешавање тема", @@ -312,6 +317,7 @@ "trash_settings_description": "Управљајте подешавањима смећа", "untracked_files": "Непраћене датотеке", "untracked_files_description": "Апликација не прати ове датотеке. one могу настати због неуспешних премештења, због прекинутих отпремања или као преостатак због грешке", + "user_cleanup_job": "Чишћење корисника", "user_delete_delay": "Налог и датотеке {user} биће заказани за трајно брисање за {delay, plural, one {# дан} other {# дана}}.", "user_delete_delay_settings": "Избриши уз кашњење", "user_delete_delay_settings_description": "Број дана након уклањања за трајно брисање корисничког налога и датотека. Посао брисања корисника се покреће у поноћ да би се проверили корисници који су спремни за брисање. Промене ове поставке ће бити процењене при следећем извршењу.", @@ -1141,6 +1147,7 @@ "search_options": "Опције претраге", "search_people": "Претражи особе", "search_places": "Претражи места", + "search_settings": "Претрага подешавања", "search_state": "Тражи регион...", "search_tags": "Претражи ознаке (tags)...", "search_timezone": "Претражи временску зону...", diff --git a/web/src/lib/i18n/sr_Latn.json b/web/src/lib/i18n/sr_Latn.json index 26f5483c69..9a32824835 100644 --- a/web/src/lib/i18n/sr_Latn.json +++ b/web/src/lib/i18n/sr_Latn.json @@ -41,6 +41,7 @@ "confirm_email_below": "Da biste potvrdili, unesite \"{email}\" ispod", "confirm_reprocess_all_faces": "Da li ste sigurni da želite da ponovo obradite sva lica? Ovo će takođe obrisati imenovane osobe.", "confirm_user_password_reset": "Da li ste sigurni da želite da resetujete lozinku korisnika {user}?", + "create_job": "Kreirajte posao", "crontab_guru": "Guru servisnih zadataka", "disable_login": "Onemogući prijavu", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Rezolucija sličice", "image_thumbnail_resolution_description": "Koristi se prilikom pregleda grupa fotografija (glavna vremenska linija, prikaz albuma, itd.). Veće rezolucije mogu da sačuvaju više detalja, ali im je potrebno više vremena za kodiranje, imaju veće veličine datoteka i mogu da smanje brzinu aplikacije.", "job_concurrency": "{job} paralelnost", + "job_created": "Posao kreiran", "job_not_concurrency_safe": "Ovaj posao nije bezbedan da bude paralelno aktivan.", "job_settings": "Podešavanja posla", "job_settings_description": "Upravljajte paralelnošću poslova", @@ -198,6 +200,7 @@ "password_settings": "Lozinka za prijavu", "password_settings_description": "Upravljajte podešavanjima za prijavu lozinkom", "paths_validated_successfully": "Sve putanje su uspešno potvrđene", + "person_cleanup_job": "Čišćenje osoba", "quota_size_gib": "Veličina kvote (GiB)", "refreshing_all_libraries": "Osvežavanje svih biblioteka", "registration": "Registracija administratora", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Resetujte podešavanja na nedavno sačuvana podešavanja", "scanning_library_for_changed_files": "Skeniranje biblioteke za promenjene datoteke", "scanning_library_for_new_files": "Skeniranje biblioteke za nove datoteke", + "search_jobs": "Traži poslove...", "send_welcome_email": "Pošaljite e-poštu dobrodošlice", "server_external_domain_settings": "Eksterni domain", "server_external_domain_settings_description": "Domain za javne deljene veze, uključujući http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Upravljajte strukturom direktorijuma i imenom datoteke sredstva za otpremanje", "storage_template_user_label": "{label} je oznaka za skladištenje korisnika", "system_settings": "Podešavanja sistema", + "tag_cleanup_job": "Čišćenje oznaka (tags)", "theme_custom_css_settings": "Prilagođeni CSS", "theme_custom_css_settings_description": "Kaskadni listovi stilova (CSS) omogućavaju prilagođavanje dizajna Immich-a.", "theme_settings": "Podešavanje tema", @@ -312,6 +317,7 @@ "trash_settings_description": "Upravljajte podešavanjima smeća", "untracked_files": "Nepraćene datoteke", "untracked_files_description": "Aplikacija ne prati ove datoteke. One mogu nastati zbog neuspešnih premeštenja, zbog prekinutih otpremanja ili kao preostatak zbog greške", + "user_cleanup_job": "Čišćenje korisnika", "user_delete_delay": "Nalog i datoteke {user} biće zakazani za trajno brisanje za {delay, plural, one {# dan} other {# dana}}.", "user_delete_delay_settings": "Izbriši uz kašnjenje", "user_delete_delay_settings_description": "Broj dana nakon uklanjanja za trajno brisanje korisničkog naloga i datoteka. Posao brisanja korisnika se pokreće u ponoć da bi se proverili korisnici koji su spremni za brisanje. Promene ove postavke će biti procenjene pri sledećem izvršenju.", @@ -702,7 +708,7 @@ "unable_to_submit_job": "Nije moguće predati zadatak", "unable_to_trash_asset": "Nije moguće izbaciti materijal u otpad", "unable_to_unlink_account": "Nije moguće raskinuti profil", - "unable_to_unlink_motion_video": "Nije moguće odvezati video sa slikom", + "unable_to_unlink_motion_video": "Nije moguće odvezati video od slike", "unable_to_update_album_cover": "Nije moguće ažurirati naslovnicu albuma", "unable_to_update_album_info": "Nije moguće ažurirati informacije o albumu", "unable_to_update_library": "Nije moguće ažurirati biblioteku", @@ -1141,6 +1147,7 @@ "search_options": "Opcije pretrage", "search_people": "Pretraži osobe", "search_places": "Pretraži mesta", + "search_settings": "Pretraga podešavanja", "search_state": "Traži region...", "search_tags": "Pretraži oznake (tags)...", "search_timezone": "Pretraži vremensku zonu...", diff --git a/web/src/lib/i18n/uk.json b/web/src/lib/i18n/uk.json index 3e24ccacc4..5c55b6fe83 100644 --- a/web/src/lib/i18n/uk.json +++ b/web/src/lib/i18n/uk.json @@ -41,6 +41,7 @@ "confirm_email_below": "Для підтвердження введіть \"{email}\" нижче", "confirm_reprocess_all_faces": "Ви впевнені, що хочете повторно визначити всі обличчя? Це також призведе до видалення імен з усіх облич.", "confirm_user_password_reset": "Ви впевнені, що хочете скинути пароль користувача {user}?", + "create_job": "Створити завдання", "crontab_guru": "", "disable_login": "Вимкнути вхід", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Розмір ескізу", "image_thumbnail_resolution_description": "Використовується при перегляді груп фотографій (основна стрічка, перегляд альбому тощо). Вища роздільна здатність може зберегти більше деталей, але вимагає більше часу для кодування, має більший розмір файлів і може знижувати чутливість додатку.", "job_concurrency": "{job} одночасно", + "job_created": "Завдання створено", "job_not_concurrency_safe": "Це завдання не є безпечним для одночасного виконання.", "job_settings": "Налаштування завдань", "job_settings_description": "Управління паралельністю завдань", @@ -198,6 +200,7 @@ "password_settings": "Налаштування входу з паролем", "password_settings_description": "Керування налаштуваннями входу за паролем", "paths_validated_successfully": "Усі шляхи успішно перевірено", + "person_cleanup_job": "Очищення особи", "quota_size_gib": "Розмір квоти (GiB)", "refreshing_all_libraries": "Оновлення всіх бібліотек", "registration": "Реєстрація адміністратора", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Скинути налаштування до недавно збережених налаштувань", "scanning_library_for_changed_files": "Сканування бібліотеки на наявність змінених файлів", "scanning_library_for_new_files": "Сканування бібліотеки на наявність нових файлів", + "search_jobs": "Пошук завдань...", "send_welcome_email": "Надіслати лист з вітанням", "server_external_domain_settings": "Зовнішній домен", "server_external_domain_settings_description": "Домен для публічних загальнодоступних посилань, включаючи http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Керуйте структурою тек та іменем завантаженого файлу", "storage_template_user_label": "{label} - це мітка зберігання користувача", "system_settings": "Системні налаштування", + "tag_cleanup_job": "Очистити тег", "theme_custom_css_settings": "Власний CSS", "theme_custom_css_settings_description": "Каскадні таблиці стилів дозволяють настроювати дизайн Immich.", "theme_settings": "Налаштування теми", @@ -312,6 +317,7 @@ "trash_settings_description": "Керування налаштуваннями кошика", "untracked_files": "Невідстежувані файли", "untracked_files_description": "Ці файли не відстежуються програмою. Вони можуть бути результатом невдалого переміщення, перерваного завантаження або залишитися через помилку програми", + "user_cleanup_job": "Очищення користувача", "user_delete_delay": "Акаунт {user} і його ресурси будуть заплановані для остаточного видалення через {delay, plural, one {# день} few {# дні} many {# днів} other {# днів}}.", "user_delete_delay_settings": "Видалити затримку", "user_delete_delay_settings_description": "Кількість днів після видалення для остаточного видалення акаунта користувача та його ресурсів. Задача видалення користувача запускається опівночі для перевірки користувачів, готових до видалення. Зміни цього налаштування будуть оцінені під час наступного виконання.", @@ -1139,6 +1145,7 @@ "search_options": "Опції пошуку", "search_people": "Шукати людей", "search_places": "Пошук місць", + "search_settings": "Налаштування пошуку", "search_state": "Пошук регіону...", "search_tags": "Пошук тегів...", "search_timezone": "Пошук часового поясу...", diff --git a/web/src/lib/i18n/vi.json b/web/src/lib/i18n/vi.json index ec8c8d4e7f..405c5da442 100644 --- a/web/src/lib/i18n/vi.json +++ b/web/src/lib/i18n/vi.json @@ -41,6 +41,7 @@ "confirm_email_below": "Để xác nhận, nhập \"{email}\" bên dưới", "confirm_reprocess_all_faces": "Bạn có chắc chắn muốn xử lý lại tất cả các khuôn mặt? Thao tác này sẽ xoá tên người đã được gán.", "confirm_user_password_reset": "Bạn có chắc chắn muốn đặt lại mật khẩu của {user}?", + "create_job": "Tạo tác vụ", "crontab_guru": "Crontab Guru", "disable_login": "Vô hiệu hoá đăng nhập", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Độ phân giải ảnh thu nhỏ", "image_thumbnail_resolution_description": "Dùng khi xem một nhóm các ảnh (dòng thời gian chính, xem album, v.v.). Độ phân giải cao hơn có thể giữ lại nhiều chi tiết hơn nhưng mất nhiều thời gian mã hóa, có kích thước lớn hơn và có thể làm giảm khả năng phản hồi của ứng dụng.", "job_concurrency": "{job} thực hiện đồng thời", + "job_created": "Tác vụ đã được tạo", "job_not_concurrency_safe": "Tác vụ này không an toàn để thực hiện đồng thời.", "job_settings": "Tác vụ", "job_settings_description": "Quản lý mức độ thực hiện đồng thời của tác vụ", @@ -198,6 +200,7 @@ "password_settings": "Mật khẩu đăng nhập", "password_settings_description": "Quản lý cài đặt mật khẩu đăng nhập", "paths_validated_successfully": "Tất cả các đường dẫn được xác minh thành công", + "person_cleanup_job": "Dọn dẹp người", "quota_size_gib": "Hạn mức (GiB)", "refreshing_all_libraries": "Làm mới tất cả các thư viện", "registration": "Đăng ký Quản trị viên", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Đặt lại cài đặt về cài đặt trước đó", "scanning_library_for_changed_files": "Đang quét thư viện để tìm các tập tin đã thay đổi", "scanning_library_for_new_files": "Đang quét thư viện để tìm các tập tin mới", + "search_jobs": "Tìm kiếm tác vụ...", "send_welcome_email": "Gửi email chào mừng", "server_external_domain_settings": "Tên miền công khai", "server_external_domain_settings_description": "Tên miền dành cho các liên kết chia sẻ công khai, bao gồm http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Quản lý cấu trúc thư mục và tên tập tin của ảnh tải lên", "storage_template_user_label": "Cụm từ {label} là Nhãn lưu trữ của người dùng", "system_settings": "Cài đặt hệ thống", + "tag_cleanup_job": "Dọn dẹp thẻ", "theme_custom_css_settings": "CSS tùy chỉnh", "theme_custom_css_settings_description": "Cascading Style Sheets cho phép tùy chỉnh thiết kế của Immich.", "theme_settings": "Chủ đề", @@ -312,6 +317,7 @@ "trash_settings_description": "Quản lý cài đặt thùng rác", "untracked_files": "Các tập tin không được theo dõi", "untracked_files_description": "Những tập tin này không được ứng dụng theo dõi. Chúng có thể là kết quả của các thao tác di chuyển thất bại, tải lên bị gián đoạn, hoặc bị bỏ lại do lỗi", + "user_cleanup_job": "Dọn dẹp người dùng", "user_delete_delay": "Tài khoản và các ảnh của {user} sẽ được lên lịch xóa vĩnh viễn sau {delay, plural, one {# ngày} other {# ngày}}.", "user_delete_delay_settings": "Thời gian xóa", "user_delete_delay_settings_description": "Số ngày chờ xóa để xóa vĩnh viễn tài khoản và các ảnh của người dùng. Tác vụ xóa người dùng chạy vào giữa đêm để kiểm tra các người dùng sẵn sàng bị xóa. Thay đổi cài đặt này sẽ được đánh giá vào lần thực hiện tiếp theo.", @@ -1111,6 +1117,7 @@ "search_options": "Tùy chọn tìm kiếm", "search_people": "Tìm kiếm người", "search_places": "Tìm kiếm địa điểm", + "search_settings": "Cài đặt tìm kiếm", "search_state": "Tìm kiếm tỉnh...", "search_tags": "Tìm kiếm thẻ...", "search_timezone": "Tìm kiếm múi giờ...", diff --git a/web/src/lib/i18n/zh_Hant.json b/web/src/lib/i18n/zh_Hant.json index fb9a18a1f5..9680363293 100644 --- a/web/src/lib/i18n/zh_Hant.json +++ b/web/src/lib/i18n/zh_Hant.json @@ -34,13 +34,14 @@ "authentication_settings_reenable": "如需重新啟用,請使用 伺服器指令。", "background_task_job": "背景任務", "check_all": "全選", - "cleared_jobs": "已清除 {job} 的任務", + "cleared_jobs": "已清除的作業:{job}", "config_set_by_file": "目前的設定已透過設定檔案設置", "confirm_delete_library": "確定要刪除「{library}」(圖庫)嗎?", "confirm_delete_library_assets": "您確定要刪除此圖庫嗎?這將從 Immich 中刪除{count, plural, one {個項目} other {個項目}},且無法復原。檔案仍會保留在硬碟中。", "confirm_email_below": "請在底下輸入 {email} 來確認", "confirm_reprocess_all_faces": "確定要重新處理所有臉孔嗎?這會清除已命名的人物。", "confirm_user_password_reset": "您確定要重設 {user} 的密碼嗎?", + "create_job": "建立作業", "crontab_guru": "", "disable_login": "停用登入", "disabled": "已禁用", @@ -70,12 +71,13 @@ "image_thumbnail_resolution": "縮圖解析度", "image_thumbnail_resolution_description": "觀賞多張照片時(時間軸、相簿等)用。較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", "job_concurrency": "{job}並行", + "job_created": "已建立作業", "job_not_concurrency_safe": "這個任務並行並不安全。", - "job_settings": "任務設定", - "job_settings_description": "管理任務並行", - "job_status": "任務狀態", - "jobs_delayed": "{jobCount, plural, other {# 項任務延遲}}", - "jobs_failed": "{jobCount, plural, other {# 項}}任務失敗", + "job_settings": "作業設定", + "job_settings_description": "管理作業並行", + "job_status": "作業狀態", + "jobs_delayed": "已延後 {jobCount, plural, other {# 項作業}}", + "jobs_failed": "{jobCount, plural, other {# 項}}作業失敗", "library_created": "已建立圖庫:{library}", "library_cron_expression": "Cron 運算式", "library_cron_expression_description": "以 Cron 格式設定掃描時段。詳細資訊請參閱 Crontab Guru", @@ -95,7 +97,7 @@ "logging_level_description": "啟用時的記錄層級。", "logging_settings": "記錄檔", "machine_learning_clip_model": "CLIP 模型", - "machine_learning_clip_model_description": "CLIP 模型 名稱列表。更換模型後須對所有影像重新執行「智慧搜尋」。", + "machine_learning_clip_model_description": "這裏有份 CLIP 模型名單。註:更換模型後須對所有圖片重新執行「智慧搜尋」作業。", "machine_learning_duplicate_detection": "重複項目偵測", "machine_learning_duplicate_detection_enabled": "啟用重複項目偵測", "machine_learning_duplicate_detection_enabled_description": "即使停用,完全一樣的素材仍會被忽略。", @@ -198,6 +200,7 @@ "password_settings": "密碼登入", "password_settings_description": "管理密碼登入設定", "paths_validated_successfully": "所有路徑驗證成功", + "person_cleanup_job": "清理人物", "quota_size_gib": "配額(GiB)", "refreshing_all_libraries": "正在重新整理所有圖庫", "registration": "管理者註冊", @@ -209,8 +212,9 @@ "require_password_change_on_login": "要求使用者在首次登入時更改密碼", "reset_settings_to_default": "將設定重設回預設", "reset_settings_to_recent_saved": "已設回最後儲存的設定", - "scanning_library_for_changed_files": "正在掃描資料庫以檢查文件變更", - "scanning_library_for_new_files": "正在掃描資料庫以檢查新文件", + "scanning_library_for_changed_files": "掃描圖庫中變更的檔案", + "scanning_library_for_new_files": "掃描圖庫中的新檔案", + "search_jobs": "搜尋作業…", "send_welcome_email": "傳送歡迎電子郵件", "server_external_domain_settings": "外部網域", "server_external_domain_settings_description": "公開分享鏈結的網域(包含「http(s)://」)", @@ -238,6 +242,7 @@ "storage_template_settings_description": "管理上傳檔案的資料夾結構和檔名", "storage_template_user_label": "{label} 是使用者的儲存標籤", "system_settings": "系統設定", + "tag_cleanup_job": "清理標記", "theme_custom_css_settings": "自訂 CSS", "theme_custom_css_settings_description": "可以用層疊樣式表(CSS)來自訂 Immich 的設計。", "theme_settings": "主題", @@ -312,8 +317,9 @@ "trash_settings_description": "管理垃圾桶設定", "untracked_files": "未被追蹤的檔案", "untracked_files_description": "這些檔案不會被追蹤。它們可能是移動失誤、上傳中斷或遇到漏洞而遺留的產物", - "user_delete_delay": "{user} 的帳戶和資產將安排在 {delay, plural, one {# 天} other {# 天}} 後進行永久刪除。", - "user_delete_delay_settings": "刪除延遲", + "user_cleanup_job": "清理使用者", + "user_delete_delay": "{user} 的帳號和檔案將於 {delay, plural, other {# 天}}後永久刪除。", + "user_delete_delay_settings": "延後刪除", "user_delete_delay_settings_description": "移除後永久刪除用戶帳戶和資產的天數。用戶刪除任務會在午夜運行,以檢查是否有準備好刪除的用戶。對此設置的更改將在下一次執行時進行評估。", "user_delete_immediately": "{user} 的帳戶和資產將被立即排隊進行永久刪除。", "user_delete_immediately_checkbox": "將用戶和資產排隊進行立即刪除", @@ -593,7 +599,7 @@ "cant_get_number_of_comments": "無法獲取評論數量", "cant_search_people": "無法搜尋人", "cant_search_places": "無法搜尋地點", - "cleared_jobs": "已清除以下工作的任務: {job}", + "cleared_jobs": "已清除的作業:{job}", "error_adding_assets_to_album": "將檔案加入相簿時出錯", "error_adding_users_to_album": "將使用者加入相簿時出錯", "error_deleting_shared_user": "刪除共享使用者時出錯", @@ -694,8 +700,8 @@ "unable_to_save_name": "無法儲存名稱", "unable_to_save_profile": "無法儲存個人資料", "unable_to_save_settings": "無法儲存設定", - "unable_to_scan_libraries": "無法掃描資料庫", - "unable_to_scan_library": "無法掃描資料庫", + "unable_to_scan_libraries": "無法掃描圖庫", + "unable_to_scan_library": "無法掃描圖庫", "unable_to_set_feature_photo": "無法設置特色照片", "unable_to_set_profile_picture": "無法設置個人頭像", "unable_to_submit_job": "無法提交作業", @@ -722,7 +728,7 @@ "expired": "已過期", "expires_date": "失效期限:{date}", "explore": "探索", - "explorer": "探測器", + "explorer": "總攬", "export": "匯出", "export_as_json": "匯出 JSON", "extension": "副檔名", @@ -747,7 +753,7 @@ "fix_incorrect_match": "修復不相符的", "folders": "資料夾", "folders_feature_description": "以資料夾瀏覽檔案系統中的照片和影片", - "force_re-scan_library_files": "強制重新掃描所有資料庫檔案", + "force_re-scan_library_files": "強制重新掃描所有圖庫檔案", "forward": "順序", "general": "一般", "get_help": "線上求助", @@ -802,7 +808,7 @@ "invite_to_album": "邀請至相簿", "items_count": "{count, plural, other {# 個項目}}", "job_settings_description": "", - "jobs": "工作", + "jobs": "作業", "keep": "保留", "keep_all": "全部保留", "keyboard_shortcuts": "鍵盤快捷鍵", diff --git a/web/src/lib/i18n/zh_SIMPLIFIED.json b/web/src/lib/i18n/zh_SIMPLIFIED.json index 08c236dcbf..6c4a433b1b 100644 --- a/web/src/lib/i18n/zh_SIMPLIFIED.json +++ b/web/src/lib/i18n/zh_SIMPLIFIED.json @@ -34,13 +34,14 @@ "authentication_settings_reenable": "如需再次启用,使用 服务器指令。", "background_task_job": "后台任务", "check_all": "检查全部", - "cleared_jobs": "已清理作业:{job}", + "cleared_jobs": "已清理任务:{job}", "config_set_by_file": "当前配置已通过配置文件设置", "confirm_delete_library": "确定要删除图库“{library}”吗?", "confirm_delete_library_assets": "确定要删除该图库吗?这将删除所有包含在Immich中的{count, plural, one {#个项目} other {#个项目}},且无法撤销。但文件仍将保留在磁盘中。", "confirm_email_below": "输入“{email}”来确认", "confirm_reprocess_all_faces": "确定要对全部照片重新进行面部识别吗?这将同时清除所有已命名人物。", "confirm_user_password_reset": "确定要重置用户{user}的密码吗?", + "create_job": "创建任务", "crontab_guru": "Crontab Guru", "disable_login": "禁用登录", "disabled": "已禁用", @@ -51,7 +52,7 @@ "face_detection": "人脸检测", "face_detection_description": "使用机器学习检测项目中的人脸(视频只检测其缩略图中的人脸)。选择“全部”项将会(重新)处理所有项目。选择“缺失”项将尚未处理的项目置于队列中。人脸检测完成后,检测到的人脸将排队进行面部识别,将它们分组到现有的或新的人物中。", "facial_recognition_job_description": "将检测到的人脸按照人物分组。这一步将在人脸检测完成后执行。选择“全部”项将会(重新)分组所有面孔。选择“缺失”项将尚未分配的人脸置于队列中。", - "failed_job_command": "{command}命令执行失败的作业:{job}", + "failed_job_command": "{command}命令执行失败的任务:{job}", "force_delete_user_warning": "警告:这将立即移除用户以及所有项目。该操作无法撤回且文件无法恢复。", "forcing_refresh_library_files": "强制刷新所有图库文件", "image_format_description": "WebP 文件比 JPEG 文件小,但编码速度较慢。", @@ -70,11 +71,12 @@ "image_thumbnail_resolution": "缩略图分辨率", "image_thumbnail_resolution_description": "用于查看照片组(主时间轴、相册视图等)。更高的分辨率可以保留更多的细节,但编码时间更长,文件体积更大,并会降低应用程序的响应速度。", "job_concurrency": "{job}并发", + "job_created": "任务已创建", "job_not_concurrency_safe": "此任务并发并不安全。", "job_settings": "任务设置", "job_settings_description": "管理任务并发", "job_status": "任务状态", - "jobs_delayed": "{jobCount, plural, other {#项作业已推迟}}", + "jobs_delayed": "{jobCount, plural, other {#项任务已推迟}}", "jobs_failed": "{jobCount, plural, other {#项失败}}", "library_created": "已创建图库:{library}", "library_cron_expression": "Cron 表达式", @@ -95,7 +97,7 @@ "logging_level_description": "启用的日志级别。", "logging_settings": "日志", "machine_learning_clip_model": "CLIP模型", - "machine_learning_clip_model_description": "支持的CLIP模型名称见 此处。注意,更换模型后需要对所有图片重新运行“智能检索”作业。", + "machine_learning_clip_model_description": "支持的CLIP模型名称见 此处。注意,更换模型后需要对所有图片重新运行“智能检索”任务。", "machine_learning_duplicate_detection": "重复项检测", "machine_learning_duplicate_detection_enabled": "启用重复检测", "machine_learning_duplicate_detection_enabled_description": "如果禁用此功能,完全相同的项目仍将被去重。", @@ -152,7 +154,7 @@ "note_cannot_be_changed_later": "注意:此项一旦设定,以后无法更改!", "note_unlimited_quota": "提示:输入0表示无限制", "notification_email_from_address": "发件人地址", - "notification_email_from_address_description": "发件人邮箱地址,例如“张三<12345@qq.com>”", + "notification_email_from_address_description": "发件人邮箱地址,例如“Immich Photo Server ”", "notification_email_host_description": "服务器地址:(例如:smtp.qq.com)", "notification_email_ignore_certificate_errors": "忽略证书错误", "notification_email_ignore_certificate_errors_description": "忽略TLS证书验证错误(不建议)", @@ -198,6 +200,7 @@ "password_settings": "密码登录", "password_settings_description": "管理密码登录设置", "paths_validated_successfully": "所有路径验证成功", + "person_cleanup_job": "清理人物", "quota_size_gib": "配额大小(GB)", "refreshing_all_libraries": "刷新所有图库", "registration": "注册管理员", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "恢复到最近保存的设置", "scanning_library_for_changed_files": "扫描图库变更的文件", "scanning_library_for_new_files": "扫描图库新增的文件", + "search_jobs": "搜索任务...", "send_welcome_email": "发送欢迎邮件", "server_external_domain_settings": "外部域名", "server_external_domain_settings_description": "共享链接域名,包括 http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "管理上传项目文件夹结构和文件名", "storage_template_user_label": "{label}是用户的存储标签", "system_settings": "系统设置", + "tag_cleanup_job": "清理标签", "theme_custom_css_settings": "自定义CSS", "theme_custom_css_settings_description": "可以通过CSS自定义Immich外观。", "theme_settings": "主题设置", @@ -312,6 +317,7 @@ "trash_settings_description": "管理回收站设置", "untracked_files": "未被追踪的文件", "untracked_files_description": "这些文件未被系统追踪。 这可能是移动失败、上传中断或因bug而落下", + "user_cleanup_job": "清理用户", "user_delete_delay": "{user}的账户及项目将在{delay, plural, one {#天} other {#天}}后自动永久删除。", "user_delete_delay_settings": "延期删除", "user_delete_delay_settings_description": "删除用户后永久删除账户及其所有项目的天数。用户删除作业在午夜运行,检查是否有用户可以删除。对该设置的更改将在下次执行时开始计算。", @@ -594,7 +600,7 @@ "cant_get_number_of_comments": "无法获取评论数量", "cant_search_people": "无法检索人物", "cant_search_places": "无法检索地点", - "cleared_jobs": "已删除作业:{job}", + "cleared_jobs": "已删除任务:{job}", "error_adding_assets_to_album": "添加项目到相册时出错", "error_adding_users_to_album": "添加用户到相册时出错", "error_deleting_shared_user": "删除共享用户时出错", From 1ef283460345b2e9e87106790e10a8af2e32ae61 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 25 Sep 2024 12:30:01 -0400 Subject: [PATCH 437/723] docs: hidden files cursed knowledge (#12929) --- docs/src/pages/cursed-knowledge.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/src/pages/cursed-knowledge.tsx b/docs/src/pages/cursed-knowledge.tsx index 55bb3d4cee..1e5c724d16 100644 --- a/docs/src/pages/cursed-knowledge.tsx +++ b/docs/src/pages/cursed-knowledge.tsx @@ -6,6 +6,7 @@ import { mdiLeadPencil, mdiLockOff, mdiLockOutline, + mdiMicrosoftWindows, mdiSecurity, mdiSpeedometerSlow, mdiTrashCan, @@ -21,6 +22,18 @@ const withLanguage = (date: Date) => (language: string) => date.toLocaleDateStri type Item = Omit & { date: Date }; const items: Item[] = [ + { + icon: mdiMicrosoftWindows, + iconColor: '#357EC7', + title: 'Hidden files in Windows are cursed', + description: + 'Hidden files in Windows cannot be opened with the "w" flag. That, combined with SMB option "hide dot files" leads to a lot of confusion.', + link: { + url: 'https://github.com/immich-app/immich/pull/12812', + text: '#12812', + }, + date: new Date(2024, 8, 20), + }, { icon: mdiWrap, iconColor: 'gray', From b2f2be34855d7c70fc699d4504d72b037df44e0b Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Wed, 25 Sep 2024 19:26:19 +0200 Subject: [PATCH 438/723] refactor(server): library syncing (#12220) * refactor: library scanning fix tests remove offline files step cleanup library service improve tests cleanup tests add db migration fix e2e cleanup openapi fix tests fix tests update docs update docs update mobile code fix formatting don't remove assets from library with invalid import path use trash for offline files add migration simplify scan endpoint cleanup library panel fix library tests e2e lint fix e2e trash e2e fix lint add asset trash tests add more tests ensure thumbs are generated cleanup svelte cleanup queue names fix tests fix lint add warning due to trash fix trash tests fix lint fix tests Admin message for offline asset fix comments Update web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> add permission to library scan endpoint revert asset interface sort add trash reason to shared link stub improve path view in offline update docs improve trash performance fix comments remove stray comment * refactor: add back isOffline and remove trashReason from asset, change sync job flow * chore(server): drop coverage to 80% for functions * chore: rebase and generated files --------- Co-authored-by: Zack Pollard --- docs/docs/features/libraries.md | 46 +- e2e/src/api/specs/library.e2e-spec.ts | 476 +++++---------- e2e/src/api/specs/search.e2e-spec.ts | 2 +- e2e/src/api/specs/trash.e2e-spec.ts | 113 +++- e2e/src/utils.ts | 14 + mobile/lib/entities/asset.entity.g.dart | 141 ++--- .../lib/extensions/collection_extensions.dart | 5 +- .../asset_viewer/bottom_gallery_bar.dart | 29 +- .../asset_viewer/top_control_app_bar.dart | 3 +- mobile/openapi/README.md | 2 - mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api/assets_api.dart | 14 +- mobile/openapi/lib/api/libraries_api.dart | 54 +- mobile/openapi/lib/api_client.dart | 2 - .../openapi/lib/model/scan_library_dto.dart | 125 ---- open-api/immich-openapi-specs.json | 59 -- open-api/typescript-sdk/src/fetch-client.ts | 19 +- server/src/controllers/library.controller.ts | 28 +- server/src/dtos/asset-media.dto.ts | 3 - server/src/dtos/library.dto.ts | 10 +- server/src/interfaces/asset.interface.ts | 3 - server/src/interfaces/job.interface.ts | 27 +- server/src/queries/asset.repository.sql | 41 -- server/src/repositories/asset.repository.ts | 60 +- server/src/repositories/job.repository.ts | 10 +- server/src/repositories/trash.repository.ts | 11 +- server/src/services/asset-media.service.ts | 1 - server/src/services/job.service.ts | 2 +- server/src/services/library.service.spec.ts | 561 +++++------------- server/src/services/library.service.ts | 403 ++++++------- server/src/services/microservices.service.ts | 10 +- server/src/services/trash.service.spec.ts | 4 +- server/src/utils/database.ts | 2 +- server/test/fixtures/asset.stub.ts | 214 +++---- .../repositories/asset.repository.mock.ts | 1 - server/vitest.config.mjs | 2 +- .../asset-viewer/asset-viewer-nav-bar.svelte | 3 +- .../asset-viewer/detail-panel.svelte | 15 +- .../buttons/circle-icon-button.svelte | 3 +- web/src/lib/i18n/ar.json | 6 +- web/src/lib/i18n/bg.json | 6 +- web/src/lib/i18n/bi.json | 6 +- web/src/lib/i18n/ca.json | 6 +- web/src/lib/i18n/cs.json | 6 +- web/src/lib/i18n/da.json | 6 +- web/src/lib/i18n/de.json | 6 +- web/src/lib/i18n/en.json | 25 +- web/src/lib/i18n/es.json | 6 +- web/src/lib/i18n/fa.json | 6 +- web/src/lib/i18n/fi.json | 4 +- web/src/lib/i18n/fr.json | 6 +- web/src/lib/i18n/he.json | 6 +- web/src/lib/i18n/hi.json | 6 +- web/src/lib/i18n/hr.json | 4 +- web/src/lib/i18n/hu.json | 6 +- web/src/lib/i18n/hy.json | 6 +- web/src/lib/i18n/id.json | 6 +- web/src/lib/i18n/it.json | 6 +- web/src/lib/i18n/ja.json | 6 +- web/src/lib/i18n/kmr.json | 6 +- web/src/lib/i18n/ko.json | 6 +- web/src/lib/i18n/lt.json | 2 +- web/src/lib/i18n/lv.json | 2 +- web/src/lib/i18n/mn.json | 2 +- web/src/lib/i18n/nb_NO.json | 6 +- web/src/lib/i18n/nl.json | 6 +- web/src/lib/i18n/pl.json | 6 +- web/src/lib/i18n/pt.json | 6 +- web/src/lib/i18n/pt_BR.json | 6 +- web/src/lib/i18n/ro.json | 4 +- web/src/lib/i18n/ru.json | 6 +- web/src/lib/i18n/sk.json | 2 +- web/src/lib/i18n/sl.json | 2 +- web/src/lib/i18n/sr_Cyrl.json | 6 +- web/src/lib/i18n/sr_Latn.json | 6 +- web/src/lib/i18n/sv.json | 4 +- web/src/lib/i18n/ta.json | 6 +- web/src/lib/i18n/th.json | 4 +- web/src/lib/i18n/tr.json | 6 +- web/src/lib/i18n/uk.json | 6 +- web/src/lib/i18n/vi.json | 6 +- web/src/lib/i18n/zh_Hant.json | 6 +- web/src/lib/i18n/zh_SIMPLIFIED.json | 6 +- web/src/lib/utils/asset-utils.ts | 7 - .../admin/library-management/+page.svelte | 88 +-- 85 files changed, 941 insertions(+), 1926 deletions(-) delete mode 100644 mobile/openapi/lib/model/scan_library_dto.dart diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index cdea1a11a5..1755546954 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -1,18 +1,14 @@ -# Libraries +# External Libraries -## Overview +External libraries track assets stored in the filesystem outside of Immich. When the external library is scanned, Immich will load videos and photos from disk and create the corresponding assets. These assets will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc. Later, if a file is modified outside of Immich, you need to scan the library for the changes to show up. -Immich supports the creation of libraries which is a top-level asset container. Currently, there are two types of libraries: traditional upload libraries that can sync with a mobile device, and external libraries, that keeps up to date with files on disk. Libraries are different from albums in that an asset can belong to multiple albums but only one library, and deleting a library deletes all assets contained within. As of August 2023, this is a new feature and libraries have a lot of potential for future development beyond what is documented here. This document attempts to describe the current state of libraries. +If an external asset is deleted from disk, Immich will move it to trash on rescan. To restore the asset, you need to restore the original file. After 30 days the file will be removed from trash, and any changes to metadata within Immich will be lost. -## External Libraries +:::caution -External libraries tracks assets stored outside of Immich, i.e. in the file system. When the external library is scanned, Immich will read the metadata from the file and create an asset in the library for each image or video file. These items will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc. +If you add metadata to an external asset in any way (i.e. add it to an album or edit the description), that metadata is only stored inside Immich and will not be persisted to the external asset file. If you move an asset to another location within the library all such metadata will be lost upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release. -If a file is modified outside of Immich, the changes will not be reflected in immich until the library is scanned again. There are different ways to scan a library depending on the use case: - -- Scan Library Files: This is the default scan method and also the quickest. It will scan all files in the library and add new files to the library. It will notice if any files are missing (see below) but not check existing assets -- Scan All Library Files: Same as above, but will check each existing asset to see if the modification time has changed. If it has, the asset will be updated. Since it has to check each asset, this is slower than Scan Library Files. -- Force Scan All Library Files: Same as above, but will read each asset from disk no matter the modification time. This is useful in some cases where an asset has been modified externally but the modification time has not changed. This is the slowest way to scan because it reads each asset from disk. +::: :::caution @@ -20,22 +16,6 @@ Due to aggressive caching it can take some time for a refreshed asset to appear ::: -In external libraries, the file path is used for duplicate detection. This means that if a file is moved to a different location, it will be added as a new asset. If the file is moved back to its original location, it will be added as a new asset. In contrast to upload libraries, two identical files can be uploaded if they are in different locations. This is a deliberate design choice to make Immich reflect the file system as closely as possible. Remember that duplication detection is only done within the same library, so if you have multiple external libraries, the same file can be added to multiple libraries. - -:::caution - -If you add assets from an external library to an album and then move the asset to another location within the library, the asset will be removed from the album upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release. - -::: - -### Deleted External Assets - -Note: Either a manual or scheduled library scan must have been performed to identify offline assets before this process will work. - -In all above scan methods, Immich will check if any files are missing. This can happen if files are deleted, or if they are on a storage location that is currently unavailable, like a network drive that is not mounted, or a USB drive that has been unplugged. In order to prevent accidental deletion of assets, Immich will not immediately delete an asset from the library if the file is missing. Instead, the asset will be internally marked as offline and will still be visible in the main timeline. If the file is moved back to its original location and the library is scanned again, the asset will be restored. - -Finally, files can be deleted from Immich via the `Remove Offline Files` job. This job can be found by the three dots menu for the associated external storage that was configured under Administration > Libraries (the same location described at [create external libraries](#create-external-libraries)). When this job is run, any assets marked as offline will then be removed from Immich. Run this job whenever files have been deleted from the file system and you want to remove them from Immich. - ### Import Paths External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. Each import file must be a readable directory that exists on the filesystem; the import path dialog will alert you of any paths that are not accessible. @@ -66,9 +46,13 @@ Some basic examples: - `**/Raw/**` will exclude all files in any directory named `Raw` - `**/*.{tif,jpg}` will exclude all files with the extension `.tif` or `.jpg` +Special characters such as @ should be escaped, for instance: + +- `**/\@eadir/**` will exclude all files in any directory named `@eadir` + ### Automatic watching (EXPERIMENTAL) -This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan. Deleted assets are, as always, marked as offline and can be removed with the "Remove offline files" button. +This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan. If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes. @@ -84,7 +68,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up. ### Nightly job -There is an automatic job that's run once a day and refreshes all modified files in all libraries as well as cleans up any libraries stuck in deletion. +There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. ## Usage @@ -120,7 +104,7 @@ This will disallow the images from being deleted in the web UI, or adding metada _Remember to run `docker compose up -d` to register the changes. Make sure you can see the mounted path in the container._ ::: -### Create External Libraries +### Create A New Library These actions must be performed by the Immich administrator. @@ -144,7 +128,7 @@ Next, we'll add an exclusion pattern to filter out raw files. - Enter `**/Raw/**` and click save. - Click save - Click the drop-down menu on the newly created library -- Click on Scan Library Files +- Click on Scan The christmas trip library will now be scanned in the background. In the meantime, let's add the videos and old photos to another library. @@ -161,7 +145,7 @@ If you get an error here, please rename the other external library to something - Click on Add Path - Enter `/mnt/media/videos` then click Add - Click Save -- Click on Scan Library Files +- Click on Scan Within seconds, the assets from the old-pics and videos folders should show up in the main timeline. diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 8d98e86630..20bd230159 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -1,11 +1,4 @@ -import { - LibraryResponseDto, - LoginResponseDto, - ScanLibraryDto, - getAllLibraries, - removeOfflineFiles, - scanLibrary, -} from '@immich/sdk'; +import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk'; import { cpSync, existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { userDto, uuidDto } from 'src/fixtures'; @@ -15,8 +8,7 @@ import request from 'supertest'; import { utimes } from 'utimes'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -const scan = async (accessToken: string, id: string, dto: ScanLibraryDto = {}) => - scanLibrary({ id, scanLibraryDto: dto }, { headers: asBearerAuth(accessToken) }); +const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); describe('/libraries', () => { let admin: LoginResponseDto; @@ -293,14 +285,19 @@ describe('/libraries', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should scan external library', async () => { + it('should import new asset when scanning external library', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp/directoryA`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 }); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, @@ -315,8 +312,13 @@ describe('/libraries', () => { exclusionPatterns: ['**/directoryA'], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 }); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -330,8 +332,13 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -340,95 +347,144 @@ describe('/libraries', () => { expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined(); }); - it('should pick up new files', async () => { + it('should reimport a modified file', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 3 }); + await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001); - expect(newAssets.count).toBe(3); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ refreshModifiedFiles: true }); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + + const { assets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + model: 'NIKON D750', + }); + expect(assets.count).toBe(1); }); - it('should offline a file missing from disk', async () => { - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + it('should not reimport unmodified files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], }); + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ refreshModifiedFiles: true }); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + + const { assets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + model: 'NIKON D750', + }); + expect(assets.count).toBe(0); + }); + + it('should set an asset offline if its file is missing', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(3); + expect(assets.count).toBe(1); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); - await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); + expect(trashedAsset.isOffline).toEqual(true); + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(newAssets.count).toBe(3); - - expect(newAssets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - isOffline: true, - originalFileName: 'assetC.png', - }), - ]), - ); + expect(newAssets.items).toEqual([]); }); - it('should offline a file outside of import paths', async () => { + it('should set an asset offline its file is not in any import path', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], + importPaths: [`${testAssetDirInternal}/temp/offline`], }); + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + + utils.createDirectory(`${testAssetDir}/temp/another-path/`); + await request(app) .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: [`${testAssetDirInternal}/temp/directoryA`] }); + .send({ importPaths: [`${testAssetDirInternal}/temp/another-path/`] }); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); - await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); + expect(trashedAsset.isOffline).toBe(true); - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - isOffline: false, - originalFileName: 'assetA.png', - }), - expect.objectContaining({ - isOffline: true, - originalFileName: 'assetB.png', - }), - ]), - ); + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([]); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + utils.removeDirectory(`${testAssetDir}/temp/another-path/`); }); - it('should offline a file covered by an exclusion pattern', async () => { + it('should set an asset offline if its file is covered by an exclusion pattern', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], @@ -437,6 +493,12 @@ describe('/libraries', () => { await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); + const { assets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + originalFileName: 'assetB.png', + }); + expect(assets.count).toBe(1); + await request(app) .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) @@ -445,282 +507,21 @@ describe('/libraries', () => { await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.isTrashed).toBe(true); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/directoryB/assetB.png`); + expect(trashedAsset.isOffline).toBe(true); - expect(assets.count).toBe(2); + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - isOffline: false, - originalFileName: 'assetA.png', - }), - expect.objectContaining({ - isOffline: true, - originalFileName: 'assetB.png', - }), - ]), - ); + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'assetA.png', + }), + ]); }); - it('should not try to delete offline files', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline1`], - }); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(initialAssets).toEqual({ - count: 1, - total: 1, - facets: [], - items: [expect.objectContaining({ originalFileName: 'assetA.png' })], - nextPage: null, - }); - - utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets).toEqual({ - count: 1, - total: 1, - facets: [], - items: [expect.objectContaining({ originalFileName: 'assetA.png' })], - nextPage: null, - }); - - utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - await removeOfflineFiles({ id: library.id }, { headers: asBearerAuth(admin.accessToken) }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForWebsocketEvent({ event: 'assetDelete', total: 1 }); - - expect(existsSync(`${testAssetDir}/temp/offline1/assetA.png`)).toBe(true); - - utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - }); - - it('should scan new files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - utils.createImageFile(`${testAssetDir}/temp/directoryC/assetC.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(3); - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - originalFileName: 'assetC.png', - }), - ]), - ); - - utils.removeImageFile(`${testAssetDir}/temp/directoryC/assetC.png`); - }); - - describe('with refreshModifiedFiles=true', () => { - it('should reimport modified files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001); - - await scan(admin.accessToken, library.id, { refreshModifiedFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(1); - }); - - it('should not reimport unmodified files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id, { refreshModifiedFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(0); - }); - }); - - describe('with refreshAllFiles=true', () => { - it('should reimport all files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id, { refreshAllFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(1); - }); - }); - }); - - describe('POST /libraries/:id/removeOffline', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/removeOffline`).send({}); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should remove offline files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - utils.createImageFile(`${testAssetDir}/temp/offline/online.png`); - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - }); - expect(initialAssets.count).toBe(2); - - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets.count).toBe(1); - - const { status } = await request(app) - .post(`/libraries/${library.id}/removeOffline`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`); - }); - - it('should remove offline files from trash', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - utils.createImageFile(`${testAssetDir}/temp/offline/online.png`); - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - }); - - expect(initialAssets.count).toBe(2); - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets.count).toBe(1); - - const { status } = await request(app) - .post(`/libraries/${library.id}/removeOffline`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - expect(assets.items[0].isOffline).toBe(false); - expect(assets.items[0].originalPath).toEqual(`${testAssetDirInternal}/temp/offline/online.png`); - - utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`); - }); - - it('should not remove online files', async () => { + it('should not trash an online asset', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], @@ -733,10 +534,11 @@ describe('/libraries', () => { expect(assetsBefore.count).toBeGreaterThan(1); const { status } = await request(app) - .post(`/libraries/${library.id}/removeOffline`) + .post(`/libraries/${library.id}/scan`) .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(204); + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -828,7 +630,7 @@ describe('/libraries', () => { }); await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { status, body } = await request(app) .delete(`/libraries/${library.id}`) diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index beeaf1cc01..0e5d882f80 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -181,7 +181,7 @@ describe('/search', () => { dto: { size: -1.5 }, expected: ['size must not be less than 1', 'size must be an integer number'], }, - ...['isArchived', 'isFavorite', 'isEncoded', 'isMotion', 'isOffline', 'isVisible'].map((value) => ({ + ...['isArchived', 'isFavorite', 'isEncoded', 'isOffline', 'isMotion', 'isVisible'].map((value) => ({ should: `should reject ${value} not a boolean`, dto: { [value]: 'immich' }, expected: [`${value} must be a boolean value`], diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 17bb568c61..0bfc0ec19b 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -1,10 +1,13 @@ -import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk'; +import { LoginResponseDto, getAssetInfo, getAssetStatistics, scanLibrary } from '@immich/sdk'; +import { existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { errorDto } from 'src/responses'; -import { app, asBearerAuth, utils } from 'src/utils'; +import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); + describe('/trash', () => { let admin: LoginResponseDto; let ws: Socket; @@ -44,6 +47,8 @@ describe('/trash', () => { const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) }); expect(after.total).toBe(0); + + expect(existsSync(before.originalPath)).toBe(false); }); it('should empty the trash with archived assets', async () => { @@ -64,6 +69,46 @@ describe('/trash', () => { const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) }); expect(after.total).toBe(0); + + expect(existsSync(before.originalPath)).toBe(false); + }); + + it('should not delete offline-trashed assets from disk', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.items.length).toBe(1); + const asset = assets.items[0]; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); + expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + + await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); + + const assetAfter = await utils.getAssetInfo(admin.accessToken, asset.id); + expect(assetAfter).toMatchObject({ isTrashed: true, isOffline: true }); + + expect(existsSync(`${testAssetDir}/temp/offline/offline.png`)).toBe(true); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); }); }); @@ -91,6 +136,37 @@ describe('/trash', () => { const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: false })); }); + + it('should not restore offline-trashed assets', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + const assetId = assets.items[0].id; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); + expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); + + const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + + const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); + expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); + }); }); describe('POST /trash/restore/assets', () => { @@ -118,5 +194,38 @@ describe('/trash', () => { const after = await utils.getAssetInfo(admin.accessToken, assetId); expect(after.isTrashed).toBe(false); }); + + it('should not restore an offline-trashed asset', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + const assetId = assets.items[0].id; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const before = await utils.getAssetInfo(admin.accessToken, assetId); + expect(before.isTrashed).toBe(true); + + const { status } = await request(app) + .post('/trash/restore/assets') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [assetId] }); + expect(status).toBe(200); + + const after = await utils.getAssetInfo(admin.accessToken, assetId); + expect(after.isTrashed).toBe(true); + }); }); }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 3c9d4284ce..e21b3bfd14 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -372,6 +372,12 @@ export const utils = { writeFileSync(path, makeRandomImage()); }, + createDirectory: (path: string) => { + if (!existsSync(dirname(path))) { + mkdirSync(dirname(path), { recursive: true }); + } + }, + removeImageFile: (path: string) => { if (!existsSync(path)) { return; @@ -380,6 +386,14 @@ export const utils = { rmSync(path); }, + removeDirectory: (path: string) => { + if (!existsSync(path)) { + return; + } + + rmSync(path); + }, + getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }), checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) => diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart index 23bf236046..8be636efb6 100644 --- a/mobile/lib/entities/asset.entity.g.dart +++ b/mobile/lib/entities/asset.entity.g.dart @@ -57,69 +57,64 @@ const AssetSchema = CollectionSchema( name: r'isFavorite', type: IsarType.bool, ), - r'isOffline': PropertySchema( - id: 8, - name: r'isOffline', - type: IsarType.bool, - ), r'isTrashed': PropertySchema( - id: 9, + id: 8, name: r'isTrashed', type: IsarType.bool, ), r'livePhotoVideoId': PropertySchema( - id: 10, + id: 9, name: r'livePhotoVideoId', type: IsarType.string, ), r'localId': PropertySchema( - id: 11, + id: 10, name: r'localId', type: IsarType.string, ), r'ownerId': PropertySchema( - id: 12, + id: 11, name: r'ownerId', type: IsarType.long, ), r'remoteId': PropertySchema( - id: 13, + id: 12, name: r'remoteId', type: IsarType.string, ), r'stackCount': PropertySchema( - id: 14, + id: 13, name: r'stackCount', type: IsarType.long, ), r'stackId': PropertySchema( - id: 15, + id: 14, name: r'stackId', type: IsarType.string, ), r'stackPrimaryAssetId': PropertySchema( - id: 16, + id: 15, name: r'stackPrimaryAssetId', type: IsarType.string, ), r'thumbhash': PropertySchema( - id: 17, + id: 16, name: r'thumbhash', type: IsarType.string, ), r'type': PropertySchema( - id: 18, + id: 17, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 19, + id: 18, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 20, + id: 19, name: r'width', type: IsarType.int, ) @@ -244,19 +239,18 @@ void _assetSerialize( writer.writeInt(offsets[5], object.height); writer.writeBool(offsets[6], object.isArchived); writer.writeBool(offsets[7], object.isFavorite); - writer.writeBool(offsets[8], object.isOffline); - writer.writeBool(offsets[9], object.isTrashed); - writer.writeString(offsets[10], object.livePhotoVideoId); - writer.writeString(offsets[11], object.localId); - writer.writeLong(offsets[12], object.ownerId); - writer.writeString(offsets[13], object.remoteId); - writer.writeLong(offsets[14], object.stackCount); - writer.writeString(offsets[15], object.stackId); - writer.writeString(offsets[16], object.stackPrimaryAssetId); - writer.writeString(offsets[17], object.thumbhash); - writer.writeByte(offsets[18], object.type.index); - writer.writeDateTime(offsets[19], object.updatedAt); - writer.writeInt(offsets[20], object.width); + writer.writeBool(offsets[8], object.isTrashed); + writer.writeString(offsets[9], object.livePhotoVideoId); + writer.writeString(offsets[10], object.localId); + writer.writeLong(offsets[11], object.ownerId); + writer.writeString(offsets[12], object.remoteId); + writer.writeLong(offsets[13], object.stackCount); + writer.writeString(offsets[14], object.stackId); + writer.writeString(offsets[15], object.stackPrimaryAssetId); + writer.writeString(offsets[16], object.thumbhash); + writer.writeByte(offsets[17], object.type.index); + writer.writeDateTime(offsets[18], object.updatedAt); + writer.writeInt(offsets[19], object.width); } Asset _assetDeserialize( @@ -275,20 +269,19 @@ Asset _assetDeserialize( id: id, isArchived: reader.readBoolOrNull(offsets[6]) ?? false, isFavorite: reader.readBoolOrNull(offsets[7]) ?? false, - isOffline: reader.readBoolOrNull(offsets[8]) ?? false, - isTrashed: reader.readBoolOrNull(offsets[9]) ?? false, - livePhotoVideoId: reader.readStringOrNull(offsets[10]), - localId: reader.readStringOrNull(offsets[11]), - ownerId: reader.readLong(offsets[12]), - remoteId: reader.readStringOrNull(offsets[13]), - stackCount: reader.readLongOrNull(offsets[14]) ?? 0, - stackId: reader.readStringOrNull(offsets[15]), - stackPrimaryAssetId: reader.readStringOrNull(offsets[16]), - thumbhash: reader.readStringOrNull(offsets[17]), - type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? + isTrashed: reader.readBoolOrNull(offsets[8]) ?? false, + livePhotoVideoId: reader.readStringOrNull(offsets[9]), + localId: reader.readStringOrNull(offsets[10]), + ownerId: reader.readLong(offsets[11]), + remoteId: reader.readStringOrNull(offsets[12]), + stackCount: reader.readLongOrNull(offsets[13]) ?? 0, + stackId: reader.readStringOrNull(offsets[14]), + stackPrimaryAssetId: reader.readStringOrNull(offsets[15]), + thumbhash: reader.readStringOrNull(offsets[16]), + type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[19]), - width: reader.readIntOrNull(offsets[20]), + updatedAt: reader.readDateTime(offsets[18]), + width: reader.readIntOrNull(offsets[19]), ); return object; } @@ -319,29 +312,27 @@ P _assetDeserializeProp

    ( case 8: return (reader.readBoolOrNull(offset) ?? false) as P; case 9: - return (reader.readBoolOrNull(offset) ?? false) as P; + return (reader.readStringOrNull(offset)) as P; case 10: return (reader.readStringOrNull(offset)) as P; case 11: - return (reader.readStringOrNull(offset)) as P; - case 12: return (reader.readLong(offset)) as P; - case 13: + case 12: return (reader.readStringOrNull(offset)) as P; - case 14: + case 13: return (reader.readLongOrNull(offset) ?? 0) as P; + case 14: + return (reader.readStringOrNull(offset)) as P; case 15: return (reader.readStringOrNull(offset)) as P; case 16: return (reader.readStringOrNull(offset)) as P; case 17: - return (reader.readStringOrNull(offset)) as P; - case 18: return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? AssetType.other) as P; - case 19: + case 18: return (reader.readDateTime(offset)) as P; - case 20: + case 19: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -1362,16 +1353,6 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder isOfflineEqualTo( - bool value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'isOffline', - value: value, - )); - }); - } - QueryBuilder isTrashedEqualTo( bool value) { return QueryBuilder.apply(this, (query) { @@ -2647,18 +2628,6 @@ extension AssetQuerySortBy on QueryBuilder { }); } - QueryBuilder sortByIsOffline() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.asc); - }); - } - - QueryBuilder sortByIsOfflineDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.desc); - }); - } - QueryBuilder sortByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'isTrashed', Sort.asc); @@ -2913,18 +2882,6 @@ extension AssetQuerySortThenBy on QueryBuilder { }); } - QueryBuilder thenByIsOffline() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.asc); - }); - } - - QueryBuilder thenByIsOfflineDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.desc); - }); - } - QueryBuilder thenByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'isTrashed', Sort.asc); @@ -3121,12 +3078,6 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } - QueryBuilder distinctByIsOffline() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'isOffline'); - }); - } - QueryBuilder distinctByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'isTrashed'); @@ -3263,12 +3214,6 @@ extension AssetQueryProperty on QueryBuilder { }); } - QueryBuilder isOfflineProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'isOffline'); - }); - } - QueryBuilder isTrashedProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'isTrashed'); diff --git a/mobile/lib/extensions/collection_extensions.dart b/mobile/lib/extensions/collection_extensions.dart index 769bec472b..f71b0aacd3 100644 --- a/mobile/lib/extensions/collection_extensions.dart +++ b/mobile/lib/extensions/collection_extensions.dart @@ -72,13 +72,14 @@ extension AssetListExtension on Iterable { } /// Filters out offline assets and returns those that are still accessible by the Immich server + /// TODO: isOffline is removed from Immich, so this method is not useful anymore Iterable nonOfflineOnly({ void Function()? errorCallback, }) { - final bool onlyLive = every((e) => !e.isOffline); + final bool onlyLive = every((e) => false); if (!onlyLive) { if (errorCallback != null) errorCallback(); - return where((a) => !a.isOffline); + return where((a) => false); } return this; } diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 7e6136c256..8b5684d0fa 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -172,29 +172,12 @@ class BottomGalleryBar extends ConsumerWidget { } shareAsset() { - if (asset.isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_share_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context); } void handleEdit() async { final image = Image(image: ImmichImage.imageProvider(asset: asset)); - if (asset.isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_edit_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } + Navigator.of(context).push( MaterialPageRoute( builder: (context) => EditImagePage( @@ -219,16 +202,6 @@ class BottomGalleryBar extends ConsumerWidget { if (asset.isLocal) { return; } - if (asset.isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_share_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } - ref.read(imageViewerStateProvider.notifier).downloadAsset( asset, context, diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index 2157a1aebb..984b61f50c 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -183,8 +183,7 @@ class TopControlAppBar extends HookConsumerWidget { if (asset.isRemote && isOwner) buildFavoriteButton(a), if (asset.livePhotoVideoId != null) buildLivePhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), - if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner) - buildDownloadButton(), + if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed) buildAddToAlbumButton(), if (asset.isTrashed) buildRestoreButton(), diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b6b0897e8f..e337c4831f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -133,7 +133,6 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries | *LibrariesApi* | [**getLibrary**](doc//LibrariesApi.md#getlibrary) | **GET** /libraries/{id} | *LibrariesApi* | [**getLibraryStatistics**](doc//LibrariesApi.md#getlibrarystatistics) | **GET** /libraries/{id}/statistics | -*LibrariesApi* | [**removeOfflineFiles**](doc//LibrariesApi.md#removeofflinefiles) | **POST** /libraries/{id}/removeOffline | *LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | *LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | @@ -385,7 +384,6 @@ Class | Method | HTTP request | Description - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) - - [ScanLibraryDto](doc//ScanLibraryDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) - [SearchExploreItem](doc//SearchExploreItem.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d08b6fc521..22b48df2fb 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -197,7 +197,6 @@ part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; part 'model/reverse_geocoding_state_response_dto.dart'; -part 'model/scan_library_dto.dart'; part 'model/search_album_response_dto.dart'; part 'model/search_asset_response_dto.dart'; part 'model/search_explore_item.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index bd1d5b8484..fd89986980 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -833,14 +833,12 @@ class AssetsApi { /// /// * [bool] isFavorite: /// - /// * [bool] isOffline: - /// /// * [bool] isVisible: /// /// * [String] livePhotoVideoId: /// /// * [MultipartFile] sidecarData: - Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { + Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { // ignore: prefer_const_declarations final path = r'/assets'; @@ -896,10 +894,6 @@ class AssetsApi { hasFields = true; mp.fields[r'isFavorite'] = parameterToString(isFavorite); } - if (isOffline != null) { - hasFields = true; - mp.fields[r'isOffline'] = parameterToString(isOffline); - } if (isVisible != null) { hasFields = true; mp.fields[r'isVisible'] = parameterToString(isVisible); @@ -951,15 +945,13 @@ class AssetsApi { /// /// * [bool] isFavorite: /// - /// * [bool] isOffline: - /// /// * [bool] isVisible: /// /// * [String] livePhotoVideoId: /// /// * [MultipartFile] sidecarData: - Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { - final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, ); + Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { + final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/libraries_api.dart b/mobile/openapi/lib/api/libraries_api.dart index 53ab0e19ce..36d98d9a88 100644 --- a/mobile/openapi/lib/api/libraries_api.dart +++ b/mobile/openapi/lib/api/libraries_api.dart @@ -243,13 +243,13 @@ class LibrariesApi { return null; } - /// Performs an HTTP 'POST /libraries/{id}/removeOffline' operation and returns the [Response]. + /// Performs an HTTP 'POST /libraries/{id}/scan' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): - Future removeOfflineFilesWithHttpInfo(String id,) async { + Future scanLibraryWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/libraries/{id}/removeOffline' + final path = r'/libraries/{id}/scan' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -276,52 +276,8 @@ class LibrariesApi { /// Parameters: /// /// * [String] id (required): - Future removeOfflineFiles(String id,) async { - final response = await removeOfflineFilesWithHttpInfo(id,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } - - /// Performs an HTTP 'POST /libraries/{id}/scan' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [ScanLibraryDto] scanLibraryDto (required): - Future scanLibraryWithHttpInfo(String id, ScanLibraryDto scanLibraryDto,) async { - // ignore: prefer_const_declarations - final path = r'/libraries/{id}/scan' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody = scanLibraryDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - path, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [ScanLibraryDto] scanLibraryDto (required): - Future scanLibrary(String id, ScanLibraryDto scanLibraryDto,) async { - final response = await scanLibraryWithHttpInfo(id, scanLibraryDto,); + Future scanLibrary(String id,) async { + final response = await scanLibraryWithHttpInfo(id,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c62d1c5b2e..3db3297acb 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -448,8 +448,6 @@ class ApiClient { return ReactionTypeTypeTransformer().decode(value); case 'ReverseGeocodingStateResponseDto': return ReverseGeocodingStateResponseDto.fromJson(value); - case 'ScanLibraryDto': - return ScanLibraryDto.fromJson(value); case 'SearchAlbumResponseDto': return SearchAlbumResponseDto.fromJson(value); case 'SearchAssetResponseDto': diff --git a/mobile/openapi/lib/model/scan_library_dto.dart b/mobile/openapi/lib/model/scan_library_dto.dart deleted file mode 100644 index 8ff978be05..0000000000 --- a/mobile/openapi/lib/model/scan_library_dto.dart +++ /dev/null @@ -1,125 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class ScanLibraryDto { - /// Returns a new [ScanLibraryDto] instance. - ScanLibraryDto({ - this.refreshAllFiles, - this.refreshModifiedFiles, - }); - - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? refreshAllFiles; - - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? refreshModifiedFiles; - - @override - bool operator ==(Object other) => identical(this, other) || other is ScanLibraryDto && - other.refreshAllFiles == refreshAllFiles && - other.refreshModifiedFiles == refreshModifiedFiles; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (refreshAllFiles == null ? 0 : refreshAllFiles!.hashCode) + - (refreshModifiedFiles == null ? 0 : refreshModifiedFiles!.hashCode); - - @override - String toString() => 'ScanLibraryDto[refreshAllFiles=$refreshAllFiles, refreshModifiedFiles=$refreshModifiedFiles]'; - - Map toJson() { - final json = {}; - if (this.refreshAllFiles != null) { - json[r'refreshAllFiles'] = this.refreshAllFiles; - } else { - // json[r'refreshAllFiles'] = null; - } - if (this.refreshModifiedFiles != null) { - json[r'refreshModifiedFiles'] = this.refreshModifiedFiles; - } else { - // json[r'refreshModifiedFiles'] = null; - } - return json; - } - - /// Returns a new [ScanLibraryDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static ScanLibraryDto? fromJson(dynamic value) { - upgradeDto(value, "ScanLibraryDto"); - if (value is Map) { - final json = value.cast(); - - return ScanLibraryDto( - refreshAllFiles: mapValueOfType(json, r'refreshAllFiles'), - refreshModifiedFiles: mapValueOfType(json, r'refreshModifiedFiles'), - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = ScanLibraryDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = ScanLibraryDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of ScanLibraryDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = ScanLibraryDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - }; -} - diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1a070f126b..d0864675a1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2853,41 +2853,6 @@ ] } }, - "/libraries/{id}/removeOffline": { - "post": { - "operationId": "removeOfflineFiles", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Libraries" - ] - } - }, "/libraries/{id}/scan": { "post": { "operationId": "scanLibrary", @@ -2902,16 +2867,6 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScanLibraryDto" - } - } - }, - "required": true - }, "responses": { "204": { "description": "" @@ -8287,9 +8242,6 @@ "isFavorite": { "type": "boolean" }, - "isOffline": { - "type": "boolean" - }, "isVisible": { "type": "boolean" }, @@ -10628,17 +10580,6 @@ ], "type": "object" }, - "ScanLibraryDto": { - "properties": { - "refreshAllFiles": { - "type": "boolean" - }, - "refreshModifiedFiles": { - "type": "boolean" - } - }, - "type": "object" - }, "SearchAlbumResponseDto": { "properties": { "count": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f2f946f262..85710af49c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -366,7 +366,6 @@ export type AssetMediaCreateDto = { fileModifiedAt: string; isArchived?: boolean; isFavorite?: boolean; - isOffline?: boolean; isVisible?: boolean; livePhotoVideoId?: string; sidecarData?: Blob; @@ -579,10 +578,6 @@ export type UpdateLibraryDto = { importPaths?: string[]; name?: string; }; -export type ScanLibraryDto = { - refreshAllFiles?: boolean; - refreshModifiedFiles?: boolean; -}; export type LibraryStatsResponseDto = { photos: number; total: number; @@ -2066,24 +2061,14 @@ export function updateLibrary({ id, updateLibraryDto }: { body: updateLibraryDto }))); } -export function removeOfflineFiles({ id }: { +export function scanLibrary({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/removeOffline`, { + return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, { ...opts, method: "POST" })); } -export function scanLibrary({ id, scanLibraryDto }: { - id: string; - scanLibraryDto: ScanLibraryDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, oazapfts.json({ - ...opts, - method: "POST", - body: scanLibraryDto - }))); -} export function getLibraryStatistics({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/library.controller.ts b/server/src/controllers/library.controller.ts index a45617fc2a..b8959ca288 100644 --- a/server/src/controllers/library.controller.ts +++ b/server/src/controllers/library.controller.ts @@ -4,7 +4,6 @@ import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, - ScanLibraryDto, UpdateLibraryDto, ValidateLibraryDto, ValidateLibraryResponseDto, @@ -43,6 +42,13 @@ export class LibraryController { return this.service.update(id, dto); } + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true }) + deleteLibrary(@Param() { id }: UUIDParamDto): Promise { + return this.service.delete(id); + } + @Post(':id/validate') @HttpCode(200) @Authenticated({ admin: true }) @@ -51,13 +57,6 @@ export class LibraryController { return this.service.validate(id, dto); } - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true }) - deleteLibrary(@Param() { id }: UUIDParamDto): Promise { - return this.service.delete(id); - } - @Get(':id/statistics') @Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true }) getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise { @@ -66,15 +65,8 @@ export class LibraryController { @Post(':id/scan') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) - scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { - return this.service.queueScan(id, dto); - } - - @Post(':id/removeOffline') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) - removeOfflineFiles(@Param() { id }: UUIDParamDto) { - return this.service.queueRemoveOffline(id); + @Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true }) + scanLibrary(@Param() { id }: UUIDParamDto) { + return this.service.queueScan(id); } } diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index e9e346c4cb..c62857da65 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -56,9 +56,6 @@ export class AssetMediaCreateDto extends AssetMediaBase { @ValidateBoolean({ optional: true }) isVisible?: boolean; - @ValidateBoolean({ optional: true }) - isOffline?: boolean; - @ValidateUUID({ optional: true }) livePhotoVideoId?: string; diff --git a/server/src/dtos/library.dto.ts b/server/src/dtos/library.dto.ts index c2c3ac9d27..7fb363dd9a 100644 --- a/server/src/dtos/library.dto.ts +++ b/server/src/dtos/library.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator'; import { LibraryEntity } from 'src/entities/library.entity'; -import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; +import { Optional, ValidateUUID } from 'src/validation'; export class CreateLibraryDto { @ValidateUUID() @@ -89,14 +89,6 @@ export class LibrarySearchDto { userId?: string; } -export class ScanLibraryDto { - @ValidateBoolean({ optional: true }) - refreshModifiedFiles?: boolean; - - @ValidateBoolean({ optional: true }) - refreshAllFiles?: boolean; -} - export class LibraryResponseDto { id!: string; ownerId!: string; diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 387fa27185..c6808e3aa8 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -36,8 +36,6 @@ export enum WithoutProperty { export enum WithProperty { SIDECAR = 'sidecar', - IS_ONLINE = 'isOnline', - IS_OFFLINE = 'isOffline', } export enum TimeBucketSize { @@ -176,7 +174,6 @@ export interface IAssetRepository { ): Paginated; getRandom(userIds: string[], count: number): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; - getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated; getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 3e7b0b9d08..8b6e2c289b 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -76,12 +76,12 @@ export enum JobName { FACIAL_RECOGNITION = 'facial-recognition', // library management - LIBRARY_SCAN = 'library-refresh', - LIBRARY_SCAN_ASSET = 'library-refresh-asset', - LIBRARY_REMOVE_OFFLINE = 'library-remove-offline', - LIBRARY_CHECK_OFFLINE = 'library-check-offline', + LIBRARY_QUEUE_SYNC_FILES = 'library-queue-sync-files', + LIBRARY_QUEUE_SYNC_ASSETS = 'library-queue-sync-assets', + LIBRARY_SYNC_FILE = 'library-sync-file', + LIBRARY_SYNC_ASSET = 'library-sync-asset', LIBRARY_DELETE = 'library-delete', - LIBRARY_QUEUE_SCAN_ALL = 'library-queue-all-refresh', + LIBRARY_QUEUE_SYNC_ALL = 'library-queue-sync-all', LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', // cleanup @@ -137,16 +137,11 @@ export interface ILibraryFileJob extends IEntityJob { assetPath: string; } -export interface ILibraryOfflineJob extends IEntityJob { +export interface ILibraryAssetJob extends IEntityJob { importPaths: string[]; exclusionPatterns: string[]; } -export interface ILibraryRefreshJob extends IEntityJob { - refreshModifiedFiles: boolean; - refreshAllFiles: boolean; -} - export interface IBulkEntityJob extends IBaseJob { ids: string[]; } @@ -277,12 +272,12 @@ export type JobItem = | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob } // Library Management - | { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob } - | { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob } - | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } + | { name: JobName.LIBRARY_SYNC_FILE; data: ILibraryFileJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_FILES; data: IEntityJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob } + | { name: JobName.LIBRARY_SYNC_ASSET; data: IEntityJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob } - | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob } - | { name: JobName.LIBRARY_CHECK_OFFLINE; data: IEntityJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob } | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } // Notification diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 5b57307179..6930932584 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -268,35 +268,6 @@ DELETE FROM "assets" WHERE "ownerId" = $1 --- AssetRepository.getExternalLibraryAssetPaths -SELECT DISTINCT - "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" -FROM - ( - SELECT - "AssetEntity"."id" AS "AssetEntity_id", - "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."isOffline" AS "AssetEntity_isOffline" - FROM - "assets" "AssetEntity" - LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId" - AND ( - "AssetEntity__AssetEntity_library"."deletedAt" IS NULL - ) - WHERE - ( - ( - ((("AssetEntity__AssetEntity_library"."id" = $1))) - AND ("AssetEntity"."isExternal" = $2) - ) - ) - AND ("AssetEntity"."deletedAt" IS NULL) - ) "distinctAlias" -ORDER BY - "AssetEntity_id" ASC -LIMIT - 2 - -- AssetRepository.getByLibraryIdAndOriginalPath SELECT DISTINCT "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" @@ -366,18 +337,6 @@ WHERE AND "originalPath" = path ); --- AssetRepository.updateOfflineLibraryAssets -UPDATE "assets" -SET - "isOffline" = $1, - "updatedAt" = CURRENT_TIMESTAMP -WHERE - ( - "libraryId" = $2 - AND NOT ("originalPath" IN ($3)) - AND "isOffline" = $4 - ) - -- AssetRepository.getAllByDeviceId SELECT "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 4ec5523df1..43e765d00b 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -13,7 +13,6 @@ import { AssetDeltaSyncOptions, AssetExploreFieldOptions, AssetFullSyncOptions, - AssetPathEntity, AssetStats, AssetStatsOptions, AssetUpdateAllOptions, @@ -177,14 +176,6 @@ export class AssetRepository implements IAssetRepository { return this.getAll(pagination, { ...options, userIds: [userId] }); } - @GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] }) - getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated { - return paginate(this.repository, pagination, { - select: { id: true, originalPath: true, isOffline: true }, - where: { library: { id: libraryId }, isExternal: true }, - }); - } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise { return this.repository.findOne({ @@ -198,24 +189,16 @@ export class AssetRepository implements IAssetRepository { async getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise { const result = await this.repository.query( ` - WITH paths AS (SELECT unnest($2::text[]) AS path) - SELECT path FROM paths - WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path); - `, + WITH paths AS (SELECT unnest($2::text[]) AS path) + SELECT path + FROM paths + WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path); + `, [libraryId, originalPaths], ); return result.map((row: { path: string }) => row.path); } - @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] }) - @ChunkedArray({ paramIndex: 1 }) - async updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise { - await this.repository.update( - { library: { id: libraryId }, originalPath: Not(In(originalPaths)), isOffline: false }, - { isOffline: true }, - ); - } - getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { let builder = this.repository.createQueryBuilder('asset').leftJoinAndSelect('asset.files', 'files'); builder = searchAssetBuilder(builder, options); @@ -373,12 +356,10 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql( - ...Object.values(WithProperty) - .filter((property) => property !== WithProperty.IS_OFFLINE && property !== WithProperty.IS_ONLINE) - .map((property) => ({ - name: property, - params: [DummyValue.PAGINATION, property], - })), + ...Object.values(WithProperty).map((property) => ({ + name: property, + params: [DummyValue.PAGINATION, property], + })), ) getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated { let relations: FindOptionsRelations = {}; @@ -531,26 +512,16 @@ export class AssetRepository implements IAssetRepository { where = [{ sidecarPath: Not(IsNull()), isVisible: true }]; break; } - case WithProperty.IS_OFFLINE: { - if (!libraryId) { - throw new Error('Library id is required when finding offline assets'); - } - where = [{ isOffline: true, libraryId }]; - break; - } - case WithProperty.IS_ONLINE: { - if (!libraryId) { - throw new Error('Library id is required when finding online assets'); - } - where = [{ isOffline: false, libraryId }]; - break; - } default: { throw new Error(`Invalid getWith property: ${property}`); } } + if (libraryId) { + where = [{ ...where, libraryId }]; + } + return paginate(this.repository, pagination, { where, withDeleted, @@ -750,7 +721,10 @@ export class AssetRepository implements IAssetRepository { builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted(); if (options.isTrashed) { - builder.andWhere('asset.status = :status', { status: AssetStatus.TRASHED }); + // TODO: Temporarily inverted to support showing offline assets in the trash queries. + // Once offline assets are handled in a separate screen, this should be set back to status = TRASHED + // and the offline screens should use a separate isOffline = true parameter in the timeline query. + builder.andWhere('asset.status != :status', { status: AssetStatus.DELETED }); } } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 2b4c1f6dc1..cd4c7135be 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -79,12 +79,12 @@ export const JOBS_TO_QUEUE: Record = { [JobName.SIDECAR_WRITE]: QueueName.SIDECAR, // Library management - [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, - [JobName.LIBRARY_SCAN]: QueueName.LIBRARY, + [JobName.LIBRARY_SYNC_FILE]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_FILES]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_ASSETS]: QueueName.LIBRARY, [JobName.LIBRARY_DELETE]: QueueName.LIBRARY, - [JobName.LIBRARY_CHECK_OFFLINE]: QueueName.LIBRARY, - [JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY, - [JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY, + [JobName.LIBRARY_SYNC_ASSET]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_ALL]: QueueName.LIBRARY, [JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY, // Notification diff --git a/server/src/repositories/trash.repository.ts b/server/src/repositories/trash.repository.ts index 9e0f6728f1..d24f4f709a 100644 --- a/server/src/repositories/trash.repository.ts +++ b/server/src/repositories/trash.repository.ts @@ -3,7 +3,7 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { AssetStatus } from 'src/enum'; import { ITrashRepository } from 'src/interfaces/trash.interface'; import { Paginated, paginatedBuilder, PaginationOptions } from 'src/utils/pagination'; -import { In, IsNull, Not, Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; export class TrashRepository implements ITrashRepository { constructor(@InjectRepository(AssetEntity) private assetRepository: Repository) {} @@ -26,7 +26,7 @@ export class TrashRepository implements ITrashRepository { async restore(userId: string): Promise { const result = await this.assetRepository.update( - { ownerId: userId, deletedAt: Not(IsNull()) }, + { ownerId: userId, status: AssetStatus.TRASHED }, { status: AssetStatus.ACTIVE, deletedAt: null }, ); @@ -35,7 +35,7 @@ export class TrashRepository implements ITrashRepository { async empty(userId: string): Promise { const result = await this.assetRepository.update( - { ownerId: userId, deletedAt: Not(IsNull()), status: AssetStatus.TRASHED }, + { ownerId: userId, status: AssetStatus.TRASHED }, { status: AssetStatus.DELETED }, ); @@ -43,7 +43,10 @@ export class TrashRepository implements ITrashRepository { } async restoreAll(ids: string[]): Promise { - const result = await this.assetRepository.update({ id: In(ids) }, { status: AssetStatus.ACTIVE, deletedAt: null }); + const result = await this.assetRepository.update( + { id: In(ids), status: AssetStatus.TRASHED }, + { status: AssetStatus.ACTIVE, deletedAt: null }, + ); return result.affected ?? 0; } } diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 5321c335a7..d3dce323f0 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -427,7 +427,6 @@ export class AssetMediaService { livePhotoVideoId: dto.livePhotoVideoId, originalFileName: file.originalName, sidecarPath: sidecarFile?.originalPath, - isOffline: dto.isOffline ?? false, }); if (sidecarFile) { diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 5ed9f32024..f978f33410 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -164,7 +164,7 @@ export class JobService { } case QueueName.LIBRARY: { - return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } }); + return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, data: { force } }); } default: { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 36bdfd05dc..8b14c76cbc 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -10,9 +10,8 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IJobRepository, + ILibraryAssetJob, ILibraryFileJob, - ILibraryOfflineJob, - ILibraryRefreshJob, JobName, JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, @@ -37,6 +36,10 @@ import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/sto import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked, vitest } from 'vitest'; +async function* mockWalk() { + yield await Promise.resolve(['/data/user1/photo.jpg']); +} + describe(LibraryService.name, () => { let sut: LibraryService; @@ -91,7 +94,7 @@ describe(LibraryService.name, () => { enabled: true, cronExpression: '0 1 * * *', }, - watch: { enabled: false }, + watch: { enabled: true }, }, } as SystemConfig); @@ -163,102 +166,29 @@ describe(LibraryService.name, () => { describe('handleQueueAssetRefresh', () => { it('should queue refresh of a new asset', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - // eslint-disable-next-line @typescript-eslint/require-await - storageMock.walk.mockImplementation(async function* generator() { - yield ['/data/user1/photo.jpg']; - }); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); + storageMock.walk.mockImplementation(mockWalk); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibrary1.id, ownerId: libraryStub.externalLibrary1.owner.id, assetPath: '/data/user1/photo.jpg', - force: false, - }, - }, - ]); - }); - - it('should queue offline check of existing online assets', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.walk.mockImplementation(async function* generator() {}); - assetMock.getWith.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); - - await sut.handleQueueAssetRefresh(mockLibraryJob); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_CHECK_OFFLINE, - data: { - id: assetStub.external.id, - importPaths: libraryStub.externalLibrary1.importPaths, - exclusionPatterns: [], }, }, ]); }); it("should fail when library can't be found", async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - libraryMock.get.mockResolvedValue(null); - await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - }); - - it('should force queue new assets', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, - }; - - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - // eslint-disable-next-line @typescript-eslint/require-await - storageMock.walk.mockImplementation(async function* generator() { - yield ['/data/user1/photo.jpg']; - }); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); - - await sut.handleQueueAssetRefresh(mockLibraryJob); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_SCAN_ASSET, - data: { - id: libraryStub.externalLibrary1.id, - ownerId: libraryStub.externalLibrary1.owner.id, - assetPath: '/data/user1/photo.jpg', - force: true, - }, - }, - ]); + await expect(sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); }); it('should ignore import paths that do not exist', async () => { @@ -276,16 +206,9 @@ describe(LibraryService.name, () => { assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibraryWithImportPaths1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); expect(storageMock.walk).toHaveBeenCalledWith({ pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]], @@ -296,9 +219,36 @@ describe(LibraryService.name, () => { }); }); - describe('handleOfflineCheck', () => { + describe('handleQueueRemoveDeleted', () => { + it('should queue online check of existing assets', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.walk.mockImplementation(async function* generator() {}); + assetMock.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); + + await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id }); + + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.LIBRARY_SYNC_ASSET, + data: { + id: assetStub.external.id, + importPaths: libraryStub.externalLibrary1.importPaths, + exclusionPatterns: [], + }, + }, + ]); + }); + + it("should fail when library can't be found", async () => { + libraryMock.get.mockResolvedValue(null); + + await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); + }); + }); + + describe('handleSyncAsset', () => { it('should skip missing assets', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: [], @@ -306,41 +256,31 @@ describe(LibraryService.name, () => { assetMock.getById.mockResolvedValue(null); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.update).not.toHaveBeenCalled(); - }); - - it('should do nothing with already-offline assets', async () => { - const mockAssetJob: ILibraryOfflineJob = { - id: assetStub.external.id, - importPaths: ['/'], - exclusionPatterns: [], - }; - - assetMock.getById.mockResolvedValue(assetStub.offline); - - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.remove).not.toHaveBeenCalled(); }); it('should offline assets no longer on disk', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: [], }; assetMock.getById.mockResolvedValue(assetStub.external); + storageMock.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.any(Date), + }); }); it('should offline assets matching an exclusion pattern', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: ['**/user1/**'], @@ -348,13 +288,15 @@ describe(LibraryService.name, () => { assetMock.getById.mockResolvedValue(assetStub.external); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.any(Date), + }); }); it('should set assets outside of import paths as offline', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/data/user2'], exclusionPatterns: [], @@ -363,28 +305,74 @@ describe(LibraryService.name, () => { assetMock.getById.mockResolvedValue(assetStub.external); storageMock.checkFileExists.mockResolvedValue(true); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.any(Date), + }); }); it('should do nothing with online assets', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: [], }; assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.checkFileExists.mockResolvedValue(true); + storageMock.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.updateAll).not.toHaveBeenCalled(); + }); + + it('should un-trash an asset previously marked as offline', async () => { + const mockAssetJob: ILibraryAssetJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: [], + }; + + assetMock.getById.mockResolvedValue(assetStub.trashedOffline); + storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); + + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], { + deletedAt: null, + fileCreatedAt: assetStub.trashedOffline.fileModifiedAt, + fileModifiedAt: assetStub.trashedOffline.fileModifiedAt, + isOffline: false, + originalFileName: 'path.jpg', + }); }); }); - describe('handleAssetRefresh', () => { + it('should update file when mtime has changed', async () => { + const mockAssetJob: ILibraryAssetJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: [], + }; + + const newMTime = new Date(); + assetMock.getById.mockResolvedValue(assetStub.external); + storageMock.stat.mockResolvedValue({ mtime: newMTime } as Stats); + + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + fileModifiedAt: newMTime, + fileCreatedAt: newMTime, + isOffline: false, + originalFileName: 'photo.jpg', + deletedAt: null, + }); + }); + + describe('handleSyncFile', () => { let mockUser: UserEntity; beforeEach(() => { @@ -397,42 +385,18 @@ describe(LibraryService.name, () => { } as Stats); }); - it('should reject an unknown file extension', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/file.xyz', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should reject an unknown file type', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/file.xyz', - force: false, - }; - - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should add a new image', async () => { + it('should import a new asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -467,19 +431,19 @@ describe(LibraryService.name, () => { ]); }); - it('should add a new image with sidecar', async () => { + it('should import a new asset with sidecar', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); storageMock.checkFileExists.mockResolvedValue(true); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -514,18 +478,18 @@ describe(LibraryService.name, () => { ]); }); - it('should add a new video', async () => { + it('should import a new video', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/video.mp4', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.video); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -568,29 +532,27 @@ describe(LibraryService.name, () => { ]); }); - it('should not add an image to a soft deleted library', async () => { + it('should not import an asset to a soft deleted library', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); expect(assetMock.create.mock.calls).toEqual([]); }); - it('should not import an asset when mtime matches db asset', async () => { + it('should not refresh a file whose mtime matches existing asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: assetStub.hasFileExtension.originalPath, - force: false, }; storageMock.stat.mockResolvedValue({ @@ -601,190 +563,52 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); }); - it('should import an asset when mtime differs from db asset', async () => { + it('should skip existing asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.METADATA_EXTRACTION, - data: { - id: assetStub.image.id, - source: 'upload', - }, - }); - - expect(jobMock.queue).not.toHaveBeenCalledWith({ - name: JobName.VIDEO_CONVERSION, - data: { - id: assetStub.image.id, - }, - }); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); }); - it('should import an asset that is missing a file extension', async () => { - // This tests for the case where the file extension is missing from the asset path. - // This happened in previous versions of Immich + it('should not refresh an asset trashed by user', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, - assetPath: assetStub.missingFileExtension.originalPath, - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.missingFileExtension); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.updateAll).toHaveBeenCalledWith( - [assetStub.missingFileExtension.id], - expect.objectContaining({ originalFileName: 'photo.jpg' }), - ); - }); - - it('should set a missing asset to offline', async () => { - storageMock.stat.mockRejectedValue(new Error('Path not found')); - - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true }); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - }); - - it('should online a previously-offline asset', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.offline.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.offline); - assetMock.create.mockResolvedValue(assetStub.offline); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false }); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.METADATA_EXTRACTION, - data: { - id: assetStub.offline.id, - source: 'upload', - }, - }); - - expect(jobMock.queue).not.toHaveBeenCalledWith({ - name: JobName.VIDEO_CONVERSION, - data: { - id: assetStub.offline.id, - }, - }); - }); - - it('should do nothing when mtime matches existing asset', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: assetStub.image.ownerId, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - - expect(assetMock.update).not.toHaveBeenCalled(); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - }); - - it('should refresh an existing asset if forced', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: assetStub.hasFileExtension.ownerId, assetPath: assetStub.hasFileExtension.originalPath, - force: true, }; - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); - assetMock.create.mockResolvedValue(assetStub.hasFileExtension); + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.trashed); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.hasFileExtension.id], { - fileCreatedAt: new Date('2023-01-01'), - fileModifiedAt: new Date('2023-01-01'), - originalFileName: assetStub.hasFileExtension.originalFileName, - }); + expect(jobMock.queue).not.toHaveBeenCalled(); + expect(jobMock.queueAll).not.toHaveBeenCalled(); }); - it('should refresh an existing asset with modified mtime', async () => { - const filemtime = new Date(); - filemtime.setSeconds(assetStub.image.fileModifiedAt.getSeconds() + 10); - - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: userStub.admin.id, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - storageMock.stat.mockResolvedValue({ - size: 100, - mtime: filemtime, - ctime: new Date('2023-01-01'), - } as Stats); - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); - assetMock.create.mockResolvedValue(assetStub.image); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.create).toHaveBeenCalled(); - const createdAsset = assetMock.create.mock.calls[0][0]; - - expect(createdAsset.fileModifiedAt).toEqual(filemtime); - }); - - it('should throw error when asset does not exist', async () => { + it('should throw BadRequestException when asset does not exist', async () => { storageMock.stat.mockRejectedValue(new Error("ENOENT, no such file or directory '/data/user1/photo.jpg'")); const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: userStub.admin.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); }); }); @@ -857,7 +681,6 @@ describe(LibraryService.name, () => { describe('getStatistics', () => { it('should return library statistics', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({ photos: 10, @@ -1092,12 +915,11 @@ describe(LibraryService.name, () => { expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibraryWithImportPaths1.id, assetPath: '/foo/photo.jpg', ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, - force: false, }, }, ]); @@ -1114,30 +936,16 @@ describe(LibraryService.name, () => { expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibraryWithImportPaths1.id, assetPath: '/foo/photo.jpg', ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, - force: false, }, }, ]); }); - it('should handle a file unlink event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); - storageMock.watch.mockImplementation( - makeMockWatcher({ items: [{ event: 'unlink', value: '/foo/photo.jpg' }] }), - ); - - await sut.watchAll(); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); - }); - it('should handle an error event', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); @@ -1232,72 +1040,23 @@ describe(LibraryService.name, () => { }); describe('queueScan', () => { - it('should queue a library scan of external library', async () => { + it('should queue a library scan', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.queueScan(libraryStub.externalLibrary1.id, {}); + await sut.queueScan(libraryStub.externalLibrary1.id); expect(jobMock.queue.mock.calls).toEqual([ [ { - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, }, }, ], - ]); - }); - - it('should queue a library scan of all modified assets', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - await sut.queueScan(libraryStub.externalLibrary1.id, { refreshModifiedFiles: true }); - - expect(jobMock.queue.mock.calls).toEqual([ [ { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: true, - refreshAllFiles: false, - }, - }, - ], - ]); - }); - - it('should queue a forced library scan', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - await sut.queueScan(libraryStub.externalLibrary1.id, { refreshAllFiles: true }); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, - }, - }, - ], - ]); - }); - }); - - describe('queueEmptyTrash', () => { - it('should queue the trash job', async () => { - await sut.queueRemoveOffline(libraryStub.externalLibrary1.id); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_REMOVE_OFFLINE, + name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id: libraryStub.externalLibrary1.id, }, @@ -1311,7 +1070,7 @@ describe(LibraryService.name, () => { it('should queue the refresh job', async () => { libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - await expect(sut.handleQueueAllScan({})).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueSyncAll()).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1323,48 +1082,32 @@ describe(LibraryService.name, () => { ]); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: true, - refreshAllFiles: false, - }, - }, - ]); - }); - - it('should queue the force refresh job', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - - await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(JobStatus.SUCCESS); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_QUEUE_CLEANUP, - data: {}, - }); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, }, }, ]); }); }); - describe('handleRemoveOfflineFiles', () => { - it('should queue trash deletion jobs', async () => { - assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); + describe('handleQueueAssetOfflineCheck', () => { + it('should queue removal jobs', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); assetMock.getById.mockResolvedValue(assetStub.image1); - await expect(sut.handleRemoveOffline({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION, data: { id: assetStub.image1.id, deleteOnDisk: false } }, + { + name: JobName.LIBRARY_SYNC_ASSET, + data: { + id: assetStub.image1.id, + importPaths: libraryStub.externalLibrary1.importPaths, + exclusionPatterns: libraryStub.externalLibrary1.exclusionPatterns, + }, + }, ]); }); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 3dd81dd613..52b786089c 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { R_OK } from 'node:constants'; -import { Stats } from 'node:fs'; import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; @@ -10,27 +9,26 @@ import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, - ScanLibraryDto, + mapLibrary, UpdateLibraryDto, ValidateLibraryDto, ValidateLibraryImportPathResponseDto, ValidateLibraryResponseDto, - mapLibrary, } from 'src/dtos/library.dto'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { LibraryEntity } from 'src/entities/library.entity'; import { AssetType } from 'src/enum'; -import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; import { - IBaseJob, IEntityJob, IJobRepository, + ILibraryAssetJob, ILibraryFileJob, - ILibraryOfflineJob, - ILibraryRefreshJob, - JOBS_LIBRARY_PAGINATION_SIZE, JobName, + JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, } from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; @@ -78,11 +76,7 @@ export class LibraryService { this.jobRepository.addCronJob( 'libraryScan', scan.cronExpression, - () => - handlePromiseError( - this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }), - this.logger, - ), + () => handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger), scan.enabled, ); @@ -143,7 +137,7 @@ export class LibraryService { const handler = async () => { this.logger.debug(`File add event received for ${path} in library ${library.id}}`); if (matcher(path)) { - await this.scanAssets(library.id, [path], library.ownerId, false); + await this.syncFiles(library, [path]); } }; return handlePromiseError(handler(), this.logger); @@ -151,9 +145,13 @@ export class LibraryService { onChange: (path) => { const handler = async () => { this.logger.debug(`Detected file change for ${path} in library ${library.id}`); + const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); + if (asset) { + await this.syncAssets(library, [asset.id]); + } if (matcher(path)) { // Note: if the changed file was not previously imported, it will be imported now. - await this.scanAssets(library.id, [path], library.ownerId, false); + await this.syncFiles(library, [path]); } }; return handlePromiseError(handler(), this.logger); @@ -162,8 +160,8 @@ export class LibraryService { const handler = async () => { this.logger.debug(`Detected deleted file at ${path} in library ${library.id}`); const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); - if (asset && matcher(path)) { - await this.assetRepository.update({ id: asset.id, isOffline: true }); + if (asset) { + await this.syncAssets(library, [asset.id]); } }; return handlePromiseError(handler(), this.logger); @@ -216,7 +214,7 @@ export class LibraryService { async getStatistics(id: string): Promise { const statistics = await this.repository.getStatistics(id); if (!statistics) { - throw new BadRequestException('Library not found'); + throw new BadRequestException(`Library ${id} not found`); } return statistics; } @@ -250,20 +248,28 @@ export class LibraryService { return mapLibrary(library); } - private async scanAssets(libraryId: string, assetPaths: string[], ownerId: string, force = false) { + private async syncFiles({ id, ownerId }: LibraryEntity, assetPaths: string[]) { await this.jobRepository.queueAll( assetPaths.map((assetPath) => ({ - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { - id: libraryId, + id, assetPath, ownerId, - force, }, })), ); } + private async syncAssets({ importPaths, exclusionPatterns }: LibraryEntity, assetIds: string[]) { + await this.jobRepository.queueAll( + assetIds.map((assetId) => ({ + name: JobName.LIBRARY_SYNC_ASSET, + data: { id: assetId, importPaths, exclusionPatterns }, + })), + ); + } + private async validateImportPath(importPath: string): Promise { const validation = new ValidateLibraryImportPathResponseDto(); validation.importPath = importPath; @@ -366,258 +372,182 @@ export class LibraryService { return JobStatus.SUCCESS; } - async handleAssetRefresh(job: ILibraryFileJob): Promise { + async handleSyncFile(job: ILibraryFileJob): Promise { + // Only needs to handle new assets const assetPath = path.normalize(job.assetPath); - const existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath); - - let stats: Stats; - try { - stats = await this.storageRepository.stat(assetPath); - } catch (error: Error | any) { - // Can't access file, probably offline - if (existingAssetEntity) { - // Mark asset as offline - this.logger.debug(`Marking asset as offline: ${assetPath}`); - - await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: true }); - return JobStatus.SUCCESS; - } else { - // File can't be accessed and does not already exist in db - throw new BadRequestException('Cannot access file', { cause: error }); - } - } - - let doImport = false; - let doRefresh = false; - - if (job.force) { - doRefresh = true; - } - - const originalFileName = parse(assetPath).base; - - if (!existingAssetEntity) { - // This asset is new to us, read it from disk - this.logger.debug(`Importing new asset: ${assetPath}`); - doImport = true; - } else if (stats.mtime.toISOString() !== existingAssetEntity.fileModifiedAt.toISOString()) { - // File modification time has changed since last time we checked, re-read from disk - this.logger.debug( - `File modification time has changed, re-importing asset: ${assetPath}. Old mtime: ${existingAssetEntity.fileModifiedAt}. New mtime: ${stats.mtime}`, - ); - doRefresh = true; - } else if (existingAssetEntity.originalFileName !== originalFileName) { - // TODO: We can likely remove this check in the second half of 2024 when all assets have likely been re-imported by all users - this.logger.debug( - `Asset is missing file extension, re-importing: ${assetPath}. Current incorrect filename: ${existingAssetEntity.originalFileName}.`, - ); - doRefresh = true; - } else if (!job.force && stats && !existingAssetEntity.isOffline) { - // Asset exists on disk and in db and mtime has not changed. Also, we are not forcing refresn. Therefore, do nothing - this.logger.debug(`Asset already exists in database and on disk, will not import: ${assetPath}`); - } - - if (stats && existingAssetEntity?.isOffline) { - // File was previously offline but is now online - this.logger.debug(`Marking previously-offline asset as online: ${assetPath}`); - await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: false }); - doRefresh = true; - } - - if (!doImport && !doRefresh) { - // If we don't import, exit here + let asset = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath); + if (asset) { return JobStatus.SKIPPED; } - let assetType: AssetType; - - if (mimeTypes.isImage(assetPath)) { - assetType = AssetType.IMAGE; - } else if (mimeTypes.isVideo(assetPath)) { - assetType = AssetType.VIDEO; - } else { - throw new BadRequestException(`Unsupported file type ${assetPath}`); + let stat; + try { + stat = await this.storageRepository.stat(assetPath); + } catch (error: any) { + if (error.code === 'ENOENT') { + this.logger.error(`File not found: ${assetPath}`); + return JobStatus.SKIPPED; + } + this.logger.error(`Error reading file: ${assetPath}. Error: ${error}`); + return JobStatus.FAILED; } + this.logger.log(`Importing new library asset: ${assetPath}`); + + const library = await this.repository.get(job.id, true); + if (!library || library.deletedAt) { + this.logger.error('Cannot import asset into deleted library'); + return JobStatus.FAILED; + } + + // TODO: device asset id is deprecated, remove it + const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); + + const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); + // TODO: doesn't xmp replace the file extension? Will need investigation let sidecarPath: string | null = null; if (await this.storageRepository.checkFileExists(`${assetPath}.xmp`, R_OK)) { sidecarPath = `${assetPath}.xmp`; } - // TODO: device asset id is deprecated, remove it - const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); + const assetType = mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE; - let assetId; - if (doImport) { - const library = await this.repository.get(job.id, true); - if (library?.deletedAt) { - this.logger.error('Cannot import asset into deleted library'); - return JobStatus.FAILED; - } + const mtime = stat.mtime; - const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); + asset = await this.assetRepository.create({ + ownerId: job.ownerId, + libraryId: job.id, + checksum: pathHash, + originalPath: assetPath, + deviceAssetId, + deviceId: 'Library Import', + fileCreatedAt: mtime, + fileModifiedAt: mtime, + localDateTime: mtime, + type: assetType, + originalFileName: parse(assetPath).base, - // TODO: In wait of refactoring the domain asset service, this function is just manually written like this - const addedAsset = await this.assetRepository.create({ - ownerId: job.ownerId, - libraryId: job.id, - checksum: pathHash, - originalPath: assetPath, - deviceAssetId, - deviceId: 'Library Import', - fileCreatedAt: stats.mtime, - fileModifiedAt: stats.mtime, - localDateTime: stats.mtime, - type: assetType, - originalFileName, - sidecarPath, - isExternal: true, - }); - assetId = addedAsset.id; - } else if (doRefresh && existingAssetEntity) { - assetId = existingAssetEntity.id; - await this.assetRepository.updateAll([existingAssetEntity.id], { - fileCreatedAt: stats.mtime, - fileModifiedAt: stats.mtime, - originalFileName, - }); - } else { - // Not importing and not refreshing, do nothing - return JobStatus.SKIPPED; - } + sidecarPath, + isExternal: true, + }); - this.logger.debug(`Queueing metadata extraction for: ${assetPath}`); - - await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: assetId, source: 'upload' } }); - - if (assetType === AssetType.VIDEO) { - await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: assetId } }); - } + await this.queuePostSyncJobs(asset); return JobStatus.SUCCESS; } - async queueScan(id: string, dto: ScanLibraryDto) { + async queuePostSyncJobs(asset: AssetEntity) { + this.logger.debug(`Queueing metadata extraction for: ${asset.originalPath}`); + + await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); + + if (asset.type === AssetType.VIDEO) { + await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } }); + } + } + + async queueScan(id: string) { await this.findOrFail(id); await this.jobRepository.queue({ - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id, - refreshModifiedFiles: dto.refreshModifiedFiles ?? false, - refreshAllFiles: dto.refreshAllFiles ?? false, }, }); + await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } }); } - async queueRemoveOffline(id: string) { - this.logger.verbose(`Queueing offline file removal from library ${id}`); - await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } }); - } - - async handleQueueAllScan(job: IBaseJob): Promise { - this.logger.debug(`Refreshing all external libraries: force=${job.force}`); + async handleQueueSyncAll(): Promise { + this.logger.debug(`Refreshing all external libraries`); await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); const libraries = await this.repository.getAll(true); await this.jobRepository.queueAll( libraries.map((library) => ({ - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, + data: { + id: library.id, + }, + })), + ); + await this.jobRepository.queueAll( + libraries.map((library) => ({ + name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id: library.id, - refreshModifiedFiles: !job.force, - refreshAllFiles: job.force ?? false, }, })), ); return JobStatus.SUCCESS; } - async handleOfflineCheck(job: ILibraryOfflineJob): Promise { + async handleSyncAsset(job: ILibraryAssetJob): Promise { const asset = await this.assetRepository.getById(job.id); - if (!asset) { - // Asset is no longer in the database, skip return JobStatus.SKIPPED; } - if (asset.isOffline) { - this.logger.verbose(`Asset is already offline: ${asset.originalPath}`); - return JobStatus.SUCCESS; - } + const markOffline = async (explanation: string) => { + if (!asset.isOffline) { + this.logger.debug(`${explanation}, removing: ${asset.originalPath}`); + await this.assetRepository.updateAll([asset.id], { isOffline: true, deletedAt: new Date() }); + } + }; const isInPath = job.importPaths.find((path) => asset.originalPath.startsWith(path)); if (!isInPath) { - this.logger.debug(`Asset is no longer in an import path, marking offline: ${asset.originalPath}`); - await this.assetRepository.update({ id: asset.id, isOffline: true }); + await markOffline('Asset is no longer in an import path'); return JobStatus.SUCCESS; } const isExcluded = job.exclusionPatterns.some((pattern) => picomatch.isMatch(asset.originalPath, pattern)); if (isExcluded) { - this.logger.debug(`Asset is covered by an exclusion pattern, marking offline: ${asset.originalPath}`); - await this.assetRepository.update({ id: asset.id, isOffline: true }); + await markOffline('Asset is covered by an exclusion pattern'); return JobStatus.SUCCESS; } - const fileExists = await this.storageRepository.checkFileExists(asset.originalPath, R_OK); - if (!fileExists) { - this.logger.debug(`Asset is no longer found on disk, marking offline: ${asset.originalPath}`); - await this.assetRepository.update({ id: asset.id, isOffline: true }); + let stat; + try { + stat = await this.storageRepository.stat(asset.originalPath); + } catch { + await markOffline('Asset is no longer on disk or is inaccessible because of permissions'); return JobStatus.SUCCESS; } - this.logger.verbose( - `Asset is found on disk, not covered by an exclusion pattern, and is in an import path, keeping online: ${asset.originalPath}`, - ); + const mtime = stat.mtime; + const isAssetModified = mtime.toISOString() !== asset.fileModifiedAt.toISOString(); + if (asset.isOffline || isAssetModified) { + this.logger.debug(`Asset was offline or modified, updating asset record ${asset.originalPath}`); + //TODO: When we have asset status, we need to leave deletedAt as is when status is trashed + await this.assetRepository.updateAll([asset.id], { + isOffline: false, + deletedAt: null, + fileCreatedAt: mtime, + fileModifiedAt: mtime, + originalFileName: parse(asset.originalPath).base, + }); + } + + if (isAssetModified) { + this.logger.debug(`Asset was modified, queuing metadata extraction for: ${asset.originalPath}`); + await this.queuePostSyncJobs(asset); + } return JobStatus.SUCCESS; } - async handleRemoveOffline(job: IEntityJob): Promise { - this.logger.debug(`Removing offline assets for library ${job.id}`); - - const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => - this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id, true), - ); - - let offlineAssets = 0; - for await (const assets of assetPagination) { - offlineAssets += assets.length; - if (assets.length > 0) { - this.logger.debug(`Discovered ${offlineAssets} offline assets in library ${job.id}`); - await this.jobRepository.queueAll( - assets.map((asset) => ({ - name: JobName.ASSET_DELETION, - data: { - id: asset.id, - deleteOnDisk: false, - }, - })), - ); - this.logger.verbose(`Queued deletion of ${assets.length} offline assets in library ${job.id}`); - } - } - - if (offlineAssets) { - this.logger.debug(`Finished queueing deletion of ${offlineAssets} offline assets for library ${job.id}`); - } else { - this.logger.debug(`Found no offline assets to delete from library ${job.id}`); - } - - return JobStatus.SUCCESS; - } - - async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise { + async handleQueueSyncFiles(job: IEntityJob): Promise { const library = await this.repository.get(job.id); if (!library) { + this.logger.debug(`Library ${job.id} not found, skipping refresh`); return JobStatus.SKIPPED; } - this.logger.log(`Refreshing library ${library.id}`); + this.logger.log(`Refreshing library ${library.id} for new assets`); const validImportPaths: string[] = []; @@ -630,55 +560,66 @@ export class LibraryService { } } - if (validImportPaths.length === 0) { + if (validImportPaths) { + const assetsOnDisk = this.storageRepository.walk({ + pathsToCrawl: validImportPaths, + includeHidden: false, + exclusionPatterns: library.exclusionPatterns, + take: JOBS_LIBRARY_PAGINATION_SIZE, + }); + + let count = 0; + + for await (const assetBatch of assetsOnDisk) { + count += assetBatch.length; + this.logger.debug(`Discovered ${count} asset(s) on disk for library ${library.id}...`); + await this.syncFiles(library, assetBatch); + this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`); + } + + if (count > 0) { + this.logger.debug(`Finished queueing scan of ${count} assets on disk for library ${library.id}`); + } else { + this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); + } + } else { this.logger.warn(`No valid import paths found for library ${library.id}`); } - const assetsOnDisk = this.storageRepository.walk({ - pathsToCrawl: validImportPaths, - includeHidden: false, - exclusionPatterns: library.exclusionPatterns, - take: JOBS_LIBRARY_PAGINATION_SIZE, - }); + await this.repository.update({ id: job.id, refreshedAt: new Date() }); - let crawledAssets = 0; + return JobStatus.SUCCESS; + } - for await (const assetBatch of assetsOnDisk) { - crawledAssets += assetBatch.length; - this.logger.debug(`Discovered ${crawledAssets} asset(s) on disk for library ${library.id}...`); - await this.scanAssets(job.id, assetBatch, library.ownerId, job.refreshAllFiles ?? false); - this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`); + async handleQueueSyncAssets(job: IEntityJob): Promise { + const library = await this.repository.get(job.id); + if (!library) { + return JobStatus.SKIPPED; } - if (crawledAssets) { - this.logger.debug(`Finished queueing scan of ${crawledAssets} assets on disk for library ${library.id}`); - } else { - this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); - } + this.logger.log(`Scanning library ${library.id} for removed assets`); const onlineAssets = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => - this.assetRepository.getWith(pagination, WithProperty.IS_ONLINE, job.id), + this.assetRepository.getAll(pagination, { libraryId: job.id }), ); - let onlineAssetCount = 0; + let assetCount = 0; for await (const assets of onlineAssets) { - onlineAssetCount += assets.length; - this.logger.debug(`Discovered ${onlineAssetCount} asset(s) in library ${library.id}...`); + assetCount += assets.length; + this.logger.debug(`Discovered ${assetCount} asset(s) in library ${library.id}...`); await this.jobRepository.queueAll( assets.map((asset) => ({ - name: JobName.LIBRARY_CHECK_OFFLINE, - data: { id: asset.id, importPaths: validImportPaths, exclusionPatterns: library.exclusionPatterns }, + name: JobName.LIBRARY_SYNC_ASSET, + data: { id: asset.id, importPaths: library.importPaths, exclusionPatterns: library.exclusionPatterns }, })), ); - this.logger.debug(`Queued online check of ${assets.length} asset(s) in library ${library.id}...`); + this.logger.debug(`Queued check of ${assets.length} asset(s) in library ${library.id}...`); } - if (onlineAssetCount) { - this.logger.log(`Finished queueing online check of ${onlineAssetCount} assets for library ${library.id}`); + if (assetCount) { + this.logger.log(`Finished queueing check of ${assetCount} assets for library ${library.id}`); } - await this.repository.update({ id: job.id, refreshedAt: new Date() }); - return JobStatus.SUCCESS; } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 25bfc0fdd2..80f1b2be41 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -86,12 +86,12 @@ export class MicroservicesService { [JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data), [JobName.SIDECAR_SYNC]: (data) => this.metadataService.handleSidecarSync(data), [JobName.SIDECAR_WRITE]: (data) => this.metadataService.handleSidecarWrite(data), - [JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data), - [JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data), + [JobName.LIBRARY_QUEUE_SYNC_ALL]: () => this.libraryService.handleQueueSyncAll(), + [JobName.LIBRARY_QUEUE_SYNC_FILES]: (data) => this.libraryService.handleQueueSyncFiles(data), //Queues all files paths on disk + [JobName.LIBRARY_SYNC_FILE]: (data) => this.libraryService.handleSyncFile(data), //Handles a single path on disk //Watcher calls for new files + [JobName.LIBRARY_QUEUE_SYNC_ASSETS]: (data) => this.libraryService.handleQueueSyncAssets(data), //Queues all library assets + [JobName.LIBRARY_SYNC_ASSET]: (data) => this.libraryService.handleSyncAsset(data), //Handles all library assets // Watcher calls for unlink and changed [JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data), - [JobName.LIBRARY_CHECK_OFFLINE]: (data) => this.libraryService.handleOfflineCheck(data), - [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleRemoveOffline(data), - [JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data), [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), [JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data), [JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data), diff --git a/server/src/services/trash.service.spec.ts b/server/src/services/trash.service.spec.ts index 87821f028a..d0c719ae48 100644 --- a/server/src/services/trash.service.spec.ts +++ b/server/src/services/trash.service.spec.ts @@ -67,7 +67,7 @@ describe(TrashService.name, () => { }); it('should restore', async () => { - trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false }); + trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false }); trashMock.restore.mockResolvedValue(1); await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 }); expect(trashMock.restore).toHaveBeenCalledWith('user-id'); @@ -83,7 +83,7 @@ describe(TrashService.name, () => { }); it('should empty the trash', async () => { - trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false }); + trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false }); trashMock.empty.mockResolvedValue(1); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 }); expect(trashMock.empty).toHaveBeenCalledWith('user-id'); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index f3232eb78b..5f4577f4df 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -80,7 +80,7 @@ export function searchAssetBuilder( }); } - const status = _.pick(options, ['isFavorite', 'isOffline', 'isVisible', 'type']); + const status = _.pick(options, ['isFavorite', 'isVisible', 'type']); const { isArchived, isEncoded, diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index a9b5167909..119c0b6e5a 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -70,9 +70,9 @@ export const assetStub = { faces: [], sidecarPath: null, deletedAt: null, - isOffline: false, isExternal: false, duplicateId: null, + isOffline: false, }), noWebpPath: Object.freeze({ @@ -104,13 +104,13 @@ export const assetStub = { originalFileName: 'IMG_456.jpg', faces: [], sidecarPath: null, - isOffline: false, isExternal: false, exifInfo: { fileSizeInByte: 123_000, } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), noThumbhash: Object.freeze({ @@ -133,7 +133,6 @@ export const assetStub = { localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, isArchived: false, - isOffline: false, duration: null, isVisible: true, isExternal: false, @@ -146,6 +145,7 @@ export const assetStub = { sidecarPath: null, deletedAt: null, duplicateId: null, + isOffline: false, }), primaryImage: Object.freeze({ @@ -173,7 +173,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -191,6 +190,7 @@ export const assetStub = { { id: 'stack-child-asset-2' } as AssetEntity, ]), duplicateId: null, + isOffline: false, }), image: Object.freeze({ @@ -218,7 +218,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -231,9 +230,50 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: false, }), trashed: Object.freeze({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + files, + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + deletedAt: new Date('2023-02-24T05:06:29.716Z'), + localDateTime: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: false, + isArchived: false, + duration: null, + isVisible: true, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + exifImageHeight: 3840, + exifImageWidth: 2160, + } as ExifEntity, + duplicateId: null, + isOffline: false, + status: AssetStatus.TRASHED, + }), + + trashedOffline: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', @@ -259,7 +299,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -271,8 +310,8 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: true, }), - archived: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, @@ -298,7 +337,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -311,6 +349,7 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: false, }), external: Object.freeze({ @@ -338,97 +377,19 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, + libraryId: 'library-id', + library: libraryStub.externalLibrary1, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + } as ExifEntity, + duplicateId: null, isOffline: false, - libraryId: 'library-id', - library: libraryStub.externalLibrary1, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - duplicateId: null, - }), - - offline: Object.freeze({ - id: 'asset-id', - status: AssetStatus.ACTIVE, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.jpg', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - isExternal: false, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - isOffline: true, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - deletedAt: null, - duplicateId: null, - }), - - externalOffline: Object.freeze({ - id: 'asset-id', - status: AssetStatus.ACTIVE, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/user1/photo.jpg', - checksum: Buffer.from('path hash', 'utf8'), - type: AssetType.IMAGE, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - isExternal: true, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - isOffline: true, - libraryId: 'library-id', - library: libraryStub.externalLibrary1, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - deletedAt: null, - duplicateId: null, }), image1: Object.freeze({ @@ -457,7 +418,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, isExternal: false, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', @@ -467,6 +427,7 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), imageFrom2015: Object.freeze({ @@ -490,7 +451,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -505,6 +465,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), video: Object.freeze({ @@ -529,7 +490,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -545,6 +505,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), livePhotoMotionAsset: Object.freeze({ @@ -664,7 +625,6 @@ export const assetStub = { isFavorite: false, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -683,6 +643,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), sidecar: Object.freeze({ id: 'asset-id', @@ -705,7 +666,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -717,6 +677,7 @@ export const assetStub = { sidecarPath: '/original/path.ext.xmp', deletedAt: null, duplicateId: null, + isOffline: false, }), sidecarWithoutExt: Object.freeze({ id: 'asset-id', @@ -739,7 +700,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -751,41 +711,7 @@ export const assetStub = { sidecarPath: '/original/path.xmp', deletedAt: null, duplicateId: null, - }), - - readOnly: Object.freeze({ - id: 'read-only-asset', - status: AssetStatus.ACTIVE, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - thumbhash: null, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, - files: [previewFile], - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - isExternal: false, isOffline: false, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - sidecarPath: '/original/path.ext.xmp', - deletedAt: null, - duplicateId: null, }), hasEncodedVideo: Object.freeze({ @@ -810,7 +736,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -824,6 +749,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), missingFileExtension: Object.freeze({ id: 'asset-id', @@ -850,7 +776,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, libraryId: 'library-id', library: libraryStub.externalLibrary1, tags: [], @@ -863,6 +788,7 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), hasFileExtension: Object.freeze({ id: 'asset-id', @@ -889,7 +815,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, libraryId: 'library-id', library: libraryStub.externalLibrary1, tags: [], @@ -902,6 +827,7 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), imageDng: Object.freeze({ id: 'asset-id', @@ -928,7 +854,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -941,6 +866,7 @@ export const assetStub = { bitsPerSample: 14, } as ExifEntity, duplicateId: null, + isOffline: false, }), hasEmbedding: Object.freeze({ id: 'asset-id-embedding', @@ -967,7 +893,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -982,6 +907,7 @@ export const assetStub = { assetId: 'asset-id', embedding: Array.from({ length: 512 }, Math.random), }, + isOffline: false, }), hasDupe: Object.freeze({ id: 'asset-id-dupe', @@ -1008,7 +934,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -1023,5 +948,6 @@ export const assetStub = { assetId: 'asset-id', embedding: Array.from({ length: 512 }, Math.random), }, + isOffline: false, }), }; diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 9ac568af30..ba2f5e10d9 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -25,7 +25,6 @@ export const newAssetRepositoryMock = (): Mocked => { getLivePhotoCount: vitest.fn(), updateAll: vitest.fn(), updateDuplicates: vitest.fn(), - getExternalLibraryAssetPaths: vitest.fn(), getByLibraryIdAndOriginalPath: vitest.fn(), deleteAll: vitest.fn(), update: vitest.fn(), diff --git a/server/vitest.config.mjs b/server/vitest.config.mjs index 3c0ea00c84..1013b4606d 100644 --- a/server/vitest.config.mjs +++ b/server/vitest.config.mjs @@ -13,7 +13,7 @@ export default defineConfig({ lines: 80, statements: 80, branches: 85, - functions: 85, + functions: 80, }, }, server: { diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index db216641d5..d19b428750 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -59,7 +59,6 @@ export let onClose: () => void; const sharedLink = getSharedLink(); - $: isOwner = $user && asset.ownerId === $user?.id; $: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; // $: showEditorButton = @@ -87,7 +86,7 @@ {/if} {#if asset.isOffline} - + {/if} {#if asset.livePhotoVideoId} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 9e32927fc3..88ea98778f 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -148,12 +148,21 @@ {#if asset.isOffline}

    -
    {$t('asset_offline')}
    -
    +
    + {$t('asset_offline')} +
    +

    - {$t('asset_offline_description')} + {#if $user?.isAdmin} +

    {$t('admin.asset_offline_description')}

    + {:else} + {$t('asset_offline_description')} + {/if}

    +
    +

    {asset.originalPath}

    +
    {/if} diff --git a/web/src/lib/components/elements/buttons/circle-icon-button.svelte b/web/src/lib/components/elements/buttons/circle-icon-button.svelte index 76f962f107..8af3f75ade 100644 --- a/web/src/lib/components/elements/buttons/circle-icon-button.svelte +++ b/web/src/lib/components/elements/buttons/circle-icon-button.svelte @@ -1,7 +1,7 @@ @@ -508,6 +508,7 @@ onNextAsset={() => navigateAsset('next')} on:close={closeViewer} {sharedLink} + haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition} /> {/if} {:else} diff --git a/web/src/lib/components/slideshow-settings.svelte b/web/src/lib/components/slideshow-settings.svelte index e2bf6a4b2c..6f0397be98 100644 --- a/web/src/lib/components/slideshow-settings.svelte +++ b/web/src/lib/components/slideshow-settings.svelte @@ -18,7 +18,7 @@ import SettingDropdown from './shared-components/settings/setting-dropdown.svelte'; import { t } from 'svelte-i18n'; - const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook } = slideshowStore; + const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook, slideshowTransition } = slideshowStore; export let onClose = () => {}; @@ -65,6 +65,7 @@ }} /> + ('slideshow-show-progressbar', true); const slideshowDelay = persisted('slideshow-delay', 5, {}); + const slideshowTransition = persisted('slideshow-transition', true); return { restartProgress: { @@ -67,6 +68,7 @@ function createSlideshowStore() { slideshowState, slideshowDelay, showProgressBar, + slideshowTransition, }; } From 03aa34602040ff075cb144b97c19473442c3f0cb Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 27 Sep 2024 22:28:31 +0700 Subject: [PATCH 454/723] fix(mobile): incorrect filename is retrieved during upload (#12990) * fix(mobile): incorrect filename is retrieve during upload * use the same convention to get local id * revert previous change * pr feedback --- mobile/lib/interfaces/asset_media.interface.dart | 3 +++ mobile/lib/repositories/asset_media.repository.dart | 13 +++++++++++++ mobile/lib/services/background.service.dart | 3 +++ mobile/lib/services/backup.service.dart | 9 ++++++++- 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/mobile/lib/interfaces/asset_media.interface.dart b/mobile/lib/interfaces/asset_media.interface.dart index f89a238dd4..2606d5c23c 100644 --- a/mobile/lib/interfaces/asset_media.interface.dart +++ b/mobile/lib/interfaces/asset_media.interface.dart @@ -4,4 +4,7 @@ abstract interface class IAssetMediaRepository { Future> deleteAll(List ids); Future get(String id); + + /// Obtaining the correct original filename of the asset + Future getOriginalFilename(String id); } diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index 20cf680339..68fffa08a6 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -43,4 +43,17 @@ class AssetMediaRepository implements IAssetMediaRepository { asset.local = local; return asset; } + + @override + Future getOriginalFilename(String id) async { + final entity = await AssetEntity.fromId(id); + + if (entity == null) { + return null; + } + + // titleAsync gets the correct original filename for some assets on iOS + // otherwise using the `entity.title` would return a random GUID + return await entity.titleAsync; + } } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index d06bc86d48..86dfd0c599 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; @@ -368,6 +369,7 @@ class BackgroundService { BackupRepository backupAlbumRepository = BackupRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); + AssetMediaRepository assetMediaRepository = AssetMediaRepository(); UserRepository userRepository = UserRepository(db); UserApiRepository userApiRepository = UserApiRepository(apiService.usersApi); @@ -409,6 +411,7 @@ class BackgroundService { albumService, albumMediaRepository, fileMediaRepository, + assetMediaRepository, ); final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 19d731d773..683339f271 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -21,6 +22,7 @@ import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -40,6 +42,7 @@ final backupServiceProvider = Provider( ref.watch(albumServiceProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(assetMediaRepositoryProvider), ), ); @@ -52,6 +55,7 @@ class BackupService { final AlbumService _albumService; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; + final IAssetMediaRepository _assetMediaRepository; BackupService( this._apiService, @@ -60,6 +64,7 @@ class BackupService { this._albumService, this._albumMediaRepository, this._fileMediaRepository, + this._assetMediaRepository, ); Future?> getDeviceBackupAsset() async { @@ -329,7 +334,9 @@ class BackupService { } if (file != null) { - String originalFileName = asset.fileName; + String? originalFileName = + await _assetMediaRepository.getOriginalFilename(asset.localId!); + originalFileName ??= asset.fileName; if (asset.local!.isLivePhoto) { if (livePhotoFile == null) { From 7c15e11efccc1f6f4d7c1da12e932ae5fc058838 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 15:32:16 +0000 Subject: [PATCH 455/723] chore: version v1.116.1 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index c66d663576..73f0e405ba 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.21", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index ba2f846822..f28bbe130f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.21", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 992aaa6d4b..9fc474c729 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.116.1", + "url": "https://v1.116.1.archive.immich.app" + }, { "label": "v1.116.0", "url": "https://v1.116.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 63ad7be469..b451e5dacf 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.116.0", + "version": "1.116.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.21", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 80bf261a03..38d671d9d5 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.116.0", + "version": "1.116.1", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 8d1539a79b..1f953b8827 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.116.0" +version = "1.116.1" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 6a6454bfe9..43d643d2f6 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 160, - "android.injected.version.name" => "1.116.0", + "android.injected.version.code" => 161, + "android.injected.version.name" => "1.116.1", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 1cc5524c40..a9382cb969 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.116.0" + version_number: "1.116.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9f2261e03d..e5280e3139 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.116.0 +- API version: 1.116.1 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a219b6ddb1..ac8294a0a6 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.116.0+160 +version: 1.116.1+161 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bb0aa83009..b2682dd95a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7409,7 +7409,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.116.0", + "version": "1.116.1", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 3ab9ac0583..95bbddc507 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 45a1fada32..3226f63b19 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 63597d49bc..bf2721f848 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.116.0 + * 1.116.1 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 57c8dd7146..53c34aeb32 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.116.0", + "version": "1.116.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 8ba20f6b3b..3817bd5d01 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.116.0", + "version": "1.116.1", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 172c315570..6a6baca4c2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.116.0", + "version": "1.116.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,7 +74,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 938b4dc9cf..9b8d356840 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From dbe542803f6e05b3cc878797677c18bc9739cae6 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 27 Sep 2024 19:07:00 +0200 Subject: [PATCH 456/723] docs: update FAQ CLIP search explanation (#12986) --- docs/docs/FAQ.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index 3144b1b9a8..b328d3a047 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -187,7 +187,7 @@ However, when the trash is emptied, the files will re-appear in the main timelin ### How does smart search work? -Immich uses CLIP models. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip). +Immich uses CLIP models. An ML model converts each image to an "embedding", which is essentially a string of numbers that semantically encodes what is in the image. The same is done for the text that you enter when you do a search, and that text embedding is then compared with those of the images to find similar ones. As such, there are no "tags", "labels", or "descriptions" generated that you can look at. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip). ### How does facial recognition work? From 789937d4a2409601c35120dff9e454fd735c6a76 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 27 Sep 2024 18:15:44 +0100 Subject: [PATCH 457/723] fix: library pagination to 10k to avoid too many postgres query params (#12993) --- server/src/interfaces/job.interface.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 8b6e2c289b..af2726b858 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -116,7 +116,7 @@ export enum JobName { } export const JOBS_ASSET_PAGINATION_SIZE = 1000; -export const JOBS_LIBRARY_PAGINATION_SIZE = 100_000; +export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000; export interface IBaseJob { force?: boolean; From 4ed1517e6032839b0bbc062a93b19fbb34e4758e Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 28 Sep 2024 01:13:24 +0700 Subject: [PATCH 458/723] chore(mobile): post release task (#12991) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 241cb8ecd9..70bddbf10b 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -401,7 +401,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 177; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 177; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -571,7 +571,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 177; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 14fc27b56d..b684804037 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.116.0 + 1.116.1 CFBundleSignature ???? CFBundleVersion - 176 + 177 FLTEnableImpeller ITSAppUsesNonExemptEncryption From 8bbcd5c31e4a227f92864ae2977c4033bc0c50b7 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 18:17:49 +0000 Subject: [PATCH 459/723] chore: version v1.116.2 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 2 +- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 30 insertions(+), 26 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 73f0e405ba..e508fe843f 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.21", + "version": "2.2.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.21", + "version": "2.2.22", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index f28bbe130f..522a8e593e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.21", + "version": "2.2.22", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 9fc474c729..36a8fed81d 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.116.2", + "url": "https://v1.116.2.archive.immich.app" + }, { "label": "v1.116.1", "url": "https://v1.116.1.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index b451e5dacf..e7b463b0b2 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.116.1", + "version": "1.116.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.21", + "version": "2.2.22", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 38d671d9d5..7c0025902d 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.116.1", + "version": "1.116.2", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 1f953b8827..840aa93c06 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.116.1" +version = "1.116.2" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 43d643d2f6..d1f09a011f 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -36,7 +36,7 @@ platform :android do build_type: 'Release', properties: { "android.injected.version.code" => 161, - "android.injected.version.name" => "1.116.1", + "android.injected.version.name" => "1.116.2", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index a9382cb969..8dc3676fb7 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.116.1" + version_number: "1.116.2" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e5280e3139..fecbbf482b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.116.1 +- API version: 1.116.2 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index ac8294a0a6..dc1eb11ca7 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.116.1+161 +version: 1.116.2+161 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b2682dd95a..6afd0d792f 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7409,7 +7409,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.116.1", + "version": "1.116.2", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 95bbddc507..72d7a3ec54 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 3226f63b19..41bc3a3b16 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index bf2721f848..b1ae5d2876 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.116.1 + * 1.116.2 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 53c34aeb32..646a26b1ee 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.116.1", + "version": "1.116.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 3817bd5d01..d481610906 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.116.1", + "version": "1.116.2", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 6a6baca4c2..a32e96e67f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.116.1", + "version": "1.116.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,7 +74,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 9b8d356840..20553759fa 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 7579bc43591dd72bb84b8426786f7834e76e2844 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 22:07:59 +0000 Subject: [PATCH 460/723] fix(deps): update machine-learning (#12883) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/Dockerfile | 4 +- machine-learning/export/Dockerfile | 2 +- machine-learning/poetry.lock | 67 ++++++++++++++---------------- 3 files changed, 35 insertions(+), 38 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index e394091ae1..d982962fbc 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:157a371e60389919fe4a72dff71ce86eaa5234f59114c23b0b346d0d02c74d39 AS builder-cpu +FROM python:3.11-bookworm@sha256:e456ff58048f52f121025159e68bf16248c4122c8b96fadffd89331df50c9994 AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:669bbd08353610485a94d5d0c976b4b6498c55280fe42c00f7581f85ee9f3121 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:585cf0799407efc267fe1cce318322ec26e015ac1b3d77f2517d50bc3acfc232 AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index 0754f882f3..195e64ab35 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:5f32c5742e2248f2ca07ccae6861371321aba37372bf8e1a80d6f728f1ab4418 AS builder +FROM mambaorg/micromamba:bookworm-slim@sha256:e3797091302382ea841498bc93a7b0a50f7c1448333d5e946d2d1608d0c5f43d AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 84c9ae5d31..5bb1726378 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -680,13 +680,13 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi-slim" -version = "0.114.2" +version = "0.115.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_slim-0.114.2-py3-none-any.whl", hash = "sha256:52ae76c53a30ad0fa96beb84c1bf4bef9c72e88c2f7c0473e836f01d7ac3ca6b"}, - {file = "fastapi_slim-0.114.2.tar.gz", hash = "sha256:76d0a450826fb0fa740268be55ef04c44807da87a94fbbf5f16338b5a4a2d321"}, + {file = "fastapi_slim-0.115.0-py3-none-any.whl", hash = "sha256:27ab44da95b622e68be7a19f06df1960a320b9d94e689b0adfc055bb26ee9be7"}, + {file = "fastapi_slim-0.115.0.tar.gz", hash = "sha256:b4b962ca2aa0a31010dafdad3d4da99d368a5591223304c6fb385712fad7feb6"}, ] [package.dependencies] @@ -2037,22 +2037,22 @@ reference = "cuda12" [[package]] name = "onnxruntime-openvino" -version = "1.18.0" +version = "1.19.0" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = "*" files = [ - {file = "onnxruntime_openvino-1.18.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:565b874d21bcd48126da7d62f57db019f5ec0e1f82ae9b0740afa2ad91f8d331"}, - {file = "onnxruntime_openvino-1.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:7f1931060f710a6c8e32121bb73044c4772ef5925802fc8776d3fe1e87ab3f75"}, - {file = "onnxruntime_openvino-1.18.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb1723d386f70a8e26398d983ebe35d2c25ba56e9cdb382670ebbf1f5139f8ba"}, - {file = "onnxruntime_openvino-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:874a1e263dd86674593e5a879257650b06a8609c4d5768c3d8ed8dc4ae874b9c"}, - {file = "onnxruntime_openvino-1.18.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:597eb18f3de7ead69b08a242d74c4573b28bbfba40ca2a1a40f75bf7a834808e"}, + {file = "onnxruntime_openvino-1.19.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8c5658da819b26d9f35f95204e1bdfb74a100a7533e74edab3af6316c1e316e8"}, + {file = "onnxruntime_openvino-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb8de2a60cf78db6e201b0a489479995d166938e9c53b01ff342dc7f5f8251ff"}, + {file = "onnxruntime_openvino-1.19.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f3a0b954026286421b3a769c746c403e8f141f3887d1dd601beb7c4dbf77488a"}, + {file = "onnxruntime_openvino-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:12330922ecdb694ea28dbdcf08c172e47a5a84fee603040691341336ee3e42bc"}, + {file = "onnxruntime_openvino-1.19.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:be00502b1a46ba1891cbe49049033745f71c0b99df6d24b979f5b4084b9567d0"}, ] [package.dependencies] coloredlogs = "*" flatbuffers = "*" -numpy = ">=1.26.4" +numpy = ">=1.21.6" packaging = "*" protobuf = "*" sympy = "*" @@ -2576,18 +2576,15 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.9" +version = "0.0.10" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.8" files = [ - {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, - {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, + {file = "python_multipart-0.0.10-py3-none-any.whl", hash = "sha256:2b06ad9e8d50c7a8db80e3b56dab590137b323410605af2be20d62a5f1ba1dc8"}, + {file = "python_multipart-0.0.10.tar.gz", hash = "sha256:46eb3c6ce6fdda5fb1a03c7e11d490e407c6930a2703fe7aef4da71c374688fa"}, ] -[package.extras] -dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] - [[package]] name = "pywin32" version = "306" @@ -2834,29 +2831,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.6.6" +version = "0.6.8" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.6.6-py3-none-linux_armv6l.whl", hash = "sha256:f5bc5398457484fc0374425b43b030e4668ed4d2da8ee7fdda0e926c9f11ccfb"}, - {file = "ruff-0.6.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:515a698254c9c47bb84335281a170213b3ee5eb47feebe903e1be10087a167ce"}, - {file = "ruff-0.6.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6bb1b4995775f1837ab70f26698dd73852bbb82e8f70b175d2713c0354fe9182"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69c546f412dfae8bb9cc4f27f0e45cdd554e42fecbb34f03312b93368e1cd0a6"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59627e97364329e4eae7d86fa7980c10e2b129e2293d25c478ebcb861b3e3fd6"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94c3f78c3d32190aafbb6bc5410c96cfed0a88aadb49c3f852bbc2aa9783a7d8"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:704da526c1e137f38c8a067a4a975fe6834b9f8ba7dbc5fd7503d58148851b8f"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efeede5815a24104579a0f6320660536c5ffc1c91ae94f8c65659af915fb9de9"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e368aef0cc02ca3593eae2fb8186b81c9c2b3f39acaaa1108eb6b4d04617e61f"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2653fc3b2a9315bd809725c88dd2446550099728d077a04191febb5ea79a4f79"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bb858cd9ce2d062503337c5b9784d7b583bcf9d1a43c4df6ccb5eab774fbafcb"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:488f8e15c01ea9afb8c0ba35d55bd951f484d0c1b7c5fd746ce3c47ccdedce68"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aefb0bd15f1cfa4c9c227b6120573bb3d6c4ee3b29fb54a5ad58f03859bc43c6"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a4c0698cc780bcb2c61496cbd56b6a3ac0ad858c966652f7dbf4ceb029252fbe"}, - {file = "ruff-0.6.6-py3-none-win32.whl", hash = "sha256:aadf81ddc8ab5b62da7aae78a91ec933cbae9f8f1663ec0325dae2c364e4ad84"}, - {file = "ruff-0.6.6-py3-none-win_amd64.whl", hash = "sha256:0adb801771bc1f1b8cf4e0a6fdc30776e7c1894810ff3b344e50da82ef50eeb1"}, - {file = "ruff-0.6.6-py3-none-win_arm64.whl", hash = "sha256:4b4d32c137bc781c298964dd4e52f07d6f7d57c03eae97a72d97856844aa510a"}, - {file = "ruff-0.6.6.tar.gz", hash = "sha256:0fc030b6fd14814d69ac0196396f6761921bd20831725c7361e1b8100b818034"}, + {file = "ruff-0.6.8-py3-none-linux_armv6l.whl", hash = "sha256:77944bca110ff0a43b768f05a529fecd0706aac7bcce36d7f1eeb4cbfca5f0f2"}, + {file = "ruff-0.6.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27b87e1801e786cd6ede4ada3faa5e254ce774de835e6723fd94551464c56b8c"}, + {file = "ruff-0.6.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd48f945da2a6334f1793d7f701725a76ba93bf3d73c36f6b21fb04d5338dcf5"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:677e03c00f37c66cea033274295a983c7c546edea5043d0c798833adf4cf4c6f"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f1476236b3eacfacfc0f66aa9e6cd39f2a624cb73ea99189556015f27c0bdeb"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f5a2f17c7d32991169195d52a04c95b256378bbf0de8cb98478351eb70d526f"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5fd0d4b7b1457c49e435ee1e437900ced9b35cb8dc5178921dfb7d98d65a08d0"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8034b19b993e9601f2ddf2c517451e17a6ab5cdb1c13fdff50c1442a7171d87"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cfb227b932ba8ef6e56c9f875d987973cd5e35bc5d05f5abf045af78ad8e098"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef0411eccfc3909269fed47c61ffebdcb84a04504bafa6b6df9b85c27e813b0"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:007dee844738c3d2e6c24ab5bc7d43c99ba3e1943bd2d95d598582e9c1b27750"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ce60058d3cdd8490e5e5471ef086b3f1e90ab872b548814e35930e21d848c9ce"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1085c455d1b3fdb8021ad534379c60353b81ba079712bce7a900e834859182fa"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:70edf6a93b19481affd287d696d9e311388d808671bc209fb8907b46a8c3af44"}, + {file = "ruff-0.6.8-py3-none-win32.whl", hash = "sha256:792213f7be25316f9b46b854df80a77e0da87ec66691e8f012f887b4a671ab5a"}, + {file = "ruff-0.6.8-py3-none-win_amd64.whl", hash = "sha256:ec0517dc0f37cad14a5319ba7bba6e7e339d03fbf967a6d69b0907d61be7a263"}, + {file = "ruff-0.6.8-py3-none-win_arm64.whl", hash = "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc"}, + {file = "ruff-0.6.8.tar.gz", hash = "sha256:a5bf44b1aa0adaf6d9d20f86162b34f7c593bfedabc51239953e446aefc8ce18"}, ] [[package]] From 4248594ac55c2adfcb84918c69ae29d351ca19b3 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 27 Sep 2024 18:10:39 -0400 Subject: [PATCH 461/723] feat(server): better transcoding logs (#13000) * better transcoding logs * pr feedback --- server/src/interfaces/logger.interface.ts | 1 + server/src/interfaces/media.interface.ts | 10 +- server/src/repositories/media.repository.ts | 62 ++- server/src/services/media.service.spec.ts | 405 ++++++++++-------- server/src/services/media.service.ts | 37 +- server/src/utils/media.ts | 1 + .../repositories/logger.repository.mock.ts | 2 +- 7 files changed, 308 insertions(+), 210 deletions(-) diff --git a/server/src/interfaces/logger.interface.ts b/server/src/interfaces/logger.interface.ts index ce9a8e64fe..42523afa6b 100644 --- a/server/src/interfaces/logger.interface.ts +++ b/server/src/interfaces/logger.interface.ts @@ -6,6 +6,7 @@ export interface ILoggerRepository { setAppName(name: string): void; setContext(message: string): void; setLogLevel(level: LogLevel): void; + isLevelEnabled(level: LogLevel): boolean; verbose(message: any, ...args: any): void; debug(message: any, ...args: any): void; diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 459e33fc36..7193684e7a 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -62,6 +62,10 @@ export interface TranscodeCommand { inputOptions: string[]; outputOptions: string[]; twoPass: boolean; + progress: { + frameCount: number; + percentInterval: number; + }; } export interface BitrateDistribution { @@ -79,6 +83,10 @@ export interface VideoCodecHWConfig extends VideoCodecSWConfig { getSupportedCodecs(): Array; } +export interface ProbeOptions { + countFrames: boolean; +} + export interface IMediaRepository { // image extract(input: string, output: string): Promise; @@ -87,6 +95,6 @@ export interface IMediaRepository { getImageDimensions(input: string): Promise; // video - probe(input: string): Promise; + probe(input: string, options?: ProbeOptions): Promise; transcode(input: string, output: string | Writable, command: TranscodeCommand): Promise; } diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 5d1aced5eb..d001aa3158 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -1,15 +1,16 @@ import { Inject, Injectable } from '@nestjs/common'; import { exiftool } from 'exiftool-vendored'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; +import { Duration } from 'luxon'; import fs from 'node:fs/promises'; import { Writable } from 'node:stream'; -import { promisify } from 'node:util'; import sharp from 'sharp'; -import { Colorspace } from 'src/enum'; +import { Colorspace, LogLevel } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMediaRepository, ImageDimensions, + ProbeOptions, ThumbnailOptions, TranscodeCommand, VideoInfo, @@ -17,10 +18,22 @@ import { import { Instrumentation } from 'src/utils/instrumentation'; import { handlePromiseError } from 'src/utils/misc'; -const probe = promisify(ffmpeg.ffprobe); +const probe = (input: string, options: string[]): Promise => + new Promise((resolve, reject) => + ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))), + ); sharp.concurrency(0); sharp.cache({ files: 0 }); +type ProgressEvent = { + frames: number; + currentFps: number; + currentKbps: number; + targetSize: number; + timemark: string; + percent?: number; +}; + @Instrumentation() @Injectable() export class MediaRepository implements IMediaRepository { @@ -65,8 +78,8 @@ export class MediaRepository implements IMediaRepository { .toFile(output); } - async probe(input: string): Promise { - const results = await probe(input); + async probe(input: string, options?: ProbeOptions): Promise { + const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817 return { format: { formatName: results.format.format_name, @@ -83,10 +96,10 @@ export class MediaRepository implements IMediaRepository { width: stream.width || 0, codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name, codecType: stream.codec_type, - frameCount: Number.parseInt(stream.nb_frames ?? '0'), - rotation: Number.parseInt(`${stream.rotation ?? 0}`), + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), + rotation: this.parseInt(stream.rotation), isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67', - bitrate: Number.parseInt(stream.bit_rate ?? '0'), + bitrate: this.parseInt(stream.bit_rate), })), audioStreams: results.streams .filter((stream) => stream.codec_type === 'audio') @@ -94,7 +107,7 @@ export class MediaRepository implements IMediaRepository { index: stream.index, codecType: stream.codec_type, codecName: stream.codec_name, - frameCount: Number.parseInt(stream.nb_frames ?? '0'), + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), })), }; } @@ -156,10 +169,37 @@ export class MediaRepository implements IMediaRepository { } private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) { - return ffmpeg(input, { niceness: 10 }) + const ffmpegCall = ffmpeg(input, { niceness: 10 }) .inputOptions(options.inputOptions) .outputOptions(options.outputOptions) .output(output) - .on('error', (error, stdout, stderr) => this.logger.error(stderr || error)); + .on('start', (command: string) => this.logger.debug(command)) + .on('error', (error, _, stderr) => this.logger.error(stderr || error)); + + const { frameCount, percentInterval } = options.progress; + const frameInterval = Math.ceil(frameCount / (100 / percentInterval)); + if (this.logger.isLevelEnabled(LogLevel.DEBUG) && frameCount && frameInterval) { + let lastProgressFrame: number = 0; + ffmpegCall.on('progress', (progress: ProgressEvent) => { + if (progress.frames - lastProgressFrame < frameInterval) { + return; + } + + lastProgressFrame = progress.frames; + const percent = ((progress.frames / frameCount) * 100).toFixed(2); + const ms = Math.floor((frameCount - progress.frames) / progress.currentFps) * 1000; + const duration = ms ? Duration.fromMillis(ms).rescale().toHuman({ unitDisplay: 'narrow' }) : ''; + const outputText = output instanceof Writable ? 'stream' : output.split('/').pop(); + this.logger.debug( + `Transcoding ${percent}% done${duration ? `, estimated ${duration} remaining` : ''} for output ${outputText}`, + ); + }); + } + + return ffmpegCall; + } + + private parseInt(value: string | number | undefined): number { + return Number.parseInt(value as string) || 0; } } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index ce6168408f..ddda8f64fc 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -349,7 +349,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -359,7 +359,7 @@ describe(MediaService.name, () => { String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); expect(assetMock.upsertFile).toHaveBeenCalledWith({ assetId: 'asset-id', @@ -377,7 +377,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -387,7 +387,7 @@ describe(MediaService.name, () => { String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); expect(assetMock.upsertFile).toHaveBeenCalledWith({ assetId: 'asset-id', @@ -407,7 +407,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -417,7 +417,7 @@ describe(MediaService.name, () => { String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); }); @@ -430,11 +430,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringContaining('scale=-2:1440')]), twoPass: false, - }, + }), ); }); @@ -731,21 +731,22 @@ describe(MediaService.name, () => { it('should transcode the longest stream', async () => { assetMock.getByIds.mockResolvedValue([assetStub.video]); + loggerMock.isLevelEnabled.mockReturnValue(false); mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext'); + expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); expect(systemMock.get).toHaveBeenCalled(); expect(storageMock.mkdirSync).toHaveBeenCalled(); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-map 0:0', '-map 0:1']), twoPass: false, - }, + }), ); }); @@ -771,11 +772,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -786,11 +787,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -801,11 +802,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -816,11 +817,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('scale')]), twoPass: false, - }, + }), ); }); @@ -832,11 +833,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:720/)]), twoPass: false, - }, + }), ); }); @@ -848,11 +849,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=720:-2/)]), twoPass: false, - }, + }), ); }); @@ -864,11 +865,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:354/)]), twoPass: false, - }, + }), ); }); @@ -880,11 +881,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=354:-2/)]), twoPass: false, - }, + }), ); }); @@ -898,11 +899,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a aac']), twoPass: false, - }, + }), ); }); @@ -920,11 +921,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining(['-tag:v hvc1']), twoPass: false, - }, + }), ); }); @@ -942,11 +943,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-tag:v hvc1']), twoPass: false, - }, + }), ); }); @@ -958,11 +959,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -973,11 +974,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -1036,11 +1037,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-maxrate 4500k', '-bufsize 9000k']), twoPass: false, - }, + }), ); }); @@ -1052,11 +1053,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), twoPass: true, - }, + }), ); }); @@ -1068,11 +1069,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -1090,11 +1091,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), twoPass: true, - }, + }), ); }); @@ -1112,11 +1113,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-maxrate')]), twoPass: true, - }, + }), ); }); @@ -1128,11 +1129,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-cpu-used 2']), twoPass: false, - }, + }), ); }); @@ -1144,11 +1145,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-cpu-used')]), twoPass: false, - }, + }), ); }); @@ -1160,11 +1161,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 2']), twoPass: false, - }, + }), ); }); @@ -1176,11 +1177,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 1', '-x264-params frame-threads=1:pools=none']), twoPass: false, - }, + }), ); }); @@ -1192,11 +1193,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), twoPass: false, - }, + }), ); }); @@ -1208,11 +1209,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v hevc', '-threads 1', '-x265-params frame-threads=1:pools=none']), twoPass: false, - }, + }), ); }); @@ -1224,11 +1225,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), twoPass: false, - }, + }), ); }); @@ -1240,7 +1241,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([ '-c:v av1', @@ -1255,7 +1256,7 @@ describe(MediaService.name, () => { '-crf 23', ]), twoPass: false, - }, + }), ); }); @@ -1267,11 +1268,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-preset 4']), twoPass: false, - }, + }), ); }); @@ -1283,11 +1284,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params mbr=2M']), twoPass: false, - }, + }), ); }); @@ -1299,11 +1300,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4']), twoPass: false, - }, + }), ); }); @@ -1315,11 +1316,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4:mbr=2M']), twoPass: false, - }, + }), ); }); @@ -1361,7 +1362,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([ '-tune hq', @@ -1382,7 +1383,7 @@ describe(MediaService.name, () => { '-cq:v 23', ]), twoPass: false, - }, + }), ); }); @@ -1400,11 +1401,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([expect.stringContaining('-multipass')]), twoPass: false, - }, + }), ); }); @@ -1416,11 +1417,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining(['-cq:v 23', '-maxrate 10000k', '-bufsize 6897k']), twoPass: false, - }, + }), ); }); @@ -1432,11 +1433,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.stringContaining('-maxrate'), twoPass: false, - }, + }), ); }); @@ -1448,11 +1449,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), twoPass: false, - }, + }), ); }); @@ -1464,11 +1465,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-multipass')]), twoPass: false, - }, + }), ); }); @@ -1482,7 +1483,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel cuda', '-hwaccel_output_format cuda', @@ -1491,7 +1492,7 @@ describe(MediaService.name, () => { ]), outputOptions: expect.arrayContaining([expect.stringContaining('scale_cuda=-2:720:format=nv12')]), twoPass: false, - }, + }), ); }); @@ -1505,7 +1506,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']), outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -1513,7 +1514,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -1526,7 +1527,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.arrayContaining([ `-c:v h264_qsv`, @@ -1547,7 +1548,7 @@ describe(MediaService.name, () => { '-bufsize 20000k', ]), twoPass: false, - }, + }), ); }); @@ -1566,14 +1567,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', '-filter_hw_device hw', ]), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -1586,11 +1587,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), twoPass: false, - }, + }), ); }); @@ -1603,11 +1604,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.arrayContaining(['-low_power 1']), twoPass: false, - }, + }), ); }); @@ -1633,7 +1634,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel qsv', '-hwaccel_output_format qsv', @@ -1645,7 +1646,7 @@ describe(MediaService.name, () => { expect.stringContaining('scale_qsv=-1:720:async_depth=4:mode=hq:format=nv12'), ]), twoPass: false, - }, + }), ); }); @@ -1662,7 +1663,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel qsv', '-hwaccel_output_format qsv', @@ -1675,7 +1676,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -1691,11 +1692,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel qsv', '-qsv_device /dev/dri/renderD129']), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -1708,7 +1709,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1728,7 +1729,7 @@ describe(MediaService.name, () => { '-rc_mode 1', ]), twoPass: false, - }, + }), ); }); @@ -1741,7 +1742,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1754,7 +1755,7 @@ describe(MediaService.name, () => { '-rc_mode 3', ]), twoPass: false, - }, + }), ); }); @@ -1767,7 +1768,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1780,7 +1781,7 @@ describe(MediaService.name, () => { '-rc_mode 1', ]), twoPass: false, - }, + }), ); }); @@ -1793,14 +1794,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', ]), outputOptions: expect.not.arrayContaining([expect.stringContaining('-compression_level')]), twoPass: false, - }, + }), ); }); @@ -1813,14 +1814,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/card1', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1833,14 +1834,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD130', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1855,14 +1856,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1877,11 +1878,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264']), twoPass: false, - }, + }), ); }); @@ -1904,7 +1905,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel rkmpp', '-hwaccel_output_format drm_prime', @@ -1927,7 +1928,7 @@ describe(MediaService.name, () => { '-qp_init 23', ]), twoPass: false, - }, + }), ); }); @@ -1948,11 +1949,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([`-c:v hevc_rkmpp`, '-level 153', '-rc_mode AVBR', '-b:v 10000k']), twoPass: false, - }, + }), ); }); @@ -1968,11 +1969,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([`-c:v h264_rkmpp`, '-level 51', '-rc_mode CQP', '-qp_init 30']), twoPass: false, - }, + }), ); }); @@ -1988,7 +1989,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -1996,7 +1997,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -2012,7 +2013,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: [], outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -2020,7 +2021,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -2036,7 +2037,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: [], outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -2044,69 +2045,101 @@ describe(MediaService.name, () => { ), ]), twoPass: false, + }), + ); + }); + + it('should tonemap when policy is required and video is hdr', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + it('should tonemap when policy is optimal and video is hdr', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + it('should count frames for progress when log level is debug', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + loggerMock.isLevelEnabled.mockReturnValue(true); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + + await sut.handleVideoConversion({ id: assetStub.video.id }); + + expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + assetStub.video.originalPath, + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + { + inputOptions: expect.any(Array), + outputOptions: expect.any(Array), + twoPass: false, + progress: { + frameCount: probeStub.videoStream2160p.videoStreams[0].frameCount, + percentInterval: expect.any(Number), + }, }, ); }); - }); - it('should tonemap when policy is required and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); - }); + it('should not count frames for progress when log level is not debug', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); + loggerMock.isLevelEnabled.mockReturnValue(false); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); - it('should tonemap when policy is optimal and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); - }); - - it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); + expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); + }); }); describe('isSRGB', () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 55a4ee0157..720bef6c76 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -11,6 +11,7 @@ import { AudioCodec, Colorspace, ImageFormat, + LogLevel, StorageFolder, TranscodeHWAccel, TranscodePolicy, @@ -31,7 +32,13 @@ import { QueueName, } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { AudioStreamInfo, IMediaRepository, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface'; +import { + AudioStreamInfo, + IMediaRepository, + TranscodeCommand, + VideoFormat, + VideoStreamInfo, +} from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -346,7 +353,9 @@ export class MediaService { const output = StorageCore.getEncodedVideoPath(asset); this.storageCore.ensureFolders(output); - const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input); + const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, { + countFrames: this.logger.isLevelEnabled(LogLevel.DEBUG), // makes frame count more reliable for progress logs + }); const mainVideoStream = this.getMainStream(videoStreams); const mainAudioStream = this.getMainStream(audioStreams); if (!mainVideoStream || !format.formatName) { @@ -365,12 +374,14 @@ export class MediaService { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } }); await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); + } else { + this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); } return JobStatus.SKIPPED; } - let command; + let command: TranscodeCommand; try { const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL()); command = config.getCommand(target, mainVideoStream, mainAudioStream); @@ -379,16 +390,20 @@ export class MediaService { return JobStatus.FAILED; } - this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(command)}`); + if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { + this.logger.log(`Encoding video ${asset.id} without hardware acceleration`); + } else { + this.logger.log(`Encoding video ${asset.id} with ${ffmpeg.accel.toUpperCase()} acceleration`); + } + try { await this.mediaRepository.transcode(input, output, command); - } catch (error) { - this.logger.error(error); - if (ffmpeg.accel !== TranscodeHWAccel.DISABLED) { - this.logger.error( - `Error occurred during transcoding. Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled.`, - ); + } catch (error: any) { + this.logger.error(`Error occurred during transcoding: ${error.message}`); + if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { + return JobStatus.FAILED; } + this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`); const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED }); command = config.getCommand(target, mainVideoStream, mainAudioStream); await this.mediaRepository.transcode(input, output, command); @@ -555,7 +570,7 @@ export class MediaService { const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); } catch { - this.logger.debug('OpenCL not available for transcoding, using CPU decoding instead.'); + this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU decoding'); this.maliOpenCL = false; } } diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index d80651eece..6f0ab4ef81 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -80,6 +80,7 @@ export class BaseConfig implements VideoCodecSWConfig { inputOptions: this.getBaseInputOptions(videoStream), outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'], twoPass: this.eligibleForTwoPass(), + progress: { frameCount: videoStream.frameCount, percentInterval: 5 }, } as TranscodeCommand; if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) { const filters = this.getFilterOptions(videoStream); diff --git a/server/test/repositories/logger.repository.mock.ts b/server/test/repositories/logger.repository.mock.ts index 5f7262c7e5..6342e9e73c 100644 --- a/server/test/repositories/logger.repository.mock.ts +++ b/server/test/repositories/logger.repository.mock.ts @@ -6,7 +6,7 @@ export const newLoggerRepositoryMock = (): Mocked => { setLogLevel: vitest.fn(), setContext: vitest.fn(), setAppName: vitest.fn(), - + isLevelEnabled: vitest.fn(), verbose: vitest.fn(), debug: vitest.fn(), log: vitest.fn(), From 995f0fda475d40e969190925af455c20abb7a02b Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 28 Sep 2024 02:01:04 -0400 Subject: [PATCH 462/723] feat(server): separate quality for thumbnail and preview images (#13006) * allow different thumbnail and preview quality, better config structure * update web and api * wording * remove empty line? --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + .../system_config_generated_image_dto.dart | 118 ++++++++++++++ .../lib/model/system_config_image_dto.dart | 58 ++----- open-api/immich-openapi-specs.json | 50 +++--- open-api/typescript-sdk/src/fetch-client.ts | 12 +- server/src/config.ts | 23 +-- server/src/dtos/system-config.dto.ts | 38 ++--- server/src/interfaces/media.interface.ts | 9 +- ...7-SeparateQualityForThumbnailAndPreview.ts | 37 +++++ server/src/services/media.service.spec.ts | 8 +- server/src/services/media.service.ts | 27 ++-- server/src/services/person.service.ts | 2 +- .../services/system-config.service.spec.ts | 15 +- .../settings/image/image-settings.svelte | 150 ++++++++++-------- web/src/lib/i18n/en.json | 16 +- 17 files changed, 369 insertions(+), 198 deletions(-) create mode 100644 mobile/openapi/lib/model/system_config_generated_image_dto.dart create mode 100644 server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index fecbbf482b..81827a9079 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -416,6 +416,7 @@ Class | Method | HTTP request | Description - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigFacesDto](doc//SystemConfigFacesDto.md) + - [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md) - [SystemConfigImageDto](doc//SystemConfigImageDto.md) - [SystemConfigJobDto](doc//SystemConfigJobDto.md) - [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 22b48df2fb..8be4402980 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -229,6 +229,7 @@ part 'model/stack_update_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_faces_dto.dart'; +part 'model/system_config_generated_image_dto.dart'; part 'model/system_config_image_dto.dart'; part 'model/system_config_job_dto.dart'; part 'model/system_config_library_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 3db3297acb..9e38eaf30a 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -512,6 +512,8 @@ class ApiClient { return SystemConfigFFmpegDto.fromJson(value); case 'SystemConfigFacesDto': return SystemConfigFacesDto.fromJson(value); + case 'SystemConfigGeneratedImageDto': + return SystemConfigGeneratedImageDto.fromJson(value); case 'SystemConfigImageDto': return SystemConfigImageDto.fromJson(value); case 'SystemConfigJobDto': diff --git a/mobile/openapi/lib/model/system_config_generated_image_dto.dart b/mobile/openapi/lib/model/system_config_generated_image_dto.dart new file mode 100644 index 0000000000..2192a7cb0c --- /dev/null +++ b/mobile/openapi/lib/model/system_config_generated_image_dto.dart @@ -0,0 +1,118 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigGeneratedImageDto { + /// Returns a new [SystemConfigGeneratedImageDto] instance. + SystemConfigGeneratedImageDto({ + required this.format, + required this.quality, + required this.size, + }); + + ImageFormat format; + + /// Minimum value: 1 + /// Maximum value: 100 + int quality; + + /// Minimum value: 1 + int size; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto && + other.format == format && + other.quality == quality && + other.size == size; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (format.hashCode) + + (quality.hashCode) + + (size.hashCode); + + @override + String toString() => 'SystemConfigGeneratedImageDto[format=$format, quality=$quality, size=$size]'; + + Map toJson() { + final json = {}; + json[r'format'] = this.format; + json[r'quality'] = this.quality; + json[r'size'] = this.size; + return json; + } + + /// Returns a new [SystemConfigGeneratedImageDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigGeneratedImageDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigGeneratedImageDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigGeneratedImageDto( + format: ImageFormat.fromJson(json[r'format'])!, + quality: mapValueOfType(json, r'quality')!, + size: mapValueOfType(json, r'size')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigGeneratedImageDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigGeneratedImageDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigGeneratedImageDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigGeneratedImageDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'format', + 'quality', + 'size', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 681a8c00c3..5309f7745c 100644 --- a/mobile/openapi/lib/model/system_config_image_dto.dart +++ b/mobile/openapi/lib/model/system_config_image_dto.dart @@ -15,64 +15,42 @@ class SystemConfigImageDto { SystemConfigImageDto({ required this.colorspace, required this.extractEmbedded, - required this.previewFormat, - required this.previewSize, - required this.quality, - required this.thumbnailFormat, - required this.thumbnailSize, + required this.preview, + required this.thumbnail, }); Colorspace colorspace; bool extractEmbedded; - ImageFormat previewFormat; + SystemConfigGeneratedImageDto preview; - /// Minimum value: 1 - int previewSize; - - /// Minimum value: 1 - /// Maximum value: 100 - int quality; - - ImageFormat thumbnailFormat; - - /// Minimum value: 1 - int thumbnailSize; + SystemConfigGeneratedImageDto thumbnail; @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigImageDto && other.colorspace == colorspace && other.extractEmbedded == extractEmbedded && - other.previewFormat == previewFormat && - other.previewSize == previewSize && - other.quality == quality && - other.thumbnailFormat == thumbnailFormat && - other.thumbnailSize == thumbnailSize; + other.preview == preview && + other.thumbnail == thumbnail; @override int get hashCode => // ignore: unnecessary_parenthesis (colorspace.hashCode) + (extractEmbedded.hashCode) + - (previewFormat.hashCode) + - (previewSize.hashCode) + - (quality.hashCode) + - (thumbnailFormat.hashCode) + - (thumbnailSize.hashCode); + (preview.hashCode) + + (thumbnail.hashCode); @override - String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, previewFormat=$previewFormat, previewSize=$previewSize, quality=$quality, thumbnailFormat=$thumbnailFormat, thumbnailSize=$thumbnailSize]'; + String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, preview=$preview, thumbnail=$thumbnail]'; Map toJson() { final json = {}; json[r'colorspace'] = this.colorspace; json[r'extractEmbedded'] = this.extractEmbedded; - json[r'previewFormat'] = this.previewFormat; - json[r'previewSize'] = this.previewSize; - json[r'quality'] = this.quality; - json[r'thumbnailFormat'] = this.thumbnailFormat; - json[r'thumbnailSize'] = this.thumbnailSize; + json[r'preview'] = this.preview; + json[r'thumbnail'] = this.thumbnail; return json; } @@ -87,11 +65,8 @@ class SystemConfigImageDto { return SystemConfigImageDto( colorspace: Colorspace.fromJson(json[r'colorspace'])!, extractEmbedded: mapValueOfType(json, r'extractEmbedded')!, - previewFormat: ImageFormat.fromJson(json[r'previewFormat'])!, - previewSize: mapValueOfType(json, r'previewSize')!, - quality: mapValueOfType(json, r'quality')!, - thumbnailFormat: ImageFormat.fromJson(json[r'thumbnailFormat'])!, - thumbnailSize: mapValueOfType(json, r'thumbnailSize')!, + preview: SystemConfigGeneratedImageDto.fromJson(json[r'preview'])!, + thumbnail: SystemConfigGeneratedImageDto.fromJson(json[r'thumbnail'])!, ); } return null; @@ -141,11 +116,8 @@ class SystemConfigImageDto { static const requiredKeys = { 'colorspace', 'extractEmbedded', - 'previewFormat', - 'previewSize', - 'quality', - 'thumbnailFormat', - 'thumbnailSize', + 'preview', + 'thumbnail', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 6afd0d792f..1077762ac3 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11654,6 +11654,28 @@ ], "type": "object" }, + "SystemConfigGeneratedImageDto": { + "properties": { + "format": { + "$ref": "#/components/schemas/ImageFormat" + }, + "quality": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "size": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "format", + "quality", + "size" + ], + "type": "object" + }, "SystemConfigImageDto": { "properties": { "colorspace": { @@ -11662,34 +11684,18 @@ "extractEmbedded": { "type": "boolean" }, - "previewFormat": { - "$ref": "#/components/schemas/ImageFormat" + "preview": { + "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" }, - "previewSize": { - "minimum": 1, - "type": "integer" - }, - "quality": { - "maximum": 100, - "minimum": 1, - "type": "integer" - }, - "thumbnailFormat": { - "$ref": "#/components/schemas/ImageFormat" - }, - "thumbnailSize": { - "minimum": 1, - "type": "integer" + "thumbnail": { + "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" } }, "required": [ "colorspace", "extractEmbedded", - "previewFormat", - "previewSize", - "quality", - "thumbnailFormat", - "thumbnailSize" + "preview", + "thumbnail" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index b1ae5d2876..e88f431e8c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1100,14 +1100,16 @@ export type SystemConfigFFmpegDto = { transcode: TranscodePolicy; twoPass: boolean; }; +export type SystemConfigGeneratedImageDto = { + format: ImageFormat; + quality: number; + size: number; +}; export type SystemConfigImageDto = { colorspace: Colorspace; extractEmbedded: boolean; - previewFormat: ImageFormat; - previewSize: number; - quality: number; - thumbnailFormat: ImageFormat; - thumbnailSize: number; + preview: SystemConfigGeneratedImageDto; + thumbnail: SystemConfigGeneratedImageDto; }; export type JobSettingsDto = { concurrency: number; diff --git a/server/src/config.ts b/server/src/config.ts index 1522371487..3317351f9f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -20,6 +20,7 @@ import { VideoContainer, } from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; +import { ImageOutputConfig } from 'src/interfaces/media.interface'; export interface SystemConfig { ffmpeg: { @@ -109,11 +110,8 @@ export interface SystemConfig { template: string; }; image: { - thumbnailFormat: ImageFormat; - thumbnailSize: number; - previewFormat: ImageFormat; - previewSize: number; - quality: number; + thumbnail: ImageOutputConfig; + preview: ImageOutputConfig; colorspace: Colorspace; extractEmbedded: boolean; }; @@ -259,11 +257,16 @@ export const defaults = Object.freeze({ template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { - thumbnailFormat: ImageFormat.WEBP, - thumbnailSize: 250, - previewFormat: ImageFormat.JPEG, - previewSize: 1440, - quality: 80, + thumbnail: { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + }, + preview: { + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + }, colorspace: Colorspace.P3, extractEmbedded: false, }, diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 4a3ca37691..c12a54cd61 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -473,26 +473,10 @@ export class SystemConfigThemeDto { customCss!: string; } -class SystemConfigImageDto { +class SystemConfigGeneratedImageDto { @IsEnum(ImageFormat) @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) - thumbnailFormat!: ImageFormat; - - @IsInt() - @Min(1) - @Type(() => Number) - @ApiProperty({ type: 'integer' }) - thumbnailSize!: number; - - @IsEnum(ImageFormat) - @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) - previewFormat!: ImageFormat; - - @IsInt() - @Min(1) - @Type(() => Number) - @ApiProperty({ type: 'integer' }) - previewSize!: number; + format!: ImageFormat; @IsInt() @Min(1) @@ -501,6 +485,24 @@ class SystemConfigImageDto { @ApiProperty({ type: 'integer' }) quality!: number; + @IsInt() + @Min(1) + @Type(() => Number) + @ApiProperty({ type: 'integer' }) + size!: number; +} + +class SystemConfigImageDto { + @Type(() => SystemConfigGeneratedImageDto) + @ValidateNested() + @IsObject() + thumbnail!: SystemConfigGeneratedImageDto; + + @Type(() => SystemConfigGeneratedImageDto) + @ValidateNested() + @IsObject() + preview!: SystemConfigGeneratedImageDto; + @IsEnum(Colorspace) @ApiProperty({ enumName: 'Colorspace', enum: Colorspace }) colorspace!: Colorspace; diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 7193684e7a..64ba6236e8 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -10,11 +10,14 @@ export interface CropOptions { height: number; } -export interface ThumbnailOptions { - size: number; +export interface ImageOutputConfig { format: ImageFormat; - colorspace: string; quality: number; + size: number; +} + +export interface ThumbnailOptions extends ImageOutputConfig { + colorspace: string; crop?: CropOptions; processInvalidImages: boolean; } diff --git a/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts new file mode 100644 index 0000000000..e02203997f --- /dev/null +++ b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeparateQualityForThumbnailAndPreview1727471863507 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update system_metadata + set value = jsonb_set(value, '{image}', jsonb_strip_nulls( + jsonb_build_object( + 'preview', jsonb_build_object( + 'format', value->'image'->'previewFormat', + 'quality', value->'image'->'quality', + 'size', value->'image'->'previewSize'), + 'thumbnail', jsonb_build_object( + 'format', value->'image'->'thumbnailFormat', + 'quality', value->'image'->'quality', + 'size', value->'image'->'thumbnailSize'), + 'extractEmbedded', value->'extractEmbedded', + 'colorspace', value->'colorspace' + ))) + where key = 'system-config'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update system_metadata + set value = jsonb_set(value, '{image}', jsonb_strip_nulls(jsonb_build_object( + 'previewFormat', value->'image'->'preview'->'format', + 'previewSize', value->'image'->'preview'->'size', + 'thumbnailFormat', value->'image'->'thumbnail'->'format', + 'thumbnailSize', value->'image'->'thumbnail'->'size', + 'extractEmbedded', value->'extractEmbedded', + 'colorspace', value->'colorspace', + 'quality', value->'image'->'preview'->'quality' + ))) + where key = 'system-config'`); + } +} diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index ddda8f64fc..c0903fa101 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -285,7 +285,7 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { previewFormat: format } }); + systemMock.get.mockResolvedValue({ image: { preview: { format } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; @@ -307,7 +307,7 @@ describe(MediaService.name, () => { }); it('should delete previous preview if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); + systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGeneratePreview({ id: assetStub.image.id }); @@ -464,7 +464,7 @@ describe(MediaService.name, () => { it.each(Object.values(ImageFormat))( 'should generate a %s thumbnail for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: format } }); + systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; @@ -487,7 +487,7 @@ describe(MediaService.name, () => { ); it('should delete previous thumbnail if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); + systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 720bef6c76..1b69c5acd5 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -10,7 +10,6 @@ import { AssetType, AudioCodec, Colorspace, - ImageFormat, LogLevel, StorageFolder, TranscodeHWAccel, @@ -175,18 +174,15 @@ export class MediaService { return JobStatus.FAILED; } - await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.previewFormat); - await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); + await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format); + await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); await this.storageCore.moveAssetVideo(asset); return JobStatus.SUCCESS; } async handleGeneratePreview({ id }: IEntityJob): Promise { - const [{ image }, [asset]] = await Promise.all([ - this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true, files: true }), - ]); + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); if (!asset) { return JobStatus.FAILED; } @@ -195,7 +191,7 @@ export class MediaService { return JobStatus.SKIPPED; } - const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat); + const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW); if (!previewPath) { return JobStatus.SKIPPED; } @@ -213,9 +209,9 @@ export class MediaService { return JobStatus.SUCCESS; } - private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType, format: ImageFormat) { + private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType) { const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); - const size = type === AssetPathType.PREVIEW ? image.previewSize : image.thumbnailSize; + const { size, format, quality } = image[type]; const path = StorageCore.getImagePath(asset, type, format); this.storageCore.ensureFolders(path); @@ -226,13 +222,13 @@ export class MediaService { const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); try { - const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize)); + const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; const imageOptions = { format, size, colorspace, - quality: image.quality, + quality, processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', }; @@ -274,10 +270,7 @@ export class MediaService { } async handleGenerateThumbnail({ id }: IEntityJob): Promise { - const [{ image }, [asset]] = await Promise.all([ - this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true, files: true }), - ]); + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); if (!asset) { return JobStatus.FAILED; } @@ -286,7 +279,7 @@ export class MediaService { return JobStatus.SKIPPED; } - const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); + const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL); if (!thumbnailPath) { return JobStatus.SKIPPED; } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 7cb76d1a71..651c8eebee 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -574,7 +574,7 @@ export class PersonService { format: ImageFormat.JPEG, size: FACE_THUMBNAIL_SIZE, colorspace: image.colorspace, - quality: image.quality, + quality: image.thumbnail.quality, crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }), processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', } as const; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 8b4fb0bc2f..514d8aa0f8 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -135,11 +135,16 @@ const updatedConfig = Object.freeze({ template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { - thumbnailFormat: ImageFormat.WEBP, - thumbnailSize: 250, - previewFormat: ImageFormat.JPEG, - previewSize: 1440, - quality: 80, + thumbnail: { + size: 250, + format: ImageFormat.WEBP, + quality: 80, + }, + preview: { + size: 1440, + format: ImageFormat.JPEG, + quality: 80, + }, colorspace: Colorspace.P3, extractEmbedded: false, }, diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index d6fc814b98..b5e381d5f8 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -11,6 +11,7 @@ SettingInputFieldType, } from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { t } from 'svelte-i18n'; + import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; @@ -24,73 +25,96 @@
    - + + - + - + + - + + - + + + + Date: Sat, 28 Sep 2024 13:47:24 -0400 Subject: [PATCH 463/723] feat(server): generate all thumbnails for an asset in one job (#13012) * wip cleanup add success logs, rename method do thumbhash too fixes fix tests handle `notify` wip refactor refactor * update tests * update sql * pr feedback * remove unused code * formatting --- server/src/config.ts | 6 +- server/src/dtos/system-config.dto.ts | 2 +- server/src/interfaces/asset.interface.ts | 9 +- server/src/interfaces/job.interface.ts | 8 +- server/src/interfaces/media.interface.ts | 44 +- server/src/queries/asset.repository.sql | 24 + server/src/repositories/asset.repository.ts | 9 +- server/src/repositories/job.repository.ts | 4 +- server/src/repositories/media.repository.ts | 68 ++- server/src/services/asset.service.spec.ts | 2 +- server/src/services/asset.service.ts | 2 +- server/src/services/job.service.spec.ts | 34 +- server/src/services/job.service.ts | 49 +- server/src/services/media.service.spec.ts | 567 +++++++++--------- server/src/services/media.service.ts | 221 +++---- server/src/services/microservices.service.ts | 4 +- .../src/services/notification.service.spec.ts | 2 +- server/src/services/notification.service.ts | 2 +- server/src/services/person.service.spec.ts | 47 +- server/src/services/person.service.ts | 6 +- .../repositories/asset.repository.mock.ts | 1 + .../repositories/media.repository.mock.ts | 5 +- 22 files changed, 574 insertions(+), 542 deletions(-) diff --git a/server/src/config.ts b/server/src/config.ts index 3317351f9f..53374d581f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -20,7 +20,7 @@ import { VideoContainer, } from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; -import { ImageOutputConfig } from 'src/interfaces/media.interface'; +import { ImageOptions } from 'src/interfaces/media.interface'; export interface SystemConfig { ffmpeg: { @@ -110,8 +110,8 @@ export interface SystemConfig { template: string; }; image: { - thumbnail: ImageOutputConfig; - preview: ImageOutputConfig; + thumbnail: ImageOptions; + preview: ImageOptions; colorspace: Colorspace; extractEmbedded: boolean; }; diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index c12a54cd61..039dbd20ff 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -492,7 +492,7 @@ class SystemConfigGeneratedImageDto { size!: number; } -class SystemConfigImageDto { +export class SystemConfigImageDto { @Type(() => SystemConfigGeneratedImageDto) @ValidateNested() @IsObject() diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index c6808e3aa8..750a852094 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -141,6 +141,12 @@ export interface AssetUpdateDuplicateOptions { duplicateIds: string[]; } +export interface UpsertFileOptions { + assetId: string; + type: AssetFileType; + path: string; +} + export type AssetPathEntity = Pick; export const IAssetRepository = 'IAssetRepository'; @@ -194,5 +200,6 @@ export interface IAssetRepository { getDuplicates(options: AssetBuilderOptions): Promise; getAllForUserFullSync(options: AssetFullSyncOptions): Promise; getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise; - upsertFile(options: { assetId: string; type: AssetFileType; path: string }): Promise; + upsertFile(file: UpsertFileOptions): Promise; + upsertFiles(files: UpsertFileOptions[]): Promise; } diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index af2726b858..aa3090675e 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -37,9 +37,7 @@ export enum JobName { // thumbnails QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails', - GENERATE_PREVIEW = 'generate-preview', - GENERATE_THUMBNAIL = 'generate-thumbnail', - GENERATE_THUMBHASH = 'generate-thumbhash', + GENERATE_THUMBNAILS = 'generate-thumbnails', GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail', // metadata @@ -212,9 +210,7 @@ export type JobItem = // Thumbnails | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } - | { name: JobName.GENERATE_PREVIEW; data: IEntityJob } - | { name: JobName.GENERATE_THUMBNAIL; data: IEntityJob } - | { name: JobName.GENERATE_THUMBHASH; data: IEntityJob } + | { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob } // User | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 64ba6236e8..2bc8ccde36 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -10,16 +10,44 @@ export interface CropOptions { height: number; } -export interface ImageOutputConfig { +export interface ImageOptions { format: ImageFormat; quality: number; size: number; } -export interface ThumbnailOptions extends ImageOutputConfig { +export interface RawImageInfo { + width: number; + height: number; + channels: 1 | 2 | 3 | 4; +} + +interface DecodeImageOptions { colorspace: string; crop?: CropOptions; processInvalidImages: boolean; + raw?: RawImageInfo; +} + +export interface DecodeToBufferOptions extends DecodeImageOptions { + size: number; +} + +export type GenerateThumbnailOptions = ImageOptions & DecodeImageOptions; + +export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo }; + +export type GenerateThumbhashOptions = DecodeImageOptions; + +export type GenerateThumbhashFromBufferOptions = GenerateThumbhashOptions & { raw: RawImageInfo }; + +export interface GenerateThumbnailsOptions { + colorspace: string; + crop?: CropOptions; + preview?: ImageOptions; + processInvalidImages: boolean; + thumbhash?: boolean; + thumbnail?: ImageOptions; } export interface VideoStreamInfo { @@ -78,6 +106,11 @@ export interface BitrateDistribution { unit: string; } +export interface ImageBuffer { + data: Buffer; + info: RawImageInfo; +} + export interface VideoCodecSWConfig { getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand; } @@ -93,8 +126,11 @@ export interface ProbeOptions { export interface IMediaRepository { // image extract(input: string, output: string): Promise; - generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise; - generateThumbhash(imagePath: string): Promise; + decodeImage(input: string, options: DecodeToBufferOptions): Promise; + generateThumbnail(input: string, options: GenerateThumbnailOptions, outputFile: string): Promise; + generateThumbnail(input: Buffer, options: GenerateThumbnailFromBufferOptions, outputFile: string): Promise; + generateThumbhash(input: string, options: GenerateThumbhashOptions): Promise; + generateThumbhash(input: Buffer, options: GenerateThumbhashFromBufferOptions): Promise; getImageDimensions(input: string): Promise; // video diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 6930932584..eda91482bb 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -1132,3 +1132,27 @@ RETURNING "id", "createdAt", "updatedAt" + +-- AssetRepository.upsertFiles +INSERT INTO + "asset_files" ( + "id", + "assetId", + "createdAt", + "updatedAt", + "type", + "path" + ) +VALUES + (DEFAULT, $1, DEFAULT, DEFAULT, $2, $3) +ON CONFLICT ("assetId", "type") DO +UPDATE +SET + "assetId" = EXCLUDED."assetId", + "type" = EXCLUDED."type", + "path" = EXCLUDED."path", + "updatedAt" = DEFAULT +RETURNING + "id", + "createdAt", + "updatedAt" diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 0ec347ed77..8bca755c32 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -801,7 +801,12 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) - async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise { - await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] }); + async upsertFile(file: { assetId: string; type: AssetFileType; path: string }): Promise { + await this.fileRepository.upsert(file, { conflictPaths: ['assetId', 'type'] }); + } + + @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) + async upsertFiles(files: { assetId: string; type: AssetFileType; path: string }[]): Promise { + await this.fileRepository.upsert(files, { conflictPaths: ['assetId', 'type'] }); } } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index cd4c7135be..3f154ee016 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -36,9 +36,7 @@ export const JOBS_TO_QUEUE: Record = { // thumbnails [JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_PREVIEW]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION, + [JobName.GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, // tags diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index d001aa3158..cca87f44f2 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -8,10 +8,12 @@ import sharp from 'sharp'; import { Colorspace, LogLevel } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { + DecodeToBufferOptions, + GenerateThumbhashOptions, + GenerateThumbnailOptions, IMediaRepository, ImageDimensions, ProbeOptions, - ThumbnailOptions, TranscodeCommand, VideoInfo, } from 'src/interfaces/media.interface'; @@ -57,19 +59,12 @@ export class MediaRepository implements IMediaRepository { return true; } - async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise { - // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes - const pipeline = sharp(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false }) - .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') - .rotate(); + decodeImage(input: string, options: DecodeToBufferOptions) { + return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); + } - if (options.crop) { - pipeline.extract(options.crop); - } - - await pipeline - .resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }) - .withIccProfile(options.colorspace) + async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise { + await this.getImageDecodingPipeline(input, options) .toFormat(options.format, { quality: options.quality, // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp @@ -78,6 +73,40 @@ export class MediaRepository implements IMediaRepository { .toFile(output); } + private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { + let pipeline = sharp(input, { + // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes + failOn: options.processInvalidImages ? 'none' : 'error', + limitInputPixels: false, + raw: options.raw, + }); + + if (!options.raw) { + pipeline = pipeline + .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') + .withIccProfile(options.colorspace) + .rotate(); + } + + if (options.crop) { + pipeline = pipeline.extract(options.crop); + } + + return pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }); + } + + async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise { + const [{ rgbaToThumbHash }, { data, info }] = await Promise.all([ + import('thumbhash'), + sharp(input, options) + .resize(100, 100, { fit: 'inside', withoutEnlargement: true }) + .raw() + .ensureAlpha() + .toBuffer({ resolveWithObject: true }), + ]); + return Buffer.from(rgbaToThumbHash(info.width, info.height, data)); + } + async probe(input: string, options?: ProbeOptions): Promise { const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817 return { @@ -150,19 +179,6 @@ export class MediaRepository implements IMediaRepository { }); } - async generateThumbhash(imagePath: string): Promise { - const maxSize = 100; - - const { data, info } = await sharp(imagePath) - .resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true }) - .raw() - .ensureAlpha() - .toBuffer({ resolveWithObject: true }); - - const thumbhash = await import('thumbhash'); - return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data)); - } - async getImageDimensions(input: string): Promise { const { width = 0, height = 0 } = await sharp(input).metadata(); return { width, height }; diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 2e2d676939..f36d26fa7c 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -395,7 +395,7 @@ describe(AssetService.name, () => { it('should run the refresh thumbnails job', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } }]); + expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]); }); it('should run the transcode video', async () => { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index b3f824f226..aa88eaf957 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -322,7 +322,7 @@ export class AssetService { } case AssetJobName.REGENERATE_THUMBNAIL: { - jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id } }); + jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id } }); break; } diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 1c810facb4..c2d7a29b9f 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -288,7 +288,7 @@ describe(JobService.name, () => { }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } }, - jobs: [JobName.GENERATE_PREVIEW], + jobs: [JobName.GENERATE_THUMBNAILS], }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1' } }, @@ -299,28 +299,16 @@ describe(JobService.name, () => { jobs: [], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } }, - jobs: [JobName.GENERATE_THUMBNAIL, JobName.GENERATE_THUMBHASH], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }, + jobs: [], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1', source: 'upload' } }, - jobs: [ - JobName.GENERATE_THUMBNAIL, - JobName.GENERATE_THUMBHASH, - JobName.SMART_SEARCH, - JobName.FACE_DETECTION, - JobName.VIDEO_CONVERSION, - ], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1', source: 'upload' } }, + jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-live-image', source: 'upload' } }, - jobs: [ - JobName.GENERATE_THUMBNAIL, - JobName.GENERATE_THUMBHASH, - JobName.SMART_SEARCH, - JobName.FACE_DETECTION, - JobName.VIDEO_CONVERSION, - ], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-live-image', source: 'upload' } }, + jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION], }, { item: { name: JobName.SMART_SEARCH, data: { id: 'asset-1' } }, @@ -338,11 +326,11 @@ describe(JobService.name, () => { for (const { item, jobs } of tests) { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { - if (item.name === JobName.GENERATE_PREVIEW && item.data.source === 'upload') { + if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') { if (item.data.id === 'asset-live-image') { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); } else { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); } } @@ -361,7 +349,7 @@ describe(JobService.name, () => { } }); - it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => { + it(`should not queue any jobs when ${item.name} fails`, async () => { await sut.init(makeMockHandlers(JobStatus.FAILED)); await jobMock.addHandler.mock.calls[0][2](item); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index f978f33410..9c73e71cbf 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -281,7 +281,7 @@ export class JobService { case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { if (item.data.source === 'upload' || item.data.source === 'copy') { - await this.jobRepository.queue({ name: JobName.GENERATE_PREVIEW, data: item.data }); + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data }); } break; } @@ -295,40 +295,33 @@ export class JobService { break; } - case JobName.GENERATE_PREVIEW: { - const jobs: JobItem[] = [ - { name: JobName.GENERATE_THUMBNAIL, data: item.data }, - { name: JobName.GENERATE_THUMBHASH, data: item.data }, - ]; - - if (item.data.source === 'upload') { - jobs.push({ name: JobName.SMART_SEARCH, data: item.data }, { name: JobName.FACE_DETECTION, data: item.data }); - - const [asset] = await this.assetRepository.getByIds([item.data.id]); - if (asset) { - if (asset.type === AssetType.VIDEO) { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data }); - } else if (asset.livePhotoVideoId) { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); - } - } - } - - await this.jobRepository.queueAll(jobs); - break; - } - - case JobName.GENERATE_THUMBNAIL: { - if (!(item.data.notify || item.data.source === 'upload')) { + case JobName.GENERATE_THUMBNAILS: { + if (!item.data.notify && item.data.source !== 'upload') { break; } const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); + if (!asset) { + this.logger.warn(`Could not find asset ${item.data.id} after generating thumbnails`); + break; + } - // Only live-photo motion part will be marked as not visible immediately on upload. Skip notifying clients - if (asset && asset.isVisible) { + const jobs: JobItem[] = [ + { name: JobName.SMART_SEARCH, data: item.data }, + { name: JobName.FACE_DETECTION, data: item.data }, + ]; + + if (asset.type === AssetType.VIDEO) { + jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data }); + } else if (asset.livePhotoVideoId) { + jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); + } + + await this.jobRepository.queueAll(jobs); + if (asset.isVisible) { this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); } + break; } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index c0903fa101..88e9f478bd 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -15,7 +15,7 @@ import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interfac import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMediaRepository } from 'src/interfaces/media.interface'; +import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -94,7 +94,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -127,7 +127,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.trashed.id }, }, ]); @@ -152,7 +152,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.archived.id }, }, ]); @@ -202,7 +202,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -226,7 +226,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBNAIL, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -250,7 +250,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBHASH, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -259,10 +259,19 @@ describe(MediaService.name, () => { }); }); - describe('handleGeneratePreview', () => { + describe('handleGenerateThumbnails', () => { + let rawBuffer: Buffer; + let rawInfo: RawImageInfo; + + beforeEach(() => { + rawBuffer = Buffer.from('image data'); + rawInfo = { width: 100, height: 100, channels: 3 }; + mediaMock.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo }); + }); + it('should skip thumbnail generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); @@ -270,80 +279,100 @@ describe(MediaService.name, () => { it('should skip video thumbnail generation if no video stream', async () => { mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); - expect(await sut.handleGeneratePreview({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); - it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { preview: { format } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; - - await sut.handleGeneratePreview({ id: assetStub.image.id }); - - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', previewPath, { - size: 1440, - format, - quality: 80, - colorspace: Colorspace.SRGB, - processInvalidImages: false, - }); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: previewPath, - }); - }); - it('should delete previous preview if different path', async () => { systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + assetMock.getById.mockResolvedValue(assetStub.image); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); }); - it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([ - { ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity }, - ]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + it('should generate P3 thumbnails for a wide gamut image', async () => { + assetMock.getById.mockResolvedValue({ + ...assetStub.image, + exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity, + }); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - '/original/path.jpg', - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { - size: 1440, - format: ImageFormat.JPEG, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.WEBP, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + ); + + expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); + expect(mediaMock.generateThumbhash).toHaveBeenCalledWith(rawBuffer, { + colorspace: Colorspace.P3, + processInvalidImages: false, + raw: rawInfo, + }); + + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); }); it('should generate a thumbnail for a video', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -361,17 +390,24 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - }); + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); }); it('should tonemap thumbnail for hdr video', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -389,11 +425,18 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - }); + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); }); it('should always generate video thumbnail in one pass', async () => { @@ -401,8 +444,8 @@ describe(MediaService.name, () => { systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -424,8 +467,8 @@ describe(MediaService.name, () => { it('should use scaling divisible by 2 even when using quick sync', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -438,233 +481,207 @@ describe(MediaService.name, () => { ); }); - it('should run successfully', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); - }); - }); + it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => { + systemMock.get.mockResolvedValue({ image: { preview: { format } } }); + assetMock.getById.mockResolvedValue(assetStub.image); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; + const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.webp`; - describe('handleGenerateThumbnail', () => { - it('should skip thumbnail generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); - expect(await sut.handleGenerateThumbnail({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); - - it.each(Object.values(ImageFormat))( - 'should generate a %s thumbnail for an image when specified', - async (format) => { - systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, { - size: 250, - format, - quality: 80, + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { colorspace: Colorspace.SRGB, + format, + size: 1440, + quality: 80, processInvalidImages: false, - }); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, - path: thumbnailPath, - }); - }, - ); + raw: rawInfo, + }, + previewPath, + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format: ImageFormat.WEBP, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + thumbnailPath, + ); + }); + + it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { + systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); + assetMock.getById.mockResolvedValue(assetStub.image); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.jpeg`; + const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + previewPath, + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + thumbnailPath, + ); + }); it('should delete previous thumbnail if different path', async () => { systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + assetMock.getById.mockResolvedValue(assetStub.image); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); }); - }); - it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + it('should extract embedded image if enabled and available', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(extractedPath, { colorspace: Colorspace.P3, processInvalidImages: false, - }, - ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, - path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + size: 1440, + }); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); }); - }); - it('should extract embedded image if enabled and available', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + it('should resize original image if embedded image is too small', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(mediaMock.generateThumbnail.mock.calls).toEqual([ - [ - extractedPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ], - ]); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); - }); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + }); - it('should resize original image if embedded image is too small', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + it('should resize original image if embedded image not found', async () => { + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail.mock.calls).toEqual([ - [ + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + }); + + it('should resize original image if embedded image extraction is not enabled', async () => { + systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.extract).not.toHaveBeenCalled(); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + }); + + it('should process invalid images if enabled', async () => { + vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); + + assetMock.getById.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith( assetStub.imageDng.originalPath, + expect.objectContaining({ processInvalidImages: true }), + ); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ], - ]); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); - }); + ); - it('should resize original image if embedded image not found', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); + expect(mediaMock.generateThumbhash).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + ); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - }); - - it('should resize original image if embedded image extraction is not enabled', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.extract).not.toHaveBeenCalled(); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - }); - - it('should process invalid images if enabled', async () => { - vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); - - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: true, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - vi.unstubAllEnvs(); - }); - - describe('handleGenerateThumbhash', () => { - it('should skip thumbhash generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGenerateThumbhash({ id: assetStub.image.id }); - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - }); - - it('should skip thumbhash generation if resize path is missing', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); - await sut.handleGenerateThumbhash({ id: assetStub.noResizePath.id }); - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - }); - - it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); - - expect(await sut.handleGenerateThumbhash({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); - - it('should generate a thumbhash', async () => { - const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); - - await sut.handleGenerateThumbhash({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 1b69c5acd5..71f432e040 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,6 +1,7 @@ -import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { dirname } from 'node:path'; -import { GeneratedImageType, StorageCore } from 'src/cores/storage.core'; + +import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -18,7 +19,7 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; +import { IAssetRepository, UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IBaseJob, @@ -95,18 +96,10 @@ export class MediaService { for (const asset of assets) { const { previewFile, thumbnailFile } = getAssetFiles(asset.files); - if (!previewFile || force) { - jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id: asset.id } }); + if (!previewFile || !thumbnailFile || !asset.thumbhash || force) { + jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } }); continue; } - - if (!thumbnailFile) { - jobs.push({ name: JobName.GENERATE_THUMBNAIL, data: { id: asset.id } }); - } - - if (!asset.thumbhash) { - jobs.push({ name: JobName.GENERATE_THUMBHASH, data: { id: asset.id } }); - } } await this.jobRepository.queueAll(jobs); @@ -181,141 +174,127 @@ export class MediaService { return JobStatus.SUCCESS; } - async handleGeneratePreview({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); + async handleGenerateThumbnails({ id }: IEntityJob): Promise { + const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true }); if (!asset) { + this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`); return JobStatus.FAILED; } if (!asset.isVisible) { + this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`); return JobStatus.SKIPPED; } - const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW); - if (!previewPath) { + let generated: { previewPath: string; thumbnailPath: string; thumbhash: Buffer }; + if (asset.type === AssetType.IMAGE) { + generated = await this.generateImageThumbnails(asset); + } else if (asset.type === AssetType.VIDEO) { + generated = await this.generateVideoThumbnails(asset); + } else { + this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); return JobStatus.SKIPPED; } - const { previewFile } = getAssetFiles(asset.files); - if (previewFile && previewFile.path !== previewPath) { + const { previewFile, thumbnailFile } = getAssetFiles(asset.files); + const toUpsert: UpsertFileOptions[] = []; + if (previewFile?.path !== generated.previewPath) { + toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.PREVIEW }); + } + + if (thumbnailFile?.path !== generated.thumbnailPath) { + toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.THUMBNAIL }); + } + + if (toUpsert.length > 0) { + await this.assetRepository.upsertFiles(toUpsert); + } + + const pathsToDelete = []; + if (previewFile && previewFile.path !== generated.previewPath) { this.logger.debug(`Deleting old preview for asset ${asset.id}`); - await this.storageRepository.unlink(previewFile.path); + pathsToDelete.push(previewFile.path); } - await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.PREVIEW, path: previewPath }); - await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); - await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date() }); - - return JobStatus.SUCCESS; - } - - private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType) { - const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); - const { size, format, quality } = image[type]; - const path = StorageCore.getImagePath(asset, type, format); - this.storageCore.ensureFolders(path); - - switch (asset.type) { - case AssetType.IMAGE: { - const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); - const extractedPath = StorageCore.getTempPathInDir(dirname(path)); - const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); - - try { - const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); - const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; - const imageOptions = { - format, - size, - colorspace, - quality, - processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', - }; - - const outputPath = useExtracted ? extractedPath : asset.originalPath; - await this.mediaRepository.generateThumbnail(outputPath, path, imageOptions); - } finally { - if (didExtract) { - await this.storageRepository.unlink(extractedPath); - } - } - break; - } - - case AssetType.VIDEO: { - const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); - const mainVideoStream = this.getMainStream(videoStreams); - if (!mainVideoStream) { - this.logger.warn(`Skipped thumbnail generation for asset ${asset.id}: no video streams found`); - return; - } - const mainAudioStream = this.getMainStream(audioStreams); - const config = ThumbnailConfig.create({ ...ffmpeg, targetResolution: size.toString() }); - const options = config.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); - await this.mediaRepository.transcode(asset.originalPath, path, options); - break; - } - - default: { - throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`); - } - } - - const assetLabel = asset.isExternal ? asset.originalPath : asset.id; - this.logger.log( - `Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${assetLabel}`, - ); - - return path; - } - - async handleGenerateThumbnail({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); - if (!asset) { - return JobStatus.FAILED; - } - - if (!asset.isVisible) { - return JobStatus.SKIPPED; - } - - const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL); - if (!thumbnailPath) { - return JobStatus.SKIPPED; - } - - const { thumbnailFile } = getAssetFiles(asset.files); - if (thumbnailFile && thumbnailFile.path !== thumbnailPath) { + if (thumbnailFile && thumbnailFile.path !== generated.thumbnailPath) { this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); - await this.storageRepository.unlink(thumbnailFile.path); + pathsToDelete.push(thumbnailFile.path); } - await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: thumbnailPath }); - await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); - await this.assetRepository.upsertJobStatus({ assetId: asset.id, thumbnailAt: new Date() }); + if (pathsToDelete.length > 0) { + await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); + } + + if (asset.thumbhash != generated.thumbhash) { + await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash }); + } + + await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date(), thumbnailAt: new Date() }); return JobStatus.SUCCESS; } - async handleGenerateThumbhash({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id], { files: true }); - if (!asset) { - return JobStatus.FAILED; + private async generateImageThumbnails(asset: AssetEntity) { + const { image } = await this.configCore.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + this.storageCore.ensureFolders(previewPath); + + const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); + const extractedPath = StorageCore.getTempPathInDir(dirname(previewPath)); + const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); + + try { + const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); + const inputPath = useExtracted ? extractedPath : asset.originalPath; + const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; + const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true'; + + const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size }; + const { data, info } = await this.mediaRepository.decodeImage(inputPath, decodeOptions); + + const options = { colorspace, processInvalidImages, raw: info }; + const outputs = await Promise.all([ + this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...options }, thumbnailPath), + this.mediaRepository.generateThumbnail(data, { ...image.preview, ...options }, previewPath), + this.mediaRepository.generateThumbhash(data, options), + ]); + + return { previewPath, thumbnailPath, thumbhash: outputs[2] }; + } finally { + if (didExtract) { + await this.storageRepository.unlink(extractedPath); + } } + } - if (!asset.isVisible) { - return JobStatus.SKIPPED; + private async generateVideoThumbnails(asset: AssetEntity) { + const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + this.storageCore.ensureFolders(previewPath); + + const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); + const mainVideoStream = this.getMainStream(videoStreams); + if (!mainVideoStream) { + throw new Error(`No video streams found for asset ${asset.id}`); } + const mainAudioStream = this.getMainStream(audioStreams); - const { previewFile } = getAssetFiles(asset.files); - if (!previewFile) { - return JobStatus.FAILED; - } + const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() }); + const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() }); - const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path); - await this.assetRepository.update({ id: asset.id, thumbhash }); + const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + const thumbnailOptions = thumbnailConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions); + await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions); - return JobStatus.SUCCESS; + const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, { + colorspace: image.colorspace, + processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', + }); + + return { previewPath, thumbnailPath, thumbhash }; } async handleQueueVideoConversion(job: IBaseJob): Promise { diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 80f1b2be41..0afefefff3 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -68,9 +68,7 @@ export class MicroservicesService { [JobName.MIGRATE_ASSET]: (data) => this.mediaService.handleAssetMigration(data), [JobName.MIGRATE_PERSON]: (data) => this.personService.handlePersonMigration(data), [JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data), - [JobName.GENERATE_PREVIEW]: (data) => this.mediaService.handleGeneratePreview(data), - [JobName.GENERATE_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbnail(data), - [JobName.GENERATE_THUMBHASH]: (data) => this.mediaService.handleGenerateThumbhash(data), + [JobName.GENERATE_THUMBNAILS]: (data) => this.mediaService.handleGenerateThumbnails(data), [JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data), [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), [JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataService.handleQueueMetadataExtraction(data), diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index a0b9436f75..b3a1e73541 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -155,7 +155,7 @@ describe(NotificationService.name, () => { it('should queue the generate thumbnail job', async () => { await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.GENERATE_THUMBNAIL, + name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-id', notify: true }, }); }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index bdb23ce700..fdb8257ffa 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -65,7 +65,7 @@ export class NotificationService { @OnEmit({ event: 'asset.show' }) async onAssetShow({ assetId }: ArgOf<'asset.show'>) { - await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAIL, data: { id: assetId, notify: true } }); + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id: assetId, notify: true } }); } @OnEmit({ event: 'asset.trash' }) diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 03da110ac6..c2b8f18221 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { CacheControl, Colorspace, SourceType, SystemMetadataKey } from 'src/enum'; +import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -961,12 +961,11 @@ describe(PersonService.name, () => { expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 238, top: 163, @@ -975,6 +974,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, + 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', @@ -990,13 +990,12 @@ describe(PersonService.name, () => { await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.image.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + assetStub.primaryImage.originalPath, { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 0, top: 85, @@ -1005,6 +1004,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, + 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); }); @@ -1017,12 +1017,11 @@ describe(PersonService.name, () => { expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 591, top: 591, @@ -1031,33 +1030,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, - ); - }); - - it('should use preview path for videos', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); - assetMock.getById.mockResolvedValue(assetStub.video); - mediaMock.getImageDimensions.mockResolvedValue({ width: 2560, height: 1440 }); - - await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - '/uploads/user-id/thumbs/path.jpg', 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', - { - format: 'jpeg', - size: 250, - quality: 80, - colorspace: Colorspace.P3, - crop: { - left: 1741, - top: 851, - width: 588, - height: 588, - }, - processInvalidImages: false, - }, ); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 651c8eebee..e8e16adb17 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -571,15 +571,15 @@ export class PersonService { this.storageCore.ensureFolders(thumbnailPath); const thumbnailOptions = { + colorspace: image.colorspace, format: ImageFormat.JPEG, size: FACE_THUMBNAIL_SIZE, - colorspace: image.colorspace, quality: image.thumbnail.quality, crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }), processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', - } as const; + }; - await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions); + await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath); await this.repository.update({ id: person.id, thumbnailPath }); return JobStatus.SUCCESS; diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index ba2f5e10d9..50fff31e55 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -39,5 +39,6 @@ export const newAssetRepositoryMock = (): Mocked => { getChangedDeltaSync: vitest.fn(), getDuplicates: vitest.fn(), upsertFile: vitest.fn(), + upsertFiles: vitest.fn(), }; }; diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 4c344a9866..a809b08162 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -3,8 +3,9 @@ import { Mocked, vitest } from 'vitest'; export const newMediaRepositoryMock = (): Mocked => { return { - generateThumbnail: vitest.fn(), - generateThumbhash: vitest.fn(), + generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), + generateThumbhash: vitest.fn().mockImplementation(() => Promise.resolve()), + decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), extract: vitest.fn().mockResolvedValue(false), probe: vitest.fn(), transcode: vitest.fn(), From fa9bb8074cec18cbaa2d1df29e48e8f4cbec5e9d Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 29 Sep 2024 15:22:02 +0700 Subject: [PATCH 464/723] feat(mobile): enhance download operations (#12973) * add packages * create download task * show progress * save video and image * show progress info * live photo wip * download and link live photos * Update list of assets * wip * correct progress * add state to download * revert unncessary change * repository pattern * translation * remove unused code * update method call from repository * remove unused variable * handle multiple livephotos download * remove logging statement * lint * not removing all records --- mobile/assets/i18n/en-US.json | 15 +- mobile/ios/Podfile.lock | 6 + mobile/lib/interfaces/download.interface.dart | 14 ++ mobile/lib/main.dart | 25 ++- .../asset_viewer_page_state.model.dart | 55 ----- .../models/download/download_state.model.dart | 109 ++++++++++ .../download/livephotos_medatada.model.dart | 60 ++++++ mobile/lib/pages/common/download_panel.dart | 150 ++++++++++++++ .../lib/pages/common/gallery_viewer.page.dart | 2 + .../asset_viewer/download.provider.dart | 191 +++++++++++++++++ .../image_viewer_page_state.provider.dart | 99 --------- .../lib/repositories/download.repository.dart | 68 ++++++ mobile/lib/services/download.service.dart | 193 ++++++++++++++++++ mobile/lib/services/image_viewer.service.dart | 117 ----------- mobile/lib/utils/download.dart | 3 + .../asset_viewer/bottom_gallery_bar.dart | 25 ++- .../widgets/asset_viewer/gallery_app_bar.dart | 4 +- .../lib/widgets/forms/login/login_form.dart | 2 +- mobile/pubspec.lock | 12 +- mobile/pubspec.yaml | 3 +- 20 files changed, 868 insertions(+), 285 deletions(-) create mode 100644 mobile/lib/interfaces/download.interface.dart delete mode 100644 mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart create mode 100644 mobile/lib/models/download/download_state.model.dart create mode 100644 mobile/lib/models/download/livephotos_medatada.model.dart create mode 100644 mobile/lib/pages/common/download_panel.dart create mode 100644 mobile/lib/providers/asset_viewer/download.provider.dart delete mode 100644 mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart create mode 100644 mobile/lib/repositories/download.repository.dart create mode 100644 mobile/lib/services/download.service.dart delete mode 100644 mobile/lib/services/image_viewer.service.dart create mode 100644 mobile/lib/utils/download.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 324c9069fd..bb4f3efd26 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -588,5 +588,16 @@ "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" -} \ No newline at end of file + "viewer_unstack": "Un-Stack", + "downloading_media": "Downloading media", + "download_finished": "Download finished", + "download_filename": "file: {}", + "downloading": "Downloading...", + "download_complete": "Download complete", + "download_failed": "Download failed", + "download_canceled": "Download canceled", + "download_paused": "Download paused", + "download_enqueue": "Download enqueued", + "download_notfound": "Download not found", + "download_waiting_to_retry": "Waiting to retry" +} diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 3b361c4e19..6a9d34ab83 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - background_downloader (0.0.1): + - Flutter - connectivity_plus (0.0.1): - Flutter - ReachabilitySwift @@ -99,6 +101,7 @@ PODS: - Flutter DEPENDENCIES: + - background_downloader (from `.symlinks/plugins/background_downloader/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -137,6 +140,8 @@ SPEC REPOS: - Toast EXTERNAL SOURCES: + background_downloader: + :path: ".symlinks/plugins/background_downloader/ios" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" device_info_plus: @@ -189,6 +194,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c diff --git a/mobile/lib/interfaces/download.interface.dart b/mobile/lib/interfaces/download.interface.dart new file mode 100644 index 0000000000..dc4f0f57f8 --- /dev/null +++ b/mobile/lib/interfaces/download.interface.dart @@ -0,0 +1,14 @@ +import 'package:background_downloader/background_downloader.dart'; + +abstract interface class IDownloadRepository { + void Function(TaskStatusUpdate)? onImageDownloadStatus; + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + Future> getLiveVideoTasks(); + Future download(DownloadTask task); + Future cancel(String id); + Future deleteAllTrackingRecords(); + Future deleteRecordsWithIds(List id); +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index dc1df746cb..40eda30204 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:background_downloader/background_downloader.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; @@ -9,6 +10,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/utils/download.dart'; import 'package:timezone/data/latest.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/services/background.service.dart'; @@ -72,7 +74,6 @@ Future initApp() async { var log = Logger("ImmichErrorLogger"); FlutterError.onError = (details) { - debugPrint("FlutterError - Catch all: $details"); FlutterError.presentError(details); log.severe( 'FlutterError - Catch all', @@ -82,11 +83,29 @@ Future initApp() async { }; PlatformDispatcher.instance.onError = (error, stack) { + debugPrint("FlutterError - Catch all: $error"); log.severe('PlatformDispatcher - Catch all', error, stack); return true; }; initializeTimeZones(); + + FileDownloader().configureNotification( + running: TaskNotification( + 'downloading_media'.tr(), + 'file: {filename}', + ), + complete: TaskNotification( + 'download_finished'.tr(), + 'file: {filename}', + ), + progressBar: true, + ); + + FileDownloader().trackTasksInGroup( + downloadGroupLivePhoto, + markDownloadedComplete: false, + ); } Future loadDb() async { @@ -188,8 +207,8 @@ class ImmichAppState extends ConsumerState @override Widget build(BuildContext context) { - var router = ref.watch(appRouterProvider); - var immichTheme = ref.watch(immichThemeProvider); + final router = ref.watch(appRouterProvider); + final immichTheme = ref.watch(immichThemeProvider); return MaterialApp( localizationsDelegates: context.localizationDelegates, diff --git a/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart b/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart deleted file mode 100644 index 0a354781f8..0000000000 --- a/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:convert'; - -enum DownloadAssetStatus { idle, loading, success, error } - -class AssetViewerPageState { - // enum - final DownloadAssetStatus downloadAssetStatus; - - AssetViewerPageState({ - required this.downloadAssetStatus, - }); - - AssetViewerPageState copyWith({ - DownloadAssetStatus? downloadAssetStatus, - }) { - return AssetViewerPageState( - downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus, - ); - } - - Map toMap() { - final result = {}; - - result.addAll({'downloadAssetStatus': downloadAssetStatus.index}); - - return result; - } - - factory AssetViewerPageState.fromMap(Map map) { - return AssetViewerPageState( - downloadAssetStatus: - DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0], - ); - } - - String toJson() => json.encode(toMap()); - - factory AssetViewerPageState.fromJson(String source) => - AssetViewerPageState.fromMap(json.decode(source)); - - @override - String toString() => - 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is AssetViewerPageState && - other.downloadAssetStatus == downloadAssetStatus; - } - - @override - int get hashCode => downloadAssetStatus.hashCode; -} diff --git a/mobile/lib/models/download/download_state.model.dart b/mobile/lib/models/download/download_state.model.dart new file mode 100644 index 0000000000..edd2fa183e --- /dev/null +++ b/mobile/lib/models/download/download_state.model.dart @@ -0,0 +1,109 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:collection/collection.dart'; + +class DownloadInfo { + final String fileName; + final double progress; + // enum + final TaskStatus status; + + DownloadInfo({ + required this.fileName, + required this.progress, + required this.status, + }); + + DownloadInfo copyWith({ + String? fileName, + double? progress, + TaskStatus? status, + }) { + return DownloadInfo( + fileName: fileName ?? this.fileName, + progress: progress ?? this.progress, + status: status ?? this.status, + ); + } + + Map toMap() { + return { + 'fileName': fileName, + 'progress': progress, + 'status': status.index, + }; + } + + factory DownloadInfo.fromMap(Map map) { + return DownloadInfo( + fileName: map['fileName'] as String, + progress: map['progress'] as double, + status: TaskStatus.values[map['status'] as int], + ); + } + + String toJson() => json.encode(toMap()); + + factory DownloadInfo.fromJson(String source) => + DownloadInfo.fromMap(json.decode(source) as Map); + + @override + String toString() => + 'DownloadInfo(fileName: $fileName, progress: $progress, status: $status)'; + + @override + bool operator ==(covariant DownloadInfo other) { + if (identical(this, other)) return true; + + return other.fileName == fileName && + other.progress == progress && + other.status == status; + } + + @override + int get hashCode => fileName.hashCode ^ progress.hashCode ^ status.hashCode; +} + +class DownloadState { + // enum + final TaskStatus downloadStatus; + final Map taskProgress; + final bool showProgress; + DownloadState({ + required this.downloadStatus, + required this.taskProgress, + required this.showProgress, + }); + + DownloadState copyWith({ + TaskStatus? downloadStatus, + Map? taskProgress, + bool? showProgress, + }) { + return DownloadState( + downloadStatus: downloadStatus ?? this.downloadStatus, + taskProgress: taskProgress ?? this.taskProgress, + showProgress: showProgress ?? this.showProgress, + ); + } + + @override + String toString() => + 'DownloadState(downloadStatus: $downloadStatus, taskProgress: $taskProgress, showProgress: $showProgress)'; + + @override + bool operator ==(covariant DownloadState other) { + if (identical(this, other)) return true; + final mapEquals = const DeepCollectionEquality().equals; + + return other.downloadStatus == downloadStatus && + mapEquals(other.taskProgress, taskProgress) && + other.showProgress == showProgress; + } + + @override + int get hashCode => + downloadStatus.hashCode ^ taskProgress.hashCode ^ showProgress.hashCode; +} diff --git a/mobile/lib/models/download/livephotos_medatada.model.dart b/mobile/lib/models/download/livephotos_medatada.model.dart new file mode 100644 index 0000000000..9c0c7ae4e9 --- /dev/null +++ b/mobile/lib/models/download/livephotos_medatada.model.dart @@ -0,0 +1,60 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +enum LivePhotosPart { + video, + image, +} + +class LivePhotosMetadata { + // enum + LivePhotosPart part; + + String id; + LivePhotosMetadata({ + required this.part, + required this.id, + }); + + LivePhotosMetadata copyWith({ + LivePhotosPart? part, + String? id, + }) { + return LivePhotosMetadata( + part: part ?? this.part, + id: id ?? this.id, + ); + } + + Map toMap() { + return { + 'part': part.index, + 'id': id, + }; + } + + factory LivePhotosMetadata.fromMap(Map map) { + return LivePhotosMetadata( + part: LivePhotosPart.values[map['part'] as int], + id: map['id'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory LivePhotosMetadata.fromJson(String source) => + LivePhotosMetadata.fromMap(json.decode(source) as Map); + + @override + String toString() => 'LivePhotosMetadata(part: $part, id: $id)'; + + @override + bool operator ==(covariant LivePhotosMetadata other) { + if (identical(this, other)) return true; + + return other.part == part && other.id == id; + } + + @override + int get hashCode => part.hashCode ^ id.hashCode; +} diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart new file mode 100644 index 0000000000..95cefd742a --- /dev/null +++ b/mobile/lib/pages/common/download_panel.dart @@ -0,0 +1,150 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; + +class DownloadPanel extends ConsumerWidget { + const DownloadPanel({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final showProgress = ref.watch( + downloadStateProvider.select((state) => state.showProgress), + ); + + final tasks = ref + .watch( + downloadStateProvider.select((state) => state.taskProgress), + ) + .entries + .toList(); + + onCancelDownload(String id) { + ref.watch(downloadStateProvider.notifier).cancelDownload(id); + } + + return Positioned( + bottom: 140, + left: 16, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: showProgress + ? ConstrainedBox( + constraints: + BoxConstraints.loose(Size(context.width - 32, 300)), + child: ListView.builder( + shrinkWrap: true, + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + return DownloadTaskTile( + progress: task.value.progress, + fileName: task.value.fileName, + status: task.value.status, + onCancelDownload: () => onCancelDownload(task.key), + ); + }, + ), + ) + : const SizedBox.shrink(key: ValueKey('no_progress')), + ), + ); + } +} + +class DownloadTaskTile extends StatelessWidget { + final double progress; + final String fileName; + final TaskStatus status; + final VoidCallback onCancelDownload; + + const DownloadTaskTile({ + super.key, + required this.progress, + required this.fileName, + required this.status, + required this.onCancelDownload, + }); + + @override + Widget build(BuildContext context) { + final progressPercent = (progress * 100).round(); + + getStatusText() { + switch (status) { + case TaskStatus.running: + return 'downloading'.tr(); + case TaskStatus.complete: + return 'download_complete'.tr(); + case TaskStatus.failed: + return 'download_failed'.tr(); + case TaskStatus.canceled: + return 'download_canceled'.tr(); + case TaskStatus.paused: + return 'download_paused'.tr(); + case TaskStatus.enqueued: + return 'download_enqueue'.tr(); + case TaskStatus.notFound: + return 'download_notfound'.tr(); + case TaskStatus.waitingToRetry: + return 'download_waiting_to_retry'.tr(); + } + } + + return SizedBox( + key: const ValueKey('download_progress'), + width: MediaQuery.of(context).size.width - 32, + child: Card( + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ListTile( + minVerticalPadding: 18, + leading: const Icon(Icons.video_file_outlined), + title: Text( + getStatusText(), + style: context.textTheme.labelLarge, + ), + trailing: IconButton( + icon: Icon(Icons.close, color: context.colorScheme.onError), + onPressed: onCancelDownload, + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error.withAlpha(200), + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: context.textTheme.labelMedium, + ), + Row( + children: [ + Expanded( + child: LinearProgressIndicator( + minHeight: 8.0, + value: progress, + borderRadius: + const BorderRadius.all(Radius.circular(10.0)), + ), + ), + const SizedBox(width: 8), + Text( + '$progressPercent%', + style: context.textTheme.labelSmall, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 1434d1cca5..57c75ca84d 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/download_panel.dart'; import 'package:immich_mobile/pages/common/video_viewer.page.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; @@ -421,6 +422,7 @@ class GalleryViewerPage extends HookConsumerWidget { ], ), ), + const DownloadPanel(), ], ), ), diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart new file mode 100644 index 0000000000..d4aa2823b5 --- /dev/null +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -0,0 +1,191 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/download/download_state.model.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/services/download.service.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/services/share.service.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/widgets/common/share_dialog.dart'; + +class DownloadStateNotifier extends StateNotifier { + final DownloadService _downloadService; + final ShareService _shareService; + + DownloadStateNotifier( + this._downloadService, + this._shareService, + ) : super( + DownloadState( + downloadStatus: TaskStatus.complete, + showProgress: false, + taskProgress: {}, + ), + ) { + _downloadService.onImageDownloadStatus = _downloadImageCallback; + _downloadService.onVideoDownloadStatus = _downloadVideoCallback; + _downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback; + _downloadService.onTaskProgress = _taskProgressCallback; + } + + void _updateDownloadStatus(String taskId, TaskStatus status) { + if (status == TaskStatus.canceled) { + return; + } + + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + taskId: DownloadInfo( + progress: state.taskProgress[taskId]?.progress ?? 0, + fileName: state.taskProgress[taskId]?.fileName ?? '', + status: status, + ), + }), + ); + } + + // Download live photo callback + void _downloadLivePhotoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + if (update.task.metaData.isEmpty) { + return; + } + final livePhotosId = + LivePhotosMetadata.fromJson(update.task.metaData).id; + _downloadService.saveLivePhotos(update.task, livePhotosId); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download image callback + void _downloadImageCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveImage(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download video callback + void _downloadVideoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveVideo(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + void _taskProgressCallback(TaskProgressUpdate update) { + // Ignore if the task is cancled or completed + if (update.progress == -2 || update.progress == -1) { + return; + } + + state = state.copyWith( + showProgress: true, + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + update.task.taskId: DownloadInfo( + progress: update.progress, + fileName: update.task.filename, + status: TaskStatus.running, + ), + }), + ); + } + + void _onDownloadComplete(String id) { + Future.delayed(const Duration(seconds: 2), () { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + }); + } + + void downloadAsset(Asset asset, BuildContext context) async { + await _downloadService.download(asset); + } + + void cancelDownload(String id) async { + final isCanceled = await _downloadService.cancelDownload(id); + + if (isCanceled) { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + } + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + } + + void shareAsset(Asset asset, BuildContext context) async { + showDialog( + context: context, + builder: (BuildContext buildContext) { + _shareService.shareAsset(asset, context).then( + (bool status) { + if (!status) { + ImmichToast.show( + context: context, + msg: 'image_viewer_page_state_provider_share_error'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + buildContext.pop(); + }, + ); + return const ShareDialog(); + }, + barrierDismissible: false, + ); + } +} + +final downloadStateProvider = + StateNotifierProvider( + ((ref) => DownloadStateNotifier( + ref.watch(downloadServiceProvider), + ref.watch(shareServiceProvider), + )), +); diff --git a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart b/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart deleted file mode 100644 index 631011f200..0000000000 --- a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:io'; - -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/models/asset_viewer/asset_viewer_page_state.model.dart'; -import 'package:immich_mobile/services/image_viewer.service.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/services/share.service.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/widgets/common/share_dialog.dart'; - -class ImageViewerStateNotifier extends StateNotifier { - final ImageViewerService _imageViewerService; - final ShareService _shareService; - final AlbumService _albumService; - - ImageViewerStateNotifier( - this._imageViewerService, - this._shareService, - this._albumService, - ) : super( - AssetViewerPageState( - downloadAssetStatus: DownloadAssetStatus.idle, - ), - ); - - void downloadAsset(Asset asset, BuildContext context) async { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading); - - ImmichToast.show( - context: context, - msg: 'download_started'.tr(), - toastType: ToastType.info, - gravity: ToastGravity.BOTTOM, - ); - - bool isSuccess = await _imageViewerService.downloadAsset(asset); - - if (isSuccess) { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success); - - ImmichToast.show( - context: context, - msg: Platform.isAndroid - ? 'download_sucess_android'.tr() - : 'download_sucess'.tr(), - toastType: ToastType.success, - gravity: ToastGravity.BOTTOM, - ); - _albumService.refreshDeviceAlbums(); - } else { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error); - ImmichToast.show( - context: context, - msg: 'download_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle); - } - - void shareAsset(Asset asset, BuildContext context) async { - showDialog( - context: context, - builder: (BuildContext buildContext) { - _shareService.shareAsset(asset, context).then( - (bool status) { - if (!status) { - ImmichToast.show( - context: context, - msg: 'image_viewer_page_state_provider_share_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - buildContext.pop(); - }, - ); - return const ShareDialog(); - }, - barrierDismissible: false, - ); - } -} - -final imageViewerStateProvider = - StateNotifierProvider( - ((ref) => ImageViewerStateNotifier( - ref.watch(imageViewerServiceProvider), - ref.watch(shareServiceProvider), - ref.watch(albumServiceProvider), - )), -); diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart new file mode 100644 index 0000000000..5b42f66b02 --- /dev/null +++ b/mobile/lib/repositories/download.repository.dart @@ -0,0 +1,68 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/download.interface.dart'; +import 'package:immich_mobile/utils/download.dart'; + +final downloadRepositoryProvider = Provider((ref) => DownloadRepository()); + +class DownloadRepository implements IDownloadRepository { + @override + void Function(TaskStatusUpdate)? onImageDownloadStatus; + + @override + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + + @override + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + + @override + void Function(TaskProgressUpdate)? onTaskProgress; + + DownloadRepository() { + FileDownloader().registerCallbacks( + group: downloadGroupImage, + taskStatusCallback: (update) => onImageDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + + FileDownloader().registerCallbacks( + group: downloadGroupVideo, + taskStatusCallback: (update) => onVideoDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + + FileDownloader().registerCallbacks( + group: downloadGroupLivePhoto, + taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + } + + @override + Future download(DownloadTask task) { + return FileDownloader().enqueue(task); + } + + @override + Future deleteAllTrackingRecords() { + return FileDownloader().database.deleteAllRecords(); + } + + @override + Future cancel(String id) { + return FileDownloader().cancelTaskWithId(id); + } + + @override + Future> getLiveVideoTasks() { + return FileDownloader().database.allRecordsWithStatus( + TaskStatus.complete, + group: downloadGroupLivePhoto, + ); + } + + @override + Future deleteRecordsWithIds(List ids) { + return FileDownloader().database.deleteRecordsWithIds(ids); + } +} diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart new file mode 100644 index 0000000000..996cbe61f1 --- /dev/null +++ b/mobile/lib/services/download.service.dart @@ -0,0 +1,193 @@ +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/download.interface.dart'; +import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/repositories/download.repository.dart'; +import 'package:immich_mobile/repositories/file_media.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/download.dart'; + +final downloadServiceProvider = Provider( + (ref) => DownloadService( + ref.watch(fileMediaRepositoryProvider), + ref.watch(downloadRepositoryProvider), + ), +); + +class DownloadService { + final IDownloadRepository _downloadRepository; + final IFileMediaRepository _fileMediaRepository; + void Function(TaskStatusUpdate)? onImageDownloadStatus; + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + DownloadService( + this._fileMediaRepository, + this._downloadRepository, + ) { + _downloadRepository.onImageDownloadStatus = _onImageDownloadCallback; + _downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback; + _downloadRepository.onLivePhotoDownloadStatus = + _onLivePhotoDownloadCallback; + _downloadRepository.onTaskProgress = _onTaskProgressCallback; + } + + void _onTaskProgressCallback(TaskProgressUpdate update) { + onTaskProgress?.call(update); + } + + void _onImageDownloadCallback(TaskStatusUpdate update) { + onImageDownloadStatus?.call(update); + } + + void _onVideoDownloadCallback(TaskStatusUpdate update) { + onVideoDownloadStatus?.call(update); + } + + void _onLivePhotoDownloadCallback(TaskStatusUpdate update) { + onLivePhotoDownloadStatus?.call(update); + } + + Future saveImage(Task task) async { + final filePath = await task.filePath(); + final title = task.filename; + final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; + final data = await File(filePath).readAsBytes(); + + final Asset? resultAsset = await _fileMediaRepository.saveImage( + data, + title: title, + relativePath: relativePath, + ); + + return resultAsset != null; + } + + Future saveVideo(Task task) async { + final filePath = await task.filePath(); + final title = task.filename; + final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; + final file = File(filePath); + + final Asset? resultAsset = await _fileMediaRepository.saveVideo( + file, + title: title, + relativePath: relativePath, + ); + + return resultAsset != null; + } + + Future saveLivePhotos( + Task task, + String livePhotosId, + ) async { + try { + final records = await _downloadRepository.getLiveVideoTasks(); + if (records.length < 2) { + return false; + } + + final imageRecord = records.firstWhere( + (record) { + final metadata = LivePhotosMetadata.fromJson(record.task.metaData); + return metadata.id == livePhotosId && + metadata.part == LivePhotosPart.image; + }, + ); + + final videoRecord = records.firstWhere((record) { + final metadata = LivePhotosMetadata.fromJson(record.task.metaData); + return metadata.id == livePhotosId && + metadata.part == LivePhotosPart.video; + }); + + final imageFilePath = await imageRecord.task.filePath(); + final videoFilePath = await videoRecord.task.filePath(); + + final resultAsset = await _fileMediaRepository.saveLivePhoto( + image: File(imageFilePath), + video: File(videoFilePath), + title: task.filename, + ); + + await _downloadRepository.deleteRecordsWithIds([ + imageRecord.task.taskId, + videoRecord.task.taskId, + ]); + + return resultAsset != null; + } catch (error) { + debugPrint("Error saving live photo: $error"); + return false; + } + } + + Future cancelDownload(String id) async { + return await FileDownloader().cancelTaskWithId(id); + } + + Future download(Asset asset) async { + if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { + await _downloadRepository.download( + _buildDownloadTask( + asset.remoteId!, + asset.fileName, + group: downloadGroupLivePhoto, + metadata: LivePhotosMetadata( + part: LivePhotosPart.image, + id: asset.remoteId!, + ).toJson(), + ), + ); + + await _downloadRepository.download( + _buildDownloadTask( + asset.livePhotoVideoId!, + asset.fileName.toUpperCase().replaceAll(".HEIC", '.MOV'), + group: downloadGroupLivePhoto, + metadata: LivePhotosMetadata( + part: LivePhotosPart.video, + id: asset.remoteId!, + ).toJson(), + ), + ); + } else { + await _downloadRepository.download( + _buildDownloadTask( + asset.remoteId!, + asset.fileName, + group: asset.isImage ? downloadGroupImage : downloadGroupVideo, + ), + ); + } + } + + DownloadTask _buildDownloadTask( + String id, + String filename, { + String? group, + String? metadata, + }) { + final path = r'/assets/{id}/original'.replaceAll('{id}', id); + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final headers = ApiService.getRequestHeaders(); + + return DownloadTask( + taskId: id, + url: serverEndpoint + path, + headers: headers, + filename: filename, + updates: Updates.statusAndProgress, + group: group ?? '', + metaData: metadata ?? '', + ); + } +} diff --git a/mobile/lib/services/image_viewer.service.dart b/mobile/lib/services/image_viewer.service.dart deleted file mode 100644 index c94244175b..0000000000 --- a/mobile/lib/services/image_viewer.service.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:io'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/response_extensions.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/interfaces/file_media.interface.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/file_media.repository.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:logging/logging.dart'; - -import 'package:path_provider/path_provider.dart'; - -final imageViewerServiceProvider = Provider( - (ref) => ImageViewerService( - ref.watch(apiServiceProvider), - ref.watch(fileMediaRepositoryProvider), - ), -); - -class ImageViewerService { - final ApiService _apiService; - final IFileMediaRepository _fileMediaRepository; - final Logger _log = Logger("ImageViewerService"); - - ImageViewerService(this._apiService, this._fileMediaRepository); - - Future downloadAsset(Asset asset) async { - File? imageFile; - File? videoFile; - try { - // Download LivePhotos image and motion part - if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { - var imageResponse = - await _apiService.assetsApi.downloadAssetWithHttpInfo( - asset.remoteId!, - ); - - var motionResponse = - await _apiService.assetsApi.downloadAssetWithHttpInfo( - asset.livePhotoVideoId!, - ); - - if (imageResponse.statusCode != 200 || - motionResponse.statusCode != 200) { - final failedResponse = - imageResponse.statusCode != 200 ? imageResponse : motionResponse; - _log.severe( - "Motion asset download failed", - failedResponse.toLoggerString(), - ); - return false; - } - - Asset? resultAsset; - - final tempDir = await getTemporaryDirectory(); - videoFile = await File('${tempDir.path}/livephoto.mov').create(); - imageFile = await File('${tempDir.path}/livephoto.heic').create(); - videoFile.writeAsBytesSync(motionResponse.bodyBytes); - imageFile.writeAsBytesSync(imageResponse.bodyBytes); - - resultAsset = await _fileMediaRepository.saveLivePhoto( - image: imageFile, - video: videoFile, - title: asset.fileName, - ); - - if (resultAsset == null) { - _log.warning( - "Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file", - ); - resultAsset = await _fileMediaRepository - .saveImage(imageResponse.bodyBytes, title: asset.fileName); - } - - return resultAsset != null; - } else { - var res = await _apiService.assetsApi - .downloadAssetWithHttpInfo(asset.remoteId!); - - if (res.statusCode != 200) { - _log.severe("Asset download failed", res.toLoggerString()); - return false; - } - - final Asset? resultAsset; - final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; - - if (asset.isImage) { - resultAsset = await _fileMediaRepository.saveImage( - res.bodyBytes, - title: asset.fileName, - relativePath: relativePath, - ); - } else { - final tempDir = await getTemporaryDirectory(); - videoFile = await File('${tempDir.path}/${asset.fileName}').create(); - videoFile.writeAsBytesSync(res.bodyBytes); - resultAsset = await _fileMediaRepository.saveVideo( - videoFile, - title: asset.fileName, - relativePath: relativePath, - ); - } - return resultAsset != null; - } - } catch (error, stack) { - _log.severe("Error saving downloaded asset", error, stack); - return false; - } finally { - // Clear temp files - imageFile?.delete(); - videoFile?.delete(); - } - } -} diff --git a/mobile/lib/utils/download.dart b/mobile/lib/utils/download.dart new file mode 100644 index 0000000000..c701f353a2 --- /dev/null +++ b/mobile/lib/utils/download.dart @@ -0,0 +1,3 @@ +const downloadGroupImage = 'group_image'; +const downloadGroupVideo = 'group_video'; +const downloadGroupLivePhoto = 'group_livephoto'; diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 8b5684d0fa..c3f1390dba 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -9,7 +9,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; @@ -172,7 +172,16 @@ class BottomGalleryBar extends ConsumerWidget { } shareAsset() { - ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context); + if (asset.isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + ref.read(downloadStateProvider.notifier).shareAsset(asset, context); } void handleEdit() async { @@ -202,7 +211,17 @@ class BottomGalleryBar extends ConsumerWidget { if (asset.isLocal) { return; } - ref.read(imageViewerStateProvider.notifier).downloadAsset( + if (asset.isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + + ref.read(downloadStateProvider.notifier).downloadAsset( asset, context, ); diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index 6de8f5da33..f400224e0a 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -5,7 +5,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart'; -import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; @@ -94,7 +94,7 @@ class GalleryAppBar extends ConsumerWidget { } handleDownloadAsset() { - ref.read(imageViewerStateProvider.notifier).downloadAsset(asset, context); + ref.read(downloadStateProvider.notifier).downloadAsset(asset, context); } return IgnorePointer( diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 51383fe195..01b717ef5b 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -176,7 +176,7 @@ class LoginForm extends HookConsumerWidget { populateTestLoginInfo1() { usernameController.text = 'testuser@email.com'; passwordController.text = 'password'; - serverEndpointController.text = 'http://10.1.15.216:2283/api'; + serverEndpointController.text = 'http://192.168.1.16:2283/api'; } login() async { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index aaea00d699..9dadbd1028 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -78,6 +78,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.0.0" + background_downloader: + dependency: "direct main" + description: + name: background_downloader + sha256: "6a945db1a1c7727a4bc9c1d7c882cfb1a819f873b77e01d5e5dd6a3fb231cb28" + url: "https://pub.dev" + source: hosted + version: "8.5.5" boolean_selector: dependency: transitive description: @@ -744,10 +752,10 @@ packages: dependency: "direct main" description: name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "0.13.6" + version: "1.2.2" http_multi_server: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index dc1eb11ca7..092b0bb75c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -32,7 +32,7 @@ dependencies: flutter_svg: ^2.0.9 package_info_plus: ^8.0.1 url_launcher: ^6.2.4 - http: ^0.13.6 + http: ^1.1.0 cancellation_token_http: ^2.0.0 easy_localization: ^3.0.3 share_plus: ^10.0.0 @@ -56,6 +56,7 @@ dependencies: thumbhash: 0.1.0+1 async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme + background_downloader: ^8.5.5 #image editing packages crop_image: ^1.0.13 From 9b309e84c922b2874afdce21961627a2841861c4 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 30 Sep 2024 00:11:42 -0400 Subject: [PATCH 465/723] docs: update config file (#13041) update config file --- docs/docs/install/config-file.md | 82 +++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index abbba8c6b3..b789d8653f 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -20,6 +20,7 @@ The default configuration looks like this: "acceptedVideoCodecs": ["h264"], "targetAudioCodec": "aac", "acceptedAudioCodecs": ["aac", "mp3", "libopus"], + "acceptedContainers": ["mov", "ogg", "webm"], "targetResolution": "720", "maxBitrate": "0", "bframes": -1, @@ -32,7 +33,8 @@ The default configuration looks like this: "preferredHwDevice": "auto", "transcode": "required", "tonemap": "hable", - "accel": "disabled" + "accel": "disabled", + "accelDecode": false }, "job": { "backgroundTask": { @@ -60,10 +62,13 @@ The default configuration looks like this: "concurrency": 5 }, "thumbnailGeneration": { - "concurrency": 5 + "concurrency": 3 }, "videoConversion": { "concurrency": 1 + }, + "notifications": { + "concurrency": 5 } }, "logging": { @@ -78,40 +83,46 @@ The default configuration looks like this: "modelName": "ViT-B-32__openai" }, "duplicateDetection": { - "enabled": false, - "maxDistance": 0.03 + "enabled": true, + "maxDistance": 0.01 }, "facialRecognition": { "enabled": true, "modelName": "buffalo_l", "minScore": 0.7, - "maxDistance": 0.6, + "maxDistance": 0.5, "minFaces": 3 } }, "map": { "enabled": true, - "lightStyle": "", - "darkStyle": "" + "lightStyle": "https://tiles.immich.cloud/v1/style/light.json", + "darkStyle": "https://tiles.immich.cloud/v1/style/dark.json" }, "reverseGeocoding": { "enabled": true }, + "metadata": { + "faces": { + "import": false + } + }, "oauth": { - "enabled": false, - "issuerUrl": "", + "autoLaunch": false, + "autoRegister": true, + "buttonText": "Login with OAuth", "clientId": "", "clientSecret": "", + "defaultStorageQuota": 0, + "enabled": false, + "issuerUrl": "", + "mobileOverrideEnabled": false, + "mobileRedirectUri": "", "scope": "openid email profile", "signingAlgorithm": "RS256", + "profileSigningAlgorithm": "none", "storageLabelClaim": "preferred_username", - "storageQuotaClaim": "immich_quota", - "defaultStorageQuota": 0, - "buttonText": "Login with OAuth", - "autoRegister": true, - "autoLaunch": false, - "mobileOverrideEnabled": false, - "mobileRedirectUri": "" + "storageQuotaClaim": "immich_quota" }, "passwordLogin": { "enabled": true @@ -122,11 +133,16 @@ The default configuration looks like this: "template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}" }, "image": { - "thumbnailFormat": "webp", - "thumbnailSize": 250, - "previewFormat": "jpeg", - "previewSize": 1440, - "quality": 80, + "thumbnail": { + "format": "webp", + "size": 250, + "quality": 80 + }, + "preview": { + "format": "jpeg", + "size": 1440, + "quality": 80 + }, "colorspace": "p3", "extractEmbedded": false }, @@ -140,23 +156,35 @@ The default configuration looks like this: "theme": { "customCss": "" }, - "user": { - "deleteDelay": 7 - }, "library": { "scan": { "enabled": true, "cronExpression": "0 0 * * *" }, "watch": { - "enabled": false, - "usePolling": false, - "interval": 10000 + "enabled": false } }, "server": { "externalDomain": "", "loginPageMessage": "" + }, + "notifications": { + "smtp": { + "enabled": false, + "from": "", + "replyTo": "", + "transport": { + "ignoreCert": false, + "host": "", + "port": 587, + "username": "", + "password": "" + } + } + }, + "user": { + "deleteDelay": 7 } } ``` From 2f13db51df15d90221cb4f964936482003da21f2 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 30 Sep 2024 00:29:14 -0400 Subject: [PATCH 466/723] fix(server): "all" button for facial recognition deleting faces instead of unassigning them (#13042) * unassign faces instead of deleting them * formatting --- server/src/interfaces/person.interface.ts | 10 ++++-- server/src/repositories/person.repository.ts | 36 +++++++++++++------ server/src/services/person.service.spec.ts | 5 +-- server/src/services/person.service.ts | 14 ++------ .../repositories/person.repository.mock.ts | 3 +- 5 files changed, 39 insertions(+), 29 deletions(-) diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index 5708274a6e..65814e0046 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -1,6 +1,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SourceType } from 'src/enum'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; @@ -40,10 +41,12 @@ export interface PeopleStatistics { hidden: number; } -export interface DeleteAllFacesOptions { - sourceType?: string; +export interface DeleteFacesOptions { + sourceType: SourceType; } +export type UnassignFacesOptions = DeleteFacesOptions; + export interface IPersonRepository { getAll(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated; @@ -59,7 +62,7 @@ export interface IPersonRepository { createFaces(entities: Partial[]): Promise; delete(entities: PersonEntity[]): Promise; deleteAll(): Promise; - deleteAllFaces(options: DeleteAllFacesOptions): Promise; + deleteFaces(options: DeleteFacesOptions): Promise; replaceFaces(assetId: string, entities: Partial[], sourceType?: string): Promise; getAllFaces(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getFaceById(id: string): Promise; @@ -75,6 +78,7 @@ export interface IPersonRepository { reassignFace(assetFaceId: string, newPersonId: string): Promise; getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; + unassignFaces(options: UnassignFacesOptions): Promise; update(person: Partial): Promise; updateAll(people: Partial[]): Promise; getLatestFaceDate(): Promise; diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 2607d2a9ec..0350e8a953 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -9,13 +9,14 @@ import { PersonEntity } from 'src/entities/person.entity'; import { PaginationMode, SourceType } from 'src/enum'; import { AssetFaceId, - DeleteAllFacesOptions, + DeleteFacesOptions, IPersonRepository, PeopleStatistics, PersonNameResponse, PersonNameSearchOptions, PersonSearchOptions, PersonStatistics, + UnassignFacesOptions, UpdateFacesData, } from 'src/interfaces/person.interface'; import { Instrumentation } from 'src/utils/instrumentation'; @@ -39,12 +40,23 @@ export class PersonRepository implements IPersonRepository { .createQueryBuilder() .update() .set({ personId: newPersonId }) - .where(_.omitBy({ personId: oldPersonId ?? undefined, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) + .where(_.omitBy({ personId: oldPersonId, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) .execute(); return result.affected ?? 0; } + async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { + await this.assetFaceRepository + .createQueryBuilder() + .update() + .set({ personId: null }) + .where({ sourceType }) + .execute(); + + await this.vacuum({ reindexVectors: false }); + } + async delete(entities: PersonEntity[]): Promise { await this.personRepository.remove(entities); } @@ -53,21 +65,14 @@ export class PersonRepository implements IPersonRepository { await this.personRepository.clear(); } - async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise { - if (!sourceType) { - return this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE'); - } - + async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { await this.assetFaceRepository .createQueryBuilder('asset_faces') .delete() .andWhere('sourceType = :sourceType', { sourceType }) .execute(); - await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search'); - if (sourceType === SourceType.MACHINE_LEARNING) { - await this.assetFaceRepository.query('REINDEX INDEX face_index'); - } + await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); } getAllFaces( @@ -331,4 +336,13 @@ export class PersonRepository implements IPersonRepository { const { id } = await this.personRepository.save(person); return this.personRepository.findOneByOrFail({ id }); } + + private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { + await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search, person'); + await this.assetFaceRepository.query('REINDEX TABLE asset_faces'); + await this.assetFaceRepository.query('REINDEX TABLE person'); + if (reindexVectors) { + await this.assetFaceRepository.query('REINDEX TABLE face_search'); + } + } } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index c2b8f18221..5214808de0 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -660,7 +660,7 @@ describe(PersonService.name, () => { expect(systemMock.set).not.toHaveBeenCalled(); }); - it('should delete existing people and faces if forced', async () => { + it('should delete existing people if forced', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); personMock.getAll.mockResolvedValue({ items: [faceStub.face1.person, personStub.randomPerson], @@ -675,7 +675,8 @@ describe(PersonService.name, () => { await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.deleteAllFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(personMock.deleteFaces).not.toHaveBeenCalled(); + expect(personMock.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index e8e16adb17..b009696b63 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -276,16 +276,6 @@ export class PersonService { this.logger.debug(`Deleted ${people.length} people`); } - private async deleteAllPeople() { - const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.repository.getAll({ ...pagination, skip: 0 }), - ); - - for await (const people of personPagination) { - await this.delete(people); // deletes thumbnails too - } - } - async handlePersonCleanup(): Promise { const people = await this.repository.getAllWithoutFaces(); await this.delete(people); @@ -299,7 +289,7 @@ export class PersonService { } if (force) { - await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.repository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); } @@ -407,7 +397,7 @@ export class PersonService { const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION); if (force) { - await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.repository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); } else if (waiting) { this.logger.debug( diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 77e8ccf010..6ffe7bf97b 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -18,7 +18,7 @@ export const newPersonRepositoryMock = (): Mocked => { updateAll: vitest.fn(), delete: vitest.fn(), deleteAll: vitest.fn(), - deleteAllFaces: vitest.fn(), + deleteFaces: vitest.fn(), getStatistics: vitest.fn(), getAllFaces: vitest.fn(), @@ -26,6 +26,7 @@ export const newPersonRepositoryMock = (): Mocked => { getRandomFace: vitest.fn(), reassignFaces: vitest.fn(), + unassignFaces: vitest.fn(), createFaces: vitest.fn(), replaceFaces: vitest.fn(), getFaces: vitest.fn(), From 7adb35e59e5c8e00e5391abfb69bee7acb068bb2 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 30 Sep 2024 00:29:35 -0400 Subject: [PATCH 467/723] fix(server): `/search/random` failing with certain options (#13040) * fix relation handling, remove pagination * update api, sql * update mock --- mobile/openapi/lib/api/search_api.dart | 9 +- .../openapi/lib/model/random_search_dto.dart | 20 +- open-api/immich-openapi-specs.json | 9 +- open-api/typescript-sdk/src/fetch-client.ts | 3 +- server/src/controllers/search.controller.ts | 2 +- server/src/dtos/search.dto.ts | 18 +- server/src/interfaces/search.interface.ts | 2 +- server/src/queries/search.repository.sql | 189 +++++++++++++++++- server/src/repositories/search.repository.ts | 41 +++- server/src/services/search.service.ts | 16 +- server/src/utils/database.ts | 2 +- .../repositories/search.repository.mock.ts | 1 + 12 files changed, 250 insertions(+), 62 deletions(-) diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 3b981e0ccb..985029f106 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -383,7 +383,7 @@ class SearchApi { /// Parameters: /// /// * [RandomSearchDto] randomSearchDto (required): - Future searchRandom(RandomSearchDto randomSearchDto,) async { + Future?> searchRandom(RandomSearchDto randomSearchDto,) async { final response = await searchRandomWithHttpInfo(randomSearchDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -392,8 +392,11 @@ class SearchApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchResponseDto',) as SearchResponseDto; - + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + } return null; } diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 419cb451e2..3fcab05bbb 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -29,7 +29,6 @@ class RandomSearchDto { this.libraryId, this.make, this.model, - this.page, this.personIds = const [], this.size, this.state, @@ -145,15 +144,6 @@ class RandomSearchDto { String? model; - /// Minimum value: 1 - /// - /// 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 - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - num? page; - List personIds; /// Minimum value: 1 @@ -276,7 +266,6 @@ class RandomSearchDto { other.libraryId == libraryId && other.make == make && other.model == model && - other.page == page && _deepEquality.equals(other.personIds, personIds) && other.size == size && other.state == state && @@ -312,7 +301,6 @@ class RandomSearchDto { (libraryId == null ? 0 : libraryId!.hashCode) + (make == null ? 0 : make!.hashCode) + (model == null ? 0 : model!.hashCode) + - (page == null ? 0 : page!.hashCode) + (personIds.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + @@ -330,7 +318,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, page=$page, 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, 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 toJson() { final json = {}; @@ -413,11 +401,6 @@ class RandomSearchDto { json[r'model'] = this.model; } else { // json[r'model'] = null; - } - if (this.page != null) { - json[r'page'] = this.page; - } else { - // json[r'page'] = null; } json[r'personIds'] = this.personIds; if (this.size != null) { @@ -514,7 +497,6 @@ class RandomSearchDto { libraryId: mapValueOfType(json, r'libraryId'), make: mapValueOfType(json, r'make'), model: mapValueOfType(json, r'model'), - page: num.parse('${json[r'page']}'), personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1077762ac3..970230f4e3 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4615,7 +4615,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SearchResponseDto" + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" } } }, @@ -10463,10 +10466,6 @@ "nullable": true, "type": "string" }, - "page": { - "minimum": 1, - "type": "number" - }, "personIds": { "items": { "format": "uuid", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index e88f431e8c..aa3501079b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -852,7 +852,6 @@ export type RandomSearchDto = { libraryId?: string | null; make?: string; model?: string | null; - page?: number; personIds?: string[]; size?: number; state?: string | null; @@ -2523,7 +2522,7 @@ export function searchRandom({ randomSearchDto }: { }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: SearchResponseDto; + data: AssetResponseDto[]; }>("/search/random", oazapfts.json({ ...opts, method: "POST", diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 5b6deb2981..9fdb2746fc 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -32,7 +32,7 @@ export class SearchController { @Post('random') @HttpCode(HttpStatus.OK) @Authenticated() - searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { + searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { return this.service.searchRandom(auth, dto); } diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index ddc6c192c5..5c5dce1a11 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -99,12 +99,6 @@ class BaseSearchDto { @Optional({ nullable: true, emptyToNull: true }) lensModel?: string | null; - @IsInt() - @Min(1) - @Type(() => Number) - @Optional() - page?: number; - @IsInt() @Min(1) @Max(1000) @@ -170,12 +164,24 @@ export class MetadataSearchDto extends RandomSearchDto { @Optional() @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) order?: AssetOrder; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; } export class SmartSearchDto extends BaseSearchDto { @IsString() @IsNotEmpty() query!: string; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; } export class SearchPlacesDto { diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 0ba524c00a..63d74a35fb 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -116,7 +116,6 @@ export interface SearchPeopleOptions { export interface SearchOrderOptions { orderDirection?: 'ASC' | 'DESC'; - random?: boolean; } export interface SearchPaginationOptions { @@ -177,6 +176,7 @@ export interface ISearchRepository { searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; searchDuplicates(options: AssetDuplicateSearch): Promise; searchFaces(search: FaceEmbeddingSearch): Promise; + searchRandom(size: number, options: AssetSearchOptions): Promise; upsert(assetId: string, embedding: number[]): Promise; searchPlaces(placeName: string): Promise; getAssetsByCity(userIds: string[]): Promise; diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 58b2999012..cd9a84b016 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -77,10 +77,11 @@ FROM "asset"."fileCreatedAt" >= $1 AND "exifInfo"."lensModel" = $2 AND 1 = 1 + AND "asset"."ownerId" IN ($3) AND 1 = 1 AND ( - "asset"."isFavorite" = $3 - AND "asset"."isArchived" = $4 + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 ) ) AND ("asset"."deletedAt" IS NULL) @@ -91,6 +92,190 @@ ORDER BY LIMIT 101 +-- SearchRepository.searchRandom +SELECT DISTINCT + "distinctAlias"."asset_id" AS "ids_asset_id", + "distinctAlias"."asset_id" +FROM + ( + SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" + FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + WHERE + ( + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND 1 = 1 + AND "asset"."ownerId" IN ($3) + AND 1 = 1 + AND ( + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 + ) + AND "asset"."id" > $6 + ) + AND ("asset"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "distinctAlias"."asset_id" ASC, + "asset_id" ASC +LIMIT + 100 +SELECT DISTINCT + "distinctAlias"."asset_id" AS "ids_asset_id", + "distinctAlias"."asset_id" +FROM + ( + SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" + FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + WHERE + ( + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND 1 = 1 + AND "asset"."ownerId" IN ($3) + AND 1 = 1 + AND ( + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 + ) + AND "asset"."id" < $6 + ) + AND ("asset"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "distinctAlias"."asset_id" ASC, + "asset_id" ASC +LIMIT + 100 + -- SearchRepository.searchSmart START TRANSACTION SET diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 60694b6bfe..cb80c8d2f1 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'node:crypto'; import { getVectorExtension } from 'src/database.config'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; @@ -63,22 +64,15 @@ export class SearchRepository implements ISearchRepository { { takenAfter: DummyValue.DATE, lensModel: DummyValue.STRING, - ownerId: DummyValue.UUID, withStacked: true, isFavorite: true, - ownerIds: [DummyValue.UUID], + userIds: [DummyValue.UUID], }, ], }) async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { let builder = this.assetRepository.createQueryBuilder('asset'); - builder = searchAssetBuilder(builder, options); - builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); - - if (options.random) { - // TODO replace with complicated SQL magic after kysely migration - builder.addSelect('RANDOM() as r').orderBy('r'); - } + builder = searchAssetBuilder(builder, options).orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, @@ -87,6 +81,35 @@ export class SearchRepository implements ISearchRepository { }); } + @GenerateSql({ + params: [ + 100, + { + takenAfter: DummyValue.DATE, + lensModel: DummyValue.STRING, + withStacked: true, + isFavorite: true, + userIds: [DummyValue.UUID], + }, + ], + }) + async searchRandom(size: number, options: AssetSearchOptions): Promise { + const builder1 = searchAssetBuilder(this.assetRepository.createQueryBuilder('asset'), options); + const builder2 = builder1.clone(); + + const uuid = randomUUID(); + builder1.andWhere('asset.id > :uuid', { uuid }).orderBy('asset.id').take(size); + builder2.andWhere('asset.id < :uuid', { uuid }).orderBy('asset.id').take(size); + + const [assets1, assets2] = await Promise.all([builder1.getMany(), builder2.getMany()]); + const missingCount = size - assets1.length; + for (let i = 0; i < missingCount && i < assets2.length; i++) { + assets1.push(assets2[i]); + } + + return assets1; + } + private createPersonFilter(builder: SelectQueryBuilder, personIds: string[]) { return builder .select(`${builder.alias}."assetId"`) diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index dc6e71f345..c3cc5399c8 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -94,20 +94,10 @@ export class SearchService { return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } - async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { + async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { const userIds = await this.getUserIdsToSearch(auth); - const page = dto.page ?? 1; - const size = dto.size || 250; - const { hasNextPage, items } = await this.searchRepository.searchMetadata( - { page, size }, - { - ...dto, - userIds, - random: true, - }, - ); - - return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); + const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds }); + return items.map((item) => mapAsset(item, { auth })); } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 5f4577f4df..498dd3456b 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -120,7 +120,7 @@ export function searchAssetBuilder( } if (withPeople) { - builder.leftJoinAndSelect(`${builder.alias}.person`, 'person'); + builder.leftJoinAndSelect('faces.person', 'person'); } if (withSmartInfo) { diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts index 5426316b65..be0e753e30 100644 --- a/server/test/repositories/search.repository.mock.ts +++ b/server/test/repositories/search.repository.mock.ts @@ -7,6 +7,7 @@ export const newSearchRepositoryMock = (): Mocked => { searchSmart: vitest.fn(), searchDuplicates: vitest.fn(), searchFaces: vitest.fn(), + searchRandom: vitest.fn(), upsert: vitest.fn(), searchPlaces: vitest.fn(), getAssetsByCity: vitest.fn(), From 5bcbe77fb6d3b322e08f671d78093f2c3102611a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 12:02:30 +0100 Subject: [PATCH 468/723] chore(deps): update terraform cloudflare to v4.43.0 (#12860) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../docs-release/.terraform.lock.hcl | 60 +++++++++---------- .../modules/cloudflare/docs-release/config.tf | 2 +- .../cloudflare/docs/.terraform.lock.hcl | 60 +++++++++---------- deployment/modules/cloudflare/docs/config.tf | 2 +- 4 files changed, 62 insertions(+), 62 deletions(-) diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index afa00e6067..6419c16dad 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.41.0" - constraints = "4.41.0" + version = "4.43.0" + constraints = "4.43.0" hashes = [ - "h1:0mc+YrjQrcctGrGYDmzlcqcgSv9MYB74rvMaZylIKC8=", - "h1:0zUx4vk4jOORQqn6xHBF7dO6N6bielFHdJ0mgF4Obn8=", - "h1:AsIZW3uLFNOZO7kL/K7/Y/S0IYxUV9Hz85NNk/3TTsA=", - "h1:FSgYM4+LHMbX/a4Y1kx7FPPWmXqS3/MQYzvjMJHHHWM=", - "h1:Tx6Nh3BWP1x9L3KK/Eyi+ET0T26g3+jf1jyiuqpNIis=", - "h1:VRI9wu8P43xxfpeTndRwsisLnqncfnmEYMOEH5zH4pQ=", - "h1:YxQqmiES/Yanq/VfGqBEqg+VIO7FGhO88aKoWFHyGIg=", - "h1:ZWHiaesjgDLKWlfdNj0oKyj/DWdxcfsO6NINu39zfpY=", - "h1:a2aCgDDBz3ccrr8YstIMl7VFnKo1xZAp+rOv59PPJ7U=", - "h1:aRyv8tB6wBAF9lKsLEdiHyCqnK5LfZq0FqMXCcUB4UU=", - "h1:lXpuO7zv2uD2GzPE1ARxznreRAh+QHTc2lAJ7iOoFgY=", - "h1:sA1xq0QNQ4fH8SHXouYNq50xirVD18SamKQwPsBQrrY=", - "h1:v7sHvKq7oqMYPn47ULHFyIQsKD9o+6Xg/uHbxQUixEw=", - "h1:wo/x4atWyXuWGlfR6h5nH0YwBAmBwTRY27HtWP8ycLo=", - "zh:339d26e06dc6fb299ea8aad9476a60fd65bb1d40631ae8eeb81cddf2dd2bebc8", - "zh:3dec2ad96ac2c283fd34ce65781b55c4edbb4d5c5cb53da8e31537176c0ed562", - "zh:5f63a5f8080319a2fff09d4d49944829fa708723436520787cfb60725ced80cf", - "zh:67162c28ccea71cb8141ed15c0637e35621354ebe14878e0b75a8f160fc5505d", - "zh:6ac1e07f5347b6395aca690ed22101bb25e957d25f986f760ff673a7adfd5ef6", - "zh:70282a723c7b52fcabde2baad41c864ed3a8d69f0c4d27a6b6933cac434cffc6", + "h1:2kDVLD36BOVgBzI9p0WIQ+xjFfMmjaItA0l8SyZWEPo=", + "h1:2sGJDAwFEgO8+3y+2suYO+yrjNOzSsihad0hbM3+jPg=", + "h1:A1WPQFcdD+7FrFBFrKcx4CiSr75xSmsO93C0e5NBAeQ=", + "h1:BuXs/1ohmF4fWyOErY6vNbm7DaEIfbLSepSiZ2ol9I8=", + "h1:QPh+X19oyo808sqdeJaVqahZcQgcG1jCi3DA5zpjz6U=", + "h1:RI7c7dhSJoIkfou5b8ITRpM5MqsQD3FULj1h/rI4rJk=", + "h1:gdI5JTCPjewdGq1bhGAs+V5qCcmJ73N2gtMfuFybJp4=", + "h1:h4lnJpCIYZ7dsN9IO2mmwNdWNiQYEPoAEUjLF2sZ5kc=", + "h1:jTaExrX/eR7vGT5wayGqH8ZtXS2zyk0WmD3zbAKFIQU=", + "h1:l5NKJUOQJ1mHl1eekeXaxUZ+g+8Yv4aGcIN9vuK6GL4=", + "h1:sNbvm66/2vc8B/khyioOO8eNaU8nb89x693AN7fQheU=", + "h1:tXS4g1yE420AU4mvZ7RrYI+yYTutkRID3l+W0gBH4BM=", + "h1:vA+kES7uqmKA9K0U45IXR94jaTQZCHZLCHqMUeGxKMI=", + "h1:zV131k79+ob9p4jrLDgztDNvZvt8fvrrzpn0nPikBw8=", + "zh:006d111d6eafe6eeb5df2f91bd0ca320f979bd71f8cd8c475f10b2bd94acba55", + "zh:031fbb5cac23a841dc18e270cbfcd3ce9f4ba504edbd3c78931f7ed9827220a8", + "zh:07a72fe8b55afee99529bf4169ab6abfac5eabcd10968c29101925bcd358b09f", + "zh:0d14727d011c2d9df4c3058f527d2409223449ab48b46cbc86922eb553ef77c1", + "zh:155ce1333672d26cd18a5866b0761489d91682beffee58e45c3a1b68e8491d3d", + "zh:35a2a1939a965335b29ebdbfd759d93a97c0f589d9cd218f537dee6f600e3fb9", + "zh:52912fe421e7d911431f77788db2ea13836efd65a2e82385adb52c6a84d4ee90", + "zh:57374318d9194ea1db08884b0541a9055823d5970ad48f9a57547ac231163007", + "zh:5fb942b9e2553c058fe09fe12fb39dd175cd6715bb41c059c1a70df2bfc64dc1", + "zh:63cabd2bda201b09b35a3279d1f813ab71394b9b90fc5cf8962a5eba207803bc", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:924cd23abc326c6b3914e2cd9c94c7832c2552e1e9ae258fb9fd9aedaa5f7ce7", - "zh:a4b75e4c239879296259e7d54f1befbc7fdc16da2d62d1294e9f73add4cae61e", - "zh:a6ceb08feb63b00c7141783b31e45a154c76fd8cdebbdf371074805f0053572d", - "zh:afae1843f9ba85f2f6d94108c65cf43a457e83531a632d44d863e935160cb2ba", - "zh:bd6628ce60c778960a5755f7010b7e2cc5c6ff0341a21c175341b28058ec843d", - "zh:cd30866a1ff99d72b5fa1699db582fa4f25562e6ab21dcc6870324f3056108e0", - "zh:df5924cca691a8220aaaebb5cb55c3d6c32ff0a881f198695eff28155eb12b54", - "zh:e78d0696c941aba58df1cb36b8a0d25cd5f3963f01d9338fdbda74db58afdd49", + "zh:978ee67d3d53970a5c474ab40b00adee97f4153b16804a2b6b7ee205ae69d18a", + "zh:bbafdbef631b5c80570087817b42b16b1a76d556d692853a71c47fb48663cf00", + "zh:be91b3f2a697cbbb41f65aad2600972d0ede1e962a7d8a00bb3177cb77d86666", + "zh:efe168ad4aaa6156ce5a31d4e50e9d54d38ee5a5888412f9e690c0de5d619683", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index 18d8ff1eb4..74ea6d5816 100644 --- a/deployment/modules/cloudflare/docs-release/config.tf +++ b/deployment/modules/cloudflare/docs-release/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.41.0" + version = "4.43.0" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index afa00e6067..6419c16dad 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.41.0" - constraints = "4.41.0" + version = "4.43.0" + constraints = "4.43.0" hashes = [ - "h1:0mc+YrjQrcctGrGYDmzlcqcgSv9MYB74rvMaZylIKC8=", - "h1:0zUx4vk4jOORQqn6xHBF7dO6N6bielFHdJ0mgF4Obn8=", - "h1:AsIZW3uLFNOZO7kL/K7/Y/S0IYxUV9Hz85NNk/3TTsA=", - "h1:FSgYM4+LHMbX/a4Y1kx7FPPWmXqS3/MQYzvjMJHHHWM=", - "h1:Tx6Nh3BWP1x9L3KK/Eyi+ET0T26g3+jf1jyiuqpNIis=", - "h1:VRI9wu8P43xxfpeTndRwsisLnqncfnmEYMOEH5zH4pQ=", - "h1:YxQqmiES/Yanq/VfGqBEqg+VIO7FGhO88aKoWFHyGIg=", - "h1:ZWHiaesjgDLKWlfdNj0oKyj/DWdxcfsO6NINu39zfpY=", - "h1:a2aCgDDBz3ccrr8YstIMl7VFnKo1xZAp+rOv59PPJ7U=", - "h1:aRyv8tB6wBAF9lKsLEdiHyCqnK5LfZq0FqMXCcUB4UU=", - "h1:lXpuO7zv2uD2GzPE1ARxznreRAh+QHTc2lAJ7iOoFgY=", - "h1:sA1xq0QNQ4fH8SHXouYNq50xirVD18SamKQwPsBQrrY=", - "h1:v7sHvKq7oqMYPn47ULHFyIQsKD9o+6Xg/uHbxQUixEw=", - "h1:wo/x4atWyXuWGlfR6h5nH0YwBAmBwTRY27HtWP8ycLo=", - "zh:339d26e06dc6fb299ea8aad9476a60fd65bb1d40631ae8eeb81cddf2dd2bebc8", - "zh:3dec2ad96ac2c283fd34ce65781b55c4edbb4d5c5cb53da8e31537176c0ed562", - "zh:5f63a5f8080319a2fff09d4d49944829fa708723436520787cfb60725ced80cf", - "zh:67162c28ccea71cb8141ed15c0637e35621354ebe14878e0b75a8f160fc5505d", - "zh:6ac1e07f5347b6395aca690ed22101bb25e957d25f986f760ff673a7adfd5ef6", - "zh:70282a723c7b52fcabde2baad41c864ed3a8d69f0c4d27a6b6933cac434cffc6", + "h1:2kDVLD36BOVgBzI9p0WIQ+xjFfMmjaItA0l8SyZWEPo=", + "h1:2sGJDAwFEgO8+3y+2suYO+yrjNOzSsihad0hbM3+jPg=", + "h1:A1WPQFcdD+7FrFBFrKcx4CiSr75xSmsO93C0e5NBAeQ=", + "h1:BuXs/1ohmF4fWyOErY6vNbm7DaEIfbLSepSiZ2ol9I8=", + "h1:QPh+X19oyo808sqdeJaVqahZcQgcG1jCi3DA5zpjz6U=", + "h1:RI7c7dhSJoIkfou5b8ITRpM5MqsQD3FULj1h/rI4rJk=", + "h1:gdI5JTCPjewdGq1bhGAs+V5qCcmJ73N2gtMfuFybJp4=", + "h1:h4lnJpCIYZ7dsN9IO2mmwNdWNiQYEPoAEUjLF2sZ5kc=", + "h1:jTaExrX/eR7vGT5wayGqH8ZtXS2zyk0WmD3zbAKFIQU=", + "h1:l5NKJUOQJ1mHl1eekeXaxUZ+g+8Yv4aGcIN9vuK6GL4=", + "h1:sNbvm66/2vc8B/khyioOO8eNaU8nb89x693AN7fQheU=", + "h1:tXS4g1yE420AU4mvZ7RrYI+yYTutkRID3l+W0gBH4BM=", + "h1:vA+kES7uqmKA9K0U45IXR94jaTQZCHZLCHqMUeGxKMI=", + "h1:zV131k79+ob9p4jrLDgztDNvZvt8fvrrzpn0nPikBw8=", + "zh:006d111d6eafe6eeb5df2f91bd0ca320f979bd71f8cd8c475f10b2bd94acba55", + "zh:031fbb5cac23a841dc18e270cbfcd3ce9f4ba504edbd3c78931f7ed9827220a8", + "zh:07a72fe8b55afee99529bf4169ab6abfac5eabcd10968c29101925bcd358b09f", + "zh:0d14727d011c2d9df4c3058f527d2409223449ab48b46cbc86922eb553ef77c1", + "zh:155ce1333672d26cd18a5866b0761489d91682beffee58e45c3a1b68e8491d3d", + "zh:35a2a1939a965335b29ebdbfd759d93a97c0f589d9cd218f537dee6f600e3fb9", + "zh:52912fe421e7d911431f77788db2ea13836efd65a2e82385adb52c6a84d4ee90", + "zh:57374318d9194ea1db08884b0541a9055823d5970ad48f9a57547ac231163007", + "zh:5fb942b9e2553c058fe09fe12fb39dd175cd6715bb41c059c1a70df2bfc64dc1", + "zh:63cabd2bda201b09b35a3279d1f813ab71394b9b90fc5cf8962a5eba207803bc", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:924cd23abc326c6b3914e2cd9c94c7832c2552e1e9ae258fb9fd9aedaa5f7ce7", - "zh:a4b75e4c239879296259e7d54f1befbc7fdc16da2d62d1294e9f73add4cae61e", - "zh:a6ceb08feb63b00c7141783b31e45a154c76fd8cdebbdf371074805f0053572d", - "zh:afae1843f9ba85f2f6d94108c65cf43a457e83531a632d44d863e935160cb2ba", - "zh:bd6628ce60c778960a5755f7010b7e2cc5c6ff0341a21c175341b28058ec843d", - "zh:cd30866a1ff99d72b5fa1699db582fa4f25562e6ab21dcc6870324f3056108e0", - "zh:df5924cca691a8220aaaebb5cb55c3d6c32ff0a881f198695eff28155eb12b54", - "zh:e78d0696c941aba58df1cb36b8a0d25cd5f3963f01d9338fdbda74db58afdd49", + "zh:978ee67d3d53970a5c474ab40b00adee97f4153b16804a2b6b7ee205ae69d18a", + "zh:bbafdbef631b5c80570087817b42b16b1a76d556d692853a71c47fb48663cf00", + "zh:be91b3f2a697cbbb41f65aad2600972d0ede1e962a7d8a00bb3177cb77d86666", + "zh:efe168ad4aaa6156ce5a31d4e50e9d54d38ee5a5888412f9e690c0de5d619683", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index 18d8ff1eb4..74ea6d5816 100644 --- a/deployment/modules/cloudflare/docs/config.tf +++ b/deployment/modules/cloudflare/docs/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.41.0" + version = "4.43.0" } } } From 95c67949f7a4870cb4c7ce0361ebd7a1c54da092 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 30 Sep 2024 20:51:47 +0700 Subject: [PATCH 469/723] fix(mobile): share to error (#13044) --- mobile/lib/extensions/collection_extensions.dart | 13 ------------- mobile/lib/widgets/asset_grid/multiselect_grid.dart | 6 +----- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/mobile/lib/extensions/collection_extensions.dart b/mobile/lib/extensions/collection_extensions.dart index f71b0aacd3..d27c9e9500 100644 --- a/mobile/lib/extensions/collection_extensions.dart +++ b/mobile/lib/extensions/collection_extensions.dart @@ -70,19 +70,6 @@ extension AssetListExtension on Iterable { } return this; } - - /// Filters out offline assets and returns those that are still accessible by the Immich server - /// TODO: isOffline is removed from Immich, so this method is not useful anymore - Iterable nonOfflineOnly({ - void Function()? errorCallback, - }) { - final bool onlyLive = every((e) => false); - if (!onlyLive) { - if (errorCallback != null) errorCallback(); - return where((a) => false); - } - return this; - } } extension SortedByProperty on Iterable { diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index 3263373554..14678903ba 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -131,11 +131,7 @@ class MultiselectGrid extends HookConsumerWidget { processing.value = true; if (shareLocal) { // Share = Download + Send to OS specific share sheet - // Filter offline assets since we cannot fetch their original file - final liveAssets = selection.value.nonOfflineOnly( - errorCallback: errorBuilder('asset_action_share_err_offline'.tr()), - ); - handleShareAssets(ref, context, liveAssets); + handleShareAssets(ref, context, selection.value); } else { final ids = remoteSelection(errorMessage: "home_page_share_err_local".tr()) From a2d457b01d5ae94fa7b3b4862a09265433c29a1b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 30 Sep 2024 10:35:11 -0400 Subject: [PATCH 470/723] refactor(server): events (#13003) * refactor(server): events * chore: better type --------- Co-authored-by: Daniel Dietzler --- e2e/src/api/specs/asset.e2e-spec.ts | 3 +- server/src/app.module.ts | 15 ------ server/src/bin/sync-sql.ts | 2 - server/src/cores/system-config.core.ts | 17 +++---- server/src/decorators.ts | 15 +++--- server/src/enum.ts | 2 +- server/src/interfaces/event.interface.ts | 44 ++++++++++------- server/src/repositories/event.repository.ts | 49 ++++++++++--------- server/src/services/database.service.ts | 4 +- server/src/services/job.service.spec.ts | 46 ++++++----------- server/src/services/job.service.ts | 38 +++++++++----- server/src/services/library.service.spec.ts | 29 ++++++----- server/src/services/library.service.ts | 33 +++++++------ server/src/services/metadata.service.ts | 20 ++++---- server/src/services/microservices.service.ts | 4 +- .../src/services/notification.service.spec.ts | 11 ++++- server/src/services/notification.service.ts | 38 ++++++++------ server/src/services/server.service.ts | 4 +- server/src/services/smart-info.service.ts | 16 +++--- .../services/storage-template.service.spec.ts | 16 +++--- .../src/services/storage-template.service.ts | 23 +++++---- server/src/services/storage.service.ts | 4 +- .../services/system-config.service.spec.ts | 14 +++--- server/src/services/system-config.service.ts | 40 +++++++-------- server/src/services/trash.service.ts | 4 +- server/src/services/version.service.ts | 10 ++-- server/src/utils/events.ts | 16 +++--- .../repositories/event.repository.mock.ts | 2 +- 28 files changed, 260 insertions(+), 259 deletions(-) diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index e0281085cf..4dd02ec69f 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -76,7 +76,6 @@ describe('/asset', () => { let user2Assets: AssetMediaResponseDto[]; let locationAsset: AssetMediaResponseDto; let ratingAsset: AssetMediaResponseDto; - let facesAsset: AssetMediaResponseDto; const setupTests = async () => { await utils.resetDatabase(); @@ -236,7 +235,7 @@ describe('/asset', () => { await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); // asset faces - facesAsset = await utils.createAsset(admin.accessToken, { + const facesAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'portrait.jpg', bytes: await readFile(facesAssetFilepath), diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 9446010127..55b9babcb4 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -2,7 +2,6 @@ import { BullModule } from '@nestjs/bullmq'; import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import _ from 'lodash'; @@ -42,7 +41,6 @@ const imports = [ BullModule.registerQueue(...bullQueues), ClsModule.forRoot(clsConfig), ConfigModule.forRoot(immichAppConfig), - EventEmitterModule.forRoot(), OpenTelemetryModule.forRoot(otelConfig), TypeOrmModule.forRootAsync({ inject: [ModuleRef], @@ -114,16 +112,3 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy { providers: [...common, ...commands, SchedulerRegistry], }) export class ImmichAdminModule {} - -@Module({ - imports: [ - ConfigModule.forRoot(immichAppConfig), - EventEmitterModule.forRoot(), - TypeOrmModule.forRoot(databaseConfig), - TypeOrmModule.forFeature(entities), - OpenTelemetryModule.forRoot(otelConfig), - ], - controllers: [...controllers], - providers: [...common, ...middleware, SchedulerRegistry], -}) -export class AppTestModule {} diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index 6bf85d1553..92c3cc1103 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import { INestApplication } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -85,7 +84,6 @@ class SqlGenerator { logger: this.sqlLogger, }), TypeOrmModule.forFeature(entities), - EventEmitterModule.forRoot(), OpenTelemetryModule.forRoot(otelConfig), ], providers: [...repositories, AuthService, SchedulerRegistry], diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index 8ed53344cc..816ab00446 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -4,7 +4,6 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { load as loadYaml } from 'js-yaml'; import * as _ from 'lodash'; -import { Subject } from 'rxjs'; import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { SystemMetadataKey } from 'src/enum'; @@ -24,8 +23,6 @@ export class SystemConfigCore { private config: SystemConfig | null = null; private lastUpdated: number | null = null; - config$ = new Subject(); - private constructor( private repository: ISystemMetadataRepository, private logger: ILoggerRepository, @@ -42,6 +39,11 @@ export class SystemConfigCore { instance = null; } + invalidateCache() { + this.config = null; + this.lastUpdated = null; + } + async getConfig({ withCache }: { withCache: boolean }): Promise { if (!withCache || !this.config) { const lastUpdated = this.lastUpdated; @@ -74,14 +76,7 @@ export class SystemConfigCore { await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); - const config = await this.getConfig({ withCache: false }); - this.config$.next(config); - return config; - } - - async refreshConfig() { - const newConfig = await this.getConfig({ withCache: false }); - this.config$.next(newConfig); + return this.getConfig({ withCache: false }); } isUsingConfigFile() { diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 9b6910391a..2782368239 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -1,11 +1,9 @@ import { SetMetadata, applyDecorators } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces'; import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; import { MetadataKey } from 'src/enum'; -import { EmitEvent, ServerEvent } from 'src/interfaces/event.interface'; +import { EmitEvent } from 'src/interfaces/event.interface'; import { setUnion } from 'src/utils/set'; // PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the @@ -133,15 +131,14 @@ export interface GenerateSqlQueries { /** Decorator to enable versioning/tracking of generated Sql */ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options); -export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) => - OnEvent(event, { suppressErrors: false, ...options }); - -export type EmitConfig = { - event: EmitEvent; +export type EventConfig = { + name: EmitEvent; + /** handle socket.io server events as well */ + server?: boolean; /** lower value has higher priority, defaults to 0 */ priority?: number; }; -export const OnEmit = (config: EmitConfig) => SetMetadata(MetadataKey.ON_EMIT_CONFIG, config); +export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EVENT_CONFIG, config); type LifecycleRelease = 'NEXT_RELEASE' | string; type LifecycleMetadata = { diff --git a/server/src/enum.ts b/server/src/enum.ts index e0c1e27859..757291b118 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -310,7 +310,7 @@ export enum MetadataKey { ADMIN_ROUTE = 'admin_route', SHARED_ROUTE = 'shared_route', API_KEY_SECURITY = 'api_key', - ON_EMIT_CONFIG = 'on_emit_config', + EVENT_CONFIG = 'event_config', } export enum RouteKey { diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index bc5ce90f40..02027d87e6 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -4,13 +4,19 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d export const IEventRepository = 'IEventRepository'; -type EmitEventMap = { +type EventMap = { // app events 'app.bootstrap': ['api' | 'microservices']; 'app.shutdown': []; // config events - 'config.update': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + 'config.update': [ + { + newConfig: SystemConfig; + /** When the server starts, `oldConfig` is `undefined` */ + oldConfig?: SystemConfig; + }, + ]; 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; // album events @@ -43,12 +49,18 @@ type EmitEventMap = { // user events 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; + + // websocket events + 'websocket.connect': [{ userId: string }]; }; -export type EmitEvent = keyof EmitEventMap; +export const serverEvents = ['config.update'] as const; +export type ServerEvents = (typeof serverEvents)[number]; + +export type EmitEvent = keyof EventMap; export type EmitHandler = (...args: ArgsOf) => Promise | void; -export type ArgOf = EmitEventMap[T][0]; -export type ArgsOf = EmitEventMap[T]; +export type ArgOf = EventMap[T][0]; +export type ArgsOf = EventMap[T]; export enum ClientEvent { UPLOAD_SUCCESS = 'on_upload_success', @@ -82,19 +94,15 @@ export interface ClientEventMap { [ClientEvent.SESSION_DELETE]: string; } -export enum ServerEvent { - CONFIG_UPDATE = 'config.update', - WEBSOCKET_CONNECT = 'websocket.connect', -} - -export interface ServerEventMap { - [ServerEvent.CONFIG_UPDATE]: null; - [ServerEvent.WEBSOCKET_CONNECT]: { userId: string }; -} +export type EventItem = { + event: T; + handler: EmitHandler; + server: boolean; +}; export interface IEventRepository { - on(event: T, handler: EmitHandler): void; - emit(event: T, ...args: ArgsOf): Promise; + on(item: EventItem): void; + emit(event: T, ...args: ArgsOf): Promise; /** * Send to connected clients for a specific user @@ -105,7 +113,7 @@ export interface IEventRepository { */ clientBroadcast(event: E, data: ClientEventMap[E]): void; /** - * Notify listeners in this and connected processes. Subscribe to an event with `@OnServerEvent` + * Send to all connected servers */ - serverSend(event: E, data: ServerEventMap[E]): boolean; + serverSend(event: T, ...args: ArgsOf): void; } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 9aa12e15dd..a8b2fa67c3 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { EventEmitter2 } from '@nestjs/event-emitter'; import { OnGatewayConnection, OnGatewayDisconnect, @@ -13,16 +12,17 @@ import { ArgsOf, ClientEventMap, EmitEvent, - EmitHandler, + EventItem, IEventRepository, - ServerEvent, - ServerEventMap, + serverEvents, + ServerEvents, } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService } from 'src/services/auth.service'; import { Instrumentation } from 'src/utils/instrumentation'; +import { handlePromiseError } from 'src/utils/misc'; -type EmitHandlers = Partial<{ [T in EmitEvent]: EmitHandler[] }>; +type EmitHandlers = Partial<{ [T in EmitEvent]: Array> }>; @Instrumentation() @WebSocketGateway({ @@ -39,7 +39,6 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect constructor( private moduleRef: ModuleRef, - private eventEmitter: EventEmitter2, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(EventRepository.name); @@ -48,14 +47,10 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect afterInit(server: Server) { this.logger.log('Initialized websocket server'); - for (const event of Object.values(ServerEvent)) { - if (event === ServerEvent.WEBSOCKET_CONNECT) { - continue; - } - - server.on(event, (data: unknown) => { + for (const event of serverEvents) { + server.on(event, (...args: ArgsOf) => { this.logger.debug(`Server event: ${event} (receive)`); - this.eventEmitter.emit(event, data); + handlePromiseError(this.onEvent({ name: event, args, server: true }), this.logger); }); } } @@ -72,7 +67,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect if (auth.session) { await client.join(auth.session.id); } - this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id }); + await this.onEvent({ name: 'websocket.connect', args: [{ userId: auth.user.id }], server: false }); } catch (error: Error | any) { this.logger.error(`Websocket connection error: ${error}`, error?.stack); client.emit('error', 'unauthorized'); @@ -85,18 +80,29 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect await client.leave(client.nsp.name); } - on(event: T, handler: EmitHandler): void { + on(item: EventItem): void { + const event = item.event; + if (!this.emitHandlers[event]) { this.emitHandlers[event] = []; } - this.emitHandlers[event].push(handler); + this.emitHandlers[event].push(item); } async emit(event: T, ...args: ArgsOf): Promise { - const handlers = this.emitHandlers[event] || []; - for (const handler of handlers) { - await handler(...args); + return this.onEvent({ name: event, args, server: false }); + } + + private async onEvent(event: { name: T; args: ArgsOf; server: boolean }): Promise { + const handlers = this.emitHandlers[event.name] || []; + for (const { handler, server } of handlers) { + // exclude handlers that ignore server events + if (!server && event.server) { + continue; + } + + await handler(...event.args); } } @@ -108,9 +114,8 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect this.server?.emit(event, data); } - serverSend(event: E, data: ServerEventMap[E]) { + serverSend(event: T, ...args: ArgsOf): void { this.logger.debug(`Server event: ${event} (send)`); - this.server?.serverSideEmit(event, data); - return this.eventEmitter.emit(event, data); + this.server?.serverSideEmit(event, ...args); } } diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index ee6176115b..9ba190d30a 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Duration } from 'luxon'; import semver from 'semver'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { IConfigRepository } from 'src/interfaces/config.interface'; import { DatabaseExtension, @@ -74,7 +74,7 @@ export class DatabaseService { this.logger.setContext(DatabaseService.name); } - @OnEmit({ event: 'app.bootstrap', priority: -200 }) + @OnEvent({ name: 'app.bootstrap', priority: -200 }) async onBootstrap() { const version = await this.databaseRepository.getPostgresVersion(); const current = semver.coerce(version); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index c2d7a29b9f..8d7c15073d 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,6 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { defaults } from 'src/config'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { @@ -60,6 +59,19 @@ describe(JobService.name, () => { expect(sut).toBeDefined(); }); + describe('onConfigUpdate', () => { + it('should update concurrency', () => { + sut.onBootstrap('microservices'); + sut.onConfigUpdate({ oldConfig: defaults, newConfig: defaults }); + + expect(jobMock.setConcurrency).toHaveBeenCalledTimes(14); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1); + }); + }); + describe('handleNightlyJobs', () => { it('should run the scheduled jobs', async () => { await sut.handleNightlyJobs(); @@ -239,36 +251,6 @@ describe(JobService.name, () => { expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length); }); - it('should subscribe to config changes', async () => { - await sut.init(makeMockHandlers(JobStatus.FAILED)); - - SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ - job: { - [QueueName.BACKGROUND_TASK]: { concurrency: 10 }, - [QueueName.SMART_SEARCH]: { concurrency: 10 }, - [QueueName.METADATA_EXTRACTION]: { concurrency: 10 }, - [QueueName.FACE_DETECTION]: { concurrency: 10 }, - [QueueName.SEARCH]: { concurrency: 10 }, - [QueueName.SIDECAR]: { concurrency: 10 }, - [QueueName.LIBRARY]: { concurrency: 10 }, - [QueueName.MIGRATION]: { concurrency: 10 }, - [QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 }, - [QueueName.VIDEO_CONVERSION]: { concurrency: 10 }, - [QueueName.NOTIFICATION]: { concurrency: 5 }, - }, - } as SystemConfig); - - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.BACKGROUND_TASK, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SMART_SEARCH, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.FACE_DETECTION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.LIBRARY, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.MIGRATION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10); - }); - const tests: Array<{ item: JobItem; jobs: JobName[] }> = [ { item: { name: JobName.SIDECAR_SYNC, data: { id: 'asset-1' } }, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 9c73e71cbf..68da13a8e4 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -1,11 +1,12 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; import { AssetType, ManualJobName } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { ConcurrentQueueName, IJobRepository, @@ -45,6 +46,7 @@ const asJobItem = (dto: JobCreateDto): JobItem => { @Injectable() export class JobService { private configCore: SystemConfigCore; + private isMicroservices = false; constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, @@ -59,6 +61,28 @@ export class JobService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } + @OnEvent({ name: 'app.bootstrap' }) + onBootstrap(app: ArgOf<'app.bootstrap'>) { + this.isMicroservices = app === 'microservices'; + } + + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: config, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.isMicroservices) { + return; + } + + this.logger.debug(`Updating queue concurrency settings`); + for (const queueName of Object.values(QueueName)) { + let concurrency = 1; + if (this.isConcurrentQueue(queueName)) { + concurrency = config.job[queueName].concurrency; + } + this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); + this.jobRepository.setConcurrency(queueName, concurrency); + } + } + async create(dto: JobCreateDto): Promise { await this.jobRepository.queue(asJobItem(dto)); } @@ -209,18 +233,6 @@ export class JobService { } }); } - - this.configCore.config$.subscribe((config) => { - this.logger.debug(`Updating queue concurrency settings`); - for (const queueName of Object.values(QueueName)) { - let concurrency = 1; - if (this.isConcurrentQueue(queueName)) { - concurrency = config.job[queueName].concurrency; - } - this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); - this.jobRepository.setConcurrency(queueName, concurrency); - } - }); } private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 8b14c76cbc..bcf0f1d0b5 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1,7 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { Stats } from 'node:fs'; -import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { defaults, SystemConfig } from 'src/config'; import { mapLibrary } from 'src/dtos/library.dto'; import { UserEntity } from 'src/entities/user.entity'; import { AssetType } from 'src/enum'; @@ -81,22 +80,26 @@ describe(LibraryService.name, () => { }); describe('onBootstrapEvent', () => { - it('should init cron job and subscribe to config changes', async () => { + it('should init cron job and handle config changes', async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryScan); await sut.onBootstrap(); - expect(systemMock.get).toHaveBeenCalled(); - expect(jobMock.addCronJob).toHaveBeenCalled(); - SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ - library: { - scan: { - enabled: true, - cronExpression: '0 1 * * *', + expect(jobMock.addCronJob).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); + + await sut.onConfigUpdate({ + oldConfig: defaults, + newConfig: { + library: { + scan: { + enabled: true, + cronExpression: '0 1 * * *', + }, + watch: { enabled: false }, }, - watch: { enabled: true }, - }, - } as SystemConfig); + } as SystemConfig, + }); expect(jobMock.updateCronJob).toHaveBeenCalledWith('libraryScan', '0 1 * * *', true); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 52b786089c..b8b478531f 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -4,7 +4,7 @@ import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { CreateLibraryDto, LibraryResponseDto, @@ -61,7 +61,7 @@ export class LibraryService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap() { const config = await this.configCore.getConfig({ withCache: false }); @@ -83,19 +83,24 @@ export class LibraryService { if (this.watchLibraries) { await this.watchAll(); } - - this.configCore.config$.subscribe(({ library }) => { - this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); - - if (library.watch.enabled !== this.watchLibraries) { - // Watch configuration changed, update accordingly - this.watchLibraries = library.watch.enabled; - handlePromiseError(this.watchLibraries ? this.watchAll() : this.unwatchAll(), this.logger); - } - }); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update', server: true }) + async onConfigUpdate({ newConfig: { library }, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.watchLock) { + return; + } + + this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); + + if (library.watch.enabled !== this.watchLibraries) { + // Watch configuration changed, update accordingly + this.watchLibraries = library.watch.enabled; + await (this.watchLibraries ? this.watchAll() : this.unwatchAll()); + } + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { const { scan } = newConfig.library; if (!validateCronExpression(scan.cronExpression)) { @@ -185,7 +190,7 @@ export class LibraryService { } } - @OnEmit({ event: 'app.shutdown' }) + @OnEvent({ name: 'app.shutdown' }) async onShutdown() { await this.unwatchAll(); } diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 224ef03b3b..9499a4bdd9 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; @@ -132,7 +132,7 @@ export class MetadataService { ); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; @@ -141,7 +141,12 @@ export class MetadataService { await this.init(config); } - @OnEmit({ event: 'config.update' }) + @OnEvent({ name: 'app.shutdown' }) + async onShutdown() { + await this.repository.teardown(); + } + + @OnEvent({ name: 'config.update' }) async onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { await this.init(newConfig); } @@ -164,11 +169,6 @@ export class MetadataService { } } - @OnEmit({ event: 'app.shutdown' }) - async onShutdown() { - await this.repository.teardown(); - } - async handleLivePhotoLinking(job: IEntityJob): Promise { const { id } = job; const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); @@ -333,12 +333,12 @@ export class MetadataService { return this.processSidecar(id, false); } - @OnEmit({ event: 'asset.tag' }) + @OnEvent({ name: 'asset.tag' }) async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) { await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); } - @OnEmit({ event: 'asset.untag' }) + @OnEvent({ name: 'asset.untag' }) async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) { await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 0afefefff3..23604b6ef6 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { ArgOf } from 'src/interfaces/event.interface'; import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface'; import { AssetService } from 'src/services/asset.service'; @@ -43,7 +43,7 @@ export class MicroservicesService { private versionService: VersionService, ) {} - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index b3a1e73541..106f0be082 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -6,7 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; +import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; @@ -100,6 +100,15 @@ describe(NotificationService.name, () => { expect(sut).toBeDefined(); }); + describe('onConfigUpdate', () => { + it('should emit client and server events', () => { + const update = { newConfig: defaults }; + expect(sut.onConfigUpdate(update)).toBeUndefined(); + expect(eventMock.clientBroadcast).toHaveBeenCalledWith(ClientEvent.CONFIG_UPDATE, {}); + expect(eventMock.serverSend).toHaveBeenCalledWith('config.update', update); + }); + }); + describe('onConfigValidateEvent', () => { it('validates smtp config when enabling smtp', async () => { const oldConfig = configs.smtpDisabled; diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index fdb8257ffa..626e536c40 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -43,7 +43,13 @@ export class NotificationService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } - @OnEmit({ event: 'config.validate', priority: -100 }) + @OnEvent({ name: 'config.update' }) + onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { + this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); + this.eventRepository.serverSend('config.update', { oldConfig, newConfig }); + } + + @OnEvent({ name: 'config.validate', priority: -100 }) async onConfigValidate({ oldConfig, newConfig }: ArgOf<'config.validate'>) { try { if ( @@ -58,74 +64,74 @@ export class NotificationService { } } - @OnEmit({ event: 'asset.hide' }) + @OnEvent({ name: 'asset.hide' }) onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) { this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId); } - @OnEmit({ event: 'asset.show' }) + @OnEvent({ name: 'asset.show' }) async onAssetShow({ assetId }: ArgOf<'asset.show'>) { await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id: assetId, notify: true } }); } - @OnEmit({ event: 'asset.trash' }) + @OnEvent({ name: 'asset.trash' }) onAssetTrash({ assetId, userId }: ArgOf<'asset.trash'>) { this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, [assetId]); } - @OnEmit({ event: 'asset.delete' }) + @OnEvent({ name: 'asset.delete' }) onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) { this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, userId, assetId); } - @OnEmit({ event: 'assets.trash' }) + @OnEvent({ name: 'assets.trash' }) onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) { this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, assetIds); } - @OnEmit({ event: 'assets.restore' }) + @OnEvent({ name: 'assets.restore' }) onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) { this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, userId, assetIds); } - @OnEmit({ event: 'stack.create' }) + @OnEvent({ name: 'stack.create' }) onStackCreate({ userId }: ArgOf<'stack.create'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'stack.update' }) + @OnEvent({ name: 'stack.update' }) onStackUpdate({ userId }: ArgOf<'stack.update'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'stack.delete' }) + @OnEvent({ name: 'stack.delete' }) onStackDelete({ userId }: ArgOf<'stack.delete'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'stacks.delete' }) + @OnEvent({ name: 'stacks.delete' }) onStacksDelete({ userId }: ArgOf<'stacks.delete'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'user.signup' }) + @OnEvent({ name: 'user.signup' }) async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { if (notify) { await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } }); } } - @OnEmit({ event: 'album.update' }) + @OnEvent({ name: 'album.update' }) async onAlbumUpdate({ id, updatedBy }: ArgOf<'album.update'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } }); } - @OnEmit({ event: 'album.invite' }) + @OnEvent({ name: 'album.invite' }) async onAlbumInvite({ id, userId }: ArgOf<'album.invite'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } - @OnEmit({ event: 'session.delete' }) + @OnEvent({ name: 'session.delete' }) onSessionDelete({ sessionId }: ArgOf<'session.delete'>) { // after the response is sent setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index a192c2f308..708fe32db5 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -3,7 +3,7 @@ import { getBuildMetadata, getServerLicensePublicKey } from 'src/config'; import { serverVersion } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, @@ -42,7 +42,7 @@ export class ServerService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index a75594100f..ef7865d25c 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; @@ -39,7 +39,7 @@ export class SmartInfoService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; @@ -49,7 +49,12 @@ export class SmartInfoService { await this.init(config); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update' }) + async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { + await this.init(newConfig, oldConfig); + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { getCLIPModelInfo(newConfig.machineLearning.clip.modelName); @@ -60,11 +65,6 @@ export class SmartInfoService { } } - @OnEmit({ event: 'config.update' }) - async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { - await this.init(newConfig, oldConfig); - } - private async init(newConfig: SystemConfig, oldConfig?: SystemConfig) { if (!isSmartSearchEnabled(newConfig.machineLearning)) { return; diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index e8e222c7b2..36a50c41bd 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,6 +1,5 @@ import { Stats } from 'node:fs'; import { SystemConfig, defaults } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -74,7 +73,7 @@ describe(StorageTemplateService.name, () => { loggerMock, ); - SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults); + sut.onConfigUpdate({ newConfig: defaults }); }); describe('onConfigValidate', () => { @@ -164,13 +163,15 @@ describe(StorageTemplateService.name, () => { originalPath: newMotionPicturePath, }); }); - it('Should use handlebar if condition for album', async () => { + + it('should use handlebar if condition for album', async () => { const asset = assetStub.image; const user = userStub.user1; const album = albumStub.oneAsset; const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; - SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + + sut.onConfigUpdate({ oldConfig: defaults, newConfig: config }); userMock.get.mockResolvedValue(user); assetMock.getByIds.mockResolvedValueOnce([asset]); @@ -185,12 +186,13 @@ describe(StorageTemplateService.name, () => { pathType: AssetPathType.ORIGINAL, }); }); - it('Should use handlebar else condition for album', async () => { + + it('should use handlebar else condition for album', async () => { const asset = assetStub.image; const user = userStub.user1; const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; - SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + sut.onConfigUpdate({ oldConfig: defaults, newConfig: config }); userMock.get.mockResolvedValue(user); assetMock.getByIds.mockResolvedValueOnce([asset]); @@ -205,6 +207,7 @@ describe(StorageTemplateService.name, () => { pathType: AssetPathType.ORIGINAL, }); }); + it('should migrate previously failed move from original path when it still exists', async () => { userMock.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; @@ -242,6 +245,7 @@ describe(StorageTemplateService.name, () => { originalPath: newPath, }); }); + it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => { userMock.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 30d0eb575f..33b08efc9b 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -3,7 +3,6 @@ import handlebar from 'handlebars'; import { DateTime } from 'luxon'; import path from 'node:path'; import sanitize from 'sanitize-filename'; -import { SystemConfig } from 'src/config'; import { supportedDayTokens, supportedHourTokens, @@ -15,7 +14,7 @@ import { } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -76,7 +75,6 @@ export class StorageTemplateService { ) { this.logger.setContext(StorageTemplateService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - this.configCore.config$.subscribe((config) => this.onConfig(config)); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, @@ -88,7 +86,16 @@ export class StorageTemplateService { ); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { + const template = newConfig.storageTemplate.template; + if (!this._template || template !== this.template.raw) { + this.logger.debug(`Compiling new storage template: ${template}`); + this._template = this.compile(template); + } + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { const { compiled } = this.compile(newConfig.storageTemplate.template); @@ -282,14 +289,6 @@ export class StorageTemplateService { } } - private onConfig(config: SystemConfig) { - const template = config.storageTemplate.template; - if (!this._template || template !== this.template.raw) { - this.logger.debug(`Compiling new storage template: ${template}`); - this._template = this.compile(template); - } - } - private compile(template: string) { return { raw: template, diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 6d15f097d3..b32e48ea49 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { join } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; @@ -21,7 +21,7 @@ export class StorageService { this.logger.setContext(StorageService.name); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap() { await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 514d8aa0f8..ac517bb3ff 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -6,14 +6,13 @@ import { CQMode, ImageFormat, LogLevel, - SystemMetadataKey, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, } from 'src/enum'; -import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @@ -381,14 +380,13 @@ describe(SystemConfigService.name, () => { }); describe('updateConfig', () => { - it('should update the config and emit client and server events', async () => { + it('should update the config and emit an event', async () => { systemMock.get.mockResolvedValue(partialConfig); - await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig); - - expect(eventMock.clientBroadcast).toHaveBeenCalled(); - expect(eventMock.serverSend).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE, null); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); + expect(eventMock.emit).toHaveBeenCalledWith( + 'config.update', + expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), + ); }); it('should throw an error if a config file is in use', async () => { diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 8a7f9123e0..100ab6f47c 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { instanceToPlain } from 'class-transformer'; import _ from 'lodash'; -import { SystemConfig, defaults } from 'src/config'; +import { defaults } from 'src/config'; import { supportedDayTokens, supportedHourTokens, @@ -13,10 +13,10 @@ import { supportedYearTokens, } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit, OnServerEvent } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; import { LogLevel } from 'src/enum'; -import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; +import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { toPlainObject } from 'src/utils/object'; @@ -32,13 +32,12 @@ export class SystemConfigService { ) { this.logger.setContext(SystemConfigService.name); this.core = SystemConfigCore.create(repository, this.logger); - this.core.config$.subscribe((config) => this.setLogLevel(config)); } - @OnEmit({ event: 'app.bootstrap', priority: -100 }) + @OnEvent({ name: 'app.bootstrap', priority: -100 }) async onBootstrap() { const config = await this.core.getConfig({ withCache: false }); - this.core.config$.next(config); + await this.eventRepository.emit('config.update', { newConfig: config }); } async getConfig(): Promise { @@ -50,7 +49,18 @@ export class SystemConfigService { return mapConfig(defaults); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: { logging } }: ArgOf<'config.update'>) { + const envLevel = this.getEnvLogLevel(); + const configLevel = logging.enabled ? logging.level : false; + const level = envLevel ?? configLevel; + this.logger.setLogLevel(level); + this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); + // TODO only do this if the event is a socket.io event + this.core.invalidateCache(); + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig, oldConfig }: ArgOf<'config.validate'>) { if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); @@ -73,9 +83,6 @@ export class SystemConfigService { const newConfig = await this.core.updateConfig(dto); - // TODO probably move web socket emits to a separate service - this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); - this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null); await this.eventRepository.emit('config.update', { newConfig, oldConfig }); return mapConfig(newConfig); @@ -101,19 +108,6 @@ export class SystemConfigService { return theme.customCss; } - @OnServerEvent(ServerEvent.CONFIG_UPDATE) - async onConfigUpdateEvent() { - await this.core.refreshConfig(); - } - - private setLogLevel({ logging }: SystemConfig) { - const envLevel = this.getEnvLogLevel(); - const configLevel = logging.enabled ? logging.level : false; - const level = envLevel ?? configLevel; - this.logger.setLogLevel(level); - this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); - } - private getEnvLogLevel() { return process.env.IMMICH_LOG_LEVEL as LogLevel; } diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index 88340f7d7c..51771d38a2 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -1,5 +1,5 @@ import { Inject } from '@nestjs/common'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TrashResponseDto } from 'src/dtos/trash.dto'; @@ -54,7 +54,7 @@ export class TrashService { return { count }; } - @OnEmit({ event: 'assets.delete' }) + @OnEvent({ name: 'assets.delete' }) async onAssetsDelete() { await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); } diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 468e8c9bdd..0c7ae52cac 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -3,11 +3,11 @@ import { DateTime } from 'luxon'; import semver, { SemVer } from 'semver'; import { isDev, serverVersion } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit, OnServerEvent } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { SystemMetadataKey } from 'src/enum'; -import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; @@ -37,7 +37,7 @@ export class VersionService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(): Promise { await this.handleVersionCheck(); } @@ -90,8 +90,8 @@ export class VersionService { return JobStatus.SUCCESS; } - @OnServerEvent(ServerEvent.WEBSOCKET_CONNECT) - async onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) { + @OnEvent({ name: 'websocket.connect' }) + async onWebsocketConnection({ userId }: ArgOf<'websocket.connect'>) { this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion); const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); if (metadata) { diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts index f5b079dea4..fbac554578 100644 --- a/server/src/utils/events.ts +++ b/server/src/utils/events.ts @@ -1,6 +1,6 @@ import { ModuleRef, Reflector } from '@nestjs/core'; import _ from 'lodash'; -import { EmitConfig } from 'src/decorators'; +import { EventConfig } from 'src/decorators'; import { MetadataKey } from 'src/enum'; import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface'; import { services } from 'src/services'; @@ -9,6 +9,7 @@ type Item = { event: T; handler: EmitHandler; priority: number; + server: boolean; label: string; }; @@ -35,14 +36,15 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => { continue; } - const options = reflector.get(MetadataKey.ON_EMIT_CONFIG, handler); - if (!options) { + const event = reflector.get(MetadataKey.EVENT_CONFIG, handler); + if (!event) { continue; } items.push({ - event: options.event, - priority: options.priority || 0, + event: event.name, + priority: event.priority || 0, + server: event.server ?? false, handler: handler.bind(instance), label: `${Service.name}.${handler.name}`, }); @@ -52,8 +54,8 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => { const handlers = _.orderBy(items, ['priority'], ['asc']); // register by priority - for (const { event, handler } of handlers) { - repository.on(event as EmitEvent, handler); + for (const handler of handlers) { + repository.on(handler); } return handlers; diff --git a/server/test/repositories/event.repository.mock.ts b/server/test/repositories/event.repository.mock.ts index a9af627599..78c62e95f2 100644 --- a/server/test/repositories/event.repository.mock.ts +++ b/server/test/repositories/event.repository.mock.ts @@ -3,7 +3,7 @@ import { Mocked, vitest } from 'vitest'; export const newEventRepositoryMock = (): Mocked => { return { - on: vitest.fn(), + on: vitest.fn() as any, emit: vitest.fn() as any, clientSend: vitest.fn(), clientBroadcast: vitest.fn(), From 15c04d3056e1342bef34426b9bd2b1d15316d7d7 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Mon, 30 Sep 2024 16:37:30 +0200 Subject: [PATCH 471/723] refactor(mobile): DB repository for asset, backup, sync service (#12953) * refactor(mobile): DB repository for asset, backup, sync service * review feedback * fix bug found by Alex --------- Co-authored-by: Alex --- mobile/analysis_options.yaml | 18 +- mobile/lib/interfaces/album.interface.dart | 30 +- mobile/lib/interfaces/asset.interface.dart | 51 ++- mobile/lib/interfaces/backup.interface.dart | 13 +- mobile/lib/interfaces/database.interface.dart | 3 + mobile/lib/interfaces/etag.interface.dart | 14 + .../lib/interfaces/exif_info.interface.dart | 5 +- mobile/lib/interfaces/user.interface.dart | 21 +- mobile/lib/providers/asset.provider.dart | 22 +- .../lib/providers/backup/backup.provider.dart | 9 +- .../backup/manual_upload.provider.dart | 10 +- .../repositories/activity_api.repository.dart | 4 +- mobile/lib/repositories/album.repository.dart | 88 +++-- .../repositories/album_api.repository.dart | 7 +- ...pi.repository.dart => api.repository.dart} | 4 +- mobile/lib/repositories/asset.repository.dart | 215 +++++++++---- .../repositories/asset_api.repository.dart | 5 +- .../lib/repositories/backup.repository.dart | 34 +- .../lib/repositories/database.repository.dart | 28 ++ mobile/lib/repositories/etag.repository.dart | 29 ++ .../repositories/exif_info.repository.dart | 23 +- .../repositories/partner_api.repository.dart | 4 +- .../repositories/person_api.repository.dart | 4 +- mobile/lib/repositories/user.repository.dart | 52 ++- .../lib/repositories/user_api.repository.dart | 5 +- mobile/lib/services/album.service.dart | 27 +- mobile/lib/services/asset.service.dart | 72 +++-- mobile/lib/services/background.service.dart | 70 ++-- mobile/lib/services/backup.service.dart | 30 +- .../services/backup_verification.service.dart | 6 +- mobile/lib/services/hash.service.dart | 4 +- mobile/lib/services/stack.service.dart | 3 +- mobile/lib/services/sync.service.dart | 302 +++++++++--------- .../modules/shared/sync_service_test.dart | 96 ++++-- mobile/test/repository.mocks.dart | 6 + mobile/test/services/album.service_test.dart | 9 +- 36 files changed, 873 insertions(+), 450 deletions(-) create mode 100644 mobile/lib/interfaces/database.interface.dart create mode 100644 mobile/lib/interfaces/etag.interface.dart rename mobile/lib/repositories/{base_api.repository.dart => api.repository.dart} (71%) create mode 100644 mobile/lib/repositories/database.repository.dart create mode 100644 mobile/lib/repositories/etag.repository.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 6a7d7a6b4d..80514f1603 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -64,19 +64,19 @@ custom_lint: allowed: # required / wanted - lib/entities/*.entity.dart - - lib/repositories/{album,asset,backup,exif_info,user}.repository.dart - # acceptable exceptions for the time being + - lib/repositories/{album,asset,backup,database,etag,exif_info,user}.repository.dart + # acceptable exceptions for the time being (until Isar is fully replaced) - integration_test/test_utils/general_helper.dart - lib/main.dart - - lib/routing/router.dart - - lib/utils/{db,migration,renderlist_generator}.dart - - test/**.dart - # refactor to make the providers and services testable - lib/pages/common/album_asset_selection.page.dart - - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart - - lib/services/{asset,background,backup,immich_logger,sync}.service.dart + - lib/routing/router.dart + - lib/services/immich_logger.service.dart # not really a service... more a util + - lib/utils/{db,migration,renderlist_generator}.dart - lib/widgets/asset_grid/asset_grid_data_structure.dart + - test/**.dart + # refactor the remaining providers + - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart + - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart index c2ba650b6f..ba188f1270 100644 --- a/mobile/lib/interfaces/album.interface.dart +++ b/mobile/lib/interfaces/album.interface.dart @@ -1,21 +1,43 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IAlbumRepository { - Future count({bool? local}); +abstract interface class IAlbumRepository implements IDatabaseRepository { Future create(Album album); - Future getById(int id); + + Future get(int id); + Future getByName( String name, { bool? shared, bool? remote, }); + + Future> getAll({ + bool? shared, + bool? remote, + int? ownerId, + AlbumSort? sortBy, + }); + Future update(Album album); + Future delete(int albumId); - Future> getAll({bool? shared}); + + Future deleteAllLocal(); + + Future count({bool? local}); + + Future addUsers(Album album, List users); + Future removeUsers(Album album, List users); + Future addAssets(Album album, List assets); + Future removeAssets(Album album, List assets); + Future recalculateMetadata(Album album); } + +enum AlbumSort { remoteId, localId } diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 0d2dcfa1b5..5aec594eb1 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -1,27 +1,62 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IAssetRepository { +abstract interface class IAssetRepository implements IDatabaseRepository { Future getByRemoteId(String id); - Future> getAllByRemoteId(Iterable ids); - Future> getByAlbum(Album album, {User? notOwnedBy}); - Future deleteById(List ids); + + Future getByOwnerIdChecksum(int ownerId, String checksum); + + Future> getAllByRemoteId( + Iterable ids, { + AssetState? state, + }); + + Future> getAllByOwnerIdChecksum( + List ids, + List checksums, + ); + Future> getAll({ required int ownerId, - bool? remote, - int limit = 100, + AssetState? state, + AssetSort? sortBy, + int? limit, }); + + Future> getAllLocal(); + + Future> getByAlbum( + Album album, { + Iterable notOwnedBy = const [], + int? ownerId, + AssetState? state, + AssetSort? sortBy, + }); + + Future update(Asset asset); + Future> updateAll(List assets); + Future deleteAllByRemoteId(List ids, {AssetState? state}); + + Future deleteById(List ids); + Future> getMatches({ required List assets, required int ownerId, - bool? remote, + AssetState? state, int limit = 100, }); Future> getDeviceAssetsById(List ids); + Future upsertDeviceAssets(List deviceAssets); + + Future upsertDuplicatedAssets(Iterable duplicatedAssets); + + Future> getAllDuplicatedAssetIds(); } + +enum AssetSort { checksum, ownerIdChecksum } diff --git a/mobile/lib/interfaces/backup.interface.dart b/mobile/lib/interfaces/backup.interface.dart index e343a9d390..c32199a58f 100644 --- a/mobile/lib/interfaces/backup.interface.dart +++ b/mobile/lib/interfaces/backup.interface.dart @@ -1,5 +1,16 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IBackupRepository implements IDatabaseRepository { + Future> getAll({BackupAlbumSort? sort}); -abstract interface class IBackupRepository { Future> getIdsBySelection(BackupSelection backup); + + Future> getAllBySelection(BackupSelection backup); + + Future updateAll(List backupAlbums); + + Future deleteAll(List ids); } + +enum BackupAlbumSort { id } diff --git a/mobile/lib/interfaces/database.interface.dart b/mobile/lib/interfaces/database.interface.dart new file mode 100644 index 0000000000..5645d15c47 --- /dev/null +++ b/mobile/lib/interfaces/database.interface.dart @@ -0,0 +1,3 @@ +abstract interface class IDatabaseRepository { + Future transaction(Future Function() callback); +} diff --git a/mobile/lib/interfaces/etag.interface.dart b/mobile/lib/interfaces/etag.interface.dart new file mode 100644 index 0000000000..e567235d1b --- /dev/null +++ b/mobile/lib/interfaces/etag.interface.dart @@ -0,0 +1,14 @@ +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IETagRepository implements IDatabaseRepository { + Future get(int id); + + Future getById(String id); + + Future> getAllIds(); + + Future upsertAll(List etags); + + Future deleteByIds(List ids); +} diff --git a/mobile/lib/interfaces/exif_info.interface.dart b/mobile/lib/interfaces/exif_info.interface.dart index fa8ca08f9d..86608c26d0 100644 --- a/mobile/lib/interfaces/exif_info.interface.dart +++ b/mobile/lib/interfaces/exif_info.interface.dart @@ -1,9 +1,12 @@ import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IExifInfoRepository { +abstract interface class IExifInfoRepository implements IDatabaseRepository { Future get(int id); Future update(ExifInfo exifInfo); + Future> updateAll(List exifInfos); + Future delete(int id); } diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index 828a7b2398..e6175a7dc9 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -1,8 +1,23 @@ import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IUserRepository { - Future> getByIds(List ids); +abstract interface class IUserRepository implements IDatabaseRepository { Future get(String id); - Future> getAll({bool self = true}); + + Future> getByIds(List ids); + + Future> getAll({bool self = true, UserSort? sortBy}); + + /// Returns all users whose assets can be accessed (self+partners) + Future> getAllAccessible(); + + Future> upsertAll(List users); + Future update(User user); + + Future deleteById(List ids); + + Future me(); } + +enum UserSort { id } diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index a2c3987aa8..c7e75df79b 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -275,28 +275,14 @@ class AssetNotifier extends StateNotifier { return isSuccess ? remote.toList() : []; } - Future toggleFavorite(List assets, [bool? status]) async { + Future toggleFavorite(List assets, [bool? status]) { status ??= !assets.every((a) => a.isFavorite); - final newAssets = await _assetService.changeFavoriteStatus(assets, status); - for (Asset? newAsset in newAssets) { - if (newAsset == null) { - log.severe("Change favorite status failed for asset"); - continue; - } - } + return _assetService.changeFavoriteStatus(assets, status); } - Future toggleArchive(List assets, [bool? status]) async { + Future toggleArchive(List assets, [bool? status]) { status ??= !assets.every((a) => a.isArchived); - final newAssets = await _assetService.changeArchiveStatus(assets, status); - int i = 0; - for (Asset oldAsset in assets) { - final newAsset = newAssets[i++]; - if (newAsset == null) { - log.severe("Change archive status failed for asset ${oldAsset.id}"); - continue; - } - } + return _assetService.changeArchiveStatus(assets, status); } } diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 0885f35f77..dc6d2f7cc8 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; @@ -17,6 +18,7 @@ import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; @@ -45,6 +47,7 @@ class BackupNotifier extends StateNotifier { this._db, this._albumMediaRepository, this._fileMediaRepository, + this._backupRepository, this.ref, ) : super( BackUpState( @@ -95,6 +98,7 @@ class BackupNotifier extends StateNotifier { final Isar _db; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; + final IBackupRepository _backupRepository; final Ref ref; /// @@ -255,9 +259,9 @@ class BackupNotifier extends StateNotifier { state = state.copyWith(availableAlbums: availableAlbums); final List excludedBackupAlbums = - await _backupService.excludedAlbumsQuery().findAll(); + await _backupRepository.getAllBySelection(BackupSelection.exclude); final List selectedBackupAlbums = - await _backupService.selectedAlbumsQuery().findAll(); + await _backupRepository.getAllBySelection(BackupSelection.select); final Set selectedAlbums = {}; for (final BackupAlbum ba in selectedBackupAlbums) { @@ -767,6 +771,7 @@ final backupProvider = ref.watch(dbProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(backupRepositoryProvider), ref, ); }); diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index 0cf159bfdd..192126f085 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -6,8 +6,10 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; @@ -25,7 +27,6 @@ import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -36,6 +37,7 @@ final manualUploadProvider = ref.watch(localNotificationService), ref.watch(backupProvider.notifier), ref.watch(backupServiceProvider), + ref.watch(backupRepositoryProvider), ref, ); }); @@ -45,12 +47,14 @@ class ManualUploadNotifier extends StateNotifier { final LocalNotificationService _localNotificationService; final BackupNotifier _backupProvider; final BackupService _backupService; + final BackupRepository _backupRepository; final Ref ref; ManualUploadNotifier( this._localNotificationService, this._backupProvider, this._backupService, + this._backupRepository, this.ref, ) : super( ManualUploadState( @@ -206,9 +210,9 @@ class ManualUploadNotifier extends StateNotifier { } final selectedBackupAlbums = - _backupService.selectedAlbumsQuery().findAllSync(); + await _backupRepository.getAllBySelection(BackupSelection.select); final excludedBackupAlbums = - _backupService.excludedAlbumsQuery().findAllSync(); + await _backupRepository.getAllBySelection(BackupSelection.exclude); // Get candidates from selected albums and excluded albums Set candidates = diff --git a/mobile/lib/repositories/activity_api.repository.dart b/mobile/lib/repositories/activity_api.repository.dart index 0b1b4d99f3..8da3759709 100644 --- a/mobile/lib/repositories/activity_api.repository.dart +++ b/mobile/lib/repositories/activity_api.repository.dart @@ -3,14 +3,14 @@ import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/activity_api.interface.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final activityApiRepositoryProvider = Provider( (ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi), ); -class ActivityApiRepository extends BaseApiRepository +class ActivityApiRepository extends ApiRepository implements IActivityApiRepository { final ActivitiesApi _api; diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index 08c939aa6c..35f5cae327 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -4,32 +4,36 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final albumRepositoryProvider = Provider((ref) => AlbumRepository(ref.watch(dbProvider))); -class AlbumRepository implements IAlbumRepository { - final Isar _db; - - AlbumRepository( - this._db, - ); +class AlbumRepository extends DatabaseRepository implements IAlbumRepository { + AlbumRepository(super.db); @override Future count({bool? local}) { - if (local == true) return _db.albums.where().localIdIsNotNull().count(); - if (local == false) return _db.albums.where().remoteIdIsNotNull().count(); - return _db.albums.count(); + final baseQuery = db.albums.where(); + final QueryBuilder query; + switch (local) { + case null: + query = baseQuery.noOp(); + case true: + query = baseQuery.localIdIsNotNull(); + case false: + query = baseQuery.remoteIdIsNotNull(); + } + return query.count(); } @override - Future create(Album album) => - _db.writeTxn(() => _db.albums.store(album)); + Future create(Album album) => txn(() => db.albums.store(album)); @override Future getByName(String name, {bool? shared, bool? remote}) { - var query = _db.albums.filter().nameEqualTo(name); + var query = db.albums.filter().nameEqualTo(name); if (shared != null) { query = query.sharedEqualTo(shared); } @@ -42,37 +46,61 @@ class AlbumRepository implements IAlbumRepository { } @override - Future update(Album album) => - _db.writeTxn(() => _db.albums.store(album)); + Future update(Album album) => txn(() => db.albums.store(album)); @override - Future delete(int albumId) => - _db.writeTxn(() => _db.albums.delete(albumId)); + Future delete(int albumId) => txn(() => db.albums.delete(albumId)); @override - Future> getAll({bool? shared}) { - final baseQuery = _db.albums.filter(); - QueryBuilder? query; - if (shared != null) { - query = baseQuery.sharedEqualTo(true); + Future> getAll({ + bool? shared, + bool? remote, + int? ownerId, + AlbumSort? sortBy, + }) { + final baseQuery = db.albums.where(); + final QueryBuilder afterWhere; + if (remote == null) { + afterWhere = baseQuery.noOp(); + } else if (remote) { + afterWhere = baseQuery.remoteIdIsNotNull(); + } else { + afterWhere = baseQuery.localIdIsNotNull(); } - return query?.findAll() ?? _db.albums.where().findAll(); + QueryBuilder filterQuery = + afterWhere.filter().noOp(); + if (shared != null) { + filterQuery = filterQuery.sharedEqualTo(true); + } + if (ownerId != null) { + filterQuery = filterQuery.owner((q) => q.isarIdEqualTo(ownerId)); + } + final QueryBuilder query; + switch (sortBy) { + case null: + query = filterQuery.noOp(); + case AlbumSort.remoteId: + query = filterQuery.sortByRemoteId(); + case AlbumSort.localId: + query = filterQuery.sortByLocalId(); + } + return query.findAll(); } @override - Future getById(int id) => _db.albums.get(id); + Future get(int id) => db.albums.get(id); @override Future removeUsers(Album album, List users) => - _db.writeTxn(() => album.sharedUsers.update(unlink: users)); + txn(() => album.sharedUsers.update(unlink: users)); @override Future addAssets(Album album, List assets) => - _db.writeTxn(() => album.assets.update(link: assets)); + txn(() => album.assets.update(link: assets)); @override Future removeAssets(Album album, List assets) => - _db.writeTxn(() => album.assets.update(unlink: assets)); + txn(() => album.assets.update(unlink: assets)); @override Future recalculateMetadata(Album album) async { @@ -82,4 +110,12 @@ class AlbumRepository implements IAlbumRepository { await album.assets.filter().updatedAtProperty().max(); return album; } + + @override + Future addUsers(Album album, List users) => + txn(() => album.sharedUsers.update(link: users)); + + @override + Future deleteAllLocal() => + txn(() => db.albums.where().localIdIsNotNull().deleteAll()); } diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart index 0e27e44684..5d0b56dc78 100644 --- a/mobile/lib/repositories/album_api.repository.dart +++ b/mobile/lib/repositories/album_api.repository.dart @@ -4,15 +4,14 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final albumApiRepositoryProvider = Provider( (ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi), ); -class AlbumApiRepository extends BaseApiRepository - implements IAlbumApiRepository { +class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository { final AlbumsApi _api; AlbumApiRepository(this._api); @@ -26,7 +25,7 @@ class AlbumApiRepository extends BaseApiRepository @override Future> getAll({bool? shared}) async { final dtos = await checkNull(_api.getAllAlbums(shared: shared)); - return dtos.map(_toAlbum).toList().cast(); + return dtos.map(_toAlbum).toList(); } @override diff --git a/mobile/lib/repositories/base_api.repository.dart b/mobile/lib/repositories/api.repository.dart similarity index 71% rename from mobile/lib/repositories/base_api.repository.dart rename to mobile/lib/repositories/api.repository.dart index 418cba84f8..b454c77f9b 100644 --- a/mobile/lib/repositories/base_api.repository.dart +++ b/mobile/lib/repositories/api.repository.dart @@ -1,8 +1,6 @@ -import 'package:flutter/foundation.dart'; import 'package:immich_mobile/constants/errors.dart'; -abstract class BaseApiRepository { - @protected +abstract class ApiRepository { Future checkNull(Future future) async { final response = await future; if (response == null) throw NoResponseDtoError(); diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index 087344302a..eaaafd3045 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -5,78 +5,145 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; +import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final assetRepositoryProvider = Provider((ref) => AssetRepository(ref.watch(dbProvider))); -class AssetRepository implements IAssetRepository { - final Isar _db; - - AssetRepository( - this._db, - ); +class AssetRepository extends DatabaseRepository implements IAssetRepository { + AssetRepository(super.db); @override - Future> getByAlbum(Album album, {User? notOwnedBy}) { + Future> getByAlbum( + Album album, { + Iterable notOwnedBy = const [], + int? ownerId, + AssetState? state, + AssetSort? sortBy, + }) { var query = album.assets.filter(); - if (notOwnedBy != null) { - query = query.not().ownerIdEqualTo(notOwnedBy.isarId); + if (notOwnedBy.length == 1) { + query = query.not().ownerIdEqualTo(notOwnedBy.first); + } else if (notOwnedBy.isNotEmpty) { + query = + query.not().anyOf(notOwnedBy, (q, int id) => q.ownerIdEqualTo(id)); } - return query.findAll(); + if (ownerId != null) { + query = query.ownerIdEqualTo(ownerId); + } + + switch (state) { + case null: + break; + case AssetState.local: + query = query.remoteIdIsNull(); + case AssetState.remote: + query = query.localIdIsNull(); + case AssetState.merged: + query = query.localIdIsNotNull().remoteIdIsNotNull(); + } + + final QueryBuilder sortedQuery; + + switch (sortBy) { + case null: + sortedQuery = query.noOp(); + case AssetSort.checksum: + sortedQuery = query.sortByChecksum(); + case AssetSort.ownerIdChecksum: + sortedQuery = query.sortByOwnerId().thenByChecksum(); + } + + return sortedQuery.findAll(); } @override - Future deleteById(List ids) => - _db.writeTxn(() => _db.assets.deleteAll(ids)); + Future deleteById(List ids) => txn(() async { + await db.assets.deleteAll(ids); + await db.exifInfos.deleteAll(ids); + }); @override - Future getByRemoteId(String id) => _db.assets.getByRemoteId(id); + Future getByRemoteId(String id) => db.assets.getByRemoteId(id); @override - Future> getAllByRemoteId(Iterable ids) => - _db.assets.getAllByRemoteId(ids); + Future> getAllByRemoteId( + Iterable ids, { + AssetState? state, + }) => + _getAllByRemoteIdImpl(ids, state).findAll(); + + QueryBuilder _getAllByRemoteIdImpl( + Iterable ids, + AssetState? state, + ) { + final query = db.assets.remote(ids).filter(); + switch (state) { + case null: + return query.noOp(); + case AssetState.local: + return query.remoteIdIsNull(); + case AssetState.remote: + return query.localIdIsNull(); + case AssetState.merged: + return query.localIdIsNotEmpty().remoteIdIsNotNull(); + } + } @override Future> getAll({ required int ownerId, - bool? remote, - int limit = 100, + AssetState? state, + AssetSort? sortBy, + int? limit, }) { - if (remote == null) { - return _db.assets - .where() - .ownerIdEqualToAnyChecksum(ownerId) - .limit(limit) - .findAll(); - } - final QueryBuilder query; - if (remote) { - query = _db.assets - .where() - .localIdIsNull() - .filter() - .remoteIdIsNotNull() - .ownerIdEqualTo(ownerId); - } else { - query = _db.assets - .where() - .remoteIdIsNull() - .filter() - .localIdIsNotNull() - .ownerIdEqualTo(ownerId); + final baseQuery = db.assets.where(); + final QueryBuilder filteredQuery; + switch (state) { + case null: + filteredQuery = baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp(); + case AssetState.local: + filteredQuery = baseQuery + .remoteIdIsNull() + .filter() + .localIdIsNotNull() + .ownerIdEqualTo(ownerId); + case AssetState.remote: + filteredQuery = baseQuery + .localIdIsNull() + .filter() + .remoteIdIsNotNull() + .ownerIdEqualTo(ownerId); + case AssetState.merged: + filteredQuery = baseQuery + .ownerIdEqualToAnyChecksum(ownerId) + .filter() + .remoteIdIsNotNull() + .localIdIsNotNull(); } - return query.limit(limit).findAll(); + final QueryBuilder query; + switch (sortBy) { + case null: + query = filteredQuery.noOp(); + case AssetSort.checksum: + query = filteredQuery.sortByChecksum(); + case AssetSort.ownerIdChecksum: + query = filteredQuery.sortByOwnerId().thenByChecksum(); + } + + return limit == null ? query.findAll() : query.limit(limit).findAll(); } @override Future> updateAll(List assets) async { - await _db.writeTxn(() => _db.assets.putAll(assets)); + await txn(() => db.assets.putAll(assets)); return assets; } @@ -84,16 +151,20 @@ class AssetRepository implements IAssetRepository { Future> getMatches({ required List assets, required int ownerId, - bool? remote, + AssetState? state, int limit = 100, }) { + final baseQuery = db.assets.where(); final QueryBuilder query; - if (remote == null) { - query = _db.assets.filter().remoteIdIsNotNull().or().localIdIsNotNull(); - } else if (remote) { - query = _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(); - } else { - query = _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(); + switch (state) { + case null: + query = baseQuery.noOp(); + case AssetState.local: + query = baseQuery.remoteIdIsNull().filter().localIdIsNotNull(); + case AssetState.remote: + query = baseQuery.localIdIsNull().filter().remoteIdIsNotNull(); + case AssetState.merged: + query = baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull(); } return _getMatchesImpl(query, ownerId, assets, limit); } @@ -101,16 +172,50 @@ class AssetRepository implements IAssetRepository { @override Future> getDeviceAssetsById(List ids) => Platform.isAndroid - ? _db.androidDeviceAssets.getAll(ids.cast()) - : _db.iOSDeviceAssets.getAllById(ids.cast()); + ? db.androidDeviceAssets.getAll(ids.cast()) + : db.iOSDeviceAssets.getAllById(ids.cast()); @override - Future upsertDeviceAssets(List deviceAssets) => - _db.writeTxn( + Future upsertDeviceAssets(List deviceAssets) => txn( () => Platform.isAndroid - ? _db.androidDeviceAssets.putAll(deviceAssets.cast()) - : _db.iOSDeviceAssets.putAll(deviceAssets.cast()), + ? db.androidDeviceAssets.putAll(deviceAssets.cast()) + : db.iOSDeviceAssets.putAll(deviceAssets.cast()), ); + + @override + Future update(Asset asset) async { + await txn(() => asset.put(db)); + return asset; + } + + @override + Future upsertDuplicatedAssets(Iterable duplicatedAssets) => txn( + () => db.duplicatedAssets + .putAll(duplicatedAssets.map(DuplicatedAsset.new).toList()), + ); + + @override + Future> getAllDuplicatedAssetIds() => + db.duplicatedAssets.where().idProperty().findAll(); + + @override + Future getByOwnerIdChecksum(int ownerId, String checksum) => + db.assets.getByOwnerIdChecksum(ownerId, checksum); + + @override + Future> getAllByOwnerIdChecksum( + List ids, + List checksums, + ) => + db.assets.getAllByOwnerIdChecksum(ids, checksums); + + @override + Future> getAllLocal() => + db.assets.where().localIdIsNotNull().findAll(); + + @override + Future deleteAllByRemoteId(List ids, {AssetState? state}) => + txn(() => _getAllByRemoteIdImpl(ids, state).deleteAll()); } Future> _getMatchesImpl( diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index eb796f6c6b..54d57c4dfc 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final assetApiRepositoryProvider = Provider( @@ -12,8 +12,7 @@ final assetApiRepositoryProvider = Provider( ), ); -class AssetApiRepository extends BaseApiRepository - implements IAssetApiRepository { +class AssetApiRepository extends ApiRepository implements IAssetApiRepository { final AssetsApi _api; final SearchApi _searchApi; diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart index c9d93f7877..61997ff23a 100644 --- a/mobile/lib/repositories/backup.repository.dart +++ b/mobile/lib/repositories/backup.repository.dart @@ -2,19 +2,41 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final backupRepositoryProvider = Provider((ref) => BackupRepository(ref.watch(dbProvider))); -class BackupRepository implements IBackupRepository { - final Isar _db; +class BackupRepository extends DatabaseRepository implements IBackupRepository { + BackupRepository(super.db); - BackupRepository( - this._db, - ); + @override + Future> getAll({BackupAlbumSort? sort}) { + final baseQuery = db.backupAlbums.where(); + final QueryBuilder query; + switch (sort) { + case null: + query = baseQuery.noOp(); + case BackupAlbumSort.id: + query = baseQuery.sortById(); + } + return query.findAll(); + } @override Future> getIdsBySelection(BackupSelection backup) => - _db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll(); + db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll(); + + @override + Future> getAllBySelection(BackupSelection backup) => + db.backupAlbums.filter().selectionEqualTo(backup).findAll(); + + @override + Future deleteAll(List ids) => + txn(() => db.backupAlbums.deleteAll(ids)); + + @override + Future updateAll(List backupAlbums) => + txn(() => db.backupAlbums.putAll(backupAlbums)); } diff --git a/mobile/lib/repositories/database.repository.dart b/mobile/lib/repositories/database.repository.dart new file mode 100644 index 0000000000..f9ee1426bb --- /dev/null +++ b/mobile/lib/repositories/database.repository.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +import 'package:immich_mobile/interfaces/database.interface.dart'; +import 'package:isar/isar.dart'; + +/// copied from Isar; needed to check if an async transaction is already active +const Symbol _zoneTxn = #zoneTxn; + +abstract class DatabaseRepository implements IDatabaseRepository { + final Isar db; + DatabaseRepository(this.db); + + bool get inTxn => Zone.current[_zoneTxn] != null; + + Future txn(Future Function() callback) => + inTxn ? callback() : transaction(callback); + + @override + Future transaction(Future Function() callback) => + db.writeTxn(callback); +} + +extension Asd on QueryBuilder { + QueryBuilder noOp() { + // ignore: invalid_use_of_protected_member + return QueryBuilder.apply(this, (query) => query); + } +} diff --git a/mobile/lib/repositories/etag.repository.dart b/mobile/lib/repositories/etag.repository.dart new file mode 100644 index 0000000000..9921b69f5e --- /dev/null +++ b/mobile/lib/repositories/etag.repository.dart @@ -0,0 +1,29 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final etagRepositoryProvider = + Provider((ref) => ETagRepository(ref.watch(dbProvider))); + +class ETagRepository extends DatabaseRepository implements IETagRepository { + ETagRepository(super.db); + + @override + Future> getAllIds() => db.eTags.where().idProperty().findAll(); + + @override + Future get(int id) => db.eTags.get(id); + + @override + Future upsertAll(List etags) => txn(() => db.eTags.putAll(etags)); + + @override + Future deleteByIds(List ids) => + txn(() => db.eTags.deleteAllById(ids)); + + @override + Future getById(String id) => db.eTags.getById(id); +} diff --git a/mobile/lib/repositories/exif_info.repository.dart b/mobile/lib/repositories/exif_info.repository.dart index a165e98bdb..3ddb50104b 100644 --- a/mobile/lib/repositories/exif_info.repository.dart +++ b/mobile/lib/repositories/exif_info.repository.dart @@ -2,27 +2,30 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; final exifInfoRepositoryProvider = Provider((ref) => ExifInfoRepository(ref.watch(dbProvider))); -class ExifInfoRepository implements IExifInfoRepository { - final Isar _db; - - ExifInfoRepository( - this._db, - ); +class ExifInfoRepository extends DatabaseRepository + implements IExifInfoRepository { + ExifInfoRepository(super.db); @override - Future delete(int id) => _db.exifInfos.delete(id); + Future delete(int id) => txn(() => db.exifInfos.delete(id)); @override - Future get(int id) => _db.exifInfos.get(id); + Future get(int id) => db.exifInfos.get(id); @override Future update(ExifInfo exifInfo) async { - await _db.writeTxn(() => _db.exifInfos.put(exifInfo)); + await txn(() => db.exifInfos.put(exifInfo)); return exifInfo; } + + @override + Future> updateAll(List exifInfos) async { + await txn(() => db.exifInfos.putAll(exifInfos)); + return exifInfos; + } } diff --git a/mobile/lib/repositories/partner_api.repository.dart b/mobile/lib/repositories/partner_api.repository.dart index 3419a2bc77..0b3d164ca3 100644 --- a/mobile/lib/repositories/partner_api.repository.dart +++ b/mobile/lib/repositories/partner_api.repository.dart @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final partnerApiRepositoryProvider = Provider( @@ -11,7 +11,7 @@ final partnerApiRepositoryProvider = Provider( ), ); -class PartnerApiRepository extends BaseApiRepository +class PartnerApiRepository extends ApiRepository implements IPartnerApiRepository { final PartnersApi _api; diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 8071c33dc2..d324a03edb 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -1,14 +1,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final personApiRepositoryProvider = Provider( (ref) => PersonApiRepository(ref.watch(apiServiceProvider).peopleApi), ); -class PersonApiRepository extends BaseApiRepository +class PersonApiRepository extends ApiRepository implements IPersonApiRepository { final PeopleApi _api; diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index 796b1f421b..fb4df84fe7 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -3,37 +3,61 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final userRepositoryProvider = Provider((ref) => UserRepository(ref.watch(dbProvider))); -class UserRepository implements IUserRepository { - final Isar _db; - - UserRepository( - this._db, - ); +class UserRepository extends DatabaseRepository implements IUserRepository { + UserRepository(super.db); @override Future> getByIds(List ids) async => - (await _db.users.getAllById(ids)).cast(); + (await db.users.getAllById(ids)).nonNulls.toList(); @override - Future get(String id) => _db.users.getById(id); + Future get(String id) => db.users.getById(id); @override - Future> getAll({bool self = true}) { - if (self) { - return _db.users.where().findAll(); - } + Future> getAll({bool self = true, UserSort? sortBy}) { + final baseQuery = db.users.where(); final int userId = Store.get(StoreKey.currentUser).isarId; - return _db.users.where().isarIdNotEqualTo(userId).findAll(); + final QueryBuilder afterWhere = + self ? baseQuery.noOp() : baseQuery.isarIdNotEqualTo(userId); + final QueryBuilder query; + switch (sortBy) { + case null: + query = afterWhere.noOp(); + case UserSort.id: + query = afterWhere.sortById(); + } + return query.findAll(); } @override Future update(User user) async { - await _db.writeTxn(() => _db.users.put(user)); + await txn(() => db.users.put(user)); return user; } + + @override + Future me() => Future.value(Store.get(StoreKey.currentUser)); + + @override + Future deleteById(List ids) => txn(() => db.users.deleteAll(ids)); + + @override + Future> upsertAll(List users) async { + await txn(() => db.users.putAll(users)); + return users; + } + + @override + Future> getAllAccessible() => db.users + .filter() + .isPartnerSharedWithEqualTo(true) + .or() + .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) + .findAll(); } diff --git a/mobile/lib/repositories/user_api.repository.dart b/mobile/lib/repositories/user_api.repository.dart index ffc50ae4c3..9641c4e0e6 100644 --- a/mobile/lib/repositories/user_api.repository.dart +++ b/mobile/lib/repositories/user_api.repository.dart @@ -5,7 +5,7 @@ import 'package:http/http.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final userApiRepositoryProvider = Provider( @@ -14,8 +14,7 @@ final userApiRepositoryProvider = Provider( ), ); -class UserApiRepository extends BaseApiRepository - implements IUserApiRepository { +class UserApiRepository extends ApiRepository implements IUserApiRepository { final UsersApi _api; UserApiRepository(this._api); diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index dd021e698e..091049edb5 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -243,14 +243,15 @@ class AlbumService { int albumId, { List add = const [], List remove = const [], - }) async { - final album = await _albumRepository.getById(albumId); - if (album == null) return; - await _albumRepository.addAssets(album, add); - await _albumRepository.removeAssets(album, remove); - await _albumRepository.recalculateMetadata(album); - await _albumRepository.update(album); - } + }) => + _albumRepository.transaction(() async { + final album = await _albumRepository.get(albumId); + if (album == null) return; + await _albumRepository.addAssets(album, add); + await _albumRepository.removeAssets(album, remove); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); + }); Future addAdditionalUserToAlbum( List sharedUserIds, @@ -285,20 +286,20 @@ class AlbumService { Future deleteAlbum(Album album) async { try { - final user = Store.get(StoreKey.currentUser); - if (album.owner.value?.isarId == user.isarId) { + final userId = Store.get(StoreKey.currentUser).isarId; + if (album.owner.value?.isarId == userId) { await _albumApiRepository.delete(album.remoteId!); } if (album.shared) { final foreignAssets = - await _assetRepository.getByAlbum(album, notOwnedBy: user); + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]); await _albumRepository.delete(album.id); final List albums = await _albumRepository.getAll(shared: true); final List existing = []; for (Album album in albums) { existing.addAll( - await _assetRepository.getByAlbum(album, notOwnedBy: user), + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]), ); } final List idsToRemove = @@ -357,7 +358,7 @@ class AlbumService { album.sharedUsers.remove(user); await _albumRepository.removeUsers(album, [user]); - final a = await _albumRepository.getById(album.id); + final a = await _albumRepository.get(album.id); // trigger watcher await _albumRepository.update(a!); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 262040026e..b2cad4dc82 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -1,27 +1,30 @@ -// ignore_for_file: null_argument_to_non_null_type - import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; import 'package:immich_mobile/repositories/exif_info.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:openapi/api.dart'; @@ -29,48 +32,54 @@ import 'package:openapi/api.dart'; final assetServiceProvider = Provider( (ref) => AssetService( ref.watch(assetApiRepositoryProvider), + ref.watch(assetRepositoryProvider), ref.watch(exifInfoRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(etagRepositoryProvider), + ref.watch(backupRepositoryProvider), ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), ref.watch(backupServiceProvider), ref.watch(albumServiceProvider), - ref.watch(dbProvider), ), ); class AssetService { final IAssetApiRepository _assetApiRepository; + final IAssetRepository _assetRepository; final IExifInfoRepository _exifInfoRepository; + final IUserRepository _userRepository; + final IETagRepository _etagRepository; + final IBackupRepository _backupRepository; final ApiService _apiService; final SyncService _syncService; final UserService _userService; final BackupService _backupService; final AlbumService _albumService; final log = Logger('AssetService'); - final Isar _db; AssetService( this._assetApiRepository, + this._assetRepository, this._exifInfoRepository, + this._userRepository, + this._etagRepository, + this._backupRepository, this._apiService, this._syncService, this._userService, this._backupService, this._albumService, - this._db, ); /// Checks the server for updated assets and updates the local database if /// required. Returns `true` if there were any changes. Future refreshRemoteAssets() async { - final syncedUserIds = await _db.eTags.where().idProperty().findAll(); + final syncedUserIds = await _etagRepository.getAllIds(); final List syncedUsers = syncedUserIds.isEmpty ? [] - : await _db.users - .where() - .anyOf(syncedUserIds, (q, id) => q.idEqualTo(id)) - .findAll(); + : await _userRepository.getByIds(syncedUserIds); final Stopwatch sw = Stopwatch()..start(); final bool changes = await _syncService.syncRemoteAssetsToDb( users: syncedUsers, @@ -175,7 +184,7 @@ class AssetService { /// Loads the exif information from the database. If there is none, loads /// the exif info from the server (remote assets only) Future loadExif(Asset a) async { - a.exifInfo ??= await _db.exifInfos.get(a.id); + a.exifInfo ??= await _exifInfoRepository.get(a.id); // fileSize is always filled on the server but not set on client if (a.exifInfo?.fileSize == null) { if (a.isRemote) { @@ -185,7 +194,7 @@ class AssetService { a.exifInfo = newExif; if (newExif != a.exifInfo) { if (a.isInDb) { - _db.writeTxn(() => a.put(_db)); + _assetRepository.transaction(() => _assetRepository.update(a)); } else { debugPrint("[loadExif] parameter Asset is not from DB!"); } @@ -214,7 +223,7 @@ class AssetService { ); } - Future> changeFavoriteStatus( + Future> changeFavoriteStatus( List assets, bool isFavorite, ) async { @@ -230,11 +239,11 @@ class AssetService { return assets; } catch (error, stack) { log.severe("Error while changing favorite status", error, stack); - return Future.value(null); + return []; } } - Future> changeArchiveStatus( + Future> changeArchiveStatus( List assets, bool isArchived, ) async { @@ -250,11 +259,11 @@ class AssetService { return assets; } catch (error, stack) { log.severe("Error while changing archive status", error, stack); - return Future.value(null); + return []; } } - Future> changeDateTime( + Future?> changeDateTime( List assets, String updatedDt, ) async { @@ -278,7 +287,7 @@ class AssetService { } } - Future> changeLocation( + Future?> changeLocation( List assets, LatLng location, ) async { @@ -307,10 +316,10 @@ class AssetService { Future syncUploadedAssetToAlbums() async { try { - final [selectedAlbums, excludedAlbums] = await Future.wait([ - _backupService.selectedAlbumsQuery().findAll(), - _backupService.excludedAlbumsQuery().findAll(), - ]); + final selectedAlbums = + await _backupRepository.getAllBySelection(BackupSelection.select); + final excludedAlbums = + await _backupRepository.getAllBySelection(BackupSelection.exclude); final candidates = await _backupService.buildUploadCandidates( selectedAlbums, @@ -319,12 +328,11 @@ class AssetService { ); await refreshRemoteAssets(); - final remoteAssets = await _db.assets - .where() - .localIdIsNotNull() - .filter() - .remoteIdIsNotNull() - .findAll(); + final owner = await _userRepository.me(); + final remoteAssets = await _assetRepository.getAll( + ownerId: owner.isarId, + state: AssetState.merged, + ); /// Map Map> assetToAlbums = {}; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 86dfd0c599..3959e2a6ed 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -9,6 +9,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; @@ -18,6 +19,8 @@ import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; @@ -38,7 +41,6 @@ import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; -import 'package:isar/isar.dart'; import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -357,7 +359,7 @@ class BackgroundService { } Future _onAssetsChanged() async { - final Isar db = await loadDb(); + final db = await loadDb(); HttpOverrides.global = HttpSSLCertOverride(); ApiService apiService = ApiService(); @@ -366,7 +368,9 @@ class BackgroundService { AppSettingsService settingsService = AppSettingsService(); AlbumRepository albumRepository = AlbumRepository(db); AssetRepository assetRepository = AssetRepository(db); - BackupRepository backupAlbumRepository = BackupRepository(db); + BackupRepository backupRepository = BackupRepository(db); + ExifInfoRepository exifInfoRepository = ExifInfoRepository(db); + ETagRepository eTagRepository = ETagRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); AssetMediaRepository assetMediaRepository = AssetMediaRepository(); @@ -382,11 +386,15 @@ class BackgroundService { EntityService entityService = EntityService(assetRepository, userRepository); SyncService syncSerive = SyncService( - db, hashService, entityService, albumMediaRepository, albumApiRepository, + albumRepository, + assetRepository, + exifInfoRepository, + userRepository, + eTagRepository, ); UserService userService = UserService( partnerApiRepository, @@ -400,22 +408,24 @@ class BackgroundService { entityService, albumRepository, assetRepository, - backupAlbumRepository, + backupRepository, albumMediaRepository, albumApiRepository, ); BackupService backupService = BackupService( apiService, - db, settingService, albumService, albumMediaRepository, fileMediaRepository, + assetRepository, assetMediaRepository, ); - final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); - final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); + final selectedAlbums = + await backupRepository.getAllBySelection(BackupSelection.select); + final excludedAlbums = + await backupRepository.getAllBySelection(BackupSelection.exclude); if (selectedAlbums.isEmpty) { return true; } @@ -433,28 +443,28 @@ class BackgroundService { await Store.delete(StoreKey.backupFailedSince); final backupAlbums = [...selectedAlbums, ...excludedAlbums]; backupAlbums.sortBy((e) => e.id); - db.writeTxnSync(() { - final dbAlbums = db.backupAlbums.where().sortById().findAllSync(); - final List toDelete = []; - final List toUpsert = []; - // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state - diffSortedListsSync( - dbAlbums, - backupAlbums, - compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), - both: (BackupAlbum a, BackupAlbum b) { - a.lastBackup = a.lastBackup.isAfter(b.lastBackup) - ? a.lastBackup - : b.lastBackup; - toUpsert.add(a); - return true; - }, - onlyFirst: (BackupAlbum a) => toUpsert.add(a), - onlySecond: (BackupAlbum b) => toDelete.add(b.isarId), - ); - db.backupAlbums.deleteAllSync(toDelete); - db.backupAlbums.putAllSync(toUpsert); - }); + + final dbAlbums = + await backupRepository.getAll(sort: BackupAlbumSort.id); + final List toDelete = []; + final List toUpsert = []; + // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state + diffSortedListsSync( + dbAlbums, + backupAlbums, + compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), + both: (BackupAlbum a, BackupAlbum b) { + a.lastBackup = a.lastBackup.isAfter(b.lastBackup) + ? a.lastBackup + : b.lastBackup; + toUpsert.add(a); + return true; + }, + onlyFirst: (BackupAlbum a) => toUpsert.add(a), + onlySecond: (BackupAlbum b) => toDelete.add(b.isarId), + ); + await backupRepository.deleteAll(toDelete); + await backupRepository.updateAll(toUpsert); } else if (Store.tryGet(StoreKey.backupFailedSince) == null) { Store.put(StoreKey.backupFailedSince, DateTime.now()); return false; diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 683339f271..a0b6bf16c2 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -9,9 +9,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; @@ -20,14 +20,13 @@ import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:path/path.dart' as p; @@ -37,11 +36,11 @@ import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; final backupServiceProvider = Provider( (ref) => BackupService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), ref.watch(appSettingsServiceProvider), ref.watch(albumServiceProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(assetRepositoryProvider), ref.watch(assetMediaRepositoryProvider), ), ); @@ -49,21 +48,21 @@ final backupServiceProvider = Provider( class BackupService { final httpClient = http.Client(); final ApiService _apiService; - final Isar _db; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; final AlbumService _albumService; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; + final IAssetRepository _assetRepository; final IAssetMediaRepository _assetMediaRepository; BackupService( this._apiService, - this._db, this._appSetting, this._albumService, this._albumMediaRepository, this._fileMediaRepository, + this._assetRepository, this._assetMediaRepository, ); @@ -78,24 +77,17 @@ class BackupService { } } - Future _saveDuplicatedAssetIds(List deviceAssetIds) { - final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList(); - return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates)); - } + Future _saveDuplicatedAssetIds(List deviceAssetIds) => + _assetRepository.transaction( + () => _assetRepository.upsertDuplicatedAssets(deviceAssetIds), + ); /// Get duplicated asset id from database Future> getDuplicatedAssetIds() async { - final duplicates = await _db.duplicatedAssets.where().findAll(); - return duplicates.map((e) => e.id).toSet(); + final duplicates = await _assetRepository.getAllDuplicatedAssetIds(); + return duplicates.toSet(); } - QueryBuilder - selectedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); - QueryBuilder - excludedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); - /// Returns all assets newer than the last successful backup per album /// if `useTimeFilter` is set to true, all assets will be returned Future> buildUploadCandidates( diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index da9d8da164..82cfb8347a 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -34,19 +34,19 @@ class BackupVerificationService { final owner = Store.get(StoreKey.currentUser).isarId; final List onlyLocal = await _assetRepository.getAll( ownerId: owner, - remote: false, + state: AssetState.local, limit: limit, ); final List remoteMatches = await _assetRepository.getMatches( assets: onlyLocal, ownerId: owner, - remote: true, + state: AssetState.remote, limit: limit, ); final List localMatches = await _assetRepository.getMatches( assets: remoteMatches, ownerId: owner, - remote: false, + state: AssetState.local, limit: limit, ); diff --git a/mobile/lib/services/hash.service.dart b/mobile/lib/services/hash.service.dart index 3827e421e6..bb19340d2f 100644 --- a/mobile/lib/services/hash.service.dart +++ b/mobile/lib/services/hash.service.dart @@ -130,7 +130,9 @@ class HashService { final validHashes = anyNull ? toAdd.where((e) => e.hash.length == 20).toList(growable: false) : toAdd; - await _assetRepository.upsertDeviceAssets(validHashes); + + await _assetRepository + .transaction(() => _assetRepository.upsertDeviceAssets(validHashes)); _log.fine("Hashed ${validHashes.length}/${toHash.length} assets"); } diff --git a/mobile/lib/services/stack.service.dart b/mobile/lib/services/stack.service.dart index 8bff21fef6..1ca56ff279 100644 --- a/mobile/lib/services/stack.service.dart +++ b/mobile/lib/services/stack.service.dart @@ -61,7 +61,8 @@ class StackService { removeAssets.add(asset); } - await _assetRepository.updateAll(removeAssets); + await _assetRepository + .transaction(() => _assetRepository.updateAll(removeAssets)); } catch (error) { debugPrint("Error while deleting stack: $error"); } diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index c3f927fc93..e23c2d1b1b 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -5,48 +5,66 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/utils/datetime_comparison.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; final syncServiceProvider = Provider( (ref) => SyncService( - ref.watch(dbProvider), ref.watch(hashServiceProvider), ref.watch(entityServiceProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(albumApiRepositoryProvider), + ref.watch(albumRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(etagRepositoryProvider), ), ); class SyncService { - final Isar _db; final HashService _hashService; final EntityService _entityService; final IAlbumMediaRepository _albumMediaRepository; final IAlbumApiRepository _albumApiRepository; + final IAlbumRepository _albumRepository; + final IAssetRepository _assetRepository; + final IExifInfoRepository _exifInfoRepository; + final IUserRepository _userRepository; + final IETagRepository _eTagRepository; final AsyncMutex _lock = AsyncMutex(); final Logger _log = Logger('SyncService'); SyncService( - this._db, this._hashService, this._entityService, this._albumMediaRepository, this._albumApiRepository, + this._albumRepository, + this._assetRepository, + this._exifInfoRepository, + this._userRepository, + this._eTagRepository, ); // public methods: @@ -119,7 +137,7 @@ class SyncService { /// Returns `true`if there were any changes Future _syncUsersFromServer(List users) async { users.sortBy((u) => u.id); - final dbUsers = await _db.users.where().sortById().findAll(); + final dbUsers = await _userRepository.getAll(sortBy: UserSort.id); assert(dbUsers.isSortedBy((u) => u.id), "dbUsers not sorted!"); final List toDelete = []; final List toUpsert = []; @@ -141,9 +159,9 @@ class SyncService { onlySecond: (User b) => toDelete.add(b.isarId), ); if (changes) { - await _db.writeTxn(() async { - await _db.users.deleteAll(toDelete); - await _db.users.putAll(toUpsert); + await _userRepository.transaction(() async { + await _userRepository.deleteById(toDelete); + await _userRepository.upsertAll(toUpsert); }); } return changes; @@ -152,15 +170,15 @@ class SyncService { /// Syncs a new asset to the db. Returns `true` if successful Future _syncNewAssetToDb(Asset a) async { final Asset? inDb = - await _db.assets.getByOwnerIdChecksum(a.ownerId, a.checksum); + await _assetRepository.getByOwnerIdChecksum(a.ownerId, a.checksum); if (inDb != null) { // unify local/remote assets by replacing the // local-only asset in the DB with a local&remote asset a = inDb.updatedCopy(a); } try { - await _db.writeTxn(() => a.put(_db)); - } on IsarError catch (e) { + await _assetRepository.update(a); + } catch (e) { _log.severe("Failed to put new asset into db", e); return false; } @@ -175,9 +193,9 @@ class SyncService { DateTime since, ) getChangedAssets, ) async { - final currentUser = Store.get(StoreKey.currentUser); + final currentUser = await _userRepository.me(); final DateTime? since = - _db.eTags.getSync(currentUser.isarId)?.time?.toUtc(); + (await _eTagRepository.get(currentUser.isarId))?.time?.toUtc(); if (since == null) return null; final DateTime now = DateTime.now(); final (toUpsert, toDelete) = await getChangedAssets(users, since); @@ -198,7 +216,7 @@ class SyncService { return true; } return false; - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote assets to db", e); } return null; @@ -206,23 +224,21 @@ class SyncService { /// Deletes remote-only assets, updates merged assets to be local-only Future handleRemoteAssetRemoval(List idsToDelete) { - return _db.writeTxn(() async { - final idsToRemove = await _db.assets - .remote(idsToDelete) - .filter() - .localIdIsNull() - .idProperty() - .findAll(); - await _db.assets.deleteAll(idsToRemove); - await _db.exifInfos.deleteAll(idsToRemove); - final onlyLocal = await _db.assets.remote(idsToDelete).findAll(); - if (onlyLocal.isNotEmpty) { - for (final Asset a in onlyLocal) { - a.remoteId = null; - a.isTrashed = false; - } - await _db.assets.putAll(onlyLocal); + return _assetRepository.transaction(() async { + await _assetRepository.deleteAllByRemoteId( + idsToDelete, + state: AssetState.remote, + ); + final merged = await _assetRepository.getAllByRemoteId( + idsToDelete, + state: AssetState.merged, + ); + if (merged.isEmpty) return; + for (final Asset asset in merged) { + asset.remoteId = null; + asset.isTrashed = false; } + await _assetRepository.updateAll(merged); }); } @@ -237,12 +253,7 @@ class SyncService { return false; } await _syncUsersFromServer(serverUsers); - final List users = await _db.users - .filter() - .isPartnerSharedWithEqualTo(true) - .or() - .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) - .findAll(); + final List users = await _userRepository.getAllAccessible(); bool changes = false; for (User u in users) { changes |= await _syncRemoteAssetsForUser(u, loadAssets); @@ -259,11 +270,10 @@ class SyncService { if (remote == null) { return false; } - final List inDb = await _db.assets - .where() - .ownerIdEqualToAnyChecksum(user.isarId) - .sortByChecksum() - .findAll(); + final List inDb = await _assetRepository.getAll( + ownerId: user.isarId, + sortBy: AssetSort.checksum, + ); assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); remote.sort(Asset.compareByChecksum); @@ -278,9 +288,9 @@ class SyncService { } final idsToDelete = toRemove.map((e) => e.id).toList(); try { - await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); + await _assetRepository.deleteById(idsToDelete); await upsertAssetsWithExif(toAdd + toUpdate); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote assets to db", e); } await _updateUserAssetsETag([user], now); @@ -289,12 +299,12 @@ class SyncService { Future _updateUserAssetsETag(List users, DateTime time) { final etags = users.map((u) => ETag(id: u.id, time: time)).toList(); - return _db.writeTxn(() => _db.eTags.putAll(etags)); + return _eTagRepository.upsertAll(etags); } Future _clearUserAssetsETag(List users) { final ids = users.map((u) => u.id).toList(); - return _db.writeTxn(() => _db.eTags.deleteAllById(ids)); + return _eTagRepository.deleteByIds(ids); } /// Syncs remote albums to the database @@ -305,15 +315,13 @@ class SyncService { ) async { remoteAlbums.sortBy((e) => e.remoteId!); - final baseQuery = _db.albums.where().remoteIdIsNotNull().filter(); - final QueryBuilder query; - if (isShared) { - query = baseQuery.sharedEqualTo(true); - } else { - final User me = Store.get(StoreKey.currentUser); - query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId)); - } - final List dbAlbums = await query.sortByRemoteId().findAll(); + final User me = await _userRepository.me(); + final List dbAlbums = await _albumRepository.getAll( + remote: true, + shared: isShared ? true : null, + ownerId: isShared ? null : me.isarId, + sortBy: AlbumSort.remoteId, + ); assert(dbAlbums.isSortedBy((e) => e.remoteId!), "dbAlbums not sorted!"); final List toDelete = []; @@ -333,10 +341,7 @@ class SyncService { if (isShared && toDelete.isNotEmpty) { final List idsToRemove = sharedAssetsToRemove(toDelete, existing); if (idsToRemove.isNotEmpty) { - await _db.writeTxn(() async { - await _db.assets.deleteAll(idsToRemove); - await _db.exifInfos.deleteAll(idsToRemove); - }); + await _assetRepository.deleteById(idsToRemove); } } else { assert(toDelete.isEmpty); @@ -360,8 +365,11 @@ class SyncService { // i.e. it will always be null. Save it here. final originalDto = dto; dto = await _albumApiRepository.get(dto.remoteId!); - final assetsInDb = - await album.assets.filter().sortByOwnerId().thenByChecksum().findAll(); + + final assetsInDb = await _assetRepository.getByAlbum( + album, + sortBy: AssetSort.ownerIdChecksum, + ); assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!"); final List assetsOnRemote = dto.remoteAssets.toList(); assetsOnRemote.sort(Asset.compareByOwnerChecksum); @@ -391,7 +399,7 @@ class SyncService { final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd); await upsertAssetsWithExif(updated); final assetsToLink = existingInDb + updated; - final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast(); + final usersToLink = await _userRepository.getByIds(userIdsToAdd); album.name = dto.name; album.shared = dto.shared; @@ -402,32 +410,33 @@ class SyncService { album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp; album.shared = dto.shared; album.activityEnabled = dto.activityEnabled; - if (album.thumbnail.value?.remoteId != dto.remoteThumbnailAssetId) { - album.thumbnail.value = await _db.assets - .where() - .remoteIdEqualTo(dto.remoteThumbnailAssetId) - .findFirst(); + final remoteThumbnailAssetId = dto.remoteThumbnailAssetId; + if (remoteThumbnailAssetId != null && + album.thumbnail.value?.remoteId != remoteThumbnailAssetId) { + album.thumbnail.value = + await _assetRepository.getByRemoteId(remoteThumbnailAssetId); } // write & commit all changes to DB try { - await _db.writeTxn(() async { - await _db.assets.putAll(toUpdate); - await album.thumbnail.save(); - await album.sharedUsers - .update(link: usersToLink, unlink: usersToUnlink); - await album.assets.update(link: assetsToLink, unlink: toUnlink.cast()); - await _db.albums.put(album); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(toUpdate); + await _albumRepository.addUsers(album, usersToLink); + await _albumRepository.removeUsers(album, usersToUnlink); + await _albumRepository.addAssets(album, assetsToLink); + await _albumRepository.removeAssets(album, toUnlink); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); }); _log.info("Synced changes of remote album ${album.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote album to database", e); } if (album.shared || dto.shared) { - final userId = Store.get(StoreKey.currentUser).isarId; + final userId = (await _userRepository.me()).isarId; final foreign = - await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]); existing.addAll(foreign); // delete assets in DB unless they belong to this user or part of some other shared album @@ -456,7 +465,7 @@ class SyncService { await upsertAssetsWithExif(updated); await _entityService.fillAlbumWithDatabaseEntities(album); - await _db.writeTxn(() => _db.albums.store(album)); + await _albumRepository.create(album); } else { _log.warning( "Failed to add album from server: assetCount ${album.remoteAssetCount} != " @@ -474,27 +483,18 @@ class SyncService { _log.info("Removing local album $album from DB"); // delete assets in DB unless they are remote or part of some other album deleteCandidates.addAll( - await album.assets.filter().remoteIdIsNull().findAll(), + await _assetRepository.getByAlbum(album, state: AssetState.local), ); } else if (album.shared) { - final User user = Store.get(StoreKey.currentUser); // delete assets in DB unless they belong to this user or are part of some other shared album or belong to a partner - final userIds = await _db.users - .filter() - .isPartnerSharedWithEqualTo(true) - .isarIdProperty() - .findAll(); - userIds.add(user.isarId); - final orphanedAssets = await album.assets - .filter() - .not() - .anyOf(userIds, (q, int id) => q.ownerIdEqualTo(id)) - .findAll(); + final userIds = + (await _userRepository.getAllAccessible()).map((user) => user.isarId); + final orphanedAssets = + await _assetRepository.getByAlbum(album, notOwnedBy: userIds); deleteCandidates.addAll(orphanedAssets); } try { - final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id)); - assert(ok); + await _albumRepository.delete(album.id); _log.info("Removed local album $album from DB"); } catch (e) { _log.severe("Failed to remove local album $album from DB", e); @@ -509,7 +509,7 @@ class SyncService { ]) async { onDevice.sort((a, b) => a.id.compareTo(b.id)); final inDb = - await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll(); + await _albumRepository.getAll(remote: false, sortBy: AlbumSort.localId); final List deleteCandidates = []; final List existing = []; assert(inDb.isSorted((a, b) => a.localId!.compareTo(b.localId!)), "sort!"); @@ -536,10 +536,9 @@ class SyncService { "${toDelete.length} assets to delete, ${toUpdate.length} to update", ); if (toDelete.isNotEmpty || toUpdate.isNotEmpty) { - await _db.writeTxn(() async { - await _db.assets.deleteAll(toDelete); - await _db.exifInfos.deleteAll(toDelete); - await _db.assets.putAll(toUpdate); + await _assetRepository.transaction(() async { + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); }); _log.info( "Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB", @@ -570,13 +569,13 @@ class SyncService { await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) { return true; } - // general case, e.g. some assets have been deleted or there are excluded albums on iOS - final inDb = await dbAlbum.assets - .filter() - .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) - .sortByChecksum() - .findAll(); + final inDb = await _assetRepository.getByAlbum( + dbAlbum, + ownerId: (await _userRepository.me()).isarId, + sortBy: AssetSort.checksum, + ); + assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); final int assetCountOnDevice = await _albumMediaRepository.getAssetCount(deviceAlbum.localId!); @@ -597,15 +596,14 @@ class SyncService { "Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.", ); if (assetCountOnDevice != - _db.eTags.getByIdSync(deviceAlbum.eTagKeyAssetCount)?.assetCount) { - await _db.writeTxn( - () => _db.eTags.put( - ETag( - id: deviceAlbum.eTagKeyAssetCount, - assetCount: assetCountOnDevice, - ), + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount) { + await _eTagRepository.upsertAll([ + ETag( + id: deviceAlbum.eTagKeyAssetCount, + assetCount: assetCountOnDevice, ), - ); + ]); } return false; } @@ -625,23 +623,21 @@ class SyncService { dbAlbum.thumbnail.value = null; } try { - await _db.writeTxn(() async { - await _db.assets.putAll(updated); - await _db.assets.putAll(toUpdate); - await dbAlbum.assets - .update(link: existingInDb + updated, unlink: toDelete); - await _db.albums.put(dbAlbum); - dbAlbum.thumbnail.value ??= await dbAlbum.assets.filter().findFirst(); - await dbAlbum.thumbnail.save(); - await _db.eTags.put( + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(updated + toUpdate); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.removeAssets(dbAlbum, toDelete); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll([ ETag( id: deviceAlbum.eTagKeyAssetCount, assetCount: assetCountOnDevice, ), - ); + ]); }); _log.info("Synced changes of local album ${deviceAlbum.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e); } @@ -657,7 +653,8 @@ class SyncService { final int totalOnDevice = await _albumMediaRepository.getAssetCount(deviceAlbum.localId!); final int lastKnownTotal = - (await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))?.assetCount ?? + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount ?? 0; if (totalOnDevice <= lastKnownTotal) { return false; @@ -675,16 +672,17 @@ class SyncService { _removeDuplicates(newAssets); final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets); try { - await _db.writeTxn(() async { - await _db.assets.putAll(updated); - await dbAlbum.assets.update(link: existingInDb + updated); - await _db.albums.put(dbAlbum); - await _db.eTags.put( - ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice), + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(updated); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll( + [ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice)], ); }); _log.info("Fast synced local album ${deviceAlbum.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe( "Failed to fast sync local album ${deviceAlbum.name} to DB", e, @@ -719,9 +717,9 @@ class SyncService { final thumb = existingInDb.firstOrNull ?? updated.firstOrNull; album.thumbnail.value = thumb; try { - await _db.writeTxn(() => _db.albums.store(album)); + await _albumRepository.create(album); _log.info("Added a new local album to DB: ${album.name}"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to add new local album ${album.name} to DB", e); } } @@ -732,7 +730,7 @@ class SyncService { ) async { if (assets.isEmpty) return ([].cast(), [].cast()); - final List inDb = await _db.assets.getAllByOwnerIdChecksum( + final List inDb = await _assetRepository.getAllByOwnerIdChecksum( assets.map((a) => a.ownerId).toInt64List(), assets.map((a) => a.checksum).toList(growable: false), ); @@ -746,7 +744,7 @@ class SyncService { } if (b.canUpdate(assets[i])) { final updated = b.updatedCopy(assets[i]); - assert(updated.id != Isar.autoIncrement); + assert(updated.isInDb); toUpsert.add(updated); } else { existing.add(b); @@ -758,24 +756,22 @@ class SyncService { /// Inserts or updates the assets in the database with their ExifInfo (if any) Future upsertAssetsWithExif(List assets) async { - if (assets.isEmpty) { - return; - } - final exifInfos = assets.map((e) => e.exifInfo).whereNotNull().toList(); + if (assets.isEmpty) return; + final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList(); try { - await _db.writeTxn(() async { - await _db.assets.putAll(assets); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(assets); for (final Asset added in assets) { added.exifInfo?.id = added.id; } - await _db.exifInfos.putAll(exifInfos); + await _exifInfoRepository.updateAll(exifInfos); }); _log.info("Upserted ${assets.length} assets into the DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to upsert ${assets.length} assets into the DB", e); // give details on the errors assets.sort(Asset.compareByOwnerChecksum); - final inDb = await _db.assets.getAllByOwnerIdChecksum( + final inDb = await _assetRepository.getAllByOwnerIdChecksum( assets.map((e) => e.ownerId).toInt64List(), assets.map((e) => e.checksum).toList(growable: false), ); @@ -783,7 +779,7 @@ class SyncService { final Asset a = assets[i]; final Asset? b = inDb[i]; if (b == null) { - if (a.id != Isar.autoIncrement) { + if (!a.isInDb) { _log.warning( "Trying to update an asset that does not exist in DB:\n$a", ); @@ -827,19 +823,19 @@ class SyncService { return deviceAlbum.name != dbAlbum.name || !deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) || await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) != - (await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount)) + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) ?.assetCount; } Future _removeAllLocalAlbumsAndAssets() async { try { - final assets = await _db.assets.where().localIdIsNotNull().findAll(); + final assets = await _assetRepository.getAllLocal(); final (toDelete, toUpdate) = _handleAssetRemoval(assets, [], remote: false); - await _db.writeTxn(() async { - await _db.assets.deleteAll(toDelete); - await _db.assets.putAll(toUpdate); - await _db.albums.where().localIdIsNotNull().deleteAll(); + await _assetRepository.transaction(() async { + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); + await _albumRepository.deleteAllLocal(); }); return true; } catch (e) { diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 8520d89b43..c85487c7d0 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -1,17 +1,21 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; -import 'package:isar/isar.dart'; +import 'package:mocktail/mocktail.dart'; import '../../repository.mocks.dart'; import '../../service.mocks.dart'; import '../../test_utils.dart'; void main() { + int assetIdCounter = 0; Asset makeAsset({ required String checksum, String? localId, @@ -20,6 +24,7 @@ void main() { }) { final DateTime date = DateTime(2000); return Asset( + id: assetIdCounter++, checksum: checksum, localId: localId, remoteId: remoteId, @@ -37,9 +42,13 @@ void main() { } group('Test SyncService grouped', () { - late final Isar db; final MockHashService hs = MockHashService(); final MockEntityService entityService = MockEntityService(); + final MockAlbumRepository albumRepository = MockAlbumRepository(); + final MockAssetRepository assetRepository = MockAssetRepository(); + final MockExifInfoRepository exifInfoRepository = MockExifInfoRepository(); + final MockUserRepository userRepository = MockUserRepository(); + final MockETagRepository eTagRepository = MockETagRepository(); final MockAlbumMediaRepository albumMediaRepository = MockAlbumMediaRepository(); final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository(); @@ -53,7 +62,7 @@ void main() { late SyncService s; setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); - db = await TestUtils.initIsar(); + final db = await TestUtils.initIsar(); ImmichLogger(); db.writeTxnSync(() => db.clearSync()); Store.init(db); @@ -67,16 +76,43 @@ void main() { makeAsset(checksum: "e", localId: "3"), ]; setUp(() { - db.writeTxnSync(() { - db.assets.clearSync(); - db.assets.putAllSync(initialAssets); - }); s = SyncService( - db, hs, entityService, albumMediaRepository, albumApiRepository, + albumRepository, + assetRepository, + exifInfoRepository, + userRepository, + eTagRepository, + ); + when(() => eTagRepository.get(owner.isarId)) + .thenAnswer((_) async => ETag(id: owner.id, time: DateTime.now())); + when(() => eTagRepository.deleteByIds(["1"])).thenAnswer((_) async {}); + when(() => eTagRepository.upsertAll(any())).thenAnswer((_) async {}); + when(() => userRepository.me()).thenAnswer((_) async => owner); + when(() => userRepository.getAll(sortBy: UserSort.id)) + .thenAnswer((_) async => [owner]); + when(() => userRepository.getAllAccessible()) + .thenAnswer((_) async => [owner]); + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => initialAssets); + when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) + .thenAnswer((_) async => [initialAssets[3], null, null]); + when(() => assetRepository.updateAll(any())).thenAnswer((_) async => []); + when(() => assetRepository.deleteById(any())).thenAnswer((_) async {}); + when(() => exifInfoRepository.updateAll(any())) + .thenAnswer((_) async => []); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), ); }); test('test inserting existing assets', () async { @@ -85,7 +121,6 @@ void main() { makeAsset(checksum: "b", remoteId: "2-1"), makeAsset(checksum: "c", remoteId: "1-1"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -93,7 +128,7 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isFalse); - expect(db.assets.countSync(), 5); + verifyNever(() => assetRepository.updateAll(any())); }); test('test inserting new assets', () async { @@ -105,7 +140,6 @@ void main() { makeAsset(checksum: "f", remoteId: "1-4"), makeAsset(checksum: "g", remoteId: "3-1"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -113,7 +147,11 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isTrue); - expect(db.assets.countSync(), 7); + final updatedAsset = initialAssets[3].updatedCopy(remoteAssets[3]); + verify( + () => assetRepository + .updateAll([remoteAssets[4], remoteAssets[5], updatedAsset]), + ); }); test('test syncing duplicate assets', () async { @@ -125,7 +163,6 @@ void main() { makeAsset(checksum: "i", remoteId: "2-1c"), makeAsset(checksum: "j", remoteId: "2-1d"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -133,7 +170,12 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isTrue); - expect(db.assets.countSync(), 8); + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => remoteAssets); final bool c2 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -141,7 +183,13 @@ void main() { refreshUsers: () => [owner], ); expect(c2, isFalse); - expect(db.assets.countSync(), 8); + final currentState = [...remoteAssets]; + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => currentState); remoteAssets.removeAt(4); final bool c3 = await s.syncRemoteAssetsToDb( users: [owner], @@ -150,7 +198,6 @@ void main() { refreshUsers: () => [owner], ); expect(c3, isTrue); - expect(db.assets.countSync(), 7); remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e")); remoteAssets.add(makeAsset(checksum: "l", remoteId: "2-2")); final bool c4 = await s.syncRemoteAssetsToDb( @@ -160,10 +207,21 @@ void main() { refreshUsers: () => [owner], ); expect(c4, isTrue); - expect(db.assets.countSync(), 9); }); test('test efficient sync', () async { + when( + () => assetRepository.deleteAllByRemoteId( + [initialAssets[1].remoteId!, initialAssets[2].remoteId!], + state: AssetState.remote, + ), + ).thenAnswer((_) async {}); + when( + () => assetRepository + .getAllByRemoteId(["2-1", "1-1"], state: AssetState.merged), + ).thenAnswer((_) async => [initialAssets[2]]); + when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) + .thenAnswer((_) async => [initialAssets[0], null, null]); //afg final List toUpsert = [ makeAsset(checksum: "a", remoteId: "0-1"), // changed makeAsset(checksum: "f", remoteId: "0-2"), // new @@ -171,6 +229,8 @@ void main() { ]; toUpsert[0].isFavorite = true; final List toDelete = ["2-1", "1-1"]; + final expected = [...toUpsert]; + expected[0].id = initialAssets[0].id; final bool c = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: (user, since) async => (toUpsert, toDelete), @@ -178,7 +238,7 @@ void main() { refreshUsers: () => throw Exception(), ); expect(c, isTrue); - expect(db.assets.countSync(), 6); + verify(() => assetRepository.updateAll(expected)); }); }); } diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 6e220e85a2..c76a003eec 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -4,6 +4,8 @@ import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:mocktail/mocktail.dart'; @@ -16,6 +18,10 @@ class MockUserRepository extends Mock implements IUserRepository {} class MockBackupRepository extends Mock implements IBackupRepository {} +class MockExifInfoRepository extends Mock implements IExifInfoRepository {} + +class MockETagRepository extends Mock implements IETagRepository {} + class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {} class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} diff --git a/mobile/test/services/album.service_test.dart b/mobile/test/services/album.service_test.dart index b2c2ec4427..fb46dceed5 100644 --- a/mobile/test/services/album.service_test.dart +++ b/mobile/test/services/album.service_test.dart @@ -29,6 +29,13 @@ void main() { albumMediaRepository = MockAlbumMediaRepository(); albumApiRepository = MockAlbumApiRepository(); + when(() => albumRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + sut = AlbumService( userService, syncService, @@ -144,7 +151,7 @@ void main() { ), ); when( - () => albumRepository.getById(AlbumStub.oneAsset.id), + () => albumRepository.get(AlbumStub.oneAsset.id), ).thenAnswer((_) async => AlbumStub.oneAsset); when( () => albumRepository.addAssets(AlbumStub.oneAsset, [AssetStub.image2]), From 47821cda35e0df2e5de408b55f590f6f96121c98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 14:16:04 -0400 Subject: [PATCH 472/723] chore(deps): bump docker/build-push-action from 6.7.0 to 6.9.0 (#13052) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.7.0 to 6.9.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6.7.0...v6.9.0) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli.yml | 2 +- .github/workflows/docker.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 5292075cce..a86408eea8 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -88,7 +88,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.9.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bf393bbcf6..8c7aeb020e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -173,7 +173,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.9.0 with: context: ${{ env.context }} file: ${{ env.file }} @@ -264,7 +264,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.9.0 with: context: ${{ env.context }} file: ${{ env.file }} From dfc2d5002b6c560150fd77e1d48b6b54d8315266 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 30 Sep 2024 15:50:34 -0400 Subject: [PATCH 473/723] refactor(server): client events (#13062) --- server/src/interfaces/event.interface.ts | 46 ++++++------------- server/src/repositories/event.repository.ts | 8 ++-- server/src/services/job.service.ts | 10 ++-- .../src/services/notification.service.spec.ts | 12 ++--- server/src/services/notification.service.ts | 24 +++++----- server/src/services/version.service.ts | 8 ++-- .../repositories/event.repository.mock.ts | 4 +- 7 files changed, 48 insertions(+), 64 deletions(-) diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 02027d87e6..a125e47ada 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -62,36 +62,20 @@ export type EmitHandler = (...args: ArgsOf) => Promise = EventMap[T][0]; export type ArgsOf = EventMap[T]; -export enum ClientEvent { - UPLOAD_SUCCESS = 'on_upload_success', - USER_DELETE = 'on_user_delete', - ASSET_DELETE = 'on_asset_delete', - ASSET_TRASH = 'on_asset_trash', - ASSET_UPDATE = 'on_asset_update', - ASSET_HIDDEN = 'on_asset_hidden', - ASSET_RESTORE = 'on_asset_restore', - ASSET_STACK_UPDATE = 'on_asset_stack_update', - PERSON_THUMBNAIL = 'on_person_thumbnail', - SERVER_VERSION = 'on_server_version', - CONFIG_UPDATE = 'on_config_update', - NEW_RELEASE = 'on_new_release', - SESSION_DELETE = 'on_session_delete', -} - export interface ClientEventMap { - [ClientEvent.UPLOAD_SUCCESS]: AssetResponseDto; - [ClientEvent.USER_DELETE]: string; - [ClientEvent.ASSET_DELETE]: string; - [ClientEvent.ASSET_TRASH]: string[]; - [ClientEvent.ASSET_UPDATE]: AssetResponseDto; - [ClientEvent.ASSET_HIDDEN]: string; - [ClientEvent.ASSET_RESTORE]: string[]; - [ClientEvent.ASSET_STACK_UPDATE]: string[]; - [ClientEvent.PERSON_THUMBNAIL]: string; - [ClientEvent.SERVER_VERSION]: ServerVersionResponseDto; - [ClientEvent.CONFIG_UPDATE]: Record; - [ClientEvent.NEW_RELEASE]: ReleaseNotification; - [ClientEvent.SESSION_DELETE]: string; + on_upload_success: [AssetResponseDto]; + on_user_delete: [string]; + on_asset_delete: [string]; + on_asset_trash: [string[]]; + on_asset_update: [AssetResponseDto]; + on_asset_hidden: [string]; + on_asset_restore: [string[]]; + on_asset_stack_update: string[]; + on_person_thumbnail: [string]; + on_server_version: [ServerVersionResponseDto]; + on_config_update: []; + on_new_release: [ReleaseNotification]; + on_session_delete: [string]; } export type EventItem = { @@ -107,11 +91,11 @@ export interface IEventRepository { /** * Send to connected clients for a specific user */ - clientSend(event: E, room: string, data: ClientEventMap[E]): void; + clientSend(event: E, room: string, ...data: ClientEventMap[E]): void; /** * Send to all connected clients */ - clientBroadcast(event: E, data: ClientEventMap[E]): void; + clientBroadcast(event: E, ...data: ClientEventMap[E]): void; /** * Send to all connected servers */ diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index a8b2fa67c3..90d8e7bf5d 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -106,12 +106,12 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect } } - clientSend(event: E, room: string, data: ClientEventMap[E]) { - this.server?.to(room).emit(event, data); + clientSend(event: T, room: string, ...data: ClientEventMap[T]) { + this.server?.to(room).emit(event, ...data); } - clientBroadcast(event: E, data: ClientEventMap[E]) { - this.server?.emit(event, data); + clientBroadcast(event: T, ...data: ClientEventMap[T]) { + this.server?.emit(event, ...data); } serverSend(event: T, ...args: ArgsOf): void { diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 68da13a8e4..159efdf023 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -6,7 +6,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; import { AssetType, ManualJobName } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { ConcurrentQueueName, IJobRepository, @@ -279,7 +279,7 @@ export class JobService { if (item.data.source === 'sidecar-write') { const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); if (asset) { - this.eventRepository.clientSend(ClientEvent.ASSET_UPDATE, asset.ownerId, mapAsset(asset)); + this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset)); } } await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data }); @@ -302,7 +302,7 @@ export class JobService { const { id } = item.data; const person = await this.personRepository.getById(id); if (person) { - this.eventRepository.clientSend(ClientEvent.PERSON_THUMBNAIL, person.ownerId, person.id); + this.eventRepository.clientSend('on_person_thumbnail', person.ownerId, person.id); } break; } @@ -331,7 +331,7 @@ export class JobService { await this.jobRepository.queueAll(jobs); if (asset.isVisible) { - this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); + this.eventRepository.clientSend('on_upload_success', asset.ownerId, mapAsset(asset)); } break; @@ -345,7 +345,7 @@ export class JobService { } case JobName.USER_DELETION: { - this.eventRepository.clientBroadcast(ClientEvent.USER_DELETE, item.data.id); + this.eventRepository.clientBroadcast('on_user_delete', item.data.id); break; } } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 106f0be082..5fba38d1eb 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -6,7 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; @@ -104,7 +104,7 @@ describe(NotificationService.name, () => { it('should emit client and server events', () => { const update = { newConfig: defaults }; expect(sut.onConfigUpdate(update)).toBeUndefined(); - expect(eventMock.clientBroadcast).toHaveBeenCalledWith(ClientEvent.CONFIG_UPDATE, {}); + expect(eventMock.clientBroadcast).toHaveBeenCalledWith('on_config_update'); expect(eventMock.serverSend).toHaveBeenCalledWith('config.update', update); }); }); @@ -236,28 +236,28 @@ describe(NotificationService.name, () => { describe('onStackCreate', () => { it('should send connected clients an event', () => { sut.onStackCreate({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStackUpdate', () => { it('should send connected clients an event', () => { sut.onStackUpdate({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStackDelete', () => { it('should send connected clients an event', () => { sut.onStackDelete({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStacksDelete', () => { it('should send connected clients an event', () => { sut.onStacksDelete({ stackIds: ['stack-id'], userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 626e536c40..a3adfa4565 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -6,7 +6,7 @@ import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { IEmailJob, IJobRepository, @@ -45,7 +45,7 @@ export class NotificationService { @OnEvent({ name: 'config.update' }) onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { - this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); + this.eventRepository.clientBroadcast('on_config_update'); this.eventRepository.serverSend('config.update', { oldConfig, newConfig }); } @@ -66,7 +66,7 @@ export class NotificationService { @OnEvent({ name: 'asset.hide' }) onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId); + this.eventRepository.clientSend('on_asset_hidden', userId, assetId); } @OnEvent({ name: 'asset.show' }) @@ -76,42 +76,42 @@ export class NotificationService { @OnEvent({ name: 'asset.trash' }) onAssetTrash({ assetId, userId }: ArgOf<'asset.trash'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, [assetId]); + this.eventRepository.clientSend('on_asset_trash', userId, [assetId]); } @OnEvent({ name: 'asset.delete' }) onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, userId, assetId); + this.eventRepository.clientSend('on_asset_delete', userId, assetId); } @OnEvent({ name: 'assets.trash' }) onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, assetIds); + this.eventRepository.clientSend('on_asset_trash', userId, assetIds); } @OnEvent({ name: 'assets.restore' }) onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, userId, assetIds); + this.eventRepository.clientSend('on_asset_restore', userId, assetIds); } @OnEvent({ name: 'stack.create' }) onStackCreate({ userId }: ArgOf<'stack.create'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); + this.eventRepository.clientSend('on_asset_stack_update', userId); } @OnEvent({ name: 'stack.update' }) onStackUpdate({ userId }: ArgOf<'stack.update'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); + this.eventRepository.clientSend('on_asset_stack_update', userId); } @OnEvent({ name: 'stack.delete' }) onStackDelete({ userId }: ArgOf<'stack.delete'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); + this.eventRepository.clientSend('on_asset_stack_update', userId); } @OnEvent({ name: 'stacks.delete' }) onStacksDelete({ userId }: ArgOf<'stacks.delete'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); + this.eventRepository.clientSend('on_asset_stack_update', userId); } @OnEvent({ name: 'user.signup' }) @@ -134,7 +134,7 @@ export class NotificationService { @OnEvent({ name: 'session.delete' }) onSessionDelete({ sessionId }: ArgOf<'session.delete'>) { // after the response is sent - setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500); + setTimeout(() => this.eventRepository.clientSend('on_session_delete', sessionId, sessionId), 500); } async sendTestEmail(id: string, dto: SystemConfigSmtpDto) { diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 0c7ae52cac..1d2785356e 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -7,7 +7,7 @@ import { OnEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { SystemMetadataKey } from 'src/enum'; -import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; @@ -80,7 +80,7 @@ export class VersionService { if (semver.gt(releaseVersion, serverVersion)) { this.logger.log(`Found ${releaseVersion}, released at ${new Date(publishedAt).toLocaleString()}`); - this.eventRepository.clientBroadcast(ClientEvent.NEW_RELEASE, asNotification(metadata)); + this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata)); } } catch (error: Error | any) { this.logger.warn(`Unable to run version check: ${error}`, error?.stack); @@ -92,10 +92,10 @@ export class VersionService { @OnEvent({ name: 'websocket.connect' }) async onWebsocketConnection({ userId }: ArgOf<'websocket.connect'>) { - this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion); + this.eventRepository.clientSend('on_server_version', userId, serverVersion); const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); if (metadata) { - this.eventRepository.clientSend(ClientEvent.NEW_RELEASE, userId, asNotification(metadata)); + this.eventRepository.clientSend('on_new_release', userId, asNotification(metadata)); } } } diff --git a/server/test/repositories/event.repository.mock.ts b/server/test/repositories/event.repository.mock.ts index 78c62e95f2..6893b29f49 100644 --- a/server/test/repositories/event.repository.mock.ts +++ b/server/test/repositories/event.repository.mock.ts @@ -5,8 +5,8 @@ export const newEventRepositoryMock = (): Mocked => { return { on: vitest.fn() as any, emit: vitest.fn() as any, - clientSend: vitest.fn(), - clientBroadcast: vitest.fn(), + clientSend: vitest.fn() as any, + clientBroadcast: vitest.fn() as any, serverSend: vitest.fn(), }; }; From f63d251490de6d94dc47b853337b2a47801e9cc6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 30 Sep 2024 16:04:24 -0400 Subject: [PATCH 474/723] refactor(server): user core (#13063) --- server/src/cores/user.core.ts | 51 ------------------- server/src/services/auth.service.ts | 38 +++++++------- server/src/services/user-admin.service.ts | 7 +-- server/src/utils/user.ts | 35 +++++++++++++ .../test/repositories/user.repository.mock.ts | 7 +-- 5 files changed, 59 insertions(+), 79 deletions(-) delete mode 100644 server/src/cores/user.core.ts create mode 100644 server/src/utils/user.ts diff --git a/server/src/cores/user.core.ts b/server/src/cores/user.core.ts deleted file mode 100644 index 153463a9cc..0000000000 --- a/server/src/cores/user.core.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; -import sanitize from 'sanitize-filename'; -import { SALT_ROUNDS } from 'src/constants'; -import { UserEntity } from 'src/entities/user.entity'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; - -let instance: UserCore | null; - -export class UserCore { - private constructor( - private cryptoRepository: ICryptoRepository, - private userRepository: IUserRepository, - ) {} - - static create(cryptoRepository: ICryptoRepository, userRepository: IUserRepository) { - if (!instance) { - instance = new UserCore(cryptoRepository, userRepository); - } - - return instance; - } - - static reset() { - instance = null; - } - - async createUser(dto: Partial & { email: string }): Promise { - const user = await this.userRepository.getByEmail(dto.email); - if (user) { - throw new BadRequestException('User exists'); - } - - if (!dto.isAdmin) { - const localAdmin = await this.userRepository.getAdmin(); - if (!localAdmin) { - throw new BadRequestException('The first registered account must the administrator.'); - } - } - - const payload: Partial = { ...dto }; - if (payload.password) { - payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS); - } - if (payload.storageLabel) { - payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', '')); - } - - return this.userRepository.create(payload); - } -} diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 6b1e4c512f..0917fc2198 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -14,7 +14,6 @@ import { Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { SystemConfig } from 'src/config'; import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { UserCore } from 'src/cores/user.core'; import { AuthDto, ChangePasswordDto, @@ -42,6 +41,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf import { IUserRepository } from 'src/interfaces/user.interface'; import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; +import { createUser } from 'src/utils/user'; export interface LoginDetails { isSecure: boolean; @@ -72,7 +72,6 @@ export type ValidateRequest = { @Injectable() export class AuthService { private configCore: SystemConfigCore; - private userCore: UserCore; constructor( @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @@ -86,7 +85,6 @@ export class AuthService { ) { this.logger.setContext(AuthService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); - this.userCore = UserCore.create(cryptoRepository, userRepository); custom.setHttpOptionsDefaults({ timeout: 30_000 }); } @@ -150,13 +148,16 @@ export class AuthService { throw new BadRequestException('The server already has an admin'); } - const admin = await this.userCore.createUser({ - isAdmin: true, - email: dto.email, - name: dto.name, - password: dto.password, - storageLabel: 'admin', - }); + const admin = await createUser( + { userRepo: this.userRepository, cryptoRepo: this.cryptoRepository }, + { + isAdmin: true, + email: dto.email, + name: dto.name, + password: dto.password, + storageLabel: 'admin', + }, + ); return mapUserAdmin(admin); } @@ -271,13 +272,16 @@ export class AuthService { }); const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`; - user = await this.userCore.createUser({ - name: userName, - email: profile.email, - oauthId: profile.sub, - quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null, - storageLabel: storageLabel || null, - }); + user = await createUser( + { userRepo: this.userRepository, cryptoRepo: this.cryptoRepository }, + { + name: userName, + email: profile.email, + oauthId: profile.sub, + quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null, + storageLabel: storageLabel || null, + }, + ); } return this.createLoginResponse(user, loginDetails); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 6a5b6ea06e..75dff32f16 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common'; import { SALT_ROUNDS } from 'src/constants'; -import { UserCore } from 'src/cores/user.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { @@ -19,11 +18,10 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; +import { createUser } from 'src/utils/user'; @Injectable() export class UserAdminService { - private userCore: UserCore; - constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @@ -32,7 +30,6 @@ export class UserAdminService { @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { - this.userCore = UserCore.create(cryptoRepository, userRepository); this.logger.setContext(UserAdminService.name); } @@ -43,7 +40,7 @@ export class UserAdminService { async create(dto: UserAdminCreateDto): Promise { const { notify, ...rest } = dto; - const user = await this.userCore.createUser(rest); + const user = await createUser({ userRepo: this.userRepository, cryptoRepo: this.cryptoRepository }, rest); await this.eventRepository.emit('user.signup', { notify: !!notify, diff --git a/server/src/utils/user.ts b/server/src/utils/user.ts new file mode 100644 index 0000000000..c7029a1eca --- /dev/null +++ b/server/src/utils/user.ts @@ -0,0 +1,35 @@ +import { BadRequestException } from '@nestjs/common'; +import sanitize from 'sanitize-filename'; +import { SALT_ROUNDS } from 'src/constants'; +import { UserEntity } from 'src/entities/user.entity'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IUserRepository } from 'src/interfaces/user.interface'; + +type RepoDeps = { userRepo: IUserRepository; cryptoRepo: ICryptoRepository }; + +export const createUser = async ( + { userRepo, cryptoRepo }: RepoDeps, + dto: Partial & { email: string }, +): Promise => { + const user = await userRepo.getByEmail(dto.email); + if (user) { + throw new BadRequestException('User exists'); + } + + if (!dto.isAdmin) { + const localAdmin = await userRepo.getAdmin(); + if (!localAdmin) { + throw new BadRequestException('The first registered account must the administrator.'); + } + } + + const payload: Partial = { ...dto }; + if (payload.password) { + payload.password = await cryptoRepo.hashBcrypt(payload.password, SALT_ROUNDS); + } + if (payload.storageLabel) { + payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', '')); + } + + return userRepo.create(payload); +}; diff --git a/server/test/repositories/user.repository.mock.ts b/server/test/repositories/user.repository.mock.ts index 6071ae47fa..6362ab6a99 100644 --- a/server/test/repositories/user.repository.mock.ts +++ b/server/test/repositories/user.repository.mock.ts @@ -1,12 +1,7 @@ -import { UserCore } from 'src/cores/user.core'; import { IUserRepository } from 'src/interfaces/user.interface'; import { Mocked, vitest } from 'vitest'; -export const newUserRepositoryMock = (reset = true): Mocked => { - if (reset) { - UserCore.reset(); - } - +export const newUserRepositoryMock = (): Mocked => { return { get: vitest.fn(), getAdmin: vitest.fn(), From a019fb670e0c1edfea24cd6620e4eaecf54a0600 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 30 Sep 2024 17:31:21 -0400 Subject: [PATCH 475/723] refactor(server): config service (#13066) * refactor(server): config service * fix: function renaming --------- Co-authored-by: Daniel Dietzler --- .../src/controllers/server-info.controller.ts | 2 +- server/src/controllers/server.controller.ts | 2 +- .../controllers/system-config.controller.ts | 4 +- server/src/cores/storage.core.ts | 12 +- server/src/cores/system-config.core.ts | 143 ------------------ server/src/services/asset.service.ts | 12 +- server/src/services/auth.service.ts | 20 ++- server/src/services/base.service.ts | 32 ++++ server/src/services/cli.service.ts | 26 ++-- server/src/services/duplicate.service.ts | 14 +- server/src/services/job.service.ts | 11 +- server/src/services/library.service.ts | 11 +- server/src/services/map.service.spec.ts | 10 +- server/src/services/map.service.ts | 12 +- server/src/services/media.service.ts | 18 +-- server/src/services/metadata.service.ts | 13 +- server/src/services/notification.service.ts | 20 ++- server/src/services/person.service.ts | 23 ++- server/src/services/search.service.ts | 12 +- server/src/services/server.service.spec.ts | 4 +- server/src/services/server.service.ts | 23 ++- server/src/services/shared-link.service.ts | 12 +- server/src/services/smart-info.service.ts | 16 +- .../src/services/storage-template.service.ts | 13 +- .../services/system-config.service.spec.ts | 26 ++-- server/src/services/system-config.service.ts | 31 ++-- server/src/services/user.service.ts | 14 +- server/src/services/version.service.ts | 14 +- server/src/utils/config.ts | 129 ++++++++++++++++ .../system-metadata.repository.mock.ts | 9 +- 30 files changed, 327 insertions(+), 361 deletions(-) delete mode 100644 server/src/cores/system-config.core.ts create mode 100644 server/src/services/base.service.ts create mode 100644 server/src/utils/config.ts diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts index 245bbbd347..36490b7119 100644 --- a/server/src/controllers/server-info.controller.ts +++ b/server/src/controllers/server-info.controller.ts @@ -66,7 +66,7 @@ export class ServerInfoController { @Get('config') @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) getServerConfig(): Promise { - return this.service.getConfig(); + return this.service.getSystemConfig(); } @Get('statistics') diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts index 75becfe341..8fcd93946e 100644 --- a/server/src/controllers/server.controller.ts +++ b/server/src/controllers/server.controller.ts @@ -58,7 +58,7 @@ export class ServerController { @Get('config') getServerConfig(): Promise { - return this.service.getConfig(); + return this.service.getSystemConfig(); } @Get('statistics') diff --git a/server/src/controllers/system-config.controller.ts b/server/src/controllers/system-config.controller.ts index 804c19500f..f59c8ad66c 100644 --- a/server/src/controllers/system-config.controller.ts +++ b/server/src/controllers/system-config.controller.ts @@ -13,7 +13,7 @@ export class SystemConfigController { @Get() @Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true }) getConfig(): Promise { - return this.service.getConfig(); + return this.service.getSystemConfig(); } @Get('defaults') @@ -25,7 +25,7 @@ export class SystemConfigController { @Put() @Authenticated({ permission: Permission.SYSTEM_CONFIG_UPDATE, admin: true }) updateConfig(@Body() dto: SystemConfigDto): Promise { - return this.service.updateConfig(dto); + return this.service.updateSystemConfig(dto); } @Get('storage-template-options') diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 8ce8f6b67a..d33d81410c 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -1,7 +1,6 @@ import { randomUUID } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { APP_MEDIA_LOCATION } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; @@ -13,6 +12,7 @@ import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { getAssetFiles } from 'src/utils/asset.util'; +import { getConfig } from 'src/utils/config'; export const THUMBNAIL_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.THUMBNAILS)); export const ENCODED_VIDEO_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.ENCODED_VIDEO)); @@ -34,18 +34,15 @@ export type GeneratedAssetType = GeneratedImageType | AssetPathType.ENCODED_VIDE let instance: StorageCore | null; export class StorageCore { - private configCore; private constructor( private assetRepository: IAssetRepository, private cryptoRepository: ICryptoRepository, private moveRepository: IMoveRepository, private personRepository: IPersonRepository, private storageRepository: IStorageRepository, - systemMetadataRepository: ISystemMetadataRepository, + private systemMetadataRepository: ISystemMetadataRepository, private logger: ILoggerRepository, - ) { - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } + ) {} static create( assetRepository: IAssetRepository, @@ -248,7 +245,8 @@ export class StorageCore { this.logger.warn(`Unable to complete move. File size mismatch: ${newPathSize} !== ${oldPathSize}`); return false; } - const config = await this.configCore.getConfig({ withCache: true }); + const repos = { metadataRepo: this.systemMetadataRepository, logger: this.logger }; + const config = await getConfig(repos, { withCache: true }); if (assetInfo && config.storageTemplate.hashVerificationEnabled) { const { checksum } = assetInfo; const newChecksum = await this.cryptoRepository.hashFile(newPath); diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts deleted file mode 100644 index 816ab00446..0000000000 --- a/server/src/cores/system-config.core.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import AsyncLock from 'async-lock'; -import { plainToInstance } from 'class-transformer'; -import { validate } from 'class-validator'; -import { load as loadYaml } from 'js-yaml'; -import * as _ from 'lodash'; -import { SystemConfig, defaults } from 'src/config'; -import { SystemConfigDto } from 'src/dtos/system-config.dto'; -import { SystemMetadataKey } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { getKeysDeep, unsetDeep } from 'src/utils/misc'; -import { DeepPartial } from 'typeorm'; - -export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; - -let instance: SystemConfigCore | null; - -@Injectable() -export class SystemConfigCore { - private readonly asyncLock = new AsyncLock(); - private config: SystemConfig | null = null; - private lastUpdated: number | null = null; - - private constructor( - private repository: ISystemMetadataRepository, - private logger: ILoggerRepository, - ) {} - - static create(repository: ISystemMetadataRepository, logger: ILoggerRepository) { - if (!instance) { - instance = new SystemConfigCore(repository, logger); - } - return instance; - } - - static reset() { - instance = null; - } - - invalidateCache() { - this.config = null; - this.lastUpdated = null; - } - - async getConfig({ withCache }: { withCache: boolean }): Promise { - if (!withCache || !this.config) { - const lastUpdated = this.lastUpdated; - await this.asyncLock.acquire(DatabaseLock[DatabaseLock.GetSystemConfig], async () => { - if (lastUpdated === this.lastUpdated) { - this.config = await this.buildConfig(); - this.lastUpdated = Date.now(); - } - }); - } - - return this.config!; - } - - async updateConfig(newConfig: SystemConfig): Promise { - // get the difference between the new config and the default config - const partialConfig: DeepPartial = {}; - for (const property of getKeysDeep(defaults)) { - const newValue = _.get(newConfig, property); - const isEmpty = newValue === undefined || newValue === null || newValue === ''; - const defaultValue = _.get(defaults, property); - const isEqual = newValue === defaultValue || _.isEqual(newValue, defaultValue); - - if (isEmpty || isEqual) { - continue; - } - - _.set(partialConfig, property, newValue); - } - - await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); - - return this.getConfig({ withCache: false }); - } - - isUsingConfigFile() { - return !!process.env.IMMICH_CONFIG_FILE; - } - - private async buildConfig() { - // load partial - const partial = this.isUsingConfigFile() - ? await this.loadFromFile(process.env.IMMICH_CONFIG_FILE as string) - : await this.repository.get(SystemMetadataKey.SYSTEM_CONFIG); - - // merge with defaults - const config = _.cloneDeep(defaults); - for (const property of getKeysDeep(partial)) { - _.set(config, property, _.get(partial, property)); - } - - // check for extra properties - const unknownKeys = _.cloneDeep(config); - for (const property of getKeysDeep(defaults)) { - unsetDeep(unknownKeys, property); - } - - if (!_.isEmpty(unknownKeys)) { - this.logger.warn(`Unknown keys found: ${JSON.stringify(unknownKeys, null, 2)}`); - } - - // validate full config - const errors = await validate(plainToInstance(SystemConfigDto, config)); - if (errors.length > 0) { - if (this.isUsingConfigFile()) { - throw new Error(`Invalid value(s) in file: ${errors}`); - } else { - this.logger.error('Validation error', errors); - } - } - - if (config.server.externalDomain.length > 0) { - config.server.externalDomain = new URL(config.server.externalDomain).origin; - } - - if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { - config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); - } - - if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) { - config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec); - } - - return config; - } - - private async loadFromFile(filepath: string) { - try { - const file = await this.repository.readFile(filepath); - return loadYaml(file.toString()) as unknown; - } catch (error: Error | any) { - this.logger.error(`Unable to load configuration file: ${filepath}`); - this.logger.error(error); - throw error; - } - } -} diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index aa88eaf957..171005ab74 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Inject } from '@nestjs/common'; import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetResponseDto, MemoryLaneResponseDto, @@ -38,13 +37,12 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { requireAccess } from 'src/utils/access'; import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; -export class AssetService { - private configCore: SystemConfigCore; - +export class AssetService extends BaseService { constructor( @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @@ -54,10 +52,10 @@ export class AssetService { @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IStackRepository) private stackRepository: IStackRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(AssetService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise { @@ -214,7 +212,7 @@ export class AssetService { } async handleAssetDeletionCheck(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const trashedDays = config.trash.enabled ? config.trash.days : 0; const trashedBefore = DateTime.now() .minus(Duration.fromObject({ days: trashedDays })) diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 0917fc2198..72d251ce78 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -13,7 +13,6 @@ import { IncomingHttpHeaders } from 'node:http'; import { Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { SystemConfig } from 'src/config'; import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto, ChangePasswordDto, @@ -39,6 +38,7 @@ import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; import { createUser } from 'src/utils/user'; @@ -70,27 +70,25 @@ export type ValidateRequest = { }; @Injectable() -export class AuthService { - private configCore: SystemConfigCore; - +export class AuthService extends BaseService { constructor( @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ISessionRepository) private sessionRepository: ISessionRepository, @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, @Inject(IKeyRepository) private keyRepository: IKeyRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(AuthService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); custom.setHttpOptionsDefaults({ timeout: 30_000 }); } async login(dto: LoginCredentialDto, details: LoginDetails) { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); if (!config.passwordLogin.enabled) { throw new UnauthorizedException('Password login has been disabled'); } @@ -212,7 +210,7 @@ export class AuthService { } async authorize(dto: OAuthConfigDto): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); if (!config.oauth.enabled) { throw new BadRequestException('OAuth is not enabled'); } @@ -228,7 +226,7 @@ export class AuthService { } async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const profile = await this.getOAuthProfile(config, dto.url); const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = config.oauth; this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); @@ -288,7 +286,7 @@ export class AuthService { } async link(auth: AuthDto, dto: OAuthCallbackDto): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const { sub: oauthId } = await this.getOAuthProfile(config, dto.url); const duplicate = await this.userRepository.getByOAuthId(oauthId); if (duplicate && duplicate.id !== auth.user.id) { @@ -310,7 +308,7 @@ export class AuthService { return LOGIN_URL; } - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); if (!config.oauth.enabled) { return LOGIN_URL; } diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts new file mode 100644 index 0000000000..776858aa1a --- /dev/null +++ b/server/src/services/base.service.ts @@ -0,0 +1,32 @@ +import { Inject } from '@nestjs/common'; +import { SystemConfig } from 'src/config'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getConfig, updateConfig } from 'src/utils/config'; + +export class BaseService { + constructor( + @Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository, + @Inject(ILoggerRepository) protected logger: ILoggerRepository, + ) {} + + getConfig(options: { withCache: boolean }) { + return getConfig( + { + metadataRepo: this.systemMetadataRepository, + logger: this.logger, + }, + options, + ); + } + + updateConfig(newConfig: SystemConfig) { + return updateConfig( + { + metadataRepo: this.systemMetadataRepository, + logger: this.logger, + }, + newConfig, + ); + } +} diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 1c25c306b6..c7ed510f5d 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -1,24 +1,22 @@ import { Inject, Injectable } from '@nestjs/common'; import { SALT_ROUNDS } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; @Injectable() -export class CliService { - private configCore: SystemConfigCore; - +export class CliService extends BaseService { constructor( @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(CliService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async listUsers(): Promise { @@ -42,26 +40,26 @@ export class CliService { } async disablePasswordLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.passwordLogin.enabled = false; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } async enablePasswordLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.passwordLogin.enabled = true; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } async disableOAuthLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.oauth.enabled = false; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } async enableOAuthLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.oauth.enabled = true; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } } diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 35a1a7325b..00f738a613 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto'; @@ -17,24 +16,23 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetDuplicateResult, ISearchRepository } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class DuplicateService { - private configCore: SystemConfigCore; - +export class DuplicateService extends BaseService { constructor( @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ISearchRepository) private searchRepository: ISearchRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(DuplicateService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } async getDuplicates(auth: AuthDto): Promise { @@ -44,7 +42,7 @@ export class DuplicateService { } async handleQueueSearchDuplicates({ force }: IBaseJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isDuplicateDetectionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -65,7 +63,7 @@ export class DuplicateService { } async handleSearchDuplicates({ id }: IEntityJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isDuplicateDetectionEnabled(machineLearning)) { return JobStatus.SKIPPED; } diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 159efdf023..8bcf5e5622 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; @@ -22,6 +21,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; const asJobItem = (dto: JobCreateDto): JobItem => { switch (dto.name) { @@ -44,8 +44,7 @@ const asJobItem = (dto: JobCreateDto): JobItem => { }; @Injectable() -export class JobService { - private configCore: SystemConfigCore; +export class JobService extends BaseService { private isMicroservices = false; constructor( @@ -55,10 +54,10 @@ export class JobService { @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IPersonRepository) private personRepository: IPersonRepository, @Inject(IMetricRepository) private metricRepository: IMetricRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(JobService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } @OnEvent({ name: 'app.bootstrap' }) @@ -198,7 +197,7 @@ export class JobService { } async init(jobHandlers: Record) { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); for (const queueName of Object.values(QueueName)) { let concurrency = 1; diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index b8b478531f..4b296570eb 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -3,7 +3,6 @@ import { R_OK } from 'node:constants'; import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { CreateLibraryDto, @@ -35,14 +34,14 @@ import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { mimeTypes } from 'src/utils/mime-types'; import { handlePromiseError } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { validateCronExpression } from 'src/validation'; @Injectable() -export class LibraryService { - private configCore: SystemConfigCore; +export class LibraryService extends BaseService { private watchLibraries = false; private watchLock = false; private watchers: Record Promise> = {}; @@ -55,15 +54,15 @@ export class LibraryService { @Inject(ILibraryRepository) private repository: ILibraryRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(LibraryService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } @OnEvent({ name: 'app.bootstrap' }) async onBootstrap() { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const { watch, scan } = config.library; diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index f8b73260af..e0127b73ef 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -1,34 +1,26 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { MapService } from 'src/services/map.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked } from 'vitest'; describe(MapService.name, () => { let sut: MapService; let albumMock: Mocked; - let loggerMock: Mocked; let partnerMock: Mocked; let mapMock: Mocked; - let systemMetadataMock: Mocked; beforeEach(() => { albumMock = newAlbumRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); partnerMock = newPartnerRepositoryMock(); mapMock = newMapRepositoryMock(); - systemMetadataMock = newSystemMetadataRepositoryMock(); - sut = new MapService(albumMock, loggerMock, partnerMock, mapMock, systemMetadataMock); + sut = new MapService(albumMock, partnerMock, mapMock); }); describe('getMapMarkers', () => { diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts index 5836505e54..3b1ee58cf1 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -1,27 +1,17 @@ import { Inject } from '@nestjs/common'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto'; import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { getMyPartnerIds } from 'src/utils/asset.util'; export class MapService { - private configCore: SystemConfigCore; - constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IMapRepository) private mapRepository: IMapRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, - ) { - this.logger.setContext(MapService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); - } + ) {} async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise { const userIds = [auth.user.id]; diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 71f432e040..1f72c373f4 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,8 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { dirname } from 'node:path'; - import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { @@ -43,14 +41,14 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class MediaService { - private configCore: SystemConfigCore; +export class MediaService extends BaseService { private storageCore: StorageCore; private maliOpenCL?: boolean; private devices?: string[]; @@ -64,10 +62,10 @@ export class MediaService { @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(MediaService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, @@ -161,7 +159,7 @@ export class MediaService { } async handleAssetMigration({ id }: IEntityJob): Promise { - const { image } = await this.configCore.getConfig({ withCache: true }); + const { image } = await this.getConfig({ withCache: true }); const [asset] = await this.assetRepository.getByIds([id], { files: true }); if (!asset) { return JobStatus.FAILED; @@ -235,7 +233,7 @@ export class MediaService { } private async generateImageThumbnails(asset: AssetEntity) { - const { image } = await this.configCore.getConfig({ withCache: true }); + const { image } = await this.getConfig({ withCache: true }); const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); this.storageCore.ensureFolders(previewPath); @@ -269,7 +267,7 @@ export class MediaService { } private async generateVideoThumbnails(asset: AssetEntity) { - const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); + const { image, ffmpeg } = await this.getConfig({ withCache: true }); const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); this.storageCore.ensureFolders(previewPath); @@ -339,7 +337,7 @@ export class MediaService { return JobStatus.FAILED; } - const { ffmpeg } = await this.configCore.getConfig({ withCache: true }); + const { ffmpeg } = await this.getConfig({ withCache: true }); const target = this.getTranscodeTarget(ffmpeg, mainVideoStream, mainAudioStream); if (target === TranscodeTarget.NONE && !this.isRemuxRequired(ffmpeg, format)) { if (asset.encodedVideoPath) { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 9499a4bdd9..e39e22b92f 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -7,7 +7,6 @@ import { constants } from 'node:fs/promises'; import path from 'node:path'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -39,6 +38,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { isFaceImportEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { upsertTags } from 'src/utils/tag'; @@ -97,9 +97,8 @@ const validateRange = (value: number | undefined, min: number, max: number): Non }; @Injectable() -export class MetadataService { +export class MetadataService extends BaseService { private storageCore: StorageCore; - private configCore: SystemConfigCore; constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @@ -117,10 +116,10 @@ export class MetadataService { @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ITagRepository) private tagRepository: ITagRepository, @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(MetadataService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, @@ -137,7 +136,7 @@ export class MetadataService { if (app !== 'microservices') { return; } - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); await this.init(config); } @@ -222,7 +221,7 @@ export class MetadataService { } async handleMetadataExtraction({ id }: IEntityJob): Promise { - const { metadata, reverseGeocoding } = await this.configCore.getConfig({ withCache: true }); + const { metadata, reverseGeocoding } = await this.getConfig({ withCache: true }); const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { return JobStatus.FAILED; diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index a3adfa4565..cf6b89384d 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; @@ -20,27 +19,26 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailImageAttachment, EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { getFilenameExtension } from 'src/utils/file'; import { isEqualObject } from 'src/utils/object'; import { getPreferences } from 'src/utils/preferences'; @Injectable() -export class NotificationService { - private configCore: SystemConfigCore; - +export class NotificationService extends BaseService { constructor( @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(INotificationRepository) private notificationRepository: INotificationRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(NotificationService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } @OnEvent({ name: 'config.update' }) @@ -149,7 +147,7 @@ export class NotificationService { throw new BadRequestException('Failed to verify SMTP configuration', { cause: error }); } - const { server } = await this.configCore.getConfig({ withCache: false }); + const { server } = await this.getConfig({ withCache: false }); const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.TEST_EMAIL, data: { @@ -177,7 +175,7 @@ export class NotificationService { return JobStatus.SKIPPED; } - const { server } = await this.configCore.getConfig({ withCache: true }); + const { server } = await this.getConfig({ withCache: true }); const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.WELCOME, data: { @@ -220,7 +218,7 @@ export class NotificationService { const attachment = await this.getAlbumThumbnailAttachment(album); - const { server } = await this.configCore.getConfig({ withCache: false }); + const { server } = await this.getConfig({ withCache: false }); const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.ALBUM_INVITE, data: { @@ -262,7 +260,7 @@ export class NotificationService { const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) => user.id !== senderId); const attachment = await this.getAlbumThumbnailAttachment(album); - const { server } = await this.configCore.getConfig({ withCache: false }); + const { server } = await this.getConfig({ withCache: false }); for (const recipient of recipients) { const user = await this.userRepository.get(recipient.id, { withDeleted: false }); @@ -303,7 +301,7 @@ export class NotificationService { } async handleSendEmail(data: IEmailJob): Promise { - const { notifications } = await this.configCore.getConfig({ withCache: false }); + const { notifications } = await this.getConfig({ withCache: false }); if (!notifications.smtp.enabled) { return JobStatus.SKIPPED; } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index b009696b63..7cb6f42f15 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { FACE_THUMBNAIL_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -55,6 +54,7 @@ import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interf import { ISearchRepository } from 'src/interfaces/search.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { checkAccess, requireAccess } from 'src/utils/access'; import { getAssetFiles } from 'src/utils/asset.util'; import { ImmichFileResponse } from 'src/utils/file'; @@ -64,8 +64,7 @@ import { usePagination } from 'src/utils/pagination'; import { IsNull } from 'typeorm'; @Injectable() -export class PersonService { - private configCore: SystemConfigCore; +export class PersonService extends BaseService { private storageCore: StorageCore; constructor( @@ -75,15 +74,15 @@ export class PersonService { @Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IPersonRepository) private repository: IPersonRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(PersonService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, @@ -102,7 +101,7 @@ export class PersonService { skip: (page - 1) * size, }; - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); const { items, hasNextPage } = await this.repository.getAllForUser(pagination, auth.user.id, { minimumFaceCount: machineLearning.facialRecognition.minFaces, withHidden, @@ -283,7 +282,7 @@ export class PersonService { } async handleQueueDetectFaces({ force }: IBaseJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -314,7 +313,7 @@ export class PersonService { } async handleDetectFaces({ id }: IEntityJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -375,7 +374,7 @@ export class PersonService { } async handleQueueRecognizeFaces({ force, nightly }: INightlyJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -425,7 +424,7 @@ export class PersonService { } async handleRecognizeFaces({ id, deferred }: IDeferrableJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -519,7 +518,7 @@ export class PersonService { } async handleGeneratePersonThumbnail(data: IEntityJob): Promise { - const { machineLearning, metadata, image } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning, metadata, image } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) { return JobStatus.SKIPPED; } diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index c3cc5399c8..4227f35ec3 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PersonResponseDto } from 'src/dtos/person.dto'; @@ -24,13 +23,12 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() -export class SearchService { - private configCore: SystemConfigCore; - +export class SearchService extends BaseService { constructor( @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @@ -38,10 +36,10 @@ export class SearchService { @Inject(ISearchRepository) private searchRepository: ISearchRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(SearchService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise { @@ -101,7 +99,7 @@ export class SearchService { } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) { throw new BadRequestException('Smart search is not enabled'); } diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 4e6a8972b0..e0cd41a27e 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -176,9 +176,9 @@ describe(ServerService.name, () => { }); }); - describe('getConfig', () => { + describe('getSystemConfig', () => { it('should respond the server configuration', async () => { - await expect(sut.getConfig()).resolves.toEqual({ + await expect(sut.getSystemConfig()).resolves.toEqual({ loginPageMessage: '', oauthButtonText: 'Login with OAuth', trashDays: 30, diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 708fe32db5..ed1533f667 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -2,7 +2,6 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nes import { getBuildMetadata, getServerLicensePublicKey } from 'src/config'; import { serverVersion } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { @@ -22,24 +21,24 @@ import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { asHumanReadable } from 'src/utils/bytes'; +import { isUsingConfigFile } from 'src/utils/config'; import { mimeTypes } from 'src/utils/mime-types'; import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() -export class ServerService { - private configCore: SystemConfigCore; - +export class ServerService extends BaseService { constructor( @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(ServerService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } @OnEvent({ name: 'app.bootstrap' }) @@ -91,7 +90,7 @@ export class ServerService { async getFeatures(): Promise { const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications } = - await this.configCore.getConfig({ withCache: false }); + await this.getConfig({ withCache: false }); return { smartSearch: isSmartSearchEnabled(machineLearning), @@ -106,18 +105,18 @@ export class ServerService { oauth: oauth.enabled, oauthAutoLaunch: oauth.autoLaunch, passwordLogin: passwordLogin.enabled, - configFile: this.configCore.isUsingConfigFile(), + configFile: isUsingConfigFile(), email: notifications.smtp.enabled, }; } async getTheme() { - const { theme } = await this.configCore.getConfig({ withCache: false }); + const { theme } = await this.getConfig({ withCache: false }); return theme; } - async getConfig(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + async getSystemConfig(): Promise { + const config = await this.getConfig({ withCache: false }); const isInitialized = await this.userRepository.hasAdmin(); const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.ADMIN_ONBOARDING); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 54c7fdf25b..883270f808 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -20,22 +19,21 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { checkAccess, requireAccess } from 'src/utils/access'; import { OpenGraphTags } from 'src/utils/misc'; @Injectable() -export class SharedLinkService { - private configCore: SystemConfigCore; - +export class SharedLinkService extends BaseService { constructor( @Inject(IAccessRepository) private access: IAccessRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, @Inject(ISharedLinkRepository) private repository: ISharedLinkRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(SharedLinkService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } getAll(auth: AuthDto): Promise { @@ -195,7 +193,7 @@ export class SharedLinkService { return null; } - const config = await this.configCore.getConfig({ withCache: true }); + const config = await this.getConfig({ withCache: true }); const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.id); const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id; const assetCount = sharedLink.assets.length > 0 ? sharedLink.assets.length : sharedLink.album?.assets.length || 0; diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index ef7865d25c..5db4236d58 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; @@ -18,14 +17,13 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class SmartInfoService { - private configCore: SystemConfigCore; - +export class SmartInfoService extends BaseService { constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @@ -33,10 +31,10 @@ export class SmartInfoService { @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @Inject(ISearchRepository) private repository: ISearchRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(SmartInfoService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } @OnEvent({ name: 'app.bootstrap' }) @@ -45,7 +43,7 @@ export class SmartInfoService { return; } - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); await this.init(config); } @@ -106,7 +104,7 @@ export class SmartInfoService { } async handleQueueEncodeClip({ force }: IBaseJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -131,7 +129,7 @@ export class SmartInfoService { } async handleEncodeClip({ id }: IEntityJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isSmartSearchEnabled(machineLearning)) { return JobStatus.SKIPPED; } diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 33b08efc9b..6ce79be33f 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -13,7 +13,6 @@ import { supportedYearTokens, } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; @@ -29,6 +28,7 @@ import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { getLivePhotoMotionFilename } from 'src/utils/file'; import { usePagination } from 'src/utils/pagination'; @@ -45,8 +45,7 @@ interface RenderMetadata { } @Injectable() -export class StorageTemplateService { - private configCore: SystemConfigCore; +export class StorageTemplateService extends BaseService { private storageCore: StorageCore; private _template: { compiled: HandlebarsTemplateDelegate; @@ -71,10 +70,10 @@ export class StorageTemplateService { @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(StorageTemplateService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, @@ -117,7 +116,7 @@ export class StorageTemplateService { } async handleMigrationSingle({ id }: IEntityJob): Promise { - const config = await this.configCore.getConfig({ withCache: true }); + const config = await this.getConfig({ withCache: true }); const storageTemplateEnabled = config.storageTemplate.enabled; if (!storageTemplateEnabled) { return JobStatus.SKIPPED; @@ -147,7 +146,7 @@ export class StorageTemplateService { async handleMigration(): Promise { this.logger.log('Starting storage template migration'); - const { storageTemplate } = await this.configCore.getConfig({ withCache: true }); + const { storageTemplate } = await this.getConfig({ withCache: true }); const { enabled } = storageTemplate; if (!enabled) { this.logger.log('Storage template migration disabled, skipping'); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index ac517bb3ff..0e45e0b694 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -216,7 +216,7 @@ describe(SystemConfigService.name, () => { it('should return the default config', async () => { systemMock.get.mockResolvedValue({}); - await expect(sut.getConfig()).resolves.toEqual(defaults); + await expect(sut.getSystemConfig()).resolves.toEqual(defaults); }); it('should merge the overrides', async () => { @@ -227,7 +227,7 @@ describe(SystemConfigService.name, () => { user: { deleteDelay: 15 }, }); - await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); }); it('should load the config from a json file', async () => { @@ -235,7 +235,7 @@ describe(SystemConfigService.name, () => { systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); }); @@ -245,7 +245,7 @@ describe(SystemConfigService.name, () => { systemMock.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); - await expect(sut.getConfig()).rejects.toBeInstanceOf(Error); + await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); expect(loggerMock.error).toHaveBeenCalledTimes(2); @@ -269,7 +269,7 @@ describe(SystemConfigService.name, () => { `; systemMock.readFile.mockResolvedValue(partialConfig); - await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.yaml'); }); @@ -278,7 +278,7 @@ describe(SystemConfigService.name, () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; systemMock.readFile.mockResolvedValue(JSON.stringify({})); - await expect(sut.getConfig()).resolves.toEqual(defaults); + await expect(sut.getSystemConfig()).resolves.toEqual(defaults); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); }); @@ -288,7 +288,7 @@ describe(SystemConfigService.name, () => { const partialConfig = { machineLearning: { url: 'immich_machine_learning' } }; systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - const config = await sut.getConfig(); + const config = await sut.getSystemConfig(); expect(config.machineLearning.url).toEqual('immich_machine_learning'); }); @@ -304,7 +304,7 @@ describe(SystemConfigService.name, () => { const partialConfig = { server: { externalDomain } }; systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - const config = await sut.getConfig(); + const config = await sut.getSystemConfig(); expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); }); } @@ -316,7 +316,7 @@ describe(SystemConfigService.name, () => { `; systemMock.readFile.mockResolvedValue(partialConfig); - await sut.getConfig(); + await sut.getSystemConfig(); expect(loggerMock.warn).toHaveBeenCalled(); }); @@ -335,10 +335,10 @@ describe(SystemConfigService.name, () => { systemMock.readFile.mockResolvedValue(JSON.stringify(test.config)); if (test.warn) { - await sut.getConfig(); + await sut.getSystemConfig(); expect(loggerMock.warn).toHaveBeenCalled(); } else { - await expect(sut.getConfig()).rejects.toBeInstanceOf(Error); + await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); } }); } @@ -382,7 +382,7 @@ describe(SystemConfigService.name, () => { describe('updateConfig', () => { it('should update the config and emit an event', async () => { systemMock.get.mockResolvedValue(partialConfig); - await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig); + await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig); expect(eventMock.emit).toHaveBeenCalledWith( 'config.update', expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), @@ -392,7 +392,7 @@ describe(SystemConfigService.name, () => { it('should throw an error if a config file is in use', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; systemMock.readFile.mockResolvedValue(JSON.stringify({})); - await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); expect(systemMock.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 100ab6f47c..acf9f542ca 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -12,36 +12,35 @@ import { supportedWeekTokens, supportedYearTokens, } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; import { LogLevel } from 'src/enum'; import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; +import { clearConfigCache, isUsingConfigFile } from 'src/utils/config'; import { toPlainObject } from 'src/utils/object'; @Injectable() -export class SystemConfigService { - private core: SystemConfigCore; - +export class SystemConfigService extends BaseService { constructor( - @Inject(ISystemMetadataRepository) repository: ISystemMetadataRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(SystemConfigService.name); - this.core = SystemConfigCore.create(repository, this.logger); } @OnEvent({ name: 'app.bootstrap', priority: -100 }) async onBootstrap() { - const config = await this.core.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); await this.eventRepository.emit('config.update', { newConfig: config }); } - async getConfig(): Promise { - const config = await this.core.getConfig({ withCache: false }); + async getSystemConfig(): Promise { + const config = await this.getConfig({ withCache: false }); return mapConfig(config); } @@ -57,7 +56,7 @@ export class SystemConfigService { this.logger.setLogLevel(level); this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); // TODO only do this if the event is a socket.io event - this.core.invalidateCache(); + clearConfigCache(); } @OnEvent({ name: 'config.validate' }) @@ -67,12 +66,12 @@ export class SystemConfigService { } } - async updateConfig(dto: SystemConfigDto): Promise { - if (this.core.isUsingConfigFile()) { + async updateSystemConfig(dto: SystemConfigDto): Promise { + if (isUsingConfigFile()) { throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use'); } - const oldConfig = await this.core.getConfig({ withCache: false }); + const oldConfig = await this.getConfig({ withCache: false }); try { await this.eventRepository.emit('config.validate', { newConfig: toPlainObject(dto), oldConfig }); @@ -81,7 +80,7 @@ export class SystemConfigService { throw new BadRequestException(error instanceof Error ? error.message : error); } - const newConfig = await this.core.updateConfig(dto); + const newConfig = await this.updateConfig(dto); await this.eventRepository.emit('config.update', { newConfig, oldConfig }); @@ -104,7 +103,7 @@ export class SystemConfigService { } async getCustomCss(): Promise { - const { theme } = await this.core.getConfig({ withCache: false }); + const { theme } = await this.getConfig({ withCache: false }); return theme.customCss; } diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index dca893aa82..f770d1d3b6 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -3,7 +3,6 @@ import { DateTime } from 'luxon'; import { getClientLicensePublicKey, getServerLicensePublicKey } from 'src/config'; import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; @@ -19,13 +18,12 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { ImmichFileResponse } from 'src/utils/file'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @Injectable() -export class UserService { - private configCore: SystemConfigCore; - +export class UserService extends BaseService { constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @@ -33,10 +31,10 @@ export class UserService { @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(UserService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async search(): Promise { @@ -189,7 +187,7 @@ export class UserService { async handleUserDeleteCheck(): Promise { const users = await this.userRepository.getDeletedUsers(); - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); await this.jobRepository.queueAll( users.flatMap((user) => this.isReadyForDeletion(user, config.user.deleteDelay) @@ -201,7 +199,7 @@ export class UserService { } async handleUserDelete({ id, force }: IEntityJob): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const user = await this.userRepository.get(id, { withDeleted: true }); if (!user) { return JobStatus.FAILED; diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 1d2785356e..0479faaed0 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -2,7 +2,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import semver, { SemVer } from 'semver'; import { isDev, serverVersion } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; @@ -12,6 +11,7 @@ import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { return { @@ -23,18 +23,16 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re }; @Injectable() -export class VersionService { - private configCore: SystemConfigCore; - +export class VersionService extends BaseService { constructor( @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IServerInfoRepository) private repository: IServerInfoRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(VersionService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } @OnEvent({ name: 'app.bootstrap' }) @@ -58,7 +56,7 @@ export class VersionService { return JobStatus.SKIPPED; } - const { newVersionCheck } = await this.configCore.getConfig({ withCache: true }); + const { newVersionCheck } = await this.getConfig({ withCache: true }); if (!newVersionCheck.enabled) { return JobStatus.SKIPPED; } diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts new file mode 100644 index 0000000000..307db173ca --- /dev/null +++ b/server/src/utils/config.ts @@ -0,0 +1,129 @@ +import AsyncLock from 'async-lock'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { load as loadYaml } from 'js-yaml'; +import * as _ from 'lodash'; +import { SystemConfig, defaults } from 'src/config'; +import { SystemConfigDto } from 'src/dtos/system-config.dto'; +import { SystemMetadataKey } from 'src/enum'; +import { DatabaseLock } from 'src/interfaces/database.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getKeysDeep, unsetDeep } from 'src/utils/misc'; +import { DeepPartial } from 'typeorm'; + +export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; + +type RepoDeps = { + metadataRepo: ISystemMetadataRepository; + logger: ILoggerRepository; +}; + +const asyncLock = new AsyncLock(); +let config: SystemConfig | null = null; +let lastUpdated: number | null = null; + +export const clearConfigCache = () => { + config = null; + lastUpdated = null; +}; + +export const isUsingConfigFile = () => { + return !!process.env.IMMICH_CONFIG_FILE; +}; + +export const getConfig = async (repos: RepoDeps, { withCache }: { withCache: boolean }): Promise => { + if (!withCache || !config) { + const timestamp = lastUpdated; + await asyncLock.acquire(DatabaseLock[DatabaseLock.GetSystemConfig], async () => { + if (timestamp === lastUpdated) { + config = await buildConfig(repos); + lastUpdated = Date.now(); + } + }); + } + + return config!; +}; + +export const updateConfig = async (repos: RepoDeps, newConfig: SystemConfig): Promise => { + const { metadataRepo } = repos; + // get the difference between the new config and the default config + const partialConfig: DeepPartial = {}; + for (const property of getKeysDeep(defaults)) { + const newValue = _.get(newConfig, property); + const isEmpty = newValue === undefined || newValue === null || newValue === ''; + const defaultValue = _.get(defaults, property); + const isEqual = newValue === defaultValue || _.isEqual(newValue, defaultValue); + + if (isEmpty || isEqual) { + continue; + } + + _.set(partialConfig, property, newValue); + } + + await metadataRepo.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); + + return getConfig(repos, { withCache: false }); +}; + +const loadFromFile = async ({ metadataRepo, logger }: RepoDeps, filepath: string) => { + try { + const file = await metadataRepo.readFile(filepath); + return loadYaml(file.toString()) as unknown; + } catch (error: Error | any) { + logger.error(`Unable to load configuration file: ${filepath}`); + logger.error(error); + throw error; + } +}; + +const buildConfig = async (repos: RepoDeps) => { + const { metadataRepo, logger } = repos; + + // load partial + const partial = isUsingConfigFile() + ? await loadFromFile(repos, process.env.IMMICH_CONFIG_FILE as string) + : await metadataRepo.get(SystemMetadataKey.SYSTEM_CONFIG); + + // merge with defaults + const config = _.cloneDeep(defaults); + for (const property of getKeysDeep(partial)) { + _.set(config, property, _.get(partial, property)); + } + + // check for extra properties + const unknownKeys = _.cloneDeep(config); + for (const property of getKeysDeep(defaults)) { + unsetDeep(unknownKeys, property); + } + + if (!_.isEmpty(unknownKeys)) { + logger.warn(`Unknown keys found: ${JSON.stringify(unknownKeys, null, 2)}`); + } + + // validate full config + const errors = await validate(plainToInstance(SystemConfigDto, config)); + if (errors.length > 0) { + if (isUsingConfigFile()) { + throw new Error(`Invalid value(s) in file: ${errors}`); + } else { + logger.error('Validation error', errors); + } + } + + if (config.server.externalDomain.length > 0) { + config.server.externalDomain = new URL(config.server.externalDomain).origin; + } + + if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { + config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); + } + + if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) { + config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec); + } + + return config; +}; diff --git a/server/test/repositories/system-metadata.repository.mock.ts b/server/test/repositories/system-metadata.repository.mock.ts index e44301fb21..793dd4c1c0 100644 --- a/server/test/repositories/system-metadata.repository.mock.ts +++ b/server/test/repositories/system-metadata.repository.mock.ts @@ -1,12 +1,9 @@ -import { SystemConfigCore } from 'src/cores/system-config.core'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { clearConfigCache } from 'src/utils/config'; import { Mocked, vitest } from 'vitest'; -export const newSystemMetadataRepositoryMock = (reset = true): Mocked => { - if (reset) { - SystemConfigCore.reset(); - } - +export const newSystemMetadataRepositoryMock = (): Mocked => { + clearConfigCache(); return { get: vitest.fn() as any, set: vitest.fn(), From fe33732958b555242acf5efe889ea856bbe51321 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 1 Oct 2024 08:18:13 +0700 Subject: [PATCH 476/723] chore(mobile): update photo_manager 3.5.0 (#13050) --- .../manifest.json | 1 + mobile/lib/repositories/file_media.repository.dart | 8 ++++++-- mobile/pubspec.lock | 4 ++-- mobile/pubspec.yaml | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json diff --git a/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json b/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json new file mode 100644 index 0000000000..7391713b6f --- /dev/null +++ b/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json @@ -0,0 +1 @@ +{"client":{"name":"basic","version":0,"file-system":"device-agnostic","perform-ownership-analysis":"no"},"targets":{"":[""]},"commands":{"":{"tool":"phony","inputs":[""],"outputs":[""]},"P0:::Gate WorkspaceHeaderMapVFSFilesWritten":{"tool":"phony","inputs":[],"outputs":[""]}}} \ No newline at end of file diff --git a/mobile/lib/repositories/file_media.repository.dart b/mobile/lib/repositories/file_media.repository.dart index e115868ba0..5612b378c3 100644 --- a/mobile/lib/repositories/file_media.repository.dart +++ b/mobile/lib/repositories/file_media.repository.dart @@ -16,8 +16,12 @@ class FileMediaRepository implements IFileMediaRepository { required String title, String? relativePath, }) async { - final entity = await PhotoManager.editor - .saveImage(data, title: title, relativePath: relativePath); + final entity = await PhotoManager.editor.saveImage( + data, + filename: title, + title: title, + relativePath: relativePath, + ); return AssetMediaRepository.toAsset(entity); } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9dadbd1028..8127a2143f 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1211,10 +1211,10 @@ packages: dependency: "direct main" description: name: photo_manager - sha256: "1e8bbe46a6858870e34c976aafd85378bed221ce31c1201961eba9ad3d94df9f" + sha256: "32a1ce1095aeaaa792a29f28c1f74613aa75109f21c2d4ab85be3ad9964230a4" url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.5.0" photo_manager_image_provider: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 092b0bb75c..51c0005f5c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: sdk: flutter path_provider_ios: - photo_manager: ^3.2.3 + photo_manager: ^3.5.0 photo_manager_image_provider: ^2.1.1 flutter_hooks: ^0.20.4 hooks_riverpod: ^2.4.9 From d772cc6c6aacfe1cc71a82ef94d12bf6a71c28d7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 08:23:15 +0700 Subject: [PATCH 477/723] chore(deps): update dependency lints to v5 (#13059) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- mobile/immich_lint/pubspec.lock | 6 +++--- mobile/immich_lint/pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mobile/immich_lint/pubspec.lock b/mobile/immich_lint/pubspec.lock index 6b7a4c99c5..e81bad7da2 100644 --- a/mobile/immich_lint/pubspec.lock +++ b/mobile/immich_lint/pubspec.lock @@ -186,10 +186,10 @@ packages: dependency: "direct dev" description: name: lints - sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.0.0" logging: dependency: transitive description: @@ -367,4 +367,4 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" + dart: ">=3.5.0 <4.0.0" diff --git a/mobile/immich_lint/pubspec.yaml b/mobile/immich_lint/pubspec.yaml index 78298f451e..9d1a3c26b3 100644 --- a/mobile/immich_lint/pubspec.yaml +++ b/mobile/immich_lint/pubspec.yaml @@ -11,4 +11,4 @@ dependencies: glob: ^2.1.2 dev_dependencies: - lints: ^4.0.0 + lints: ^5.0.0 From 14e6d23eebe1687eab0cf8d079a66ad7d8040656 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 01:26:39 +0000 Subject: [PATCH 478/723] chore(deps): update dependency @types/node to ^20.16.9 (#13069) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 10 +++++----- cli/package.json | 2 +- e2e/package-lock.json | 12 ++++++------ e2e/package.json | 2 +- open-api/typescript-sdk/package-lock.json | 8 ++++---- open-api/typescript-sdk/package.json | 2 +- server/package-lock.json | 14 +++++++------- server/package.json | 2 +- 8 files changed, 26 insertions(+), 26 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index e508fe843f..2f7e9dda9e 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -24,7 +24,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -59,7 +59,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "typescript": "^5.3.3" } }, @@ -1337,9 +1337,9 @@ } }, "node_modules/@types/node": { - "version": "20.16.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", - "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "version": "20.16.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", + "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 522a8e593e..cee258bff5 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index e7b463b0b2..f5ab18e4c8 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -64,7 +64,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -99,7 +99,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "typescript": "^5.3.3" } }, @@ -1569,9 +1569,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.16.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", - "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "version": "20.16.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", + "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 7c0025902d..c107732ab3 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 72d7a3ec54..e977f56834 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -12,7 +12,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "typescript": "^5.3.3" } }, @@ -22,9 +22,9 @@ "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" }, "node_modules/@types/node": { - "version": "20.16.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", - "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "version": "20.16.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", + "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 41bc3a3b16..17472327f7 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "typescript": "^5.3.3" }, "repository": { diff --git a/server/package-lock.json b/server/package-lock.json index 646a26b1ee..450b210388 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -83,7 +83,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/react": "^18.3.4", @@ -5389,9 +5389,9 @@ } }, "node_modules/@types/node": { - "version": "20.16.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", - "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "version": "20.16.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", + "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", "dependencies": { "undici-types": "~6.19.2" } @@ -18701,9 +18701,9 @@ } }, "@types/node": { - "version": "20.16.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", - "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "version": "20.16.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", + "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", "requires": { "undici-types": "~6.19.2" } diff --git a/server/package.json b/server/package.json index d481610906..2e6238ad54 100644 --- a/server/package.json +++ b/server/package.json @@ -109,7 +109,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/react": "^18.3.4", From f0ad6627a5bba4a29e97d3247f47eeec95880f38 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 21:54:28 -0400 Subject: [PATCH 479/723] fix(deps): update machine-learning (#13070) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/Dockerfile | 4 ++-- machine-learning/poetry.lock | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index d982962fbc..3bfdf7d2e2 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:e456ff58048f52f121025159e68bf16248c4122c8b96fadffd89331df50c9994 AS builder-cpu +FROM python:3.11-bookworm@sha256:3cdce69fd5663ca47c420ec4d4df8e3545519a4030372f7d2064fb1be2279844 AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:585cf0799407efc267fe1cce318322ec26e015ac1b3d77f2517d50bc3acfc232 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:5501a4fe605abe24de87c2f3d6cf9fd760354416a0cad0296cf284fddcdca9e2 AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 5bb1726378..1f6a378eda 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -2,13 +2,13 @@ [[package]] name = "aiocache" -version = "0.12.2" +version = "0.12.3" description = "multi backend asyncio cache" optional = false python-versions = "*" files = [ - {file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"}, - {file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"}, + {file = "aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d"}, + {file = "aiocache-0.12.3.tar.gz", hash = "sha256:f528b27bf4d436b497a1d0d1a8f59a542c153ab1e37c3621713cb376d44c4713"}, ] [package.extras] @@ -1237,13 +1237,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "huggingface-hub" -version = "0.25.0" +version = "0.25.1" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" files = [ - {file = "huggingface_hub-0.25.0-py3-none-any.whl", hash = "sha256:e2f357b35d72d5012cfd127108c4e14abcd61ba4ebc90a5a374dc2456cb34e12"}, - {file = "huggingface_hub-0.25.0.tar.gz", hash = "sha256:fb5fbe6c12fcd99d187ec7db95db9110fb1a20505f23040a5449a717c1a0db4d"}, + {file = "huggingface_hub-0.25.1-py3-none-any.whl", hash = "sha256:a5158ded931b3188f54ea9028097312cb0acd50bffaaa2612014c3c526b44972"}, + {file = "huggingface_hub-0.25.1.tar.gz", hash = "sha256:9ff7cb327343211fbd06e2b149b8f362fd1e389454f3f14c6db75a4999ee20ff"}, ] [package.dependencies] @@ -1531,13 +1531,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "locust" -version = "2.31.6" +version = "2.31.8" description = "Developer-friendly load testing framework" optional = false python-versions = ">=3.9" files = [ - {file = "locust-2.31.6-py3-none-any.whl", hash = "sha256:004c963c7a588dc15d57d710cdc6a262d85b57936d7fad3c38ac0657aa98fc3b"}, - {file = "locust-2.31.6.tar.gz", hash = "sha256:03b6da0491d6a0b905692d9ac128d9deec403f40dc605c481a90dbab5126318c"}, + {file = "locust-2.31.8-py3-none-any.whl", hash = "sha256:4194e3d4a0472f1206c51532ed527017f3da1a7d1037ca4b2f0735d5dcd2f78f"}, + {file = "locust-2.31.8.tar.gz", hash = "sha256:b240c0d3e1724317d9211e81e99fbe42a3469071ef4d34d2ae6a727776d56377"}, ] [package.dependencies] From 06048b6db92941ed819528f4b9d9fc47a0edfd9a Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Tue, 1 Oct 2024 04:08:25 +0200 Subject: [PATCH 480/723] feat: preload fonts (#13068) --- server/src/constants.ts | 1 - web/src/app.html | 2 ++ web/src/hooks.server.ts | 12 ++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 web/src/hooks.server.ts diff --git a/server/src/constants.ts b/server/src/constants.ts index e0a4fe8cef..8115101ca0 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -23,7 +23,6 @@ export const ONE_HOUR = Duration.fromObject({ hours: 1 }); export const envName = (process.env.IMMICH_ENV || 'production').toUpperCase(); export const isDev = () => process.env.IMMICH_ENV === 'development'; export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; -export const WEB_ROOT = process.env.IMMICH_WEB_ROOT || '/usr/src/app/www'; const HOST_SERVER_PORT = process.env.IMMICH_PORT || '2283'; export const DEFAULT_EXTERNAL_DOMAIN = 'http://localhost:' + HOST_SERVER_PORT; diff --git a/web/src/app.html b/web/src/app.html index ff6a8bf580..d76e52c859 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -13,6 +13,8 @@ + + %sveltekit.head%