From 749f63e4a00e406fd7ece2ba7e629a3f434929d1 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Tue, 17 Jun 2025 14:56:54 +0100 Subject: [PATCH 01/71] fix: partner asset and exif sync backfill (#19224) * fix: partner asset sync backfill * fix: add partner asset exif backfill * ci: output content of files that have changed --- .github/workflows/test.yml | 1 + .../openapi/lib/model/sync_entity_type.dart | 9 ++ open-api/immich-openapi-specs.json | 5 +- open-api/typescript-sdk/src/fetch-client.ts | 5 +- server/src/database.ts | 1 + server/src/db.d.ts | 1 + server/src/decorators.ts | 3 + server/src/dtos/sync.dto.ts | 3 + server/src/enum.ts | 4 + server/src/queries/sync.repository.sql | 78 +++++++++ server/src/repositories/sync.repository.ts | 39 +++++ .../1750107668827-PartnerCreateId.ts | 10 ++ server/src/schema/tables/partner.table.ts | 5 +- server/src/services/sync.service.ts | 138 ++++++++++++++-- server/src/types.ts | 1 + server/src/utils/sync.ts | 15 +- server/test/medium.factory.ts | 4 +- .../sync/sync-partner-asset-exif.spec.ts | 152 +++++++++++++++++- .../specs/sync/sync-partner-asset.spec.ts | 149 ++++++++++++++++- server/test/small.factory.ts | 17 +- server/test/utils.ts | 4 + 21 files changed, 607 insertions(+), 37 deletions(-) create mode 100644 server/src/schema/migrations/1750107668827-PartnerCreateId.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 186eb07761..c9fd2600bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -722,6 +722,7 @@ jobs: run: | echo "ERROR: Generated SQL files not up to date!" echo "Changed files: ${CHANGED_FILES}" + git diff exit 1 # mobile-integration-tests: diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 600371545a..f1366a2bfc 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -31,12 +31,15 @@ class SyncEntityType { static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1'); static const assetExifV1 = SyncEntityType._(r'AssetExifV1'); static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1'); + static const partnerAssetBackfillV1 = SyncEntityType._(r'PartnerAssetBackfillV1'); static const partnerAssetDeleteV1 = SyncEntityType._(r'PartnerAssetDeleteV1'); static const partnerAssetExifV1 = SyncEntityType._(r'PartnerAssetExifV1'); + static const partnerAssetExifBackfillV1 = SyncEntityType._(r'PartnerAssetExifBackfillV1'); static const albumV1 = SyncEntityType._(r'AlbumV1'); static const albumDeleteV1 = SyncEntityType._(r'AlbumDeleteV1'); static const albumUserV1 = SyncEntityType._(r'AlbumUserV1'); static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1'); + static const syncAckV1 = SyncEntityType._(r'SyncAckV1'); /// List of all possible values in this [enum][SyncEntityType]. static const values = [ @@ -48,12 +51,15 @@ class SyncEntityType { assetDeleteV1, assetExifV1, partnerAssetV1, + partnerAssetBackfillV1, partnerAssetDeleteV1, partnerAssetExifV1, + partnerAssetExifBackfillV1, albumV1, albumDeleteV1, albumUserV1, albumUserDeleteV1, + syncAckV1, ]; static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value); @@ -100,12 +106,15 @@ class SyncEntityTypeTypeTransformer { case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1; case r'AssetExifV1': return SyncEntityType.assetExifV1; case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1; + case r'PartnerAssetBackfillV1': return SyncEntityType.partnerAssetBackfillV1; case r'PartnerAssetDeleteV1': return SyncEntityType.partnerAssetDeleteV1; case r'PartnerAssetExifV1': return SyncEntityType.partnerAssetExifV1; + case r'PartnerAssetExifBackfillV1': return SyncEntityType.partnerAssetExifBackfillV1; case r'AlbumV1': return SyncEntityType.albumV1; case r'AlbumDeleteV1': return SyncEntityType.albumDeleteV1; case r'AlbumUserV1': return SyncEntityType.albumUserV1; case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1; + case r'SyncAckV1': return SyncEntityType.syncAckV1; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 3ab1a0ce74..6e1f029862 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13699,12 +13699,15 @@ "AssetDeleteV1", "AssetExifV1", "PartnerAssetV1", + "PartnerAssetBackfillV1", "PartnerAssetDeleteV1", "PartnerAssetExifV1", + "PartnerAssetExifBackfillV1", "AlbumV1", "AlbumDeleteV1", "AlbumUserV1", - "AlbumUserDeleteV1" + "AlbumUserDeleteV1", + "SyncAckV1" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9a642a2d4b..6a288e75db 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4052,12 +4052,15 @@ export enum SyncEntityType { AssetDeleteV1 = "AssetDeleteV1", AssetExifV1 = "AssetExifV1", PartnerAssetV1 = "PartnerAssetV1", + PartnerAssetBackfillV1 = "PartnerAssetBackfillV1", PartnerAssetDeleteV1 = "PartnerAssetDeleteV1", PartnerAssetExifV1 = "PartnerAssetExifV1", + PartnerAssetExifBackfillV1 = "PartnerAssetExifBackfillV1", AlbumV1 = "AlbumV1", AlbumDeleteV1 = "AlbumDeleteV1", AlbumUserV1 = "AlbumUserV1", - AlbumUserDeleteV1 = "AlbumUserDeleteV1" + AlbumUserDeleteV1 = "AlbumUserDeleteV1", + SyncAckV1 = "SyncAckV1" } export enum SyncRequestType { UsersV1 = "UsersV1", diff --git a/server/src/database.ts b/server/src/database.ts index 87509bd72a..79c550dd52 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -209,6 +209,7 @@ export type Partner = { sharedWithId: string; sharedWith: User; createdAt: Date; + createId: string; updatedAt: Date; updateId: string; inTimeline: boolean; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index af1dd964fd..5aa8a8c4dc 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -332,6 +332,7 @@ export interface PartnersAudit { export interface Partners { createdAt: Generated; + createId: Generated; inTimeline: Generated; sharedById: string; sharedWithId: string; diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 6b34ffcafe..766e7c70b9 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -14,6 +14,9 @@ const GeneratedUuidV7Column = (options: Omit = {}) => GeneratedUuidV7Column(options); +export const CreateIdColumn = (options: Omit = {}) => + GeneratedUuidV7Column(options); + export const PrimaryGeneratedUuidV7Column = () => GeneratedUuidV7Column({ primary: true }); export const UpdatedAtTrigger = (name: string) => diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 050635308e..dbd58cde53 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -154,12 +154,15 @@ export type SyncItem = { [SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1; [SyncEntityType.AssetExifV1]: SyncAssetExifV1; [SyncEntityType.PartnerAssetV1]: SyncAssetV1; + [SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1; [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; [SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1; + [SyncEntityType.PartnerAssetExifBackfillV1]: SyncAssetExifV1; [SyncEntityType.AlbumV1]: SyncAlbumV1; [SyncEntityType.AlbumDeleteV1]: SyncAlbumDeleteV1; [SyncEntityType.AlbumUserV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1; + [SyncEntityType.SyncAckV1]: object; }; const responseDtos = [ diff --git a/server/src/enum.ts b/server/src/enum.ts index e7e40eb122..4353e43ad1 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -595,13 +595,17 @@ export enum SyncEntityType { AssetExifV1 = 'AssetExifV1', PartnerAssetV1 = 'PartnerAssetV1', + PartnerAssetBackfillV1 = 'PartnerAssetBackfillV1', PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', PartnerAssetExifV1 = 'PartnerAssetExifV1', + PartnerAssetExifBackfillV1 = 'PartnerAssetExifBackfillV1', AlbumV1 = 'AlbumV1', AlbumDeleteV1 = 'AlbumDeleteV1', AlbumUserV1 = 'AlbumUserV1', AlbumUserDeleteV1 = 'AlbumUserDeleteV1', + + SyncAckV1 = 'SyncAckV1', } export enum NotificationLevel { diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 659365c563..8e52754467 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -96,6 +96,45 @@ where order by "updateId" asc +-- SyncRepository.getPartnerBackfill +select + "sharedById", + "createId" +from + "partners" +where + "sharedWithId" = $1 + and "createId" >= $2 + and "createdAt" < now() - interval '1 millisecond' +order by + "partners"."createId" asc + +-- SyncRepository.getPartnerAssetsBackfill +select + "id", + "ownerId", + "originalFileName", + "thumbhash", + "checksum", + "fileCreatedAt", + "fileModifiedAt", + "localDateTime", + "type", + "deletedAt", + "isFavorite", + "visibility", + "updateId", + "duration" +from + "assets" +where + "ownerId" = $1 + and "updatedAt" < now() - interval '1 millisecond' + and "updateId" < $2 + and "updateId" >= $3 +order by + "updateId" asc + -- SyncRepository.getPartnerAssetsUpserts select "id", @@ -201,6 +240,45 @@ where order by "updateId" asc +-- SyncRepository.getPartnerAssetExifsBackfill +select + "exif"."assetId", + "exif"."description", + "exif"."exifImageWidth", + "exif"."exifImageHeight", + "exif"."fileSizeInByte", + "exif"."orientation", + "exif"."dateTimeOriginal", + "exif"."modifyDate", + "exif"."timeZone", + "exif"."latitude", + "exif"."longitude", + "exif"."projectionType", + "exif"."city", + "exif"."state", + "exif"."country", + "exif"."make", + "exif"."model", + "exif"."lensModel", + "exif"."fNumber", + "exif"."focalLength", + "exif"."iso", + "exif"."exposureTime", + "exif"."profileDescription", + "exif"."rating", + "exif"."fps", + "exif"."updateId" +from + "exif" + inner join "assets" on "assets"."id" = "exif"."assetId" +where + "assets"."ownerId" = $1 + and "exif"."updatedAt" < now() - interval '1 millisecond' + and "exif"."updateId" < $2 + and "exif"."updateId" >= $3 +order by + "exif"."updateId" asc + -- SyncRepository.getPartnerAssetExifsUpserts select "exif"."assetId", diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 43fd732747..0f2d382fe0 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -92,6 +92,31 @@ export class SyncRepository { .stream(); } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + getPartnerBackfill(userId: string, afterCreateId?: string) { + return this.db + .selectFrom('partners') + .select(['sharedById', 'createId']) + .where('sharedWithId', '=', userId) + .$if(!!afterCreateId, (qb) => qb.where('createId', '>=', afterCreateId!)) + .where('createdAt', '<', sql.raw("now() - interval '1 millisecond'")) + .orderBy('partners.createId', 'asc') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true }) + getPartnerAssetsBackfill(partnerId: string, afterUpdateId: string | undefined, beforeUpdateId: string) { + return this.db + .selectFrom('assets') + .select(columns.syncAsset) + .where('ownerId', '=', partnerId) + .where('updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .where('updateId', '<', beforeUpdateId) + .$if(!!afterUpdateId, (eb) => eb.where('updateId', '>=', afterUpdateId!)) + .orderBy('updateId', 'asc') + .stream(); + } + @GenerateSql({ params: [DummyValue.UUID], stream: true }) getPartnerAssetsUpserts(userId: string, ack?: SyncAck) { return this.db @@ -136,6 +161,20 @@ export class SyncRepository { .stream(); } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true }) + getPartnerAssetExifsBackfill(partnerId: string, afterUpdateId: string | undefined, beforeUpdateId: string) { + return this.db + .selectFrom('exif') + .select(columns.syncAssetExif) + .innerJoin('assets', 'assets.id', 'exif.assetId') + .where('assets.ownerId', '=', partnerId) + .where('exif.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .where('exif.updateId', '<', beforeUpdateId) + .$if(!!afterUpdateId, (eb) => eb.where('exif.updateId', '>=', afterUpdateId!)) + .orderBy('exif.updateId', 'asc') + .stream(); + } + @GenerateSql({ params: [DummyValue.UUID], stream: true }) getPartnerAssetExifsUpserts(userId: string, ack?: SyncAck) { return this.db diff --git a/server/src/schema/migrations/1750107668827-PartnerCreateId.ts b/server/src/schema/migrations/1750107668827-PartnerCreateId.ts new file mode 100644 index 0000000000..56d78bf25a --- /dev/null +++ b/server/src/schema/migrations/1750107668827-PartnerCreateId.ts @@ -0,0 +1,10 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "partners" ADD "createId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); + await sql`UPDATE "partners" SET "createId" = immich_uuid_v7("createdAt")`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "partners" DROP COLUMN "createId";`.execute(db); +} diff --git a/server/src/schema/tables/partner.table.ts b/server/src/schema/tables/partner.table.ts index 0da60cfc0c..6b83c6ba4c 100644 --- a/server/src/schema/tables/partner.table.ts +++ b/server/src/schema/tables/partner.table.ts @@ -1,4 +1,4 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { partners_delete_audit } from 'src/schema/functions'; import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, Column, CreateDateColumn, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools'; @@ -27,6 +27,9 @@ export class PartnerTable { @CreateDateColumn() createdAt!: Date; + @CreateIdColumn() + createId!: string; + @UpdateDateColumn() updatedAt!: Date; diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index d6cbc17a29..4705fc8e1f 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -20,7 +20,7 @@ import { SyncAck } from 'src/types'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { setIsEqual } from 'src/utils/set'; -import { fromAck, serialize } from 'src/utils/sync'; +import { fromAck, serialize, toAck } from 'src/utils/sync'; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; export const SYNC_TYPES_ORDER = [ @@ -98,12 +98,12 @@ export class SyncService extends BaseService { case SyncRequestType.UsersV1: { const deletes = this.syncRepository.getUserDeletes(checkpointMap[SyncEntityType.UserDeleteV1]); for await (const { id, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.UserDeleteV1, updateId: id, data })); + response.write(serialize({ type: SyncEntityType.UserDeleteV1, ids: [id], data })); } const upserts = this.syncRepository.getUserUpserts(checkpointMap[SyncEntityType.UserV1]); for await (const { updateId, ...data } of upserts) { - response.write(serialize({ type: SyncEntityType.UserV1, updateId, data })); + response.write(serialize({ type: SyncEntityType.UserV1, ids: [updateId], data })); } break; @@ -115,12 +115,12 @@ export class SyncService extends BaseService { checkpointMap[SyncEntityType.PartnerDeleteV1], ); for await (const { id, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.PartnerDeleteV1, updateId: id, data })); + response.write(serialize({ type: SyncEntityType.PartnerDeleteV1, ids: [id], data })); } const upserts = this.syncRepository.getPartnerUpserts(auth.user.id, checkpointMap[SyncEntityType.PartnerV1]); for await (const { updateId, ...data } of upserts) { - response.write(serialize({ type: SyncEntityType.PartnerV1, updateId, data })); + response.write(serialize({ type: SyncEntityType.PartnerV1, ids: [updateId], data })); } break; @@ -132,7 +132,7 @@ export class SyncService extends BaseService { checkpointMap[SyncEntityType.AssetDeleteV1], ); for await (const { id, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.AssetDeleteV1, updateId: id, data })); + response.write(serialize({ type: SyncEntityType.AssetDeleteV1, ids: [id], data })); } const upserts = this.syncRepository.getAssetUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetV1]); @@ -140,7 +140,7 @@ export class SyncService extends BaseService { response.write( serialize({ type: SyncEntityType.AssetV1, - updateId, + ids: [updateId], data: { ...data, checksum: hexOrBufferToBase64(checksum), @@ -159,7 +159,60 @@ export class SyncService extends BaseService { checkpointMap[SyncEntityType.PartnerAssetDeleteV1], ); for await (const { id, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.PartnerAssetDeleteV1, updateId: id, data })); + response.write(serialize({ type: SyncEntityType.PartnerAssetDeleteV1, ids: [id], data })); + } + + const checkpoint = checkpointMap[SyncEntityType.PartnerAssetBackfillV1]; + const partnerAssetCheckpoint = checkpointMap[SyncEntityType.PartnerAssetV1]; + + const partners = await this.syncRepository.getPartnerBackfill(auth.user.id, checkpoint?.updateId); + + if (partnerAssetCheckpoint) { + for (const partner of partners) { + if (partner.createId === checkpoint?.updateId && checkpoint.extraId === 'complete') { + continue; + } + const partnerCheckpoint = checkpoint?.updateId === partner.createId ? checkpoint?.extraId : undefined; + const backfill = this.syncRepository.getPartnerAssetsBackfill( + partner.sharedById, + partnerCheckpoint, + partnerAssetCheckpoint.updateId, + ); + + for await (const { updateId, checksum, thumbhash, ...data } of backfill) { + response.write( + serialize({ + type: SyncEntityType.PartnerAssetBackfillV1, + ids: [updateId], + data: { + ...data, + checksum: hexOrBufferToBase64(checksum), + thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null, + }, + }), + ); + } + response.write( + serialize({ + type: SyncEntityType.SyncAckV1, + data: {}, + ackType: SyncEntityType.PartnerAssetBackfillV1, + ids: [partner.sharedById, 'complete'], + }), + ); + } + } else if (partners.length > 0) { + await this.syncRepository.upsertCheckpoints([ + { + type: SyncEntityType.PartnerAssetBackfillV1, + sessionId, + ack: toAck({ + type: SyncEntityType.PartnerAssetBackfillV1, + updateId: partners.at(-1)!.createId, + extraId: 'complete', + }), + }, + ]); } const upserts = this.syncRepository.getPartnerAssetsUpserts( @@ -170,7 +223,7 @@ export class SyncService extends BaseService { response.write( serialize({ type: SyncEntityType.PartnerAssetV1, - updateId, + ids: [updateId], data: { ...data, checksum: hexOrBufferToBase64(checksum), @@ -189,19 +242,74 @@ export class SyncService extends BaseService { checkpointMap[SyncEntityType.AssetExifV1], ); for await (const { updateId, ...data } of upserts) { - response.write(serialize({ type: SyncEntityType.AssetExifV1, updateId, data })); + response.write(serialize({ type: SyncEntityType.AssetExifV1, ids: [updateId], data })); } break; } case SyncRequestType.PartnerAssetExifsV1: { + const checkpoint = checkpointMap[SyncEntityType.PartnerAssetExifBackfillV1]; + const partnerAssetCheckpoint = checkpointMap[SyncEntityType.PartnerAssetExifV1]; + + const partners = await this.syncRepository.getPartnerBackfill(auth.user.id, checkpoint?.updateId); + + if (partnerAssetCheckpoint) { + for (const partner of partners) { + if (partner.createId === checkpoint?.updateId && checkpoint.extraId === 'complete') { + continue; + } + const partnerCheckpoint = checkpoint?.updateId === partner.createId ? checkpoint?.extraId : undefined; + const backfill = this.syncRepository.getPartnerAssetExifsBackfill( + partner.sharedById, + partnerCheckpoint, + partnerAssetCheckpoint.updateId, + ); + + for await (const { updateId, ...data } of backfill) { + response.write( + serialize({ + type: SyncEntityType.PartnerAssetExifBackfillV1, + ids: [updateId], + data, + }), + ); + } + response.write( + serialize({ + type: SyncEntityType.SyncAckV1, + data: {}, + ackType: SyncEntityType.PartnerAssetExifBackfillV1, + ids: [partner.sharedById, 'complete'], + }), + ); + } + } else if (partners.length > 0) { + await this.syncRepository.upsertCheckpoints([ + { + type: SyncEntityType.PartnerAssetExifBackfillV1, + sessionId, + ack: toAck({ + type: SyncEntityType.PartnerAssetExifBackfillV1, + updateId: partners.at(-1)!.createId, + extraId: 'complete', + }), + }, + ]); + } + const upserts = this.syncRepository.getPartnerAssetExifsUpserts( auth.user.id, checkpointMap[SyncEntityType.PartnerAssetExifV1], ); for await (const { updateId, ...data } of upserts) { - response.write(serialize({ type: SyncEntityType.PartnerAssetExifV1, updateId, data })); + response.write( + serialize({ + type: SyncEntityType.PartnerAssetExifV1, + ids: [updateId], + data, + }), + ); } break; @@ -213,12 +321,12 @@ export class SyncService extends BaseService { checkpointMap[SyncEntityType.AlbumDeleteV1], ); for await (const { id, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.AlbumDeleteV1, updateId: id, data })); + response.write(serialize({ type: SyncEntityType.AlbumDeleteV1, ids: [id], data })); } const upserts = this.syncRepository.getAlbumUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumV1]); for await (const { updateId, ...data } of upserts) { - response.write(serialize({ type: SyncEntityType.AlbumV1, updateId, data })); + response.write(serialize({ type: SyncEntityType.AlbumV1, ids: [updateId], data })); } break; @@ -230,7 +338,7 @@ export class SyncService extends BaseService { checkpointMap[SyncEntityType.AlbumUserDeleteV1], ); for await (const { id, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.AlbumUserDeleteV1, updateId: id, data })); + response.write(serialize({ type: SyncEntityType.AlbumUserDeleteV1, ids: [id], data })); } const upserts = this.syncRepository.getAlbumUserUpserts( @@ -238,7 +346,7 @@ export class SyncService extends BaseService { checkpointMap[SyncEntityType.AlbumUserV1], ); for await (const { updateId, ...data } of upserts) { - response.write(serialize({ type: SyncEntityType.AlbumUserV1, updateId, data })); + response.write(serialize({ type: SyncEntityType.AlbumUserV1, ids: [updateId], data })); } break; diff --git a/server/src/types.ts b/server/src/types.ts index 3ef22f96ff..6776604078 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -421,6 +421,7 @@ export interface IBulkAsset { export type SyncAck = { type: SyncEntityType; updateId: string; + extraId?: string; }; export type StorageAsset = { diff --git a/server/src/utils/sync.ts b/server/src/utils/sync.ts index cfb6660bdc..893c94dfcd 100644 --- a/server/src/utils/sync.ts +++ b/server/src/utils/sync.ts @@ -9,20 +9,23 @@ type Impossible = { type Exact = U & Impossible>; export const fromAck = (ack: string): SyncAck => { - const [type, updateId] = ack.split('|'); - return { type: type as SyncEntityType, updateId }; + const [type, updateId, extraId] = ack.split('|'); + return { type: type as SyncEntityType, updateId, extraId }; }; -export const toAck = ({ type, updateId }: SyncAck) => [type, updateId].join('|'); +export const toAck = ({ type, updateId, extraId }: SyncAck) => + [type, updateId, extraId].filter((v) => v !== undefined).join('|'); export const mapJsonLine = (object: unknown) => JSON.stringify(object) + '\n'; export const serialize = ({ type, - updateId, data, + ids, + ackType, }: { type: T; - updateId: string; data: Exact; -}) => mapJsonLine({ type, data, ack: toAck({ type, updateId }) }); + ids: [string] | [string, string]; + ackType?: SyncEntityType; +}) => mapJsonLine({ type, data, ack: toAck({ type: ackType ?? type, updateId: ids[0], extraId: ids[1] }) }); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 9a4065aa9d..988f1b406f 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -33,7 +33,7 @@ import { BaseService } from 'src/services/base.service'; import { SyncService } from 'src/services/sync.service'; import { RepositoryInterface } from 'src/types'; import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory'; -import { automock, ServiceOverrides } from 'test/utils'; +import { automock, ServiceOverrides, wait } from 'test/utils'; import { Mocked } from 'vitest'; const sha256 = (value: string) => createHash('sha256').update(value).digest('base64'); @@ -120,7 +120,7 @@ export const newSyncTest = (options: SyncTestOptions) => { const testSync = async (auth: AuthDto, types: SyncRequestType[]) => { const stream = mediumFactory.syncStream(); // Wait for 2ms to ensure all updates are available and account for setTimeout inaccuracy - await new Promise((resolve) => setTimeout(resolve, 2)); + await wait(2); await sut.stream(auth, stream, { types }); return stream.getResponse(); diff --git a/server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts b/server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts index 8d9e6d6ac5..edf6eee564 100644 --- a/server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts +++ b/server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts @@ -3,7 +3,7 @@ import { DB } from 'src/db'; import { SyncEntityType, SyncRequestType } from 'src/enum'; import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; import { factory } from 'test/small.factory'; -import { getKyselyDB } from 'test/utils'; +import { getKyselyDB, wait } from 'test/utils'; let defaultDatabase: Kysely; @@ -126,4 +126,154 @@ describe.concurrent(SyncRequestType.PartnerAssetExifsV1, () => { await expect(testSync(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0); }); + + it('should backfill partner asset exif when a partner shared their library with you', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + const user3 = mediumFactory.userInsert(); + await userRepo.create(user2); + await userRepo.create(user3); + + const assetRepo = getRepository('asset'); + const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id }); + const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id }); + await assetRepo.create(assetUser3); + await assetRepo.upsertExif({ assetId: assetUser3.id, make: 'Canon' }); + await wait(2); + await assetRepo.create(assetUser2); + await assetRepo.upsertExif({ assetId: assetUser2.id, make: 'Canon' }); + + const partnerRepo = getRepository('partner'); + await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + + const response = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: expect.objectContaining({ + assetId: assetUser2.id, + }), + type: SyncEntityType.PartnerAssetExifV1, + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id }); + const backfillResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); + + expect(backfillResponse).toHaveLength(2); + expect(backfillResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: expect.objectContaining({ + assetId: assetUser3.id, + }), + type: SyncEntityType.PartnerAssetExifBackfillV1, + }, + { + ack: expect.any(String), + data: {}, + type: SyncEntityType.SyncAckV1, + }, + ]), + ); + + const backfillAck = backfillResponse[1].ack; + await sut.setAcks(auth, { acks: [backfillAck] }); + + const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); + + const finalAcks = finalResponse.map(({ ack }) => ack); + expect(finalAcks).toEqual([]); + }); + + it('should only backfill partner assets created prior to the current partner asset checkpoint', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + const user3 = mediumFactory.userInsert(); + await userRepo.create(user2); + await userRepo.create(user3); + + const assetRepo = getRepository('asset'); + const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id }); + const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id }); + const asset2User3 = mediumFactory.assetInsert({ ownerId: user3.id }); + await assetRepo.create(assetUser3); + await assetRepo.upsertExif({ assetId: assetUser3.id, make: 'Canon' }); + await wait(2); + await assetRepo.create(assetUser2); + await assetRepo.upsertExif({ assetId: assetUser2.id, make: 'Canon' }); + await wait(2); + await assetRepo.create(asset2User3); + await assetRepo.upsertExif({ assetId: asset2User3.id, make: 'Canon' }); + + const partnerRepo = getRepository('partner'); + await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + + const response = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: expect.objectContaining({ + assetId: assetUser2.id, + }), + type: SyncEntityType.PartnerAssetExifV1, + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id }); + const backfillResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); + + expect(backfillResponse).toHaveLength(3); + expect(backfillResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: expect.objectContaining({ + assetId: assetUser3.id, + }), + type: SyncEntityType.PartnerAssetExifBackfillV1, + }, + { + ack: expect.stringContaining(SyncEntityType.PartnerAssetExifBackfillV1), + data: {}, + type: SyncEntityType.SyncAckV1, + }, + { + ack: expect.any(String), + data: expect.objectContaining({ + assetId: asset2User3.id, + }), + type: SyncEntityType.PartnerAssetExifV1, + }, + ]), + ); + + const backfillAck = backfillResponse[1].ack; + const partnerAssetAck = backfillResponse[2].ack; + await sut.setAcks(auth, { acks: [backfillAck, partnerAssetAck] }); + + const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); + + const finalAcks = finalResponse.map(({ ack }) => ack); + expect(finalAcks).toEqual([]); + }); }); diff --git a/server/test/medium/specs/sync/sync-partner-asset.spec.ts b/server/test/medium/specs/sync/sync-partner-asset.spec.ts index 8125193ba5..fe3d4edbcc 100644 --- a/server/test/medium/specs/sync/sync-partner-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-partner-asset.spec.ts @@ -3,7 +3,7 @@ import { DB } from 'src/db'; import { SyncEntityType, SyncRequestType } from 'src/enum'; import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; import { factory } from 'test/small.factory'; -import { getKyselyDB } from 'test/utils'; +import { getKyselyDB, wait } from 'test/utils'; let defaultDatabase: Kysely; @@ -19,7 +19,7 @@ beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); -describe.concurrent(SyncRequestType.PartnerAssetsV1, () => { +describe(SyncRequestType.PartnerAssetsV1, () => { it('should detect and sync the first partner asset', async () => { const { auth, sut, getRepository, testSync } = await setup(); @@ -210,4 +210,149 @@ describe.concurrent(SyncRequestType.PartnerAssetsV1, () => { await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); }); + + it('should backfill partner assets when a partner shared their library with you', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + const user3 = mediumFactory.userInsert(); + await userRepo.create(user2); + await userRepo.create(user3); + + const assetRepo = getRepository('asset'); + const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id }); + const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id }); + await assetRepo.create(assetUser3); + await wait(2); + await assetRepo.create(assetUser2); + + const partnerRepo = getRepository('partner'); + await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + + const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: assetUser2.id, + }), + type: SyncEntityType.PartnerAssetV1, + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id }); + const backfillResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(backfillResponse).toHaveLength(2); + expect(backfillResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: assetUser3.id, + }), + type: SyncEntityType.PartnerAssetBackfillV1, + }, + { + ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV1), + data: {}, + type: SyncEntityType.SyncAckV1, + }, + ]), + ); + + const backfillAck = backfillResponse[1].ack; + await sut.setAcks(auth, { acks: [backfillAck] }); + + const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + const finalAcks = finalResponse.map(({ ack }) => ack); + expect(finalAcks).toEqual([]); + }); + + it('should only backfill partner assets created prior to the current partner asset checkpoint', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + const user3 = mediumFactory.userInsert(); + await userRepo.create(user2); + await userRepo.create(user3); + + const assetRepo = getRepository('asset'); + const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id }); + const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id }); + const asset2User3 = mediumFactory.assetInsert({ ownerId: user3.id }); + await assetRepo.create(assetUser3); + await wait(2); + await assetRepo.create(assetUser2); + await wait(2); + await assetRepo.create(asset2User3); + + const partnerRepo = getRepository('partner'); + await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + + const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: assetUser2.id, + }), + type: SyncEntityType.PartnerAssetV1, + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id }); + const backfillResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(backfillResponse).toHaveLength(3); + expect(backfillResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: assetUser3.id, + }), + type: SyncEntityType.PartnerAssetBackfillV1, + }, + { + ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV1), + data: {}, + type: SyncEntityType.SyncAckV1, + }, + { + ack: expect.any(String), + data: expect.objectContaining({ + id: asset2User3.id, + }), + type: SyncEntityType.PartnerAssetV1, + }, + ]), + ); + + const backfillAck = backfillResponse[1].ack; + const partnerAssetAck = backfillResponse[2].ack; + await sut.setAcks(auth, { acks: [backfillAck, partnerAssetAck] }); + + const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + const finalAcks = finalResponse.map(({ ack }) => ack); + expect(finalAcks).toEqual([]); + }); }); diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index b70f02bcf5..79d6d511a3 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -24,7 +24,7 @@ export const newUuids = () => .fill(0) .map(() => newUuid()); export const newDate = () => new Date(); -export const newUpdateId = () => 'uuid-v7'; +export const newUuidV7 = () => 'uuid-v7'; export const newSha1 = () => Buffer.from('this is a fake hash'); export const newEmbedding = () => { const embedding = Array.from({ length: 512 }) @@ -110,9 +110,10 @@ const partnerFactory = (partner: Partial = {}) => { sharedBy, sharedWithId: sharedWith.id, sharedWith, + createId: newUuidV7(), createdAt: newDate(), updatedAt: newDate(), - updateId: newUpdateId(), + updateId: newUuidV7(), inTimeline: true, ...partner, }; @@ -122,7 +123,7 @@ const sessionFactory = (session: Partial = {}) => ({ id: newUuid(), createdAt: newDate(), updatedAt: newDate(), - updateId: newUpdateId(), + updateId: newUuidV7(), deviceOS: 'android', deviceType: 'mobile', token: 'abc123', @@ -201,7 +202,7 @@ const assetFactory = (asset: Partial = {}) => ({ createdAt: newDate(), updatedAt: newDate(), deletedAt: null, - updateId: newUpdateId(), + updateId: newUuidV7(), status: AssetStatus.ACTIVE, checksum: newSha1(), deviceAssetId: '', @@ -240,7 +241,7 @@ const activityFactory = (activity: Partial = {}) => { albumId: newUuid(), createdAt: newDate(), updatedAt: newDate(), - updateId: newUpdateId(), + updateId: newUuidV7(), ...activity, }; }; @@ -250,7 +251,7 @@ const apiKeyFactory = (apiKey: Partial = {}) => ({ userId: newUuid(), createdAt: newDate(), updatedAt: newDate(), - updateId: newUpdateId(), + updateId: newUuidV7(), name: 'Api Key', permissions: [Permission.ALL], ...apiKey, @@ -260,7 +261,7 @@ const libraryFactory = (library: Partial = {}) => ({ id: newUuid(), createdAt: newDate(), updatedAt: newDate(), - updateId: newUpdateId(), + updateId: newUuidV7(), deletedAt: null, refreshedAt: null, name: 'Library', @@ -275,7 +276,7 @@ const memoryFactory = (memory: Partial = {}) => ({ id: newUuid(), createdAt: newDate(), updatedAt: newDate(), - updateId: newUpdateId(), + updateId: newUuidV7(), deletedAt: null, ownerId: newUuid(), type: MemoryType.ON_THIS_DAY, diff --git a/server/test/utils.ts b/server/test/utils.ts index 5738ae88dc..0b3cd186c7 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -438,3 +438,7 @@ export async function* makeStream(items: T[] = []): AsyncIterableIterator yield item; } } + +export const wait = (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; From bd708249614028ccbfdaa8c6646ead1c55d44bb5 Mon Sep 17 00:00:00 2001 From: Dag Stuan Date: Tue, 17 Jun 2025 16:09:34 +0200 Subject: [PATCH 02/71] fix(web): more refactoring and tweaking of the memory viewer. (#19214) * Fix fade in for video-native-viewer. The previous implementation never actually faded in the video element. Fix this by ensuring the video element is only added to the DOM after mounting, so Svelte can handle the fade-in transition correctly. * Refactor asset viewing in memory page. Split photo and video viewing into separate components to ensure they work similarly to the assets viewer. The previous implementation faded out the assets, while the assets-viewer only fades assets in. For images, add a spinner while waiting for the image to load, before adding the image to the DOM. For videos, add the video to the DOM after mounting the component. In both cases, the assets fade in smoothly, like the regular assets viewer. * fix: styling --------- Co-authored-by: Alex --- .../asset-viewer/video-native-viewer.svelte | 112 +++++++++--------- .../memory-page/memory-photo-viewer.svelte | 69 +++++++++++ .../memory-page/memory-video-viewer.svelte | 40 +++++++ .../memory-page/memory-viewer.svelte | 92 +++++++------- .../shared-components/control-app-bar.svelte | 3 - 5 files changed, 209 insertions(+), 107 deletions(-) create mode 100644 web/src/lib/components/memory-page/memory-photo-viewer.svelte create mode 100644 web/src/lib/components/memory-page/memory-video-viewer.svelte diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 8205c8c353..4b8bb40f77 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -2,6 +2,7 @@ import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; + import { assetViewerFadeDuration } from '$lib/constants'; import { castManager } from '$lib/managers/cast-manager.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; @@ -41,8 +42,11 @@ let assetFileUrl = $state(''); let forceMuted = $state(false); let isScrubbing = $state(false); + let showVideo = $state(false); onMount(() => { + // Show video after mount to ensure fading in. + showVideo = true; assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey }); if (videoPlayer) { forceMuted = false; @@ -102,59 +106,61 @@ }); -
- {#if castManager.isCasting} -
- -
- {:else} - - - {#if isLoading} -
- +{#if showVideo} +
+ {#if castManager.isCasting} +
+
- {/if} + {:else} + - {#if isFaceEditMode.value} - + {#if isLoading} +
+ +
+ {/if} + + {#if isFaceEditMode.value} + + {/if} {/if} - {/if} -
+
+{/if} diff --git a/web/src/lib/components/memory-page/memory-photo-viewer.svelte b/web/src/lib/components/memory-page/memory-photo-viewer.svelte new file mode 100644 index 0000000000..b0b1dc98a6 --- /dev/null +++ b/web/src/lib/components/memory-page/memory-photo-viewer.svelte @@ -0,0 +1,69 @@ + + +{#if !imageLoaded} + + +{/if} + +{#if !imageLoaded} +
+ +
+{:else if imageLoaded} +
+ {$getAltText(asset)} +
+{/if} + + diff --git a/web/src/lib/components/memory-page/memory-video-viewer.svelte b/web/src/lib/components/memory-page/memory-video-viewer.svelte new file mode 100644 index 0000000000..7758b067f3 --- /dev/null +++ b/web/src/lib/components/memory-page/memory-video-viewer.svelte @@ -0,0 +1,40 @@ + + +{#if showVideo} +
+ +
+{/if} diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 77ccebe42b..f1a15f4429 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -4,6 +4,8 @@ import { intersectionObserver } from '$lib/actions/intersection-observer'; import { resizeObserver } from '$lib/actions/resize-observer'; import { shortcuts } from '$lib/actions/shortcut'; + import MemoryPhotoViewer from '$lib/components/memory-page/memory-photo-viewer.svelte'; + import MemoryVideoViewer from '$lib/components/memory-page/memory-video-viewer.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; @@ -23,7 +25,7 @@ notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; - import { AppRoute, assetViewerFadeDuration, QueryParameter } from '$lib/constants'; + import { AppRoute, QueryParameter } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; @@ -31,9 +33,8 @@ import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte'; import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { preferences } from '$lib/stores/user.store'; - import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; + import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; - import { getAltText } from '$lib/utils/thumbnail-util'; import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetMediaSize, getAssetInfo } from '@immich/sdk'; import { IconButton } from '@immich/ui'; @@ -59,7 +60,6 @@ import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; import { Tween } from 'svelte/motion'; - import { fade } from 'svelte/transition'; let memoryGallery: HTMLElement | undefined = $state(); let memoryWrapper: HTMLElement | undefined = $state(); @@ -363,15 +363,16 @@ {/snippet}
- handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))} - class="hover:text-black" - /> +
+ handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))} + /> +
{#each current.memory.assets as asset, index (asset.id)} @@ -385,20 +386,23 @@ {(current.assetIndex + 1).toLocaleString($locale)}/{current.memory.assets.length.toLocaleString($locale)}

- ($videoViewerMuted = !$videoViewerMuted)} - /> + +
+ ($videoViewerMuted = !$videoViewerMuted)} + /> +
{#if galleryInView}
@@ -409,7 +413,6 @@ >
{#key current.asset.id} -
- {#if current.asset.isVideo} - - {:else} - {$getAltText(current.asset)} - {/if} -
+ {#if current.asset.isVideo} + + {:else} + + {/if} {/key}
{#if current.previous} -
+
{/if} {#if current.next} -
+
@@ -626,13 +617,12 @@
@@ -90,7 +88,6 @@ variant="ghost" icon={backIcon} size="large" - class={buttonClass} /> {/if} {@render leading?.()} From f28c0d912cae9226801251accb8f58dbf92f031e Mon Sep 17 00:00:00 2001 From: Gleb Khmyznikov Date: Tue, 17 Jun 2025 16:10:25 +0200 Subject: [PATCH 03/71] chore: update truenas repo link (#19195) Update truenas repo link --- docs/docs/install/truenas.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/install/truenas.md b/docs/docs/install/truenas.md index bd0dc7af5f..de110e00a1 100644 --- a/docs/docs/install/truenas.md +++ b/docs/docs/install/truenas.md @@ -9,7 +9,7 @@ This is a community contribution and not officially supported by the Immich team Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/). -**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).** +**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/apps/tree/master/trains/community/immich).** ::: Immich can easily be installed on TrueNAS Community Edition via the **Community** train application. From 8038ae1e7aa22eef8e373b088aa74e8cbdd17457 Mon Sep 17 00:00:00 2001 From: xCJPECKOVERx Date: Tue, 17 Jun 2025 10:19:30 -0400 Subject: [PATCH 04/71] fix(web): Asset viewer stack thumbnails overflow on top of asset (#19088) * - create constants for thet asset-viewer stack thumbnail sizes - use 2x selected thumbnail size to set the max-height of the stack-slideshow container. * - increase the stack-slideshow max-height as it's scrolled * Revert "- increase the stack-slideshow max-height as it's scrolled" This reverts commit da4614547acfb8853258071c1b192c8162b86424. * change asset stack veritcal scroll to horizontal scroll --- .../lib/components/asset-viewer/asset-viewer.svelte | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 34e00625b5..9ab33b7ce1 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -8,9 +8,9 @@ import { AssetAction, ProjectionType } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; + import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { isShowDetail } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { user } from '$lib/stores/user.store'; @@ -91,6 +91,8 @@ slideshowState, slideshowTransition, } = slideshowStore; + const stackThumbnailSize = 60; + const stackSelectedThumbnailSize = 65; let appearsInAlbums: AlbumResponseDto[] = $state([]); let shouldPlayMotionPhoto = $state(false); @@ -546,9 +548,9 @@ {@const stackedAssets = stack.assets}
-
+
{#each stackedAssets as stackedAsset (stackedAsset.id)}
handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} readonly - thumbnailSize={stackedAsset.id === asset.id ? 65 : 60} + thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize} showStackedIcon={false} disableLinkMouseOver /> From bc062da11b1885be94c352b8655fecf5aaf0f91b Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 17 Jun 2025 10:20:14 -0400 Subject: [PATCH 05/71] feat(web): wasm justified layout (#19150) * wasm justified layout * fix tests * redundant layout generation * raw position --- web/eslint.config.js | 1 + web/package-lock.json | 7 + web/package.json | 1 + .../gallery-viewer/gallery-viewer.svelte | 123 +++++------------- .../timeline-manager/day-group.svelte.ts | 5 +- .../timeline-manager.svelte.spec.ts | 14 +- .../timeline-manager.svelte.ts | 6 +- .../timeline-manager/viewer-asset.svelte.ts | 2 +- web/src/lib/utils/layout-utils.ts | 76 ++++++----- web/src/lib/utils/server.ts | 6 +- web/src/lib/utils/timeline-util.ts | 6 +- web/src/lib/utils/tunables.ts | 2 +- web/src/test-data/factories/asset-factory.ts | 2 +- 13 files changed, 106 insertions(+), 145 deletions(-) diff --git a/web/eslint.config.js b/web/eslint.config.js index 6b7b343ad1..78b87d24ef 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -118,6 +118,7 @@ export default typescriptEslint.config( 'unicorn/filename-case': 'off', 'unicorn/prefer-top-level-await': 'off', 'unicorn/import-style': 'off', + 'unicorn/no-for-loop': 'off', 'svelte/button-has-type': 'error', '@typescript-eslint/await-thenable': 'error', '@typescript-eslint/no-floating-promises': 'error', diff --git a/web/package-lock.json b/web/package-lock.json index 72b9eb6a7c..2888dfb71b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,6 +10,7 @@ "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", + "@immich/justified-layout-wasm": "^0.3.0", "@immich/sdk": "file:../open-api/typescript-sdk", "@immich/ui": "^0.22.7", "@mapbox/mapbox-gl-rtl-text": "0.2.3", @@ -1328,6 +1329,12 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@immich/justified-layout-wasm": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@immich/justified-layout-wasm/-/justified-layout-wasm-0.3.0.tgz", + "integrity": "sha512-eiKaPHHVsm0YL8SZVUuEs8miTT2uF3b9Tggve7QvIh+KF1Vq41EZFUeDI0RzLvoXAUnoAh37iFvYxaA9iXMHZA==", + "license": "AGPL-3" + }, "node_modules/@immich/sdk": { "resolved": "../open-api/typescript-sdk", "link": true diff --git a/web/package.json b/web/package.json index a06b12f826..2665904c62 100644 --- a/web/package.json +++ b/web/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", + "@immich/justified-layout-wasm": "^0.3.0", "@immich/sdk": "file:../open-api/typescript-sdk", "@immich/ui": "^0.22.7", "@mapbox/mapbox-gl-rtl-text": "0.2.3", 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 09998ed060..eb1869cfbb 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 @@ -16,7 +16,7 @@ import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils'; import { moveFocus } from '$lib/utils/focus-util'; import { handleError } from '$lib/utils/handle-error'; - import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils'; + import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { navigate } from '$lib/utils/navigation'; import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetVisibility, type AssetResponseDto } from '@immich/sdk'; @@ -27,7 +27,7 @@ import Portal from '../portal/portal.svelte'; interface Props { - assets: (TimelineAsset | AssetResponseDto)[]; + assets: TimelineAsset[] | AssetResponseDto[]; assetInteraction: AssetInteraction; disableAssetSelect?: boolean; showArchiveIcon?: boolean; @@ -62,91 +62,39 @@ let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore; - let geometry: CommonJustifiedLayout | undefined = $state(); - - $effect(() => { - const _assets = assets; - updateSlidingWindow(); - - const rowWidth = Math.floor(viewport.width); - const rowHeight = rowWidth < 850 ? 100 : 235; - - geometry = getJustifiedLayoutFromAssets(_assets, { + const geometry = $derived( + getJustifiedLayoutFromAssets(assets, { spacing: 2, - heightTolerance: 0.15, - rowHeight, - rowWidth, - }); - }); - - let assetLayouts = $derived.by(() => { - const assetLayout = []; - let containerHeight = 0; - let containerWidth = 0; - if (geometry) { - containerHeight = geometry.containerHeight; - containerWidth = geometry.containerWidth; - for (const [index, asset] of assets.entries()) { - const top = geometry.getTop(index); - const left = geometry.getLeft(index); - const width = geometry.getWidth(index); - const height = geometry.getHeight(index); - - const layoutTopWithOffset = top + pageHeaderOffset; - const layoutBottom = layoutTopWithOffset + height; - - const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top; - - const layout = { - asset, - top, - left, - width, - height, - display, - }; - - assetLayout.push(layout); - } - } - - return { - assetLayout, - containerHeight, - containerWidth, - }; - }); + heightTolerance: 0.3, + rowHeight: Math.floor(viewport.width) < 850 ? 100 : 235, + rowWidth: Math.floor(viewport.width), + }), + ); let currentViewAssetIndex = 0; let shiftKeyIsDown = $state(false); let lastAssetMouseEvent: TimelineAsset | null = $state(null); - let slidingWindow = $state({ top: 0, bottom: 0 }); + let slidingTop = $state(0); + let slidingBottom = $state(0); const updateSlidingWindow = () => { const v = $state.snapshot(viewport); const top = (document.scrollingElement?.scrollTop || 0) - slidingWindowOffset; - const bottom = top + v.height; - const w = { - top, - bottom, - }; - slidingWindow = w; + slidingTop = top; + slidingBottom = top + v.height; }; + $effect(updateSlidingWindow); const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true }); let lastIntersectedHeight = 0; $effect(() => { // notify we got to (near) the end of scroll const scrollPercentage = - ((slidingWindow.bottom - viewport.height) / (viewport.height - (document.scrollingElement?.clientHeight || 0))) * - 100; + (slidingBottom - viewport.height) / (viewport.height - (document.scrollingElement?.clientHeight || 0)); - if (scrollPercentage > 90) { - const intersectedHeight = geometry?.containerHeight || 0; - if (lastIntersectedHeight !== intersectedHeight) { - debouncedOnIntersected(); - lastIntersectedHeight = intersectedHeight; - } + if (scrollPercentage > 0.9 && lastIntersectedHeight !== geometry.containerHeight) { + debouncedOnIntersected(); + lastIntersectedHeight = geometry.containerHeight; } }); const viewAssetHandler = async (asset: TimelineAsset) => { @@ -256,7 +204,7 @@ isShowDeleteConfirmation = false; await deleteAssets( !(isTrashEnabled && !force), - (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))), + (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id)) as TimelineAsset[]), assetInteraction.selectedAssets, onReload, ); @@ -269,7 +217,7 @@ assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive, ); if (ids) { - assets = assets.filter((asset) => !ids.includes(asset.id)); + assets = assets.filter((asset) => !ids.includes(asset.id)) as TimelineAsset[]; deselectAllAssets(); } }; @@ -454,7 +402,7 @@ onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} - onscroll={() => updateSlidingWindow()} + onscroll={updateSlidingWindow} /> {#if isShowDeleteConfirmation} @@ -468,13 +416,12 @@ {#if assets.length > 0}
- {#each assetLayouts.assetLayout as layout, layoutIndex (layout.asset.id + '-' + layoutIndex)} - {@const currentAsset = layout.asset} - - {#if layout.display} + {#each assets as asset, i (asset.id + i)} + {#if geometry.getTop(i) + pageHeaderOffset < slidingBottom && geometry.getTop(i) + pageHeaderOffset + geometry.getHeight(i) > slidingTop} + {@const layout = geometry.getPosition(i)}
{ if (assetInteraction.selectionActive) { - handleSelectAssets(toTimelineAsset(currentAsset)); + handleSelectAssets(toTimelineAsset(asset)); return; } - void viewAssetHandler(toTimelineAsset(currentAsset)); + void viewAssetHandler(toTimelineAsset(asset)); }} - onSelect={() => handleSelectAssets(toTimelineAsset(currentAsset))} - onMouseEvent={() => assetMouseEventHandler(toTimelineAsset(currentAsset))} + onSelect={() => handleSelectAssets(toTimelineAsset(asset))} + onMouseEvent={() => assetMouseEventHandler(toTimelineAsset(asset))} {showArchiveIcon} - asset={toTimelineAsset(currentAsset)} - selected={assetInteraction.hasSelectedAsset(currentAsset.id)} - selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)} + asset={toTimelineAsset(asset)} + selected={assetInteraction.hasSelectedAsset(asset.id)} + selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} thumbnailWidth={layout.width} thumbnailHeight={layout.height} /> - {#if showAssetName && !isTimelineAsset(currentAsset)} + {#if showAssetName && !isTimelineAsset(asset)}
- {currentAsset.originalFileName} + {asset.originalFileName}
{/if}
diff --git a/web/src/lib/managers/timeline-manager/day-group.svelte.ts b/web/src/lib/managers/timeline-manager/day-group.svelte.ts index 2a949499ec..a43a71c511 100644 --- a/web/src/lib/managers/timeline-manager/day-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/day-group.svelte.ts @@ -1,7 +1,7 @@ import { AssetOrder } from '@immich/sdk'; import type { CommonLayoutOptions } from '$lib/utils/layout-utils'; -import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils'; +import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { plainDateTimeCompare } from '$lib/utils/timeline-util'; import type { MonthGroup } from './month-group.svelte'; @@ -153,8 +153,7 @@ export class DayGroup { this.width = geometry.containerWidth; this.height = assets.length === 0 ? 0 : geometry.containerHeight; for (let i = 0; i < this.viewerAssets.length; i++) { - const position = getPosition(geometry, i); - this.viewerAssets[i].position = position; + this.viewerAssets[i].position = geometry.getPosition(i); } } diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 05f8b7c7f7..9aa06d940d 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -2,8 +2,10 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte'; import { AbortError } from '$lib/utils'; import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util'; +import { initSync } from '@immich/justified-layout-wasm'; import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; +import { readFile } from 'node:fs/promises'; import { TimelineManager } from './timeline-manager.svelte'; import type { TimelineAsset } from './types'; @@ -23,6 +25,12 @@ function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset } describe('TimelineManager', () => { + beforeAll(async () => { + // needed for Node.js + const file = await readFile('node_modules/@immich/justified-layout-wasm/pkg/justified-layout-wasm_bg.wasm'); + initSync({ module: file }); + }); + beforeEach(() => { vi.resetAllMocks(); }); @@ -80,15 +88,15 @@ describe('TimelineManager', () => { expect(plainMonths).toEqual( expect.arrayContaining([ - expect.objectContaining({ year: 2024, month: 3, height: 185.5 }), - expect.objectContaining({ year: 2024, month: 2, height: 12_016 }), + expect.objectContaining({ year: 2024, month: 3, height: 353.5 }), + expect.objectContaining({ year: 2024, month: 2, height: 7786.452_636_718_75 }), expect.objectContaining({ year: 2024, month: 1, height: 286 }), ]), ); }); it('calculates timeline height', () => { - expect(timelineManager.timelineHeight).toBe(12_487.5); + expect(timelineManager.timelineHeight).toBe(8425.952_636_718_75); }); }); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 8aacd0a90a..e17082c816 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -377,13 +377,11 @@ export class TimelineManager { } createLayoutOptions() { - const viewportWidth = this.viewportWidth; - return { spacing: 2, - heightTolerance: 0.15, + heightTolerance: 0.3, rowHeight: this.#rowHeight, - rowWidth: Math.floor(viewportWidth), + rowWidth: Math.floor(this.viewportWidth), }; } diff --git a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts index b6e28df576..161cc049f1 100644 --- a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts +++ b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts @@ -18,7 +18,7 @@ export class ViewerAsset { return calculateViewerAssetIntersecting(store, positionTop, this.position.height); }); - position: CommonPosition | undefined = $state(); + position: CommonPosition | undefined = $state.raw(); asset: TimelineAsset = $state(); id: string = $derived(this.asset.id); diff --git a/web/src/lib/utils/layout-utils.ts b/web/src/lib/utils/layout-utils.ts index e60fa3b9e1..090a7168d5 100644 --- a/web/src/lib/utils/layout-utils.ts +++ b/web/src/lib/utils/layout-utils.ts @@ -1,16 +1,13 @@ -// import { TUNABLES } from '$lib/utils/tunables'; -// note: it's important that this is not imported in more than one file due to https://github.com/sveltejs/kit/issues/7805 -// import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm'; +import { TUNABLES } from '$lib/utils/tunables'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { getAssetRatio } from '$lib/utils/asset-utils'; -import { isTimelineAsset } from '$lib/utils/timeline-util'; +import { isTimelineAsset, isTimelineAssets } from '$lib/utils/timeline-util'; +import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm'; import type { AssetResponseDto } from '@immich/sdk'; import createJustifiedLayout from 'justified-layout'; -export type getJustifiedLayoutFromAssetsFunction = typeof getJustifiedLayoutFromAssets; - -// let useWasm = TUNABLES.LAYOUT.WASM; +const useWasm = TUNABLES.LAYOUT.WASM; export type CommonJustifiedLayout = { containerWidth: number; @@ -19,6 +16,12 @@ export type CommonJustifiedLayout = { getLeft(boxIdx: number): number; getWidth(boxIdx: number): number; getHeight(boxIdx: number): number; + getPosition(boxIdx: number): { + top: number; + left: number; + width: number; + height: number; + }; }; export type CommonLayoutOptions = { @@ -29,25 +32,32 @@ export type CommonLayoutOptions = { }; export function getJustifiedLayoutFromAssets( - assets: (TimelineAsset | AssetResponseDto)[], + assets: AssetResponseDto[] | TimelineAsset[], options: CommonLayoutOptions, -): CommonJustifiedLayout { - // if (useWasm) { - // return wasmJustifiedLayout(assets, options); - // } +) { + if (useWasm) { + return isTimelineAssets(assets) ? wasmLayoutFromTimeline(assets, options) : wasmLayoutFromDto(assets, options); + } + return justifiedLayout(assets, options); } -// commented out until a solution for top level awaits on safari is fixed -// function wasmJustifiedLayout(assets: AssetResponseDto[], options: LayoutOptions) { -// const aspectRatios = new Float32Array(assets.length); -// // eslint-disable-next-line unicorn/no-for-loop -// for (let i = 0; i < assets.length; i++) { -// const { width, height } = getAssetRatio(assets[i]); -// aspectRatios[i] = width / height; -// } -// return new JustifiedLayout(aspectRatios, options); -// } +function wasmLayoutFromTimeline(assets: TimelineAsset[], options: LayoutOptions) { + const aspectRatios = new Float32Array(assets.length); + for (let i = 0; i < assets.length; i++) { + aspectRatios[i] = assets[i].ratio; + } + return new JustifiedLayout(aspectRatios, options); +} + +function wasmLayoutFromDto(assets: AssetResponseDto[], options: LayoutOptions) { + const aspectRatios = new Float32Array(assets.length); + for (let i = 0; i < assets.length; i++) { + const { width, height } = getAssetRatio(assets[i]); + aspectRatios[i] = width / height; + } + return new JustifiedLayout(aspectRatios, options); +} type Geometry = ReturnType; class Adapter { @@ -88,9 +98,13 @@ class Adapter { getHeight(boxIdx: number) { return this.result.boxes[boxIdx]?.height; } + + getPosition(boxIdx: number): CommonPosition { + return this.result.boxes[boxIdx]; + } } -export function justifiedLayout(assets: (TimelineAsset | AssetResponseDto)[], options: CommonLayoutOptions) { +export function justifiedLayout(assets: TimelineAsset[] | AssetResponseDto[], options: CommonLayoutOptions) { const adapter = { targetRowHeight: options.rowHeight, containerWidth: options.rowWidth, @@ -105,25 +119,9 @@ export function justifiedLayout(assets: (TimelineAsset | AssetResponseDto)[], op return new Adapter(result); } -export const emptyGeometry = () => - new Adapter({ - containerHeight: 0, - widowCount: 0, - boxes: [], - }); - export type CommonPosition = { top: number; left: number; width: number; height: number; }; - -export function getPosition(geometry: CommonJustifiedLayout, boxIdx: number): CommonPosition { - const top = geometry.getTop(boxIdx); - const left = geometry.getLeft(boxIdx); - const width = geometry.getWidth(boxIdx); - const height = geometry.getHeight(boxIdx); - - return { top, left, width, height }; -} diff --git a/web/src/lib/utils/server.ts b/web/src/lib/utils/server.ts index 1c52274d23..b99ab80ec5 100644 --- a/web/src/lib/utils/server.ts +++ b/web/src/lib/utils/server.ts @@ -1,17 +1,17 @@ import { retrieveServerConfig } from '$lib/stores/server-config.store'; import { initLanguage } from '$lib/utils'; +import { init as initLayout } from '@immich/justified-layout-wasm'; import { defaults } from '@immich/sdk'; import { memoize } from 'lodash-es'; type Fetch = typeof fetch; -async function _init(fetch: Fetch) { +function _init(fetch: 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 initLanguage(); - await retrieveServerConfig(); + return Promise.all([initLayout(), initLanguage(), retrieveServerConfig()]); } export const init = memoize(_init, () => 'singlevalue'); diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index c3e41c01be..ca9dece6b2 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -190,8 +190,10 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): }; }; -export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset => - (unknownAsset as TimelineAsset).ratio !== undefined; +export const isTimelineAsset = (asset: AssetResponseDto | TimelineAsset): asset is TimelineAsset => 'ratio' in asset; + +export const isTimelineAssets = (assets: AssetResponseDto[] | TimelineAsset[]): assets is TimelineAsset[] => + assets.length === 0 || 'ratio' in assets[0]; export const plainDateTimeCompare = (ascending: boolean, a: TimelinePlainDateTime, b: TimelinePlainDateTime) => { const [aDateTime, bDateTime] = ascending ? [a, b] : [b, a]; diff --git a/web/src/lib/utils/tunables.ts b/web/src/lib/utils/tunables.ts index 6ce64ed041..c586e11957 100644 --- a/web/src/lib/utils/tunables.ts +++ b/web/src/lib/utils/tunables.ts @@ -19,7 +19,7 @@ const storage = browser }; export const TUNABLES = { LAYOUT: { - WASM: getBoolean(storage.getItem('LAYOUT.WASM'), false), + WASM: getBoolean(storage.getItem('LAYOUT.WASM'), true), }, TIMELINE: { INTERSECTION_EXPAND_TOP: getNumber(storage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500), diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index c2f03f9c6a..273bb6f97b 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -31,7 +31,7 @@ export const assetFactory = Sync.makeFactory({ export const timelineAssetFactory = Sync.makeFactory({ id: Sync.each(() => faker.string.uuid()), - ratio: Sync.each(() => faker.number.int()), + ratio: Sync.each((i) => 0.2 + ((i * 0.618_034) % 3.8)), // deterministic random float between 0.2 and 4.0 ownerId: Sync.each(() => faker.string.uuid()), thumbhash: Sync.each(() => faker.string.alphanumeric(28)), localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())), From 15c488ccd93d0a3f26413d6e1f02b6398a4927b9 Mon Sep 17 00:00:00 2001 From: xCJPECKOVERx Date: Tue, 17 Jun 2025 10:21:30 -0400 Subject: [PATCH 06/71] fix(web): MemoryStore does not initialize on direct navigation (#18947) * - no longer return early when navigating directly to memory-viewer * Update memory-viewer.svelte - remove early return from afterNavigate * lint From a0f44f147bda2383bf61254b2090f15533335924 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 17 Jun 2025 09:43:09 -0500 Subject: [PATCH 07/71] feat(mobile): ios widgets (#19148) * feat: working widgets * chore/feat: cleaned up API, added album picker to random widget * album filtering for requests * check album and throw if not found * fix app IDs and project configuration * switch to repository/service model for updating widgets * fix: remove home widget import * revert info.plist formatting changes * ran swift-format on widget code * more formatting changes (this time run from xcode) * show memory on widget picker snapshot * fix: dart changes from code review * fix: swift code review changes (not including task groups) * fix: use task groups to run image retrievals concurrently, get rid of do catch in favor of if let * chore: cleanup widget service in dart app * chore: format swift * fix: remove comma why does xcode not freak out over this >:( * switch to preview size for thumbnail * chore: cropped large image * fix: properly resize widgets so we dont OOM * fix: set app group on logout happens on first install * fix: stupid app ids * fix: revert back to thumbnail we are hitting OOM exceptions due to resizing, once we have on-the-fly resizing on server this can be upgraded * fix: more memory efficient resizing method, remove extraneous resize commands from API call * fix: random widget use 12 entries instead of 24 to save memory * fix: modify duration of entries to 20 minutes and only generate 10 at a time to avoid OOM * feat: toggle to show album name on random widget * Podfile lock --------- Co-authored-by: Alex --- mobile/ios/Podfile.lock | 6 + mobile/ios/Runner.xcodeproj/project.pbxproj | 226 +++++++++++++++++- .../Assets.xcassets/Contents.json | 6 + .../ios/WidgetExtension/EntryGenerators.swift | 58 +++++ .../ios/WidgetExtension/ImageWidgetView.swift | 90 +++++++ mobile/ios/WidgetExtension/ImmichAPI.swift | 219 +++++++++++++++++ mobile/ios/WidgetExtension/Info.plist | 11 + .../ios/WidgetExtension/UIImage+Resize.swift | 20 ++ mobile/ios/WidgetExtension/WidgetBundle.swift | 10 + .../WidgetExtension.entitlements | 10 + .../widgets/MemoryWidget.swift | 166 +++++++++++++ .../widgets/RandomWidget.swift | 170 +++++++++++++ mobile/lib/constants/constants.dart | 12 + mobile/lib/interfaces/widget.interface.dart | 5 + mobile/lib/providers/auth.provider.dart | 11 + .../lib/repositories/widget.repository.dart | 24 ++ mobile/lib/services/widget.service.dart | 40 ++++ mobile/pubspec.lock | 8 + mobile/pubspec.yaml | 1 + 19 files changed, 1092 insertions(+), 1 deletion(-) create mode 100644 mobile/ios/WidgetExtension/Assets.xcassets/Contents.json create mode 100644 mobile/ios/WidgetExtension/EntryGenerators.swift create mode 100644 mobile/ios/WidgetExtension/ImageWidgetView.swift create mode 100644 mobile/ios/WidgetExtension/ImmichAPI.swift create mode 100644 mobile/ios/WidgetExtension/Info.plist create mode 100644 mobile/ios/WidgetExtension/UIImage+Resize.swift create mode 100644 mobile/ios/WidgetExtension/WidgetBundle.swift create mode 100644 mobile/ios/WidgetExtension/WidgetExtension.entitlements create mode 100644 mobile/ios/WidgetExtension/widgets/MemoryWidget.swift create mode 100644 mobile/ios/WidgetExtension/widgets/RandomWidget.swift create mode 100644 mobile/lib/interfaces/widget.interface.dart create mode 100644 mobile/lib/repositories/widget.repository.dart create mode 100644 mobile/lib/services/widget.service.dart diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 36ec03dd0e..09bd36022b 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -58,6 +58,8 @@ PODS: - Flutter - geolocator_apple (1.2.0): - Flutter + - home_widget (0.0.1): + - Flutter - image_picker_ios (0.0.1): - Flutter - integration_test (0.0.1): @@ -144,6 +146,7 @@ DEPENDENCIES: - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`) + - home_widget (from `.symlinks/plugins/home_widget/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) @@ -201,6 +204,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/fluttertoast/ios" geolocator_apple: :path: ".symlinks/plugins/geolocator_apple/ios" + home_widget: + :path: ".symlinks/plugins/home_widget/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" integration_test: @@ -256,6 +261,7 @@ SPEC CHECKSUMS: flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 geolocator_apple: 1560c3c875af2a412242c7a923e15d0d401966ff + home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 3cbbf83f01..5cd040be79 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -17,12 +17,23 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; }; + F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + F0B57D3A2DF764BD00DC5BCC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */; }; + F0B57D3C2DF764BD00DC5BCC /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D3B2DF764BD00DC5BCC /* SwiftUI.framework */; }; + F0B57D492DF764BE00DC5BCC /* WidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; }; FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + F0B57D472DF764BE00DC5BCC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = F0B57D372DF764BD00DC5BCC; + remoteInfo = WidgetExtension; + }; FAC6F8982D287C890078CB2F /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -49,6 +60,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + F0B57D492DF764BE00DC5BCC /* WidgetExtension.appex in Embed Foundation Extensions */, FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; @@ -78,6 +90,9 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = ""; }; E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + F0B57D3B2DF764BD00DC5BCC /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; F7101BB0391A314774615E89 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = ""; }; FA9973382CF6DF4B000EF859 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; @@ -89,6 +104,16 @@ FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + F0B57D4D2DF764BE00DC5BCC /* Exceptions for "WidgetExtension" folder in "WidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = F0B57D372DF764BD00DC5BCC /* WidgetExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { isa = PBXFileSystemSynchronizedRootGroup; @@ -97,6 +122,14 @@ path = Sync; sourceTree = ""; }; + F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + F0B57D4D2DF764BE00DC5BCC /* Exceptions for "WidgetExtension" folder in "WidgetExtension" target */, + ); + path = WidgetExtension; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -108,6 +141,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F0B57D352DF764BD00DC5BCC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F0B57D3C2DF764BD00DC5BCC /* SwiftUI.framework in Frameworks */, + F0B57D3A2DF764BD00DC5BCC /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FAC6F88D2D287C890078CB2F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -137,6 +179,8 @@ children = ( 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */, 357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */, + F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */, + F0B57D3B2DF764BD00DC5BCC /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -167,6 +211,7 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, FAC6F8B62D287F120078CB2F /* ShareExtension */, + F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */, 97C146EF1CF9000F007C117D /* Products */, 0FB772A5B9601143383626CA /* Pods */, 1754452DD81DA6620E279E51 /* Frameworks */, @@ -178,6 +223,7 @@ children = ( 97C146EE1CF9000F007C117D /* Immich-Debug.app */, FAC6F8902D287C890078CB2F /* ShareExtension.appex */, + F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -234,6 +280,7 @@ ); dependencies = ( FAC6F8992D287C890078CB2F /* PBXTargetDependency */, + F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( B2CF7F8C2DDE4EBB00744BF6 /* Sync */, @@ -243,6 +290,26 @@ productReference = 97C146EE1CF9000F007C117D /* Immich-Debug.app */; productType = "com.apple.product-type.application"; }; + F0B57D372DF764BD00DC5BCC /* WidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = F0B57D4E2DF764BE00DC5BCC /* Build configuration list for PBXNativeTarget "WidgetExtension" */; + buildPhases = ( + F0B57D342DF764BD00DC5BCC /* Sources */, + F0B57D352DF764BD00DC5BCC /* Frameworks */, + F0B57D362DF764BD00DC5BCC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */, + ); + name = WidgetExtension; + productName = WidgetExtension; + productReference = F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; FAC6F88F2D287C890078CB2F /* ShareExtension */ = { isa = PBXNativeTarget; buildConfigurationList = FAC6F8A02D287C890078CB2F /* Build configuration list for PBXNativeTarget "ShareExtension" */; @@ -268,7 +335,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1600; + LastSwiftUpdateCheck = 1640; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -277,6 +344,9 @@ LastSwiftMigration = 1100; ProvisioningStyle = Automatic; }; + F0B57D372DF764BD00DC5BCC = { + CreatedOnToolsVersion = 16.4; + }; FAC6F88F2D287C890078CB2F = { CreatedOnToolsVersion = 16.0; ProvisioningStyle = Automatic; @@ -298,6 +368,7 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, FAC6F88F2D287C890078CB2F /* ShareExtension */, + F0B57D372DF764BD00DC5BCC /* WidgetExtension */, ); }; /* End PBXProject section */ @@ -314,6 +385,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F0B57D362DF764BD00DC5BCC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FAC6F88E2D287C890078CB2F /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -448,6 +527,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F0B57D342DF764BD00DC5BCC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; FAC6F88C2D287C890078CB2F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -459,6 +545,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F0B57D372DF764BD00DC5BCC /* WidgetExtension */; + targetProxy = F0B57D472DF764BE00DC5BCC /* PBXContainerItemProxy */; + }; FAC6F8992D287C890078CB2F /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FAC6F88F2D287C890078CB2F /* ShareExtension */; @@ -751,6 +842,129 @@ }; name = Release; }; + F0B57D4A2DF764BE00DC5BCC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2F67MQ8R79; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.Widget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F0B57D4B2DF764BE00DC5BCC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2F67MQ8R79; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.Widget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + F0B57D4C2DF764BE00DC5BCC /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2F67MQ8R79; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.Widget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; FAC6F89C2D287C890078CB2F /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */; @@ -900,6 +1114,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F0B57D4E2DF764BE00DC5BCC /* Build configuration list for PBXNativeTarget "WidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F0B57D4A2DF764BE00DC5BCC /* Debug */, + F0B57D4B2DF764BE00DC5BCC /* Release */, + F0B57D4C2DF764BE00DC5BCC /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; FAC6F8A02D287C890078CB2F /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/mobile/ios/WidgetExtension/Assets.xcassets/Contents.json b/mobile/ios/WidgetExtension/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/mobile/ios/WidgetExtension/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/ios/WidgetExtension/EntryGenerators.swift b/mobile/ios/WidgetExtension/EntryGenerators.swift new file mode 100644 index 0000000000..6c1e1d4118 --- /dev/null +++ b/mobile/ios/WidgetExtension/EntryGenerators.swift @@ -0,0 +1,58 @@ +import SwiftUI +import WidgetKit + +func buildEntry( + api: ImmichAPI, + asset: SearchResult, + dateOffset: Int, + subtitle: String? = nil +) + async throws -> ImageEntry +{ + let entryDate = Calendar.current.date( + byAdding: .minute, + value: dateOffset * 20, + to: Date.now + )! + let image = try await api.fetchImage(asset: asset) + return ImageEntry(date: entryDate, image: image, subtitle: subtitle) +} + +func generateRandomEntries( + api: ImmichAPI, + now: Date, + count: Int, + albumId: String? = nil, + subtitle: String? = nil +) + async throws -> [ImageEntry] +{ + + var entries: [ImageEntry] = [] + let albumIds = albumId != nil ? [albumId!] : [] + + let randomAssets = try await api.fetchSearchResults( + with: SearchFilters(size: count, albumIds: albumIds) + ) + + await withTaskGroup(of: ImageEntry?.self) { group in + for (dateOffset, asset) in randomAssets.enumerated() { + group.addTask { + return try? await buildEntry( + api: api, + asset: asset, + dateOffset: dateOffset, + subtitle: subtitle + ) + } + } + + for await result in group { + if let entry = result { + entries.append(entry) + } + } + } + + return entries +} diff --git a/mobile/ios/WidgetExtension/ImageWidgetView.swift b/mobile/ios/WidgetExtension/ImageWidgetView.swift new file mode 100644 index 0000000000..ff11133e51 --- /dev/null +++ b/mobile/ios/WidgetExtension/ImageWidgetView.swift @@ -0,0 +1,90 @@ +import SwiftUI +import WidgetKit + +struct ImageEntry: TimelineEntry { + let date: Date + var image: UIImage? + var subtitle: String? = nil + var error: WidgetError? = nil + + // Resizes the stored image to a maximum width of 450 pixels + mutating func resize() { + if (image == nil || image!.size.height < 450 || image!.size.width < 450 ) { + return + } + + image = image?.resized(toWidth: 450) + + if image == nil { + error = .unableToResize + } + } +} + +struct ImmichWidgetView: View { + var entry: ImageEntry + + func getErrorText(_ error: WidgetError?) -> String { + switch error { + case .noLogin: + return "Login to Immich" + + case .fetchFailed: + return "Unable to connect to your Immich instance" + + case .albumNotFound: + return "Album not found" + + default: + return "An unknown error occured" + } + } + + var body: some View { + if entry.image == nil { + VStack { + Image("LaunchImage") + Text(getErrorText(entry.error)) + .minimumScaleFactor(0.25) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + .padding(16) + } else { + ZStack(alignment: .leading) { + Color.clear.overlay( + Image(uiImage: entry.image!) + .resizable() + .scaledToFill() + ) + VStack { + Spacer() + if let subtitle = entry.subtitle { + Text(subtitle) + .foregroundColor(.white) + .padding(8) + .background(Color.black.opacity(0.6)) + .cornerRadius(8) + .font(.system(size: 16)) + } + } + .padding(16) + } + } + } +} + +#Preview( + as: .systemMedium, + widget: { + ImmichRandomWidget() + }, + timeline: { + let date = Date() + ImageEntry( + date: date, + image: UIImage(named: "ImmichLogo"), + subtitle: "1 year ago" + ) + } +) diff --git a/mobile/ios/WidgetExtension/ImmichAPI.swift b/mobile/ios/WidgetExtension/ImmichAPI.swift new file mode 100644 index 0000000000..4da610f1c7 --- /dev/null +++ b/mobile/ios/WidgetExtension/ImmichAPI.swift @@ -0,0 +1,219 @@ +import Foundation +import SwiftUI +import WidgetKit + +enum WidgetError: Error { + case noLogin + case fetchFailed + case unknown + case albumNotFound + case unableToResize +} + +enum AssetType: String, Codable { + case image = "IMAGE" + case video = "VIDEO" + case audio = "AUDIO" + case other = "OTHER" +} + +struct SearchResult: Codable { + let id: String + let type: AssetType +} + +struct SearchFilters: Codable { + var type: AssetType = .image + let size: Int + var albumIds: [String] = [] +} + +struct MemoryResult: Codable { + let id: String + var assets: [SearchResult] + let type: String + + struct MemoryData: Codable { + let year: Int + } + + let data: MemoryData +} + +struct Album: Codable { + let id: String + let albumName: String +} + +// MARK: API + +class ImmichAPI { + struct ServerConfig { + let serverEndpoint: String + let sessionKey: String + } + let serverConfig: ServerConfig + + init() async throws { + // fetch the credentials from the UserDefaults store that dart placed here + guard let defaults = UserDefaults(suiteName: "group.app.immich.share"), + let serverURL = defaults.string(forKey: "widget_server_url"), + let sessionKey = defaults.string(forKey: "widget_auth_token") + else { + throw WidgetError.noLogin + } + + if serverURL == "" || sessionKey == "" { + throw WidgetError.noLogin + } + + serverConfig = ServerConfig( + serverEndpoint: serverURL, + sessionKey: sessionKey + ) + } + + private func buildRequestURL( + serverConfig: ServerConfig, + endpoint: String, + params: [URLQueryItem] = [] + ) -> URL? { + guard let baseURL = URL(string: serverConfig.serverEndpoint) else { + fatalError("Invalid base URL") + } + + // Combine the base URL and API path + let fullPath = baseURL.appendingPathComponent( + endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + ) + + // Add the session key as a query parameter + var components = URLComponents( + url: fullPath, + resolvingAgainstBaseURL: false + ) + components?.queryItems = [ + URLQueryItem(name: "sessionKey", value: serverConfig.sessionKey) + ] + components?.queryItems?.append(contentsOf: params) + + return components?.url + } + + func fetchSearchResults(with filters: SearchFilters) async throws + -> [SearchResult] + { + // get URL + guard + let searchURL = buildRequestURL( + serverConfig: serverConfig, + endpoint: "/search/random" + ) + else { + throw URLError(.badURL) + } + + var request = URLRequest(url: searchURL) + request.httpMethod = "POST" + request.httpBody = try JSONEncoder().encode(filters) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, _) = try await URLSession.shared.data(for: request) + + // decode data + return try JSONDecoder().decode([SearchResult].self, from: data) + } + + func fetchMemory(for date: Date) async throws -> [MemoryResult] { + // get URL + let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())] + guard + let searchURL = buildRequestURL( + serverConfig: serverConfig, + endpoint: "/memories", + params: memoryParams + ) + else { + throw URLError(.badURL) + } + + var request = URLRequest(url: searchURL) + request.httpMethod = "GET" + + let (data, _) = try await URLSession.shared.data(for: request) + + // decode data + return try JSONDecoder().decode([MemoryResult].self, from: data) + } + + func fetchImage(asset: SearchResult) async throws -> UIImage { + let thumbnailParams = [URLQueryItem(name: "size", value: "preview")] + let assetEndpoint = "/assets/" + asset.id + "/thumbnail" + + guard + let fetchURL = buildRequestURL( + serverConfig: serverConfig, + endpoint: assetEndpoint, + params: thumbnailParams + ) + else { + throw URLError(.badURL) + } + + let (data, _) = try await URLSession.shared.data(from: fetchURL) + + guard let img = UIImage(data: data) else { + throw URLError(.badServerResponse) + } + + return img + } + + func fetchAlbums() async throws -> [Album] { + // get URL + guard + let searchURL = buildRequestURL( + serverConfig: serverConfig, + endpoint: "/albums" + ) + else { + throw URLError(.badURL) + } + + var request = URLRequest(url: searchURL) + request.httpMethod = "GET" + + let (data, _) = try await URLSession.shared.data(for: request) + + // decode data + return try JSONDecoder().decode([Album].self, from: data) + } +} + +// We need a shared cache for albums to efficiently handle the album picker queries +actor AlbumCache { + static let shared = AlbumCache() + + private var api: ImmichAPI? = nil + private var albums: [Album]? = nil + + func getAlbums(refresh: Bool = false) async throws -> [Album] { + // Check the API before we try to show cached albums + // Sometimes iOS caches this object and keeps it around + // even after nuking the timeline + + api = try? await ImmichAPI() + + guard api != nil else { + throw WidgetError.noLogin + } + + if let albums, !refresh { + return albums + } + + let fetched = try await api!.fetchAlbums() + albums = fetched + return fetched + } +} diff --git a/mobile/ios/WidgetExtension/Info.plist b/mobile/ios/WidgetExtension/Info.plist new file mode 100644 index 0000000000..0f118fb75e --- /dev/null +++ b/mobile/ios/WidgetExtension/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/mobile/ios/WidgetExtension/UIImage+Resize.swift b/mobile/ios/WidgetExtension/UIImage+Resize.swift new file mode 100644 index 0000000000..40bb9e2ace --- /dev/null +++ b/mobile/ios/WidgetExtension/UIImage+Resize.swift @@ -0,0 +1,20 @@ +// +// Utils.swift +// Runner +// +// Created by Alex Tran and Brandon Wees on 6/16/25. +// +import UIKit + +extension UIImage { + /// Crops the image to ensure width and height do not exceed maxSize. + /// Keeps original aspect ratio and crops excess equally from edges (center crop). + func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? { + let canvas = CGSize(width: width, height: CGFloat(ceil(width/size.width * size.height))) + let format = imageRendererFormat + format.opaque = isOpaque + return UIGraphicsImageRenderer(size: canvas, format: format).image { + _ in draw(in: CGRect(origin: .zero, size: canvas)) + } + } +} diff --git a/mobile/ios/WidgetExtension/WidgetBundle.swift b/mobile/ios/WidgetExtension/WidgetBundle.swift new file mode 100644 index 0000000000..2f125608e4 --- /dev/null +++ b/mobile/ios/WidgetExtension/WidgetBundle.swift @@ -0,0 +1,10 @@ +import SwiftUI +import WidgetKit + +@main +struct ImmichWidgetBundle: WidgetBundle { + var body: some Widget { + ImmichRandomWidget() + ImmichMemoryWidget() + } +} diff --git a/mobile/ios/WidgetExtension/WidgetExtension.entitlements b/mobile/ios/WidgetExtension/WidgetExtension.entitlements new file mode 100644 index 0000000000..4ad1a257d8 --- /dev/null +++ b/mobile/ios/WidgetExtension/WidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.app.immich.share + + + diff --git a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift new file mode 100644 index 0000000000..516bf6905e --- /dev/null +++ b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift @@ -0,0 +1,166 @@ +import AppIntents +import SwiftUI +import WidgetKit + +struct ImmichMemoryProvider: TimelineProvider { + func getYearDifferenceSubtitle(assetYear: Int) -> String { + let currentYear = Calendar.current.component(.year, from: Date.now) + // construct a "X years ago" subtitle + let yearDifference = currentYear - assetYear + + return "\(yearDifference) year\(yearDifference == 1 ? "" : "s") ago" + } + + func placeholder(in context: Context) -> ImageEntry { + ImageEntry(date: Date(), image: nil) + } + + func getSnapshot( + in context: Context, + completion: @escaping @Sendable (ImageEntry) -> Void + ) { + Task { + guard let api = try? await ImmichAPI() else { + completion(ImageEntry(date: Date(), image: nil, error: .noLogin)) + return + } + + guard let memories = try? await api.fetchMemory(for: Date.now) + else { + completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed)) + return + } + + for memory in memories { + if let asset = memory.assets.first(where: { $0.type == .image }), + var entry = try? await buildEntry( + api: api, + asset: asset, + dateOffset: 0, + subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year) + ) + { + entry.resize() + completion(entry) + return + } + } + + // fallback to random image + guard + let randomImage = try? await api.fetchSearchResults( + with: SearchFilters(size: 1) + ).first + else { + completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed)) + return + } + + guard + var imageEntry = try? await buildEntry( + api: api, + asset: randomImage, + dateOffset: 0 + ) + else { + completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed)) + return + } + + imageEntry.resize() + completion(imageEntry) + } + } + + func getTimeline( + in context: Context, + completion: @escaping @Sendable (Timeline) -> Void + ) { + Task { + var entries: [ImageEntry] = [] + let now = Date() + + guard let api = try? await ImmichAPI() else { + entries.append(ImageEntry(date: now, image: nil, error: .noLogin)) + completion(Timeline(entries: entries, policy: .atEnd)) + return + } + + let memories = try await api.fetchMemory(for: Date.now) + + await withTaskGroup(of: ImageEntry?.self) { group in + var totalAssets = 0 + + for memory in memories { + for asset in memory.assets { + if asset.type == .image && totalAssets < 12 { + group.addTask { + try? await buildEntry( + api: api, + asset: asset, + dateOffset: totalAssets, + subtitle: getYearDifferenceSubtitle( + assetYear: memory.data.year + ) + ) + } + + totalAssets += 1 + } + } + } + + for await result in group { + if let entry = result { + entries.append(entry) + } + } + } + + // If we didnt add any memory images (some failure occured or no images in memory), + // default to 12 hours of random photos + if entries.count == 0 { + entries.append( + contentsOf: (try? await generateRandomEntries( + api: api, + now: now, + count: 12 + )) ?? [] + ) + } + + // If we fail to fetch images, we still want to add an entry + // with a nil image and an error + if entries.count == 0 { + entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed)) + } + + // Resize all images to something that can be stored by iOS + for i in entries.indices { + entries[i].resize() + } + + completion(Timeline(entries: entries, policy: .atEnd)) + } + } +} + +struct ImmichMemoryWidget: Widget { + let kind: String = "com.immich.widget.memory" + + var body: some WidgetConfiguration { + StaticConfiguration( + kind: kind, + provider: ImmichMemoryProvider() + ) { entry in + ImmichWidgetView(entry: entry) + .containerBackground(.regularMaterial, for: .widget) + } + // allow image to take up entire widget + .contentMarginsDisabled() + + // widget picker info + .configurationDisplayName("Memories") + .description("See memories from Immich.") + } +} diff --git a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift new file mode 100644 index 0000000000..e3590b70ca --- /dev/null +++ b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift @@ -0,0 +1,170 @@ +import AppIntents +import SwiftUI +import WidgetKit + +// MARK: Widget Configuration + +extension Album: @unchecked Sendable, AppEntity, Identifiable { + + struct AlbumQuery: EntityQuery { + func entities(for identifiers: [Album.ID]) async throws -> [Album] { + // use cached albums to search + var albums = (try? await AlbumCache.shared.getAlbums()) ?? [] + albums.insert(NO_ALBUM, at: 0) + + return albums.filter { + identifiers.contains($0.id) + } + } + + func suggestedEntities() async throws -> [Album] { + var albums = (try? await AlbumCache.shared.getAlbums(refresh: true)) ?? [] + albums.insert(NO_ALBUM, at: 0) + + return albums + } + } + + static var defaultQuery = AlbumQuery() + static var typeDisplayRepresentation = TypeDisplayRepresentation( + name: "Album" + ) + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(albumName)") + } +} + +let NO_ALBUM = Album(id: "NONE", albumName: "None") + +struct RandomConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Select Album" } + static var description: IntentDescription { + "Choose an album to show images from" + } + + @Parameter(title: "Album", default: NO_ALBUM) + var album: Album? + + @Parameter(title: "Show Album Name", default: false) + var showAlbumName: Bool +} + +// MARK: Provider + +struct ImmichRandomProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> ImageEntry { + ImageEntry(date: Date(), image: nil) + } + + func snapshot( + for configuration: RandomConfigurationAppIntent, + in context: Context + ) async + -> ImageEntry + { + guard let api = try? await ImmichAPI() else { + return ImageEntry(date: Date(), image: nil, error: .noLogin) + } + + guard + let randomImage = try? await api.fetchSearchResults( + with: SearchFilters(size: 1) + ).first + else { + return ImageEntry(date: Date(), image: nil, error: .fetchFailed) + } + + guard + var entry = try? await buildEntry( + api: api, + asset: randomImage, + dateOffset: 0 + ) + else { + return ImageEntry(date: Date(), image: nil, error: .fetchFailed) + } + + entry.resize() + + return entry + } + + func timeline( + for configuration: RandomConfigurationAppIntent, + in context: Context + ) async + -> Timeline + { + var entries: [ImageEntry] = [] + let now = Date() + + // If we don't have a server config, return an entry with an error + guard let api = try? await ImmichAPI() else { + entries.append(ImageEntry(date: now, image: nil, error: .noLogin)) + return Timeline(entries: entries, policy: .atEnd) + } + + // nil if album is NONE or nil + let albumId = + configuration.album?.id != "NONE" ? configuration.album?.id : nil + var albumName: String? = albumId != nil ? configuration.album?.albumName : nil + + if albumId != nil { + // make sure the album exists on server, otherwise show error + guard let albums = try? await api.fetchAlbums() else { + entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed)) + return Timeline(entries: entries, policy: .atEnd) + } + + if !albums.contains(where: { $0.id == albumId }) { + entries.append(ImageEntry(date: now, image: nil, error: .albumNotFound)) + return Timeline(entries: entries, policy: .atEnd) + } + } + + entries.append( + contentsOf: (try? await generateRandomEntries( + api: api, + now: now, + count: 12, + albumId: albumId, + subtitle: configuration.showAlbumName ? albumName : nil + )) + ?? [] + ) + + // If we fail to fetch images, we still want to add an entry with a nil image and an error + if entries.count == 0 { + entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed)) + } + + // Resize all images to something that can be stored by iOS + for i in entries.indices { + entries[i].resize() + } + + return Timeline(entries: entries, policy: .atEnd) + } +} + +struct ImmichRandomWidget: Widget { + let kind: String = "com.immich.widget.random" + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: kind, + intent: RandomConfigurationAppIntent.self, + provider: ImmichRandomProvider() + ) { entry in + ImmichWidgetView(entry: entry) + .containerBackground(.regularMaterial, for: .widget) + } + // allow image to take up entire widget + .contentMarginsDisabled() + + // widget picker info + .configurationDisplayName("Random") + .description("View a random image from your library or a specific album.") + } +} diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 3d9d9a9063..6d98152efc 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -20,3 +20,15 @@ const String kSecuredPinCode = "secured_pin_code"; const int kTimelineNoneSegmentSize = 120; const int kTimelineAssetLoadBatchSize = 256; const int kTimelineAssetLoadOppositeSize = 64; + +// Widget keys +const String kWidgetAuthToken = "widget_auth_token"; +const String kWidgetServerEndpoint = "widget_server_url"; +const String appShareGroupId = "group.app.immich.share"; + +// add widget identifiers here for new widgets +// these are used to force a widget refresh +const List kWidgetNames = [ + 'com.immich.widget.random', + 'com.immich.widget.memory', +]; diff --git a/mobile/lib/interfaces/widget.interface.dart b/mobile/lib/interfaces/widget.interface.dart new file mode 100644 index 0000000000..f76fbef8de --- /dev/null +++ b/mobile/lib/interfaces/widget.interface.dart @@ -0,0 +1,5 @@ +abstract interface class IWidgetRepository { + Future saveData(String key, String value); + Future refresh(String name); + Future setAppGroupId(String appGroupId); +} diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 5207858f99..dfbd18953a 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; +import 'package:immich_mobile/services/widget.service.dart'; import 'package:immich_mobile/utils/hash.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -23,6 +24,7 @@ final authProvider = StateNotifierProvider((ref) { ref.watch(apiServiceProvider), ref.watch(userServiceProvider), ref.watch(secureStorageServiceProvider), + ref.watch(widgetServiceProvider), ); }); @@ -31,6 +33,7 @@ class AuthNotifier extends StateNotifier { final ApiService _apiService; final UserService _userService; final SecureStorageService _secureStorageService; + final WidgetService _widgetService; final _log = Logger("AuthenticationNotifier"); static const Duration _timeoutDuration = Duration(seconds: 7); @@ -40,6 +43,7 @@ class AuthNotifier extends StateNotifier { this._apiService, this._userService, this._secureStorageService, + this._widgetService, ) : super( AuthState( deviceId: "", @@ -76,6 +80,8 @@ class AuthNotifier extends StateNotifier { Future logout() async { try { await _secureStorageService.delete(kSecuredPinCode); + await _widgetService.clearCredentials(); + await _authService.logout(); } finally { await _cleanUp(); @@ -112,6 +118,11 @@ class AuthNotifier extends StateNotifier { }) async { await _apiService.setAccessToken(accessToken); + await _widgetService.writeCredentials( + Store.get(StoreKey.serverEndpoint), + accessToken, + ); + // Get the deviceid from the store if it exists, otherwise generate a new one String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid; diff --git a/mobile/lib/repositories/widget.repository.dart b/mobile/lib/repositories/widget.repository.dart new file mode 100644 index 0000000000..a813bc56d6 --- /dev/null +++ b/mobile/lib/repositories/widget.repository.dart @@ -0,0 +1,24 @@ +import 'package:home_widget/home_widget.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/widget.interface.dart'; + +final widgetRepositoryProvider = Provider((_) => WidgetRepository()); + +class WidgetRepository implements IWidgetRepository { + WidgetRepository(); + + @override + Future saveData(String key, String value) async { + await HomeWidget.saveWidgetData(key, value); + } + + @override + Future refresh(String name) async { + await HomeWidget.updateWidget(name: name, iOSName: name); + } + + @override + Future setAppGroupId(String appGroupId) async { + await HomeWidget.setAppGroupId(appGroupId); + } +} diff --git a/mobile/lib/services/widget.service.dart b/mobile/lib/services/widget.service.dart new file mode 100644 index 0000000000..bb7b367c27 --- /dev/null +++ b/mobile/lib/services/widget.service.dart @@ -0,0 +1,40 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/interfaces/widget.interface.dart'; +import 'package:immich_mobile/repositories/widget.repository.dart'; + +final widgetServiceProvider = Provider((ref) { + return WidgetService( + ref.watch(widgetRepositoryProvider), + ); +}); + +class WidgetService { + final IWidgetRepository _repository; + + WidgetService(this._repository); + + Future writeCredentials(String serverURL, String sessionKey) async { + await _repository.setAppGroupId(appShareGroupId); + await _repository.saveData(kWidgetServerEndpoint, serverURL); + await _repository.saveData(kWidgetAuthToken, sessionKey); + + // wait 3 seconds to ensure the widget is updated, dont block + Future.delayed(const Duration(seconds: 3), refreshWidgets); + } + + Future clearCredentials() async { + await _repository.setAppGroupId(appShareGroupId); + await _repository.saveData(kWidgetServerEndpoint, ""); + await _repository.saveData(kWidgetAuthToken, ""); + + // wait 3 seconds to ensure the widget is updated, dont block + Future.delayed(const Duration(seconds: 3), refreshWidgets); + } + + Future refreshWidgets() async { + for (final name in kWidgetNames) { + await _repository.refresh(name); + } + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 79f2901b7d..3be12d497c 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -863,6 +863,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + home_widget: + dependency: "direct main" + description: + name: home_widget + sha256: ad9634ef5894f3bac73f04d59e2e5151a39798f49985399fd928dadc828d974a + url: "https://pub.dev" + source: hosted + version: "0.8.0" hooks_riverpod: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 9980622185..a70ae25bfa 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: fluttertoast: ^8.2.12 geolocator: ^14.0.0 hooks_riverpod: ^2.6.1 + home_widget: ^0.8.0 http: ^1.3.0 image_picker: ^1.1.2 intl: ^0.19.0 From 0684a3ada48817af98fe229509294fcd59d6c24c Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Tue, 17 Jun 2025 17:07:20 +0200 Subject: [PATCH 08/71] chore(web): update translations (#19127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Antonio Vazquez Co-authored-by: Bezruchenko Simon Co-authored-by: Celeste Cossard Co-authored-by: Dag Stuan Co-authored-by: DevServs Co-authored-by: Felipe Garcia Co-authored-by: Felipe Simões Co-authored-by: Fjuro Co-authored-by: Happy Co-authored-by: Hurricane-32 Co-authored-by: Indrek Haav Co-authored-by: Ivan Dimitrov Co-authored-by: Jozef Gaal Co-authored-by: Leo Bottaro Co-authored-by: MSDNicrosoft Co-authored-by: Malhelo Co-authored-by: Mateusz779 Co-authored-by: Matjaž T Co-authored-by: Matteo De Carli Co-authored-by: MattiaPell Co-authored-by: Mārtiņš Bruņenieks Co-authored-by: Nick Huang Co-authored-by: Niko Savola Co-authored-by: Philipp Burndorfer Co-authored-by: Ponas Co-authored-by: Sylvain Pichon Co-authored-by: Taiki M Co-authored-by: Theodoor van Donge Co-authored-by: Tijs-B Co-authored-by: User 123456789 Co-authored-by: Vegard Fladby Co-authored-by: albanobattistella Co-authored-by: drshounak Co-authored-by: manosrh Co-authored-by: naroou Co-authored-by: oopzzozzo Co-authored-by: pyccl Co-authored-by: waclaw66 Co-authored-by: Àlex Bravo Co-authored-by: Вячеслав Лукьяненко --- i18n/ar.json | 1 - i18n/bg.json | 162 +++++++++++++++++++++++++++------------- i18n/bn.json | 4 +- i18n/ca.json | 39 +++++----- i18n/cs.json | 8 +- i18n/da.json | 1 - i18n/de.json | 7 +- i18n/el.json | 45 +++++++++-- i18n/es.json | 18 +++-- i18n/et.json | 8 +- i18n/fa.json | 1 - i18n/fi.json | 7 +- i18n/fr.json | 84 +++++++++++---------- i18n/gl.json | 1 - i18n/he.json | 1 - i18n/hi.json | 1 - i18n/hr.json | 1 - i18n/hu.json | 1 - i18n/id.json | 1 - i18n/it.json | 26 +++++-- i18n/ja.json | 9 ++- i18n/ko.json | 1 - i18n/lt.json | 106 +++++++++++++++++++++++++- i18n/lv.json | 23 +++++- i18n/ms.json | 1 - i18n/nb_NO.json | 28 +++++-- i18n/nl.json | 10 ++- i18n/pl.json | 8 +- i18n/pt.json | 8 +- i18n/pt_BR.json | 25 +++++-- i18n/ro.json | 1 - i18n/ru.json | 8 +- i18n/sk.json | 13 +++- i18n/sl.json | 10 ++- i18n/sr_Cyrl.json | 1 - i18n/sr_Latn.json | 1 - i18n/sv.json | 1 - i18n/ta.json | 1 - i18n/te.json | 1 - i18n/th.json | 1 - i18n/tr.json | 1 - i18n/uk.json | 28 +++++-- i18n/vi.json | 1 - i18n/zh_Hant.json | 11 ++- i18n/zh_SIMPLIFIED.json | 51 +++++++++---- 45 files changed, 548 insertions(+), 218 deletions(-) diff --git a/i18n/ar.json b/i18n/ar.json index 0791e3aeaa..67f7752ae1 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -232,7 +232,6 @@ "storage_template_migration_info": "تغييرات القالب ستنطبق فقط على المحتويات الجديدة. لتطبيق القالب على المحتويات التي تم رفعها سابقًا، قم بتشغيل {job}.", "storage_template_migration_job": "وظيفة تهجير قالب التخزين", "storage_template_more_details": "لمزيد من التفاصيل حول هذه الميزة، يرجى الرجوع إلى Storage Template وimplications", - "storage_template_onboarding_description": "عند تفعيل هذه الميزة، سيقوم بتنظيم الملفات تلقائيًا بناءً على قالب محدد من قبل المستخدم. بسبب مشاكل الاستقرار، تم تعطيل الميزة افتراضيًا. للمزيد من المعلومات، يرجى الرجوع إلى الوثائق.", "storage_template_path_length": "الحد التقريبي لطول المسار: {length, number}/{limit, number}", "storage_template_settings": "قالب التخزين", "storage_template_settings_description": "إدارة هيكل المجلد واسم الملف للأصول المرفوعة", diff --git a/i18n/bg.json b/i18n/bg.json index 1ec0dc0cae..2df3dd927d 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "Добавени {count, number} към любими", "admin": { "add_exclusion_pattern_description": "Добави модели за изключване. Поддържа се \"globbing\" с помощта на *, ** и ?. За да игнорирате всички файлове в директория с име \"Raw\", използвайте \"**/Raw/**\". За да игнорирате всички файлове, завършващи на \".tif\", използвайте \"**/*.tif\". За да игнорирате абсолютен път, използвайте \"/path/to/ignore/**\".", + "admin_user": "Администратор", "asset_offline_description": "Този външен библиотечен елемент не може да бъде открит на диска и е преместен в кошчето за боклук. Ако файлът е преместен в библиотеката, проверете вашата история за нов съответстващ елемент. За да възстановите елемента, моля проверете дали файловият път отдолу може да бъде достъпен от Immich и сканирайте библиотеката.", "authentication_settings": "Настройки за удостоверяване", "authentication_settings_description": "Управление на парола, OAuth и други настройки за удостоверяване", @@ -52,7 +53,7 @@ "confirm_email_below": "За потвърждение, моля въведете \"{email}\" отдолу", "confirm_reprocess_all_faces": "Сигурни ли сте, че искате да се обработят лицата отново? Това ще изчисти всички именувани хора.", "confirm_user_password_reset": "Сигурни ли сте, че искате да нулирате паролата на {user}?", - "confirm_user_pin_code_reset": "Наистина ли искаш да смениш PIN-кода на потребителя {user}?", + "confirm_user_pin_code_reset": "Наистина ли искате да смените PIN кода на потребителя {user}?", "create_job": "Създайте задача", "cron_expression": "Cron израз", "cron_expression_description": "Настрой интервала на сканиране използвайки cron формата. За повече информация Crontab Guru", @@ -170,7 +171,7 @@ "note_apply_storage_label_previous_assets": "Забележка: За да приложите етикета за съхранение към предварително качени файлове, стартирайте", "note_cannot_be_changed_later": "ВНИМАНИЕ: Това не може да бъде променено по-късно!", "notification_email_from_address": "От адрес", - "notification_email_from_address_description": "Електронна поща на изпращача, например: \"Immich Photo Server \". Използвай адрес, от който може да изпращаш имейли.", + "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 сертификат (не се препоръчва)", @@ -179,7 +180,7 @@ "notification_email_sent_test_email_button": "Изпрати тестов имейл и запази", "notification_email_setting_description": "Настройки за изпращане на имейл известия", "notification_email_test_email": "Изпрати тестов имейл", - "notification_email_test_email_failed": "Неуспешно изпращане на тестов имейл, провери променливите", + "notification_email_test_email_failed": "Неуспешно изпращане на тестов имейл, проверете настройките", "notification_email_test_email_sent": "Тестов имейл беше изпратен на {email}. Проверете входящата си пощa.", "notification_email_username_description": "Потребителско име за удостоверяване пред имейл сървъра", "notification_enable_email_notifications": "Включване на имейл известията", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "Заявка за квота за съхранение", "oauth_storage_quota_claim_description": "Автоматично задайте квотата за съхранение на потребителя със стойността от тази заявка.", "oauth_storage_quota_default": "Стандартна квота за съхранение (GiB)", - "oauth_storage_quota_default_description": "Квота в GiB, която да се използва, когато не е предоставена заявка (Въведете 0 за неограничена квота).", + "oauth_storage_quota_default_description": "Квота в GiB, която да се използва, когато не е посочено друго.", "oauth_timeout": "Време на изчакване при заявка", "oauth_timeout_description": "Време за изчакване на отговор на заявка, в милисекунди", "password_enable_description": "Влизане с имейл и парола", @@ -243,7 +244,7 @@ "storage_template_migration_info": "Шаблона ще преобразува всички разширения на имената на файловете в долен регистър. Промените в шаблоните ще се прилагат само за нови елементи. За да приложите принудително шаблона към вече качени елементи, изпълнете {job}.", "storage_template_migration_job": "Задача за миграция на шаблона за съхранение", "storage_template_more_details": "За повече подробности относно тази функция се обърнете към шаблона Storage Template и неговите последствия ", - "storage_template_onboarding_description": "Когато е активирана, тази функция ще организира автоматично файлове въз основа на дефиниран от потребителя шаблон. Поради проблеми със стабилността, функцията е изключена по подразбиране. За повече информация, моля, вижте документацията.", + "storage_template_onboarding_description_v2": "Когато е разрешена, тази функция ще организира автоматично файловете, според шаблон, дефиниран от потребителя. За допълнителна информация, моля вижте документацията.", "storage_template_path_length": "Ограничение на дължината на пътя: {length, number}/{limit, number}", "storage_template_settings": "Шаблон за съхранение", "storage_template_settings_description": "Управление на структурата на папките и името на файла за качване", @@ -356,7 +357,7 @@ "admin_password": "Администраторска парола", "administration": "Администрация", "advanced": "Разширено", - "advanced_settings_enable_alternate_media_filter_subtitle": "При синхронизация, използвай тази опция като филтър, основан на промяна на даден критерии. Опитай само в случай, че приложението има проблем с откриване на всички албуми.", + "advanced_settings_enable_alternate_media_filter_subtitle": "При синхронизация, използвайте тази опция като филтър, основан на промяна на даден критерии. Опитайте само в случай, че приложението има проблем с откриване на всички албуми.", "advanced_settings_enable_alternate_media_filter_title": "[ЕКСПЕРИМЕНТАЛНО] Използвай филтъра на алтернативното устройство за синхронизация на албуми", "advanced_settings_log_level_title": "Ниво на запис в дневника: {level}", "advanced_settings_prefer_remote_subtitle": "Някои устройства са твърде бавни за да генерират миниатюри. Активирай тази опция за да се зареждат винаги от сървъра.", @@ -392,7 +393,7 @@ "album_updated_setting_description": "Получавайте известие по имейл, когато споделен албум има нови файлове", "album_user_left": "Напусна {album}", "album_user_removed": "Премахнат {user}", - "album_viewer_appbar_delete_confirm": "Сигурен ли си, че искаш да изтриеш този албум от своя профил?", + "album_viewer_appbar_delete_confirm": "Сигурни ли сте, че искате да изтриете този албум от своя профил?", "album_viewer_appbar_share_err_delete": "Неуспешно изтриване на албум", "album_viewer_appbar_share_err_leave": "Неуспешно напускане на албум", "album_viewer_appbar_share_err_remove": "Проблем при пермахване на обекти от албума", @@ -464,9 +465,12 @@ "assets_added_count": "Добавено {count, plural, one {# asset} other {# assets}}", "assets_added_to_album_count": "Добавен(и) са {count, plural, one {# актив} other {# актива}} в албума", "assets_added_to_name_count": "Добавен(и) са {count, plural, one {# актив} other {# актива}} към {hasName, select, true {{name}} other {нов албум}}", + "assets_cannot_be_added_to_album_count": "{count, plural, one {Обекта не може да се добави} other {Обектите не може да се добавят}} в албума", "assets_count": "{count, plural, one {# актив} other {# актива}}", "assets_deleted_permanently": "{count} обекта са изтрити завинаги", "assets_deleted_permanently_from_server": "{count} обекта са изтити от Immich сървъра завинаги", + "assets_downloaded_failed": "{count, plural, one {Зареден # файл} many {Заредени # файла} other {заредени # файла}}, {error} - неуспешно", + "assets_downloaded_successfully": "Успешно {count, plural, one {е качен # файл} many {са качени # файла} 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 {# актива}}", @@ -505,7 +509,7 @@ "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": "Само при зареждане", @@ -586,8 +590,8 @@ "cannot_merge_people": "Не може да обединява хора", "cannot_undo_this_action": "Не можете да отмените това действие!", "cannot_update_the_description": "Описанието не може да бъде актуализирано", - "cast": "Промяна на регистъра", - "cast_description": "Настройка на наличните цели за промяна на регистъра", + "cast": "Поточно предаване", + "cast_description": "Настройка на наличните цели за предаване", "change_date": "Промени датата", "change_description": "Промени описанието", "change_display_order": "Промени реда на показване", @@ -598,11 +602,11 @@ "change_password": "Промени паролата", "change_password_description": "Това е или първият път, когато влизате в системата, или е направена заявка за промяна на паролата ви. Моля, въведете новата парола по-долу.", "change_password_form_confirm_password": "Потвърди паролата", - "change_password_form_description": "Здравей {name},\n\nТова или е първото ти вписване в системата или има подадена заявка за смяна на паролата. Моля, въведи нова парола в полето по-долу.", + "change_password_form_description": "Здравейте {name},\n\nТова или е първото ви вписване в системата или има подадена заявка за смяна на паролата. Моля, въведете нова парола в полето по-долу.", "change_password_form_new_password": "Нова парола", "change_password_form_password_mismatch": "Паролите не съвпадат", "change_password_form_reenter_new_password": "Повтори новата парола", - "change_pin_code": "Смени PIN-кода", + "change_pin_code": "Смени PIN кода", "change_your_password": "Променете паролата си", "changed_visibility_successfully": "Видимостта е променена успешно", "check_corrupt_asset_backup": "Провери за повредени архивни копия", @@ -635,17 +639,17 @@ "comments_and_likes": "Коментари и харесвания", "comments_are_disabled": "Коментарите са деактивирани", "common_create_new_album": "Създай нов албум", - "common_server_error": "Моля, провери мрежовата връзка, убеди се, че сървъра е достъпен и версиите на сървъра и приложението са съвместими.", + "common_server_error": "Моля, проверете мрежовата връзка, убедете се, че сървъра е достъпен и версиите на сървъра и приложението са съвместими.", "completed": "Завършено", "confirm": "Потвърди", "confirm_admin_password": "Потвърждаване на паролата на администратора", "confirm_delete_face": "Сигурни ли сте, че искате да изтриете лицето на {name} от актива?", "confirm_delete_shared_link": "Сигурни ли сте, че искате да изтриете тази споделена връзка?", "confirm_keep_this_delete_others": "Всички останали файлове в стека ще бъдат изтрити, с изключение на този файл. Сигурни ли сте, че искате да продължите?", - "confirm_new_pin_code": "Потвърди новия PIN-код", + "confirm_new_pin_code": "Потвърди новия PIN код", "confirm_password": "Потвърдете паролата", - "confirm_tag_face": "Искаш ли да отбележиш това лице като {name}?", - "confirm_tag_face_unnamed": "Искаш ли да отбележиш това лице?", + "confirm_tag_face": "Искате ли да отбележите това лице като {name}?", + "confirm_tag_face_unnamed": "Искате ли да отбележите това лице?", "connected_device": "Свързано устройство", "connected_to": "Свързан към", "contain": "В рамките на", @@ -692,7 +696,7 @@ "crop": "Изрежи", "curated_object_page_title": "Неща", "current_device": "Текущо устройство", - "current_pin_code": "Сегашен PIN-код", + "current_pin_code": "Сегашен PIN код", "current_server_address": "Настоящ адрес на сървъра", "custom_locale": "Персонализиран локал", "custom_locale_description": "Форматиране на дати и числа в зависимост от езика и региона", @@ -713,7 +717,7 @@ "deduplication_info": "Информация за дедупликацията", "deduplication_info_description": "За автоматично предварително избиране на ресурси и премахване на дубликати на едро, разглеждаме:", "default_locale": "Локализация по подразбиране", - "default_locale_description": "Форматиране на дати и числа в зависимост от местоположението на браузъра", + "default_locale_description": "Форматиране на дати и числа в зависимост от езиковата настройка на браузъра", "delete": "Изтрий", "delete_album": "Изтрий албум", "delete_api_key_prompt": "Сигурни ли сте, че искате да изтриете този API ключ?", @@ -813,13 +817,13 @@ "empty_trash": "Изпразване на кош", "empty_trash_confirmation": "Сигурни ли сте, че искате да изпразните кошчето? Това ще премахне всичко в кошчето за постоянно от Immich.\nНе можете да отмените това действие!", "enable": "Включване", - "enable_biometric_auth_description": "Въведи своя ПИН-код, за да разрешиш биометрично удостоверяване", + "enable_biometric_auth_description": "Въведете вашия PIN код, за да разрешите биометрично удостоверяване", "enabled": "Включено", "end_date": "Крайна дата", "enqueued": "Наредено в опашката", "enter_wifi_name": "Въведи име на Wi-Fi", - "enter_your_pin_code": "Въведи твоя PIN-код", - "enter_your_pin_code_subtitle": "Въведи твоя PIN-код, за да достъпиш заключена папка", + "enter_your_pin_code": "Въведете вашия PIN код", + "enter_your_pin_code_subtitle": "Въвеждане на PIN код, за достъп до заключена папка", "error": "Грешка", "error_change_sort_album": "Неуспешна промяна на реда на сортиране на албум", "error_delete_face": "Грешка при изтриване на лице от актива", @@ -919,7 +923,7 @@ "unable_to_remove_partner": "Неуспешно премахване на партньор", "unable_to_remove_reaction": "Неуспешно премахване на реакцията", "unable_to_reset_password": "Неуспешно смяна на паролата", - "unable_to_reset_pin_code": "Неуспешно нулиране на PIN-кода", + "unable_to_reset_pin_code": "Неуспешно нулиране на PIN кода", "unable_to_resolve_duplicate": "Неуспешно справяне с дублирането", "unable_to_restore_assets": "Неуспешно възстановяване на елементи", "unable_to_restore_trash": "Неуспешно възстановяване от кошчето", @@ -1004,7 +1008,7 @@ "gcast_enabled_description": "За да работи тази функция зарежда външни ресурси от Google.", "general": "Общи", "get_help": "Помощ", - "get_wifiname_error": "Неуспешно получаване името на Wi-Fi мрежата. Моля, убеди се, че са предоставени нужните разрешения на приложението и има връзка с Wi-Fi", + "get_wifiname_error": "Неуспешно получаване името на Wi-Fi мрежата. Моля, убедете се, че са предоставени нужните разрешения на приложението и има връзка с Wi-Fi", "getting_started": "Как да започнем", "go_back": "Връщане назад", "go_to_folder": "Отиди в папката", @@ -1043,7 +1047,7 @@ "home_page_delete_remote_err_local": "Локални обекти не могат да се изтриват от сървъра, пропускане", "home_page_favorite_err_local": "Локални обекти все още не могат да се правят любими, пропускане", "home_page_favorite_err_partner": "Партньорски обекти все още не могат да се правят любими, пропускане", - "home_page_first_time_notice": "Ако за първи път използваш приложението, моля избери албум за архивиране, за да може обектите от времевата линия да се записват в него", + "home_page_first_time_notice": "Ако за първи път използвате приложението, моля изберете албум за архивиране, за да може обектите от времевата линия да се записват в него", "home_page_locked_error_local": "Локални обекти не могат да се преместят в заключена папка, пропускане", "home_page_locked_error_partner": "Партньорски обекти не могат да се преместят в заключена папка, пропускане", "home_page_share_err_local": "Локални обекти не могат да се споделят чрез връзка, пропускане", @@ -1094,6 +1098,7 @@ "ios_debug_info_last_sync_at": "Синхронизирано на {dateTime}", "ios_debug_info_no_processes_queued": "Няма фонови процеси в опашката", "ios_debug_info_no_sync_yet": "Все още не е изпълнявана задача за фонова синхронизация", + "ios_debug_info_processes_queued": "{count, plural, one {{count} фонов процес} many {{count} фонови процеса} other {{count} фонови процеса}} в опашката", "ios_debug_info_processing_ran_at": "Започната обработка на {dateTime}", "items_count": "{count, plural, one {# елемент} other {# елементи}}", "jobs": "Задачи", @@ -1103,7 +1108,7 @@ "kept_this_deleted_others": "Запази този елемент и другите изтрити {count, plural, one {# елемент} other {# елемента}}", "keyboard_shortcuts": "Бързи клавишни комбинации", "language": "Език", - "language_no_results_subtitle": "Опитай да коригираш термина си за търсене", + "language_no_results_subtitle": "Опитайте да коригирате термина си за търсене", "language_no_results_title": "Не са намерени езици", "language_search_hint": "Търсене на езици...", "language_setting_description": "Изберете предпочитан език", @@ -1138,13 +1143,14 @@ "location_permission_content": "За да работи функцията автоматично превключване, Immich се нуждае от разрешение за точно местоположение, за да може да чете името на текущата Wi-Fi мрежа", "location_picker_choose_on_map": "Избери на карта", "location_picker_latitude_error": "Въведи правилна ширина", - "location_picker_latitude_hint": "Въведи ширината тук", + "location_picker_latitude_hint": "Въведете географска ширина тук", "location_picker_longitude_error": "Въведи правилна дължина", - "location_picker_longitude_hint": "Въведи дължината тук", + "location_picker_longitude_hint": "Въведете географска дължина тук", "lock": "Заключи", "locked_folder": "Заключена папка", "log_out": "Излизане", "log_out_all_devices": "Излизане с всички устройства", + "logged_in_as": "Вписан като {user}", "logged_out_all_devices": "Успешно излизане от всички устройства", "logged_out_device": "Успешно излизане от устройство", "login": "Вписване", @@ -1161,8 +1167,8 @@ "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": "Грешка при договаряне на връзката със сървъра. Ако използваш самоподписан сертификат, разреши в настройкте използване на самоподписан сертификат.", + "login_form_failed_login": "Грешка при вписване, проверете URL, имейла и паролата", + "login_form_handshake_exception": "Грешка при договаряне на връзката със сървъра. Ако използвате самоподписан сертификат, разрешете в настройкте използване на самоподписан сертификат.", "login_form_password_hint": "парола", "login_form_save_login": "Остани вписан", "login_form_server_empty": "Въведи URL на сървъра.", @@ -1192,12 +1198,12 @@ "map_cannot_get_user_location": "Не можах да получа местоположението", "map_location_dialog_yes": "Да", "map_location_picker_page_use_location": "Използвай това местоположение", - "map_location_service_disabled_content": "За да се показват обектите от текущото място, трябва да бъде включена услугата за местоположение. Искаш ли да я включиш сега?", + "map_location_service_disabled_content": "За да се показват обектите от текущото място, трябва да бъде включена услугата за местоположение. Искате ли да я включите сега?", "map_location_service_disabled_title": "Услугата за местоположение е изключена", "map_marker_for_images": "Маркери на картата за снимки направени в {city}, {country}", "map_marker_with_image": "Маркер на картата с изображение", "map_no_assets_in_bounds": "Няма снимки от този район", - "map_no_location_permission_content": "За да се показват обектите от текущото място, трябва разрешение за определяне на местоположението. Искаш ли да предоставиш разрешение сега?", + "map_no_location_permission_content": "За да се показват обектите от текущото място, трябва разрешение за определяне на местоположението. Искате ли да предоставите разрешение сега?", "map_no_location_permission_title": "Отказан достъп до местоположение", "map_settings": "Настройки на картата", "map_settings_dark_mode": "Тъмен режим", @@ -1242,6 +1248,8 @@ "move_off_locked_folder": "Извади от заключената папка", "move_to_locked_folder": "Премести в заключена папка", "move_to_locked_folder_confirmation": "Тези снимки и видеа ще бъдат изтрити от всички албуми и ще са достъпни само в заключената папка", + "moved_to_archive": "{count, plural, one {# обект е преместен} many {# обекта са преместени} other {# обекта са преместени}} в архива", + "moved_to_library": "{count, plural, one {# обект е преместен} many {# обекта са преместени} other {# обекта са преместени}} в библиотеката", "moved_to_trash": "Преместено в кошчето", "multiselect_grid_edit_date_time_err_read_only": "Не може да се редактира датата на обект само за четене, пропускане", "multiselect_grid_edit_gps_err_read_only": "Не може да се редактира местоположението на обект само за четене, пропускане", @@ -1257,14 +1265,14 @@ "new_password": "Нова парола", "new_person": "Нов човек", "new_pin_code": "Нов PIN код", - "new_pin_code_subtitle": "Това е първи достъп до заключена папка. Създай PIN код за защитен достъп до тази страница", + "new_pin_code_subtitle": "Това е първи достъп до заключена папка. Създайте PIN код за защитен достъп до тази страница", "new_user_created": "Създаден нов потребител", "new_version_available": "НАЛИЧНА НОВА ВЕРСИЯ", "newest_first": "Най-новите първи", "next": "Следващо", "next_memory": "Следващ спомен", "no": "Не", - "no_albums_message": "Създаване на албум за организиране на снимки и видеоклипове", + "no_albums_message": "Създайте албум за организиране на снимки и видеоклипове", "no_albums_with_name_yet": "Изглежда, че все още нямате албуми с това име.", "no_albums_yet": "Изглежда, че все още нямате албуми.", "no_archived_assets_message": "Архивирайте снимки и видеоклипове, за да ги скриете от изгледа на Снимки", @@ -1274,8 +1282,8 @@ "no_duplicates_found": "Не бяха открити дубликати.", "no_exif_info_available": "Няма exif информация", "no_explore_results_message": "Качете още снимки, за да разгледате колекцията си.", - "no_favorites_message": "Добавяне на любими, за да намерите бързо най-добрите си снимки и видеоклипове", - "no_libraries_message": "Създаване на външна библиотека за разглеждане на снимки и видеоклипове", + "no_favorites_message": "Добавете в любими, за да намирате бързо най-добрите си снимки и видеоклипове", + "no_libraries_message": "Създайте външна библиотека за да разглеждате снимки и видеоклипове", "no_locked_photos_message": "Снимките и видеата в заключената папка са скрити и не се показват при разглеждане на библиотеката.", "no_name": "Без име", "no_notifications": "Няма известия", @@ -1303,7 +1311,7 @@ "oldest_first": "Най-старите първи", "on_this_device": "На това устройство", "onboarding": "Въвеждане", - "onboarding_locale_description": "Избери предпочитан език. По-късно може да го промениш в Настройки.", + "onboarding_locale_description": "Изберете предпочитан език. По-късно може да го промените в Настройки.", "onboarding_privacy_description": "Следните (незадължителни) функции разчитат на външни услуги и могат да бъдат деактивирани по всяко време в настройките.", "onboarding_server_welcome_description": "Да направим общите настройки на вашата инсталация.", "onboarding_theme_description": "Изберете цветова тема. Може да я промените по-късно в настройките.", @@ -1335,7 +1343,7 @@ "partner_page_partner_add_failed": "Неуспешно добавяне на партньор", "partner_page_select_partner": "Избери партньор", "partner_page_shared_to_title": "Споделено с", - "partner_page_stop_sharing_content": "{partner} вече няма да има достъп до твоите снимки.", + "partner_page_stop_sharing_content": "{partner} вече няма да има достъп до вашите снимки.", "partner_sharing": "Споделяне с партньори", "partners": "Партньори", "password": "Парола", @@ -1372,7 +1380,7 @@ "permission_onboarding_go_to_settings": "Отиди в настройки", "permission_onboarding_permission_denied": "Отказан достъп. За да ползваш Immich, разреши в настройките достъп до снимки и видео.", "permission_onboarding_permission_granted": "Предоставено е разрешение! Всичко е готово.", - "permission_onboarding_permission_limited": "Ограничен достъп. За да може Immich да архивира и управлява галерията, предостави достъп до снимки и видео в настройките.", + "permission_onboarding_permission_limited": "Ограничен достъп. За да може Immich да архивира и управлява галерията, предоставете достъп до снимки и видео в настройките.", "permission_onboarding_request": "Immich се нуждае от разрешение за преглед на снимки и видео.", "person": "Човек", "person_birthdate": "Дата на раждане {date}", @@ -1383,10 +1391,10 @@ "photos_count": "{count, plural, one {{count, number} Снимка} other {{count, number} Снимки}}", "photos_from_previous_years": "Снимки от предходни години", "pick_a_location": "Избери локация", - "pin_code_changed_successfully": "Успешно сменен PIN-код", - "pin_code_reset_successfully": "Успешно нулиран PIN-код", - "pin_code_setup_successfully": "Успешно зададен PIN-код", - "pin_verification": "Проверка на PIN-кода", + "pin_code_changed_successfully": "Успешно сменен PIN код", + "pin_code_reset_successfully": "Успешно нулиран PIN код", + "pin_code_setup_successfully": "Успешно зададен PIN код", + "pin_verification": "Проверка на PIN кода", "place": "Местоположение", "places": "Местоположения", "places_count": "{count, plural, one {{count, number} Място} other {{count, number} Места}}", @@ -1440,7 +1448,7 @@ "purchase_lifetime_description": "Покупка за цял живот", "purchase_option_title": "ОПЦИИ ЗА ЗАКУПУВАНЕ", "purchase_panel_info_1": "Създаването на Immich отнема много време и усилия, и имаме инженери на пълно работно време, които работят по него, за да го направим възможно най-добро. Нашата мисия е софтуерът с отворен код и етичните бизнес практики да се превърнат в устойчив източник на доходи за разработчиците и да създадем екосистема, която уважава личната неприкосновеност и предлага истински алтернативи на експлоататорските облачни услуги.", - "purchase_panel_info_2": "Тъй като сме ангажирани да не добавяме платени стени, тази покупка няма да ви предостави допълнителни функции в Immich. Ние разчитаме на потребители като вас, за да подкрепяте продължаващото развитие на Immich.", + "purchase_panel_info_2": "Тъй като сме обещали да не добавяме платени функции, тази покупка няма да ви предостави допълнителни функции в Immich. Ние разчитаме на потребители като вас, за да подкрепите продължаване на развитието на Immich.", "purchase_panel_title": "Поддържайте проекта", "purchase_per_server": "на сървър", "purchase_per_user": "на потребител", @@ -1489,7 +1497,7 @@ "remove_from_album": "Премахни от албума", "remove_from_favorites": "Премахни от Любими", "remove_from_locked_folder": "Махни от заключената папка", - "remove_from_locked_folder_confirmation": "Сигурен ли си, че искаш тези снимки и видеа да бъдат извадени от заключената папка? Те ще бъдат видими в библиотеката.", + "remove_from_locked_folder_confirmation": "Сигурни ли си, че искате тези снимки и видеа да бъдат извадени от заключената папка? Те ще бъдат видими в библиотеката.", "remove_from_shared_link": "Премахни от споделения линк", "remove_memory": "Премахни спомените", "remove_photo_from_memory": "Премахни снимките от спомените", @@ -1514,7 +1522,7 @@ "reset": "Нулиране", "reset_password": "Нулиране на паролата", "reset_people_visibility": "Нулиране на видимостта на хората", - "reset_pin_code": "Нулирай PIN-кода", + "reset_pin_code": "Нулирай PIN кода", "reset_to_default": "Връщане на фабрични настройки", "resolve_duplicates": "Реши дубликатите", "resolved_all_duplicates": "Всички дубликати са решени", @@ -1579,8 +1587,8 @@ "search_page_selfies": "Селфита", "search_page_things": "Предмети", "search_page_view_all_button": "Виж всички", - "search_page_your_activity": "Твоите действия", - "search_page_your_map": "Твоята карта", + "search_page_your_activity": "Вашите действия", + "search_page_your_map": "Вашата карта", "search_people": "Търсете на хора", "search_places": "Търсене на места", "search_rating": "Търси по рейтинг…", @@ -1588,7 +1596,7 @@ "search_settings": "Търсене на настройки", "search_state": "Търсене на щат...", "search_suggestion_list_smart_search_hint_1": "Умното търсене е включено по подразбиране, за търсене в метаданните използвай специален синтакс ", - "search_suggestion_list_smart_search_hint_2": "m:твоя-термин-за-търсене", + "search_suggestion_list_smart_search_hint_2": "m:вашия-термин-за-търсене", "search_tags": "Търсене на етикети...", "search_timezone": "Търсене на часова зона...", "search_type": "Тип на търсене", @@ -1600,6 +1608,7 @@ "select_album_cover": "Изберете обложка на албум", "select_all": "Изберете всички", "select_all_duplicates": "Избери всички дубликати", + "select_all_in": "Избери всички от групата {group}", "select_avatar_color": "Изберете цвят на аватара", "select_face": "Изберете лице", "select_featured_photo": "Избери представителна снимка", @@ -1656,7 +1665,7 @@ "settings": "Настройки", "settings_require_restart": "Моля, за да се приложи настройката рестартирай Immich", "settings_saved": "Настройките са запазени", - "setup_pin_code": "Задай PIN-код", + "setup_pin_code": "Задай PIN код", "share": "Споделяне", "share_add_photos": "Добави снимки", "share_assets_selected": "{count} избрани", @@ -1664,7 +1673,7 @@ "share_link": "Връзка за споделяне", "shared": "Споделено", "shared_album_activities_input_disable": "Коментарите са изключени", - "shared_album_activity_remove_content": "Искаш ли да изтриеш тази активност?", + "shared_album_activity_remove_content": "Искате ли да изтриете тази активност?", "shared_album_activity_remove_title": "Изтрий", "shared_album_section_people_action_error": "Грешка при напускане/премахване от албума", "shared_album_section_people_action_leave": "Премахване на потребител от албума", @@ -1672,7 +1681,7 @@ "shared_album_section_people_title": "ХОРА", "shared_by": "Споделено от", "shared_by_user": "Споделено от {user}", - "shared_by_you": "Споделено от теб", + "shared_by_you": "Споделено от вас", "shared_from_partner": "Снимки от {partner}", "shared_intent_upload_button_progress_text": "{current} / {total} Заредено", "shared_link_app_bar_title": "Споделени връзки", @@ -1712,7 +1721,7 @@ "sharing": "Споделени", "sharing_enter_password": "Моля, въведете паролата, за да видите тази страница.", "sharing_page_album": "Споделени албуми", - "sharing_page_description": "Създай споделени албуми за да споделиш снимки и видеа с хора от твоята мрежа.", + "sharing_page_description": "Създайте споделени албуми за да споделите снимки и видеа с хора от вашата мрежа.", "sharing_page_empty_list": "ПРАЗЕН СПИСЪК", "sharing_sidebar_description": "Покажи връзка към Споделяне в страничната лента", "sharing_silver_appbar_create_shared_album": "Нов споделен албум", @@ -1787,6 +1796,7 @@ "sync": "Синхронизиране", "sync_albums": "Синхронизиране на албуми", "sync_albums_manual_subtitle": "Синхронизирай всички заредени видеа и снимки в избраните архивни албуми", + "sync_upload_album_setting_subtitle": "Създавайте и зареждайте снимки и видеа в избрани албуми в Immich", "tag": "Таг", "tag_assets": "Тагни елементи", "tag_created": "Създаден етикет: {tag}", @@ -1800,6 +1810,19 @@ "theme": "Тема", "theme_selection": "Избор на тема", "theme_selection_description": "Автоматично задаване на светла или тъмна тема въз основа на системните предпочитания на вашия браузър", + "theme_setting_asset_list_storage_indicator_title": "Показвай индикатор за хранилището в заглавията на обектите", + "theme_setting_asset_list_tiles_per_row_title": "Брой обекти на ред ({count})", + "theme_setting_colorful_interface_subtitle": "Нанеси основен цвят върху фоновите повърхности.", + "theme_setting_colorful_interface_title": "Цветове на интерфейса", + "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_three_stage_loading_subtitle": "Три-степенното зареждане може да увеличи производителността, но ще увеличи значително и мрежовия трафик", + "theme_setting_three_stage_loading_title": "Включи три-степенно зареждане", "they_will_be_merged_together": "Те ще бъдат обединени", "third_party_resources": "Ресурси от трети страни", "time_based_memories": "Спомени, базирани на времето", @@ -1818,11 +1841,22 @@ "trash_all": "Изхвърли всички", "trash_count": "В Кошчето {count, number}", "trash_delete_asset": "Вкарай в Кошчето/Изтрий елемент", + "trash_emptied": "Коша е изпразнен", "trash_no_results_message": "Изтритите снимки и видеоклипове ще се показват тук.", + "trash_page_delete_all": "Изтрий всичко", + "trash_page_empty_trash_dialog_content": "Искате ли да изпразня коша? Тези обекти ще бъдат изтрити завинаги от Immich", + "trash_page_info": "Обектите в коша ще бъдат изтривани завинаги след {days} дни", + "trash_page_no_assets": "Коша е празен", + "trash_page_restore_all": "Възстановяване на всички", + "trash_page_select_assets_btn": "Избери обекти", + "trash_page_title": "В коша ({count})", "trashed_items_will_be_permanently_deleted_after": "Изхвърлените в кошчето елементи ще бъдат изтрити за постоянно след {days, plural, one {# ден} other {# дни}}.", "type": "Тип", + "unable_to_change_pin_code": "Невъзможна промяна на PIN кода", + "unable_to_setup_pin_code": "Неуспешно задаване на PIN кода", "unarchive": "Разархивирай", "unarchived_count": "{count, plural, other {Неархивирани #}}", + "undo": "Отмени", "unfavorite": "Премахване от любимите", "unhide_person": "Покажи отново човека", "unknown": "Неизвестно", @@ -1839,12 +1873,16 @@ "unsaved_change": "Незапазена промяна", "unselect_all": "Деселектирайте всички", "unselect_all_duplicates": "От маркирай всички дубликати", + "unselect_all_in": "Премахни избора на всички от групата {group}", "unstack": "Разкачи", "unstacked_assets_count": "Разкачени {count, plural, one {# елемент} other {# елементи}}", "up_next": "Следващ", + "updated_at": "Обновено", "updated_password": "Паролата е актуализирана", "upload": "Качване", "upload_concurrency": "Успоредни качвания", + "upload_dialog_info": "Искате ли да архивирате на сървъра избраните обекти?", + "upload_dialog_title": "Качи обект", "upload_errors": "Качването е завъшено с {count, plural, one {# грешка} other {# грешки}}, обновете страницата за да видите новите елементи.", "upload_progress": "Остават {remaining, number} - Обработени {processed, number}/{total, number}", "upload_skipped_duplicates": "Прескочени {count, plural, one {# дублиран елемент} other {# дублирани елементи}}", @@ -1852,13 +1890,22 @@ "upload_status_errors": "Грешки", "upload_status_uploaded": "Качено", "upload_success": "Качването е успешно, опреснете страницата, за да видите новите файлове.", + "upload_to_immich": "Казване в Immich ({count})", + "uploading": "Качваме", + "url": "URL", "usage": "Потребление", + "use_biometric": "Използвай биометрия", + "use_current_connection": "използвай текущата връзка", "use_custom_date_range": "Използвайте собствен диапазон от дати вместо това", "user": "Потребител", + "user_has_been_deleted": "Този потребител е премахнат.", "user_id": "Потребител ИД", "user_liked": "{user} хареса {type, select, photo {тази снимка} video {това видео} asset {този елемент} other {}}", + "user_pin_code_settings": "PIN код", + "user_pin_code_settings_description": "Управлявайте настройките на PIN кода", + "user_privacy": "Поверителност на потребителите", "user_purchase_settings": "Покупка", - "user_purchase_settings_description": "Управлявай покупката си", + "user_purchase_settings_description": "Управлявайте покупката си", "user_role_set": "Задай {user} като {role}", "user_usage_detail": "Подробности за използването на потребителя", "user_usage_stats": "Статистика за използването на акаунта", @@ -1867,6 +1914,7 @@ "users": "Потребители", "utilities": "Инструменти", "validate": "Валидиране", + "validate_endpoint_error": "Моля, въведи правилен URL", "variables": "Променливи", "version": "Версия", "version_announcement_closing": "Твой приятел, Алекс", @@ -1888,16 +1936,24 @@ "view_name": "Прегледай", "view_next_asset": "Преглед на следващия файл", "view_previous_asset": "Преглед на предишния файл", + "view_qr_code": "Виж QR кода", "view_stack": "Покажи в стек", + "view_user": "Виж потребителя", + "viewer_remove_from_stack": "Премахване от опашката", + "viewer_stack_use_as_main_asset": "Използвай като основен", + "viewer_unstack": "Премахни от опашката", "visibility_changed": "Видимостта е променена за {count, plural, one {# човек} other {# човека}}", "waiting": "в изчакване", "warning": "Внимание", "week": "Седмица", "welcome": "Добре дошли", "welcome_to_immich": "Добре дошли в Immich", + "wifi_name": "Wi-Fi мрежа", + "wrong_pin_code": "Грешен PIN код", "year": "Година", "years_ago": "преди {years, plural, one {# година} other {# години}}", "yes": "Да", "you_dont_have_any_shared_links": "Нямате споделени връзки", + "your_wifi_name": "Вашата Wi-Fi мрежа", "zoom_image": "Увеличаване на изображението" } diff --git a/i18n/bn.json b/i18n/bn.json index 966d474111..ed108652b7 100644 --- a/i18n/bn.json +++ b/i18n/bn.json @@ -13,5 +13,7 @@ "add_a_location": "একটি অবস্থান যোগ করুন", "add_a_name": "একটি নাম যোগ করুন", "add_a_title": "একটি শিরোনাম যোগ করুন", - "add_endpoint": "এন্ডপয়েন্ট যোগ করুন" + "add_endpoint": "এন্ডপয়েন্ট যোগ করুন", + "add_exclusion_pattern": "বহির্ভূতকরণ নমুনা", + "add_url": "লিঙ্ক যোগ করুন" } diff --git a/i18n/ca.json b/i18n/ca.json index 05fd40aa5b..5c53311a02 100644 --- a/i18n/ca.json +++ b/i18n/ca.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "{count, number} afegits als preferits", "admin": { "add_exclusion_pattern_description": "Afegeix patrons d'exclusió. Es permet englobar fent ús de *, **, i ?. Per a ignorar els fitxers de qualsevol directori anomenat \"Raw\" introduïu \"**/Raw/**\". Per a ignorar els fitxers acabats en \".tif\" introduïu \"**/*.tif\". Per a ignorar una ruta absoluta, utilitzeu \"/ruta/a/ignorar/**\".", + "admin_user": "Administrador", "asset_offline_description": "Aquest recurs de la biblioteca externa ja no es troba al disc i s'ha mogut a la paperera. Si el fitxer s'ha mogut dins de la biblioteca, comproveu la vostra línia de temps per trobar el nou recurs corresponent. Per restaurar aquest recurs, assegureu-vos que Immich pugui accedir a la ruta del fitxer següent i escanegeu la biblioteca.", "authentication_settings": "Configuració de l'autenticació", "authentication_settings_description": "Gestiona la contrasenya, OAuth i altres configuracions de l'autenticació", @@ -58,7 +59,7 @@ "cron_expression_description": "Estableix l'interval d'escaneig amb el format cron. Per obtenir més informació, consulteu, p.e Crontab Guru", "cron_expression_presets": "Ajustos predefinits d'expressions Cron", "disable_login": "Deshabiliteu l'inici de sessió", - "duplicate_detection_job_description": "Executa l'aprenentatge automàtic en els elements per a detectar imatges semblants. Fa servir l'Smart Search", + "duplicate_detection_job_description": "Executa l'aprenentatge automàtic en els elements per a detectar imatges semblants. Fa servir la cerca intel·ligent", "exclusion_pattern_description": "Els patrons d'exclusió permeten ignorar fitxers i carpetes quan escanegeu una llibreria. Això és útil si teniu carpetes que contenen fitxer que no voleu importar, com els fitxers RAW.", "external_library_management": "Gestió de llibreries externes", "face_detection": "Detecció de cares", @@ -88,7 +89,7 @@ "image_thumbnail_description": "Miniatura petita amb metadades eliminades, que s'utilitza quan es visualitzen grups de fotos com la línia de temps principal", "image_thumbnail_quality_description": "Qualitat de miniatura d'1 a 100. Més alt és millor, però produeix fitxers més grans i pot reduir la capacitat de resposta de l'aplicació.", "image_thumbnail_title": "Configuració de miniatures", - "job_concurrency": "{job} concurrència", + "job_concurrency": "{job} simultàniament", "job_created": "Tasca creada", "job_not_concurrency_safe": "Aquesta tasca no és segura per a la conconcurrència.", "job_settings": "Configuració de les tasques", @@ -104,7 +105,7 @@ "library_scanning_enable_description": "Habilita l'escaneig periòdic de biblioteques", "library_settings": "Llibreria externes", "library_settings_description": "Gestiona la configuració de les llibreries externes", - "library_tasks_description": "Escaneja biblioteques externes per arxius nous o canviats", + "library_tasks_description": "Escaneja les biblioteques externes per trobar arxius nous o canviats", "library_watching_enable_description": "Consultar llibreries externes per detectar canvis en fitxers", "library_watching_settings": "Monitoratge de la llibreria (EXPERIMENTAL)", "library_watching_settings_description": "Monitorització automàtica de fitxers modificats", @@ -112,19 +113,19 @@ "logging_level_description": "Quan està habilitat, quin nivell de registre es vol emprar.", "logging_settings": "Registre", "machine_learning_clip_model": "Model CLIP", - "machine_learning_clip_model_description": "El nom d'un model CLIP que apareix a aquí. Tingues en compte que has de tornar a executar l'Smart Search' per a totes les imatges quan es canvia de model.", + "machine_learning_clip_model_description": "El nom d'un model CLIP que apareix a aquí. Tingues en compte que has de tornar a executar la cerca intel·ligent per a totes les imatges quan es canvia de model.", "machine_learning_duplicate_detection": "Detecció de duplicats", - "machine_learning_duplicate_detection_enabled": "Activa detecció de duplicats", - "machine_learning_duplicate_detection_enabled_description": "Si es deshabilitat, els elements exactament idèntics encara es desduplicaran.", + "machine_learning_duplicate_detection_enabled": "Activa la detecció de duplicats", + "machine_learning_duplicate_detection_enabled_description": "Si està desactivada, els elements idèntics encara es desduplicaran.", "machine_learning_duplicate_detection_setting_description": "Usa incrustacions CLIP per a trobar prossibles duplicats", "machine_learning_enabled": "Activa l'aprenentatge automàtic", - "machine_learning_enabled_description": "Si està deshabilitat, totes les funcions ML es deshabilitaran sense tenir en compte la configuració següent.", + "machine_learning_enabled_description": "Si està desactivat, totes les funcions ML es deshabilitaran sense tenir en compte la configuració següent.", "machine_learning_facial_recognition": "Reconeixement facial", "machine_learning_facial_recognition_description": "Detecta, reconeix i agrupa cares a les imatges", "machine_learning_facial_recognition_model": "Model de reconeixement facial", "machine_learning_facial_recognition_model_description": "Els models es llisten en ordre descent segons la mida. Els models més grans són més lents i usen més memòria però produeixen millors resultats. Tingueu en compte que després de canviar un model haureu de tornar a executar la tasca de detecció de cares per a totes les imatges.", - "machine_learning_facial_recognition_setting": "Activa reconeixement facial", - "machine_learning_facial_recognition_setting_description": "Si està deshabilitat, les imatges no es codificaran pel reconeixement facial i no s'afegiran a la secció Persones de la pàgina Explorar.", + "machine_learning_facial_recognition_setting": "Activa el reconeixement facial", + "machine_learning_facial_recognition_setting_description": "Si està desactivat, les imatges no es codificaran pel reconeixement facial i no s'afegiran a la secció Persones de la pàgina Explorar.", "machine_learning_max_detection_distance": "Distància màxima de detecció", "machine_learning_max_detection_distance_description": "Diferència màxima entre dues imatges per a considerar-les duplicades, en un rang d'entre 0.001-0.1. Com més elevat el valor més detecció de duplicats, però pot resultar en falsos positius.", "machine_learning_max_recognition_distance": "Màxima diferència de reconeixement", @@ -135,17 +136,17 @@ "machine_learning_min_recognized_faces_description": "El nombre mínim de cares reconegudes per crear una persona. Augmentar aquest valor fa que el reconeixement facial sigui més precís, però augmenta la possibilitat que una cara no sigui assignada a una persona.", "machine_learning_settings": "Configuració d'aprenentatge automàtic", "machine_learning_settings_description": "Gestiona funcions i configuració d'aprenentatge automàtic", - "machine_learning_smart_search": "Cerca Intel·ligent", + "machine_learning_smart_search": "Cerca intel·ligent", "machine_learning_smart_search_description": "Cerca imatges semànticament emprant incrustacions CLIP", "machine_learning_smart_search_enabled": "Activa la cerca intel·ligent", - "machine_learning_smart_search_enabled_description": "Si està deshabilitat les imatges no es codificaran per la cerca intel·ligent.", + "machine_learning_smart_search_enabled_description": "Si està desactivada, les imatges no es codificaran per la cerca intel·ligent.", "machine_learning_url_description": "L'URL del servidor d'aprenentatge automàtic. Si es proporciona més d'un URL, s'intentarà accedir a cada servidor en ordre fins que un d'ells respongui correctament.", "manage_concurrency": "Gestiona la concurrència", "manage_log_settings": "Gestiona la configuració del registre", "map_dark_style": "Tema fosc", "map_enable_description": "Habilita característiques del mapa", - "map_gps_settings": "Configuració de mapa i GPS", - "map_gps_settings_description": "Gestiona la configuració de mapa i GPS (Geocodificació inversa)", + "map_gps_settings": "Configuració del mapa i GPS", + "map_gps_settings_description": "Gestiona la configuració del mapa i GPS (Geocodificació inversa)", "map_implications": "La funció mapa depèn del servei extern de tesel·les (tiles.immich.cloud)", "map_light_style": "Tema clar", "map_manage_reverse_geocoding_settings": "Gestiona els paràmetres de geocodificació inversa", @@ -243,7 +244,6 @@ "storage_template_migration_info": "Les extensions es convertiran a minúscules. Els canvis de plantilla només s'aplicaran a nous elements. Per aplicar la plantilla rectroactivament a elements pujats prèviament, executeu la {job}.", "storage_template_migration_job": "Tasca de migració de la plantilla d'emmagatzematge", "storage_template_more_details": "Per obtenir més detalls sobre aquesta funció, consulteu la Storage Template i les seves implications", - "storage_template_onboarding_description": "Quan està activada, aquesta funció organitzarà automàticament els fitxers en funció d'una plantilla definida per l'usuari. A causa de problemes d'estabilitat, la funció s'ha desactivat de manera predeterminada. Per obtenir més informació, consulteu la documentation.", "storage_template_path_length": "Límit aproximat de longitud de la ruta: {length, number}/{limit, number}", "storage_template_settings": "Plantilla d'emmagatzematge", "storage_template_settings_description": "Gestiona l'estructura de les carpetes i el nom del fitxers dels elements pujats", @@ -260,7 +260,7 @@ "template_settings": "Plantilles de notificació", "template_settings_description": "Gestiona les plantilles personalitzades per les notificacions", "theme_custom_css_settings": "CSS personalitzat", - "theme_custom_css_settings_description": "Els Fulls d'Estil en Cascada permeten personalitzar el disseny d'Immich.", + "theme_custom_css_settings_description": "Els fulls d'estil en cascada permeten personalitzar el disseny d'Immich.", "theme_settings": "Configuració del tema", "theme_settings_description": "Gestiona la personalització de la interfície web Immich", "thumbnail_generation_job": "Generar miniatures", @@ -354,7 +354,7 @@ }, "admin_email": "Correu de l'administrador", "admin_password": "Contrasenya de l'administrador", - "administration": "Administrador", + "administration": "Administració", "advanced": "Avançat", "advanced_settings_enable_alternate_media_filter_subtitle": "Feu servir aquesta opció per filtrar els continguts multimèdia durant la sincronització segons criteris alternatius. Només proveu-ho si teniu problemes amb l'aplicació per detectar tots els àlbums.", "advanced_settings_enable_alternate_media_filter_title": "Utilitza el filtre de sincronització d'àlbums de dispositius alternatius", @@ -468,6 +468,8 @@ "assets_count": "{count, plural, one {# recurs} other {# recursos}}", "assets_deleted_permanently": "{count} element(s) esborrats permanentment", "assets_deleted_permanently_from_server": "{count} element(s) esborrats permanentment del servidor d'Immich", + "assets_downloaded_failed": "{count, plural, one {S'ha baixat un arxiu - {error} l'arxiu ha fallat} other {S'han baixat # arxius - {error} els arxius han fallat}}", + "assets_downloaded_successfully": "{count, plural, one {S'ha baixat un arxiu amb èxit} other {S'han baixat # arxius amb èxit}}", "assets_moved_to_trash_count": "{count, plural, one {# recurs mogut} other {# recursos moguts}} a la paperera", "assets_permanently_deleted_count": "{count, plural, one {# recurs esborrat} other {# recursos esborrats}} permanentment", "assets_removed_count": "{count, plural, one {# element eliminat} other {# elements eliminats}}", @@ -551,7 +553,7 @@ "backup_setting_subtitle": "Gestiona la configuració de càrrega en segon pla i en primer pla", "backward": "Enrere", "biometric_auth_enabled": "Autentificació biomètrica activada", - "biometric_locked_out": "Esteu bloquejat fora de l'autenticació biomètrica", + "biometric_locked_out": "Esteu bloquejats fora de l'autenticació biomètrica", "biometric_no_options": "No hi ha opcions biomètriques disponibles", "biometric_not_available": "L'autenticació biomètrica no està disponible en aquest dispositiu", "birthdate_saved": "Data de naixement guardada amb èxit", @@ -1095,6 +1097,7 @@ "ios_debug_info_last_sync_at": "Darrera sincronització {dateTime}", "ios_debug_info_no_processes_queued": "No hi ha processos en segon pla en cua", "ios_debug_info_no_sync_yet": "Encara no s'ha executat cap tasca de sincronització en segon pla", + "ios_debug_info_processes_queued": "{count, plural, one {Un procés en segon pla a la cua} other {{count} processos en segon pla a la cua}}", "ios_debug_info_processing_ran_at": "El processament s'ha executat {dateTime}", "items_count": "{count, plural, one {# element} other {# elements}}", "jobs": "Tasques", @@ -1132,7 +1135,7 @@ "list": "Llista", "loading": "Carregant", "loading_search_results_failed": "No s'han pogut carregar els resultats de la cerca", - "local_asset_cast_failed": "No es pot convertir un actiu que no s'ha penjat al servidor.", + "local_asset_cast_failed": "No es pot convertir un actiu que no s'ha penjat al servidor", "local_network": "Xarxa local", "local_network_sheet_info": "L'aplicació es connectarà al servidor mitjançant aquest URL quan utilitzeu la xarxa Wi-Fi especificada", "location_permission": "Permís d'ubicació", diff --git a/i18n/cs.json b/i18n/cs.json index 7e8bd524ad..82e72cab46 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "Přidáno {count, number} do oblíbených", "admin": { "add_exclusion_pattern_description": "Přidání vzorů vyloučení. Podporováno je globování pomocí *, ** a ?. Chcete-li ignorovat všechny soubory v jakémkoli adresáři s názvem \"Raw\", použijte \"**/Raw/**\". Chcete-li ignorovat všechny soubory končící na \".tif\", použijte \"**/*.tif\". Chcete-li ignorovat absolutní cestu, použijte příkaz \"/path/to/ignore/**\".", + "admin_user": "Administrátor", "asset_offline_description": "Tato položka externí knihovny se již na disku nenachází a byla přesunuta do koše. Pokud byl soubor přesunut v rámci knihovny, zkontrolujte časovou osu a vyhledejte nové odpovídající položku. Chcete-li tuto položku obnovit, ujistěte se, že je cesta k níže uvedenému souboru přístupná pomocí aplikace Immich a prohledejte knihovnu.", "authentication_settings": "Přihlašování", "authentication_settings_description": "Správa hesel, OAuth a dalších nastavení ověření", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "Deklarace kvóty úložiště", "oauth_storage_quota_claim_description": "Automaticky nastavit kvótu úložiště uživatele na hodnotu této deklarace.", "oauth_storage_quota_default": "Výchozí kvóta úložiště (GiB)", - "oauth_storage_quota_default_description": "Kvóta v GiB, která se použije, pokud není poskytnuta žádná deklarace (pro neomezenou kvótu zadejte 0).", + "oauth_storage_quota_default_description": "Kvóta v GiB, která se použije, pokud není poskytnuta žádná deklarace.", "oauth_timeout": "Časový limit požadavku", "oauth_timeout_description": "Časový limit pro požadavky v milisekundách", "password_enable_description": "Přihlášení pomocí e-mailu a hesla", @@ -243,7 +244,7 @@ "storage_template_migration_info": "Šablona úložiště převede všechny přípony na malá písmena. Změny šablon se uplatní pouze u nových položek. Chcete-li šablonu zpětně použít na dříve nahrané položky, spusťte {job}.", "storage_template_migration_job": "Úloha migrace šablony úložiště", "storage_template_more_details": "Další podrobnosti o této funkci naleznete v sekci Šablona úložiště včetně jejích důsledků", - "storage_template_onboarding_description": "Je-li tato funkce povolena, automaticky uspořádá soubory na základě uživatelem definované šablony. Z důvodu problémů se stabilitou byla tato funkce ve výchozím nastavení vypnuta. Další informace naleznete v dokumentaci.", + "storage_template_onboarding_description_v2": "Pokud je tato funkce povolena, automaticky uspořádá soubory na základě uživatelem definované šablony. Další informace naleznete v dokumentaci.", "storage_template_path_length": "Přibližný limit délky cesty: {length, number}/{limit, number}", "storage_template_settings": "Šablona úložiště", "storage_template_settings_description": "Správa struktury složek a názvů nahraných souborů", @@ -1149,6 +1150,7 @@ "locked_folder": "Uzamčená složka", "log_out": "Odhlásit", "log_out_all_devices": "Odhlásit všechna zařízení", + "logged_in_as": "Přihlášen jako {user}", "logged_out_all_devices": "Všechna zařízení odhlášena", "logged_out_device": "Zařízení odhlášeno", "login": "Přihlášení", @@ -1606,6 +1608,7 @@ "select_album_cover": "Vybrat obal alba", "select_all": "Vybrat vše", "select_all_duplicates": "Vybrat všechny duplicity", + "select_all_in": "Vybrat vše ve skupině {group}", "select_avatar_color": "Vyberte barvu avatara", "select_face": "Vybrat obličej", "select_featured_photo": "Vybrat hlavní fotografii", @@ -1870,6 +1873,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", + "unselect_all_in": "Zrušit výběr ve skupině {group}", "unstack": "Zrušit seskupení", "unstacked_assets_count": "{count, plural, one {Rozložená # položka} few {Rozložené # položky} other {Rozložených # položiek}}", "up_next": "To je prozatím vše", diff --git a/i18n/da.json b/i18n/da.json index 3483d38dff..3273a2d553 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -243,7 +243,6 @@ "storage_template_migration_info": "Lager-skabelonen vil konvertere alle filendelser til små bogstaver. Skabelonændringer vil kun gælde for nye mediefiler. For at anvende skabelonen retroaktivt på tidligere uploadede mediefiler skal du køre {job}.", "storage_template_migration_job": "Lager Skabelon Migreringsjob", "storage_template_more_details": "For flere detaljer om denne funktion, referer til Lager Skabelonen og dens implikationer", - "storage_template_onboarding_description": "Når denne funktion er aktiveret, vil den automatisk organisere filer baseret på en brugerdefineret skabelon. På grund af stabilitetsproblemer er funktionen som standard slået fra. For mere information, se dokumentation.", "storage_template_path_length": "Anslået sti-længde begrænsning {length, number}/{limit, number}", "storage_template_settings": "Lagringsskabelon", "storage_template_settings_description": "Administrer mappestrukturen og filnavnet for den uploadede mediefil", diff --git a/i18n/de.json b/i18n/de.json index be6013c410..bbcd9c569c 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -34,6 +34,7 @@ "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.", + "admin_user": "Administrator", "asset_offline_description": "Diese Datei einer externen Bibliothek befindet sich nicht mehr auf der Festplatte und wurde in den Papierkorb verschoben. Falls die Datei innerhalb der Bibliothek verschoben wurde, überprüfe deine Zeitleiste auf die neue entsprechende Datei. Um diese Datei wiederherzustellen, stelle bitte sicher, dass Immich auf den unten stehenden Dateipfad zugreifen kann und scanne die Bibliothek.", "authentication_settings": "Authentifizierungseinstellungen", "authentication_settings_description": "Passwort-, OAuth- und sonstige Authentifizierungseinstellungen verwalten", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "Speicherkontingentangabe", "oauth_storage_quota_claim_description": "Setzen Sie das Speicherkontingent des Benutzers automatisch auf den angegebenen Wert.", "oauth_storage_quota_default": "Standard-Speicherplatzkontingent (GiB)", - "oauth_storage_quota_default_description": "Kontingent in GiB, das verwendet werden soll, wenn keines übermittelt wird (gib 0 für ein unbegrenztes Kontingent ein).", + "oauth_storage_quota_default_description": "Kontingent in GiB, das verwendet werden soll, wenn keines übermittelt wird.", "oauth_timeout": "Zeitüberschreitung bei Anfrage", "oauth_timeout_description": "Zeitüberschreitung für Anfragen in Millisekunden", "password_enable_description": "Mit E-Mail und Passwort anmelden", @@ -243,7 +244,6 @@ "storage_template_migration_info": "Die Speichervorlage wird alle Dateierweiterungen in Kleinbuchstaben umwandeln. Vorlagenänderungen gelten nur für neue Dateien. Um die Vorlage rückwirkend auf bereits hochgeladene Assets anzuwenden, führe den {job} aus.", "storage_template_migration_job": "Speichervorlagenmigrations-Aufgabe", "storage_template_more_details": "Weitere Details zu dieser Funktion findest du unter Speichervorlage und dessen Implikationen", - "storage_template_onboarding_description": "Wenn aktiviert, sortiert diese Funktion Dateien automatisch basierend auf einer benutzerdefinierten Vorlage. Aufgrund von Stabilitätsproblemen ist die Funktion standardmäßig deaktiviert. Weitere Informationen findest du in der Dokumentation.", "storage_template_path_length": "Ungefähres Pfadlängen-Limit: {length, number}/{limit, number}", "storage_template_settings": "Speichervorlage", "storage_template_settings_description": "Die Ordnerstruktur und den Dateinamen der hochgeladenen Datei verwalten", @@ -1149,6 +1149,7 @@ "locked_folder": "Gesperrter Ordner", "log_out": "Abmelden", "log_out_all_devices": "Alle Geräte abmelden", + "logged_in_as": "Angemeldet als {user}", "logged_out_all_devices": "Alle Geräte abgemeldet", "logged_out_device": "Gerät abgemeldet", "login": "Anmelden", @@ -1606,6 +1607,7 @@ "select_album_cover": "Album-Cover auswählen", "select_all": "Alles auswählen", "select_all_duplicates": "Alle Duplikate auswählen", + "select_all_in": "Alle in {group} auswählen", "select_avatar_color": "Avatar-Farbe auswählen", "select_face": "Gesicht auswählen", "select_featured_photo": "Anzeigebild auswählen", @@ -1870,6 +1872,7 @@ "unsaved_change": "Ungespeicherte Änderung", "unselect_all": "Alles abwählen", "unselect_all_duplicates": "Alle Duplikate abwählen", + "unselect_all_in": "Alle in {group} abwählen", "unstack": "Entstapeln", "unstacked_assets_count": "{count, plural, one {# Datei} other {# Dateien}} entstapelt", "up_next": "Weiter", diff --git a/i18n/el.json b/i18n/el.json index 60eaba6c08..62d0481ec6 100644 --- a/i18n/el.json +++ b/i18n/el.json @@ -22,6 +22,7 @@ "add_partner": "Προσθήκη συνεργάτη", "add_path": "Προσθήκη διαδρομής", "add_photos": "Προσθήκη φωτογραφιών", + "add_tag": "Προσθήκη ετικέτας", "add_to": "Προσθήκη σε…", "add_to_album": "Προσθήκη σε άλμπουμ", "add_to_album_bottom_sheet_added": "Προστέθηκε στο {album}", @@ -33,6 +34,7 @@ "added_to_favorites_count": "Προστέθηκαν {count, number} στα αγαπημένα", "admin": { "add_exclusion_pattern_description": "Προσθέστε μοτίβα αποκλεισμού. Υποστηρίζεται η επιλογή πολλών με *, **, και ?. Για να αγνοηθούν όλα τα αρχεία σε έναν φάκελο με το όνομα \"Raw\", χρησιμοποιήστε \"**/Raw/**\". Για να αγνοηθούν όλα τα αρχεία με κατάληξη \".tif\", χρησιμοποιήστε \"**/*.tif\". Για να αγνοηθεί μία απόλυτη διαδρομή, χρησιμοποιήστε \"/path/to/ignore/**\".", + "admin_user": "Διαχειριστής", "asset_offline_description": "Αυτό το στοιχείο εξωτερικής βιβλιοθήκης δε βρίσκεται πλέον στο δίσκο και έχει μεταφερθεί στα απορρίμματα. Εάν το αρχείο έχει μετακινηθεί εντός της βιβλιοθήκης, ελέγξτε το χρονολόγιο φωτογραφιών σας για το νέο αντίστοιχο στοιχείο. Για να επαναφέρετε αυτό το στοιχείο, βεβαιωθείτε ότι το παρακάτω μονοπάτι αρχείου είναι προσβάσιμο από το Immich και σαρώστε τη βιβλιοθήκη.", "authentication_settings": "Ρυθμίσεις Ελέγχου Ταυτότητας", "authentication_settings_description": "Διαχείριση κωδικού πρόσβασης, OAuth και άλλων ρυθμίσεων ελέγχου ταυτότητας", @@ -43,7 +45,7 @@ "backup_database_enable_description": "Ενεργοποίηση dumps βάσης δεδομένων", "backup_keep_last_amount": "Ποσότητα προηγούμενων dumps που πρέπει να διατηρηθούν", "backup_settings": "Ρυθμίσεις dump βάσης δεδομένων", - "backup_settings_description": "Διαχείριση ρυθμίσεων dump της βάσης δεδομένων. Σημείωση: Αυτές οι εργασίες δεν παρακολουθούνται και δεν θα ειδοποιηθείτε για αποτυχία.", + "backup_settings_description": "Διαχείριση ρυθμίσεων dump της βάσης δεδομένων.", "cleared_jobs": "Εκκαθαρίστηκαν οι εργασίες για: {job}", "config_set_by_file": "Η παραμετροποίηση γίνεται, προς το παρόν, μέσω ενός αρχείου παραμέτρων", "confirm_delete_library": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τη βιβλιοθήκη {library};", @@ -169,7 +171,7 @@ "note_apply_storage_label_previous_assets": "Σημείωση: Για να εφαρμοστεί η Ετικέτα Αποθήκευσης σε στοιχεία που είχαν αναρτηθεί παλαιότερα, εκτέλεσε το", "note_cannot_be_changed_later": "ΣΗΜΕΊΩΣΗ: Αυτό δεν μπορεί να τροποποιηθεί αργότερα!", "notification_email_from_address": "Διεύθυνση αποστολέα", - "notification_email_from_address_description": "Διεύθυνση αποστολέα, πχ: \"Immich Photo Server \"", + "notification_email_from_address_description": "Διεύθυνση αποστολέα, πχ: \"Immich Photo Server \". Βεβαιωθείτε ότι έχετε δικαίωμα χρήσης της διεύθυνσης που χρησιμοποιείτε.", "notification_email_host_description": "Πάροχος του email server (πχ smtp.immich.app)", "notification_email_ignore_certificate_errors": "Παράβλεψη των σφαλμάτων πιστοποίησης", "notification_email_ignore_certificate_errors_description": "Παράβλεψη σφαλμάτων επικύρωσης της πιστοποίησης TLS (δεν προτείνεται)", @@ -202,7 +204,7 @@ "oauth_storage_quota_claim": "Δήλωση ποσοστού αποθήκευσης", "oauth_storage_quota_claim_description": "Ορίζει αυτόματα το ποσοστό αποθήκευσης του χρήστη στη δηλωμένη τιμή.", "oauth_storage_quota_default": "Προεπιλεγμένο όριο αποθήκευσης (GiB)", - "oauth_storage_quota_default_description": "Ποσοστό σε GiB που θα χρησιμοποιηθεί όταν δεν ορίζεται από τη δηλωμένη τιμή (Εισάγετε 0 για απεριόριστο ποσοστό).", + "oauth_storage_quota_default_description": "Ποσοστό σε GiB που θα χρησιμοποιηθεί όταν δεν ορίζεται από τη δηλωμένη τιμή.", "oauth_timeout": "Χρονικό όριο Αιτήματος", "oauth_timeout_description": "Χρονικό όριο Αιτήματος σε milliseconds", "password_enable_description": "Σύνδεση με ηλεκτρονικό ταχυδρομείο", @@ -242,7 +244,7 @@ "storage_template_migration_info": "Το πρότυπο αποθήκευσης θα μετατρέψει όλες τις επεκτάσεις σε πεζά γράμματα. Οι αλλαγές στο πρότυπο θα ισχύουν μόνο για νέα περιουσιακά στοιχεία. Για να εφαρμόσετε αναδρομικά το πρότυπο σε περιουσιακά στοιχεία που έχουν μεταφορτωθεί προηγουμένως, εκτελέστε το {job}.", "storage_template_migration_job": "Εργασία Μεταφοράς Προτύπων Αποθήκευσης", "storage_template_more_details": "Για περισσότερες λεπτομέρειες σχετικά με αυτήν τη δυνατότητα, ανατρέξτε στο Πρότυπο Αποθήκευσης και στις συνέπειές του", - "storage_template_onboarding_description": "Όταν ενεργοποιηθεί, αυτή η δυνατότητα θα οργανώνει αυτόματα τα αρχεία με βάση ένα πρότυπο που καθορίζεται από τον χρήστη. Λόγω θεμάτων σταθερότητας, η δυνατότητα είναι απενεργοποιημένη από προεπιλογή. Για περισσότερες πληροφορίες, παρακαλώ δείτε την τεκμηρίωση.", + "storage_template_onboarding_description_v2": "Όταν είναι ενεργοποιημένη, αυτή η λειτουργία θα οργανώνει αυτόματα τα αρχεία με βάση ένα πρότυπο που ορίζεται από το χρήστη. Για περισσότερες πληροφορίες, παρακαλώ ανατρέξτε στις οδηγίες χρήσης.", "storage_template_path_length": "Όριο μήκους διαδρομής: {length, number}/{limit, number}, κατά προσέγγιση", "storage_template_settings": "Πρότυπο Αποθήκευσης", "storage_template_settings_description": "Διαχείριση της δομής φακέλου και του ονόματος, του ανεβασμένου αρχείου", @@ -402,6 +404,9 @@ "album_with_link_access": "Επιτρέψτε σε οποιονδήποτε έχει τον σύνδεσμο, να δει τις φωτογραφίες και τα άτομα σε αυτό το άλμπουμ.", "albums": "Άλμπουμ", "albums_count": "{count, plural, one {{count, number} Άλμπουμ} other {{count, number} Άλμπουμ}}", + "albums_default_sort_order": "Προεπιλεγμένη ταξινόμηση άλμπουμ", + "albums_default_sort_order_description": "Αρχική ταξινόμηση κατά τη δημιουργία νέων άλμπουμ.", + "albums_feature_description": "Συλλογές στοιχείων που μπορούν να κοινοποιηθούν σε άλλους χρήστες.", "all": "Όλα", "all_albums": "Όλα τα άλμπουμ", "all_people": "Όλα τα άτομα", @@ -460,9 +465,12 @@ "assets_added_count": "Προστέθηκε {count, plural, one {# αρχείο} other {# αρχεία}}", "assets_added_to_album_count": "Προστέθηκε {count, plural, one {# αρχείο} other {# αρχεία}} στο άλμπουμ", "assets_added_to_name_count": "Προστέθηκε {count, plural, one {# αρχείο} other {# αρχεία}} στο {hasName, select, true {{name}} other {νέο άλμπουμ}}", + "assets_cannot_be_added_to_album_count": "{count, plural, one {Στοιχείο} other {Στοιχεία}} δεν μπορούν να προστεθούν στο άλμπουμ", "assets_count": "{count, plural, one {# αρχείο} other {# αρχεία}}", "assets_deleted_permanently": "{count} τα στοιχεία διαγράφηκαν οριστικά", "assets_deleted_permanently_from_server": "{count} στοιχεία διαγράφηκαν οριστικά από το διακομιστή Immich", + "assets_downloaded_failed": "{count, plural, one {Έγινε λήψη # αρχείου - {error} αρχείο απέτυχε} other {Έγινε λήψη # αρχείων - {error} αρχεία απέτυχαν}}", + "assets_downloaded_successfully": "{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 {# αρχεία}}", @@ -477,6 +485,7 @@ "authorized_devices": "Εξουσιοδοτημένες Συσκευές", "automatic_endpoint_switching_subtitle": "Σύνδεση τοπικά μέσω του καθορισμένου Wi-Fi όταν είναι διαθέσιμο και χρήση εναλλακτικών συνδέσεων αλλού", "automatic_endpoint_switching_title": "Αυτόματη εναλλαγή URL", + "autoplay_slideshow": "Αυτόματη αναπαραγωγή παρουσίασης", "back": "Πίσω", "back_close_deselect": "Πίσω, κλείσιμο ή αποεπιλογή", "background_location_permission": "Άδεια τοποθεσίας στο παρασκήνιο", @@ -641,6 +650,7 @@ "confirm_password": "Επιβεβαίωση κωδικού", "confirm_tag_face": "Θέλετε να επισημάνετε αυτό το πρόσωπο ως {name};", "confirm_tag_face_unnamed": "Θέλετε να επισημάνετε αυτό το πρόσωπο;", + "connected_device": "Συνδεδεμένη συσκευή", "connected_to": "Συνδεδεμένο με", "contain": "Περιέχει", "context": "Συμφραζόμενα", @@ -693,6 +703,7 @@ "daily_title_text_date": "Ε, MMM dd", "daily_title_text_date_year": "Ε, MMM dd, yyyy", "dark": "Σκούρο", + "darkTheme": "Εναλλαγή σκούρου θέματος", "date_after": "Ημερομηνία μετά", "date_and_time": "Ημερομηνία και ώρα", "date_before": "Ημερομηνία πριν", @@ -740,6 +751,7 @@ "disallow_edits": "Απαγόρευση επεξεργασιών", "discord": "Πλατφόρμα Discord", "discover": "Ανίχνευση", + "discovered_devices": "Διαθέσιμες συσκευές", "dismiss_all_errors": "Παράβλεψη όλων των σφαλμάτων", "dismiss_error": "Παράβλεψη σφάλματος", "display_options": "Επιλογές εμφάνισης", @@ -1096,6 +1108,9 @@ "kept_this_deleted_others": "Διατηρήθηκε αυτό το στοιχείο και διαγράφηκε/καν {count, plural, one {# στοιχείο} other {# στοιχεία}}", "keyboard_shortcuts": "Συντομεύσεις πληκτρολογίου", "language": "Γλώσσα", + "language_no_results_subtitle": "Δοκιμάστε να αλλάξετε τον όρο αναζήτησης", + "language_no_results_title": "Δε βρέθηκαν γλώσσες", + "language_search_hint": "Αναζήτηση γλωσσών...", "language_setting_description": "Επιλέξτε τη γλώσσα που προτιμάτε", "last_seen": "Τελευταία προβολή", "latest_version": "Τελευταία Έκδοση", @@ -1121,6 +1136,7 @@ "list": "Λίστα", "loading": "Φόρτωση", "loading_search_results_failed": "Η φόρτωση αποτελεσμάτων αναζήτησης απέτυχε", + "local_asset_cast_failed": "Αδυναμία μετάδοσης στοιχείου που δεν έχει ανέβει στον διακομιστή", "local_network": "Τοπικό δίκτυο", "local_network_sheet_info": "Η εφαρμογή θα συνδεθεί με τον διακομιστή μέσω αυτού του URL όταν χρησιμοποιείται το καθορισμένο δίκτυο Wi-Fi", "location_permission": "Άδεια τοποθεσίας", @@ -1134,6 +1150,7 @@ "locked_folder": "Κλειδωμένος φάκελος", "log_out": "Αποσύνδεση", "log_out_all_devices": "Αποσύνδεση από Όλες τις Συσκευές", + "logged_in_as": "Συνδεδεμένος ως {user}", "logged_out_all_devices": "Όλες οι συσκευές αποσυνδέθηκαν", "logged_out_device": "Αποσυνδεδεμένη συσκευή", "login": "Είσοδος", @@ -1165,7 +1182,7 @@ "look": "Εμφάνιση", "loop_videos": "Επανάληψη βίντεο", "loop_videos_description": "Ενεργοποιήστε την αυτόματη επανάληψη ενός βίντεο στο πρόγραμμα προβολής λεπτομερειών.", - "main_branch_warning": "Χρησιμοποιείτε μια έκδοση σε ανάπτυξη· συνιστούμε ανεπιφύλακτα τη χρήση μιας επίσημης έκδοσης!", + "main_branch_warning": "Χρησιμοποιείτε μια έκδοση σε ανάπτυξη· συνιστούμε ανεπιφύλακτα τη χρήση μιας τελικής έκδοσης!", "main_menu": "Κύριο μενού", "make": "Κατασκευαστής", "manage_shared_links": "Διαχείριση κοινόχρηστων συνδέσμων", @@ -1261,6 +1278,7 @@ "no_archived_assets_message": "Αρχειοθετήστε φωτογραφίες και βίντεο για να τα αποκρύψετε από την Προβολή Φωτογραφιών", "no_assets_message": "ΚΑΝΤΕ ΚΛΙΚ ΓΙΑ ΝΑ ΑΝΕΒΑΣΕΤΕ ΤΗΝ ΠΡΩΤΗ ΣΑΣ ΦΩΤΟΓΡΑΦΙΑ", "no_assets_to_show": "Δεν υπάρχουν στοιχεία προς εμφάνιση", + "no_cast_devices_found": "Δε βρέθηκαν συσκευές μετάδοσης", "no_duplicates_found": "Δεν βρέθηκαν διπλότυπα.", "no_exif_info_available": "Καμία πληροφορία exif διαθέσιμη", "no_explore_results_message": "Ανεβάστε περισσότερες φωτογραφίες για να περιηγηθείτε στη συλλογή σας.", @@ -1293,8 +1311,11 @@ "oldest_first": "Τα παλαιότερα πρώτα", "on_this_device": "Σε αυτή τη συσκευή", "onboarding": "Οδηγός εκκίνησης", - "onboarding_privacy_description": "Οι παρακάτω (προαιρετικές) λειτουργίες βασίζονται σε εξωτερικές υπηρεσίες και μπορούν να απενεργοποιηθούν ανά πάσα στιγμή από τις ρυθμίσεις διαχείρισης.", + "onboarding_locale_description": "Επιλέξτε την γλώσσα που προτιμάτε. Μπορείτε να την αλλάξετε αργότερα από τις ρυθμίσεις.", + "onboarding_privacy_description": "Οι παρακάτω (προαιρετικές) λειτουργίες βασίζονται σε εξωτερικές υπηρεσίες και μπορούν να απενεργοποιηθούν ανά πάσα στιγμή από τις ρυθμίσεις.", + "onboarding_server_welcome_description": "Ας ξεκινήσουμε με μερικές συνηθισμένες ρυθμίσεις.", "onboarding_theme_description": "Επιλέξτε ένα θέμα χρώματος για το προφίλ σας. Μπορείτε να το αλλάξετε αργότερα στις ρυθμίσεις σας.", + "onboarding_user_welcome_description": "Ας ξεκινήσουμε!", "onboarding_welcome_user": "Καλωσόρισες, {user}", "online": "Σε σύνδεση", "only_favorites": "Μόνο αγαπημένα", @@ -1480,6 +1501,7 @@ "remove_from_shared_link": "Αφαίρεση από τον κοινόχρηστο σύνδεσμο", "remove_memory": "Αφαίρεση ανάμνησης", "remove_photo_from_memory": "Αφαίρεση φωτογραφίας από την ανάμνηση", + "remove_tag": "Αφαίρεση ετικέτας", "remove_url": "Αφαίρεση Συνδέσμου", "remove_user": "Αφαίρεση χρήστη", "removed_api_key": "Αφαιρέθηκε το API Key: {name}", @@ -1586,6 +1608,7 @@ "select_album_cover": "Επιλέξτε εξώφυλλο άλμπουμ", "select_all": "Επιλογή όλων", "select_all_duplicates": "Επιλογή όλων των διπλότυπων", + "select_all_in": "Επιλογή όλων στο {group}", "select_avatar_color": "Επιλέξτε χρώμα avatar", "select_face": "Επιλογή προσώπου", "select_featured_photo": "Επιλέξτε φωτογραφία για προβολή", @@ -1606,6 +1629,7 @@ "server_info_box_server_url": "URL διακομιστή", "server_offline": "Διακομιστής Εκτός Σύνδεσης", "server_online": "Διακομιστής Σε Σύνδεση", + "server_privacy": "Απόρρητο Διακομιστή", "server_stats": "Στατιστικά Διακομιστή", "server_version": "Έκδοση Διακομιστή", "set": "Ορισμός", @@ -1615,6 +1639,7 @@ "set_date_of_birth": "Ορισμός ημερομηνίας γέννησης", "set_profile_picture": "Ορισμός εικόνας προφίλ", "set_slideshow_to_fullscreen": "Ορίστε την παρουσίαση σε πλήρη οθόνη", + "set_stack_primary_asset": "Ορισμός ως κύριο στοιχείο", "setting_image_viewer_help": "Το πρόγραμμα προβολής λεπτομερειών φορτώνει πρώτα τη μικρογραφία, στη συνέχεια φορτώνει την προεπισκόπηση μεσαίου μεγέθους (αν είναι ενεργοποιημένη), τέλος φορτώνει το πρωτότυπο (αν είναι ενεργοποιημένο).", "setting_image_viewer_original_subtitle": "Ενεργοποιήστε τη φόρτωση της πρωτότυπης εικόνας πλήρους ανάλυσης (μεγάλη!). Απενεργοποιήστε για να μειώσετε τη χρήση δεδομένων (τόσο στο δίκτυο όσο και στην κρυφή μνήμη της συσκευής).", "setting_image_viewer_original_title": "Φόρτωση πρωτότυπης εικόνας", @@ -1752,6 +1777,7 @@ "start_date": "Από", "state": "Νομός", "status": "Κατάσταση", + "stop_casting": "Διακοπή μετάδοσης", "stop_motion_photo": "Διέκοψε την Φωτογραφία Κίνησης", "stop_photo_sharing": "Διακοπή κοινής χρήσης των φωτογραφιών σας;", "stop_photo_sharing_description": "Ο χρήστης {partner} δεν θα έχει πλέον πρόσβαση στις φωτογραφίες σας.", @@ -1810,7 +1836,7 @@ "to_trash": "Κάδος απορριμμάτων", "toggle_settings": "Εναλλαγή ρυθμίσεων", "total": "Σύνολο", - "total_usage": "Συνολική χρήση", + "total_usage": "Συνολικη χρηση", "trash": "Κάδος απορριμμάτων", "trash_all": "Διαγραφή Όλων", "trash_count": "Διαγραφή {count, number}", @@ -1830,6 +1856,7 @@ "unable_to_setup_pin_code": "Αδυναμία ρύθμισης κωδικού PIN", "unarchive": "Αναίρεση αρχειοθέτησης", "unarchived_count": "{count, plural, other {Αρχειοθετήσεις αναιρέθηκαν #}}", + "undo": "Αναίρεση", "unfavorite": "Αποεπιλογή από τα αγαπημένα", "unhide_person": "Αναίρεση απόκρυψης ατόμου", "unknown": "Άγνωστο", @@ -1846,6 +1873,7 @@ "unsaved_change": "Μη αποθηκευμένη αλλαγή", "unselect_all": "Αποεπιλογή όλων", "unselect_all_duplicates": "Αποεπιλογή όλων των διπλότυπων", + "unselect_all_in": "Αποεπιλογή όλων στο {group}", "unstack": "Αποστοίβαξη", "unstacked_assets_count": "Αποστοιβάξατε {count, plural, one {# στοιχείο} other {# στοιχεία}}", "up_next": "Ακολουθεί", @@ -1875,10 +1903,11 @@ "user_liked": "Στο χρήστη {user} αρέσει {type, select, photo {αυτή η φωτογραφία} video {αυτό το βίντεο} asset {αυτό το αντικείμενο} other {it}}", "user_pin_code_settings": "Κωδικός PIN", "user_pin_code_settings_description": "Διαχειριστείτε τον κωδικό PIN σας", + "user_privacy": "Απόρρητο Χρήστη", "user_purchase_settings": "Αγορά", "user_purchase_settings_description": "Διαχείριση Αγοράς", "user_role_set": "Ορισμός {user} ως {role}", - "user_usage_detail": "Λεπτομέρειες χρήσης του χρήστη", + "user_usage_detail": "Λεπτομερειες χρησης του χρηστη", "user_usage_stats": "Στατιστικά χρήσης λογαριασμού", "user_usage_stats_description": "Προβολή στατιστικών χρήσης λογαριασμού", "username": "Όνομα Χρήστη", diff --git a/i18n/es.json b/i18n/es.json index e551d8af93..7bfdc678df 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "Agregado {count, number} a favoritos", "admin": { "add_exclusion_pattern_description": "Agrega patrones de exclusión. Puedes utilizar los caracteres *, ** y ? (globbing). Ejemplos: para ignorar todos los archivos en cualquier directorio llamado \"Raw\", utiliza \"**/Raw/**\". Para ignorar todos los archivos que terminan en \".tif\", utiliza \"**/*.tif\". Para ignorar una ruta absoluta, utiliza \"/carpeta/a/ignorar/**\".", + "admin_user": "Usuario admin", "asset_offline_description": "Este recurso externo de la biblioteca ya no se encuentra en el disco y se ha movido a la papelera. Si el archivo se movió dentro de la biblioteca, comprueba la línea temporal para el nuevo recurso correspondiente. Para restaurar este recurso, asegúrate de que Immich puede acceder a la siguiente ruta de archivo y escanear la biblioteca.", "authentication_settings": "Parámetros de autenticación", "authentication_settings_description": "Gestionar contraseñas, OAuth y otros parámetros de autenticación", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "Reclamar quota de almacenamiento", "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).", + "oauth_storage_quota_default_description": "Cuota en GiB que se utilizará cuando no se proporcione ninguna por defecto.", "oauth_timeout": "Expiración de solicitud", "oauth_timeout_description": "Tiempo de espera de solicitudes en milisegundos", "password_enable_description": "Iniciar sesión con correo electrónico y contraseña", @@ -243,7 +244,7 @@ "storage_template_migration_info": "La plantilla de almacenamiento convertirá todas las extensiones a minúscula. Los cambios en las plantillas solo se aplican a los elementos nuevos. Para aplicarlos retroactivamente a los elementos subidos previamente ejecute la {job}.", "storage_template_migration_job": "Tarea de migración de la plantilla de almacenamiento", "storage_template_more_details": "Para obtener más detalles sobre esta función, consulte la Plantilla de almacenamiento y sus implicaciones", - "storage_template_onboarding_description": "Cuando está habilitada, esta función organizará automáticamente los archivos según una plantilla definida por el usuario. Debido a problemas de estabilidad, la función se ha desactivado de forma predeterminada. Para obtener más información, consulte la documentación.", + "storage_template_onboarding_description_v2": "Al habilitar esta función, los archivos se organizarán automáticamente según la plantilla definida por el usuario. Para más información, consulte la documentación.", "storage_template_path_length": "Límite aproximado de la longitud de la ruta: {length, number}/{limit, number}", "storage_template_settings": "Plantilla de almacenamiento", "storage_template_settings_description": "Administrar la estructura de carpetas y el nombre de archivo del recurso cargado", @@ -464,9 +465,12 @@ "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_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} no pueden ser añadidos al album", "assets_count": "{count, plural, one {# activo} other {# activos}}", "assets_deleted_permanently": "{count} elemento(s) eliminado(s) permanentemente", "assets_deleted_permanently_from_server": "{count} recurso(s) eliminado(s) de forma permanente del servidor de Immich", + "assets_downloaded_failed": "{count, plural, one {Descargado archivo # - {error} archivo fallido} other {Descargados # archivos - {error} archivos fallidos}}", + "assets_downloaded_successfully": "{count, plural, one {Archivo # descargado correctamente} other {Archivos # descargados correctamente}}", "assets_moved_to_trash_count": "{count, plural, one {# elemento movido} other {# elementos movidos}} a la papelera", "assets_permanently_deleted_count": "Eliminado permanentemente {count, plural, one {# elemento} other {# elementos}}", "assets_removed_count": "Eliminado {count, plural, one {# elemento} other {# elementos}}", @@ -745,7 +749,6 @@ "direction": "Dirección", "disabled": "Deshabilitado", "disallow_edits": "Bloquear edición", - "discord": "Discord", "discover": "Descubrir", "discovered_devices": "Dispositivos descubiertos", "dismiss_all_errors": "Descartar todos los errores", @@ -1094,7 +1097,7 @@ "ios_debug_info_last_sync_at": "Última sincronización en {dateTime}", "ios_debug_info_no_processes_queued": "Ningún proceso de fondo encolado", "ios_debug_info_no_sync_yet": "Todavía no se ha ejecutado ningún trabajo de sincronización en segundo plano", - "ios_debug_info_processes_queued": "{count, plural, un {{count} proceso de segundo plano en cola} otros {{count} procesos de segundo plano en cola}}", + "ios_debug_info_processes_queued": "{count, plural, one {{count} proceso encolado de fondo} other {{count} procesos encolados de fondo}}", "ios_debug_info_processing_ran_at": "El procesamiento se ejecutó el {dateTime}", "items_count": "{count, plural, one {# elemento} other {# elementos}}", "jobs": "Tareas", @@ -1146,6 +1149,7 @@ "locked_folder": "Carpeta bloqueada", "log_out": "Cerrar sesión", "log_out_all_devices": "Cerrar sesión en todos los dispositivos", + "logged_in_as": "Sesión iniciada como {user}", "logged_out_all_devices": "Se ha cerrado la sesión en todos los dispositivos", "logged_out_device": "Dispositivo desconectado", "login": "Inicio de sesión", @@ -1273,7 +1277,7 @@ "no_archived_assets_message": "Archive fotos y videos para ocultarlos de su vista de Fotos", "no_assets_message": "HAZ CLIC PARA SUBIR TU PRIMERA FOTO", "no_assets_to_show": "No hay elementos a mostrar", - "no_cast_devices_found": "Dispositivos de cast no encontrados", + "no_cast_devices_found": "Dispositivos de difusión no encontrados", "no_duplicates_found": "No se encontraron duplicados.", "no_exif_info_available": "No hay información exif disponible", "no_explore_results_message": "Sube más fotos para explorar tu colección.", @@ -1496,6 +1500,7 @@ "remove_from_shared_link": "Eliminar desde enlace compartido", "remove_memory": "Quitar memoria", "remove_photo_from_memory": "Quitar foto de esta memoria", + "remove_tag": "Quitar etiqueta", "remove_url": "Eliminar URL", "remove_user": "Eliminar usuario", "removed_api_key": "Clave API eliminada: {name}", @@ -1602,6 +1607,7 @@ "select_album_cover": "Seleccionar portada del álbum", "select_all": "Seleccionar todo", "select_all_duplicates": "Seleccionar todos los duplicados", + "select_all_in": "Selecciona todos en {group}", "select_avatar_color": "Seleccionar color del avatar", "select_face": "Seleccionar cara", "select_featured_photo": "Seleccionar foto principal", @@ -1770,6 +1776,7 @@ "start_date": "Fecha de inicio", "state": "Estado", "status": "Estado", + "stop_casting": "Parar difusión", "stop_motion_photo": "Parar foto en movimiento", "stop_photo_sharing": "¿Dejar de compartir tus fotos?", "stop_photo_sharing_description": "{partner} ya no podrá acceder a tus fotos.", @@ -1865,6 +1872,7 @@ "unsaved_change": "Cambio no guardado", "unselect_all": "Limpiar selección", "unselect_all_duplicates": "Deseleccionar todos los duplicados", + "unselect_all_in": "Deselecciona todos en {group}", "unstack": "Desapilar", "unstacked_assets_count": "Desapilado(s) {count, plural, one {# elemento} other {# elementos}}", "up_next": "A continuación", diff --git a/i18n/et.json b/i18n/et.json index 6c8ea40260..a2b17c6138 100644 --- a/i18n/et.json +++ b/i18n/et.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "{count, number} pilti lisatud lemmikutesse", "admin": { "add_exclusion_pattern_description": "Lisa välistamismustreid. Toetatud on metamärgid *, ** ja ?. Kõikide kataloogis nimega \"Raw\" olevate failide ignoreerimiseks kasuta \"**/Raw/**\". Kõikide .tif failide ignoreerimiseks kasuta \"**/*.tif\". Absouutse tee ignoreerimiseks kasuta \"/path/to/ignore/**\".", + "admin_user": "Administraator", "asset_offline_description": "Seda välise kogu üksust ei leitud kettalt ning see liigutati prügikasti. Kui faili asukoht kogu siseselt muutus, leiad vastava uue üksuse oma ajajoonelt. Üksuse taastamiseks veendu, et allpool toodud failitee on Immich'ile kättesaadav ning skaneeri kogu uuesti.", "authentication_settings": "Autentimise seaded", "authentication_settings_description": "Halda parooli, OAuth ja muid autentimise seadeid", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "Talletuskvoodi väide", "oauth_storage_quota_claim_description": "Sea kasutaja talletuskvoodiks automaatselt selle väite väärtus.", "oauth_storage_quota_default": "Vaikimisi talletuskvoot (GiB)", - "oauth_storage_quota_default_description": "Kvoot (GiB), mida kasutada, kui ühtegi väidet pole esitatud (piiramatu kvoodi jaoks sisesta 0).", + "oauth_storage_quota_default_description": "Kvoot (GiB), mida kasutada, kui ühtegi väidet pole esitatud.", "oauth_timeout": "Päringu ajalõpp", "oauth_timeout_description": "Päringute ajalõpp millisekundites", "password_enable_description": "Logi sisse e-posti aadressi ja parooliga", @@ -243,7 +244,7 @@ "storage_template_migration_info": "Talletusmall teeb kõik faililaiendid väiketähtedeks. Malli muudatused rakenduvad ainult uutele üksustele. Et rakendada malli tagasiulatuvalt varem üleslaaditud üksustele, käivita {job}.", "storage_template_migration_job": "Talletusmallide migreerimise tööde", "storage_template_more_details": "Et selle funktsiooni kohta rohkem teada saada, loe talletusmallide ja nende tagajärgede kohta", - "storage_template_onboarding_description": "Kui sisse lülitatud, võimaldab see faile kasutaja määratud malli alusel automaatselt organiseerida. Stabiilsusprobleemide tõttu on see funktsioon vaikimisi välja lülitatud. Rohkem infot leiad dokumentatsioonist.", + "storage_template_onboarding_description_v2": "Kui lubatud, organiseeritakse failid automaatselt kasutaja määratud malli alusel. Rohkem infot leiad dokumentatsioonist.", "storage_template_path_length": "Tee pikkuse umbkaudne limiit: {length, number}/{limit, number}", "storage_template_settings": "Talletusmall", "storage_template_settings_description": "Halda üleslaaditud üksuse kaustastruktuuri ja failinime", @@ -1149,6 +1150,7 @@ "locked_folder": "Lukustatud kaust", "log_out": "Logi välja", "log_out_all_devices": "Logi kõigist seadmetest välja", + "logged_in_as": "Logitud sisse kasutajana {user}", "logged_out_all_devices": "Kõigist seadmetest välja logitud", "logged_out_device": "Seadmest välja logitud", "login": "Logi sisse", @@ -1606,6 +1608,7 @@ "select_album_cover": "Vali albumi kaanepilt", "select_all": "Vali kõik", "select_all_duplicates": "Vali kõik duplikaadid", + "select_all_in": "Vali kõik grupis {group}", "select_avatar_color": "Vali avatari värv", "select_face": "Vali nägu", "select_featured_photo": "Vali esiletõstetud foto", @@ -1870,6 +1873,7 @@ "unsaved_change": "Salvestamata muudatus", "unselect_all": "Ära vali ühtegi", "unselect_all_duplicates": "Ära vali duplikaate", + "unselect_all_in": "Ära vali ühtegi grupis {group}", "unstack": "Eralda", "unstacked_assets_count": "{count, plural, one {# üksus} other {# üksust}} eraldatud", "up_next": "Järgmine", diff --git a/i18n/fa.json b/i18n/fa.json index 79935d4892..84857479b3 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -192,7 +192,6 @@ "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": "مدیریت ساختار پوشه و نام فایل دارایی بارگذاری شده", diff --git a/i18n/fi.json b/i18n/fi.json index 763105ab85..f41d0cf631 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -22,6 +22,7 @@ "add_partner": "Lisää kumppani", "add_path": "Lisää polku", "add_photos": "Lisää kuvia", + "add_tag": "Lisää tunniste", "add_to": "Lisää…", "add_to_album": "Lisää albumiin", "add_to_album_bottom_sheet_added": "Lisätty albumiin {album}", @@ -33,6 +34,7 @@ "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/**\".", + "admin_user": "Ylläpitäjä", "asset_offline_description": "Ulkoista kirjaston resurssia ei enää löydy levyltä, ja se on siirretty roskakoriin. Jos tiedosto siirrettiin kirjaston sisällä, tarkista aikajanaltasi uusi vastaava resurssi. Palautaaksesi tämän resurssin, varmista, että alla oleva tiedostopolku on Immichin käytettävissä ja skannaa kirjasto uudelleen.", "authentication_settings": "Autentikointiasetukset", "authentication_settings_description": "Hallitse salasana-, OAuth- ja muut autentikoinnin asetukset", @@ -169,7 +171,7 @@ "note_apply_storage_label_previous_assets": "Huom: Asettaaksesi nimikkeen aiemmin ladatulle aineistolle, aja", "note_cannot_be_changed_later": "Huom: Tätä ei voi enää myöhemmin vaihtaa!", "notification_email_from_address": "Lähettäjän osoite", - "notification_email_from_address_description": "Lähettäjän sähköpostiosoite. Esimerkiksi \"Immich-kuvapalvelin \"", + "notification_email_from_address_description": "Lähettäjän sähköpostiosoite. Esimerkiksi \"Immich-kuvapalvelin \". Varmista, että käytetystä osoiteesta on lupa lähettää sähköposteja.", "notification_email_host_description": "Sähköpostipalvelin (esim. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Älä huomioi varmennevirheitä", "notification_email_ignore_certificate_errors_description": "Älä huomioi TLS-varmenteiden validointivirheitä (ei suositeltu)", @@ -202,7 +204,7 @@ "oauth_storage_quota_claim": "Tallennustilan kiintiön väittämä (claim)", "oauth_storage_quota_claim_description": "Aseta automaattisesti käyttäjien tallennustilan määrä tähän arvoon.", "oauth_storage_quota_default": "Tallennustilan oletuskiintiö (Gt)", - "oauth_storage_quota_default_description": "Käytettävä kiintiön määrä gigatavuissa, käytetään kun väittämää ei ole annettu (0 rajoittamaton kiintiö).", + "oauth_storage_quota_default_description": "Käytettävä kiintiön määrä gigatavuissa, kun väittämää ei ole annettu.", "oauth_timeout": "Pyynnön aikakatkaisu", "oauth_timeout_description": "Pyyntöjen aikakatkaisu millisekunteina", "password_enable_description": "Kirjaudu käyttäen sähköpostiosoitetta ja salasanaa", @@ -242,7 +244,6 @@ "storage_template_migration_info": "Tallennusmalli muuntaa kaikki tiedostopäätteet pieniksi kirjaimiksi. Mallipohjan muutokset koskevat vain uusia resursseja. Jos haluat käyttää mallipohjaa takautuvasti aiemmin ladattuihin resursseihin, suorita {job}.", "storage_template_migration_job": "Tallennustilan mallin muutostyö", "storage_template_more_details": "Saadaksesi lisätietoa tästä ominaisuudesta, katso Tallennustilan Mallit sekä mihin se vaikuttaa", - "storage_template_onboarding_description": "Kun tämä ominaisuus on käytössä, se järjestää tiedostot automaattisesti käyttäjän määrittämän mallin perusteella. Vakausongelmien vuoksi ominaisuus on oletuksena poistettu käytöstä. Lisätietoja on dokumentaatiossa.", "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ä", diff --git a/i18n/fr.json b/i18n/fr.json index 59cfc9b1c6..bf57469944 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "{count, number} ajouté(s) aux favoris", "admin": { "add_exclusion_pattern_description": "Ajouter des schémas d'exclusion. Les caractères génériques *, ** et ? sont pris en charge. Pour ignorer tous les fichiers dans un répertoire nommé « Raw », utilisez « **/Raw/** ». Pour ignorer tous les fichiers se terminant par « .tif », utilisez « **/*.tif ». Pour ignorer un chemin absolu, utilisez « /chemin/à/ignorer/** ».", + "admin_user": "Administrateur", "asset_offline_description": "Ce média de la bibliothèque externe n'est plus présent sur le disque et a été déplacé vers la corbeille. Si le fichier a été déplacé dans la bibliothèque, vérifiez votre chronologie pour le nouveau média correspondant. Pour restaurer ce média, veuillez vous assurer que le chemin du fichier ci-dessous peut être accédé par Immich et lancez l'analyse de la bibliothèque.", "authentication_settings": "Paramètres d'authentification", "authentication_settings_description": "Gérer le mot de passe, l'authentification OAuth et d'autres paramètres d'authentification", @@ -167,7 +168,7 @@ "migration_job_description": "Migration des miniatures pour les médias et les visages vers la dernière structure de dossiers", "no_paths_added": "Aucun chemin n'a été ajouté", "no_pattern_added": "Aucun schéma d'exclusion n'a été ajouté", - "note_apply_storage_label_previous_assets": "Remarque : pour appliquer l'étiquette de stockage à des médias précédemment téléversés, exécutez", + "note_apply_storage_label_previous_assets": "Remarque : pour appliquer l'étiquette de stockage à des médias précédemment envoyés, exécutez", "note_cannot_be_changed_later": "REMARQUE : Il n'est pas possible de modifier ce paramètre ultérieurement !", "notification_email_from_address": "Depuis l'adresse", "notification_email_from_address_description": "Adresse courriel de l'expéditeur, par exemple : « Serveur de photos Immich  ». Assurez-vous d'utiliser une adresse à partir de laquelle vous pouvez envoyer des courriels.", @@ -194,7 +195,7 @@ "oauth_enable_description": "Connexion avec OAuth", "oauth_mobile_redirect_uri": "URI de redirection mobile", "oauth_mobile_redirect_uri_override": "Remplacer l'URI de redirection mobile", - "oauth_mobile_redirect_uri_override_description": "Activer quand le fournisseur d'OAuth ne permet pas un URI mobile, comme '{callback} '", + "oauth_mobile_redirect_uri_override_description": "Activer quand le fournisseur d'OAuth ne permet pas un URI mobile, comme ''{callback}''", "oauth_settings": "OAuth", "oauth_settings_description": "Gérer les paramètres de connexion OAuth", "oauth_settings_more_details": "Pour plus de détails sur cette fonctionnalité, consultez ce lien.", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "Demande de quota de stockage", "oauth_storage_quota_claim_description": "Définir automatiquement le quota de stockage de l'utilisateur par la valeur de cette demande.", "oauth_storage_quota_default": "Quota de stockage par défaut (Go)", - "oauth_storage_quota_default_description": "Quota en Go à utiliser lorsqu'aucune valeur n'est précisée (saisir 0 pour un quota illimité).", + "oauth_storage_quota_default_description": "Quota en Gio à utiliser lorsqu'aucune valeur n'est précisée.", "oauth_timeout": "Expiration de la durée de la requête", "oauth_timeout_description": "Délai d'expiration des requêtes en millisecondes", "password_enable_description": "Connexion avec courriel et mot de passe", @@ -239,14 +240,14 @@ "storage_template_hash_verification_enabled": "Vérification du hachage activée", "storage_template_hash_verification_enabled_description": "Active la vérification du hachage, ne désactivez pas cette option à moins d'être sûr de ce que vous faites", "storage_template_migration": "Migration du modèle de stockage", - "storage_template_migration_description": "Appliquer le modèle courant {template} aux médias précédemment téléversés", - "storage_template_migration_info": "L'enregistrement des modèles va convertir toutes les extensions en minuscule. Les changements de modèle ne s'appliqueront qu'aux nouveaux médias. Pour appliquer rétroactivement le modèle aux médias précédemment téléversés, exécutez la tâche {job}.", + "storage_template_migration_description": "Appliquer le modèle courant {template} aux médias précédemment envoyés", + "storage_template_migration_info": "L'enregistrement des modèles va convertir toutes les extensions en minuscule. Les changements de modèle ne s'appliqueront qu'aux nouveaux médias. Pour appliquer rétroactivement le modèle aux médias précédemment envoyés, exécutez la tâche {job}.", "storage_template_migration_job": "Tâche de migration du modèle de stockage", "storage_template_more_details": "Pour plus de détails sur cette fonctionnalité, reportez-vous au Modèle de stockage et à ses implications", - "storage_template_onboarding_description": "Lorsqu'elle est activée, cette fonctionnalité réorganise les fichiers basés sur un modèle défini par l'utilisateur. En raison de problèmes de stabilité, la fonction a été désactivée par défaut. Pour plus d'informations, veuillez consulter la documentation.", + "storage_template_onboarding_description_v2": "Quand elle est activée, cette fonctionnalité organise automatiquement les fichiers, sur base d'un modèle défini par l'utilisateur. Pour plus d'informations, se répéter à la documentation.", "storage_template_path_length": "Limite approximative de la longueur du chemin : {length, number}/{limit, number}", "storage_template_settings": "Modèle de stockage", - "storage_template_settings_description": "Gérer la structure des dossiers et le nom des fichiers du média téléversé", + "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", @@ -413,7 +414,7 @@ "allow_dark_mode": "Autoriser le mode sombre", "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 le téléversement aux utilisateurs non connectés", + "allow_public_user_to_upload": "Autoriser l'envoi aux utilisateurs non connectés", "alt_text_qr_code": "Image du code QR", "anti_clockwise": "Sens anti-horaire", "api_key": "Clé API", @@ -456,8 +457,8 @@ "asset_restored_successfully": "Élément restauré avec succès", "asset_skipped": "Sauté", "asset_skipped_in_trash": "À la corbeille", - "asset_uploaded": "Téléversé", - "asset_uploading": "Téléversement…", + "asset_uploaded": "Envoyé", + "asset_uploading": "Envoi…", "asset_viewer_settings_subtitle": "Modifier les paramètres du visualiseur photos", "asset_viewer_settings_title": "Visualiseur d'éléments", "assets": "Médias", @@ -498,11 +499,11 @@ "backup_all": "Tout", "backup_background_service_backup_failed_message": "Échec de la sauvegarde des médias. Nouvelle tentative…", "backup_background_service_connection_failed_message": "Impossible de se connecter au serveur. Nouvelle tentative…", - "backup_background_service_current_upload_notification": "Téléversement de {filename}", + "backup_background_service_current_upload_notification": "Envoi de {filename}", "backup_background_service_default_notification": "Recherche de nouveaux médias…", "backup_background_service_error_title": "Erreur de sauvegarde", "backup_background_service_in_progress_notification": "Sauvegarde de vos médias…", - "backup_background_service_upload_failure_notification": "Échec lors du téléversement de {filename}", + "backup_background_service_upload_failure_notification": "Échec lors de l'envoi de {filename}", "backup_controller_page_albums": "Sauvegarder les albums", "backup_controller_page_background_app_refresh_disabled_content": "Activez le rafraîchissement de l'application en arrière-plan dans Paramètres > Général > Rafraîchissement de l'application en arrière-plan afin d'utiliser la sauvegarde en arrière-plan.", "backup_controller_page_background_app_refresh_disabled_title": "Rafraîchissement de l'application en arrière-plan désactivé", @@ -524,7 +525,7 @@ "backup_controller_page_backup_selected": "Sélectionné : ", "backup_controller_page_backup_sub": "Photos et vidéos sauvegardées", "backup_controller_page_created": "Créé le : {date}", - "backup_controller_page_desc_backup": "Activez la sauvegarde au premier plan pour téléverser automatiquement les nouveaux médias sur le serveur lors de l'ouverture de l'application.", + "backup_controller_page_desc_backup": "Activez la sauvegarde au premier plan pour envoyer automatiquement les nouveaux médias sur le serveur lors de l'ouverture de l'application.", "backup_controller_page_excluded": "Exclus : ", "backup_controller_page_failed": "Échec de l'opération ({count})", "backup_controller_page_filename": "Nom du fichier : {filename} [{size}]", @@ -542,15 +543,15 @@ "backup_controller_page_total_sub": "Toutes les photos et vidéos uniques des albums sélectionnés", "backup_controller_page_turn_off": "Désactiver la sauvegarde", "backup_controller_page_turn_on": "Activer la sauvegarde", - "backup_controller_page_uploading_file_info": "Téléversement des informations du fichier", + "backup_controller_page_uploading_file_info": "Envoi des informations du fichier", "backup_err_only_album": "Impossible de retirer le seul album", "backup_info_card_assets": "éléments", "backup_manual_cancelled": "Annulé", - "backup_manual_in_progress": "Téléversement déjà en cours. Réessayez plus tard", + "backup_manual_in_progress": "Envoi déjà en cours. Réessayez plus tard", "backup_manual_success": "Succès", - "backup_manual_title": "Statut du téléversement", + "backup_manual_title": "Statut de l'envoi", "backup_options_page_title": "Options de sauvegarde", - "backup_setting_subtitle": "Ajuster les paramètres de téléversement au premier et en arrière-plan", + "backup_setting_subtitle": "Ajuster les paramètres d'envoi au premier et en arrière-plan", "backward": "Arrière", "biometric_auth_enabled": "Authentification biométrique activée", "biometric_locked_out": "L'authentification biométrique est verrouillé", @@ -780,7 +781,7 @@ "downloading": "Téléchargement", "downloading_asset_filename": "Téléchargement du média {filename}", "downloading_media": "Téléchargement du média", - "drop_files_to_upload": "Déposez les fichiers n'importe où pour téléverser", + "drop_files_to_upload": "Déposez les fichiers n'importe où pour envoyer", "duplicates": "Doublons", "duplicates_description": "Examiner chaque groupe et indiquer s'il y a des doublons", "duration": "Durée", @@ -948,7 +949,7 @@ "unable_to_update_settings": "Impossible de mettre à jour les paramètres", "unable_to_update_timeline_display_status": "Impossible de mettre à jour le statut d'affichage de la vue chronologique", "unable_to_update_user": "Impossible de mettre à jour l'utilisateur", - "unable_to_upload_file": "Impossible de téléverser le fichier" + "unable_to_upload_file": "Impossible d'envoyer le fichier" }, "exif": "Exif", "exif_bottom_sheet_description": "Ajouter une description...", @@ -1050,12 +1051,12 @@ "home_page_locked_error_local": "Impossible de déplacer l'objet vers le dossier verrouillé, passer", "home_page_locked_error_partner": "Impossible de déplacer l'objet du collaborateur vers le dossier verrouillé, opération ignorée", "home_page_share_err_local": "Impossible de partager par lien les médias locaux, ils sont ignorés", - "home_page_upload_err_limit": "Impossible de téléverser plus de 30 médias en même temps, demande ignorée", + "home_page_upload_err_limit": "Impossible d'envoyer plus de 30 médias en même temps, demande ignorée", "host": "Hôte", "hour": "Heure", "id": "ID", "ignore_icloud_photos": "Ignorer les photos iCloud", - "ignore_icloud_photos_description": "Les photos stockées sur iCloud ne sont pas téléversées sur le serveur Immich", + "ignore_icloud_photos_description": "Les photos stockées sur iCloud ne seront pas envoyées sur le serveur Immich", "image": "Image", "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} prise le {date}", "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} prise avec {person1} le {date}", @@ -1135,7 +1136,7 @@ "list": "Liste", "loading": "Chargement", "loading_search_results_failed": "Chargement des résultats échoué", - "local_asset_cast_failed": "Impossible de diffuser un appareil qui n'a pas téléversé vers le serveur", + "local_asset_cast_failed": "Impossible de caster un média qui n'a pas envoyé vers le serveur", "local_network": "Réseau local", "local_network_sheet_info": "L'application va se connecter au serveur via cette URL quand l'appareil est connecté à ce réseau Wi-Fi", "location_permission": "Autorisation de localisation", @@ -1149,6 +1150,7 @@ "locked_folder": "Dossier verrouillé", "log_out": "Se déconnecter", "log_out_all_devices": "Déconnecter tous les appareils", + "logged_in_as": "Connecté en tant que {user}", "logged_out_all_devices": "Déconnecté de tous les appareils", "logged_out_device": "Déconnecté de l'appareil", "login": "Connexion", @@ -1274,12 +1276,12 @@ "no_albums_with_name_yet": "Il semble que vous n'ayez pas encore d'albums avec ce nom.", "no_albums_yet": "Il semble que vous n'ayez pas encore d'album.", "no_archived_assets_message": "Archiver des photos et vidéos pour les masquer dans votre bibliothèque", - "no_assets_message": "CLIQUER ICI POUR TÉLÉVERSER VOTRE PREMIÈRE PHOTO", + "no_assets_message": "CLIQUEZ POUR ENVOYER VOTRE PREMIÈRE PHOTO", "no_assets_to_show": "Aucun élément à afficher", "no_cast_devices_found": "Aucun appareil de diffusion trouvé", "no_duplicates_found": "Aucun doublon n'a été trouvé.", "no_exif_info_available": "Aucune information exif disponible", - "no_explore_results_message": "Téléversez plus de photos pour explorer votre bibliothèque.", + "no_explore_results_message": "Envoyez plus de photos pour explorer votre bibliothèque.", "no_favorites_message": "Ajouter des photos et vidéos à vos favoris pour les retrouver plus rapidement", "no_libraries_message": "Créer une bibliothèque externe pour voir vos photos et vidéos dans un autre espace de stockage", "no_locked_photos_message": "Les photos et vidéos du dossier verrouillé sont masqués et ne s'afficheront pas dans votre galerie ou la recherche.", @@ -1292,7 +1294,7 @@ "no_shared_albums_message": "Créer un album pour partager vos photos et vidéos avec les personnes de votre réseau", "not_in_any_album": "Dans aucun album", "not_selected": "Non sélectionné", - "note_apply_storage_label_to_previously_uploaded assets": "Note : Pour appliquer l'étiquette de stockage aux médias déjà téléversés, exécutez", + "note_apply_storage_label_to_previously_uploaded assets": "Note : Pour appliquer l'étiquette de stockage aux médias précédemment envoyés, exécutez", "notes": "Notes", "nothing_here_yet": "Rien pour le moment", "notification_permission_dialog_content": "Pour activer les notifications, allez dans Paramètres et sélectionnez Autoriser.", @@ -1512,7 +1514,7 @@ "rename": "Renommer", "repair": "Réparer", "repair_no_results_message": "Les fichiers non importés ou absents s'afficheront ici", - "replace_with_upload": "Remplacer par téléversement", + "replace_with_upload": "Remplacer avec l'envoi", "repository": "Dépôt", "require_password": "Demander le mot de passe", "require_user_to_change_password_on_first_login": "Demander à l'utilisateur de changer son mot de passe lors de sa première connexion", @@ -1529,7 +1531,7 @@ "restore_user": "Restaurer l'utilisateur", "restored_asset": "Média restauré", "resume": "Reprendre", - "retry_upload": "Réessayer le téléversement", + "retry_upload": "Réessayer l'envoi", "review_duplicates": "Consulter les doublons", "role": "Rôle", "role_editor": "Éditeur", @@ -1606,6 +1608,7 @@ "select_album_cover": "Sélectionner la couverture d'album", "select_all": "Tout sélectionner", "select_all_duplicates": "Sélectionner tous les doublons", + "select_all_in": "Tout sélectionner dans {group}", "select_avatar_color": "Sélectionner la couleur de l'avatar", "select_face": "Sélectionner le visage", "select_featured_photo": "Sélectionner la photo de profil de cette personne", @@ -1651,10 +1654,10 @@ "setting_notifications_notify_minutes": "{count} minutes", "setting_notifications_notify_never": "jamais", "setting_notifications_notify_seconds": "{count} secondes", - "setting_notifications_single_progress_subtitle": "Informations détaillées sur la progression du téléversement par média", + "setting_notifications_single_progress_subtitle": "Informations détaillées sur la progression de l'envoi par média", "setting_notifications_single_progress_title": "Afficher la progression du détail de la sauvegarde en arrière-plan", "setting_notifications_subtitle": "Ajustez vos préférences de notification", - "setting_notifications_total_progress_subtitle": "Progression globale du téléversement (effectué/total des médias)", + "setting_notifications_total_progress_subtitle": "Progression globale de l'envoi (effectué/total des médias)", "setting_notifications_total_progress_title": "Afficher la progression totale de la sauvegarde en arrière-plan", "setting_video_viewer_looping_title": "Boucle", "setting_video_viewer_original_video_subtitle": "Lors de la diffusion d'une vidéo depuis le serveur, lisez l'original même si un transcodage est disponible. Cela peut entraîner de la mise en mémoire tampon. Les vidéos disponibles localement sont lues en qualité d'origine, quel que soit ce paramètre.", @@ -1680,7 +1683,7 @@ "shared_by_user": "Partagé par {user}", "shared_by_you": "Partagé par vous", "shared_from_partner": "Photos de {partner}", - "shared_intent_upload_button_progress_text": "{current} / {total} Téléversé(s)", + "shared_intent_upload_button_progress_text": "{current} / {total} Envoyé(s)", "shared_link_app_bar_title": "Liens partagés", "shared_link_clipboard_copied_massage": "Copié dans le presse-papier", "shared_link_clipboard_text": "Lien : {link}\nMot de passe : {password}", @@ -1792,8 +1795,8 @@ "swap_merge_direction": "Inverser la direction de fusion", "sync": "Synchroniser", "sync_albums": "Synchroniser dans des albums", - "sync_albums_manual_subtitle": "Synchroniser toutes les vidéos et photos téléversées dans les albums sélectionnés", - "sync_upload_album_setting_subtitle": "Crée et téléverse vos photos et vidéos dans les albums sélectionnés sur Immich", + "sync_albums_manual_subtitle": "Synchroniser toutes les vidéos et photos envoyées dans les albums sélectionnés", + "sync_upload_album_setting_subtitle": "Créez et envoyez vos photos et vidéos dans les albums sélectionnés sur Immich", "tag": "Étiquette", "tag_assets": "Étiqueter les médias", "tag_created": "Étiquette créée : {tag}", @@ -1870,24 +1873,25 @@ "unsaved_change": "Modification non enregistrée", "unselect_all": "Annuler la sélection", "unselect_all_duplicates": "Désélectionner tous les doublons", + "unselect_all_in": "Tout désélectionner dans {group}", "unstack": "Désempiler", "unstacked_assets_count": "{count, plural, one {# média dépilé} other {# médias dépilés}}", "up_next": "Suite", "updated_at": "Mis à jour à", "updated_password": "Mot de passe mis à jour", - "upload": "Téléverser", - "upload_concurrency": "Téléversements simultanés", + "upload": "Envoyer", + "upload_concurrency": "Envois simultanés", "upload_dialog_info": "Voulez-vous sauvegarder la sélection vers le serveur ?", - "upload_dialog_title": "Téléverser le média", - "upload_errors": "Le téléversement s'est achevé avec {count, plural, one {# erreur} other {# erreurs}}. Rafraîchir la page pour voir les nouveaux médias téléversés.", + "upload_dialog_title": "Envoyer le média", + "upload_errors": "L'envoi s'est complété avec {count, plural, one {# erreur} other {# erreurs}}. Rafraîchissez la page pour voir les nouveaux médias envoyés.", "upload_progress": "{remaining, number} restant(s) - {processed, number} traité(s)/{total, number}", "upload_skipped_duplicates": "{count, plural, one {# doublon ignoré} other {# doublons ignorés}}", "upload_status_duplicates": "Doublons", "upload_status_errors": "Erreurs", - "upload_status_uploaded": "Téléversé", - "upload_success": "Téléversement réussi. Rafraîchir la page pour voir les nouveaux médias téléversés.", - "upload_to_immich": "Téléverser vers Immich ({count})", - "uploading": "Téléversement en cours", + "upload_status_uploaded": "Envoyé", + "upload_success": "Envoi réussi. Rafraîchissez la page pour voir les nouveaux médias envoyés.", + "upload_to_immich": "Envoyer vers Immich ({count})", + "uploading": "Envoi", "url": "URL", "usage": "Utilisation", "use_biometric": "Utiliser l'authentification biométrique", diff --git a/i18n/gl.json b/i18n/gl.json index 6b388a4a17..558cc48900 100644 --- a/i18n/gl.json +++ b/i18n/gl.json @@ -239,7 +239,6 @@ "storage_template_migration_info": "O modelo de almacenamento converterá todas as extensións a minúsculas. Os cambios no modelo só se aplicarán aos activos novos. Para aplicar retroactivamente o modelo aos activos cargados previamente, execute o {job}.", "storage_template_migration_job": "Traballo de Migración do Modelo de Almacenamento", "storage_template_more_details": "Para máis detalles sobre esta función, consulte o Modelo de Almacenamento e as súas implicacións", - "storage_template_onboarding_description": "Cando estea activada, esta función autoorganizará os ficheiros baseándose nun modelo definido polo usuario. Debido a problemas de estabilidade, a función desactivouse por defecto. Para obter máis información, consulte a documentación.", "storage_template_path_length": "Límite aproximado da lonxitude da ruta: {length, number}/{limit, number}", "storage_template_settings": "Modelo de Almacenamento", "storage_template_settings_description": "Xestionar a estrutura de cartafoles e o nome de ficheiro do activo cargado", diff --git a/i18n/he.json b/i18n/he.json index 4f8e5a83ad..737c307f31 100644 --- a/i18n/he.json +++ b/i18n/he.json @@ -243,7 +243,6 @@ "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": "ניהול מבנה התיקיות ואת שם הקובץ של התמונה שהועלתה", diff --git a/i18n/hi.json b/i18n/hi.json index e99a33eca6..abb1552154 100644 --- a/i18n/hi.json +++ b/i18n/hi.json @@ -242,7 +242,6 @@ "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": "अपलोड संपत्ति की फ़ोल्डर संरचना और फ़ाइल नाम प्रबंधित करें", diff --git a/i18n/hr.json b/i18n/hr.json index 4f9fe21697..d2edfc9a8e 100644 --- a/i18n/hr.json +++ b/i18n/hr.json @@ -243,7 +243,6 @@ "storage_template_migration_info": "Predložak za pohranu će sve nastavke (ekstenzije) pretvoriti u mala slova. Promjene predloška primjenjivat će se samo na nova sredstva. Za retroaktivnu primjenu predloška na prethodno prenesena sredstva, pokrenite {job}.", "storage_template_migration_job": "Posao Migracije Predloška Pohrane", "storage_template_more_details": "Za više pojedinosti o ovoj značajci pogledajte Predložak pohrane i njegove implikacije", - "storage_template_onboarding_description": "Kada je omogućena, ova će značajka automatski organizirati datoteke na temelju korisnički definiranog predloška. Zbog problema sa stabilnošću značajka je isključena prema zadanim postavkama. Za više informacija pogledajte dokumentaciju.", "storage_template_path_length": "Približno ograničenje duljine putanje: {length, number}/{limit, number}", "storage_template_settings": "Predložak pohrane", "storage_template_settings_description": "Upravljajte strukturom mape i nazivom datoteke učitanog sredstva", diff --git a/i18n/hu.json b/i18n/hu.json index 97e85c9b15..22ec6f22a1 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -242,7 +242,6 @@ "storage_template_migration_info": "A sablon az összes kiterjesztést kisbetűssé alakítja át. A megváltozott sablon csak az újonnan feltöltött elemekre vonatkozik. A korábbi elemek visszamenőleges áthelyezéséhez ezt futtasd: {job}.", "storage_template_migration_job": "Tárhely Sablon Migrációja", "storage_template_more_details": "További részletekért erről a funkcióról lásd a Tárhely Sablon és annak következményeit a dokumentációban", - "storage_template_onboarding_description": "Ha ez a funkció engedélyezve van, akkor a fájlokat automatikusan az egyéni sablon alapján rendszerezi el. Stabilitási problémák miatt a funkció alapértelmezés szerint ki van kapcsolva. További információkért lásd a dokumentációt.", "storage_template_path_length": "Útvonal hozzávetőleges maximális hossza: {length, number}{limit, number}", "storage_template_settings": "Tárhely Sablon", "storage_template_settings_description": "A feltöltött elemek mappaszerkezetének és fájl elnevezésének kezelése", diff --git a/i18n/id.json b/i18n/id.json index 8596a8e660..4d15ef01e9 100644 --- a/i18n/id.json +++ b/i18n/id.json @@ -243,7 +243,6 @@ "storage_template_migration_info": "Templat penyimpanan akan mengubah semua ekstensi ke huruf kecil. Perubahan templat hanya akan diterapkan pada aset baru. Untuk menerapkan templat pada setiap aset yang sebelumnya telah diunggah, jalankan {job}.", "storage_template_migration_job": "Tugas Migrasi Templat Ruang Penyimpanan", "storage_template_more_details": "Untuk detail lebih lanjut tentang fitur ini, pergi ke Templat Penyimpanan dan kekurangannya", - "storage_template_onboarding_description": "Ketika diaktifkan, fitur ini akan mengelola berkas secara otomatis berdasarkan templat pengguna. Karena masalah stabilitas, fitur ini telah dimatikan secara bawaan. Untuk informasi lebih lanjut, silakan lihat dokumentasi.", "storage_template_path_length": "Batas panjang jalur: {length, number}{limit, number}", "storage_template_settings": "Templat Penyimpanan", "storage_template_settings_description": "Kelola struktur folder dan nama berkas dari aset yang diunggah", diff --git a/i18n/it.json b/i18n/it.json index f5a9402fe2..07e19736a7 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "Aggiunto {count, number} ai preferiti", "admin": { "add_exclusion_pattern_description": "Aggiungi modelli di esclusione. È supportato il globbing utilizzando *, ** e ?. Per ignorare tutti i file in qualsiasi directory denominata \"Raw\", usa \"**/Raw/**\". Per ignorare tutti i file con estensione \".tif\", usa \"**/*.tif\". Per ignorare un percorso assoluto, usa \"/percorso/da/ignorare/**\".", + "admin_user": "Utente amministratore", "asset_offline_description": "Questa risorsa della libreria esterna non si trova più sul disco ed è stata spostata nel cestino. Se il file è stato spostato all'interno della libreria, controlla la timeline per la nuova risorsa corrispondente. Per ripristinare questa risorsa, assicurati che Immich possa accedere al percorso del file ed esegui la scansione della libreria.", "authentication_settings": "Impostazioni di Autenticazione", "authentication_settings_description": "Gestisci password, OAuth e altre impostazioni di autenticazione", @@ -170,7 +171,7 @@ "note_apply_storage_label_previous_assets": "Nota: Per assegnare l'etichetta storage ad asset precedentemente caricati, esegui", "note_cannot_be_changed_later": "NOTA: Non potrà essere modificato in futuro!", "notification_email_from_address": "Indirizzo mittente", - "notification_email_from_address_description": "Indirizzo email mittente, ad esempio: \"Server Foto Immich \"", + "notification_email_from_address_description": "Indirizzo email del mittente, ad esempio: \"Immich Photo Server \". Assicurati di utilizzare un indirizzo da cui sei autorizzato a inviare email.", "notification_email_host_description": "Host del server email (es. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignora errori di certificato", "notification_email_ignore_certificate_errors_description": "Ignora errori di validazione del certificato TLS (sconsigliato)", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "Dichiarazione di ambito(claim) limite archiviazione", "oauth_storage_quota_claim_description": "Imposta automaticamente il limite di archiviazione dell'utente in base al valore di questa dichiarazione di ambito(claim).", "oauth_storage_quota_default": "Limite predefinito di archiviazione (GiB)", - "oauth_storage_quota_default_description": "Limite in GiB da usare quanto nessuna dichiarazione di ambito(claim) è stata fornita (Inserisci 0 per archiviazione illimitata).", + "oauth_storage_quota_default_description": "Limite in GiB da usare quanto nessuna dichiarazione di ambito(claim) è stata fornita.", "oauth_timeout": "Timeout Richiesta", "oauth_timeout_description": "Timeout per le richieste, espresso in millisecondi", "password_enable_description": "Login con email e password", @@ -243,7 +244,7 @@ "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 maggiori 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.", + "storage_template_onboarding_description_v2": "Se attiva, questa funzionalità organizzerà automaticamente i file utilizzando un modello definito dall'utente. Per maggiori informazioni, consultare la documentazione.", "storage_template_path_length": "Limite approssimativo lunghezza percorso: {length, number}/{limit, number}", "storage_template_settings": "Modello Archiviazione", "storage_template_settings_description": "Gestisci la struttura delle cartelle e il nome degli asset caricati", @@ -468,6 +469,8 @@ "assets_count": "{count, plural, other {# asset}}", "assets_deleted_permanently": "{count} elementi cancellati definitivamente", "assets_deleted_permanently_from_server": "{count} elementi cancellati definitivamente dal server Immich", + "assets_downloaded_failed": "{count, plural, one {Scaricato # file - {error} file non riusciti} other {Scaricati # file - {error} file non riusciti}}", + "assets_downloaded_successfully": "{count, plural, one {Scaricato # file con successo} other {Scaricati # file con successo}}", "assets_moved_to_trash_count": "{count, plural, one {# asset spostato} other {# asset spostati}} nel cestino", "assets_permanently_deleted_count": "{count, plural, one {# asset cancellato} other {# asset cancellati}} definitivamente", "assets_removed_count": "{count, plural, one {# asset rimosso} other {# asset rimossi}}", @@ -647,6 +650,7 @@ "confirm_password": "Conferma password", "confirm_tag_face": "Vuoi taggare questo volto come {name}?", "confirm_tag_face_unnamed": "Vuoi taggare questo volto?", + "connected_device": "Dispositivo connesso", "connected_to": "Connesso", "contain": "Adatta alla finestra", "context": "Contesto", @@ -747,6 +751,7 @@ "disallow_edits": "Blocca modifiche", "discord": "Discord", "discover": "Scopri", + "discovered_devices": "Dispositivi trovati", "dismiss_all_errors": "Ignora tutti gli errori", "dismiss_error": "Ignora errore", "display_options": "Impostazioni visualizzazione", @@ -1131,6 +1136,7 @@ "list": "Lista", "loading": "Caricamento", "loading_search_results_failed": "Impossibile caricare i risultati della ricerca", + "local_asset_cast_failed": "Impossibile trasmettere una risorsa che non è caricata sul server", "local_network": "Rete locale", "local_network_sheet_info": "L'app si collegherà al server tramite questo URL quando è in uso la rete Wi-Fi specificata", "location_permission": "Permesso di localizzazione", @@ -1141,9 +1147,10 @@ "location_picker_longitude_error": "Inserisci una longitudine valida", "location_picker_longitude_hint": "Inserisci la longitudine qui", "lock": "Rendi privato", - "locked_folder": "Cartella Bloccata", + "locked_folder": "Cartella Privata", "log_out": "Esci", "log_out_all_devices": "Disconnetti tutti i dispositivi", + "logged_in_as": "Effettuato l'accesso come {user}", "logged_out_all_devices": "Disconnesso da tutti i dispositivi", "logged_out_device": "Disconnesso dal dispositivo", "login": "Accesso", @@ -1175,7 +1182,7 @@ "look": "Guarda", "loop_videos": "Riproduci video in loop", "loop_videos_description": "Abilita per riprodurre automaticamente un video in loop nella vista dettagli.", - "main_branch_warning": "Stai usando una versione di sviluppo. Consigliamo vivamente di utilizzare una versione di rilascio!", + "main_branch_warning": "Stai utilizzando una versione di sviluppo. Ti consigliamo vivamente di utilizzare una versione di rilascio!", "main_menu": "Menu Principale", "make": "Produttore", "manage_shared_links": "Gestisci link condivisi", @@ -1271,6 +1278,7 @@ "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_assets_to_show": "Nessuna risorsa da mostrare", + "no_cast_devices_found": "Nessun dispositivo di trasmissione trovato", "no_duplicates_found": "Nessun duplicato trovato.", "no_exif_info_available": "Nessuna informazione exif disponibile", "no_explore_results_message": "Carica più foto per esplorare la tua collezione.", @@ -1440,7 +1448,7 @@ "purchase_lifetime_description": "Acquisto a vita", "purchase_option_title": "OPZIONI DI ACQUISTO", "purchase_panel_info_1": "Costruire Immich richiede molto tempo e impegno, e abbiamo ingegneri a tempo pieno che lavorano per renderlo il migliore possibile. La nostra missione è fare in modo che i software open source e le pratiche aziendali etiche diventino una fonte di reddito sostenibile per gli sviluppatori e creare un ecosistema che rispetti la privacy, offrendo vere alternative ai servizi cloud sfruttatori.", - "purchase_panel_info_2": "Poiché siamo impegnati a non aggiungere barriere di pagamento, questo acquisto non ti offrirà funzionalità aggiuntive in Immich. Contiamo su utenti come te per sostenere lo sviluppo continuo di Immich.", + "purchase_panel_info_2": "Poiché ci impegniamo a non aggiungere paywall, questo acquisto non ti garantirà funzionalità aggiuntive in Immich. Contiamo su utenti come te per supportare lo sviluppo continuo di Immich.", "purchase_panel_title": "Contribuisci al progetto", "purchase_per_server": "Per server", "purchase_per_user": "Per utente", @@ -1493,6 +1501,7 @@ "remove_from_shared_link": "Rimuovi dal link condiviso", "remove_memory": "Rimuovi ricordo", "remove_photo_from_memory": "Rimuovi foto da questo ricordo", + "remove_tag": "Rimuovi tag", "remove_url": "Rimuovi URL", "remove_user": "Rimuovi utente", "removed_api_key": "Rimossa chiave API: {name}", @@ -1599,6 +1608,7 @@ "select_album_cover": "Seleziona copertina album", "select_all": "Seleziona tutto", "select_all_duplicates": "Seleziona tutti i duplicati", + "select_all_in": "Seleziona tutto in {group}", "select_avatar_color": "Seleziona colore avatar", "select_face": "Seleziona volto", "select_featured_photo": "Seleziona foto in evidenza", @@ -1629,6 +1639,7 @@ "set_date_of_birth": "Imposta data di nascita", "set_profile_picture": "Imposta foto profilo", "set_slideshow_to_fullscreen": "Imposta presentazione a schermo intero", + "set_stack_primary_asset": "Imposta come risorsa primaria", "setting_image_viewer_help": "Il visualizzatore dettagliato carica una piccola thumbnail per prima, per poi caricare un immagine di media grandezza (se abilitato). Ed infine carica l'originale (se abilitato).", "setting_image_viewer_original_subtitle": "Abilita per caricare l'immagine originale a risoluzione massima (grande!). Disabilita per ridurre l'utilizzo di banda (sia sul network che nella cache del dispositivo).", "setting_image_viewer_original_title": "Carica l'immagine originale", @@ -1766,6 +1777,7 @@ "start_date": "Data di inizio", "state": "Provincia", "status": "Stato", + "stop_casting": "Interrompi trasmissione", "stop_motion_photo": "Ferma Foto in Movimento", "stop_photo_sharing": "Interrompere la condivisione delle tue foto?", "stop_photo_sharing_description": "{partner} non potrà più accedere alle tue foto.", @@ -1844,6 +1856,7 @@ "unable_to_setup_pin_code": "Impossibile configurare il codice PIN", "unarchive": "Annulla l'archiviazione", "unarchived_count": "{count, plural, other {Non archiviati #}}", + "undo": "Annulla", "unfavorite": "Rimuovi preferito", "unhide_person": "Mostra persona", "unknown": "Sconosciuto", @@ -1860,6 +1873,7 @@ "unsaved_change": "Modifica non salvata", "unselect_all": "Deseleziona tutto", "unselect_all_duplicates": "Deseleziona tutti i duplicati", + "unselect_all_in": "Deseleziona tutto in {group}", "unstack": "Rimuovi dal gruppo", "unstacked_assets_count": "{count, plural, one {Separato # asset} other {Separati # asset}}", "up_next": "Prossimo", diff --git a/i18n/ja.json b/i18n/ja.json index e3cd9528ed..1649f978d8 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "{count, number} 枚の画像をお気に入りに追加しました", "admin": { "add_exclusion_pattern_description": "除外パターンを追加します。ワイルドカード「*」「**」「?」を使用できます。すべてのディレクトリで「Raw」と名前が付いたファイルを無視するには、「**/Raw/**」を使用します。また、「.tif」で終わるファイルをすべて無視するには、「**/*.tif」を使用します。さらに、絶対パスを無視するには「/path/to/ignore/**」を使用します。", + "admin_user": "管理ユーザー", "asset_offline_description": "この外部ライブラリのアセットはディスク上に見つからなくなってゴミ箱に移動されました。ファイルがライブラリの中で移動された場合はタイムラインで新しい対応するアセットを確認してください。このアセットを復元するには以下のファイルパスがImmichからアクセスできるか確認してライブラリをスキャンしてください。", "authentication_settings": "認証設定", "authentication_settings_description": "認証設定の管理(パスワード、OAuth、その他)", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "ストレージクォータ クレーム", "oauth_storage_quota_claim_description": "ユーザーのストレージクォータをこのクレームの値に自動的に設定します。", "oauth_storage_quota_default": "デフォルトのストレージ割り当て(GiB)", - "oauth_storage_quota_default_description": "クレームが提供されていない場合に使用されるクォータをGiB単位で設定します(無制限にする場合は0を入力してください)。", + "oauth_storage_quota_default_description": "クレームが提供されていない場合に使用されるクォータをGiB単位で設定します。", "oauth_timeout": "リクエストタイムアウト", "oauth_timeout_description": "リクエストのタイムアウトまでの時間(ms)", "password_enable_description": "メールアドレスとパスワードでログイン", @@ -243,7 +244,7 @@ "storage_template_migration_info": "ストレージテンプレートは全ての拡張子を小文字に変換します。テンプレートの変更は新しいアセットにのみ適用されます。 以前にアップロードしたアセットにテンプレートを遡って適用するには、{job} を実行してください。", "storage_template_migration_job": "ストレージテンプレート移行ジョブ", "storage_template_more_details": "この機能の詳細については、ストレージテンプレートとその影響を参照してください", - "storage_template_onboarding_description": "この機能を有効にすると、ユーザー定義のテンプレートに基づいてファイルが自動で整理されます。 安定性の問題のため、この機能はデフォルトでオフになっています。 詳細については、ドキュメントを参照してください。", + "storage_template_onboarding_description_v2": "この設定をオンにすると、ユーザーの定義したテンプレートに従って自動でファイルが整理されます。詳しい情報はドキュメンテーションで確認してください。", "storage_template_path_length": "おおよそのパス長の制限: {length, number}/{limit, number}", "storage_template_settings": "ストレージ テンプレート", "storage_template_settings_description": "アップロードしたアセットのフォルダ構造とファイル名を管理します", @@ -1149,6 +1150,7 @@ "locked_folder": "鍵付きフォルダー", "log_out": "ログアウト", "log_out_all_devices": "全てのデバイスからログアウト", + "logged_in_as": "{user}としてログイン中", "logged_out_all_devices": "全てのデバイスからログアウトしました", "logged_out_device": "デバイスからログアウトしました", "login": "ログイン", @@ -1499,6 +1501,7 @@ "remove_from_shared_link": "共有リンクから削除", "remove_memory": "メモリーの削除", "remove_photo_from_memory": "メモリーから写真を削除", + "remove_tag": "タグを削除", "remove_url": "URLの削除", "remove_user": "ユーザーを削除", "removed_api_key": "削除されたAPI キー: {name}", @@ -1605,6 +1608,7 @@ "select_album_cover": "アルバムカバーを選択", "select_all": "全て選択", "select_all_duplicates": "全ての重複を選択", + "select_all_in": "{group}のすべてを選択", "select_avatar_color": "アバターの色を選択", "select_face": "顔を選択", "select_featured_photo": "人物写真を選択", @@ -1869,6 +1873,7 @@ "unsaved_change": "未保存の変更", "unselect_all": "全て選択解除", "unselect_all_duplicates": "全ての重複の選択を解除", + "unselect_all_in": "{group}のすべての選択を解除", "unstack": "スタックを解除", "unstacked_assets_count": "{count, plural, one {#個のアセット} other {#個のアセット}}をスタックから解除しました", "up_next": "次へ", diff --git a/i18n/ko.json b/i18n/ko.json index d155d8635a..01b29e6776 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -242,7 +242,6 @@ "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": "업로드된 항목의 폴더 구조 및 파일 이름 관리", diff --git a/i18n/lt.json b/i18n/lt.json index 7d820d452e..d5e6ff20ed 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -756,6 +756,8 @@ "refreshing_metadata": "Perkraunami metaduomenys", "remove": "Pašalinti", "remove_assets_shared_link_confirmation": "Ar tikrai norite pašalinti {count, plural, one {# elementą} few {# elementus} other {# elementų}} iš šios bendrinimo nuorodos?", + "remove_assets_title": "Pašalinti elementus?", + "remove_deleted_assets": "Pašalinti Ištrintus Elemenuts", "remove_from_album": "Pašalinti iš albumo", "remove_from_favorites": "Pašalinti iš mėgstamiausių", "remove_from_shared_link": "Pašalinti iš bendrinimo nuorodos", @@ -772,47 +774,82 @@ "repair_no_results_message": "Nesekami ir trūkstami failai bus rodomi čia", "replace_with_upload": "Pakeisti naujai įkeltu failu", "require_password": "Reikalauti slaptažodžio", + "rescan": "Perskenuoti", "reset": "Atstatyti", + "reset_password": "Atstayti slaptažodį", + "reset_pin_code": "Atsatyti PIN kodą", + "reset_to_default": "Atkurti numatytuosius", "resolve_duplicates": "Sutvarkyti dublikatus", "resolved_all_duplicates": "Sutvarkyti visi dublikatai", "restore": "Atkurti", "restore_all": "Atkurti visus", "restore_user": "Atkurti naudotoją", + "restored_asset": "Atkurti elementą", "review_duplicates": "Peržiūrėti dublikatus", "save": "Išsaugoti", + "save_to_gallery": "Išsaugoti galerijoje", "saved_api_key": "Išsaugotas API raktas", "saved_profile": "Išsaugotas profilis", "saved_settings": "Išsaugoti nustatymai", "say_something": "Ką nors pasakykite", + "scaffold_body_error_occurred": "Įvyko klaida", "scan_all_libraries": "Skenuoti visas bibliotekas", "scan_library": "Skenuoti", "scan_settings": "Skenavimo nustatymai", + "scanning_for_album": "Skenuojama albumų...", "search": "Ieškoti", + "search_albums": "Ieškoti albumų", "search_by_context": "Ieškoti pagal kontekstą", + "search_by_description": "Ieškoti pagal aprašymą", "search_by_description_example": "Žygio diena Sapoje", "search_by_filename": "Ieškoti pagal failo pavadinimą arba plėtinį", "search_by_filename_example": "pvz. IMG_1234.JPG arba PNG", + "search_camera_make": "Ieškoti pagal kameros gamintoją...", + "search_camera_model": "Ieškoti kameros modelį...", + "search_city": "Ieškoti miesto...", "search_country": "Ieškoti šalies...", + "search_filter_date": "Data", + "search_filter_display_option_not_in_album": "Ne albume", + "search_filter_display_options": "Rodymo Nustatymai", + "search_filter_filename": "Ieškoti pagal failo pavadinimą", + "search_filter_location": "Vietovė", + "search_filter_location_title": "Pasirinkti vietovę", + "search_filter_media_type": "Medijos timas", + "search_filter_media_type_title": "Pasirinkti medijos tipą", + "search_no_more_result": "Nėra daugiau rezultatų", "search_no_people_named": "Nėra žmonių vardu „{name}“", + "search_page_screenshots": "Ekrano nuotraukos", + "search_page_selfies": "Asmenukės", + "search_page_things": "Dalykai", + "search_page_view_all_button": "Peržiūrėti visus", "search_people": "Ieškoti žmonių", "search_places": "Ieškoti vietų", + "search_rating": "Ieškoti pagal įvertinimą...", "search_settings": "Ieškoti nustatymų", "search_tags": "Ieškoti žymų...", + "search_timezone": "Ieškoti laiko zonos...", "search_type": "Paieškos tipas", "search_your_photos": "Ieškoti nuotraukų", "select_all_duplicates": "Pasirinkti visus dublikatus", + "select_all_in": "Pažymėti visus esančius {group}", "select_avatar_color": "Pasirinkti avataro spalvą", "select_face": "Pasirinkti veidą", "select_featured_photo": "Pasirinkti rodomą nuotrauką", + "select_from_computer": "Pasirinkti iš kompiuterio", "select_keep_all": "Visus pažymėti \"Palikti\"", "select_library_owner": "Pasirinkti bibliotekos savininką", + "select_new_face": "Pasirinkti naują veidą", + "select_photos": "Pasirinkti nuotraukas", "select_trash_all": "Visus pažymėti \"Išmesti\"", "selected": "Pasirinkta", "selected_count": "{count, plural, one {# pasirinktas} few {# pasirinkti} other {# pasirinktų}}", "send_message": "Siųsti žinutę", "send_welcome_email": "Siųsti sveikinimo el. laišką", + "server_info_box_app_version": "Programėlės versija", + "server_info_box_server_url": "Serverio URL", "server_offline": "Serveris nepasiekiamas", "server_online": "Serveris pasiekiamas", + "server_privacy": "Serverio Privatumas", "server_stats": "Serverio statistika", "server_version": "Serverio versija", "set": "Nustatyti", @@ -820,24 +857,52 @@ "set_date_of_birth": "Nustatyti gimimo datą", "set_profile_picture": "Nustatyti profilio nuotrauką", "set_slideshow_to_fullscreen": "Nustatyti skaidrių peržiūrą per visą ekraną", + "set_stack_primary_asset": "Nustatyti kaip pagrindinį elementą", + "setting_image_viewer_original_title": "Užkrauti originalią nuotrauką", + "setting_image_viewer_preview_title": "Užkrauti peržiūros nuotrauką", + "setting_image_viewer_title": "Nuotraukos", + "setting_languages_apply": "Pritaikyti", + "setting_notifications_notify_never": "niekada", + "setting_notifications_single_progress_subtitle": "Detali įkėlimo progreso informacija kiekvienam elementui", "settings": "Nustatymai", + "settings_require_restart": "Prašome perkrauti Immich, siekiant pritaikyti šį nustatymą", + "settings_saved": "Nustatymai išsaugoti", + "setup_pin_code": "Nustatyti PIN kodą", "share": "Dalintis", + "share_add_photos": "Įtraukti nuotraukų", + "share_dialog_preparing": "Ruošiama...", + "share_link": "Bendrinti nuorodą", "shared": "Bendrinami", + "shared_by_user": "Bendrina {user}", + "shared_by_you": "Bendrinama jūsų", + "shared_from_partner": "Nuotraukos iš {partner}", + "shared_link_clipboard_copied_massage": "Nukopijuota į iškarpinę", "shared_link_options": "Bendrinimo nuorodos parametrai", "shared_links": "Bendrinimo nuorodos", "shared_photos_and_videos_count": "{assetCount, plural, one {# bendrinama nuotrauka ir vaizdo įrašas} few {# bendrinamos nuotraukos ir vaizdo įrašai} other {# bendrinamų nuotraukų ir vaizdo įrašų}}", + "shared_with_me": "Bendrinama su manimi", "shared_with_partner": "Pasidalinta su {partner}", "sharing": "Dalijimasis", "sharing_enter_password": "Norėdami peržiūrėti šį puslapį, įveskite slaptažodį.", + "sharing_page_album": "Bendrinami albumai", + "sharing_page_empty_list": "TUŠČIAS SĄRAŠAS", "sharing_sidebar_description": "Rodyti bendrinimo rodinio nuorodą šoninėje juostoje", + "sharing_silver_appbar_create_shared_album": "Naujas bendrinamas albumas", + "sharing_silver_appbar_share_partner": "Bendrinti su partneriu", "show_album_options": "Rodyti albumo parinktis", + "show_albums": "Rodyti albumus", + "show_all_people": "Rodyti visus asmenis", + "show_and_hide_people": "Rodyti ir paslėpti žmones", "show_file_location": "Rodyti rinkmenos vietą", "show_gallery": "Rodyti galeriją", + "show_hidden_people": "Rodyti paslėptus asmenis", "show_in_timeline": "Rodyti laiko skalėje", "show_in_timeline_setting_description": "Rodyti šio naudotojo nuotraukas ir vaizdo įrašus mano laiko skalėje", + "show_keyboard_shortcuts": "Rodyti klaviatūros trumpinius", "show_metadata": "Rodyti metaduomenis", "show_or_hide_info": "Rodyti arba slėpti informaciją", "show_password": "Rodyti slaptažodį", + "show_progress_bar": "Rodyti progreso juostą", "show_search_options": "Rodyti paieškos parinktis", "show_slideshow_transition": "Rodyti perėjimą tarp skaidrių", "show_supporter_badge": "Rėmėjo ženklelis", @@ -848,11 +913,16 @@ "sign_up": "Užsiregistruoti", "size": "Dydis", "skip_to_content": "Pereiti prie turinio", + "skip_to_folders": "Praleisti iki aplankų", + "skip_to_tags": "Praleisti iki žymių", "slideshow": "Skaidrių peržiūra", "slideshow_settings": "Skaidrių peržiūros nustatymai", + "sort_albums_by": "Rikiuoti albumus pagal...", "sort_created": "Sukūrimo data", + "sort_items": "Elementų skaičių", "sort_modified": "Keitimo data", "sort_oldest": "Seniausia nuotrauka", + "sort_people_by_similarity": "Rikiuoti žmonės pagal panašumą", "sort_recent": "Naujausia nuotrauka", "sort_title": "Pavadinimas", "source": "Šaltinis", @@ -864,12 +934,17 @@ "start": "Pradėti", "start_date": "Pradžios data", "status": "Statusas", + "stop_casting": "Nutraukti transliavimą", "storage": "Saugykla", "storage_usage": "Naudojama {used} iš {available}", "submit": "Pateikti", + "suggestions": "Pasiūlymai", "sunrise_on_the_beach": "Saulėtekis paplūdimyje", + "support": "Pagalba", "support_and_feedback": "Palaikymas ir atsiliepimai", "sync": "Sinchronizuoti", + "sync_albums": "Sinchronizuoti albumus", + "sync_upload_album_setting_subtitle": "Sukurti ir įkelti jūsų nuotraukas ir vaizdo įrašus į pasirinktus Immich albumus", "tag": "Žyma", "tag_created": "Sukurta žyma: {tag}", "tag_feature_description": "Peržiūrėkite nuotraukas ir vaizdo įrašus sugrupuotus pagal sužymėtas temas", @@ -879,31 +954,47 @@ "tags": "Žymos", "template": "Šablonas", "theme": "Tema", + "theme_selection": "Temos pasirinkimas", + "theme_setting_primary_color_title": "Pagrindinė spalva", + "theme_setting_system_primary_color_title": "Naudoti sistemos spalvą", + "theme_setting_system_theme_switch": "Automatinė (Naudoti sistemos nustatymus)", "time_based_memories": "Atsiminimai pagal laiką", "timeline": "Laiko skalė", "timezone": "Laiko juosta", "to_archive": "Archyvuoti", "to_change_password": "Pakeisti slaptažodį", "to_favorite": "Įtraukti prie mėgstamiausių", + "to_login": "Prisijungti", "to_trash": "Išmesti", "trash": "Šiukšliadėžė", "trash_all": "Perkelti visus į šiukšliadėžę", "trash_count": "Perkelti {count, number} į šiukšliadėžę", + "trash_emptied": "Išvalytos šiukšlės", "trash_no_results_message": "Į šiukšliadėžę perkeltos nuotraukos ir vaizdo įrašai bus rodomi čia.", + "trash_page_delete_all": "Ištrinti Visus", + "trash_page_empty_trash_dialog_content": "Ar norite ištrinti išmestus elementus? Šie elementai bus visam laikui pašalinti iš Immich", + "trash_page_no_assets": "Nėra išmestų elementų", + "trash_page_restore_all": "Atkurti Visus", "trashed_items_will_be_permanently_deleted_after": "Į šiukšliadėžę perkelti elementai bus visam laikui ištrinti po {days, plural, one {# dienos} other {# dienų}}.", "type": "Tipas", "unarchive": "Išarchyvuoti", "unarchived_count": "{count, plural, other {# išarchyvuota}}", "unfavorite": "Pašalinti iš mėgstamiausių", + "unhide_person": "Nebeslėpti žmogaus", + "unknown_country": "Nežinoma Šalis", "unknown_year": "Nežinomi metai", + "unlimited": "Neribota", "unlink_oauth": "Atsieti OAuth", "unlinked_oauth_account": "Atsieta OAuth paskyra", + "unnamed_album": "Neįvardytas Albumas", "unnamed_album_delete_confirmation": "Ar tikrai norite ištrinti šį albumą?", + "unnamed_share": "Neįvardytas Bendrinimas", "unsaved_change": "Neišsaugoti pakeitimai", "unselect_all": "Atšaukti visų pasirinkimą", "unselect_all_duplicates": "Atžymėti visus dublikatus", "unstack": "Išgrupuoti", "unstacked_assets_count": "{count, plural, one {Išgrupuotas # elementas} few {Išgrupuoti # elementai} other {Išgrupuota # elementų}}", + "up_next": "Seknatis", "updated_at": "Atnaujintas", "updated_password": "Slaptažodis atnaujintas", "upload": "Įkelti", @@ -919,24 +1010,32 @@ "upload_to_immich": "Įkelti į Immich ({count})", "uploading": "Įkeliama", "usage": "Naudojymas", + "use_biometric": "Naudoti biometriją", "use_current_connection": "naudoti dabartinį ryšį", "user": "Naudotojas", "user_has_been_deleted": "Šis naudotojas buvo ištrintas.", "user_id": "Naudotojo ID", + "user_liked": "{user} patinka {type, select, photo {ši nuotrauka} video {šis vaizdo įrašas} asset {šis elementas} other {tai}}", "user_pin_code_settings": "PIN kodas", "user_pin_code_settings_description": "Tvarkykite savo PIN kodą", + "user_privacy": "Vartotojo Privatumas", + "user_purchase_settings": "Įsigyti", + "user_role_set": "Nustatyti {user}, kaip {role}", "user_usage_stats": "Paskyros naudojimo statistika", "user_usage_stats_description": "Žiūrėti paskyros naudojimo statistiką", "username": "Naudotojo vardas", "users": "Naudotojai", "utilities": "Įrankiai", "validate": "Validuoti", + "validate_endpoint_error": "Prašome įvesti galiojantį URL", "variables": "Kintamieji", "version": "Versija", "version_announcement_closing": "Tavo draugas, Alex", + "version_announcement_message": "Sveiki! Nauja „Immich“ versija yra pasiekiama. Prašome skirti šiek tiek laiko perskaityti leidimo pastabas, kad įsitikintumėte, jog jūsų nustatymai yra atnaujinti. Tai padės išvengti netinkamo sukonfigūravimo, ypač jei naudojate „WatchTower“ ar kitą mechanizmą, kuris automatiškai atnaujina jūsų „Immich“ serverį.", "version_history": "Versijų istorija", "version_history_item": "Versija {version} įdiegta {date}", "video": "Vaizdo įrašas", + "video_hover_setting": "Paleisti vaizdo įrašo miniatiūrą užvedus pele", "video_hover_setting_description": "Atkurti vaizdo įrašo miniatiūrą, kai pelė užvedama ant elemento. Net ir išjungus, atkūrimą galima pradėti užvedus pelės žymeklį ant atkūrimo piktogramos.", "videos": "Video", "videos_count": "{count, plural, one {# vaizdo įrašas} few {# vaizdo įrašai} other {# vaizdo įrašų}}", @@ -946,16 +1045,19 @@ "view_all_users": "Peržiūrėti visus naudotojus", "view_in_timeline": "Žiūrėti laiko skalėje", "view_links": "Žiūrėti nuorodas", + "view_qr_code": "Žiūrėti QR kodą", "view_stack": "Peržiūrėti grupę", "waiting": "Laukiama", "warning": "Įspėjimas", "week": "Savaitė", + "welcome": "Sveiki atvykę", "welcome_to_immich": "Sveiki atvykę į Immich", - "wifi_name": "WiFi Name", + "wifi_name": "Wi-Fi Pavadinimas", + "wrong_pin_code": "Neteisingas PIN kodas", "year": "Metai", "years_ago": "Prieš {years, plural, one {# metus} other {# metų}}", "yes": "Taip", "you_dont_have_any_shared_links": "Bendrinimo nuorodų neturite", - "your_wifi_name": "Your WiFi name", + "your_wifi_name": "Jūsų Wi-Fi pavadinimas", "zoom_image": "Priartinti vaizdą" } diff --git a/i18n/lv.json b/i18n/lv.json index 852318ce0b..3fcf228612 100644 --- a/i18n/lv.json +++ b/i18n/lv.json @@ -33,6 +33,7 @@ "added_to_favorites_count": "{count, number} pievienoti izlasei", "admin": { "add_exclusion_pattern_description": "Pievienojiet izlaišanas shēmas. Aizstājējzīmju izmantoša *, **, un ? tiek atbalstīta. Lai ignorētu visus failus jebkurā direktorijā ar nosaukumu “RAW”, izmantojiet “**/RAW/**”. Lai ignorētu visus failus, kas beidzas ar “. tif”, izmantojiet “**/*. tif”. Lai ignorētu absolūto ceļu, izmantojiet “/path/to/ignore/**”.", + "admin_user": "Administrators", "asset_offline_description": "Šis ārējās bibliotēkas resurss vairs nav atrodams diskā un ir pārvietots uz atkritumu grozu. Ja fails tika pārvietots bibliotēkas ietvaros, pārbaudiet, vai jūsu hronoloģijā ir jauns atbilstošais resurss. Lai atjaunotu šo resursu, pārliecinieties, vai Immich var piekļūt tālāk norādītajam faila ceļam un skenēt bibliotēku.", "authentication_settings": "Autentifikācijas iestatījumi", "authentication_settings_description": "Paroļu, OAuth un citu autentifikācijas iestatījumu pārvaldība", @@ -70,6 +71,10 @@ "job_settings_description": "Uzdevumu izpildes vienlaicīguma pārvaldība", "job_status": "Uzdevumu statuss", "library_deleted": "Bibliotēka dzēsta", + "library_scanning": "Periodiska skenēšana", + "library_scanning_description": "Konfigurē periodisku bibliotēku skenēšanu", + "library_scanning_enable_description": "Iespējot periodisku bibliotēku skenēšanu", + "library_settings": "Ārējā bibliotēka", "library_settings_description": "Ārējo bibliotēku iestatījumu pārvaldība", "library_watching_settings": "Bibliotēku uzraudzīšana (EKSPERIMENTĀLA)", "library_watching_settings_description": "Automātiski uzraudzīt, vai ir mainīti faili", @@ -317,6 +322,7 @@ "cannot_merge_people": "Nevar apvienot cilvēkus", "change_date": "Mainīt datumu", "change_description": "Mainīt aprakstu", + "change_display_order": "Mainīt attēlošanas secību", "change_expiration_time": "Izmainīt derīguma termiņu", "change_location": "Mainīt atrašanās vietu", "change_name": "Mainīt nosaukumu", @@ -411,12 +417,16 @@ "documentation": "Dokumentācija", "done": "Gatavs", "download": "Lejupielādēt", + "download_canceled": "Lejupielāde atcelta", + "download_complete": "Lejupielāde pabeigta", + "download_error": "Lejupielādes kļūda", + "download_failed": "Lejupielāde neizdevās", "download_notfound": "Lejupielāde nav atrasta", "download_paused": "Lejupielāde nopauzēta", "download_settings": "Lejupielāde", "download_settings_description": "Ar failu lejupielādi saistīto iestatījumu pārvaldība", "download_started": "Lejupielāde sākta", - "download_sucess": "Lejupielāde pabeigta", + "download_sucess": "Lejupielāde izdevās", "downloading": "Lejupielādē", "downloading_asset_filename": "Lejupielādē failu {filename}", "duplicates": "Dublikāti", @@ -462,7 +472,9 @@ "unable_to_create_user": "Neizdevās izveidot lietotāju", "unable_to_delete_user": "Neizdevās dzēst lietotāju", "unable_to_hide_person": "Neizdevās paslēpt personu", - "unable_to_save_date_of_birth": "Neizdevās saglabāt dzimšanas datumu" + "unable_to_save_date_of_birth": "Neizdevās saglabāt dzimšanas datumu", + "unable_to_scan_libraries": "Bibliotēku skenēšana neizdevās", + "unable_to_scan_library": "Bibliotēkas skenēšana neizdevās" }, "exif": "Exif", "exif_bottom_sheet_description": "Pievienot Aprakstu...", @@ -518,7 +530,7 @@ "id": "ID", "image": "Attēls", "image_viewer_page_state_provider_download_started": "Lejupielāde Uzsākta", - "image_viewer_page_state_provider_download_success": "Lejupielāde Izdevās", + "image_viewer_page_state_provider_download_success": "Lejupielāde izdevās", "image_viewer_page_state_provider_share_error": "Kopīgošanas Kļūda", "immich_logo": "Immich logo", "import_from_json": "Importēt no JSON", @@ -777,6 +789,7 @@ "repair": "Remonts", "replace_with_upload": "Aizstāt ar augšupielādi", "require_user_to_change_password_on_first_login": "Pieprasīt lietotājam mainīt paroli pēc pirmās pieteikšanās", + "rescan": "Pārskenēt atkārtoti", "resolve_duplicates": "Atrisināt dublēšanās gadījumus", "resolved_all_duplicates": "Visi dublikāti ir atrisināti", "restore": "Atjaunot", @@ -794,6 +807,10 @@ "saved_settings": "Iestatījumi saglabāti", "say_something": "Teikt kaut ko", "scaffold_body_error_occurred": "Radās kļūda", + "scan_all_libraries": "Skenēt visas bibliotēkas", + "scan_library": "Skenēt", + "scan_settings": "Skenēšanas iestatījumi", + "scanning_for_album": "Skenē albumu...", "search": "Meklēt", "search_albums": "Meklēt albumus", "search_by_filename_example": "piemēram, IMG_1234.JPG vai PNG", diff --git a/i18n/ms.json b/i18n/ms.json index 5f16053d65..cfe935102e 100644 --- a/i18n/ms.json +++ b/i18n/ms.json @@ -236,7 +236,6 @@ "storage_template_migration_info": "Perubahan templat hanya akan digunakan pada aset baharu. Untuk menggunakan templat secara retroaktif pada aset-aset yang dimuat naik sebelum ini, jalankan {job}.", "storage_template_migration_job": "Kerja Migrasi Templat Storan", "storage_template_more_details": "Untuk butiran lanjut tentang ciri ini, rujuk kepada Templat Storan dan implikasi", - "storage_template_onboarding_description": "Apabila didayakan, ciri ini akan menyusun fail secara automatik berdasarkan templat yang ditentukan pengguna. Disebabkan isu kestabilan, ciri ini telah dimatikan secara umum. Untuk mendapatkan maklumat lanjut, sila lihat dokumentasi.", "storage_template_path_length": "Anggaran kepanjangan laluan: {length, number}/{limit, number}", "storage_template_settings": "Templat Storan", "storage_template_settings_description": "Urus struktur folder dan nama fail aset dimuat naik", diff --git a/i18n/nb_NO.json b/i18n/nb_NO.json index e8c43d6604..3478262019 100644 --- a/i18n/nb_NO.json +++ b/i18n/nb_NO.json @@ -1,7 +1,7 @@ { "about": "Om", "account": "Konto", - "account_settings": "Konto Innstillinger", + "account_settings": "Kontoinnstillinger", "acknowledge": "Bekreft", "action": "Handling", "action_common_update": "Oppdater", @@ -22,6 +22,7 @@ "add_partner": "Legg til partner", "add_path": "Legg til sti", "add_photos": "Legg til bilder", + "add_tag": "Legg til tag", "add_to": "Legg til…", "add_to_album": "Legg til album", "add_to_album_bottom_sheet_added": "Lagt til i {album}", @@ -33,6 +34,7 @@ "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 \"/filsti/til/ignorer/**\".", + "admin_user": "Administrasjonsbruker", "asset_offline_description": "Denne eksterne bibliotekressursen finnes ikke lenger på disk og har blitt flyttet til papirkurven. Hvis filen ble flyttet innad i biblioteket, sjekk tidslinjen din for den tilsvarende ressursen. For å gjenopprette ressursen, vennligst sørg for at filstien under er tilgjengelig for Immich og skan biblioteket.", "authentication_settings": "Godkjenningsinnstillinger", "authentication_settings_description": "Administrer passord, OAuth, og andre innstillinger for autentisering", @@ -169,7 +171,7 @@ "note_apply_storage_label_previous_assets": "Merk: For å bruke lagringsetiketten på tidligere opplastede filer, kjør", "note_cannot_be_changed_later": "MERK: Dette kan ikke endres senere!", "notification_email_from_address": "Fra adresse", - "notification_email_from_address_description": "Avsenderens e-postadresse, for eksempel: \"Immich Photo Server \"", + "notification_email_from_address_description": "Avsenderens e-postadresse, for eksempel: \"Immich Photo Server \". Bruk en e-postadresse du har tillatelse til å sende epost fra.", "notification_email_host_description": "Verten til e-posts serveren (f.eks. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorer sertifikatfeil", "notification_email_ignore_certificate_errors_description": "Ignorer valideringsfeil for TLS-sertifikat (ikke anbefalt)", @@ -202,7 +204,7 @@ "oauth_storage_quota_claim": "Lagringskvotekrav", "oauth_storage_quota_claim_description": "Sett automatisk brukerens lagringskvote til verdien av dette kravet.", "oauth_storage_quota_default": "Standard lagringskvote (GiB)", - "oauth_storage_quota_default_description": "Kvote i GiB som skal brukes når ingen krav er oppgitt (Skriv 0 for ubegrenset kvote).", + "oauth_storage_quota_default_description": "Kvote i GiB som skal brukes når ingen krav er oppgitt.", "oauth_timeout": "Forespørselen tok for lang tid", "oauth_timeout_description": "Tidsavbrudd for forespørsel i millisekunder", "password_enable_description": "Logg inn med e-post og passord", @@ -242,7 +244,7 @@ "storage_template_migration_info": "Lagringsmalen vil endre filtypen til små bokstaver. 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 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_onboarding_description_v2": "Når aktivert vil denne funksjonen automatisk organisere filer basert på en brukerdefinert mal. For mer informasjon, se denne linken dokumentasjon.", "storage_template_path_length": "Omtrentlig stilengdebegrensning: {length, number}/{limit, number}", "storage_template_settings": "Lagringsmal", "storage_template_settings_description": "Administrer mappestrukturen og filnavnet til opplastede fil", @@ -402,6 +404,9 @@ "album_with_link_access": "La hvem som helst med lenken se bilder og folk i dette albumet.", "albums": "Albumer", "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albumer}}", + "albums_default_sort_order": "Standard sorteringsrekkefølge for albumer", + "albums_default_sort_order_description": "Standard sorteringsrekkefølge for bilder når man lager et nytt album.", + "albums_feature_description": "Samlinger av bilder som kan deles med andre brukere.", "all": "Alle", "all_albums": "Alle album", "all_people": "Alle personer", @@ -460,10 +465,11 @@ "assets_added_count": "Lagt til {count, plural, one {# element} other {# elementer}}", "assets_added_to_album_count": "Lagt til {count, plural, one {# asset} other {# assets}} i album", "assets_added_to_name_count": "Lagt til {count, plural, one {# asset} other {# assets}} i {hasName, select, true {{name}} other {new album}}", + "assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} kan ikke legges til i albumet", "assets_count": "{count, plural, one {# fil} other {# filer}}", "assets_deleted_permanently": "{count} objekt(er) slettet permanent", "assets_deleted_permanently_from_server": "{count} objekt(er) slettet permanent fra Immich-serveren", - "assets_downloaded_failed": "{count, plural, one {Downloaded # file - {error} file failed} other {Downloaded # files - {error} files failed}}", + "assets_downloaded_failed": "{count, plural, one {Nedlasting av # fil - {error} fil feilet} other {Nedlastede # filer - {error} filer feilet}}", "assets_downloaded_successfully": "{count, plural, one {Downloaded # file successfully} other {Downloaded # files successfully}}", "assets_moved_to_trash_count": "Flyttet {count, plural, one {# asset} other {# assets}} til søppel", "assets_permanently_deleted_count": "Permanent slettet {count, plural, one {# asset} other {# assets}}", @@ -644,6 +650,7 @@ "confirm_password": "Bekreft passord", "confirm_tag_face": "Vil du merke dette ansiktet som {name}?", "confirm_tag_face_unnamed": "Vil du merke dette ansiktet?", + "connected_device": "Tilkoblet enhet", "connected_to": "Koblet til", "contain": "Inneholder", "context": "Kontekst", @@ -744,6 +751,7 @@ "disallow_edits": "Forby redigering", "discord": "Discord", "discover": "Oppdag", + "discovered_devices": "Oppdagede enheter", "dismiss_all_errors": "Avvis alle feil", "dismiss_error": "Avvis feil", "display_options": "Visningsalternativer", @@ -1128,6 +1136,7 @@ "list": "Liste", "loading": "Laster", "loading_search_results_failed": "Klarte ikke å laste inn søkeresultater", + "local_asset_cast_failed": "Kan ikke caste et bilde som ikke er lastet opp til serveren", "local_network": "Lokalt nettverk", "local_network_sheet_info": "Appen vil koble til serveren via denne URL-en når du bruker det angitte Wi-Fi-nettverket", "location_permission": "Stedstillatelse", @@ -1141,6 +1150,7 @@ "locked_folder": "Låst mappe", "log_out": "Logg ut", "log_out_all_devices": "Logg ut fra alle enheter", + "logged_in_as": "Logget inn som {user}", "logged_out_all_devices": "Logg ut av alle enheter", "logged_out_device": "Logg ut enhet", "login": "Logg inn", @@ -1268,6 +1278,7 @@ "no_archived_assets_message": "Arkiver bilder og videoer for å skjule dem fra visningen av bildene dine", "no_assets_message": "KLIKK FOR Å LASTE OPP DITT FØRSTE BILDE", "no_assets_to_show": "Ingen objekter å vise", + "no_cast_devices_found": "Ingen caste-enheter oppdaget", "no_duplicates_found": "Ingen duplikater ble funnet.", "no_exif_info_available": "Ingen EXIF-informasjon tilgjengelig", "no_explore_results_message": "Last opp flere bilder for å utforske samlingen din.", @@ -1319,7 +1330,7 @@ "other": "Annet", "other_devices": "Andre enheter", "other_variables": "Andre variabler", - "owned": "Ditt album", + "owned": "Dine", "owner": "Eier", "partner": "Partner", "partner_can_access": "{partner} har tilgang", @@ -1490,6 +1501,7 @@ "remove_from_shared_link": "Fjern fra delt lenke", "remove_memory": "Slett minne", "remove_photo_from_memory": "Slett bilde fra dette minne", + "remove_tag": "Fjern tag", "remove_url": "Fjern URL", "remove_user": "Fjern bruker", "removed_api_key": "Fjernet API-nøkkel: {name}", @@ -1596,6 +1608,7 @@ "select_album_cover": "Velg albumomslag", "select_all": "Velg alle", "select_all_duplicates": "Velg alle duplikater", + "select_all_in": "Velg alt i {group}", "select_avatar_color": "Velg avatarfarge", "select_face": "Velg ansikt", "select_featured_photo": "Velg fremhevet bilde", @@ -1626,6 +1639,7 @@ "set_date_of_birth": "Sett fødselsdato", "set_profile_picture": "Sett profilbilde", "set_slideshow_to_fullscreen": "Sett lysbildefremvisning til fullskjerm", + "set_stack_primary_asset": "Velg som primærbilde", "setting_image_viewer_help": "Detaljvisningen laster først miniatyrbildet, deretter forhåndsvisningsbildet (hvis aktivert), og til slutt originalen (hvis aktivert).", "setting_image_viewer_original_subtitle": "Aktiver for å laste originalbildet i full oppløsning (stort!). Deaktiver for å spare databruk (både nettverksbruk og bufferdata på enheten).", "setting_image_viewer_original_title": "Last originalbildet", @@ -1763,6 +1777,7 @@ "start_date": "Startdato", "state": "Fylke", "status": "Status", + "stop_casting": "Stopp casting", "stop_motion_photo": "Stopmotionbilde", "stop_photo_sharing": "Stopp deling av bildene dine?", "stop_photo_sharing_description": "{partner} vil ikke lenger ha tilgang til bildene dine.", @@ -1858,6 +1873,7 @@ "unsaved_change": "Ulagrede endringer", "unselect_all": "Fjern alle valg", "unselect_all_duplicates": "Fjern markeringen av alle duplikater", + "unselect_all_in": "Fjern alle i {group}", "unstack": "avstable", "unstacked_assets_count": "Ikke stablet {count, plural, one {# asset} other {# assets}}", "up_next": "Neste", diff --git a/i18n/nl.json b/i18n/nl.json index cd58e77351..07699e0b31 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "{count, number} toegevoegd aan favorieten", "admin": { "add_exclusion_pattern_description": "Uitsluitingspatronen toevoegen. Globbing met *, ** en ? wordt ondersteund. Om alle bestanden in een map met de naam \"Raw\" te negeren, gebruik \"**/Raw/**\". Om alle bestanden die eindigen op \".tif\" te negeren, gebruik \"**/*.tif\". Om een absoluut pad te negeren, gebruik \"/path/to/ignore/**\".", + "admin_user": "Beheerder gebruiker", "asset_offline_description": "Deze asset uit een externe bibliotheek is niet meer beschikbaar op de schijf en is naar de prullenbak verplaatst. Als het bestand binnen de bibliotheek is verplaatst, controleer dan je tijdlijn voor de nieuwe bijbehorende asset. Om dit bestand te herstellen, zorg ervoor dat het onderstaande bestandspad toegankelijk is voor Immich en scan de bibliotheek opnieuw.", "authentication_settings": "Authenticatie-instellingen", "authentication_settings_description": "Wachtwoord, OAuth, en andere authenticatie-instellingen beheren", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "Claim voor opslaglimiet", "oauth_storage_quota_claim_description": "Stel de opslaglimiet van de gebruiker automatisch in op de waarde van deze claim.", "oauth_storage_quota_default": "Standaard opslaglimiet (GiB)", - "oauth_storage_quota_default_description": "Limiet in GiB die moet worden gebruikt als er geen claim is opgegeven (voer 0 in voor onbeperkt).", + "oauth_storage_quota_default_description": "Limiet in GiB die moet worden gebruikt als er geen claim is opgegeven.", "oauth_timeout": "Aanvraag timeout", "oauth_timeout_description": "Time-out voor aanvragen in milliseconden", "password_enable_description": "Inloggen met e-mailadres en wachtwoord", @@ -243,7 +244,7 @@ "storage_template_migration_info": "Wijzigingen in de opslagtemplate worden alleen toegepast op nieuwe assets. Om de template met terugwerkende kracht toe te passen op eerder geüploade assets, voer je de {job} uit.", "storage_template_migration_job": "Opslagtemplate migratietaak", "storage_template_more_details": "Voor meer details over deze functie, bekijk de Opslagstemplate en de implicaties daarvan", - "storage_template_onboarding_description": "Wanneer ingeschakeld, zal deze functie bestanden automatisch organiseren gebaseerd op een template gedefinieerd door de gebruiker. Gezien stabiliteitsproblemen is deze functie standaard uitgeschakeld. Voor meer informatie, bekijk de documentatie.", + "storage_template_onboarding_description_v2": "Wanneer ingeschakeld, zal deze functie bestanden automatisch organiseren gebaseerd op een template gedefinieerd door de gebruiker. Voor meer informatie, bekijk de documentatie.", "storage_template_path_length": "Geschatte padlengte: {length, number}/{limit, number}", "storage_template_settings": "Opslagtemplate", "storage_template_settings_description": "Beheer de mapstructuur en bestandsnaam van geüploade bestanden", @@ -595,7 +596,7 @@ "change_description": "Wijzig beschrijving", "change_display_order": "Weergavevolgorde wijzigen", "change_expiration_time": "Verlooptijd wijzigen", - "change_location": "Locatie wijzigen", + "change_location": "Wijzig locatie", "change_name": "Naam wijzigen", "change_name_successfully": "Naam succesvol gewijzigd", "change_password": "Wijzig wachtwoord", @@ -1149,6 +1150,7 @@ "locked_folder": "Vergrendelde map", "log_out": "Uitloggen", "log_out_all_devices": "Uitloggen op alle apparaten", + "logged_in_as": "Ingelogd als {user}", "logged_out_all_devices": "Uitgelogd op alle apparaten", "logged_out_device": "Uitgelogd van apparaat", "login": "Inloggen", @@ -1606,6 +1608,7 @@ "select_album_cover": "Selecteer album cover", "select_all": "Alles selecteren", "select_all_duplicates": "Selecteer alle duplicaten", + "select_all_in": "Selecteer alles in {group}", "select_avatar_color": "Selecteer avatarkleur", "select_face": "Selecteer gezicht", "select_featured_photo": "Selecteer uitgelichte foto", @@ -1870,6 +1873,7 @@ "unsaved_change": "Niet-opgeslagen wijziging", "unselect_all": "Alles deselecteren", "unselect_all_duplicates": "Deselecteer alle duplicaten", + "unselect_all_in": "Deselecteer alles in {group}", "unstack": "Ontstapelen", "unstacked_assets_count": "{count, plural, one {# asset} other {# assets}} ontstapeld", "up_next": "Volgende", diff --git a/i18n/pl.json b/i18n/pl.json index 05d4221f00..e84c6f53e6 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "Dodano {count, number} do ulubionych", "admin": { "add_exclusion_pattern_description": "Dodaj wzorce wykluczające. Wspierane są specjalne sekwencje (glob) *, ** oraz ?. Aby ignorować całą zawartość wszystkich folderów nazwanych \"Raw\", użyj \"**/Raw/**\". Aby ignorować wszystkie pliki kończące się na \".tif\", użyj \"**/*.tif\". Aby ignorować ścieżkę absolutną, użyj \"/ścieżka/do/ignorowania/**\".", + "admin_user": "Administrator", "asset_offline_description": "Ten zewnętrzny zasób biblioteki nie jest już dostępny na dysku i został przeniesiony do kosza. Jeśli plik został przeniesiony w obrębie biblioteki, sprawdź swoją oś czasu pod kątem nowego odpowiadającego zasobu. Aby przywrócić ten zasób, upewnij się, że ścieżka pliku poniżej jest dostępna dla Immich i przeskanuj bibliotekę.", "authentication_settings": "Ustawienia Uwierzytelnienia", "authentication_settings_description": "Zarządzaj hasłem, OAuth i innymi ustawienia uwierzytelnienia", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "Ilość miejsca w magazynie", "oauth_storage_quota_claim_description": "Automatycznie ustaw ilość miejsca w magazynie na podaną niżej wartość.", "oauth_storage_quota_default": "Domyślna ilość miejsca w magazynie (GiB)", - "oauth_storage_quota_default_description": "Limit w GiB do wykorzystania, gdy nie podano żadnej wartości (wpisz 0, aby wyłączyć limit).", + "oauth_storage_quota_default_description": "Limit w GiB do wykorzystania, gdy nie podano żadnej wartości.", "oauth_timeout": "Upłynął czas żądania", "oauth_timeout_description": "Limit czasu żądania (w milisekundach)", "password_enable_description": "Zaloguj używając e-mail i hasła", @@ -243,7 +244,7 @@ "storage_template_migration_info": "Szablon Magazynu przekonwertuje wszystkie rozszerzenia na pisane małą literą. Zmiany w szablonie zostaną zastosowane tylko do nowych zasobów. Aby wstecznie zastosować szablon do wcześniej przesłanych zasobów, uruchom zadanie {job}.", "storage_template_migration_job": "Zadanie migracji szablonu przechowywania", "storage_template_more_details": "Aby uzyskać więcej szczegółów na temat tej funkcji, odwiedź Szablon Przechowywania oraz jego implikacje", - "storage_template_onboarding_description": "Po włączeniu tej funkcji pliki będą organizowane automatycznie na podstawie szablonu zdefiniowanego przez użytkownika. Obecnie domyślnie wyłączona przez problemy ze stabilnością. Więcej informacji znajdziesz w dokumentacji.", + "storage_template_onboarding_description_v2": "Po włączeniu ta funkcja automatycznie organizuje pliki w oparciu o zdefiniowany przez użytkownika szablon. Więcej informacji można znaleźć w dokumentacji.", "storage_template_path_length": "Przybliżony limit długości ścieżki: {length, number}/{limit, number}", "storage_template_settings": "Szablon Magazynu", "storage_template_settings_description": "Zarządzaj strukturą folderów i nazwą pliku przesyłanego zasobu", @@ -1149,6 +1150,7 @@ "locked_folder": "Folder zablokowany", "log_out": "Wyloguj", "log_out_all_devices": "Wyloguj ze Wszystkich Urządzeń", + "logged_in_as": "Zalogowano jako {user}", "logged_out_all_devices": "Wylogowano ze wszystkich urządzeń", "logged_out_device": "Wylogowany z urządzenia", "login": "Logowanie", @@ -1606,6 +1608,7 @@ "select_album_cover": "Wybierz okładkę albumu", "select_all": "Zaznacz wszystko", "select_all_duplicates": "Wybierz wszystkie duplikaty", + "select_all_in": "Wybierz wszystkie w {group}", "select_avatar_color": "Wybierz kolor awatara", "select_face": "Wybierz twarz", "select_featured_photo": "Zmień główne zdjęcie", @@ -1870,6 +1873,7 @@ "unsaved_change": "Niezapisana zmiana", "unselect_all": "Odznacz wszystko", "unselect_all_duplicates": "Odznacz wszystkie duplikaty", + "unselect_all_in": "Odznacz wszystkie w {group}", "unstack": "Rozłóż stos", "unstacked_assets_count": "{count, plural, one {Rozłożony # zasób} few {Rozłożone # zasoby} other {Rozłożonych # zasobów}}", "up_next": "Do następnego", diff --git a/i18n/pt.json b/i18n/pt.json index 37031364a9..bb88cafbfa 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -34,6 +34,7 @@ "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 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/**\".", + "admin_user": "Utilizador Administrador", "asset_offline_description": "Este ficheiro proveniente de uma biblioteca externa deixou de estar disponível no disco e foi movido para a reciclagem. Se o ficheiro foi movido no interior da biblioteca, procure na linha de tempo pelo novo ficheiro correspondente. Para restaurar este ficheiro, certifique-se que o caminho do ficheiro abaixo pode ser acedido pelo Immich e analise a biblioteca.", "authentication_settings": "Definições de Autenticação", "authentication_settings_description": "Gerir palavras-passe, OAuth, e outras definições de autenticação", @@ -203,7 +204,7 @@ "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).", + "oauth_storage_quota_default_description": "Quota em GiB a ser usada quando nenhuma reivindicação for fornecida.", "oauth_timeout": "Tempo Limite de Requisição", "oauth_timeout_description": "Tempo limite para requisições, em milissegundos", "password_enable_description": "Iniciar sessão com e-mail e palavra-passe", @@ -243,7 +244,7 @@ "storage_template_migration_info": "O modelo de armazenamento irá converter todas as extensões para letra minúscula. 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_onboarding_description_v2": "Quando ativada, está função irá automaticamente organizar ficheiros com base num modelo definido pelo utilizador. Para mais informações, consulte 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": "Gerir a estrutura de pastas e o nome do ficheiro carregado", @@ -1149,6 +1150,7 @@ "locked_folder": "Pasta Trancada", "log_out": "Sair", "log_out_all_devices": "Terminar a sessão de todos os dispositivos", + "logged_in_as": "Utilizador atual: {user}", "logged_out_all_devices": "Sessão terminada em todos os dispositivos", "logged_out_device": "Sessão terminada no dispositivo", "login": "Iniciar sessão", @@ -1606,6 +1608,7 @@ "select_album_cover": "Escolher capa do álbum", "select_all": "Selecionar todos", "select_all_duplicates": "Selecionar todos os itens duplicados", + "select_all_in": "Selecionar tudo em {group}", "select_avatar_color": "Selecionar cor do avatar", "select_face": "Selecionar rosto", "select_featured_photo": "Selecionar foto principal", @@ -1870,6 +1873,7 @@ "unsaved_change": "Alteração não guardada", "unselect_all": "Limpar seleção", "unselect_all_duplicates": "Remover seleção de todos os itens duplicados", + "unselect_all_in": "Remover seleção de {group}", "unstack": "Desempilhar", "unstacked_assets_count": "Desempilhados {count, plural, one {# ficheiro} other {# ficheiros}}", "up_next": "A seguir", diff --git a/i18n/pt_BR.json b/i18n/pt_BR.json index 023dbc4dd6..705180cafd 100644 --- a/i18n/pt_BR.json +++ b/i18n/pt_BR.json @@ -34,6 +34,7 @@ "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/**\".", + "admin_user": "Usuário Administrador", "asset_offline_description": "Este arquivo não foi encontrado na biblioteca externa, então foi enviado para a lixeira. Se o arquivo foi movido para outra pasta dentro da biblioteca, verifique sua linha do tempo para encontrar o arquivo novamente. Para restaurar este arquivo, certifique-se de que o caminho descrito abaixo pode ser acessado pelo Immich e então escaneie a biblioteca.", "authentication_settings": "Configurações de Autenticação", "authentication_settings_description": "Gerenciar senhas, OAuth, e outras configurações de autenticação", @@ -170,7 +171,7 @@ "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!", "notification_email_from_address": "E-mail de origem", - "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \". Tenha certeza de ter permissão para enviar e-mails a partir do endereço selecionado", + "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \". Tenha certeza de ter permissão para enviar e-mails a partir do endereço selecionado.", "notification_email_host_description": "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)", @@ -200,10 +201,10 @@ "oauth_settings_more_details": "Para mais detalhes sobre este recurso, consulte a documentação.", "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_quota_claim": "Reivindicação de cota de armazenamento", + "oauth_storage_quota_claim": "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_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).", + "oauth_storage_quota_default_description": "Cota em GiB a ser usada quando nenhuma outra reivindicação for fornecida.", "oauth_timeout": "Tempo Limite de Requisição", "oauth_timeout_description": "Tempo limite para requisições, em milissegundos", "password_enable_description": "Login com e-mail e senha", @@ -243,7 +244,7 @@ "storage_template_migration_info": "O modelo altera todas extensões para minúsculo. As mudanças no modelo serão aplicadas apenas em novos arquivos. Para aplicar retroativamente o modelo aos arquivos carregados anteriormente, execute o {job}.", "storage_template_migration_job": "Tarefa de Migração de Modelo de Armazenamento", "storage_template_more_details": "Para mais detalhes sobre este recurso, consulte o Modelo de Armazenamento e suas implicações", - "storage_template_onboarding_description": "Quando ativado, este recurso organizará automaticamente os arquivos com base em um modelo definido pelo usuário. Devido a problemas de estabilidade, o recurso está desativado por padrão. Para mais informações, consulte a documentação.", + "storage_template_onboarding_description_v2": "Ao ser ativado, este recurso irá organizar automaticamente os arquivos com base em um modelo definido pelo usuário. Para mais informações, consulte a documentação.", "storage_template_path_length": "Limite aproximado de comprimento do caminho: {length, number}/{limit, number}", "storage_template_settings": "Modelo de Armazenamento", "storage_template_settings_description": "Gerencie a estrutura de pasta e o nome do arquivo carregado", @@ -403,6 +404,9 @@ "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}}", + "albums_default_sort_order": "Ordem padrão do álbum", + "albums_default_sort_order_description": "Ordem padrão dos arquivos ao criar novos álbuns.", + "albums_feature_description": "Coleções de arquivos que podem ser compartilhados com outros usuários.", "all": "Todos", "all_albums": "Todos os álbuns", "all_people": "Todas as pessoas", @@ -461,6 +465,7 @@ "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}} {hasName, select, true {ao álbum {name}} other {em um novo álbum}}", + "assets_cannot_be_added_to_album_count": "Não foi possível adicionar {count, plural, one {o arquivo} other {os arquivos}} ao álbum", "assets_count": "{count, plural, one {# arquivo} other {# arquivos}}", "assets_deleted_permanently": "{count} arquivo(s) deletado(s) permanentemente", "assets_deleted_permanently_from_server": "{count} arquivo(s) deletado(s) permanentemente do servidor Immich", @@ -645,6 +650,7 @@ "confirm_password": "Confirme a senha", "confirm_tag_face": "Deseja marcar este rosto como {name}?", "confirm_tag_face_unnamed": "Deseja marcar este rosto?", + "connected_device": "Dispositivo conectado", "connected_to": "Conectado a", "contain": "Caber", "context": "Contexto", @@ -731,7 +737,7 @@ "delete_others": "Excluir restante", "delete_shared_link": "Excluir link de compartilhamento", "delete_shared_link_dialog_title": "Excluir link compartilhado", - "delete_tag": "Remover marcador", + "delete_tag": "Excluir marcador", "delete_tag_confirmation_prompt": "Tem certeza que deseja excluir o marcador {tagName} ?", "delete_user": "Excluir usuário", "deleted_shared_link": "Link de compartilhamento excluído", @@ -745,6 +751,7 @@ "disallow_edits": "Não permitir edições", "discord": "Discord", "discover": "Descobrir", + "discovered_devices": "Dispositivos encontrados", "dismiss_all_errors": "Dispensar todos os erros", "dismiss_error": "Dispensar erro", "display_options": "Opções de exibição", @@ -1129,6 +1136,7 @@ "list": "Lista", "loading": "Carregando", "loading_search_results_failed": "Falha ao carregar os resultados da pesquisa", + "local_asset_cast_failed": "Não é possível transmitir um arquivo que não foi enviado ao servidor", "local_network": "Rede local", "local_network_sheet_info": "O aplicativo irá se conectar ao servidor através desta URL quando estiver na rede Wi-Fi especificada", "location_permission": "Permissão de localização", @@ -1142,6 +1150,7 @@ "locked_folder": "Pasta Trancada", "log_out": "Sair", "log_out_all_devices": "Sair de todos dispositivos", + "logged_in_as": "Usuário atual: {user}", "logged_out_all_devices": "Saiu de todos os dispositivos", "logged_out_device": "Dispositivo desconectado", "login": "Iniciar sessão", @@ -1269,6 +1278,7 @@ "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_assets_to_show": "Não há arquivos para exibir", + "no_cast_devices_found": "Nenhum dispositivo encontrado", "no_duplicates_found": "Nenhuma duplicidade foi encontrada.", "no_exif_info_available": "Sem informações exif disponíveis", "no_explore_results_message": "Carregue mais fotos para explorar sua coleção.", @@ -1491,6 +1501,7 @@ "remove_from_shared_link": "Remover do link compartilhado", "remove_memory": "Remover memória", "remove_photo_from_memory": "Remover foto desta memória", + "remove_tag": "Remover marcador", "remove_url": "Remover URL", "remove_user": "Remover usuário", "removed_api_key": "Removido a Chave de API: {name}", @@ -1597,6 +1608,7 @@ "select_album_cover": "Escolher capa do álbum", "select_all": "Selecionar todos", "select_all_duplicates": "Selecionar todas as duplicatas", + "select_all_in": "Selecionar tudo em {group}", "select_avatar_color": "Selecionar cor do avatar", "select_face": "Selecionar rosto", "select_featured_photo": "Selecionar foto principal", @@ -1627,6 +1639,7 @@ "set_date_of_birth": "Definir data de nascimento", "set_profile_picture": "Definir foto de perfil", "set_slideshow_to_fullscreen": "Apresentação em tela cheia", + "set_stack_primary_asset": "Selecionar como arquivo principal", "setting_image_viewer_help": "O visualizador de imagens carrega primeiro a miniatura pequena, depois carrega a imagem de tamanho médio (se ativado) e, por fim, carrega o original (se ativado).", "setting_image_viewer_original_subtitle": "Ative para carregar a imagem original em resolução máxima (grande!). Desative para reduzir o uso de dados (tanto na rede quanto no cache do dispositivo).", "setting_image_viewer_original_title": "Carregar imagem original", @@ -1764,6 +1777,7 @@ "start_date": "Data inicial", "state": "Estado", "status": "Status", + "stop_casting": "Parar transmissão", "stop_motion_photo": "Parar foto em movimento", "stop_photo_sharing": "Parar de compartilhar suas fotos?", "stop_photo_sharing_description": "{partner} não terá mais acesso às suas fotos.", @@ -1859,6 +1873,7 @@ "unsaved_change": "Alteração não salva", "unselect_all": "Desselecionar todos", "unselect_all_duplicates": "Desselecionar todas as duplicatas", + "unselect_all_in": "Remover seleção de {group}", "unstack": "Retirar do grupo", "unstacked_assets_count": "{count, plural, one {# arquivo retirado} other {# arquivos retirados}} do grupo", "up_next": "A seguir", diff --git a/i18n/ro.json b/i18n/ro.json index c29b5907ef..0b2ef61a36 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -237,7 +237,6 @@ "storage_template_migration_info": "Șablonul de stocare va converti extensiile in litere mici. Modificările șablonului se vor aplica doar materialelor noi. Pentru a aplica retroactiv șablonul la materialele încărcate anterior, rulați {job}.", "storage_template_migration_job": "Sarcină Migrare Șablon Stocare", "storage_template_more_details": "Pentru mai multe detalii despre aceasta caracteristică, accesați Șablon stocare si implicațiile", - "storage_template_onboarding_description": "Atunci când este activată, această caracteristică va organiza automat fișierele pe baza unui șablon definit de utilizator. Din cauza unor probleme de stabilitate, această caracteristică este dezactivată implicit. Pentru mai multe informații, te rog să consulți documentația.", "storage_template_path_length": "Limita de lungime pentru calea aproximativă: {length, number}/{limit, number}", "storage_template_settings": "Șablon Stocare", "storage_template_settings_description": "Gestionează structura folderelor și numele fișierelor pentru elementele încărcate", diff --git a/i18n/ru.json b/i18n/ru.json index 04e70a0d9a..5810a31053 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "Добавлено{count, number} в избранное", "admin": { "add_exclusion_pattern_description": "Добавьте шаблоны исключений. Поддерживаются символы подстановки *, ** и ?. Чтобы игнорировать все файлы в любом каталоге с именем \"Raw\", укажите \"**/Raw/**\". Чтобы игнорировать все файлы, заканчивающиеся на \".tif\", используйте \"**/*.tif\". Чтобы игнорировать путь целиком, укажите \"/path/to/ignore/**\".", + "admin_user": "Администратор", "asset_offline_description": "Этот файл внешней библиотеки не был найден на диске и был перемещён в корзину. Если файл был перемещён внутри библиотеки, проверьте временную шкалу, чтобы найти новый соответствующий ресурс. Чтобы восстановить файл, убедитесь, что путь ниже доступен для Immich и выполните сканирование библиотеки.", "authentication_settings": "Настройки аутентификации", "authentication_settings_description": "Управление паролями, OAuth и другими настройками аутентификации", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "Квота хранилища", "oauth_storage_quota_claim_description": "Автоматически установить квоту хранилища пользователя на значение этого утверждения.", "oauth_storage_quota_default": "Квота хранилища по умолчанию (GiB)", - "oauth_storage_quota_default_description": "Квота в GiB, которая будет использоваться, если утверждение не задано (введите 0 для отсутствия ограничений).", + "oauth_storage_quota_default_description": "Квота в GiB, которая будет использоваться, если утверждение не задано.", "oauth_timeout": "Таймаут для запросов", "oauth_timeout_description": "Максимальное время, в течение которого ожидать ответа, в миллисекундах", "password_enable_description": "Вход по электронной почте и паролю", @@ -243,7 +244,7 @@ "storage_template_migration_info": "Расширения файлов всегда будут сохраняться в нижнем регистре. Изменения в шаблоне будут применяться только к новым ресурсам. Чтобы применить шаблон к ранее загруженным ресурсам, запустите {job}.", "storage_template_migration_job": "Задание по применению шаблона хранилища", "storage_template_more_details": "Для получения дополнительной информации об этой функции обратитесь к разделам документации Шаблон хранилища и Структура хранения файлов", - "storage_template_onboarding_description": "При включении этой функции файлы в хранилище будут автоматически переименовываться и распределяться по заданной шаблоном структуре папок. Из-за возможных проблем со стабильностью функция по умолчанию отключена. Дополнительную информацию можно найти в документации.", + "storage_template_onboarding_description_v2": "Если эта функция включена, она автоматически организует файлы на основе заданного пользователем шаблона. Для получения дополнительной информации обратитесь к документации.", "storage_template_path_length": "Примерный предел длины пути: {length, number}/{limit, number}", "storage_template_settings": "Шаблон хранилища", "storage_template_settings_description": "Управление структурой папок и именами загруженных файлов", @@ -1149,6 +1150,7 @@ "locked_folder": "Личная папка", "log_out": "Выйти", "log_out_all_devices": "Завершить сеансы на всех устройствах", + "logged_in_as": "Авторизован как {user}", "logged_out_all_devices": "Завершены все сеансы, кроме текущего", "logged_out_device": "Сеанс на выбранном устройстве завершён", "login": "Войти", @@ -1606,6 +1608,7 @@ "select_album_cover": "Выбрать обложку альбома", "select_all": "Выбрать все", "select_all_duplicates": "Выбрать все дубликаты", + "select_all_in": "Выбрать все в {group}", "select_avatar_color": "Выберите цвет аватара", "select_face": "Выбрать лицо", "select_featured_photo": "Выбрать избранное фото", @@ -1870,6 +1873,7 @@ "unsaved_change": "Несохранённое изменение", "unselect_all": "Отменить выделение", "unselect_all_duplicates": "Отменить выбор всех дубликатов", + "unselect_all_in": "Отменить выделение в {group}", "unstack": "Разгруппировать", "unstacked_assets_count": "{count, plural, one {Разгруппирован # объект} many {Разгруппировано # объектов} other {Разгруппировано # объекта}}", "up_next": "Следующее", diff --git a/i18n/sk.json b/i18n/sk.json index 869de49e26..cd64c52532 100644 --- a/i18n/sk.json +++ b/i18n/sk.json @@ -201,7 +201,7 @@ "oauth_storage_quota_claim": "Deklarácia kvóty úložiska", "oauth_storage_quota_claim_description": "Automaticky nastaviť kvótu úložiska používateľa na hodnotu tejto deklarácie.", "oauth_storage_quota_default": "Predvolený limit úložiska (GiB)", - "oauth_storage_quota_default_description": "Kvóta v GiB, ktorá sa má použiť, keď nie je poskytnutý žiadna deklarácia (zadajte 0 pre neobmedzenú kvótu).", + "oauth_storage_quota_default_description": "Kvóta v GiB, ktorá sa použije, ak nie je poskytnutá žiadna požiadavka.", "password_enable_description": "Prihlásiť sa pomocou emailu a hesla", "password_settings": "Prihlásenie cez heslo", "password_settings_description": "Spravovať nastavenia prihlásenia cez heslo", @@ -239,7 +239,6 @@ "storage_template_migration_info": "Šablóna úložiska skonvertuje všetky prípony na malé písmená. Zmeny šablón sa budú vzťahovať iba na nové diela. Ak chcete šablónu spätne použiť na predtým nahrané médiá, spustite {job}.", "storage_template_migration_job": "Úloha migrácie šablóny úložiska", "storage_template_more_details": "Ďalšie podrobnosti o tejto funkcii nájdete v Šablóna úložiska a jej dôsledky", - "storage_template_onboarding_description": "Keď je táto funkcia povolená, automaticky usporiada súbory na základe šablóny definovanej používateľom. Kvôli problémom so stabilitou bola funkcia predvolene vypnutá. Viac informácií nájdete v dokumentácii.", "storage_template_path_length": "Približný limit dĺžky cesty: {length, number}/{limit, number}", "storage_template_settings": "Šablóna úložiska", "storage_template_settings_description": "Spravujte štruktúru priečinkov a názov súboru odovzdaného média", @@ -919,6 +918,7 @@ "extension": "Rozšírenie", "external": "Externý", "external_libraries": "Externé knižnice", + "external_network": "Externá sieť", "external_network_sheet_info": "Ak nie ste v preferovanej sieti Wi-Fi, aplikácia sa pripojí k serveru prostredníctvom prvej z nižšie uvedených adries URL, na ktorú sa dostane, počnúc zhora nadol", "face_unassigned": "Nepriradená", "failed": "Neúspešné", @@ -936,6 +936,7 @@ "filetype": "Typ súboru", "filter": "Filter", "filter_people": "Filtrovať ľudí", + "filter_places": "Filtrovať miesta", "find_them_fast": "Nájdite ich rýchlejšie podľa mena", "fix_incorrect_match": "Opraviť nesprávnu zhodu", "folder": "Priečinok", @@ -948,6 +949,7 @@ "go_back": "Vrátiť sa späť", "go_to_folder": "Prejsť do priečinka", "go_to_search": "Prejsť na vyhľadávanie", + "grant_permission": "Udeliť povolenie", "group_albums_by": "Zoskupiť albumy podľa...", "group_country": "Zoskupenie podľa krajiny", "group_no": "Nezoskupovať", @@ -1012,6 +1014,7 @@ "night_at_midnight": "Každý deň o polnoci", "night_at_twoam": "Každú noc o 2:00" }, + "invalid_date": "Neplatný dátum", "invite_people": "Pozvať ľudí", "invite_to_album": "Pozvať do albumu", "items_count": "{count, plural, one {# položka} few {# položky} other {# položiek}}", @@ -1047,6 +1050,7 @@ "list": "Zoznam", "loading": "Načítavanie", "loading_search_results_failed": "Načítanie výsledkov hľadania sa nepodarilo", + "local_network": "Miestna sieť", "location_permission_content": "Na používanie funkcie automatického prepínania potrebuje aplikácia Immich presné povolenie na určenie polohy, aby mohla prečítať názov aktuálnej Wi-Fi siete", "location_picker_choose_on_map": "Zvoľte na mape", "location_picker_latitude_error": "Zadajte platnú zemepisnú šírku", @@ -1077,7 +1081,7 @@ "login_form_handshake_exception": "Nastala chyba handshake. Zapnite podoporu samo-podpísaných certifikátov v nastaveniach ak používate samo-podpísané certifikáty.", "login_form_password_hint": "heslo", "login_form_save_login": "Zostať prihlásený", - "login_form_server_empty": "Zadajte URL adresu servera", + "login_form_server_empty": "Zadajte URL adresu servera.", "login_form_server_error": "Nemožno pripojiť na server.", "login_has_been_disabled": "Prihlásenie bolo vypnuté.", "login_password_changed_error": "Nastala chyba pri aktualizovaní hesla", @@ -1264,6 +1268,7 @@ "permanently_delete_assets_prompt": "Naozaj si prajete navždy zmazať {count, plural, one {túto položku?} other {týchto # položiek?}} Vymažú sa aj {count, plural, one {zo svojho albumu} other {zo svojich albumov}}.", "permanently_deleted_asset": "Navždy odstránená položka", "permanently_deleted_assets_count": "Navždy {count, plural, one {odstránená # položka} other {odstránené # položky}}", + "permission": "Povolenie", "permission_onboarding_back": "Späť", "permission_onboarding_continue_anyway": "Pokračovať aj tak", "permission_onboarding_get_started": "Začať", @@ -1661,6 +1666,7 @@ "support_third_party_description": "Vaša inštalácia Immich bola pripravená treťou stranou. Problémy, ktoré sa vyskytli, môžu byť spôsobené týmto balíčkom, preto sa na nich obráťte v prvom rade cez nasledujúce odkazy.", "swap_merge_direction": "Vymeniť smer zlúčenia", "sync": "Synchronizovať", + "sync_albums": "Synchronizovať albumy", "tag": "Značka", "tag_assets": "Pridať značku", "tag_created": "Vytvorená značka: {tag}", @@ -1732,6 +1738,7 @@ "unstack": "Odskupiť", "unstacked_assets_count": "{count, plural, one {Rozložená # položka} few {Rozložené # položky} other {Rozložených # položiek}}", "up_next": "To je všetko", + "updated_at": "Aktualizované", "updated_password": "Heslo zmenené", "upload": "Nahrať", "upload_concurrency": "Súbežnosť nahrávania", diff --git a/i18n/sl.json b/i18n/sl.json index 5865d4ee86..5132d09fc8 100644 --- a/i18n/sl.json +++ b/i18n/sl.json @@ -34,6 +34,7 @@ "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/**\".", + "admin_user": "Skrbniški uporabnik", "asset_offline_description": "Sredstva zunanje knjižnice ni več mogoče najti na disku in je bilo premaknjeno v koš. Če je bila datoteka premaknjena znotraj knjižnice, preverite svojo časovnico za novo ustrezno sredstvo. Če želite obnoviti to sredstvo, zagotovite, da ima Immich dostop do spodnje poti datoteke, in skenirajte knjižnico.", "authentication_settings": "Nastavitve preverjanja pristnosti", "authentication_settings_description": "Upravljanje gesel, OAuth in drugih nastavitev preverjanja pristnosti", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "Zahtevek za kvoto prostora za shranjevanje", "oauth_storage_quota_claim_description": "Samodejno nastavi uporabnikovo kvoto shranjevanja na vrednost tega zahtevka.", "oauth_storage_quota_default": "Privzeta kvota za shranjevanje (GiB)", - "oauth_storage_quota_default_description": "Kvota v GiB, ki se uporabi, ko ni predložen noben zahtevek (vnesite 0 za neomejeno kvoto).", + "oauth_storage_quota_default_description": "Kvota v GiB, ki se uporabi, kadar ni podanega zahtevka.", "oauth_timeout": "Časovna omejitev zahteve", "oauth_timeout_description": "Časovna omejitev za zahteve v milisekundah", "password_enable_description": "Prijava z e-pošto in geslom", @@ -243,7 +244,7 @@ "storage_template_migration_info": "Spremembe predloge bodo veljale samo za nova sredstva. Če želite retroaktivno uporabiti predlogo za predhodno naložena sredstva, zaženite {job}.", "storage_template_migration_job": "Opravilo selitve predloge za shranjevanje", "storage_template_more_details": "Za več podrobnosti o tej funkciji si oglejte Predlogo za shranjevanje in njene posledice", - "storage_template_onboarding_description": "Ko je omogočena, bo ta funkcija samodejno organizirala datoteke na podlagi uporabniško določene predloge. Zaradi težav s stabilnostjo je bila funkcija privzeto izklopljena. Za več informacij si oglejte dokumentacijo.", + "storage_template_onboarding_description_v2": "Ko je omogočena, bo ta funkcija samodejno organizirala datoteke na podlagi uporabniško določene predloge. Za več informacij glejte dokumentacijo.", "storage_template_path_length": "Približna omejitev dolžine poti: {length, number}/{limit, number}", "storage_template_settings": "Predloga za shranjevanje", "storage_template_settings_description": "Upravljajte strukturo map in ime datoteke sredstva za nalaganje", @@ -290,7 +291,7 @@ "transcoding_encoding_options": "Možnosti kodiranja", "transcoding_encoding_options_description": "Nastavite kodeke, ločljivost, kakovost in druge možnosti za kodirane videoposnetke", "transcoding_hardware_acceleration": "Strojno pospeševanje", - "transcoding_hardware_acceleration_description": "Eksperimentalno: hitrejše prekodiranje, vendar se lahko kakovost pri enaki bitni hitrosti zmanjša.", + "transcoding_hardware_acceleration_description": "Eksperimentalno: hitrejše prekodiranje, vendar se lahko kakovost pri enaki bitni hitrosti zmanjša", "transcoding_hardware_decoding": "Strojno dekodiranje", "transcoding_hardware_decoding_setting_description": "Omogoča pospeševanje od konca do konca namesto samo pospeševanja kodiranja. Morda ne bo delovalo na vseh videoposnetkih.", "transcoding_max_b_frames": "Največji B-okvirji", @@ -1149,6 +1150,7 @@ "locked_folder": "Zaklenjena mapa", "log_out": "Odjava", "log_out_all_devices": "Odjava vseh naprav", + "logged_in_as": "Prijavljen kot {user}", "logged_out_all_devices": "Odjavljene so vse naprave", "logged_out_device": "Odjavljena naprava", "login": "Prijava", @@ -1606,6 +1608,7 @@ "select_album_cover": "Izberi naslovnico albuma", "select_all": "Izberi vse", "select_all_duplicates": "Izberi vse dvojnike", + "select_all_in": "Izberi vse v {group}", "select_avatar_color": "Izberi barvo avatarja", "select_face": "Izberi obraz", "select_featured_photo": "Izberi predstavljeno fotografijo", @@ -1870,6 +1873,7 @@ "unsaved_change": "Neshranjena sprememba", "unselect_all": "Odznači vse", "unselect_all_duplicates": "Odznači vse dvojnike", + "unselect_all_in": "Prekliči izbor vseh v {group}", "unstack": "Razklad", "unstacked_assets_count": "Razloži {count, plural, one {# sredstvo} two {# sredstvi} few {# sredstva} other {# sredstev}}", "up_next": "Naslednja", diff --git a/i18n/sr_Cyrl.json b/i18n/sr_Cyrl.json index 7c2f472b07..b518bb39af 100644 --- a/i18n/sr_Cyrl.json +++ b/i18n/sr_Cyrl.json @@ -242,7 +242,6 @@ "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": "Управљајте структуром директоријума и именом датотеке средства за отпремање", diff --git a/i18n/sr_Latn.json b/i18n/sr_Latn.json index 81e3954e83..aa92e6da49 100644 --- a/i18n/sr_Latn.json +++ b/i18n/sr_Latn.json @@ -241,7 +241,6 @@ "storage_template_migration_info": "Promene šablona će se primeniti samo na nove datoteke. Da biste retroaktivno primenili šablon na prethodno otpremljene datoteke, pokrenite {job}.", "storage_template_migration_job": "Posao migracije skladišta", "storage_template_more_details": "Za više detalja o ovoj funkciji pogledajte Šablon za skladište i njegove implikacije", - "storage_template_onboarding_description": "Kada je omogućena, ova funkcija će automatski organizovati datoteke na osnovu šablona koji definiše korisnik. Zbog problema sa stabilnošću ova funkcija je podrazumevano isključena. Za više informacija pogledajte dokumentaciju.", "storage_template_path_length": "Približno ograničenje dužine putanje: {length, number}/{limit, number}", "storage_template_settings": "Šablon za skladištenje", "storage_template_settings_description": "Upravljajte strukturom direktorijuma i imenom datoteke sredstva za otpremanje", diff --git a/i18n/sv.json b/i18n/sv.json index f8a29e0020..a4c08f8c22 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -242,7 +242,6 @@ "storage_template_migration_info": "Lagringsmallen kommer konvertera alla filändelser till gemena bokstäver. Ändringar gäller endast för nya resurser, för att retoaktivt tillämpa mallen på befintliga 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å grund 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": "Hantera mappstruktur och filnamn för uppladdade resurser", diff --git a/i18n/ta.json b/i18n/ta.json index 67bcda3285..ce0768982d 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -227,7 +227,6 @@ "storage_template_migration_info": "டெம்ப்ளேட் மாற்றங்கள் புதிய படங்களுக்கு மட்டுமே பொருந்தும். முன்பு பதிவேற்றிய படங்களுக்கு டெம்ப்ளேட்டைப் பயன்படுத்த, {job} ஐ இயக்கவும்.", "storage_template_migration_job": "ஸ்டோரேஜ் டெம்ப்ளேட் இடம்பெயர்வு வேலை", "storage_template_more_details": "இந்த அம்சத்தைப் பற்றிய கூடுதல் விவரங்களுக்கு, Storage Template மற்றும் அதன் தாக்கங்கள் ஐப் பார்க்கவும்", - "storage_template_onboarding_description": "இயக்கப்பட்டால், இந்த அம்சம் பயனர் வரையறுக்கப்பட்ட டெம்ப்ளேட்டின் அடிப்படையில் புகைப்படங்களைத் தானாக ஒழுங்கமைக்கும். நிலைத்தன்மை சிக்கல்கள் காரணமாக, அம்சம் இயல்பாகவே முடக்கப்பட்டுள்ளது. மேலும் தகவலுக்கு, ஆவணத்தைப் பார்க்கவும்.", "storage_template_path_length": "தோராயமான பாதை நீள வரம்பு: {length, number}/{limit, number}", "storage_template_settings": "ஸ்டோரேஜ் டெம்ப்ளேட்", "storage_template_settings_description": "பதிவேற்ற புகைப்படங்களின் கோப்புறை அமைப்பு மற்றும் கோப்பு பெயரை நிர்வகிக்கவும்", diff --git a/i18n/te.json b/i18n/te.json index 39639c5f64..8bd57bc3bc 100644 --- a/i18n/te.json +++ b/i18n/te.json @@ -230,7 +230,6 @@ "storage_template_migration_info": "నిల్వ టెంప్లేట్ అన్ని పొడిగింపులను చిన్న అక్షరాలకు మారుస్తుంది. టెంప్లేట్ మార్పులు కొత్త ఆస్తులకు మాత్రమే వర్తిస్తాయి. గతంలో అప్‌లోడ్ చేసిన ఆస్తులకు టెంప్లేట్‌ను మునుపు వర్తింపజేయడానికి, {job}ను అమలు చేయండి.", "storage_template_migration_job": "నిల్వ టెంప్లేట్ మైగ్రేషన్ జాబ్", "storage_template_more_details": "ఈ ఫీచర్ గురించి మరిన్ని వివరాల కోసం, storage template మరియు దాని implications చూడండి", - "storage_template_onboarding_description": "ప్రారంభించబడినప్పుడు, ఈ లక్షణం వినియోగదారు నిర్వచించిన టెంప్లేట్ ఆధారంగా ఫైళ్ళను స్వయంచాలకంగా నిర్వహిస్తుంది. స్థిరత్వ సమస్యల కారణంగా ఈ లక్షణం డిఫాల్ట్‌గా ఆపివేయబడింది. మరిన్ని వివరాల కోసం, దయచేసి documentation చూడండి.", "storage_template_path_length": "సుమారు పాత్ పొడవు పరిమితి: {length, number}/{limit, number}", "storage_template_settings": "నిల్వ టెంప్లేట్", "storage_template_settings_description": "అప్‌లోడ్ ఆస్తి యొక్క ఫోల్డర్ నిర్మాణం మరియు ఫైల్ పేరును నిర్వహించండి", diff --git a/i18n/th.json b/i18n/th.json index 5897e99fb7..13d9514d63 100644 --- a/i18n/th.json +++ b/i18n/th.json @@ -242,7 +242,6 @@ "storage_template_migration_info": "เทมเพลตของการจัดเก็บข้อมูลจะเปลี่ยนตัวอักษรเป็นตัวพิมพ์เล็กทั้งหมด การเปลี่ยนแปลงเทมเพลตจะมีผลกับแอสเซ็ตใหม่เท่านั้น หากต้องการนำเทมเพลตไปใช้กับ Asset ที่อัปโหลดก่อนหน้านี้ ให้รัน {job}.", "storage_template_migration_job": "เทมเพลตการ Migration ข้อมูล", "storage_template_more_details": "สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับฟีเจอร์นี้ โปรดดูที่ Storage Template และ ผลกระทบ", - "storage_template_onboarding_description": "เมื่อเปิดใช้งาน ฟีเจอร์นี้จะจัดระเบียบไฟล์โดยอัตโนมัติตามเทมเพลตที่ผู้ใช้กำหนด เนื่องจากปัญหาด้านความเสถียร ฟีเจอร์นี้จึงถูกปิดใช้งานเป็นค่าเริ่มต้น สำหรับข้อมูลเพิ่มเติม โปรดดูที่ เอกสารประกอบ", "storage_template_path_length": "ขีดจำกัดของความยาวพาธโดยประมาณ: {length, number}/{limit, number}", "storage_template_settings": "เทมเพลตการจัดเก็บข้อมูล", "storage_template_settings_description": "จัดการโครงสร้างโฟลเดอร์และชื่อไฟล์ที่อัปโหลด", diff --git a/i18n/tr.json b/i18n/tr.json index a88cc30b09..ddb99ca019 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -240,7 +240,6 @@ "storage_template_migration_info": "Şablon ayarlarındaki değişiklikler sadece yeni varlıklara uygulanacak. Şablon ayarlarını daha önce yüklenmiş olan varlıklara uygulamak için {job} çalıştırın.", "storage_template_migration_job": "Depolama Adreslerini Değiştirme Görevi", "storage_template_more_details": "Bu özellik hakkında daha fazla bilgi için, Depolama Şablonu ve onun etkileri kısmına bakın", - "storage_template_onboarding_description": "Bu özellik açıldığında, dosyaları kullanıcı için belirlenen depolama adresi taslağına göre otomatik olarak düzenler. Bu özellik bazen sorun çıkarabildiğini için kapalı gelmektedir. Daha fazla bilgi için dokümantasyona bakabilirsiniz.", "storage_template_path_length": "Tahmini dosya adresi uzunluğu: {length, number}/{limit, number}", "storage_template_settings": "Depolama Şablonu", "storage_template_settings_description": "Yüklenen dosyanın ismini ve klasör yapısını düzenle", diff --git a/i18n/uk.json b/i18n/uk.json index 04b7ce969c..7b843ceff9 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -22,6 +22,7 @@ "add_partner": "Додати партнера", "add_path": "Додати шлях", "add_photos": "Додати знімки", + "add_tag": "Додати тег", "add_to": "Додати у…", "add_to_album": "Додати у альбом", "add_to_album_bottom_sheet_added": "Додано до {album}", @@ -33,6 +34,7 @@ "added_to_favorites_count": "Додано {count, number} до обраного", "admin": { "add_exclusion_pattern_description": "Додайте шаблони виключень. Підстановка з використанням *, ** та ? підтримується. Для ігнорування всіх файлів у будь-якому каталозі з ім'ям «Raw», використовуйте \"**/Raw/**\". Для ігнорування всіх файлів, що закінчуються на \".tif\", використовуйте \"**/*.tif\". Для ігнорування абсолютного шляху використовуйте \"/path/to/ignore/**\".", + "admin_user": "Адміністратор", "asset_offline_description": "Цей зовнішній бібліотечний актив більше не знайдено на диску і був переміщений до смітника. Якщо файл був переміщений у межах бібліотеки, перевірте свій таймлайн на наявність нового відповідного активу. Щоб відновити цей актив, переконайтеся, що шлях файлу нижче доступний для Immich, і проскануйте бібліотеку.", "authentication_settings": "Налаштування аутентифікації", "authentication_settings_description": "Управління паролями, OAuth та іншими налаштуваннями аутентифікації", @@ -169,7 +171,7 @@ "note_apply_storage_label_previous_assets": "Примітка: Щоб застосувати мітку зберігання до раніше завантажених ресурсів, запустіть", "note_cannot_be_changed_later": "ПРИМІТКА: Це не можна змінити пізніше!", "notification_email_from_address": "З адреси", - "notification_email_from_address_description": "Адреса електронної пошти відправника, наприклад: \"Immich Photo Server \"", + "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 (не рекомендується)", @@ -202,7 +204,7 @@ "oauth_storage_quota_claim": "Заявка на квоту на зберігання", "oauth_storage_quota_claim_description": "Автоматично встановити квоту сховища користувача на значення цієї вимоги.", "oauth_storage_quota_default": "Квота за замовчуванням (GiB)", - "oauth_storage_quota_default_description": "Квота в GiB, що використовується, коли налаштування не надано (введіть 0 для необмеженої квоти).", + "oauth_storage_quota_default_description": "Квота в GiB, що використовується, коли налаштування не надано.", "oauth_timeout": "Тайм-аут для запитів", "oauth_timeout_description": "Максимальний час очікування відповіді в мілісекундах", "password_enable_description": "Увійти за електронною поштою та паролем", @@ -242,7 +244,6 @@ "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": "Керуйте структурою тек та іменем завантаженого файлу", @@ -402,6 +403,9 @@ "album_with_link_access": "Поділіться посиланням на альбом, щоб ваші друзі могли його переглянути.", "albums": "Альбоми", "albums_count": "{count, plural, one {1 альбом} few {{count, number} альбоми} many {{count, number} альбомів} other {{count, number} альбомів}}", + "albums_default_sort_order": "Порядок сортування альбомів за замовчуваням", + "albums_default_sort_order_description": "Початковий порядок сортування ресурсів під час створення нових альбомів.", + "albums_feature_description": "Колекції ресурсів, які можна спільно використовувати з іншими користувачами.", "all": "Усі", "all_albums": "Усі альбоми", "all_people": "Усі люди", @@ -460,9 +464,12 @@ "assets_added_count": "Додано {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}}", "assets_added_to_album_count": "Додано {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}} до альбому", "assets_added_to_name_count": "Додано {count, plural, one {# елемент} other {# елементів}} до {hasName, select, true {{name}} other {нового альбому}}", + "assets_cannot_be_added_to_album_count": "{count, plural, one {Ресурс} other {Ресурси}} не можна додати до альбому", "assets_count": "{count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}}", "assets_deleted_permanently": "{count} елемент(и) остаточно видалено", "assets_deleted_permanently_from_server": "{count} елемент(и) видалено назавжди з сервера Immich", + "assets_downloaded_failed": "{count, plural, one {Завантажено # файл — {error} файл не вдалося} other {Завантажено # файлів — {error} файлів не вдалося}}", + "assets_downloaded_successfully": "{count, plural, one {Успішно завантажено # файл} other {Успішно завантажено # файлів}}", "assets_moved_to_trash_count": "Переміщено {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}} у смітник", "assets_permanently_deleted_count": "Остаточно видалено {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}}", "assets_removed_count": "Вилучено {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}}", @@ -642,6 +649,7 @@ "confirm_password": "Підтвердити пароль", "confirm_tag_face": "Бажаєте позначити це обличчя як {name}?", "confirm_tag_face_unnamed": "Бажаєте позначити це обличчя?", + "connected_device": "Підключений пристрій", "connected_to": "Підключено до", "contain": "Містити", "context": "Контекст", @@ -742,6 +750,7 @@ "disallow_edits": "Заборонити редагування", "discord": "Discord", "discover": "Виявити", + "discovered_devices": "Виявлені пристрої", "dismiss_all_errors": "Пропустити всі помилки", "dismiss_error": "Пропустити помилку", "display_options": "Параметри відображення", @@ -1126,6 +1135,7 @@ "list": "Перелік", "loading": "Завантаження", "loading_search_results_failed": "Не вдалося завантажити результати пошуку", + "local_asset_cast_failed": "Неможливо транслювати ресурс, який не завантажено на сервер", "local_network": "Локальна мережа", "local_network_sheet_info": "Додаток підключатиметься до сервера через цей URL, коли використовується вказана Wi-Fi мережа", "location_permission": "Дозвіл до місцезнаходження", @@ -1139,6 +1149,7 @@ "locked_folder": "Особиста папка", "log_out": "Вийти", "log_out_all_devices": "Вийти з усіх пристроїв", + "logged_in_as": "Вхід виконано як {user}", "logged_out_all_devices": "Вийшли з усіх пристроїв", "logged_out_device": "Вихід з пристрою", "login": "Вхід", @@ -1170,7 +1181,7 @@ "look": "Дивитися", "loop_videos": "Циклічні відео", "loop_videos_description": "Увімкнути циклічне відтворення відео.", - "main_branch_warning": "Ви використовуєте версію для розробників; ми настійно рекомендуємо використовувати релізну версію!", + "main_branch_warning": "Ви використовуєте версію для розробників; настійно рекомендуємо використовувати релізну версію!", "main_menu": "Головне меню", "make": "Виробник", "manage_shared_links": "Керування спільними посиланнями", @@ -1266,6 +1277,7 @@ "no_archived_assets_message": "Заархівувати фотографії та відео, щоб приховати їх у вашому перегляді фото", "no_assets_message": "НАТИСНІТЬ, ЩОБ ЗАВАНТАЖИТИ ВАШЕ ПЕРШЕ ФОТО", "no_assets_to_show": "Елементи відсутні", + "no_cast_devices_found": "Пристрої для трансляції не знайдено", "no_duplicates_found": "Дублікатів не виявлено.", "no_exif_info_available": "Відсутня інформація про exif", "no_explore_results_message": "Завантажуйте більше фотографій, щоб насолоджуватися вашою колекцією.", @@ -1435,7 +1447,7 @@ "purchase_lifetime_description": "Назавжди", "purchase_option_title": "ВАРІАНТИ КУПІВЛІ", "purchase_panel_info_1": "Розробка Immich вимагає багато часу та зусиль. Ми маємо штатних інженерів, які працюють над тим, щоб зробити його якомога кращим. Наша місія — зробити програмне забезпечення з відкритим кодом та етичні бізнес-практики стійким джерелом доходу для розробників і створити екосистему, що поважає приватність, з реальними альтернативами експлуататорським хмарним сервісам.", - "purchase_panel_info_2": "Оскільки ми зобов'язалися не додавати платних блокувань, ця покупка не надасть вам додаткових функцій у Immich. Ми покладаємося на користувачів, таких як ви, щоб підтримувати постійний розвиток Immich.", + "purchase_panel_info_2": "Оскільки ми зобов’язуємося не додавати платні обмеження, ця покупка не надасть вам додаткових функцій в Immich. Ми покладаємося на таких користувачів, як ви, щоб підтримувати подальший розвиток Immich.", "purchase_panel_title": "Підтримати проєкт", "purchase_per_server": "На сервер", "purchase_per_user": "На користувача", @@ -1488,6 +1500,7 @@ "remove_from_shared_link": "Видалити зі спільного посилання", "remove_memory": "Видалити спогад", "remove_photo_from_memory": "Видалити фото з цього спогаду", + "remove_tag": "Видалити тег", "remove_url": "Видалити URL", "remove_user": "Видалити користувача", "removed_api_key": "Видалено ключ API: {name}", @@ -1594,6 +1607,7 @@ "select_album_cover": "Обрати обкладинку альбому", "select_all": "Вибрати все", "select_all_duplicates": "Вибрати всі дублікати", + "select_all_in": "Вибрати все в {group}", "select_avatar_color": "Вибрати колір аватара", "select_face": "Виберіть обличчя", "select_featured_photo": "Обрати обране фото", @@ -1624,6 +1638,7 @@ "set_date_of_birth": "Встановити дату народження", "set_profile_picture": "Встановити зображення профілю", "set_slideshow_to_fullscreen": "Встановити слайд-шоу на весь екран", + "set_stack_primary_asset": "Встановити як основний ресурс", "setting_image_viewer_help": "Повноекранний переглядач спочатку завантажує зображення для попереднього перегляду в низькій роздільній здатності, потім завантажує зображення в зменшеній роздільній здатності відносно оригіналу (якщо включено) і зрештою завантажує оригінал (якщо включено).", "setting_image_viewer_original_subtitle": "Увімкнути для завантаження оригінального зображення з повною роздільною здатністю (велике!). Вимкнути, щоб зменшити використання даних (як через мережу, так і на кеші пристрою).", "setting_image_viewer_original_title": "Завантажувати оригінальне зображення", @@ -1761,6 +1776,7 @@ "start_date": "Дата початку", "state": "Регіон", "status": "Стан", + "stop_casting": "Зупинити трансляцію", "stop_motion_photo": "Фото \"Стоп-моушен\"", "stop_photo_sharing": "Припинити надання ваших знімків?", "stop_photo_sharing_description": "{partner} більше не матиме доступу до ваших фотографій.", @@ -1839,6 +1855,7 @@ "unable_to_setup_pin_code": "Неможливо налаштувати PIN-код", "unarchive": "Розархівувати", "unarchived_count": "{count, plural, other {Повернуто з архіву #}}", + "undo": "Скасувати", "unfavorite": "Видалити з улюблених", "unhide_person": "Розкрити особу", "unknown": "Невідомо", @@ -1855,6 +1872,7 @@ "unsaved_change": "Незбережена зміна", "unselect_all": "Зняти все", "unselect_all_duplicates": "Скасувати вибір усіх дублікатів", + "unselect_all_in": "Зняти вибір у всьому {group}", "unstack": "Розібрати стек", "unstacked_assets_count": "Розгорнути {count, plural, one {# ресурс} few {# ресурси} many {# ресурсів} other {# ресурсів}}", "up_next": "Наступне", diff --git a/i18n/vi.json b/i18n/vi.json index 0a404804cb..5890ef87db 100644 --- a/i18n/vi.json +++ b/i18n/vi.json @@ -242,7 +242,6 @@ "storage_template_migration_info": "Mẫu lưu trữ sẽ chuyển tất cả định dạng tập tin thành chữ thường. Những thay đổi của mẫu này chỉ áp dụng cho các ảnh mới tải lên. Để áp dụng mẫu này cho các ảnh đã 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 tin của ảnh tải lên", diff --git a/i18n/zh_Hant.json b/i18n/zh_Hant.json index ce1ec92098..e5a8a65e25 100644 --- a/i18n/zh_Hant.json +++ b/i18n/zh_Hant.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "將 {count, number} 個項目加入收藏", "admin": { "add_exclusion_pattern_description": "新增排除條件。支援使用「*」、「 **」、「?」來找尋符合規則的字串。如果要在任何名為「Raw」的目錄內排除所有符合條件的檔案,請使用「**/Raw/**」。如果要排除所有「.tif」結尾的檔案,請使用「**/*.tif」。如果要排除某個絕對路徑,請使用「/path/to/ignore/**」。", + "admin_user": "管理員", "asset_offline_description": "磁碟上找不到此外部相簿檔案,且已移至垃圾桶。如果檔案在相簿內被移動,請檢查時間軸中是否有新的相應的檔案。若要還原這份檔案,請確保 Immich 可以寫入下列檔案路徑,並讀取掃描相簿內容。", "authentication_settings": "驗證設定", "authentication_settings_description": "管理密碼、OAuth 與其他驗證設定", @@ -44,7 +45,7 @@ "backup_database_enable_description": "開啟資料庫備份", "backup_keep_last_amount": "保留先前備份的數量", "backup_settings": "資料庫備份設定", - "backup_settings_description": "管理資料庫備份設定。 *註:這項任務不會有紀錄,失敗時無法收到通知。", + "backup_settings_description": "管理資料庫備份設定。", "cleared_jobs": "已刪除「{job}」任務", "config_set_by_file": "已透過設定檔更新設定", "confirm_delete_library": "確定要刪除 {library} 相簿嗎?", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "儲存配額宣告", "oauth_storage_quota_claim_description": "自動將使用者的儲存配額定為此宣告之值。", "oauth_storage_quota_default": "預設儲存配額(GiB)", - "oauth_storage_quota_default_description": "未宣告時所使用的配額(單位:GiB)(輸入 0 表示不限制配額)。", + "oauth_storage_quota_default_description": "未宣告時所使用的配額(單位:GiB)。", "oauth_timeout": "請求逾時", "oauth_timeout_description": "請求的逾時時間(毫秒)", "password_enable_description": "用電子郵件和密碼登入", @@ -243,7 +244,7 @@ "storage_template_migration_info": "透用儲存範例將將把所有檔案副檔名改爲小寫。模板更新僅適用於新項目。若要套用過去範例請先上傳項目,請執行 {job}。", "storage_template_migration_job": "存儲模板遷移任務", "storage_template_more_details": "欲了解更多有關此功能的詳細信息,請參閱 存儲模板 及其 影響", - "storage_template_onboarding_description": "啟用此功能後,將根據用戶自定義的模板自動組織文件。由於穩定性問題,此功能已默認關閉。欲了解更多信息,請參閱 文檔。", + "storage_template_onboarding_description_v2": "啟用此功能後,系統將根據使用者自訂的範本自動整理檔案。有關更多信息,請參閱文檔 。", "storage_template_path_length": "大致路徑長度限制:{length, number}/{limit, number}", "storage_template_settings": "存儲模板", "storage_template_settings_description": "管理上傳檔案的資料夾結構和檔名", @@ -405,6 +406,7 @@ "albums_count": "{count, plural, one {{count, number} 本相簿} other {{count, number} 本相簿}}", "albums_default_sort_order": "預設相簿排序", "albums_default_sort_order_description": "建立新相簿時要初始化項目排序。", + "albums_feature_description": "一系列可以分享給其他用戶的項目。", "all": "全部", "all_albums": "所有相簿", "all_people": "所有人物", @@ -463,6 +465,7 @@ "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_cannot_be_added_to_album_count": "{count. plural, one {個} other {個}} 項目未能被添加至相簿", "assets_count": "{count, plural, one {# 個項目} other {# 個項目}}", "assets_deleted_permanently": "{count} 個項目已被永久刪除", "assets_deleted_permanently_from_server": "已從伺服器中永久移除 {count} 個項目", @@ -1881,7 +1884,7 @@ "user_usage_stats": "帳號使用量統計", "user_usage_stats_description": "查看帳號使用量", "username": "使用者名稱", - "users": "使用者", + "users": "admin", "utilities": "工具", "validate": "驗證", "validate_endpoint_error": "請輸入有效的連結", diff --git a/i18n/zh_SIMPLIFIED.json b/i18n/zh_SIMPLIFIED.json index 99b86602de..17c6954ab7 100644 --- a/i18n/zh_SIMPLIFIED.json +++ b/i18n/zh_SIMPLIFIED.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "添加{count, number}项到收藏", "admin": { "add_exclusion_pattern_description": "添加排除规则。支持使用 *、** 和 ? 通配符。比如要忽略任何名为 “Raw” 的文件夹中的所有文件,请使用 “**/Raw/**”;要忽略所有以 “.tif” 结尾的文件,请使用 “**/*.tif”;要忽略绝对路径,请使用 “/path/to/ignore/**”。", + "admin_user": "管理员用户", "asset_offline_description": "磁盘上已找不到此外部库项目,已将其移至回收站。如果文件已在库中移动,请检查时间线中是否有对应项目。要恢复此项目,请确保 Immich 可以访问以下文件路径并执行“扫描库”任务。", "authentication_settings": "认证设置", "authentication_settings_description": "管理密码、OAuth 和其它认证设置", @@ -170,7 +171,7 @@ "note_apply_storage_label_previous_assets": "提示:要将存储标签应用于之前上传的项目,需要运行", "note_cannot_be_changed_later": "注意:此项一旦设定,以后无法更改!", "notification_email_from_address": "发件人地址", - "notification_email_from_address_description": "发件人邮箱,例如:“张三<12345@qq.com>”", + "notification_email_from_address_description": "发件人邮箱,例如:“张三<12345@qq.com>”。请确保使用允许您发送电子邮件的地址。", "notification_email_host_description": "服务器地址(例如:smtp.qq.com)", "notification_email_ignore_certificate_errors": "忽略证书错误", "notification_email_ignore_certificate_errors_description": "忽略 TLS 证书验证错误(不建议)", @@ -203,7 +204,7 @@ "oauth_storage_quota_claim": "存储配额声明", "oauth_storage_quota_claim_description": "自动将用户的存储配额设置为此项的值。", "oauth_storage_quota_default": "默认存储配额(GB)", - "oauth_storage_quota_default_description": "未提供声明时使用的配额(GB)(0表示无限制)。", + "oauth_storage_quota_default_description": "未提供声明时使用的配额(GB)。", "oauth_timeout": "请求超时", "oauth_timeout_description": "请求超时(毫秒)", "password_enable_description": "使用邮箱和密码登录", @@ -243,7 +244,7 @@ "storage_template_migration_info": "存储模板会将所有扩展名转换为小写。模板修改只会作用于新的项目,如需应用此模板到之前上传的项目,请运行{job}。", "storage_template_migration_job": "存储模板转换任务", "storage_template_more_details": "关于本功能的更多细节,请参见存储模板及其实现方式", - "storage_template_onboarding_description": "启用后,本功能将根据用户定义的模板自动整理文件。出于稳定性考虑,本功能默认是禁用的。更多详细信息请参见 文档。", + "storage_template_onboarding_description_v2": "启用后,该功能将根据用户定义的模板自动整理文件。有关详细信息,请参阅 文档。", "storage_template_path_length": "路径的字符长度及限制:{length, number}/{limit, number}", "storage_template_settings": "存储模板", "storage_template_settings_description": "管理上传项目文件夹结构和文件名", @@ -403,6 +404,9 @@ "album_with_link_access": "拥有此链接的任何人均可查看本相册中的照片和人物。", "albums": "相册", "albums_count": "{count, plural, one {{count, number} 个相册} other {{count, number} 个相册}}", + "albums_default_sort_order": "默认相册排序方式", + "albums_default_sort_order_description": "创建新相册时的项目初始排序方式。", + "albums_feature_description": "可与其他用户共享的项目收藏。", "all": "全部", "all_albums": "所有相册", "all_people": "全部人物", @@ -415,7 +419,7 @@ "anti_clockwise": "逆时针", "api_key": "API 密钥", "api_key_description": "该应用密钥只会显示一次。请确保在关闭窗口前复制下来。", - "api_key_empty": "API Key 的名称不可以为空", + "api_key_empty": "API 密钥的名称不可以为空", "api_keys": "API 密钥", "app_bar_signout_dialog_content": "您确定要退出吗?", "app_bar_signout_dialog_ok": "是", @@ -461,6 +465,7 @@ "assets_added_count": "已添加{count, plural, one {#个项目} other {#个项目}}", "assets_added_to_album_count": "已添加{count, plural, one {#个项目} other {#个项目}}到相册", "assets_added_to_name_count": "已添加{count, plural, one {#个项目} other {#个项目}}到{hasName, select, true {{name}} other {新相册}}", + "assets_cannot_be_added_to_album_count": "无法添加 {count, plural, one {个项目} other {个项目}} 到相册中", "assets_count": "{count, plural, one {#个项目} other {#个项目}}", "assets_deleted_permanently": "{count} 个项目已被永久删除", "assets_deleted_permanently_from_server": "已永久移除 {count} 个项目", @@ -476,7 +481,7 @@ "assets_trashed": "{count} 个项目放入回收站", "assets_trashed_count": "{count, plural, one {#个项目} other {#个项目}}已放入回收站", "assets_trashed_from_server": "{count} 个项目已放入回收站", - "assets_were_part_of_album_count": "{count, plural, one {项目} other {项目}}已经在相册中", + "assets_were_part_of_album_count": "{count, plural, one {个项目} other {个项目}}已经在相册中", "authorized_devices": "已授权设备", "automatic_endpoint_switching_subtitle": "当连接到指定的 Wi-Fi 时使用本地连接,在其它环境下使用替代连接", "automatic_endpoint_switching_title": "自动切换 URL", @@ -638,13 +643,14 @@ "completed": "已完成", "confirm": "确认", "confirm_admin_password": "确认管理员密码", - "confirm_delete_face": "您确定要从资产中删除 {name} 的脸吗?", + "confirm_delete_face": "您确定要从资产中删除 {name} 的人脸信息吗?", "confirm_delete_shared_link": "确定要删除此共享链接吗?", "confirm_keep_this_delete_others": "除此项目外,堆叠中的所有其它项目都将被删除。确定要继续吗?", "confirm_new_pin_code": "确认新的PIN码", "confirm_password": "确认密码", "confirm_tag_face": "您想将这张脸标记为{name}吗?", "confirm_tag_face_unnamed": "你想标记这张脸吗?", + "connected_device": "已连接设备", "connected_to": "已连接到", "contain": "包含", "context": "以文搜图", @@ -694,11 +700,14 @@ "current_server_address": "当前服务器地址", "custom_locale": "自定义地区", "custom_locale_description": "日期和数字显示格式跟随语言和地区", + "daily_title_text_date": "MMM dd (E)", + "daily_title_text_date_year": "YYYY年M月D日 (E)", "dark": "深色", "darkTheme": "暗色主题", "date_after": "开始日期", "date_and_time": "日期与时间", "date_before": "结束日期", + "date_format": "y年M月d日 (E) h:mm a", "date_of_birth_saved": "出生日期保存成功", "date_range": "日期范围", "day": "日", @@ -711,7 +720,7 @@ "default_locale_description": "根据您的浏览器地区设置日期和数字显示格式", "delete": "删除", "delete_album": "删除相册", - "delete_api_key_prompt": "确定删除此 API key 吗?", + "delete_api_key_prompt": "确定删除此 API 密钥吗?", "delete_dialog_alert": "这些项目将从 Immich 和您的设备中永久删除", "delete_dialog_alert_local": "这些项目将从您的移动设备中永久删除,但仍然可以从 Immich 服务器中再次获取", "delete_dialog_alert_local_non_backed_up": "部分项目还未备份至 Immich 服务器,将从您的移动设备中永久删除", @@ -742,6 +751,7 @@ "disallow_edits": "不允许编辑", "discord": "Discord 社区", "discover": "发现", + "discovered_devices": "已发现的设备", "dismiss_all_errors": "忽略所有错误", "dismiss_error": "忽略错误", "display_options": "显示选项", @@ -786,7 +796,7 @@ "edit_faces": "编辑人脸", "edit_import_path": "编辑导入路径", "edit_import_paths": "编辑导入路径", - "edit_key": "编辑 API Key", + "edit_key": "编辑 API 密钥", "edit_link": "编辑链接", "edit_location": "编辑位置", "edit_location_dialog_title": "位置", @@ -878,7 +888,7 @@ "unable_to_connect": "无法连接", "unable_to_copy_to_clipboard": "无法复制到剪切板,请确保您在使用https访问本页", "unable_to_create_admin_account": "无法创建管理员账户", - "unable_to_create_api_key": "无法创建新的 API Key", + "unable_to_create_api_key": "无法创建新的 API 密钥", "unable_to_create_library": "无法创建图库", "unable_to_create_user": "无法创建用户", "unable_to_delete_album": "无法删除相册", @@ -903,11 +913,11 @@ "unable_to_log_out_device": "无法从设备登出", "unable_to_login_with_oauth": "无法使用 OAuth 进行登录", "unable_to_play_video": "无法播放视频", - "unable_to_reassign_assets_existing_person": "无法将项目指派给{name, select, null {已存在的人物} other {{name}}}", - "unable_to_reassign_assets_new_person": "无法重新指派项目给新的人物", + "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 Key", + "unable_to_remove_api_key": "无法移除 API 密钥", "unable_to_remove_assets_from_shared_link": "无法从共享链接中移除项目", "unable_to_remove_library": "无法移除图库", "unable_to_remove_partner": "无法移除同伴", @@ -919,7 +929,7 @@ "unable_to_restore_trash": "无法恢复回收站", "unable_to_restore_user": "无法恢复用户", "unable_to_save_album": "无法保存相册", - "unable_to_save_api_key": "无法保存 API Key", + "unable_to_save_api_key": "无法保存 API 密钥", "unable_to_save_date_of_birth": "无法保存出生日期", "unable_to_save_name": "无法保存名称", "unable_to_save_profile": "无法保存配置文件", @@ -1126,6 +1136,7 @@ "list": "列表", "loading": "加载中", "loading_search_results_failed": "加载搜索结果失败", + "local_asset_cast_failed": "无法投放未上传至服务器的项目", "local_network": "本地网络", "local_network_sheet_info": "当使用指定的 Wi-Fi 网络时,应用程序将通过此 URL 访问服务器", "location_permission": "定位权限", @@ -1139,6 +1150,7 @@ "locked_folder": "锁定文件夹", "log_out": "注销", "log_out_all_devices": "注销所有设备", + "logged_in_as": "以 {user} 身份登录", "logged_out_all_devices": "从所有设备注销", "logged_out_device": "从设备注销", "login": "登录", @@ -1230,6 +1242,7 @@ "missing": "缺失", "model": "型号", "month": "月", + "monthly_title_text_date_format": "y MMMM", "more": "更多", "move": "移动", "move_off_locked_folder": "移出锁定文件夹", @@ -1248,7 +1261,7 @@ "networking_subtitle": "管理服务器接口设置", "never": "永不过期", "new_album": "新相册", - "new_api_key": "新增 API Key", + "new_api_key": "新增 API 密钥", "new_password": "新密码", "new_person": "新人物", "new_pin_code": "新的PIN码", @@ -1265,6 +1278,7 @@ "no_archived_assets_message": "归档照片和视频以便在照片视图中隐藏它们", "no_assets_message": "点击上传您的第一张照片", "no_assets_to_show": "无项目展示", + "no_cast_devices_found": "未找到投放设备", "no_duplicates_found": "未发现重复项。", "no_exif_info_available": "没有可用的 EXIF 信息", "no_explore_results_message": "上传更多照片来探索。", @@ -1487,9 +1501,10 @@ "remove_from_shared_link": "从共享链接中移除", "remove_memory": "移出回忆区", "remove_photo_from_memory": "从当前回忆区移除照片", + "remove_tag": "移除标签", "remove_url": "移除 URL", "remove_user": "移除用户", - "removed_api_key": "已移除 API Key:{name}", + "removed_api_key": "已移除 API 密钥:{name}", "removed_from_archive": "从归档中移除", "removed_from_favorites": "从收藏中移除", "removed_from_favorites_count": "从收藏中移除{count, plural, other {#项}}", @@ -1523,7 +1538,7 @@ "role_viewer": "仅查看", "save": "保存", "save_to_gallery": "保存到图库", - "saved_api_key": "已保存的 API Key", + "saved_api_key": "已保存的 API 密钥", "saved_profile": "已保存资料", "saved_settings": "已保存设置", "say_something": "说点什么", @@ -1593,6 +1608,7 @@ "select_album_cover": "选择相册封面", "select_all": "全选", "select_all_duplicates": "选择所有重复项", + "select_all_in": "选择 {group} 中的所有内容", "select_avatar_color": "选择头像颜色", "select_face": "选择人脸", "select_featured_photo": "选择个性头像", @@ -1623,6 +1639,7 @@ "set_date_of_birth": "设置出生日期", "set_profile_picture": "设置个人资料图片", "set_slideshow_to_fullscreen": "全屏放映幻灯片", + "set_stack_primary_asset": "设为主要项目", "setting_image_viewer_help": "详细信息查看器首先加载小缩略图,然后加载中等大小的预览图(若启用),最后加载原始图像。", "setting_image_viewer_original_subtitle": "启用以加载原图,禁用以减少数据使用量(网络和设备缓存)。", "setting_image_viewer_original_title": "加载原图", @@ -1760,6 +1777,7 @@ "start_date": "开始日期", "state": "省份", "status": "状态", + "stop_casting": "停止投放", "stop_motion_photo": "定格照片", "stop_photo_sharing": "停止共享照片?", "stop_photo_sharing_description": "“{partner}”将不能访问您的照片。", @@ -1855,6 +1873,7 @@ "unsaved_change": "修改未保存", "unselect_all": "取消全选", "unselect_all_duplicates": "取消选择所有重复项", + "unselect_all_in": "取消选择 {group} 中的所有内容", "unstack": "取消堆叠", "unstacked_assets_count": "{count, plural, one {#个项目} other {#个项目}}已取消堆叠", "up_next": "下一个", From 4c69511225275ee794047e46f8d53de4fae9cf09 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Tue, 17 Jun 2025 17:01:40 +0100 Subject: [PATCH 09/71] revert: "feat(web): wasm justified layout" (#19226) --- web/eslint.config.js | 1 - web/package-lock.json | 7 - web/package.json | 1 - .../gallery-viewer/gallery-viewer.svelte | 123 +++++++++++++----- .../timeline-manager/day-group.svelte.ts | 5 +- .../timeline-manager.svelte.spec.ts | 14 +- .../timeline-manager.svelte.ts | 6 +- .../timeline-manager/viewer-asset.svelte.ts | 2 +- web/src/lib/utils/layout-utils.ts | 76 +++++------ web/src/lib/utils/server.ts | 6 +- web/src/lib/utils/timeline-util.ts | 6 +- web/src/lib/utils/tunables.ts | 2 +- web/src/test-data/factories/asset-factory.ts | 2 +- 13 files changed, 145 insertions(+), 106 deletions(-) diff --git a/web/eslint.config.js b/web/eslint.config.js index 78b87d24ef..6b7b343ad1 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -118,7 +118,6 @@ export default typescriptEslint.config( 'unicorn/filename-case': 'off', 'unicorn/prefer-top-level-await': 'off', 'unicorn/import-style': 'off', - 'unicorn/no-for-loop': 'off', 'svelte/button-has-type': 'error', '@typescript-eslint/await-thenable': 'error', '@typescript-eslint/no-floating-promises': 'error', diff --git a/web/package-lock.json b/web/package-lock.json index 2888dfb71b..72b9eb6a7c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,7 +10,6 @@ "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", - "@immich/justified-layout-wasm": "^0.3.0", "@immich/sdk": "file:../open-api/typescript-sdk", "@immich/ui": "^0.22.7", "@mapbox/mapbox-gl-rtl-text": "0.2.3", @@ -1329,12 +1328,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@immich/justified-layout-wasm": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@immich/justified-layout-wasm/-/justified-layout-wasm-0.3.0.tgz", - "integrity": "sha512-eiKaPHHVsm0YL8SZVUuEs8miTT2uF3b9Tggve7QvIh+KF1Vq41EZFUeDI0RzLvoXAUnoAh37iFvYxaA9iXMHZA==", - "license": "AGPL-3" - }, "node_modules/@immich/sdk": { "resolved": "../open-api/typescript-sdk", "link": true diff --git a/web/package.json b/web/package.json index 2665904c62..a06b12f826 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,6 @@ }, "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", - "@immich/justified-layout-wasm": "^0.3.0", "@immich/sdk": "file:../open-api/typescript-sdk", "@immich/ui": "^0.22.7", "@mapbox/mapbox-gl-rtl-text": "0.2.3", 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 eb1869cfbb..09998ed060 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 @@ -16,7 +16,7 @@ import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils'; import { moveFocus } from '$lib/utils/focus-util'; import { handleError } from '$lib/utils/handle-error'; - import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; + import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils'; import { navigate } from '$lib/utils/navigation'; import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetVisibility, type AssetResponseDto } from '@immich/sdk'; @@ -27,7 +27,7 @@ import Portal from '../portal/portal.svelte'; interface Props { - assets: TimelineAsset[] | AssetResponseDto[]; + assets: (TimelineAsset | AssetResponseDto)[]; assetInteraction: AssetInteraction; disableAssetSelect?: boolean; showArchiveIcon?: boolean; @@ -62,39 +62,91 @@ let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore; - const geometry = $derived( - getJustifiedLayoutFromAssets(assets, { + let geometry: CommonJustifiedLayout | undefined = $state(); + + $effect(() => { + const _assets = assets; + updateSlidingWindow(); + + const rowWidth = Math.floor(viewport.width); + const rowHeight = rowWidth < 850 ? 100 : 235; + + geometry = getJustifiedLayoutFromAssets(_assets, { spacing: 2, - heightTolerance: 0.3, - rowHeight: Math.floor(viewport.width) < 850 ? 100 : 235, - rowWidth: Math.floor(viewport.width), - }), - ); + heightTolerance: 0.15, + rowHeight, + rowWidth, + }); + }); + + let assetLayouts = $derived.by(() => { + const assetLayout = []; + let containerHeight = 0; + let containerWidth = 0; + if (geometry) { + containerHeight = geometry.containerHeight; + containerWidth = geometry.containerWidth; + for (const [index, asset] of assets.entries()) { + const top = geometry.getTop(index); + const left = geometry.getLeft(index); + const width = geometry.getWidth(index); + const height = geometry.getHeight(index); + + const layoutTopWithOffset = top + pageHeaderOffset; + const layoutBottom = layoutTopWithOffset + height; + + const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top; + + const layout = { + asset, + top, + left, + width, + height, + display, + }; + + assetLayout.push(layout); + } + } + + return { + assetLayout, + containerHeight, + containerWidth, + }; + }); let currentViewAssetIndex = 0; let shiftKeyIsDown = $state(false); let lastAssetMouseEvent: TimelineAsset | null = $state(null); - let slidingTop = $state(0); - let slidingBottom = $state(0); + let slidingWindow = $state({ top: 0, bottom: 0 }); const updateSlidingWindow = () => { const v = $state.snapshot(viewport); const top = (document.scrollingElement?.scrollTop || 0) - slidingWindowOffset; - slidingTop = top; - slidingBottom = top + v.height; + const bottom = top + v.height; + const w = { + top, + bottom, + }; + slidingWindow = w; }; - $effect(updateSlidingWindow); const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true }); let lastIntersectedHeight = 0; $effect(() => { // notify we got to (near) the end of scroll const scrollPercentage = - (slidingBottom - viewport.height) / (viewport.height - (document.scrollingElement?.clientHeight || 0)); + ((slidingWindow.bottom - viewport.height) / (viewport.height - (document.scrollingElement?.clientHeight || 0))) * + 100; - if (scrollPercentage > 0.9 && lastIntersectedHeight !== geometry.containerHeight) { - debouncedOnIntersected(); - lastIntersectedHeight = geometry.containerHeight; + if (scrollPercentage > 90) { + const intersectedHeight = geometry?.containerHeight || 0; + if (lastIntersectedHeight !== intersectedHeight) { + debouncedOnIntersected(); + lastIntersectedHeight = intersectedHeight; + } } }); const viewAssetHandler = async (asset: TimelineAsset) => { @@ -204,7 +256,7 @@ isShowDeleteConfirmation = false; await deleteAssets( !(isTrashEnabled && !force), - (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id)) as TimelineAsset[]), + (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))), assetInteraction.selectedAssets, onReload, ); @@ -217,7 +269,7 @@ assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive, ); if (ids) { - assets = assets.filter((asset) => !ids.includes(asset.id)) as TimelineAsset[]; + assets = assets.filter((asset) => !ids.includes(asset.id)); deselectAllAssets(); } }; @@ -402,7 +454,7 @@ onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} - onscroll={updateSlidingWindow} + onscroll={() => updateSlidingWindow()} /> {#if isShowDeleteConfirmation} @@ -416,12 +468,13 @@ {#if assets.length > 0}
- {#each assets as asset, i (asset.id + i)} - {#if geometry.getTop(i) + pageHeaderOffset < slidingBottom && geometry.getTop(i) + pageHeaderOffset + geometry.getHeight(i) > slidingTop} - {@const layout = geometry.getPosition(i)} + {#each assetLayouts.assetLayout as layout, layoutIndex (layout.asset.id + '-' + layoutIndex)} + {@const currentAsset = layout.asset} + + {#if layout.display}
{ if (assetInteraction.selectionActive) { - handleSelectAssets(toTimelineAsset(asset)); + handleSelectAssets(toTimelineAsset(currentAsset)); return; } - void viewAssetHandler(toTimelineAsset(asset)); + void viewAssetHandler(toTimelineAsset(currentAsset)); }} - onSelect={() => handleSelectAssets(toTimelineAsset(asset))} - onMouseEvent={() => assetMouseEventHandler(toTimelineAsset(asset))} + onSelect={() => handleSelectAssets(toTimelineAsset(currentAsset))} + onMouseEvent={() => assetMouseEventHandler(toTimelineAsset(currentAsset))} {showArchiveIcon} - asset={toTimelineAsset(asset)} - selected={assetInteraction.hasSelectedAsset(asset.id)} - selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} + asset={toTimelineAsset(currentAsset)} + selected={assetInteraction.hasSelectedAsset(currentAsset.id)} + selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)} thumbnailWidth={layout.width} thumbnailHeight={layout.height} /> - {#if showAssetName && !isTimelineAsset(asset)} + {#if showAssetName && !isTimelineAsset(currentAsset)}
- {asset.originalFileName} + {currentAsset.originalFileName}
{/if}
diff --git a/web/src/lib/managers/timeline-manager/day-group.svelte.ts b/web/src/lib/managers/timeline-manager/day-group.svelte.ts index a43a71c511..2a949499ec 100644 --- a/web/src/lib/managers/timeline-manager/day-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/day-group.svelte.ts @@ -1,7 +1,7 @@ import { AssetOrder } from '@immich/sdk'; import type { CommonLayoutOptions } from '$lib/utils/layout-utils'; -import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; +import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils'; import { plainDateTimeCompare } from '$lib/utils/timeline-util'; import type { MonthGroup } from './month-group.svelte'; @@ -153,7 +153,8 @@ export class DayGroup { this.width = geometry.containerWidth; this.height = assets.length === 0 ? 0 : geometry.containerHeight; for (let i = 0; i < this.viewerAssets.length; i++) { - this.viewerAssets[i].position = geometry.getPosition(i); + const position = getPosition(geometry, i); + this.viewerAssets[i].position = position; } } diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 9aa06d940d..05f8b7c7f7 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -2,10 +2,8 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte'; import { AbortError } from '$lib/utils'; import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util'; -import { initSync } from '@immich/justified-layout-wasm'; import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; -import { readFile } from 'node:fs/promises'; import { TimelineManager } from './timeline-manager.svelte'; import type { TimelineAsset } from './types'; @@ -25,12 +23,6 @@ function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset } describe('TimelineManager', () => { - beforeAll(async () => { - // needed for Node.js - const file = await readFile('node_modules/@immich/justified-layout-wasm/pkg/justified-layout-wasm_bg.wasm'); - initSync({ module: file }); - }); - beforeEach(() => { vi.resetAllMocks(); }); @@ -88,15 +80,15 @@ describe('TimelineManager', () => { expect(plainMonths).toEqual( expect.arrayContaining([ - expect.objectContaining({ year: 2024, month: 3, height: 353.5 }), - expect.objectContaining({ year: 2024, month: 2, height: 7786.452_636_718_75 }), + expect.objectContaining({ year: 2024, month: 3, height: 185.5 }), + expect.objectContaining({ year: 2024, month: 2, height: 12_016 }), expect.objectContaining({ year: 2024, month: 1, height: 286 }), ]), ); }); it('calculates timeline height', () => { - expect(timelineManager.timelineHeight).toBe(8425.952_636_718_75); + expect(timelineManager.timelineHeight).toBe(12_487.5); }); }); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index e17082c816..8aacd0a90a 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -377,11 +377,13 @@ export class TimelineManager { } createLayoutOptions() { + const viewportWidth = this.viewportWidth; + return { spacing: 2, - heightTolerance: 0.3, + heightTolerance: 0.15, rowHeight: this.#rowHeight, - rowWidth: Math.floor(this.viewportWidth), + rowWidth: Math.floor(viewportWidth), }; } diff --git a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts index 161cc049f1..b6e28df576 100644 --- a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts +++ b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts @@ -18,7 +18,7 @@ export class ViewerAsset { return calculateViewerAssetIntersecting(store, positionTop, this.position.height); }); - position: CommonPosition | undefined = $state.raw(); + position: CommonPosition | undefined = $state(); asset: TimelineAsset = $state(); id: string = $derived(this.asset.id); diff --git a/web/src/lib/utils/layout-utils.ts b/web/src/lib/utils/layout-utils.ts index 090a7168d5..e60fa3b9e1 100644 --- a/web/src/lib/utils/layout-utils.ts +++ b/web/src/lib/utils/layout-utils.ts @@ -1,13 +1,16 @@ -import { TUNABLES } from '$lib/utils/tunables'; +// import { TUNABLES } from '$lib/utils/tunables'; +// note: it's important that this is not imported in more than one file due to https://github.com/sveltejs/kit/issues/7805 +// import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { getAssetRatio } from '$lib/utils/asset-utils'; -import { isTimelineAsset, isTimelineAssets } from '$lib/utils/timeline-util'; -import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm'; +import { isTimelineAsset } from '$lib/utils/timeline-util'; import type { AssetResponseDto } from '@immich/sdk'; import createJustifiedLayout from 'justified-layout'; -const useWasm = TUNABLES.LAYOUT.WASM; +export type getJustifiedLayoutFromAssetsFunction = typeof getJustifiedLayoutFromAssets; + +// let useWasm = TUNABLES.LAYOUT.WASM; export type CommonJustifiedLayout = { containerWidth: number; @@ -16,12 +19,6 @@ export type CommonJustifiedLayout = { getLeft(boxIdx: number): number; getWidth(boxIdx: number): number; getHeight(boxIdx: number): number; - getPosition(boxIdx: number): { - top: number; - left: number; - width: number; - height: number; - }; }; export type CommonLayoutOptions = { @@ -32,32 +29,25 @@ export type CommonLayoutOptions = { }; export function getJustifiedLayoutFromAssets( - assets: AssetResponseDto[] | TimelineAsset[], + assets: (TimelineAsset | AssetResponseDto)[], options: CommonLayoutOptions, -) { - if (useWasm) { - return isTimelineAssets(assets) ? wasmLayoutFromTimeline(assets, options) : wasmLayoutFromDto(assets, options); - } - +): CommonJustifiedLayout { + // if (useWasm) { + // return wasmJustifiedLayout(assets, options); + // } return justifiedLayout(assets, options); } -function wasmLayoutFromTimeline(assets: TimelineAsset[], options: LayoutOptions) { - const aspectRatios = new Float32Array(assets.length); - for (let i = 0; i < assets.length; i++) { - aspectRatios[i] = assets[i].ratio; - } - return new JustifiedLayout(aspectRatios, options); -} - -function wasmLayoutFromDto(assets: AssetResponseDto[], options: LayoutOptions) { - const aspectRatios = new Float32Array(assets.length); - for (let i = 0; i < assets.length; i++) { - const { width, height } = getAssetRatio(assets[i]); - aspectRatios[i] = width / height; - } - return new JustifiedLayout(aspectRatios, options); -} +// commented out until a solution for top level awaits on safari is fixed +// function wasmJustifiedLayout(assets: AssetResponseDto[], options: LayoutOptions) { +// const aspectRatios = new Float32Array(assets.length); +// // eslint-disable-next-line unicorn/no-for-loop +// for (let i = 0; i < assets.length; i++) { +// const { width, height } = getAssetRatio(assets[i]); +// aspectRatios[i] = width / height; +// } +// return new JustifiedLayout(aspectRatios, options); +// } type Geometry = ReturnType; class Adapter { @@ -98,13 +88,9 @@ class Adapter { getHeight(boxIdx: number) { return this.result.boxes[boxIdx]?.height; } - - getPosition(boxIdx: number): CommonPosition { - return this.result.boxes[boxIdx]; - } } -export function justifiedLayout(assets: TimelineAsset[] | AssetResponseDto[], options: CommonLayoutOptions) { +export function justifiedLayout(assets: (TimelineAsset | AssetResponseDto)[], options: CommonLayoutOptions) { const adapter = { targetRowHeight: options.rowHeight, containerWidth: options.rowWidth, @@ -119,9 +105,25 @@ export function justifiedLayout(assets: TimelineAsset[] | AssetResponseDto[], op return new Adapter(result); } +export const emptyGeometry = () => + new Adapter({ + containerHeight: 0, + widowCount: 0, + boxes: [], + }); + export type CommonPosition = { top: number; left: number; width: number; height: number; }; + +export function getPosition(geometry: CommonJustifiedLayout, boxIdx: number): CommonPosition { + const top = geometry.getTop(boxIdx); + const left = geometry.getLeft(boxIdx); + const width = geometry.getWidth(boxIdx); + const height = geometry.getHeight(boxIdx); + + return { top, left, width, height }; +} diff --git a/web/src/lib/utils/server.ts b/web/src/lib/utils/server.ts index b99ab80ec5..1c52274d23 100644 --- a/web/src/lib/utils/server.ts +++ b/web/src/lib/utils/server.ts @@ -1,17 +1,17 @@ import { retrieveServerConfig } from '$lib/stores/server-config.store'; import { initLanguage } from '$lib/utils'; -import { init as initLayout } from '@immich/justified-layout-wasm'; import { defaults } from '@immich/sdk'; import { memoize } from 'lodash-es'; type Fetch = typeof fetch; -function _init(fetch: Fetch) { +async function _init(fetch: 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; - return Promise.all([initLayout(), initLanguage(), retrieveServerConfig()]); + await initLanguage(); + await retrieveServerConfig(); } export const init = memoize(_init, () => 'singlevalue'); diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index ca9dece6b2..c3e41c01be 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -190,10 +190,8 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): }; }; -export const isTimelineAsset = (asset: AssetResponseDto | TimelineAsset): asset is TimelineAsset => 'ratio' in asset; - -export const isTimelineAssets = (assets: AssetResponseDto[] | TimelineAsset[]): assets is TimelineAsset[] => - assets.length === 0 || 'ratio' in assets[0]; +export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset => + (unknownAsset as TimelineAsset).ratio !== undefined; export const plainDateTimeCompare = (ascending: boolean, a: TimelinePlainDateTime, b: TimelinePlainDateTime) => { const [aDateTime, bDateTime] = ascending ? [a, b] : [b, a]; diff --git a/web/src/lib/utils/tunables.ts b/web/src/lib/utils/tunables.ts index c586e11957..6ce64ed041 100644 --- a/web/src/lib/utils/tunables.ts +++ b/web/src/lib/utils/tunables.ts @@ -19,7 +19,7 @@ const storage = browser }; export const TUNABLES = { LAYOUT: { - WASM: getBoolean(storage.getItem('LAYOUT.WASM'), true), + WASM: getBoolean(storage.getItem('LAYOUT.WASM'), false), }, TIMELINE: { INTERSECTION_EXPAND_TOP: getNumber(storage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500), diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 273bb6f97b..c2f03f9c6a 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -31,7 +31,7 @@ export const assetFactory = Sync.makeFactory({ export const timelineAssetFactory = Sync.makeFactory({ id: Sync.each(() => faker.string.uuid()), - ratio: Sync.each((i) => 0.2 + ((i * 0.618_034) % 3.8)), // deterministic random float between 0.2 and 4.0 + ratio: Sync.each(() => faker.number.int()), ownerId: Sync.each(() => faker.string.uuid()), thumbhash: Sync.each(() => faker.string.alphanumeric(28)), localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())), From 35280b94cc3fc13a36a3b954ed548a655409f108 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 17 Jun 2025 12:06:40 -0400 Subject: [PATCH 10/71] refactor: sync service (#19225) --- server/src/services/sync.service.ts | 454 ++++++++++++++-------------- server/src/utils/sync.ts | 15 +- 2 files changed, 235 insertions(+), 234 deletions(-) diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 4705fc8e1f..46937a570f 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -12,6 +12,8 @@ import { AssetFullSyncDto, SyncAckDeleteDto, SyncAckSetDto, + SyncAssetV1, + SyncItem, SyncStreamDto, } from 'src/dtos/sync.dto'; import { AssetVisibility, DatabaseAction, EntityType, Permission, SyncEntityType, SyncRequestType } from 'src/enum'; @@ -20,7 +22,35 @@ import { SyncAck } from 'src/types'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { setIsEqual } from 'src/utils/set'; -import { fromAck, serialize, toAck } from 'src/utils/sync'; +import { fromAck, serialize, SerializeOptions, toAck } from 'src/utils/sync'; + +type CheckpointMap = Partial>; +type AssetLike = Omit & { + checksum: Buffer; + thumbhash: Buffer | null; +}; + +const COMPLETE_ID = 'complete'; + +const mapSyncAssetV1 = ({ checksum, thumbhash, ...data }: AssetLike): SyncAssetV1 => ({ + ...data, + checksum: hexOrBufferToBase64(checksum), + thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null, +}); + +const isEntityBackfillComplete = (entity: { createId: string }, checkpoint: SyncAck | undefined): boolean => + entity.createId === checkpoint?.updateId && checkpoint.extraId === COMPLETE_ID; + +const getStartId = (entity: { createId: string }, checkpoint: SyncAck | undefined): string | undefined => + checkpoint?.updateId === entity.createId ? checkpoint?.extraId : undefined; + +const send = (response: Writable, item: SerializeOptions) => { + response.write(serialize(item)); +}; + +const sendEntityBackfillCompleteAck = (response: Writable, ackType: SyncEntityType, id: string) => { + send(response, { type: SyncEntityType.SyncAckV1, data: {}, ackType, ids: [id, COMPLETE_ID] }); +}; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; export const SYNC_TYPES_ORDER = [ @@ -89,266 +119,48 @@ export class SyncService extends BaseService { } const checkpoints = await this.syncRepository.getCheckpoints(sessionId); - const checkpointMap: Partial> = Object.fromEntries( - checkpoints.map(({ type, ack }) => [type, fromAck(ack)]), - ); + const checkpointMap: CheckpointMap = Object.fromEntries(checkpoints.map(({ type, ack }) => [type, fromAck(ack)])); for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) { switch (type) { case SyncRequestType.UsersV1: { - const deletes = this.syncRepository.getUserDeletes(checkpointMap[SyncEntityType.UserDeleteV1]); - for await (const { id, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.UserDeleteV1, ids: [id], data })); - } - - const upserts = this.syncRepository.getUserUpserts(checkpointMap[SyncEntityType.UserV1]); - for await (const { updateId, ...data } of upserts) { - response.write(serialize({ type: SyncEntityType.UserV1, ids: [updateId], data })); - } - + await this.syncUsersV1(response, checkpointMap); break; } case SyncRequestType.PartnersV1: { - const deletes = this.syncRepository.getPartnerDeletes( - auth.user.id, - checkpointMap[SyncEntityType.PartnerDeleteV1], - ); - for await (const { id, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.PartnerDeleteV1, ids: [id], data })); - } - - const upserts = this.syncRepository.getPartnerUpserts(auth.user.id, checkpointMap[SyncEntityType.PartnerV1]); - for await (const { updateId, ...data } of upserts) { - response.write(serialize({ type: SyncEntityType.PartnerV1, ids: [updateId], data })); - } - + await this.syncPartnersV1(response, checkpointMap, auth); break; } case SyncRequestType.AssetsV1: { - const deletes = this.syncRepository.getAssetDeletes( - auth.user.id, - checkpointMap[SyncEntityType.AssetDeleteV1], - ); - for await (const { id, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.AssetDeleteV1, ids: [id], data })); - } - - const upserts = this.syncRepository.getAssetUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetV1]); - for await (const { updateId, checksum, thumbhash, ...data } of upserts) { - response.write( - serialize({ - type: SyncEntityType.AssetV1, - ids: [updateId], - data: { - ...data, - checksum: hexOrBufferToBase64(checksum), - thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null, - }, - }), - ); - } - + await this.syncAssetsV1(response, checkpointMap, auth); break; } case SyncRequestType.PartnerAssetsV1: { - const deletes = this.syncRepository.getPartnerAssetDeletes( - auth.user.id, - checkpointMap[SyncEntityType.PartnerAssetDeleteV1], - ); - for await (const { id, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.PartnerAssetDeleteV1, ids: [id], data })); - } - - const checkpoint = checkpointMap[SyncEntityType.PartnerAssetBackfillV1]; - const partnerAssetCheckpoint = checkpointMap[SyncEntityType.PartnerAssetV1]; - - const partners = await this.syncRepository.getPartnerBackfill(auth.user.id, checkpoint?.updateId); - - if (partnerAssetCheckpoint) { - for (const partner of partners) { - if (partner.createId === checkpoint?.updateId && checkpoint.extraId === 'complete') { - continue; - } - const partnerCheckpoint = checkpoint?.updateId === partner.createId ? checkpoint?.extraId : undefined; - const backfill = this.syncRepository.getPartnerAssetsBackfill( - partner.sharedById, - partnerCheckpoint, - partnerAssetCheckpoint.updateId, - ); - - for await (const { updateId, checksum, thumbhash, ...data } of backfill) { - response.write( - serialize({ - type: SyncEntityType.PartnerAssetBackfillV1, - ids: [updateId], - data: { - ...data, - checksum: hexOrBufferToBase64(checksum), - thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null, - }, - }), - ); - } - response.write( - serialize({ - type: SyncEntityType.SyncAckV1, - data: {}, - ackType: SyncEntityType.PartnerAssetBackfillV1, - ids: [partner.sharedById, 'complete'], - }), - ); - } - } else if (partners.length > 0) { - await this.syncRepository.upsertCheckpoints([ - { - type: SyncEntityType.PartnerAssetBackfillV1, - sessionId, - ack: toAck({ - type: SyncEntityType.PartnerAssetBackfillV1, - updateId: partners.at(-1)!.createId, - extraId: 'complete', - }), - }, - ]); - } - - const upserts = this.syncRepository.getPartnerAssetsUpserts( - auth.user.id, - checkpointMap[SyncEntityType.PartnerAssetV1], - ); - for await (const { updateId, checksum, thumbhash, ...data } of upserts) { - response.write( - serialize({ - type: SyncEntityType.PartnerAssetV1, - ids: [updateId], - data: { - ...data, - checksum: hexOrBufferToBase64(checksum), - thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null, - }, - }), - ); - } + await this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId); break; } case SyncRequestType.AssetExifsV1: { - const upserts = this.syncRepository.getAssetExifsUpserts( - auth.user.id, - checkpointMap[SyncEntityType.AssetExifV1], - ); - for await (const { updateId, ...data } of upserts) { - response.write(serialize({ type: SyncEntityType.AssetExifV1, ids: [updateId], data })); - } - + await this.syncAssetExifsV1(response, checkpointMap, auth); break; } case SyncRequestType.PartnerAssetExifsV1: { - const checkpoint = checkpointMap[SyncEntityType.PartnerAssetExifBackfillV1]; - const partnerAssetCheckpoint = checkpointMap[SyncEntityType.PartnerAssetExifV1]; - - const partners = await this.syncRepository.getPartnerBackfill(auth.user.id, checkpoint?.updateId); - - if (partnerAssetCheckpoint) { - for (const partner of partners) { - if (partner.createId === checkpoint?.updateId && checkpoint.extraId === 'complete') { - continue; - } - const partnerCheckpoint = checkpoint?.updateId === partner.createId ? checkpoint?.extraId : undefined; - const backfill = this.syncRepository.getPartnerAssetExifsBackfill( - partner.sharedById, - partnerCheckpoint, - partnerAssetCheckpoint.updateId, - ); - - for await (const { updateId, ...data } of backfill) { - response.write( - serialize({ - type: SyncEntityType.PartnerAssetExifBackfillV1, - ids: [updateId], - data, - }), - ); - } - response.write( - serialize({ - type: SyncEntityType.SyncAckV1, - data: {}, - ackType: SyncEntityType.PartnerAssetExifBackfillV1, - ids: [partner.sharedById, 'complete'], - }), - ); - } - } else if (partners.length > 0) { - await this.syncRepository.upsertCheckpoints([ - { - type: SyncEntityType.PartnerAssetExifBackfillV1, - sessionId, - ack: toAck({ - type: SyncEntityType.PartnerAssetExifBackfillV1, - updateId: partners.at(-1)!.createId, - extraId: 'complete', - }), - }, - ]); - } - - const upserts = this.syncRepository.getPartnerAssetExifsUpserts( - auth.user.id, - checkpointMap[SyncEntityType.PartnerAssetExifV1], - ); - for await (const { updateId, ...data } of upserts) { - response.write( - serialize({ - type: SyncEntityType.PartnerAssetExifV1, - ids: [updateId], - data, - }), - ); - } - + await this.syncPartnerAssetExifsV1(response, checkpointMap, auth, sessionId); break; } case SyncRequestType.AlbumsV1: { - const deletes = this.syncRepository.getAlbumDeletes( - auth.user.id, - checkpointMap[SyncEntityType.AlbumDeleteV1], - ); - for await (const { id, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.AlbumDeleteV1, ids: [id], data })); - } - - const upserts = this.syncRepository.getAlbumUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumV1]); - for await (const { updateId, ...data } of upserts) { - response.write(serialize({ type: SyncEntityType.AlbumV1, ids: [updateId], data })); - } - + await this.syncAlbumsV1(response, checkpointMap, auth); break; } case SyncRequestType.AlbumUsersV1: { - const deletes = this.syncRepository.getAlbumUserDeletes( - auth.user.id, - checkpointMap[SyncEntityType.AlbumUserDeleteV1], - ); - for await (const { id, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.AlbumUserDeleteV1, ids: [id], data })); - } - - const upserts = this.syncRepository.getAlbumUserUpserts( - auth.user.id, - checkpointMap[SyncEntityType.AlbumUserV1], - ); - for await (const { updateId, ...data } of upserts) { - response.write(serialize({ type: SyncEntityType.AlbumUserV1, ids: [updateId], data })); - } - + await this.syncAlbumUsersV1(response, checkpointMap, auth); break; } @@ -362,6 +174,192 @@ export class SyncService extends BaseService { response.end(); } + private async syncUsersV1(response: Writable, checkpointMap: CheckpointMap) { + const deletes = this.syncRepository.getUserDeletes(checkpointMap[SyncEntityType.UserDeleteV1]); + for await (const { id, ...data } of deletes) { + send(response, { type: SyncEntityType.UserDeleteV1, ids: [id], data }); + } + + const upserts = this.syncRepository.getUserUpserts(checkpointMap[SyncEntityType.UserV1]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: SyncEntityType.UserV1, ids: [updateId], data }); + } + } + + private async syncPartnersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const deletes = this.syncRepository.getPartnerDeletes(auth.user.id, checkpointMap[SyncEntityType.PartnerDeleteV1]); + for await (const { id, ...data } of deletes) { + send(response, { type: SyncEntityType.PartnerDeleteV1, ids: [id], data }); + } + + const upserts = this.syncRepository.getPartnerUpserts(auth.user.id, checkpointMap[SyncEntityType.PartnerV1]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: SyncEntityType.PartnerV1, ids: [updateId], data }); + } + } + + private async syncAssetsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const deletes = this.syncRepository.getAssetDeletes(auth.user.id, checkpointMap[SyncEntityType.AssetDeleteV1]); + for await (const { id, ...data } of deletes) { + send(response, { type: SyncEntityType.AssetDeleteV1, ids: [id], data }); + } + + const upserts = this.syncRepository.getAssetUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetV1]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: SyncEntityType.AssetV1, ids: [updateId], data: mapSyncAssetV1(data) }); + } + } + + private async syncPartnerAssetsV1( + response: Writable, + checkpointMap: CheckpointMap, + auth: AuthDto, + sessionId: string, + ) { + const backfillType = SyncEntityType.PartnerAssetBackfillV1; + const upsertType = SyncEntityType.PartnerAssetV1; + const deleteType = SyncEntityType.PartnerAssetDeleteV1; + + const backfillCheckpoint = checkpointMap[backfillType]; + const upsertCheckpoint = checkpointMap[upsertType]; + + const deletes = this.syncRepository.getPartnerAssetDeletes(auth.user.id, checkpointMap[deleteType]); + + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const partners = await this.syncRepository.getPartnerBackfill(auth.user.id, backfillCheckpoint?.updateId); + + if (upsertCheckpoint) { + const endId = upsertCheckpoint.updateId; + + for (const partner of partners) { + if (isEntityBackfillComplete(partner, backfillCheckpoint)) { + continue; + } + + const startId = getStartId(partner, backfillCheckpoint); + const backfill = this.syncRepository.getPartnerAssetsBackfill(partner.sharedById, startId, endId); + + for await (const { updateId, ...data } of backfill) { + send(response, { + type: backfillType, + ids: [updateId], + data: mapSyncAssetV1(data), + }); + } + + sendEntityBackfillCompleteAck(response, backfillType, partner.sharedById); + } + } else if (partners.length > 0) { + await this.upsertBackfillCheckpoint({ + type: backfillType, + sessionId, + createId: partners.at(-1)!.createId, + }); + } + + const upserts = this.syncRepository.getPartnerAssetsUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) }); + } + } + + private async syncAssetExifsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const upserts = this.syncRepository.getAssetExifsUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetExifV1]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: SyncEntityType.AssetExifV1, ids: [updateId], data }); + } + } + + private async syncPartnerAssetExifsV1( + response: Writable, + checkpointMap: CheckpointMap, + auth: AuthDto, + sessionId: string, + ) { + const backfillType = SyncEntityType.PartnerAssetExifBackfillV1; + const upsertType = SyncEntityType.PartnerAssetExifV1; + + const backfillCheckpoint = checkpointMap[backfillType]; + const upsertCheckpoint = checkpointMap[upsertType]; + + const partners = await this.syncRepository.getPartnerBackfill(auth.user.id, backfillCheckpoint?.updateId); + + if (upsertCheckpoint) { + const endId = upsertCheckpoint.updateId; + + for (const partner of partners) { + if (isEntityBackfillComplete(partner, backfillCheckpoint)) { + continue; + } + + const startId = getStartId(partner, backfillCheckpoint); + const backfill = this.syncRepository.getPartnerAssetExifsBackfill(partner.sharedById, startId, endId); + + for await (const { updateId, ...data } of backfill) { + send(response, { type: backfillType, ids: [updateId], data }); + } + + sendEntityBackfillCompleteAck(response, backfillType, partner.sharedById); + } + } else if (partners.length > 0) { + await this.upsertBackfillCheckpoint({ + type: backfillType, + sessionId, + createId: partners.at(-1)!.createId, + }); + } + + const upserts = this.syncRepository.getPartnerAssetExifsUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + + private async syncAlbumsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const deletes = this.syncRepository.getAlbumDeletes(auth.user.id, checkpointMap[SyncEntityType.AlbumDeleteV1]); + for await (const { id, ...data } of deletes) { + send(response, { type: SyncEntityType.AlbumDeleteV1, ids: [id], data }); + } + + const upserts = this.syncRepository.getAlbumUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumV1]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: SyncEntityType.AlbumV1, ids: [updateId], data }); + } + } + + private async syncAlbumUsersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const deletes = this.syncRepository.getAlbumUserDeletes( + auth.user.id, + checkpointMap[SyncEntityType.AlbumUserDeleteV1], + ); + for await (const { id, ...data } of deletes) { + send(response, { type: SyncEntityType.AlbumUserDeleteV1, ids: [id], data }); + } + + const upserts = this.syncRepository.getAlbumUserUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumUserV1]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: SyncEntityType.AlbumUserV1, ids: [updateId], data }); + } + } + + private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) { + const { type, sessionId, createId } = item; + await this.syncRepository.upsertCheckpoints([ + { + type, + sessionId, + ack: toAck({ + type, + updateId: createId, + extraId: COMPLETE_ID, + }), + }, + ]); + } + async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise { // mobile implementation is faster if this is a single id const userId = dto.userId || auth.user.id; diff --git a/server/src/utils/sync.ts b/server/src/utils/sync.ts index 893c94dfcd..82222708af 100644 --- a/server/src/utils/sync.ts +++ b/server/src/utils/sync.ts @@ -18,14 +18,17 @@ export const toAck = ({ type, updateId, extraId }: SyncAck) => export const mapJsonLine = (object: unknown) => JSON.stringify(object) + '\n'; +export type SerializeOptions = { + type: T; + data: Exact; + ids: [string] | [string, string]; + ackType?: SyncEntityType; +}; + export const serialize = ({ type, data, ids, ackType, -}: { - type: T; - data: Exact; - ids: [string] | [string, string]; - ackType?: SyncEntityType; -}) => mapJsonLine({ type, data, ack: toAck({ type: ackType ?? type, updateId: ids[0], extraId: ids[1] }) }); +}: SerializeOptions) => + mapJsonLine({ type, data, ack: toAck({ type: ackType ?? type, updateId: ids[0], extraId: ids[1] }) }); From 91cbd56c1c6b3e0d815655ffe34d27eed134660a Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 17 Jun 2025 13:07:54 -0400 Subject: [PATCH 11/71] revert: service worker changes (#19227) --- web/src/service-worker/broadcast-channel.ts | 2 +- web/src/service-worker/cache.ts | 106 ------------------ web/src/service-worker/fetch-event.ts | 116 ++++++++++++++++---- web/src/service-worker/index.ts | 6 +- 4 files changed, 99 insertions(+), 131 deletions(-) delete mode 100644 web/src/service-worker/cache.ts diff --git a/web/src/service-worker/broadcast-channel.ts b/web/src/service-worker/broadcast-channel.ts index e1a30b203b..12f835c52f 100644 --- a/web/src/service-worker/broadcast-channel.ts +++ b/web/src/service-worker/broadcast-channel.ts @@ -1,4 +1,4 @@ -import { cancelLoad, getCachedOrFetch } from './cache'; +import { cancelLoad, getCachedOrFetch } from './fetch-event'; export const installBroadcastChannelListener = () => { const broadcast = new BroadcastChannel('immich'); diff --git a/web/src/service-worker/cache.ts b/web/src/service-worker/cache.ts deleted file mode 100644 index 3a2e92c973..0000000000 --- a/web/src/service-worker/cache.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { build, files, version } from '$service-worker'; - -const useCache = true; -const CACHE = `cache-${version}`; - -export const APP_RESOURCES = [ - ...build, // the app itself - ...files, // everything in `static` -]; - -export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined; -export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined; - -export async function deleteOldCaches() { - for (const key of await caches.keys()) { - if (key !== CACHE) { - await caches.delete(key); - } - } -} - -export async function addFilesToCache() { - const cache = await caches.open(CACHE); - await cache.addAll(APP_RESOURCES); -} - -const pendingLoads = new Map(); - -export async function cancelLoad(urlString: string) { - const pending = pendingLoads.get(urlString); - if (pending) { - pending.abort(); - pendingLoads.delete(urlString); - } -} - -export async function getCachedOrFetch(request: URL | Request | string, cancelable: boolean = false) { - const cached = await checkCache(request); - if (cached.response) { - return cached.response; - } - - try { - if (!cancelable) { - const response = await fetch(request); - checkResponse(response); - return response; - } - - return await fetchWithCancellation(request, cached.cache); - } catch { - return new Response(undefined, { - status: 499, - statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself', - }); - } -} - -async function fetchWithCancellation(request: URL | Request | string, cache: Cache) { - const cacheKey = getCacheKey(request); - const cancelToken = new AbortController(); - - try { - pendingLoads.set(cacheKey, cancelToken); - const response = await fetch(request, { - signal: cancelToken.signal, - }); - - checkResponse(response); - setCached(response, cache, cacheKey); - return response; - } finally { - pendingLoads.delete(cacheKey); - } -} - -async function checkCache(url: URL | Request | string) { - if (!useCache) { - return; - } - const cache = await caches.open(CACHE); - const response = await cache.match(url); - return { cache, response }; -} - -async function setCached(response: Response, cache: Cache, cacheKey: URL | Request | string) { - if (response.status === 200) { - cache.put(cacheKey, response.clone()); - } -} - -function checkResponse(response: Response) { - if (!(response instanceof Response)) { - throw new TypeError('invalid response from fetch'); - } -} - -function getCacheKey(request: URL | Request | string) { - if (isURL(request)) { - return request.toString(); - } else if (isRequest(request)) { - return request.url; - } else { - return request; - } -} diff --git a/web/src/service-worker/fetch-event.ts b/web/src/service-worker/fetch-event.ts index 9ac53c8c14..11c8e0fd00 100644 --- a/web/src/service-worker/fetch-event.ts +++ b/web/src/service-worker/fetch-event.ts @@ -1,26 +1,111 @@ -import { APP_RESOURCES, getCachedOrFetch } from './cache'; +import { version } from '$service-worker'; + +const useCache = true; +const CACHE = `cache-${version}`; + +export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined; +export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined; + +export async function deleteOldCaches() { + for (const key of await caches.keys()) { + if (key !== CACHE) { + await caches.delete(key); + } + } +} + +const pendingLoads = new Map(); + +export async function cancelLoad(urlString: string) { + const pending = pendingLoads.get(urlString); + if (pending) { + pending.abort(); + pendingLoads.delete(urlString); + } +} + +export async function getCachedOrFetch(request: URL | Request | string, cancelable: boolean = false) { + const cached = await checkCache(request); + if (cached.response) { + return cached.response; + } + + try { + if (!cancelable) { + const response = await fetch(request); + checkResponse(response); + return response; + } + + return await fetchWithCancellation(request, cached.cache); + } catch { + return new Response(undefined, { + status: 499, + statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself', + }); + } +} + +async function fetchWithCancellation(request: URL | Request | string, cache: Cache) { + const cacheKey = getCacheKey(request); + const cancelToken = new AbortController(); + + try { + pendingLoads.set(cacheKey, cancelToken); + const response = await fetch(request, { + signal: cancelToken.signal, + }); + + checkResponse(response); + setCached(response, cache, cacheKey); + return response; + } finally { + pendingLoads.delete(cacheKey); + } +} + +async function checkCache(url: URL | Request | string) { + if (!useCache) { + return; + } + const cache = await caches.open(CACHE); + const response = await cache.match(url); + return { cache, response }; +} + +async function setCached(response: Response, cache: Cache, cacheKey: URL | Request | string) { + if (response.status === 200) { + cache.put(cacheKey, response.clone()); + } +} + +function checkResponse(response: Response) { + if (!(response instanceof Response)) { + throw new TypeError('invalid response from fetch'); + } +} + +function getCacheKey(request: URL | Request | string) { + if (isURL(request)) { + return request.toString(); + } else if (isRequest(request)) { + return request.url; + } else { + return request; + } +} function isAssetRequest(pathname: string): boolean { return /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(pathname); } -function isIgnoredFileType(pathname: string): boolean { - return /\.(png|ico|txt|json|ts|ttf|css|js|svelte)$/.test(pathname); -} - -function isIgnoredPath(pathname: string): boolean { - return /^\/(src|api)(\/.*)?$/.test(pathname) || /^\/(node_modules|@vite|@id)(\/.*)?$/.test(pathname); -} - export function handleFetchEvent(event: FetchEvent): void { if (event.request.method !== 'GET') { return; } const url = new URL(event.request.url); - - if (APP_RESOURCES.includes(url.pathname)) { - event.respondWith(getCachedOrFetch(event.request)); + if (url.origin !== self.location.origin) { return; } @@ -28,11 +113,4 @@ export function handleFetchEvent(event: FetchEvent): void { event.respondWith(getCachedOrFetch(event.request, true)); return; } - - if (isIgnoredFileType(url.pathname) || isIgnoredPath(url.pathname)) { - return; - } - - const slash = new URL('/', url.origin); - event.respondWith(getCachedOrFetch(slash)); } diff --git a/web/src/service-worker/index.ts b/web/src/service-worker/index.ts index b3c1fda38e..fbb6f74d82 100644 --- a/web/src/service-worker/index.ts +++ b/web/src/service-worker/index.ts @@ -3,21 +3,17 @@ /// /// import { installBroadcastChannelListener } from './broadcast-channel'; -import { addFilesToCache, deleteOldCaches } from './cache'; -import { handleFetchEvent } from './fetch-event'; +import { deleteOldCaches, handleFetchEvent } from './fetch-event'; const sw = globalThis as unknown as ServiceWorkerGlobalScope; const handleActivate = (event: ExtendableEvent) => { event.waitUntil(sw.clients.claim()); - // Remove previous cached data from disk event.waitUntil(deleteOldCaches()); }; const handleInstall = (event: ExtendableEvent) => { event.waitUntil(sw.skipWaiting()); - // Create a new cache and add all files to it - event.waitUntil(addFilesToCache()); }; sw.addEventListener('install', handleInstall, { passive: true }); From c6641d4859af0815bd769b8c370c701b35962c00 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Tue, 17 Jun 2025 16:52:57 -0400 Subject: [PATCH 12/71] fix: devcontainer paths/logs (#19236) --- .devcontainer/devcontainer.json | 2 +- .devcontainer/server/container-common.sh | 46 +++++++++++++------ .../server/container-compose-overrides.yml | 1 + .../server/container-start-backend.sh | 10 ++-- .../server/container-start-frontend.sh | 12 ++--- .devcontainer/server/container-start.sh | 13 ++++++ 6 files changed, 59 insertions(+), 25 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4e4285f131..c8a491b4d3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -55,7 +55,7 @@ "userEnvProbe": "loginInteractiveShell", "remoteEnv": { // The location where your uploaded files are stored - "UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:./Library}", + "UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:upload-devcontainer-volume}", // 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": "${localEnv:DB_PASSWORD:postgres}", diff --git a/.devcontainer/server/container-common.sh b/.devcontainer/server/container-common.sh index 95f4e222a1..8fc0038d49 100755 --- a/.devcontainer/server/container-common.sh +++ b/.devcontainer/server/container-common.sh @@ -7,12 +7,34 @@ export DEV_PORT="${DEV_PORT:-3000}" # Devcontainer: Clone [repository|pull request] in container volumne WORKSPACES_DIR="/workspaces" IMMICH_DIR="$WORKSPACES_DIR/immich" +IMMICH_DEVCONTAINER_LOG="$HOME/immich-devcontainer.log" + +log() { + # Display command on console, log with timestamp to file + echo "$*" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >>"$IMMICH_DEVCONTAINER_LOG" +} + +run_cmd() { + # Ensure log directory exists + mkdir -p "$(dirname "$IMMICH_DEVCONTAINER_LOG")" + + log "$@" + + # Execute command: display normally on console, log with timestamps to file + "$@" 2>&1 | tee >(while IFS= read -r line; do + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $line" >>"$IMMICH_DEVCONTAINER_LOG" + done) + + # Preserve exit status + return "${PIPESTATUS[0]}" +} # Find directories excluding /workspaces/immich mapfile -t other_dirs < <(find "$WORKSPACES_DIR" -mindepth 1 -maxdepth 1 -type d ! -path "$IMMICH_DIR" ! -name ".*") if [ ${#other_dirs[@]} -gt 1 ]; then - echo "Error: More than one directory found in $WORKSPACES_DIR other than $IMMICH_DIR." + log "Error: More than one directory found in $WORKSPACES_DIR other than $IMMICH_DIR." exit 1 elif [ ${#other_dirs[@]} -eq 1 ]; then export IMMICH_WORKSPACE="${other_dirs[0]}" @@ -20,16 +42,12 @@ else export IMMICH_WORKSPACE="$IMMICH_DIR" fi -echo "Found immich workspace in $IMMICH_WORKSPACE" - -run_cmd() { - echo "$@" - "$@" -} +log "Found immich workspace in $IMMICH_WORKSPACE" +log "" fix_permissions() { - echo "Fixing permissions for ${IMMICH_WORKSPACE}" + log "Fixing permissions for ${IMMICH_WORKSPACE}" run_cmd sudo find "${IMMICH_WORKSPACE}/server/upload" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres/*" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres" -exec chown node {} + @@ -41,17 +59,19 @@ fix_permissions() { "${IMMICH_WORKSPACE}/server/dist" \ "${IMMICH_WORKSPACE}/web/node_modules" \ "${IMMICH_WORKSPACE}/web/dist" + + log "" } install_dependencies() { - echo "Installing dependencies" - + log "Installing dependencies" ( cd "${IMMICH_WORKSPACE}" || exit 1 run_cmd make install-server - run_cmd make install-open-api - run_cmd make build-open-api + run_cmd make install-sdk + run_cmd make build-sdk run_cmd make install-web ) -} \ No newline at end of file + log "" +} diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml index 94fbbab8cd..eb36a062a5 100644 --- a/.devcontainer/server/container-compose-overrides.yml +++ b/.devcontainer/server/container-compose-overrides.yml @@ -42,3 +42,4 @@ volumes: open_api_node_modules: server_node_modules: web_node_modules: + upload-devcontainer-volume: diff --git a/.devcontainer/server/container-start-backend.sh b/.devcontainer/server/container-start-backend.sh index 230a1378a2..aeb70df72d 100755 --- a/.devcontainer/server/container-start-backend.sh +++ b/.devcontainer/server/container-start-backend.sh @@ -3,15 +3,15 @@ # shellcheck disable=SC1091 source /immich-devcontainer/container-common.sh -echo "Starting Nest API Server" - +log "Starting Nest API Server" +log "" cd "${IMMICH_WORKSPACE}/server" || ( - echo workspace not found + log "Immich workspace not found" exit 1 ) while true; do - node ./node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch - echo " Nest API Server crashed with exit code $?. Respawning in 3s ..." + run_cmd node ./node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch + log "Nest API Server crashed with exit code $?. Respawning in 3s ..." sleep 3 done diff --git a/.devcontainer/server/container-start-frontend.sh b/.devcontainer/server/container-start-frontend.sh index 43bde2a344..633dcc3a93 100755 --- a/.devcontainer/server/container-start-frontend.sh +++ b/.devcontainer/server/container-start-frontend.sh @@ -3,20 +3,20 @@ # shellcheck disable=SC1091 source /immich-devcontainer/container-common.sh -echo "Starting Immich Web Frontend" - +log "Starting Immich Web Frontend" +log "" cd "${IMMICH_WORKSPACE}/web" || ( - echo Workspace not found + log "Immich Workspace not found" exit 1 ) until curl --output /dev/null --silent --head --fail "http://127.0.0.1:${IMMICH_PORT}/api/server/config"; do - echo 'waiting for api server...' + log "Waiting for api server..." sleep 1 done while true; do - node ./node_modules/.bin/vite dev --host 0.0.0.0 --port "${DEV_PORT}" - echo "Web crashed with exit code $?. Respawning in 3s ..." + run_cmd node ./node_modules/.bin/vite dev --host 0.0.0.0 --port "${DEV_PORT}" + log "Web crashed with exit code $?. Respawning in 3s ..." sleep 3 done diff --git a/.devcontainer/server/container-start.sh b/.devcontainer/server/container-start.sh index ef22db5d72..860b2826b0 100755 --- a/.devcontainer/server/container-start.sh +++ b/.devcontainer/server/container-start.sh @@ -3,5 +3,18 @@ # shellcheck disable=SC1091 source /immich-devcontainer/container-common.sh +log "Setting up Immich dev container..." fix_permissions + +log "Installing npm dependencies (node_modules)..." install_dependencies + +log "Setup complete, please wait while backend and frontend services automatically start" +log +log "If necessary, the services may be manually started using" +log +log "$ /immich-devcontainer/container-start-backend.sh" +log "$ /immich-devcontainer/container-start-frontend.sh" +log +log "From different terminal windows, as these scripts automatically restart the server" +log "on error, and will continuously run in a loop" From 06f1d0dc4d1375fd9b21e77a3088401d8cbca209 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 17 Jun 2025 15:56:42 -0500 Subject: [PATCH 13/71] fix(mobile): correct share option for local asset (#19233) --- mobile/lib/services/share.service.dart | 2 +- .../asset_grid/control_bottom_app_bar.dart | 17 ++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/mobile/lib/services/share.service.dart b/mobile/lib/services/share.service.dart index d44d75931d..77afa10fb6 100644 --- a/mobile/lib/services/share.service.dart +++ b/mobile/lib/services/share.service.dart @@ -30,7 +30,7 @@ class ShareService { for (var asset in assets) { if (asset.isLocal) { // Prefer local assets to share - File? f = await asset.local!.file; + File? f = await asset.local!.originFile; downloadedXFiles.add(XFile(f!.path)); } else if (asset.isRemote) { // Download remote asset otherwise 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 3859e5ec1b..3283b90b21 100644 --- a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart +++ b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart @@ -131,15 +131,14 @@ class ControlBottomAppBar extends HookConsumerWidget { List renderActionButtons() { return [ - if (hasRemote) - ControlBoxButton( - iconData: Platform.isAndroid - ? Icons.share_rounded - : Icons.ios_share_rounded, - label: "share".tr(), - onPressed: enabled ? () => onShare(true) : null, - ), - if (!isInLockedView) + ControlBoxButton( + iconData: Platform.isAndroid + ? Icons.share_rounded + : Icons.ios_share_rounded, + label: "share".tr(), + onPressed: enabled ? () => onShare(true) : null, + ), + if (!isInLockedView && hasRemote) ControlBoxButton( iconData: Icons.link_rounded, label: "share_link".tr(), From 023bcffdb8c925a7afafb7f93c3541aad4f3b643 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 17 Jun 2025 21:16:52 -0400 Subject: [PATCH 14/71] chore: no test coverage in ci (#19235) --- .github/workflows/test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c9fd2600bf..542804e655 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -101,7 +101,7 @@ jobs: if: ${{ !cancelled() }} - name: Run small tests & coverage - run: npm run test:cov + run: npm test if: ${{ !cancelled() }} cli-unit-tests: @@ -146,7 +146,7 @@ jobs: if: ${{ !cancelled() }} - name: Run unit tests & coverage - run: npm run test:cov + run: npm run test if: ${{ !cancelled() }} cli-unit-tests-win: @@ -184,7 +184,7 @@ jobs: if: ${{ !cancelled() }} - name: Run unit tests & coverage - run: npm run test:cov + run: npm run test if: ${{ !cancelled() }} web-lint: @@ -262,7 +262,7 @@ jobs: if: ${{ !cancelled() }} - name: Run unit tests & coverage - run: npm run test:cov + run: npm run test if: ${{ !cancelled() }} i18n-tests: From 65e8d75e82fba6f6c3f75924ceacbf0782144a15 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 13:45:07 +0000 Subject: [PATCH 15/71] chore: version v1.135.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 +- 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 | 8 ++++---- web/package.json | 2 +- 17 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 6b879f416a..9dca99f022 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.68", + "version": "2.2.69", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.68", + "version": "2.2.69", "license": "GNU Affero General Public License version 3", "dependencies": { "chokidar": "^4.0.3", @@ -54,7 +54,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.134.0", + "version": "1.135.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 746b907d9f..36665e2638 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.68", + "version": "2.2.69", "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 31543e7abb..dfb535b069 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.135.0", + "url": "https://v1.135.0.archive.immich.app" + }, { "label": "v1.134.0", "url": "https://v1.134.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 17643e62ef..25850dbd55 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.134.0", + "version": "1.135.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.134.0", + "version": "1.135.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -44,7 +44,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.68", + "version": "2.2.69", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -93,7 +93,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.134.0", + "version": "1.135.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 37c4c54395..a7f365451e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.134.0", + "version": "1.135.0", "description": "", "main": "index.js", "type": "module", diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 20dfaaffad..ef30c77b5b 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" => 200, - "android.injected.version.name" => "1.134.0", + "android.injected.version.code" => 201, + "android.injected.version.name" => "1.135.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 1d93a13568..5c7ba98758 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -22,7 +22,7 @@ platform :ios do path: "./Runner.xcodeproj", ) increment_version_number( - version_number: "1.134.0" + version_number: "1.135.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b648b0a709..2d9fbe5373 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.134.0 +- API version: 1.135.0 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a70ae25bfa..b0a638de5c 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.134.0+200 +version: 1.135.0+201 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 6e1f029862..1c240eb775 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8503,7 +8503,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.134.0", + "version": "1.135.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 19d24f993d..a3bac84b42 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.134.0", + "version": "1.135.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.134.0", + "version": "1.135.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 7b6125f73b..c07c6dffe5 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.134.0", + "version": "1.135.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 6a288e75db..49c97b79e9 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.134.0 + * 1.135.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 78e1ee43fe..9571b9cb53 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.134.0", + "version": "1.135.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.134.0", + "version": "1.135.0", "hasInstallScript": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/server/package.json b/server/package.json index e0de8e2afc..834cdee349 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.134.0", + "version": "1.135.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 72b9eb6a7c..4e092f0da1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.134.0", + "version": "1.135.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.134.0", + "version": "1.135.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -87,13 +87,13 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.134.0", + "version": "1.135.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.15.29", + "@types/node": "^22.15.31", "typescript": "^5.3.3" } }, diff --git a/web/package.json b/web/package.json index a06b12f826..24d27a1e59 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.134.0", + "version": "1.135.0", "license": "GNU Affero General Public License version 3", "type": "module", "scripts": { From e0144b4ecef9f1d91257352129ce72845c47818e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 18 Jun 2025 10:48:11 -0400 Subject: [PATCH 16/71] feat: backfill album users (#19234) --- .../openapi/lib/model/sync_entity_type.dart | 3 + open-api/immich-openapi-specs.json | 1 + open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/database.ts | 6 ++ server/src/db.d.ts | 4 +- server/src/dtos/sync.dto.ts | 1 + server/src/enum.ts | 1 + server/src/queries/sync.repository.sql | 29 ++++++ server/src/repositories/sync.repository.ts | 32 +++++-- .../1750189909087-AddAlbumUserCreateFields.ts | 15 +++ server/src/schema/tables/album-user.table.ts | 9 +- server/src/schema/tables/partner.table.ts | 2 +- server/src/services/sync.service.ts | 60 +++++++++--- .../medium/specs/sync/sync-album-user.spec.ts | 92 ++++++++++++++++++- 14 files changed, 232 insertions(+), 24 deletions(-) create mode 100644 server/src/schema/migrations/1750189909087-AddAlbumUserCreateFields.ts diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index f1366a2bfc..654ff45d6f 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -38,6 +38,7 @@ class SyncEntityType { static const albumV1 = SyncEntityType._(r'AlbumV1'); static const albumDeleteV1 = SyncEntityType._(r'AlbumDeleteV1'); static const albumUserV1 = SyncEntityType._(r'AlbumUserV1'); + static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1'); static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1'); static const syncAckV1 = SyncEntityType._(r'SyncAckV1'); @@ -58,6 +59,7 @@ class SyncEntityType { albumV1, albumDeleteV1, albumUserV1, + albumUserBackfillV1, albumUserDeleteV1, syncAckV1, ]; @@ -113,6 +115,7 @@ class SyncEntityTypeTypeTransformer { case r'AlbumV1': return SyncEntityType.albumV1; case r'AlbumDeleteV1': return SyncEntityType.albumDeleteV1; case r'AlbumUserV1': return SyncEntityType.albumUserV1; + case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1; case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1; case r'SyncAckV1': return SyncEntityType.syncAckV1; default: diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1c240eb775..f942015fb3 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13706,6 +13706,7 @@ "AlbumV1", "AlbumDeleteV1", "AlbumUserV1", + "AlbumUserBackfillV1", "AlbumUserDeleteV1", "SyncAckV1" ], diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 49c97b79e9..0d57652df9 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4059,6 +4059,7 @@ export enum SyncEntityType { AlbumV1 = "AlbumV1", AlbumDeleteV1 = "AlbumDeleteV1", AlbumUserV1 = "AlbumUserV1", + AlbumUserBackfillV1 = "AlbumUserBackfillV1", AlbumUserDeleteV1 = "AlbumUserDeleteV1", SyncAckV1 = "SyncAckV1" } diff --git a/server/src/database.ts b/server/src/database.ts index 79c550dd52..1cddda1ee6 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -355,6 +355,12 @@ export const columns = { 'updateId', 'duration', ], + syncAlbumUser: [ + 'albums_shared_users_users.albumsId as albumId', + 'albums_shared_users_users.usersId as userId', + 'albums_shared_users_users.role', + 'albums_shared_users_users.updateId', + ], stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'], syncAssetExif: [ 'exif.assetId', diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 5aa8a8c4dc..7a4c319d0b 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -98,8 +98,10 @@ export interface AlbumsSharedUsersUsers { albumsId: string; role: Generated; usersId: string; - updatedAt: Generated; + createId: Generated; + createdAt: Generated; updateId: Generated; + updatedAt: Generated; } export interface ApiKeys { diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index dbd58cde53..91c93fef66 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -161,6 +161,7 @@ export type SyncItem = { [SyncEntityType.AlbumV1]: SyncAlbumV1; [SyncEntityType.AlbumDeleteV1]: SyncAlbumDeleteV1; [SyncEntityType.AlbumUserV1]: SyncAlbumUserV1; + [SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1; [SyncEntityType.SyncAckV1]: object; }; diff --git a/server/src/enum.ts b/server/src/enum.ts index 4353e43ad1..4f3fd9a521 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -603,6 +603,7 @@ export enum SyncEntityType { AlbumV1 = 'AlbumV1', AlbumDeleteV1 = 'AlbumDeleteV1', AlbumUserV1 = 'AlbumUserV1', + AlbumUserBackfillV1 = 'AlbumUserBackfillV1', AlbumUserDeleteV1 = 'AlbumUserDeleteV1', SyncAckV1 = 'SyncAckV1', diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 8e52754467..4c4747e5da 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -394,6 +394,35 @@ where order by "id" asc +-- SyncRepository.getAlbumBackfill +select + "albumsId" as "id", + "createId" +from + "albums_shared_users_users" +where + "usersId" = $1 + and "createId" >= $2 + and "createdAt" < now() - interval '1 millisecond' +order by + "createId" asc + +-- SyncRepository.getAlbumUsersBackfill +select + "albums_shared_users_users"."albumsId" as "albumId", + "albums_shared_users_users"."usersId" as "userId", + "albums_shared_users_users"."role", + "albums_shared_users_users"."updateId" +from + "albums_shared_users_users" +where + "albumsId" = $1 + and "updatedAt" < now() - interval '1 millisecond' + and "updateId" < $2 + and "updateId" >= $3 +order by + "updateId" asc + -- SyncRepository.getAlbumUserUpserts select "albums_shared_users_users"."albumsId" as "albumId", diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 0f2d382fe0..dee419f334 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -254,16 +254,36 @@ export class SyncRepository { .stream(); } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + getAlbumBackfill(userId: string, afterCreateId?: string) { + return this.db + .selectFrom('albums_shared_users_users') + .select(['albumsId as id', 'createId']) + .where('usersId', '=', userId) + .$if(!!afterCreateId, (qb) => qb.where('createId', '>=', afterCreateId!)) + .where('createdAt', '<', sql.raw("now() - interval '1 millisecond'")) + .orderBy('createId', 'asc') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true }) + getAlbumUsersBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) { + return this.db + .selectFrom('albums_shared_users_users') + .select(columns.syncAlbumUser) + .where('albumsId', '=', albumId) + .where('updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .where('updateId', '<', beforeUpdateId) + .$if(!!afterUpdateId, (eb) => eb.where('updateId', '>=', afterUpdateId!)) + .orderBy('updateId', 'asc') + .stream(); + } + @GenerateSql({ params: [DummyValue.UUID], stream: true }) getAlbumUserUpserts(userId: string, ack?: SyncAck) { return this.db .selectFrom('albums_shared_users_users') - .select([ - 'albums_shared_users_users.albumsId as albumId', - 'albums_shared_users_users.usersId as userId', - 'albums_shared_users_users.role', - 'albums_shared_users_users.updateId', - ]) + .select(columns.syncAlbumUser) .where('albums_shared_users_users.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) .$if(!!ack, (qb) => qb.where('albums_shared_users_users.updateId', '>', ack!.updateId)) .orderBy('albums_shared_users_users.updateId', 'asc') diff --git a/server/src/schema/migrations/1750189909087-AddAlbumUserCreateFields.ts b/server/src/schema/migrations/1750189909087-AddAlbumUserCreateFields.ts new file mode 100644 index 0000000000..0ad59f9e82 --- /dev/null +++ b/server/src/schema/migrations/1750189909087-AddAlbumUserCreateFields.ts @@ -0,0 +1,15 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "albums_shared_users_users" ADD "createId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); + await sql`ALTER TABLE "albums_shared_users_users" ADD "createdAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`CREATE INDEX "IDX_album_users_create_id" ON "albums_shared_users_users" ("createId")`.execute(db); + await sql`CREATE INDEX "IDX_partners_create_id" ON "partners" ("createId")`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX "IDX_partners_create_id";`.execute(db); + await sql`DROP INDEX "IDX_album_users_create_id";`.execute(db); + await sql`ALTER TABLE "albums_shared_users_users" DROP COLUMN "createId";`.execute(db); + await sql`ALTER TABLE "albums_shared_users_users" DROP COLUMN "createdAt";`.execute(db); +} diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts index 276efd126a..15300f7c6a 100644 --- a/server/src/schema/tables/album-user.table.ts +++ b/server/src/schema/tables/album-user.table.ts @@ -1,4 +1,4 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AlbumUserRole } from 'src/enum'; import { album_user_after_insert, album_users_delete_audit } from 'src/schema/functions'; import { AlbumTable } from 'src/schema/tables/album.table'; @@ -7,6 +7,7 @@ import { AfterDeleteTrigger, AfterInsertTrigger, Column, + CreateDateColumn, ForeignKeyColumn, Index, Table, @@ -51,6 +52,12 @@ export class AlbumUserTable { @Column({ type: 'character varying', default: AlbumUserRole.EDITOR }) role!: AlbumUserRole; + @CreateIdColumn({ indexName: 'IDX_album_users_create_id' }) + createId?: string; + + @CreateDateColumn() + createdAt!: Date; + @UpdateIdColumn({ indexName: 'IDX_album_users_update_id' }) updateId?: string; diff --git a/server/src/schema/tables/partner.table.ts b/server/src/schema/tables/partner.table.ts index 6b83c6ba4c..8cec2ee58a 100644 --- a/server/src/schema/tables/partner.table.ts +++ b/server/src/schema/tables/partner.table.ts @@ -27,7 +27,7 @@ export class PartnerTable { @CreateDateColumn() createdAt!: Date; - @CreateIdColumn() + @CreateIdColumn({ indexName: 'IDX_partners_create_id' }) createId!: string; @UpdateDateColumn() diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 46937a570f..9021aa57e9 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -138,14 +138,14 @@ export class SyncService extends BaseService { break; } - case SyncRequestType.PartnerAssetsV1: { - await this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId); - + case SyncRequestType.AssetExifsV1: { + await this.syncAssetExifsV1(response, checkpointMap, auth); break; } - case SyncRequestType.AssetExifsV1: { - await this.syncAssetExifsV1(response, checkpointMap, auth); + case SyncRequestType.PartnerAssetsV1: { + await this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId); + break; } @@ -160,7 +160,7 @@ export class SyncService extends BaseService { } case SyncRequestType.AlbumUsersV1: { - await this.syncAlbumUsersV1(response, checkpointMap, auth); + await this.syncAlbumUsersV1(response, checkpointMap, auth, sessionId); break; } @@ -330,18 +330,50 @@ export class SyncService extends BaseService { } } - private async syncAlbumUsersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { - const deletes = this.syncRepository.getAlbumUserDeletes( - auth.user.id, - checkpointMap[SyncEntityType.AlbumUserDeleteV1], - ); + private async syncAlbumUsersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) { + const backfillType = SyncEntityType.AlbumUserBackfillV1; + const upsertType = SyncEntityType.AlbumUserV1; + const deleteType = SyncEntityType.AlbumUserDeleteV1; + + const backfillCheckpoint = checkpointMap[backfillType]; + const upsertCheckpoint = checkpointMap[upsertType]; + + const deletes = this.syncRepository.getAlbumUserDeletes(auth.user.id, checkpointMap[deleteType]); + for await (const { id, ...data } of deletes) { - send(response, { type: SyncEntityType.AlbumUserDeleteV1, ids: [id], data }); + send(response, { type: deleteType, ids: [id], data }); } - const upserts = this.syncRepository.getAlbumUserUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumUserV1]); + const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId); + + if (upsertCheckpoint) { + const endId = upsertCheckpoint.updateId; + + for (const album of albums) { + if (isEntityBackfillComplete(album, backfillCheckpoint)) { + continue; + } + + const startId = getStartId(album, backfillCheckpoint); + const backfill = this.syncRepository.getAlbumUsersBackfill(album.id, startId, endId); + + for await (const { updateId, ...data } of backfill) { + send(response, { type: backfillType, ids: [updateId], data }); + } + + sendEntityBackfillCompleteAck(response, backfillType, album.id); + } + } else if (albums.length > 0) { + await this.upsertBackfillCheckpoint({ + type: backfillType, + sessionId, + createId: albums.at(-1)!.createId, + }); + } + + const upserts = this.syncRepository.getAlbumUserUpserts(auth.user.id, checkpointMap[upsertType]); for await (const { updateId, ...data } of upserts) { - send(response, { type: SyncEntityType.AlbumUserV1, ids: [updateId], data }); + send(response, { type: upsertType, ids: [updateId], data }); } } diff --git a/server/test/medium/specs/sync/sync-album-user.spec.ts b/server/test/medium/specs/sync/sync-album-user.spec.ts index 4967df5264..305bead275 100644 --- a/server/test/medium/specs/sync/sync-album-user.spec.ts +++ b/server/test/medium/specs/sync/sync-album-user.spec.ts @@ -2,7 +2,7 @@ import { Kysely } from 'kysely'; import { DB } from 'src/db'; import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum'; import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; -import { getKyselyDB } from 'test/utils'; +import { getKyselyDB, wait } from 'test/utils'; let defaultDatabase: Kysely; @@ -265,5 +265,95 @@ describe(SyncRequestType.AlbumUsersV1, () => { }, ]); }); + + it('should backfill album users when a user shares an album with you', async () => { + const { auth, sut, testSync, getRepository } = await setup(); + + const albumRepo = getRepository('album'); + const albumUserRepo = getRepository('albumUser'); + const userRepo = getRepository('user'); + + const user1 = mediumFactory.userInsert(); + const user2 = mediumFactory.userInsert(); + await userRepo.create(user1); + await userRepo.create(user2); + + const album1 = mediumFactory.albumInsert({ ownerId: user1.id }); + const album2 = mediumFactory.albumInsert({ ownerId: user1.id }); + await albumRepo.create(album1, [], []); + await albumRepo.create(album2, [], []); + + // backfill album user + await albumUserRepo.create({ albumsId: album1.id, usersId: user1.id, role: AlbumUserRole.EDITOR }); + await wait(2); + // initial album user + await albumUserRepo.create({ albumsId: album2.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR }); + await wait(2); + // post checkpoint album user + await albumUserRepo.create({ albumsId: album1.id, usersId: user2.id, role: AlbumUserRole.EDITOR }); + + const response = await testSync(auth, [SyncRequestType.AlbumUsersV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + albumId: album2.id, + role: AlbumUserRole.EDITOR, + userId: auth.user.id, + }), + type: SyncEntityType.AlbumUserV1, + }, + ]); + + // ack initial user + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + // get access to the backfill album user + await albumUserRepo.create({ albumsId: album1.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR }); + + // should backfill the album user + const backfillResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]); + expect(backfillResponse).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + albumId: album1.id, + role: AlbumUserRole.EDITOR, + userId: user1.id, + }), + type: SyncEntityType.AlbumUserBackfillV1, + }, + { + ack: expect.stringContaining(SyncEntityType.AlbumUserBackfillV1), + data: {}, + type: SyncEntityType.SyncAckV1, + }, + { + ack: expect.any(String), + data: expect.objectContaining({ + albumId: album1.id, + role: AlbumUserRole.EDITOR, + userId: user2.id, + }), + type: SyncEntityType.AlbumUserV1, + }, + { + ack: expect.any(String), + data: expect.objectContaining({ + albumId: album1.id, + role: AlbumUserRole.EDITOR, + userId: auth.user.id, + }), + type: SyncEntityType.AlbumUserV1, + }, + ]); + + await sut.setAcks(auth, { acks: [backfillResponse[1].ack, backfillResponse.at(-1).ack] }); + + const finalResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]); + expect(finalResponse).toEqual([]); + }); }); }); From de8100636790c260aae07c77f105e580c0610aec Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 18 Jun 2025 18:10:35 +0200 Subject: [PATCH 17/71] fix: album share modal navigation (#19245) --- web/src/lib/modals/AlbumShareModal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/modals/AlbumShareModal.svelte b/web/src/lib/modals/AlbumShareModal.svelte index ae60d7d0a5..83f63141ce 100644 --- a/web/src/lib/modals/AlbumShareModal.svelte +++ b/web/src/lib/modals/AlbumShareModal.svelte @@ -171,7 +171,7 @@ {#if sharedLinks.length > 0}
{$t('shared_links')} - {$t('view_all')} + onClose()} class="text-sm">{$t('view_all')}
From 0a9a520ed211b799299123f4b6ad23234a90d7f9 Mon Sep 17 00:00:00 2001 From: SGT Date: Wed, 18 Jun 2025 15:30:39 -0300 Subject: [PATCH 18/71] feat(server): sql-tools support for class level composite fk (#19242) * feat: support for class level composite fk * chore: clean up --------- Co-authored-by: Jason Rasmussen --- .../foreign-key-column.decorator.ts | 7 +- .../foreign-key-constraint.decorator.ts | 23 ++++ server/src/sql-tools/from-code/index.ts | 6 +- .../from-code/processors/column.processor.ts | 4 +- ...sor.ts => foreign-key-column.processor.ts} | 12 +- .../foreign-key-constraint.processor.ts | 86 ++++++++++++ .../from-code/processors/index.processor.ts | 15 +-- .../from-code/processors/table.processor.ts | 4 +- .../src/sql-tools/from-code/register-item.ts | 4 +- server/src/sql-tools/helpers.ts | 20 +++ server/src/sql-tools/public_api.ts | 1 + ...oreign-key-constraint-column-order.stub.ts | 124 ++++++++++++++++++ ...eign-key-constraint-missing-column.stub.ts | 78 +++++++++++ ...onstraint-missing-reference-column.stub.ts | 78 +++++++++++ ...constraint-missing-reference-table.stub.ts | 44 +++++++ ...gn-key-constraint-multiple-columns.stub.ts | 120 +++++++++++++++++ .../foreign-key-constraint-no-index.stub.ts | 88 +++++++++++++ .../foreign-key-constraint-no-primary.stub.ts | 85 ++++++++++++ .../sql-tools/foreign-key-constraint.stub.ts | 96 ++++++++++++++ 19 files changed, 865 insertions(+), 30 deletions(-) create mode 100644 server/src/sql-tools/from-code/decorators/foreign-key-constraint.decorator.ts rename server/src/sql-tools/from-code/processors/{foreign-key-constriant.processor.ts => foreign-key-column.processor.ts} (84%) create mode 100644 server/src/sql-tools/from-code/processors/foreign-key-constraint.processor.ts create mode 100644 server/test/sql-tools/foreign-key-constraint-column-order.stub.ts create mode 100644 server/test/sql-tools/foreign-key-constraint-missing-column.stub.ts create mode 100644 server/test/sql-tools/foreign-key-constraint-missing-reference-column.stub.ts create mode 100644 server/test/sql-tools/foreign-key-constraint-missing-reference-table.stub.ts create mode 100644 server/test/sql-tools/foreign-key-constraint-multiple-columns.stub.ts create mode 100644 server/test/sql-tools/foreign-key-constraint-no-index.stub.ts create mode 100644 server/test/sql-tools/foreign-key-constraint-no-primary.stub.ts create mode 100644 server/test/sql-tools/foreign-key-constraint.stub.ts diff --git a/server/src/sql-tools/from-code/decorators/foreign-key-column.decorator.ts b/server/src/sql-tools/from-code/decorators/foreign-key-column.decorator.ts index beb3aa6fd6..d2b7d623a7 100644 --- a/server/src/sql-tools/from-code/decorators/foreign-key-column.decorator.ts +++ b/server/src/sql-tools/from-code/decorators/foreign-key-column.decorator.ts @@ -1,11 +1,10 @@ import { ColumnBaseOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; +import { ForeignKeyAction } from 'src/sql-tools/from-code/decorators/foreign-key-constraint.decorator'; import { register } from 'src/sql-tools/from-code/register'; -type Action = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION'; - export type ForeignKeyColumnOptions = ColumnBaseOptions & { - onUpdate?: Action; - onDelete?: Action; + onUpdate?: ForeignKeyAction; + onDelete?: ForeignKeyAction; constraintName?: string; }; diff --git a/server/src/sql-tools/from-code/decorators/foreign-key-constraint.decorator.ts b/server/src/sql-tools/from-code/decorators/foreign-key-constraint.decorator.ts new file mode 100644 index 0000000000..7d18a9fda0 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/foreign-key-constraint.decorator.ts @@ -0,0 +1,23 @@ +import { register } from 'src/sql-tools/from-code/register'; + +export type ForeignKeyAction = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION'; + +export type ForeignKeyConstraintOptions = { + name?: string; + index?: boolean; + indexName?: string; + columns: string[]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + referenceTable: () => Function; + referenceColumns?: string[]; + onUpdate?: ForeignKeyAction; + onDelete?: ForeignKeyAction; + synchronize?: boolean; +}; + +export const ForeignKeyConstraint = (options: ForeignKeyConstraintOptions): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (target: Function) => { + register({ type: 'foreignKeyConstraint', item: { object: target, options } }); + }; +}; diff --git a/server/src/sql-tools/from-code/index.ts b/server/src/sql-tools/from-code/index.ts index 95f1dbb22d..d820f236df 100644 --- a/server/src/sql-tools/from-code/index.ts +++ b/server/src/sql-tools/from-code/index.ts @@ -5,7 +5,8 @@ import { processConfigurationParameters } from 'src/sql-tools/from-code/processo import { processDatabases } from 'src/sql-tools/from-code/processors/database.processor'; import { processEnums } from 'src/sql-tools/from-code/processors/enum.processor'; import { processExtensions } from 'src/sql-tools/from-code/processors/extension.processor'; -import { processForeignKeyConstraints } from 'src/sql-tools/from-code/processors/foreign-key-constriant.processor'; +import { processForeignKeyColumns } from 'src/sql-tools/from-code/processors/foreign-key-column.processor'; +import { processForeignKeyConstraints } from 'src/sql-tools/from-code/processors/foreign-key-constraint.processor'; import { processFunctions } from 'src/sql-tools/from-code/processors/function.processor'; import { processIndexes } from 'src/sql-tools/from-code/processors/index.processor'; import { processPrimaryKeyConstraints } from 'src/sql-tools/from-code/processors/primary-key-contraint.processor'; @@ -32,10 +33,11 @@ const processors: Processor[] = [ processFunctions, processTables, processColumns, + processForeignKeyColumns, + processForeignKeyConstraints, processUniqueConstraints, processCheckConstraints, processPrimaryKeyConstraints, - processForeignKeyConstraints, processIndexes, processTriggers, ]; diff --git a/server/src/sql-tools/from-code/processors/column.processor.ts b/server/src/sql-tools/from-code/processors/column.processor.ts index e8c2544f87..6fff3070e3 100644 --- a/server/src/sql-tools/from-code/processors/column.processor.ts +++ b/server/src/sql-tools/from-code/processors/column.processor.ts @@ -1,7 +1,7 @@ import { ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type'; -import { asMetadataKey, fromColumnValue } from 'src/sql-tools/helpers'; +import { addWarning, asMetadataKey, fromColumnValue } from 'src/sql-tools/helpers'; import { DatabaseColumn } from 'src/sql-tools/types'; export const processColumns: Processor = (builder, items) => { @@ -81,7 +81,7 @@ export const onMissingColumn = ( propertyName?: symbol | string, ) => { const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : ''); - builder.warnings.push(`[${context}] Unable to find column (${label})`); + addWarning(builder, context, `Unable to find column (${label})`); }; const METADATA_KEY = asMetadataKey('table-metadata'); diff --git a/server/src/sql-tools/from-code/processors/foreign-key-constriant.processor.ts b/server/src/sql-tools/from-code/processors/foreign-key-column.processor.ts similarity index 84% rename from server/src/sql-tools/from-code/processors/foreign-key-constriant.processor.ts rename to server/src/sql-tools/from-code/processors/foreign-key-column.processor.ts index 612b74c30f..d706763d1d 100644 --- a/server/src/sql-tools/from-code/processors/foreign-key-constriant.processor.ts +++ b/server/src/sql-tools/from-code/processors/foreign-key-column.processor.ts @@ -1,10 +1,10 @@ import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor'; import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; import { Processor } from 'src/sql-tools/from-code/processors/type'; -import { asKey } from 'src/sql-tools/helpers'; +import { asForeignKeyConstraintName, asKey } from 'src/sql-tools/helpers'; import { DatabaseActionType, DatabaseConstraintType } from 'src/sql-tools/types'; -export const processForeignKeyConstraints: Processor = (builder, items) => { +export const processForeignKeyColumns: Processor = (builder, items) => { for (const { item: { object, propertyName, options, target }, } of items.filter((item) => item.type === 'foreignKeyColumn')) { @@ -34,13 +34,16 @@ export const processForeignKeyConstraints: Processor = (builder, items) => { column.type = referenceColumns[0].type; } + const referenceColumnNames = referenceColumns.map((column) => column.name); + const name = options.constraintName || asForeignKeyConstraintName(table.name, columnNames); + table.constraints.push({ - name: options.constraintName || asForeignKeyConstraintName(table.name, columnNames), + name, tableName: table.name, columnNames, type: DatabaseConstraintType.FOREIGN_KEY, referenceTableName: referenceTable.name, - referenceColumnNames: referenceColumns.map((column) => column.name), + referenceColumnNames, onUpdate: options.onUpdate as DatabaseActionType, onDelete: options.onDelete as DatabaseActionType, synchronize: options.synchronize ?? true, @@ -58,5 +61,4 @@ export const processForeignKeyConstraints: Processor = (builder, items) => { } }; -const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, columns); const asRelationKeyConstraintName = (table: string, columns: string[]) => asKey('REL_', table, columns); diff --git a/server/src/sql-tools/from-code/processors/foreign-key-constraint.processor.ts b/server/src/sql-tools/from-code/processors/foreign-key-constraint.processor.ts new file mode 100644 index 0000000000..e88297b6c6 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/foreign-key-constraint.processor.ts @@ -0,0 +1,86 @@ +import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { addWarning, asForeignKeyConstraintName, asIndexName } from 'src/sql-tools/helpers'; +import { DatabaseActionType, DatabaseConstraintType } from 'src/sql-tools/types'; + +export const processForeignKeyConstraints: Processor = (builder, items, config) => { + for (const { + item: { object, options }, + } of items.filter((item) => item.type === 'foreignKeyConstraint')) { + const table = resolveTable(builder, object); + if (!table) { + onMissingTable(builder, '@ForeignKeyConstraint', { name: 'referenceTable' }); + continue; + } + + const referenceTable = resolveTable(builder, options.referenceTable()); + if (!referenceTable) { + const referenceTableName = options.referenceTable()?.name; + addWarning( + builder, + '@ForeignKeyConstraint.referenceTable', + `Unable to find table` + (referenceTableName ? ` (${referenceTableName})` : ''), + ); + continue; + } + + let missingColumn = false; + + for (const columnName of options.columns) { + if (!table.columns.some(({ name }) => name === columnName)) { + addWarning( + builder, + '@ForeignKeyConstraint.columns', + `Unable to find column (${table.metadata.object.name}.${columnName})`, + ); + missingColumn = true; + } + } + + for (const columnName of options.referenceColumns || []) { + if (!referenceTable.columns.some(({ name }) => name === columnName)) { + addWarning( + builder, + '@ForeignKeyConstraint.referenceColumns', + `Unable to find column (${referenceTable.metadata.object.name}.${columnName})`, + ); + missingColumn = true; + } + } + + if (missingColumn) { + continue; + } + + const referenceColumns = + options.referenceColumns || referenceTable.columns.filter(({ primary }) => primary).map(({ name }) => name); + + const name = options.name || asForeignKeyConstraintName(table.name, options.columns); + + table.constraints.push({ + type: DatabaseConstraintType.FOREIGN_KEY, + name, + tableName: table.name, + columnNames: options.columns, + referenceTableName: referenceTable.name, + referenceColumnNames: referenceColumns, + onUpdate: options.onUpdate as DatabaseActionType, + onDelete: options.onDelete as DatabaseActionType, + synchronize: options.synchronize ?? true, + }); + + if (options.index === false) { + continue; + } + + if (options.index || options.indexName || config.createForeignKeyIndexes) { + table.indexes.push({ + name: options.indexName || asIndexName(table.name, options.columns), + tableName: table.name, + columnNames: options.columns, + unique: false, + synchronize: options.synchronize ?? true, + }); + } + } +}; diff --git a/server/src/sql-tools/from-code/processors/index.processor.ts b/server/src/sql-tools/from-code/processors/index.processor.ts index f4c9c7cec1..4de8914231 100644 --- a/server/src/sql-tools/from-code/processors/index.processor.ts +++ b/server/src/sql-tools/from-code/processors/index.processor.ts @@ -1,7 +1,7 @@ import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor'; import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; import { Processor } from 'src/sql-tools/from-code/processors/type'; -import { asKey } from 'src/sql-tools/helpers'; +import { asIndexName } from 'src/sql-tools/helpers'; export const processIndexes: Processor = (builder, items, config) => { for (const { @@ -75,16 +75,3 @@ export const processIndexes: Processor = (builder, items, config) => { }); } }; - -const asIndexName = (table: string, columns?: string[], where?: string) => { - const items: string[] = []; - for (const columnName of columns ?? []) { - items.push(columnName); - } - - if (where) { - items.push(where); - } - - return asKey('IDX_', table, items); -}; diff --git a/server/src/sql-tools/from-code/processors/table.processor.ts b/server/src/sql-tools/from-code/processors/table.processor.ts index 4ef4e82020..e96f858266 100644 --- a/server/src/sql-tools/from-code/processors/table.processor.ts +++ b/server/src/sql-tools/from-code/processors/table.processor.ts @@ -1,6 +1,6 @@ import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator'; import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type'; -import { asMetadataKey, asSnakeCase } from 'src/sql-tools/helpers'; +import { addWarning, asMetadataKey, asSnakeCase } from 'src/sql-tools/helpers'; export const processTables: Processor = (builder, items) => { for (const { @@ -45,7 +45,7 @@ export const onMissingTable = ( propertyName?: symbol | string, ) => { const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : ''); - builder.warnings.push(`[${context}] Unable to find table (${label})`); + addWarning(builder, context, `Unable to find table (${label})`); }; const METADATA_KEY = asMetadataKey('table-metadata'); diff --git a/server/src/sql-tools/from-code/register-item.ts b/server/src/sql-tools/from-code/register-item.ts index 4889ae34b9..2f394cf9c1 100644 --- a/server/src/sql-tools/from-code/register-item.ts +++ b/server/src/sql-tools/from-code/register-item.ts @@ -4,6 +4,7 @@ import { ConfigurationParameterOptions } from 'src/sql-tools/from-code/decorator import { DatabaseOptions } from 'src/sql-tools/from-code/decorators/database.decorator'; import { ExtensionOptions } from 'src/sql-tools/from-code/decorators/extension.decorator'; import { ForeignKeyColumnOptions } from 'src/sql-tools/from-code/decorators/foreign-key-column.decorator'; +import { ForeignKeyConstraintOptions } from 'src/sql-tools/from-code/decorators/foreign-key-constraint.decorator'; import { IndexOptions } from 'src/sql-tools/from-code/decorators/index.decorator'; import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator'; import { TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator'; @@ -25,5 +26,6 @@ export type RegisterItem = | { type: 'trigger'; item: ClassBased<{ options: TriggerOptions }> } | { type: 'extension'; item: ClassBased<{ options: ExtensionOptions }> } | { type: 'configurationParameter'; item: ClassBased<{ options: ConfigurationParameterOptions }> } - | { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }> }; + | { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }> } + | { type: 'foreignKeyConstraint'; item: ClassBased<{ options: ForeignKeyConstraintOptions }> }; export type RegisterItemType = Extract['item']; diff --git a/server/src/sql-tools/helpers.ts b/server/src/sql-tools/helpers.ts index 2802407ea6..015bbe4d9c 100644 --- a/server/src/sql-tools/helpers.ts +++ b/server/src/sql-tools/helpers.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator'; +import { SchemaBuilder } from 'src/sql-tools/from-code/processors/type'; import { Comparer, DatabaseColumn, @@ -211,3 +212,22 @@ export const asColumnComment = (tableName: string, columnName: string, comment: }; export const asColumnList = (columns: string[]) => columns.map((column) => `"${column}"`).join(', '); + +export const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, [...columns]); + +export const asIndexName = (table: string, columns?: string[], where?: string) => { + const items: string[] = []; + for (const columnName of columns ?? []) { + items.push(columnName); + } + + if (where) { + items.push(where); + } + + return asKey('IDX_', table, items); +}; + +export const addWarning = (builder: SchemaBuilder, context: string, message: string) => { + builder.warnings.push(`[${context}] ${message}`); +}; diff --git a/server/src/sql-tools/public_api.ts b/server/src/sql-tools/public_api.ts index c7a3023a4d..61e4a3e431 100644 --- a/server/src/sql-tools/public_api.ts +++ b/server/src/sql-tools/public_api.ts @@ -12,6 +12,7 @@ export * from 'src/sql-tools/from-code/decorators/delete-date-column.decorator'; export * from 'src/sql-tools/from-code/decorators/extension.decorator'; export * from 'src/sql-tools/from-code/decorators/extensions.decorator'; export * from 'src/sql-tools/from-code/decorators/foreign-key-column.decorator'; +export * from 'src/sql-tools/from-code/decorators/foreign-key-constraint.decorator'; export * from 'src/sql-tools/from-code/decorators/generated-column.decorator'; export * from 'src/sql-tools/from-code/decorators/index.decorator'; export * from 'src/sql-tools/from-code/decorators/primary-column.decorator'; diff --git a/server/test/sql-tools/foreign-key-constraint-column-order.stub.ts b/server/test/sql-tools/foreign-key-constraint-column-order.stub.ts new file mode 100644 index 0000000000..b211620343 --- /dev/null +++ b/server/test/sql-tools/foreign-key-constraint-column-order.stub.ts @@ -0,0 +1,124 @@ +import { + Column, + DatabaseConstraintType, + DatabaseSchema, + ForeignKeyConstraint, + PrimaryColumn, + Table, +} from 'src/sql-tools'; + +@Table() +export class Table1 { + @PrimaryColumn({ type: 'uuid' }) + id1!: string; + + @PrimaryColumn({ type: 'uuid' }) + id2!: string; +} + +@Table() +@ForeignKeyConstraint({ + columns: ['parentId1', 'parentId2'], + referenceTable: () => Table1, + referenceColumns: ['id2', 'id1'], +}) +export class Table2 { + @Column({ type: 'uuid' }) + parentId1!: string; + + @Column({ type: 'uuid' }) + parentId2!: string; +} + +export const description = 'should create a foreign key constraint to the target table'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id1', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + { + name: 'id2', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_e457e8b1301b7bc06ef78188ee4', + tableName: 'table1', + columnNames: ['id1', 'id2'], + synchronize: true, + }, + ], + synchronize: true, + }, + { + name: 'table2', + columns: [ + { + name: 'parentId1', + tableName: 'table2', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + { + name: 'parentId2', + tableName: 'table2', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [ + { + name: 'IDX_aed36d04470eba20161aa8b1dc', + tableName: 'table2', + columnNames: ['parentId1', 'parentId2'], + unique: false, + synchronize: true, + }, + ], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.FOREIGN_KEY, + name: 'FK_aed36d04470eba20161aa8b1dc6', + tableName: 'table2', + columnNames: ['parentId1', 'parentId2'], + referenceColumnNames: ['id2', 'id1'], + referenceTableName: 'table1', + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/foreign-key-constraint-missing-column.stub.ts b/server/test/sql-tools/foreign-key-constraint-missing-column.stub.ts new file mode 100644 index 0000000000..5bb335030a --- /dev/null +++ b/server/test/sql-tools/foreign-key-constraint-missing-column.stub.ts @@ -0,0 +1,78 @@ +import { + Column, + DatabaseConstraintType, + DatabaseSchema, + ForeignKeyConstraint, + PrimaryColumn, + Table, +} from 'src/sql-tools'; + +@Table() +export class Table1 { + @PrimaryColumn({ type: 'uuid' }) + id!: string; +} + +@Table() +@ForeignKeyConstraint({ columns: ['parentId2'], referenceTable: () => Table1 }) +export class Table2 { + @Column({ type: 'uuid' }) + parentId!: string; +} + +export const description = 'should warn against missing column in foreign key constraint'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_b249cc64cf63b8a22557cdc8537', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + ], + synchronize: true, + }, + { + name: 'table2', + columns: [ + { + name: 'parentId', + tableName: 'table2', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [], + synchronize: true, + }, + ], + warnings: ['[@ForeignKeyConstraint.columns] Unable to find column (Table2.parentId2)'], +}; diff --git a/server/test/sql-tools/foreign-key-constraint-missing-reference-column.stub.ts b/server/test/sql-tools/foreign-key-constraint-missing-reference-column.stub.ts new file mode 100644 index 0000000000..83c3adeb6d --- /dev/null +++ b/server/test/sql-tools/foreign-key-constraint-missing-reference-column.stub.ts @@ -0,0 +1,78 @@ +import { + Column, + DatabaseConstraintType, + DatabaseSchema, + ForeignKeyConstraint, + PrimaryColumn, + Table, +} from 'src/sql-tools'; + +@Table() +export class Table1 { + @PrimaryColumn({ type: 'uuid' }) + id!: string; +} + +@Table() +@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1, referenceColumns: ['foo'] }) +export class Table2 { + @Column({ type: 'uuid' }) + parentId!: string; +} + +export const description = 'should warn against missing reference column in foreign key constraint'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_b249cc64cf63b8a22557cdc8537', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + ], + synchronize: true, + }, + { + name: 'table2', + columns: [ + { + name: 'parentId', + tableName: 'table2', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [], + synchronize: true, + }, + ], + warnings: ['[@ForeignKeyConstraint.referenceColumns] Unable to find column (Table1.foo)'], +}; diff --git a/server/test/sql-tools/foreign-key-constraint-missing-reference-table.stub.ts b/server/test/sql-tools/foreign-key-constraint-missing-reference-table.stub.ts new file mode 100644 index 0000000000..54cf731479 --- /dev/null +++ b/server/test/sql-tools/foreign-key-constraint-missing-reference-table.stub.ts @@ -0,0 +1,44 @@ +import { Column, DatabaseSchema, ForeignKeyConstraint, Table } from 'src/sql-tools'; + +class Foo {} + +@Table() +@ForeignKeyConstraint({ + columns: ['parentId'], + referenceTable: () => Foo, +}) +export class Table1 { + @Column() + parentId!: string; +} + +export const description = 'should warn against missing reference table'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'parentId', + tableName: 'table1', + type: 'character varying', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [], + synchronize: true, + }, + ], + warnings: ['[@ForeignKeyConstraint.referenceTable] Unable to find table (Foo)'], +}; diff --git a/server/test/sql-tools/foreign-key-constraint-multiple-columns.stub.ts b/server/test/sql-tools/foreign-key-constraint-multiple-columns.stub.ts new file mode 100644 index 0000000000..30f18eaf9d --- /dev/null +++ b/server/test/sql-tools/foreign-key-constraint-multiple-columns.stub.ts @@ -0,0 +1,120 @@ +import { + Column, + DatabaseConstraintType, + DatabaseSchema, + ForeignKeyConstraint, + PrimaryColumn, + Table, +} from 'src/sql-tools'; + +@Table() +export class Table1 { + @PrimaryColumn({ type: 'uuid' }) + id1!: string; + + @PrimaryColumn({ type: 'uuid' }) + id2!: string; +} + +@Table() +@ForeignKeyConstraint({ columns: ['parentId1', 'parentId2'], referenceTable: () => Table1 }) +export class Table2 { + @Column({ type: 'uuid' }) + parentId1!: string; + + @Column({ type: 'uuid' }) + parentId2!: string; +} + +export const description = 'should create a foreign key constraint to the target table'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id1', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + { + name: 'id2', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_e457e8b1301b7bc06ef78188ee4', + tableName: 'table1', + columnNames: ['id1', 'id2'], + synchronize: true, + }, + ], + synchronize: true, + }, + { + name: 'table2', + columns: [ + { + name: 'parentId1', + tableName: 'table2', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + { + name: 'parentId2', + tableName: 'table2', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [ + { + name: 'IDX_aed36d04470eba20161aa8b1dc', + tableName: 'table2', + columnNames: ['parentId1', 'parentId2'], + unique: false, + synchronize: true, + }, + ], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.FOREIGN_KEY, + name: 'FK_aed36d04470eba20161aa8b1dc6', + tableName: 'table2', + columnNames: ['parentId1', 'parentId2'], + referenceColumnNames: ['id1', 'id2'], + referenceTableName: 'table1', + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/foreign-key-constraint-no-index.stub.ts b/server/test/sql-tools/foreign-key-constraint-no-index.stub.ts new file mode 100644 index 0000000000..5ad0aa7a6b --- /dev/null +++ b/server/test/sql-tools/foreign-key-constraint-no-index.stub.ts @@ -0,0 +1,88 @@ +import { + Column, + DatabaseConstraintType, + DatabaseSchema, + ForeignKeyConstraint, + PrimaryColumn, + Table, +} from 'src/sql-tools'; + +@Table() +export class Table1 { + @PrimaryColumn({ type: 'uuid' }) + id!: string; +} + +@Table() +@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1, index: false }) +export class Table2 { + @Column({ type: 'uuid' }) + parentId!: string; +} + +export const description = 'should create a foreign key constraint to the target table without an index'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_b249cc64cf63b8a22557cdc8537', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + ], + synchronize: true, + }, + { + name: 'table2', + columns: [ + { + name: 'parentId', + tableName: 'table2', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.FOREIGN_KEY, + name: 'FK_3fcca5cc563abf256fc346e3ff4', + tableName: 'table2', + columnNames: ['parentId'], + referenceColumnNames: ['id'], + referenceTableName: 'table1', + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/foreign-key-constraint-no-primary.stub.ts b/server/test/sql-tools/foreign-key-constraint-no-primary.stub.ts new file mode 100644 index 0000000000..645b0e76f2 --- /dev/null +++ b/server/test/sql-tools/foreign-key-constraint-no-primary.stub.ts @@ -0,0 +1,85 @@ +import { Column, DatabaseConstraintType, DatabaseSchema, ForeignKeyConstraint, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @Column() + foo!: string; +} + +@Table() +@ForeignKeyConstraint({ + columns: ['bar'], + referenceTable: () => Table1, + referenceColumns: ['foo'], +}) +export class Table2 { + @Column() + bar!: string; +} + +export const description = 'should create a foreign key constraint to the target table without a primary key'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'foo', + tableName: 'table1', + type: 'character varying', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [], + synchronize: true, + }, + { + name: 'table2', + columns: [ + { + name: 'bar', + tableName: 'table2', + type: 'character varying', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [ + { + name: 'IDX_7d9c784c98d12365d198d52e4e', + tableName: 'table2', + columnNames: ['bar'], + unique: false, + synchronize: true, + }, + ], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.FOREIGN_KEY, + name: 'FK_7d9c784c98d12365d198d52e4e6', + tableName: 'table2', + columnNames: ['bar'], + referenceTableName: 'table1', + referenceColumnNames: ['foo'], + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/foreign-key-constraint.stub.ts b/server/test/sql-tools/foreign-key-constraint.stub.ts new file mode 100644 index 0000000000..c8117bd96d --- /dev/null +++ b/server/test/sql-tools/foreign-key-constraint.stub.ts @@ -0,0 +1,96 @@ +import { + Column, + DatabaseConstraintType, + DatabaseSchema, + ForeignKeyConstraint, + PrimaryColumn, + Table, +} from 'src/sql-tools'; + +@Table() +export class Table1 { + @PrimaryColumn({ type: 'uuid' }) + id!: string; +} + +@Table() +@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1 }) +export class Table2 { + @Column({ type: 'uuid' }) + parentId!: string; +} + +export const description = 'should create a foreign key constraint to the target table'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'id', + tableName: 'table1', + type: 'uuid', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_b249cc64cf63b8a22557cdc8537', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + ], + synchronize: true, + }, + { + name: 'table2', + columns: [ + { + name: 'parentId', + tableName: 'table2', + type: 'uuid', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [ + { + name: 'IDX_3fcca5cc563abf256fc346e3ff', + tableName: 'table2', + columnNames: ['parentId'], + unique: false, + synchronize: true, + }, + ], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.FOREIGN_KEY, + name: 'FK_3fcca5cc563abf256fc346e3ff4', + tableName: 'table2', + columnNames: ['parentId'], + referenceColumnNames: ['id'], + referenceTableName: 'table1', + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; From 07aa51638c2b32690683a9c087baa3209565563c Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 19 Jun 2025 13:28:53 +0200 Subject: [PATCH 19/71] fix: panning interrupted while moving around the map (#19276) --- web/src/lib/components/shared-components/map/map.svelte | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index af9604b680..a7bb5cbdf9 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -22,7 +22,7 @@ import { isEqual, omit } from 'lodash-es'; import { DateTime, Duration } from 'luxon'; import maplibregl, { GlobeControl, type GeoJSONSource, type LngLatLike } from 'maplibre-gl'; - import { onDestroy, onMount } from 'svelte'; + import { onDestroy, onMount, untrack } from 'svelte'; import { t } from 'svelte-i18n'; import { AttributionControl, @@ -251,7 +251,11 @@ }); $effect(() => { - map?.jumpTo({ center, zoom }); + if (!center || !zoom) { + return; + } + + untrack(() => map?.jumpTo({ center, zoom })); }); From 14b771d7c76682b637a616e4d7333a88d20435d9 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 19 Jun 2025 09:29:22 -0400 Subject: [PATCH 20/71] fix: devcontainer in codespaces (#19259) * fix: devcontainer perms * Fix host based auth * use path tricks to get to volume mount, but remain compat with current meaning of variables * eureka, i think * bit of cleanup --- .devcontainer/devcontainer.json | 2 +- .devcontainer/server/container-common.sh | 9 +++++++-- .../server/container-compose-overrides.yml | 13 ++++++++----- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c8a491b4d3..79126fd658 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -55,7 +55,7 @@ "userEnvProbe": "loginInteractiveShell", "remoteEnv": { // The location where your uploaded files are stored - "UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:upload-devcontainer-volume}", + "UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:./library}", // 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": "${localEnv:DB_PASSWORD:postgres}", diff --git a/.devcontainer/server/container-common.sh b/.devcontainer/server/container-common.sh index 8fc0038d49..34dcb6beac 100755 --- a/.devcontainer/server/container-common.sh +++ b/.devcontainer/server/container-common.sh @@ -51,14 +51,19 @@ fix_permissions() { run_cmd sudo find "${IMMICH_WORKSPACE}/server/upload" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres/*" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres" -exec chown node {} + - run_cmd sudo chown node -R "${IMMICH_WORKSPACE}/.vscode" \ + # Change ownership for directories that exist + for dir in "${IMMICH_WORKSPACE}/.vscode" \ "${IMMICH_WORKSPACE}/cli/node_modules" \ "${IMMICH_WORKSPACE}/e2e/node_modules" \ "${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \ "${IMMICH_WORKSPACE}/server/node_modules" \ "${IMMICH_WORKSPACE}/server/dist" \ "${IMMICH_WORKSPACE}/web/node_modules" \ - "${IMMICH_WORKSPACE}/web/dist" + "${IMMICH_WORKSPACE}/web/dist"; do + if [ -d "$dir" ]; then + run_cmd sudo chown node -R "$dir" + fi + done log "" } diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml index eb36a062a5..eb8e66a3d3 100644 --- a/.devcontainer/server/container-compose-overrides.yml +++ b/.devcontainer/server/container-compose-overrides.yml @@ -12,8 +12,8 @@ services: - open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules - server_node_modules:/workspaces/immich/server/node_modules - web_node_modules:/workspaces/immich/web/node_modules - - ${UPLOAD_LOCATION-./Library}/photos:/workspaces/immich/server/upload - - ${UPLOAD_LOCATION-./Library}/photos/upload:/workspaces/immich/server/upload/upload + - ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/workspaces/immich/server/upload + - ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/workspaces/immich/server/upload/upload - /etc/localtime:/etc/localtime:ro immich-web: @@ -29,8 +29,9 @@ services: POSTGRES_USER: ${DB_USERNAME-postgres} POSTGRES_DB: ${DB_DATABASE_NAME-immich} POSTGRES_INITDB_ARGS: '--data-checksums' - volumes: - - ${UPLOAD_LOCATION-./Library}/postgres:/var/lib/postgresql/data + POSTGRES_HOST_AUTH_METHOD: md5 + volumes: + - ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data redis: env_file: !reset [] @@ -42,4 +43,6 @@ volumes: open_api_node_modules: server_node_modules: web_node_modules: - upload-devcontainer-volume: + upload1-devcontainer-volume: + upload2-devcontainer-volume: + postgres-devcontainer-volume: From e29103b69fd909c7b77d4fd313f6fc2d2d88c15e Mon Sep 17 00:00:00 2001 From: Paul Larsen Date: Thu, 19 Jun 2025 16:03:14 +0200 Subject: [PATCH 21/71] fix album list CSS margins (#19262) --- web/src/lib/components/album-page/albums-table.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/album-page/albums-table.svelte b/web/src/lib/components/album-page/albums-table.svelte index ed509251df..df263b4011 100644 --- a/web/src/lib/components/album-page/albums-table.svelte +++ b/web/src/lib/components/album-page/albums-table.svelte @@ -45,7 +45,7 @@ {@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)} {@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'} Date: Thu, 19 Jun 2025 17:03:40 +0300 Subject: [PATCH 22/71] fix(server): drop vector indices before updating extension (#19283) drop indices before updating --- server/src/repositories/database.repository.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 94d9029f60..d5f94fe13d 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -142,21 +142,29 @@ export class DatabaseRepository { } targetVersion ??= availableVersion; - const isVectors = extension === DatabaseExtension.VECTORS; let restartRequired = false; const diff = semver.diff(installedVersion, targetVersion); + if (!diff) { + return { restartRequired: false }; + } + + await Promise.all([ + this.db.schema.dropIndex(VectorIndex.CLIP).ifExists().execute(), + this.db.schema.dropIndex(VectorIndex.FACE).ifExists().execute(), + ]); + await this.db.transaction().execute(async (tx) => { await this.setSearchPath(tx); await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(tx); - if (isVectors && (diff === 'major' || diff === 'minor')) { + if (extension === DatabaseExtension.VECTORS && (diff === 'major' || diff === 'minor')) { await sql`SELECT pgvectors_upgrade()`.execute(tx); restartRequired = true; } }); - if (diff && !restartRequired) { + if (!restartRequired) { await Promise.all([this.reindexVectors(VectorIndex.CLIP), this.reindexVectors(VectorIndex.FACE)]); } From 5122512f192bff9b9c648cee3800a61dc967e3d4 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Thu, 19 Jun 2025 10:04:18 -0400 Subject: [PATCH 23/71] fix(docs): REINDEX vchord on upgrade (#19282) * reindex * lint * collapse migrations * remove title * reformat --- .../administration/postgres-standalone.md | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/docs/administration/postgres-standalone.md b/docs/docs/administration/postgres-standalone.md index d9ad331810..fd9b8a5e4d 100644 --- a/docs/docs/administration/postgres-standalone.md +++ b/docs/docs/administration/postgres-standalone.md @@ -64,7 +64,13 @@ COMMIT; ### Updating VectorChord -When installing a new version of VectorChord, you will need to manually update the extension by connecting to the Immich database and running `ALTER EXTENSION vchord UPDATE;`. +When installing a new version of VectorChord, you will need to manually update the extension and reindex by connecting to the Immich database and running: + +``` +ALTER EXTENSION vchord UPDATE; +REINDEX INDEX face_index; +REINDEX INDEX clip_index; +``` ## Migrating to VectorChord @@ -76,6 +82,8 @@ Support for pgvecto.rs will be dropped in a later release, hence we recommend al The easiest option is to have both extensions installed during the migration: +
+Migration steps (automatic) 1. Ensure you still have pgvecto.rs installed 2. Install `pgvector` (`>= 0.7.0, < 1.0.0`). The easiest way to do this is on Debian/Ubuntu by adding the [PostgreSQL Apt repository][pg-apt] and then running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`) 3. [Install VectorChord][vchord-install] @@ -89,8 +97,12 @@ The easiest option is to have both extensions installed during the migration: 11. Restart the Postgres database 12. Uninstall pgvecto.rs (e.g. `apt-get purge vectors-pg14` on Debian-based environments, replacing `pg14` as appropriate). `pgvector` must remain installed as it provides the data types used by `vchord` +
+ If it is not possible to have both VectorChord and pgvecto.rs installed at the same time, you can perform the migration with more manual steps: +
+Migration steps (manual) 1. While pgvecto.rs is still installed, run the following SQL command using psql or your choice of database client. Take note of the number outputted by this command as you will need it later ```sql @@ -123,14 +135,20 @@ ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512); 5. Start Immich and let it create new indices using VectorChord +
+ ### Migrating from pgvector +
+Migration steps 1. Ensure you have at least 0.7.0 of pgvector installed. If it is below that, please upgrade it and run the SQL command `ALTER EXTENSION vector UPDATE;` using psql or your choice of database client 2. Follow the Prerequisites to install VectorChord 3. If Immich does not have superuser permissions, run the SQL command `CREATE EXTENSION vchord CASCADE;` 4. Remove the `DB_VECTOR_EXTENSION=pgvector` environmental variable as it will make Immich still use pgvector if set 5. Start Immich and let it create new indices using VectorChord +
+ Note that VectorChord itself uses pgvector types, so you should not uninstall pgvector after following these steps. [vchord-install]: https://docs.vectorchord.ai/vectorchord/getting-started/installation.html From f99c6feac54f62d4d7a042bd575f25084947328c Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 19 Jun 2025 17:04:52 +0300 Subject: [PATCH 24/71] fix(server): unset prewarm dim parameter (#19271) unset prewarm dim --- server/src/repositories/database.repository.ts | 2 -- .../1750323941566-UnsetPrewarmDimParameter.ts | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 server/src/schema/migrations/1750323941566-UnsetPrewarmDimParameter.ts diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index d5f94fe13d..8d9141f182 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -119,8 +119,6 @@ export class DatabaseRepository { await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(extension)} CASCADE`.execute(this.db); if (extension === DatabaseExtension.VECTORCHORD) { const dbName = sql.id(await this.getDatabaseName()); - await sql`ALTER DATABASE ${dbName} SET vchordrq.prewarm_dim = '512,640,768,1024,1152,1536'`.execute(this.db); - await sql`SET vchordrq.prewarm_dim = '512,640,768,1024,1152,1536'`.execute(this.db); await sql`ALTER DATABASE ${dbName} SET vchordrq.probes = 1`.execute(this.db); await sql`SET vchordrq.probes = 1`.execute(this.db); } diff --git a/server/src/schema/migrations/1750323941566-UnsetPrewarmDimParameter.ts b/server/src/schema/migrations/1750323941566-UnsetPrewarmDimParameter.ts new file mode 100644 index 0000000000..ffad855964 --- /dev/null +++ b/server/src/schema/migrations/1750323941566-UnsetPrewarmDimParameter.ts @@ -0,0 +1,15 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + const { rows } = await sql<{ db: string }>`SELECT current_database() as db;`.execute(db); + const databaseName = rows[0].db; + await sql.raw(`ALTER DATABASE "${databaseName}" RESET vchordrq.prewarm_dim;`).execute(db); +} + +export async function down(db: Kysely): Promise { + const { rows } = await sql<{ db: string }>`SELECT current_database() as db;`.execute(db); + const databaseName = rows[0].db; + await sql + .raw(`ALTER DATABASE "${databaseName}" SET vchordrq.prewarm_dim = '512,640,768,1024,1152,1536';`) + .execute(db); +} From caf11fbb961a19af6123767195a6844de5fc4390 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:09:23 +0200 Subject: [PATCH 25/71] fix: album asset viewer (#19252) --- .../components/album-page/album-map.svelte | 54 +------------------ .../components/photos-page/asset-grid.svelte | 43 +++++++-------- .../managers/album-view-map.manager.svelte.ts | 13 ----- 3 files changed, 21 insertions(+), 89 deletions(-) delete mode 100644 web/src/lib/managers/album-view-map.manager.svelte.ts diff --git a/web/src/lib/components/album-page/album-map.svelte b/web/src/lib/components/album-page/album-map.svelte index 83a66db3af..fbb831a38b 100644 --- a/web/src/lib/components/album-page/album-map.svelte +++ b/web/src/lib/components/album-page/album-map.svelte @@ -1,10 +1,7 @@ - - - {#if $showAssetViewer} - {#await import('../../../lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} - 1} - onNext={navigateNext} - onPrevious={navigatePrevious} - onRandom={navigateRandom} - onClose={() => { - assetViewingStore.showAssetViewer(false); - handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); - }} - isShared={false} - /> - {/await} - {/if} - diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index ce2b1ca096..ac6ec8906a 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -12,7 +12,6 @@ import ChangeDate from '$lib/components/shared-components/change-date.svelte'; import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte'; import { AppRoute, AssetAction } from '$lib/constants'; - import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { modalManager } from '$lib/managers/modal-manager.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; @@ -911,28 +910,26 @@
-{#if !albumMapViewManager.isInMapView} - - {#if $showAssetViewer} - {#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} - - {/await} - {/if} - -{/if} + + {#if $showAssetViewer} + {#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} + + {/await} + {/if} +